Skip to content

Rust SDK

SecretSpec provides a Rust library with type-safe access to secrets through a derive macro. The macro reads secretspec.toml at compile time and generates Rust types for its profiles and secrets.

Add the runtime, derive macro, and the generated code’s direct dependencies to your Cargo.toml:

[dependencies]
secretspec = "0.18"
secretspec-derive = "0.18"
secrecy = { version = "0.10", features = ["serde"] }
serde = { version = "1", features = ["derive"] }

The examples on this page are compiled as Cargo examples in secretspec-derive. They generate their types from this manifest:

[project]
name = "rust-sdk-example"
revision = "1.0"

[profiles.default]
DATABASE_URL = { description = "PostgreSQL connection string", required = true }
REDIS_URL = { description = "Redis connection string", required = false }
TLS_CERT = { description = "TLS certificate", required = true, as_path = true }
TLS_KEY = { description = "TLS private key", required = false, as_path = true }

[profiles.development]
DATABASE_URL = { default = "postgresql://localhost/development" }

[profiles.production]
DATABASE_URL = { required = true }
API_KEY = { description = "Production API key", required = true }

[scopes.api]
secrets = ["DATABASE_URL"]

declare_secrets! generates SecretSpec, Profile, and SecretSpecProfile. The standard loader returns the union type that is safe to use with any declared profile:

secretspec_derive::declare_secrets!("secretspec.toml");

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let resolved = SecretSpec::builder()
        .with_provider("keyring://")
        .with_profile("development")
        .with_reason("start application")
        .load()?;

    println!("Database: {}", resolved.secrets.database_url);

    if let Some(redis_url) = &resolved.secrets.redis_url {
        println!("Redis: {redis_url}");
    }

    resolved.secrets.set_as_env_vars();

    println!("Profile: {}", resolved.profile);
    println!("Provider: {}", resolved.provider);

    Ok(())
}

Required and defaulted secrets are generated as String; secrets that may be absent are Option<String>. Field names use Rust snake case, so DATABASE_URL becomes database_url.

Use load_profile() when code should receive the exact shape of the selected profile. It returns a SecretSpecProfile enum whose variants contain that profile’s effective fields, including fields inherited from [profiles.default]:

secretspec_derive::declare_secrets!("secretspec.toml");

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let resolved = SecretSpec::builder()
        .with_provider("keyring://")
        .with_profile(Profile::Production)
        .with_reason("start production application")
        .load_profile()?;

    match resolved.secrets {
        SecretSpecProfile::Production {
            database_url,
            api_key,
            ..
        } => {
            println!("Database: {database_url}");
            println!("API key loaded: {} bytes", api_key.len());
        }
        _ => unreachable!("the production profile was selected"),
    }

    Ok(())
}

A scope resolves only a named subset of a profile. Scopes are available through the untyped Secrets API:

use secretspec::Secrets;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut spec = Secrets::load()?;
    spec.set_scope("api");

    let resolved = spec.resolve()?;
    assert_eq!(resolved.scope.as_deref(), Some("api"));

    Ok(())
}

resolve() and report() both return the active scope. The untyped API also honors SECRETSPEC_SCOPE when no scope is selected explicitly.

Typed loaders generated by declare_secrets! deliberately do not support scopes. A generated struct has a field for every declared secret, so hiding one would leave that field unfillable. SecretSpec::builder() therefore has no with_scope, and typed load() and load_profile() always resolve the full profile. Use a separate manifest or the untyped API when a component needs a narrowed set.

Secrets declared with as_path = true are generated as PathBuf instead of String. Optional file-shaped secrets use Option<PathBuf>:

secretspec_derive::declare_secrets!("secretspec.toml");

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let resolved = SecretSpec::builder()
        .with_provider("keyring://")
        .with_reason("configure TLS")
        .load()?;

    let certificate: &std::path::PathBuf = &resolved.secrets.tls_cert;
    println!("Certificate: {}", certificate.display());

    if let Some(private_key) = &resolved.secrets.tls_key {
        println!("Private key: {}", private_key.display());
    }

    // The materialized files remain valid until `resolved` is dropped.
    Ok(())
}