rtb-credentials 0.6.4

OS keychain credential storage. Part of the phpboyscout Rust toolkit.
Documentation
---
title: Resolver — resolve and probe
description: Resolver constructors, the exact resolution chain, which errors abort the walk, and how probe differs from resolve.
tags:
  - reference
  - resolver
---

# `Resolver` — `resolve` and `probe`

`Resolver` walks a [`CredentialRef`](credential-ref.md) through a fixed
chain of sources and returns the first hit.

## Constructing a `Resolver`

| Constructor | Backend |
|---|---|
| `Resolver::new(store: Arc<dyn CredentialStore>)` | the store supplied |
| `Resolver::with_platform_default()` | `KeyringStore::new()` |
| `Resolver::default()` | identical to `with_platform_default()` |

All three are infallible and do no I/O. The injected store is used for
the keychain leg only — the environment and literal legs do not consult
it.

```rust
use rtb_credentials::{MemoryStore, Resolver};
use std::sync::Arc;

let resolver = Resolver::new(Arc::new(MemoryStore::new()));
```

## The resolution chain

`resolve` returns the first source that produces a value:

1. **`cref.env`** — `std::env::var(name)`. A set variable is a hit, even
   if its value is the empty string.
2. **`cref.keychain`** — `store.get(service, account)` on the injected
   store.
3. **`cref.literal`** — the embedded secret, unless `CI=true`.
4. **`cref.fallback_env`** — `std::env::var(name)`.

A key that is absent is skipped. If every configured leg misses,
`resolve` returns `CredentialError::NotFound`.

The order is fixed in code and there is no way to reorder, disable or
extend it. A tool that needs different precedence has to implement its
own walk over the four fields.

## A keychain backend error aborts the walk

The keychain leg distinguishes two kinds of failure:

| Store returns | Resolver does |
|---|---|
| `CredentialError::NotFound` | falls through to the next leg |
| **any other error** | **returns that error immediately** |

A locked, unavailable or misconfigured keychain therefore fails the
whole resolution even when `literal` or `fallback_env` is configured and
would have produced a value. Only a clean "no such entry" continues the
chain.

This is why a machine with a broken keychain can fail to start a tool
that appears, from its config, to have a perfectly good environment-variable
fallback.

## `literal` under `CI=true` stops the chain rather than falling through

When the `literal` key is present and the process is running under
`CI=true`, `resolve` returns `CredentialError::LiteralRefusedInCi`.

It does **not** continue to `fallback_env`. A reference carrying both a
`literal` and a `fallback_env` fails in CI even when the fallback
variable is set and readable:

```yaml
anthropic:
  literal: sk-ant-...        # present
  fallback_env: ANTHROPIC_API_KEY   # set in CI, but never reached
```

Removing the `literal` key from config that runs in CI is the way to
make the fallback reachable.

## What counts as CI

The check is exact-match, case-sensitive:

```rust
fn is_ci() -> bool {
    std::env::var("CI").as_deref() == Ok("true")
}
```

Only the literal string `true` triggers refusal. `CI=1`, `CI=TRUE`,
`CI=True` and `CI=yes` all leave literals permitted. See
[environment variables](environment-variables.md) for which platforms
set the value this crate expects.

## Which name appears in a `NotFound` error

When nothing resolves, the `name` field of `NotFound` is chosen by this
order — which is **not** the resolution order:

1. `fallback_env`, if set
2. otherwise `env`, if set
3. otherwise `"{service}/{account}"` from `keychain`, if set
4. otherwise the literal string `<unnamed credential>`

A reference with both `env: MYTOOL_KEY` and
`fallback_env: ANTHROPIC_API_KEY` reports
`credential not found: ANTHROPIC_API_KEY`, naming the ecosystem variable
rather than the tool's own. The message names one source, not every
source that was tried.

## `probe` reports the winning source without returning the secret

```rust
pub async fn probe(&self, cref: &CredentialRef)
    -> Result<ResolutionOutcome, CredentialError>;
```

`probe` walks the same chain and reports which leg would win.

| `ResolutionOutcome` | Meaning |
|---|---|
| `Resolved(ResolutionSource)` | this leg produced a readable value |
| `LiteralRefusedInCi` | `literal` is configured and `CI=true` |
| `Missing` | nothing resolved |

`ResolutionSource` is `Env`, `Keychain`, `Literal` or `FallbackEnv`.
Both enums are `#[non_exhaustive]`, so a `match` on either needs a
wildcard arm and adding a variant is not a breaking change.

`LiteralRefusedInCi` is an `Ok` outcome from `probe` and an `Err` from
`resolve`. The split is deliberate: a status command wants to display
"refused in CI" as a state, not to fail.

### `probe` does the same I/O as `resolve`

`probe` is not a cheap, side-effect-free inspection. When `keychain` is
configured it performs a real `store.get`, reading the secret and
dropping it. On a platform keychain that means a genuine round-trip per
reference, and on some systems an unlock prompt.

What `probe` avoids is returning the secret to the caller, so a status
command can list credentials without a secret reaching a terminal.

### `probe` and `resolve` can disagree on an empty keychain entry

`probe`'s environment legs test `std::env::var(name).is_ok()`, and its
keychain leg tests whether `get` returned `Ok`. These are the same
conditions `resolve` uses, so the two agree on which leg wins.

The one behaviour worth knowing is shared by both: a configured source
holding an empty string counts as a hit. `probe` reports `Resolved`, and
`resolve` returns an empty `SecretString`. Neither treats "set but
empty" as a miss, so a credential can resolve successfully to nothing
and fail later at the point of use.