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.
Quick start
Section titled “Quick start”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.
Profile-specific types
Section titled “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 (0.17+)
Section titled “Scopes (0.17+)”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.
Resolving one secret (0.19+)
Section titled “Resolving one secret (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 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 as file paths
Section titled “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(())
}