# Rust SDK

> Type-safe Rust integration for SecretSpec

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.

## Quick start

**Changed in version 0.20**

On 0.19 and earlier, also depend on `secrecy = { version = "0.10", features = ["serde"] }` and `serde = { version = "1", features = ["derive"] }`.

Add the runtime and derive macro from the command line:

```shell
cargo add secretspec secretspec-derive
```

Alternatively, add both dependencies to your `Cargo.toml`:

```toml
[dependencies]
secretspec = "0.20"
secretspec-derive = "0.20"
```

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`.

## Describing secrets in Rust

**New in version 0.20**

Starting with SecretSpec 0.20, `Spec` is the format-independent declaration API. Build one directly in Rust when an application owns its secret contract in code, then pass the validated specification to `Secrets::from_spec`:

```
use secretspec::{Profile, Secret, Secrets, Spec};

fn main() -> secretspec::Result<()> {
    let spec = Spec::builder("checkout")
        .provider("env", "env://")
        .secret(
            "DATABASE_URL",
            Secret::required("PostgreSQL connection URL").providers(["env"]),
        )
        .secret(
            "SENTRY_DSN",
            Secret::optional("Sentry error-reporting endpoint"),
        )
        .profile(
            "production",
            Profile::new().secret("SENTRY_DSN", Secret::required("Production Sentry endpoint")),
        )
        .scope("web", ["DATABASE_URL", "SENTRY_DSN"])
        .build()?;

    let mut secrets = Secrets::from_spec(spec)?;
    secrets.set_profile("production");
    secrets.set_scope("web");

    let resolved = secrets.resolve()?;
    println!("resolved profile: {}", resolved.profile);
    Ok(())
}
```

`Spec::from_toml` and `Spec::try_from(path)` produce the same type through the same validation and compilation path. Convert from a path for manifests with `extends`, because a TOML string has no directory from which to resolve relative paths. A spec loaded from a file retains that file’s directory for relative provider paths. A Rust-built declaration resolves them from the current working directory by default; `Secrets::from_spec_at` selects another logical base directory.

Use `schema_json(None)` to emit the value-free JSON Schema for the union shape, or pass a profile name for that profile’s effective fields:

```rust
let union_schema = spec.schema_json(None)?;
let production_schema = spec.schema_json(Some("production"))?;
```

Schema generation reads declarations only. It does not resolve secret values or contact providers.

`Spec` is immutable so its declarations and compiled view cannot drift apart. Use `to_builder()` to edit a copy, or `into_builder()` to consume the original, then rebuild to validate the result:

```rust
let edited = spec
    .to_builder()
    .remove_secret("default", "LEGACY_TOKEN")
    .add_secret(
        "production",
        "DEPLOY_TOKEN",
        Secret::required("Production deployment token"),
    )
    .build()?;
```

`remove_secret` removes the declaration from that profile. Removing an override can reveal the declaration inherited from `default`; removing it from `default` also removes it from profiles that only inherited it. `build()` rejects dangling scope membership, invalid compositions, empty profiles, and other semantic errors introduced by an edit.

For a spec loaded from TOML, `secret`, `add_secret`, `replace_secret`, and `remove_secret` preserve comments, ordering, quoting, and unrelated syntax in the root document. Read the edited document with `edited.preserved_text()`. Adding and then removing the same declaration restores the original bytes, and inherited declarations are never inlined into a child manifest. Other builder operations are semantic edits and clear the retained text; use `to_toml()` when freshly formatted output is acceptable. A spec constructed with `Spec::builder()` has no original document to preserve.

The Rust-first API complements `declare_secrets!`: `Spec` describes and resolves names dynamically, while the macro continues to generate statically typed fields from a manifest at compile time.

## Listing providers

**New in version 0.22**

Use `providers()` to list built-in provider names, descriptions, and example URIs without loading a manifest or connecting to a provider:

```rust
for provider in secretspec::providers() {
    println!("{}: {}", provider.name, provider.description);
    for example in provider.examples {
        println!("  {example}");
    }
}
```

The list also includes providers disabled by Cargo features. Attempting to use one of those providers reports the feature required to enable it. External providers discovered at runtime are not in this list.

## Setting byte values

**New in version 0.21**

`SecretBytes` is the zeroizing byte container used by provider, write, and validation APIs. It has redacted `Debug` output and no generic Serde implementation:

```rust
use secretspec::{SecretBytes, Secrets};


let secrets = Secrets::load()?;
secrets.set("TLS_KEYSTORE", SecretBytes::from_vec(binary_keystore))?;
secrets.set_text("DATABASE_URL", "postgres://localhost/app")?;
```

`Secrets::set` accepts arbitrary non-empty bytes. `set_text` is the UTF-8 convenience method, and `prompt_and_set` opens the existing text prompt. Manifest `encoding` transforms logical bytes before provider storage, which allows binary values to use a text-only provider.

In 0.21+, `validate()` retains inline `SecretBytes` in `resolved.secrets`. `resolve_bytes()` and `resolve_named_bytes(name)` return the usual resolution metadata with `SecretBytes` inline values:

```rust
// SecretSpec 0.21+
let resolved = secrets.resolve_bytes()?;
let bytes = resolved.secrets["TLS_KEYSTORE"].value.as_ref().unwrap();
consume_keystore(bytes.expose_secret());
```

Use `as_path = true` when an application expects a filename. The existing `resolve()` and `resolve_named()` methods, generated `String` fields, JSON responses, and text exports validate UTF-8 at their text boundaries and fail with `SecretSpecError::SecretNotText`, which names the secret without exposing its bytes (0.21+). Composition and extraction also validate UTF-8, returning `CompositionFailed` and `DecodeFailed`, respectively. For a text value, use `SecretBytes::try_as_utf8()` or `try_as_utf8_for(name)`; for a process environment, `try_as_env_value()` or `try_as_env_value_for(name)` (0.21+) preserves non-UTF-8 bytes on Unix and rejects NUL bytes on every platform.

## Profile-specific types

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

## Scopes

**New in version 0.17**

A [scope](/concepts/scopes/) 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.

## Resolving one secret

**New in version 0.19**

`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](/concepts/scopes/) 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. When a wrapper only needs to identify the software integration, use the separate caller context below; unlike a default reason, it cannot satisfy `require_reason`.

## Caller context

**New in version 0.20**

Software integrations can record what invoked SecretSpec without replacing the user-supplied access reason:

```rust
use secretspec::{CallerContext, Secrets};


let spec = Secrets::load()?.with_caller(
    CallerContext::new("git")
        .with_version("2.51.0")
        .with_operation("credential_get")
        .with_resource("github.com"),
);
```

Generated builders expose the same `with_caller()` method. Caller context is caller-asserted audit metadata, not an authenticated identity, and never satisfies `require_reason`. Do not put credentials or secret values in it.

## Interactive prompting

**New in version 0.20**

`Secrets::ensure_secrets` prompts for and stores any missing required secret when stdin is a real terminal. Generated builders opt into the same behavior with `prompt_missing()`:

```
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")
        .prompt_missing(true)
        .load()?;

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

    Ok(())
}
```

Left unset (the default), a missing required secret still fails fast with `RequiredSecretMissing`, exactly as without `prompt_missing()`.

## Secrets as file paths

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

## Provider retry policy

**New in version 0.22**

Override the user configuration for a resolver with a validated `RetryPolicy`:

```rust
use secretspec::{RetryPolicy, Secrets};


let secrets = Secrets::load()?.with_retry_policy(RetryPolicy::new(3)?);
// Use RetryPolicy::new(1)? to disable SecretSpec-controlled retries.
```

The built-in default is three total attempts with exponential backoff. `RetryPolicy::new` accepts 1 to 10 total attempts. URI-based provider factories install shared retry handling automatically. Custom providers can opt in using `secretspec::provider::RetryingProvider::new(provider, policy)`; direct concrete constructors retain their provider-specific behavior.

Providers supply structured transient errors and use the `Provider` retry hooks to declare native retry ownership and mutation replay safety. The default rejects mutation replay. Set the policy before first provider I/O and before sharing an `Arc` provider. See [transient failures](/concepts/providers/#transient-failures) for batching, authentication, and native SDK behavior.