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.

resolve() answers whether the whole profile can be satisfied, so a single missing required secret fails it and returns nothing. When a component needs one secret, resolve_named() reads only that secret and the inputs it composes from, and reports the outcomes separately:

use secretspec::{NamedResolution, Secrets};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Resolving one secret reads only that secret and its composition inputs,
    // so an unrelated missing required secret cannot fail the call.
    let spec = Secrets::load()?.with_default_reason("cache warmup");

    match spec.resolve_named("REDIS_URL")? {
        NamedResolution::Resolved(secret) => {
            // Exactly one of `value` and `path` is set; `path` for `as_path`.
            println!("resolved from {:?}", secret.source);
        }
        // Declared, but nothing provided it. `required` says whether a
        // whole-profile resolve would treat that as an error.
        NamedResolution::Missing { required } => {
            println!("no value (required: {required})");
        }
        // Not declared in this profile, or hidden by the active scope.
        NamedResolution::Undeclared => println!("not on this profile's surface"),
    }

    Ok(())
}

NamedResolution::Undeclared covers both a name the profile does not declare and one the active scope hides, since neither is on the surface this session resolves. Provider and configuration failures stay Err rather than turning into a missing value, and whole-profile presence constraints (at_least_one, exactly_one) are not evaluated for a single-secret read.

with_default_reason() (also 0.19+) supplies a reason only when the caller has not already set one through with_reason() or SECRETSPEC_REASON, so a wrapper can describe itself without overwriting the more specific reason it was given.

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(())
}