TL;DR: go-tool-base solved where a CLI keeps an API key: env var, OS keychain, or literal config. rust-tool-base keeps all three modes and adds something Go structurally can’t. Secrets cross every boundary as a SecretString. Its Debug output is [REDACTED], so it can’t leak into a log line by accident. Its memory is overwritten when it drops, so it doesn’t linger in freed heap. And it isn’t serialisable, so it can’t be written back to a config file by a blanket round-trip.
What go-tool-base already settled
A while back I wrote about where a CLI should keep your API keys. The answer go-tool-base settled on was three storage modes, in a fixed precedence: an environment variable reference (the recommended default), the OS keychain (opt-in), or a literal value in the config file (legacy, and refused outright when CI=true).
rust-tool-base keeps that design unchanged. Same three modes, same precedence, same refusal of literal secrets in CI. A tool embeds a CredentialRef in its typed config, and a Resolver walks env, then keychain, then literal, then a well-known fallback variable, first hit wins. That part is a straight carry-over, because where to keep the secret was design, and design survives the port.
But storage is only half the life of a secret. The other half is what happens to it once it’s resolved and sitting in your process memory. That’s where Rust can do something Go cannot, and rust-tool-base takes the opening.
The two ways a secret leaks after you’ve loaded it
You’ve resolved the API key. It’s a value in memory now. Two very ordinary things can leak it from there, and neither involves your storage being wrong.
The first is the log line. Somewhere a developer writes a debug print of a config struct, or an error includes the struct that holds the key, or a panic dumps it. The secret is a string like any other string, so it renders like any other string, straight into a log aggregator that a lot of people can read.
The second is the leftover bytes. The key sat in a heap allocation. The variable goes out of scope, the allocation is freed, and on most runtimes “freed” just means “returned to the allocator.” The bytes are still there until something else writes over them. A core dump taken in that window contains your key. So does the next allocation that happens to land on that memory and gets logged before it’s overwritten.
A Go string can’t really defend against either. Go strings are immutable, so you cannot zero one in place; the runtime copies them freely, so you can’t even track every copy; and there’s no compile-time barrier stopping anyone printing one. You can be disciplined, but discipline is all you’ve got.
SecretString closes both
rust-tool-base routes every secret through secrecy::SecretString, and the crate is explicit that taking a plain &str or String for a secret is a type error, not a style preference.
For the log line, SecretString has its own Debug implementation, and it prints [REDACTED]. Always. A config struct holding a SecretString can be debug-printed, put in an error, caught in a panic, and the secret field shows up as [REDACTED] every time. You don’t have to remember not to log it. The type already won’t.
For the leftover bytes, SecretString zeroes its memory when it’s dropped. When the value goes out of scope, before the allocation is handed back, the bytes are overwritten. The window where a freed allocation still holds your key is closed. A core dump taken afterwards finds zeroes.
There’s a third leak SecretString blocks that’s easy to miss. It deliberately does not implement Serialize. You cannot serialise a SecretString. That sounds like an inconvenience until you see what it prevents: a tool that loads config, changes one setting, and writes the whole struct back would, with an ordinary string, faithfully write the resolved secret to disk in plain text. Because SecretString can’t be serialised, CredentialRef can’t be either, and that accident is structurally impossible. Writing a secret back is a deliberate, separate path, never a side effect of saving config.
When code genuinely needs the raw value, to put it in an Authorization header, it calls expose_secret(). The name is the point. Getting at the plaintext is one explicit, greppable, reviewable call, and everywhere else the secret stays wrapped.
Discipline versus the type system
The honest framing is this. None of these leaks are exotic. Logging a struct, a core dump after a free, re-saving a config file: they’re all routine, and they’re all how real credentials end up somewhere they shouldn’t.
go-tool-base’s storage design is good, and rust-tool-base kept it. But in Go, not leaking the secret once it’s in memory comes down to every developer being careful every time. In Rust, SecretString makes the type system carry it. The redaction, the zeroing, the un-serialisability aren’t things you remember to do. They’re things the secret does to itself because of what it is. That’s the part Go structurally can’t match, and it’s why the port didn’t just copy the storage modes across, it tightened the handling underneath them.
The gist
go-tool-base settled where a CLI keeps a secret: env var, keychain, or literal, in a fixed precedence. rust-tool-base keeps that design and hardens what happens once the secret is loaded.
Every secret is a secrecy::SecretString. It debug-prints as [REDACTED], so it can’t fall into a log by accident. Its memory is zeroed on drop, so it doesn’t survive in freed heap. It isn’t serialisable, so it can’t be written back to config by a blanket save. Getting the plaintext is one explicit expose_secret() call. Go can only ask developers to be careful with a secret in memory; Rust lets the type be careful for them.