diff --git a/.github/workflows/cli-windows-arm64.yml b/.github/workflows/cli-windows-arm64.yml index 7141a712..0e9665aa 100644 --- a/.github/workflows/cli-windows-arm64.yml +++ b/.github/workflows/cli-windows-arm64.yml @@ -72,7 +72,7 @@ jobs: # holding the target path. cp secretspec/README.md staging/README.md cp CHANGELOG.md LICENSE staging/ - cp target/aarch64-pc-windows-msvc/dist/secretspec.exe staging/ + cp target/aarch64-pc-windows-msvc/dist/{secretspec,docker-credential-secretspec}.exe staging/ - name: Create the archive shell: pwsh @@ -88,7 +88,7 @@ jobs: $names = $zip.Entries.FullName $zip.Dispose() # Every file dist puts in a Windows archive, at the archive root. - foreach ($entry in 'secretspec.exe', 'README.md', 'LICENSE', 'CHANGELOG.md') { + foreach ($entry in 'secretspec.exe', 'docker-credential-secretspec.exe', 'README.md', 'LICENSE', 'CHANGELOG.md') { if ($names -notcontains $entry) { throw "the archive is missing $entry" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 419ee984..671a5287 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,9 +79,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 individually. - OnePassword optional references whose item names resemble authentication diagnostics are omitted as missing instead of aborting batch resolution. +- A read-only `docker-credential-secretspec` helper lets Docker retrieve + registry usernames and tokens through any SecretSpec provider. + `secretspec docker configure` and `unconfigure` safely manage per-registry + Docker credential-helper settings without replacing existing helpers, while + `secretspec docker login` and `logout` manage isolated embedded credentials; + custom manifests remain available through `--file` (0.20+). ### Fixed +- `cargo run` continues to launch the main SecretSpec CLI after installing the + Docker credential-helper binary in the same package (0.20+). + +- Embedded Docker credentials now remain isolated by registry and Docker + configuration when used with flat providers such as Dotenv. Provider keys + carry the same stable identity as the embedded project, preventing one + registry's login or logout from affecting another (0.20+). + +- Docker now reports when `configure` replaces a registry's existing + SecretSpec metadata and clarifies that the stored credential was not removed + (0.20+). + +- Docker can now manage the same registry independently in multiple + `DOCKER_CONFIG` directories. Helper lookup and embedded credential storage + are isolated by both registry and Docker configuration (0.20+). + +- Equivalent `DOCKER_CONFIG` paths that resolve through symlinked directories + now share one Docker credential identity, so helper lookup and cleanup work + regardless of which path spelling invokes them (0.20+). + +- Default Docker audit caller context no longer reports the SecretSpec release + as Docker's version. Docker remains identified as the caller while its + unknown version is omitted (0.20+). + +- Docker's managed credential state is now restricted to owner-only permissions + without changing the existing mode of Docker's own `config.json`. Atomic + updates and final-entry removal preserve a symlinked state file, and + interrupted removal can be resumed when Docker's helper entry was already + deleted (0.20+). + +- Docker credentials configured through a symlinked custom manifest now retain + that logical manifest path, so relative `extends` entries continue to resolve + from the directory where the symlink was selected (0.20+). + +- `secretspec docker` no longer treats exported `SECRETSPEC_FILE`, + `SECRETSPEC_PROFILE`, `SECRETSPEC_PROVIDER`, or `SECRETSPEC_REASON` as typed + configuration flags. Only an explicit `--provider` or `--reason` is saved for + later helper calls, and ambient manifest/profile settings no longer switch or + block the embedded credential workflow (0.20+). + - Google Cloud Secret Manager convention names now use the readable, versioned `secretspec2--{project}--{profile}--{key}` layout. Distinct logical addresses such as `my-app/prod/K` and `my/app-prod/K` can no longer collide diff --git a/Cargo.lock b/Cargo.lock index 292e1a5f..5ee317ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5263,6 +5263,7 @@ dependencies = [ "data-encoding", "detect-coding-agent", "dotenv-ng", + "dunce", "etcetera", "google-cloud-secretmanager-v1", "inquire", @@ -5283,6 +5284,7 @@ dependencies = [ "secrecy", "serde", "serde_json", + "sha2 0.10.9", "tempfile", "thiserror 2.0.18", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 30b9f7a1..07fb06c3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,7 @@ thiserror = "2.0" etcetera = "0.11" colored = "3.0" dotenv = { package = "dotenv-ng", version = "1.0.0" } +dunce = "1.0" inquire = { version = "0.9.4", features = ["experimental-multiline-input"] } miette = { version = "7.6", features = ["fancy"] } serde_json = "1.0" @@ -62,6 +63,7 @@ rand = "0.9" rsa = { version = "0.9", features = ["pem"] } uuid = { version = "1", features = ["serde", "v4"] } data-encoding = "2" +sha2 = "0.10" detect-coding-agent = "0.1" age = { version = "0.12", features = ["armor", "plugin", "ssh"] } diff --git a/docs/astro.config.ts b/docs/astro.config.ts index ef0357d5..17c03c2e 100644 --- a/docs/astro.config.ts +++ b/docs/astro.config.ts @@ -379,6 +379,16 @@ Values can be resolved from: keyring (default), KeePass KDBX (0.17+), dotenv fil }, ], }, + { + label: "Integrations", + items: [ + { + label: "Docker credentials", + slug: "integrations/docker", + badge: { text: "0.20+", variant: "note" }, + }, + ], + }, { label: "SDK", items: [ diff --git a/docs/src/content/docs/integrations/docker.md b/docs/src/content/docs/integrations/docker.md new file mode 100644 index 00000000..363a3df9 --- /dev/null +++ b/docs/src/content/docs/integrations/docker.md @@ -0,0 +1,230 @@ +--- +title: Docker credentials +description: Let Docker retrieve registry credentials through SecretSpec providers +--- + +The Docker credential integration is available in SecretSpec 0.20+. It lets +`docker pull`, `docker push`, `docker build`, and Docker Compose retrieve +registry credentials from any SecretSpec provider without copying the +password or token into Docker's `config.json`. + +## Prerequisites + +- Docker +- SecretSpec 0.20 or newer, including `docker-credential-secretspec` on `PATH` + +## Quick start + +These commands are available in SecretSpec 0.20+. + +Configure the registry with its non-secret username: + +::::danger[This changes your Docker configuration] +Docker has no repository-local configuration. `configure` updates +`$DOCKER_CONFIG/config.json` when `DOCKER_CONFIG` is set, or the user-level +`~/.docker/config.json` (`%USERPROFILE%\.docker\config.json` on Windows) +otherwise. The change applies to every Docker command using that configuration. +SecretSpec preserves unrelated settings and refuses to replace another helper. +Undo it with `secretspec docker unconfigure --registry ghcr.io`. +:::: + +```bash +$ secretspec docker configure --registry ghcr.io --username YOUR_USERNAME +``` + +After confirmation, the command prints the matching login command: + +```console +Configured Docker credential for ghcr.io. +Docker configuration: /home/you/.docker/config.json +Store the credential with: secretspec docker login 'ghcr.io' +Undo with: secretspec docker unconfigure --registry 'ghcr.io' +``` + +Store the password or access token in SecretSpec's embedded, registry-isolated +credential store: + +```bash +$ secretspec docker login ghcr.io +``` + +`login` prompts securely on a terminal and reads the password or token from +standard input when piped. + +::::caution[Use SecretSpec to log in and out] +After configuring the helper, use `secretspec docker login` and +`secretspec docker logout` to manage the stored credential. Docker's own +`docker login` and `docker logout` operations try to write or erase through the +helper, which is intentionally read-only. +:::: + +Docker now invokes `docker-credential-secretspec get` automatically: + +```bash +$ docker pull ghcr.io/OWNER/IMAGE:TAG +$ docker push ghcr.io/OWNER/IMAGE:TAG +``` + +`configure` does not retrieve or store the credential. It adds the registry's +`credHelpers` entry and records only the registry, Docker configuration path, +username, provider selection, and other value-free metadata. `login` prompts +for the secret and stores it through the selected provider. Each registry and +physical Docker configuration pair has a separate SecretSpec project and +secret-key identity, so credentials remain isolated even in flat providers that +do not namespace keys by project or profile. SecretSpec's managed state is +owner-readable and owner-writable only; Docker's existing `config.json` +permissions are preserved. + +Rerunning `configure` for the same registry and Docker configuration replaces +its SecretSpec metadata and reports that replacement. It does not delete the +stored credential. + +To use a provider other than your default, pass the same override to both +commands. The follow-up command printed by `configure` includes it automatically: + +```bash +$ secretspec docker configure \ + --registry ghcr.io \ + --username YOUR_USERNAME \ + --provider onepassword +$ secretspec docker login ghcr.io --provider onepassword +``` + +Exported `SECRETSPEC_FILE`, `SECRETSPEC_PROFILE`, `SECRETSPEC_PROVIDER`, and +`SECRETSPEC_REASON` values are not saved as durable Docker helper settings. +Pass `--file`, `--profile`, `--provider`, or `--reason` explicitly when the +helper should keep using that selection. + +## Docker Hub + +Docker uses the historical key `https://index.docker.io/v1/` for Docker Hub. +SecretSpec 0.20+ normalizes the familiar Docker Hub hostnames and URL forms to +that key: + +```bash +$ secretspec docker configure \ + --registry docker.io \ + --username YOUR_DOCKER_ID +$ secretspec docker login docker.io +``` + +Registry addresses may contain a port, such as +`registry.example.com:5000`, but not a repository path. Credentials are scoped +to the registry rather than an image namespace. + +## Use a project manifest + +Custom Docker credential configuration is available in SecretSpec 0.20+. + +For a credential already declared by a project, pass `--file` to select the +advanced custom-manifest mode. In this mode, `--token-secret` and either +`--username` or `--username-secret` are required: + +```toml +[project] +name = "docker-credentials" +revision = "1.0" + +[profiles.default] +GHCR_TOKEN = { description = "GitHub Container Registry token" } +``` + +```bash +$ secretspec set GHCR_TOKEN --file secretspec.toml +$ secretspec --file secretspec.toml docker configure \ + --registry ghcr.io \ + --token-secret GHCR_TOKEN \ + --username YOUR_USERNAME +``` + +To resolve the username from SecretSpec too, declare it and replace +`--username` with `--username-secret GHCR_USERNAME`. Custom-manifest mode also +accepts `--profile` and `--provider`. + +The managed state records the manifest's absolute path and resolved profile, +but never resolved secret values. A symlinked manifest retains its logical path, +so relative `extends` entries resolve beside the symlink. If the manifest moves, +rerun `configure` for the affected registry. Manage custom-manifest values with +`secretspec set` and `secretspec delete`; `secretspec docker login` and `logout` +intentionally manage only the embedded store. + +## Alternate Docker configuration directory + +Per-configuration Docker credential isolation is available in SecretSpec 0.20+. +SecretSpec and Docker both honor `DOCKER_CONFIG` when selecting `config.json`: + +```bash +$ DOCKER_CONFIG="$HOME/.config/docker-work" \ + secretspec docker configure \ + --registry registry.example.com \ + --username YOUR_USERNAME +``` + +The same registry can use different SecretSpec credentials in different Docker +configuration directories. Embedded credentials are isolated by both registry +and the physical Docker configuration path. Equivalent paths through symlinked +directories resolve to the same credential identity. Use the same +`DOCKER_CONFIG` value when logging in, logging out, or unconfiguring entries +from that file. + +::::caution[Use DOCKER_CONFIG instead of docker --config] +Docker's `--config ` flag selects a configuration for the Docker process, +but Docker does not pass that path to credential helpers. Export +`DOCKER_CONFIG=` instead so `docker-credential-secretspec` can identify +the matching managed credential. Using only `docker --config ` can find +the helper registration while leaving the helper unable to select its +credential. +:::: + +## Remove credentials and configuration + +These removal commands are available in SecretSpec 0.20+. + +Remove an embedded secret without changing Docker's helper configuration: + +```bash +$ secretspec docker logout ghcr.io +``` + +Pass the same `--provider` used for login when it was explicitly overridden. + +Remove one helper registration from the active Docker configuration: + +```bash +$ secretspec docker unconfigure --registry ghcr.io +``` + +Remove every Docker credential helper registration that SecretSpec owns in +that file: + +```bash +$ secretspec docker unconfigure --all +``` + +Configuration changes prompt with a default of **No**. Pass `--yes` for +non-interactive setup or removal. SecretSpec preserves the default credential +store, other registry helpers, existing `auths`, and unrelated Docker options. +If a managed entry changes outside SecretSpec, `unconfigure` refuses to modify +another helper's entry. If the SecretSpec helper entry is already absent, +`unconfigure` safely removes the stale managed state so an interrupted removal +can be rerun. + +`logout` and `unconfigure` are independent: logout deletes the embedded secret, +while unconfigure removes Docker's reference to the helper. This matches the +separation between `login` and `configure`. + +## Read-only helper behavior + +In SecretSpec 0.20+, `docker-credential-secretspec` answers Docker's `get` +operation. It rejects `store`, `erase`, and `list`, so Docker's own +`docker login` and `docker logout` cannot overwrite or delete values in a shared +provider. Use `secretspec docker login` and `secretspec docker logout` for the +embedded store, or normal SecretSpec commands for a custom manifest. + +Docker may still print `Removing login credentials` and exit successfully after +`docker logout` even though a read-only helper retained the credential. Use +`secretspec docker logout` to remove the stored value, and +`secretspec docker unconfigure` to stop Docker from invoking the helper. + +When no matching configuration or stored value exists, the helper returns +Docker's standard credential-not-found response. diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index b2522901..96d1d215 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -222,6 +222,74 @@ Run 'secretspec check --provider bws' to verify authentication. A read-only source provider is rejected. An alias that declares no credentials reports that there is nothing to store. +### docker configure (0.20+) + +Configure Docker to retrieve credentials for one registry through SecretSpec. + +```bash +$ secretspec docker configure --registry --username [OPTIONS] +``` + +**Options:** + +- `--registry ` - Registry hostname, optionally including a port; + Docker Hub aliases are normalized to Docker's canonical registry key +- `--username ` - Non-secret registry username; required for the + embedded store, or as an alternative to `--username-secret` with `--file` +- `--token-secret ` - Custom manifest key containing the password or + access token; requires `--file` +- `--username-secret ` - Custom manifest key containing the username; + requires `--file` and conflicts with `--username` +- `-P, --profile ` - Custom manifest profile; requires `--file` +- `-p, --provider ` - Provider override the helper should use +- `-y, --yes` - Confirm the Docker configuration change non-interactively + +Without `--file`, the command configures the embedded registry-isolated store +and prints the corresponding `secretspec docker login` command. With `--file`, +`--token-secret` and either username option are required. The command adds a +registry-specific `credHelpers` entry to Docker's `config.json`, prompts with a +default of **No**, and refuses to replace an existing helper. + +### docker login (0.20+) + +Store a password or token in the embedded Docker credential store: + +```bash +$ secretspec docker login [--provider ] +``` + +The registry is normalized exactly as it is for `configure`. Each registry and +physical Docker configuration pair uses a separate SecretSpec project identity. +This command rejects `--file`; use `secretspec set` for custom-manifest +credentials. + +### docker logout (0.20+) + +Remove a password or token from the embedded Docker credential store: + +```bash +$ secretspec docker logout [--provider ] +``` + +Use the same provider override supplied to `login`. This does not remove the +Docker helper registration; use `unconfigure` for that. + +### docker unconfigure (0.20+) + +Remove one or all Docker credentials configured by SecretSpec in the active +Docker configuration. + +```bash +$ secretspec docker unconfigure --registry +$ secretspec docker unconfigure --all +``` + +Use `--yes` to confirm the change non-interactively. `--all` removes only +entries SecretSpec owns; it preserves the default credential store, other +registry helpers, stored authentication entries, and unrelated Docker options. +See [Docker credentials](/integrations/docker/) for complete setup, custom +manifest, and ownership details. + ### check Check if all required secrets are available, with interactive prompting for missing secrets. diff --git a/secretspec/Cargo.toml b/secretspec/Cargo.toml index ed8013de..9ae7533d 100644 --- a/secretspec/Cargo.toml +++ b/secretspec/Cargo.toml @@ -5,6 +5,7 @@ edition.workspace = true repository = "https://github.com/cachix/secretspec" description = "A declarative interface for every secret provider." license = "Apache-2.0" +default-run = "secretspec" [lib] name = "secretspec" @@ -15,6 +16,11 @@ name = "secretspec" path = "src/bin/secretspec.rs" required-features = ["cli"] +[[bin]] +name = "docker-credential-secretspec" +path = "src/bin/docker-credential-secretspec.rs" +required-features = ["cli"] + [dependencies] clap.workspace = true clap_complete = { workspace = true, optional = true } @@ -30,6 +36,7 @@ thiserror.workspace = true etcetera.workspace = true colored.workspace = true dotenv.workspace = true +dunce = { workspace = true, optional = true } inquire.workspace = true miette.workspace = true serde_json.workspace = true @@ -55,12 +62,13 @@ rand.workspace = true rsa.workspace = true uuid.workspace = true data-encoding.workspace = true +sha2.workspace = true detect-coding-agent.workspace = true age = { workspace = true, optional = true } [features] default = ["cli", "keyring", "kdbx", "keeper", "gcsm", "awssm", "awsps", "vault", "openbao", "bws", "akv", "aac", "infisical", "bw", "age", "scaleway", "sops"] -cli = ["dep:toml_edit", "dep:clap_complete", "dep:clap_complete_nushell", "dep:is_executable"] +cli = ["dep:toml_edit", "dep:clap_complete", "dep:clap_complete_nushell", "dep:is_executable", "dep:dunce"] keyring = ["dep:keyring", "dep:whoami"] kdbx = ["dep:keepass"] keeper = ["dep:keeper-secrets-manager-core"] diff --git a/secretspec/README.md b/secretspec/README.md index 00fdac68..83d6a909 100644 --- a/secretspec/README.md +++ b/secretspec/README.md @@ -61,6 +61,7 @@ SecretSpec fixes this by separating secret **declaration** from secret **storage - **Composed Secrets (0.16+)**: Derive read-only values such as DSNs from declared secrets with strict, order-independent `${UPPERCASE_NAME}` references - **[Configuration Inheritance](https://secretspec.dev/concepts/inheritance/)**: Extend and override shared configurations using the `extends` feature - **[Audit Logging](https://secretspec.dev/concepts/audit/)**: Every secret access recorded locally (who, when, why, outcome) — on by default, secret values never logged +- **[Docker credential helper](https://secretspec.dev/integrations/docker/)** (0.20+): Authenticate registry pulls and pushes through any SecretSpec provider - **[Discovery](https://secretspec.dev/reference/cli#init)**: `secretspec init` to discover secrets from existing `.env` files ## Quick Start diff --git a/secretspec/src/bin/docker-credential-secretspec.rs b/secretspec/src/bin/docker-credential-secretspec.rs new file mode 100644 index 00000000..c0e38679 --- /dev/null +++ b/secretspec/src/bin/docker-credential-secretspec.rs @@ -0,0 +1,3 @@ +fn main() -> std::process::ExitCode { + secretspec::integration::docker::main() +} diff --git a/secretspec/src/cli/docker.rs b/secretspec/src/cli/docker.rs new file mode 100644 index 00000000..dd02c977 --- /dev/null +++ b/secretspec/src/cli/docker.rs @@ -0,0 +1,814 @@ +use super::{TypedArgs, load_secrets, shell_quote}; +use crate::integration::docker::{ + CredentialSource, EmbeddedDockerCredentials, HELPER_NAME, ManagedCredential, UsernameSource, + canonical_registry, docker_config_path, load_embedded_docker_credentials, load_state, + state_entry_path, state_path, valid_username, +}; +use crate::{CallerContext, Secrets}; +use clap::Subcommand; +use miette::{IntoDiagnostic, Result, WrapErr, miette}; +use serde_json::{Map, Value}; +use std::fs; +use std::io::{ErrorKind, IsTerminal, Write}; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use tempfile::NamedTempFile; + +#[derive(Subcommand)] +pub(super) enum DockerAction { + #[command( + about = "Configure Docker to retrieve a registry credential through SecretSpec (0.20+)" + )] + Configure { + #[arg(long, help = "Registry hostname, optionally including a port")] + registry: String, + #[arg(long, help = "Custom manifest key containing the password or token")] + token_secret: Option, + #[arg( + long, + conflicts_with = "username_secret", + help = "Non-secret username to store in the managed Docker configuration" + )] + username: Option, + #[arg( + long, + conflicts_with = "username", + help = "Custom manifest key containing the username" + )] + username_secret: Option, + #[arg( + short = 'P', + long, + env = "SECRETSPEC_PROFILE", + help = "Custom manifest profile the helper should use" + )] + profile: Option, + #[arg( + short, + long, + env = "SECRETSPEC_PROVIDER", + help = "Provider override the helper should use" + )] + provider: Option, + #[arg( + short, + long, + help = "Confirm the Docker configuration change non-interactively" + )] + yes: bool, + }, + #[command( + about = "Store a Docker registry credential in the embedded SecretSpec store (0.20+)" + )] + Login { + #[arg(help = "Registry hostname, optionally including a port")] + registry: String, + #[arg( + short, + long, + env = "SECRETSPEC_PROVIDER", + help = "Provider override to store the credential in" + )] + provider: Option, + }, + #[command( + about = "Remove a Docker registry credential from the embedded SecretSpec store (0.20+)" + )] + Logout { + #[arg(help = "Registry hostname, optionally including a port")] + registry: String, + #[arg( + short, + long, + env = "SECRETSPEC_PROVIDER", + help = "Provider override to remove the credential from" + )] + provider: Option, + }, + #[command(about = "Remove Docker credential configuration managed by SecretSpec (0.20+)")] + Unconfigure { + #[arg( + long, + required_unless_present = "all", + conflicts_with = "all", + help = "Registry whose managed credential should be removed" + )] + registry: Option, + #[arg(long, help = "Remove every Docker credential managed by SecretSpec")] + all: bool, + #[arg( + short, + long, + help = "Confirm the Docker configuration change non-interactively" + )] + yes: bool, + }, +} + +pub(super) fn run( + action: DockerAction, + file: &Option, + reason: &Option, + caller: &Option, + typed: TypedArgs, +) -> Result<()> { + let file = if typed.file { file.clone() } else { None }; + let file = &file; + match action { + DockerAction::Configure { + registry, + token_secret, + username, + username_secret, + profile, + provider, + yes, + } => configure(ConfigureOptions { + registry, + token_secret, + username, + username_secret, + profile, + provider, + yes, + file, + reason, + caller, + typed, + }), + DockerAction::Login { registry, provider } => { + login(registry, provider, file, reason, caller) + } + DockerAction::Logout { registry, provider } => { + logout(registry, provider, file, reason, caller) + } + DockerAction::Unconfigure { registry, all, yes } => unconfigure(registry, all, yes), + } +} + +struct ConfigureOptions<'a> { + registry: String, + token_secret: Option, + username: Option, + username_secret: Option, + profile: Option, + provider: Option, + yes: bool, + file: &'a Option, + reason: &'a Option, + caller: &'a Option, + typed: TypedArgs, +} + +fn configure(options: ConfigureOptions<'_>) -> Result<()> { + let registry = canonical_registry(&options.registry).map_err(|error| miette!(error))?; + let docker_config = docker_config_path().map_err(|error| miette!(error))?; + let (mut secrets, source, manifest) = if options.file.is_some() { + let token_secret = options.token_secret.as_deref().ok_or_else(|| { + miette!("--token-secret is required when --file selects a custom manifest") + })?; + let username = match (options.username, options.username_secret) { + (Some(username), None) => { + validate_literal_username(&username)?; + UsernameSource::Literal(username) + } + (None, Some(secret)) => UsernameSource::Secret(secret), + (None, None) => { + return Err(miette!( + "--username or --username-secret is required when --file selects a custom manifest" + )); + } + (Some(_), Some(_)) => unreachable!("clap rejects conflicting username options"), + }; + let manifest = manifest_path(options.file)?; + let mut secrets = load_secrets(options.file, options.reason, options.caller)?; + if let Some(profile) = &options.profile { + secrets.set_profile(profile); + } + let profile = secrets.resolve_profile_name(None); + validate_secret(&secrets, token_secret, &profile)?; + if let UsernameSource::Secret(secret) = &username { + validate_secret(&secrets, secret, &profile)?; + } + ( + secrets, + CredentialSource::Manifest { + manifest: manifest.clone(), + profile, + username, + password_secret: token_secret.to_string(), + }, + Some(manifest), + ) + } else { + if options.token_secret.is_some() + || options.username_secret.is_some() + || options.typed.profile + { + return Err(miette!( + "--token-secret, --username-secret, and --profile require --file; the embedded Docker credential store uses PASSWORD and the default profile" + )); + } + let username = options.username.ok_or_else(|| { + miette!("--username is required when using the embedded Docker credential store") + })?; + validate_literal_username(&username)?; + ( + load_embedded_docker_credentials(®istry, &docker_config) + .map_err(|error| miette!(error))? + .secrets, + CredentialSource::Embedded { username }, + None, + ) + }; + if let Some(provider) = &options.provider { + secrets.set_provider(provider); + } + + let persisted_provider = options + .typed + .provider + .then_some(options.provider.as_deref()) + .flatten(); + let persisted_reason = options + .typed + .reason + .then_some(options.reason.as_deref()) + .flatten(); + + let original_docker = read_optional(&docker_config)?; + let mut docker = parse_docker_config(original_docker.as_deref(), &docker_config)?; + let existing_helper = credential_helper(&docker, ®istry)?; + let state_file = state_path().map_err(|error| miette!(error))?; + let original_state = read_optional(&state_file)?; + let mut state = load_state().map_err(|error| miette!(error))?; + let existing_index = state.credentials.iter().position(|credential| { + credential.registry == registry && credential.docker_config == docker_config + }); + if let Some(helper) = existing_helper + && helper != HELPER_NAME + { + return Err(miette!( + "Docker registry '{registry}' already uses credential helper '{helper}'; refusing to replace it" + )); + } + if existing_helper == Some(HELPER_NAME) && existing_index.is_none() { + return Err(miette!( + "Docker registry '{registry}' already names the SecretSpec helper but is not managed by this configuration; remove that entry manually before configuring it" + )); + } + + let credential = ManagedCredential { + registry: registry.clone(), + docker_config: docker_config.clone(), + provider: persisted_provider.map(str::to_string), + reason: persisted_reason.map(str::to_string), + source, + }; + let state_changed = match existing_index { + Some(index) if state.credentials[index] == credential => false, + Some(index) => { + state.credentials[index] = credential; + true + } + None => { + state.credentials.push(credential); + true + } + }; + let replaced = existing_index.is_some() && state_changed; + let docker_changed = existing_helper != Some(HELPER_NAME); + if !state_changed && !docker_changed { + println!("Docker credential for {registry} is already configured."); + return Ok(()); + } + if !confirm( + options.yes, + &format!("Configure Docker credential for {registry}?"), + )? { + return Ok(()); + } + + ensure_unchanged(&docker_config, original_docker.as_deref())?; + ensure_unchanged(&state_file, original_state.as_deref())?; + set_credential_helper(&mut docker, ®istry, HELPER_NAME)?; + write_json_atomically( + &state_file, + &serde_json::to_value(&state).into_diagnostic()?, + true, + )?; + if let Err(error) = ensure_unchanged(&docker_config, original_docker.as_deref()) { + restore_file(&state_file, original_state.as_deref(), true)?; + return Err(error); + } + if let Err(error) = write_json_atomically(&docker_config, &docker, false) { + restore_file(&state_file, original_state.as_deref(), true)?; + return Err(error); + } + + println!("Configured Docker credential for {registry}."); + if replaced { + println!( + "Replaced the previous SecretSpec configuration for {registry}. No stored credential was removed." + ); + } + println!("Docker configuration: {}", docker_config.display()); + if let Some(manifest) = manifest { + println!("SecretSpec manifest: {}", manifest.display()); + } else { + let mut login = format!("secretspec docker login {}", shell_quote(®istry)); + if let Some(provider) = persisted_provider { + login.push_str(" --provider "); + login.push_str(&shell_quote(provider)); + } + println!("Store the credential with: {login}"); + if persisted_provider.is_none() && options.provider.is_some() { + println!( + "Note: SECRETSPEC_PROVIDER was not recorded in the Docker helper; pass --provider to pin it." + ); + } + } + println!( + "Undo with: secretspec docker unconfigure --registry {}", + shell_quote(®istry) + ); + Ok(()) +} + +fn embedded_cli_secrets( + registry: &str, + docker_config: &Path, + provider: Option<&str>, + file: &Option, + reason: &Option, + caller: &Option, + action: &str, +) -> Result { + if file.is_some() { + return Err(miette!( + "secretspec docker {action} manages the embedded Docker credential store; omit --file and use secretspec set or delete for a custom manifest" + )); + } + let mut embedded = load_embedded_docker_credentials(registry, docker_config) + .map_err(|error| miette!(error))?; + if let Some(provider) = provider { + embedded.secrets.set_provider(provider); + } + if let Some(reason) = reason { + embedded.secrets = embedded.secrets.with_reason(reason.clone()); + } + let caller = caller.clone().unwrap_or_else(|| { + CallerContext::new("docker") + .with_operation(format!("credential_{action}")) + .with_resource(registry) + }); + embedded.secrets = embedded.secrets.with_caller(caller); + embedded.secrets.set_write_target_reporter(|target| { + eprintln!( + "Writing secret '{}' to {} (profile: {})\n target: {}", + target.name, target.provider_uri, target.profile, target.target + ); + }); + Ok(embedded) +} + +fn login( + registry: String, + provider: Option, + file: &Option, + reason: &Option, + caller: &Option, +) -> Result<()> { + let registry = canonical_registry(®istry).map_err(|error| miette!(error))?; + let docker_config = docker_config_path().map_err(|error| miette!(error))?; + let embedded = embedded_cli_secrets( + ®istry, + &docker_config, + provider.as_deref(), + file, + reason, + caller, + "login", + )?; + embedded + .secrets + .set(&embedded.password_secret, None) + .into_diagnostic() + .wrap_err("Failed to store Docker password or token")?; + println!("Stored Docker credential for {registry}."); + Ok(()) +} + +fn logout( + registry: String, + provider: Option, + file: &Option, + reason: &Option, + caller: &Option, +) -> Result<()> { + let registry = canonical_registry(®istry).map_err(|error| miette!(error))?; + let docker_config = docker_config_path().map_err(|error| miette!(error))?; + let embedded = embedded_cli_secrets( + ®istry, + &docker_config, + provider.as_deref(), + file, + reason, + caller, + "logout", + )?; + if embedded + .secrets + .delete(&embedded.password_secret) + .into_diagnostic() + .wrap_err("Failed to remove Docker password or token")? + { + println!("Removed stored Docker credential for {registry}."); + } else { + println!("No stored Docker credential for {registry} was found."); + } + Ok(()) +} + +fn unconfigure(registry: Option, all: bool, yes: bool) -> Result<()> { + let registry = registry + .as_deref() + .map(canonical_registry) + .transpose() + .map_err(|error| miette!(error))?; + let docker_config = docker_config_path().map_err(|error| miette!(error))?; + let original_docker = read_optional(&docker_config)?; + let mut docker = parse_docker_config(original_docker.as_deref(), &docker_config)?; + let state_entry = state_entry_path().map_err(|error| miette!(error))?; + let state_file = state_path().map_err(|error| miette!(error))?; + let original_state = read_optional(&state_file)?; + let mut state = load_state().map_err(|error| miette!(error))?; + + let selected: Vec<_> = state + .credentials + .iter() + .filter(|credential| { + credential.docker_config == docker_config + && (all || registry.as_deref() == Some(&credential.registry)) + }) + .map(|credential| credential.registry.clone()) + .collect(); + if selected.is_empty() { + println!("No matching SecretSpec-managed Docker credentials found."); + return Ok(()); + } + let mut configured = Vec::new(); + for registry in &selected { + match credential_helper(&docker, registry)? { + Some(HELPER_NAME) => configured.push(registry.clone()), + Some(helper) => { + return Err(miette!( + "Docker credential helper for '{registry}' changed to '{helper}'; refusing to modify it" + )); + } + None => {} + } + } + if !confirm( + yes, + if all { + "Remove all SecretSpec-managed Docker credentials from this Docker configuration?" + } else { + "Remove this SecretSpec-managed Docker credential?" + }, + )? { + return Ok(()); + } + ensure_unchanged(&docker_config, original_docker.as_deref())?; + ensure_unchanged(&state_file, original_state.as_deref())?; + + for registry in &configured { + remove_credential_helper(&mut docker, registry)?; + } + state.credentials.retain(|credential| { + credential.docker_config != docker_config || !selected.contains(&credential.registry) + }); + if !configured.is_empty() { + write_json_atomically(&docker_config, &docker, false)?; + } + if let Err(error) = ensure_unchanged(&state_file, original_state.as_deref()) { + if !configured.is_empty() { + restore_file(&docker_config, original_docker.as_deref(), false)?; + } + return Err(error); + } + let state_result = if state.credentials.is_empty() { + match fs::symlink_metadata(&state_entry) { + Ok(metadata) if metadata.file_type().is_symlink() => write_json_atomically( + &state_file, + &serde_json::to_value(&state).into_diagnostic()?, + true, + ), + Ok(_) => fs::remove_file(&state_file) + .into_diagnostic() + .wrap_err_with(|| format!("Failed to remove {}", state_file.display())), + Err(error) => Err(error) + .into_diagnostic() + .wrap_err_with(|| format!("Failed to inspect {}", state_entry.display())), + } + } else { + write_json_atomically( + &state_file, + &serde_json::to_value(&state).into_diagnostic()?, + true, + ) + }; + if let Err(error) = state_result { + if !configured.is_empty() { + restore_file(&docker_config, original_docker.as_deref(), false)?; + } + return Err(error); + } + println!( + "Removed {} SecretSpec-managed Docker credential{}.", + selected.len(), + if selected.len() == 1 { "" } else { "s" } + ); + Ok(()) +} + +fn validate_literal_username(username: &str) -> Result<()> { + if !valid_username(username) { + return Err(miette!( + "Docker username cannot be empty or contain control characters" + )); + } + Ok(()) +} + +fn validate_secret(secrets: &Secrets, name: &str, profile: &str) -> Result<()> { + if name.is_empty() { + return Err(miette!("Secret name cannot be empty")); + } + let secret = secrets.resolve_secret_config(name, None).ok_or_else(|| { + miette!("Secret '{name}' is not declared in SecretSpec profile '{profile}'") + })?; + if secret.as_path == Some(true) { + return Err(miette!( + "Secret '{name}' uses as_path and cannot be returned as a Docker credential" + )); + } + Ok(()) +} + +fn manifest_path(file: &Option) -> Result { + let path = match file { + Some(path) => path.clone(), + None => crate::secrets::find_config_file().into_diagnostic()?, + }; + if path.is_absolute() { + Ok(path) + } else { + std::env::current_dir() + .into_diagnostic() + .wrap_err("Failed to resolve the current directory") + .map(|directory| directory.join(path)) + } +} + +fn parse_docker_config(contents: Option<&[u8]>, path: &Path) -> Result { + match contents { + Some(contents) => { + let value: Value = serde_json::from_slice(contents) + .into_diagnostic() + .wrap_err_with(|| format!("Failed to parse {}", path.display()))?; + if !value.is_object() { + return Err(miette!("{} must contain a JSON object", path.display())); + } + Ok(value) + } + None => Ok(Value::Object(Map::new())), + } +} + +fn credential_helpers(config: &Value) -> Result>> { + match config.get("credHelpers") { + Some(Value::Object(helpers)) => Ok(Some(helpers)), + Some(_) => Err(miette!( + "Docker config field 'credHelpers' must be an object" + )), + None => Ok(None), + } +} + +fn credential_helper<'a>(config: &'a Value, registry: &str) -> Result> { + let Some(value) = credential_helpers(config)?.and_then(|helpers| helpers.get(registry)) else { + return Ok(None); + }; + value + .as_str() + .map(Some) + .ok_or_else(|| miette!("Docker credential helper for '{registry}' must be a string")) +} + +fn set_credential_helper(config: &mut Value, registry: &str, helper: &str) -> Result<()> { + let object = config + .as_object_mut() + .ok_or_else(|| miette!("Docker configuration must be an object"))?; + let helpers = object + .entry("credHelpers") + .or_insert_with(|| Value::Object(Map::new())) + .as_object_mut() + .ok_or_else(|| miette!("Docker config field 'credHelpers' must be an object"))?; + helpers.insert(registry.to_string(), Value::String(helper.to_string())); + Ok(()) +} + +fn remove_credential_helper(config: &mut Value, registry: &str) -> Result<()> { + let object = config + .as_object_mut() + .ok_or_else(|| miette!("Docker configuration must be an object"))?; + let remove_field = match object.get_mut("credHelpers") { + Some(Value::Object(helpers)) => { + helpers.remove(registry); + helpers.is_empty() + } + Some(_) => { + return Err(miette!( + "Docker config field 'credHelpers' must be an object" + )); + } + None => false, + }; + if remove_field { + object.remove("credHelpers"); + } + Ok(()) +} + +fn confirm(yes: bool, prompt: &str) -> Result { + if yes { + return Ok(true); + } + if !std::io::stdin().is_terminal() { + return Err(miette!( + "refusing to change Docker configuration without confirmation; pass --yes for non-interactive use" + )); + } + if !inquire::Confirm::new(prompt) + .with_default(false) + .prompt() + .into_diagnostic()? + { + println!("Cancelled."); + return Ok(false); + } + Ok(true) +} + +fn read_optional(path: &Path) -> Result>> { + match fs::read(path) { + Ok(contents) => Ok(Some(contents)), + Err(error) if error.kind() == ErrorKind::NotFound => Ok(None), + Err(error) => Err(error) + .into_diagnostic() + .wrap_err_with(|| format!("Failed to read {}", path.display())), + } +} + +fn ensure_unchanged(path: &Path, expected: Option<&[u8]>) -> Result<()> { + if read_optional(path)?.as_deref() != expected { + return Err(miette!( + "{} changed during this operation; no changes were made; rerun the command", + path.display() + )); + } + Ok(()) +} + +fn write_json_atomically(path: &Path, value: &Value, owner_only: bool) -> Result<()> { + let directory = path + .parent() + .ok_or_else(|| miette!("{} has no parent directory", path.display()))?; + fs::create_dir_all(directory) + .into_diagnostic() + .wrap_err_with(|| format!("Failed to create {}", directory.display()))?; + let permissions = (!owner_only) + .then(|| { + fs::metadata(path) + .ok() + .map(|metadata| metadata.permissions()) + }) + .flatten(); + let mut temporary = NamedTempFile::new_in(directory) + .into_diagnostic() + .wrap_err_with(|| format!("Failed to create temporary file in {}", directory.display()))?; + serde_json::to_writer_pretty(&mut temporary, value).into_diagnostic()?; + temporary.write_all(b"\n").into_diagnostic()?; + temporary.flush().into_diagnostic()?; + if let Some(permissions) = permissions { + temporary + .as_file() + .set_permissions(permissions) + .into_diagnostic()?; + } else { + #[cfg(unix)] + temporary + .as_file() + .set_permissions(fs::Permissions::from_mode(0o600)) + .into_diagnostic()?; + } + temporary.as_file().sync_all().into_diagnostic()?; + temporary.persist(path).map_err(|error| { + miette!( + "Failed to atomically replace {}: {}", + path.display(), + error.error + ) + })?; + Ok(()) +} + +fn restore_file(path: &Path, contents: Option<&[u8]>, owner_only: bool) -> Result<()> { + match contents { + Some(contents) => { + let directory = path + .parent() + .ok_or_else(|| miette!("{} has no parent directory", path.display()))?; + let permissions = (!owner_only) + .then(|| { + fs::metadata(path) + .ok() + .map(|metadata| metadata.permissions()) + }) + .flatten(); + let mut temporary = NamedTempFile::new_in(directory).into_diagnostic()?; + temporary.write_all(contents).into_diagnostic()?; + temporary.flush().into_diagnostic()?; + if let Some(permissions) = permissions { + temporary + .as_file() + .set_permissions(permissions) + .into_diagnostic()?; + } else { + #[cfg(unix)] + temporary + .as_file() + .set_permissions(fs::Permissions::from_mode(0o600)) + .into_diagnostic()?; + } + temporary.as_file().sync_all().into_diagnostic()?; + temporary.persist(path).map_err(|error| { + miette!("Failed to restore {}: {}", path.display(), error.error) + })?; + } + None => { + if path.try_exists().into_diagnostic()? { + fs::remove_file(path).into_diagnostic()?; + } + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[cfg(windows)] + use tempfile::TempDir; + + #[test] + fn preserves_unrelated_docker_configuration() { + let mut config = serde_json::json!({ + "auths": {"example.com": {"auth": "encoded"}}, + "credsStore": "desktop", + "credHelpers": {"existing.example.com": "pass"}, + "plugins": {"debug": {"hooks": "exec"}} + }); + set_credential_helper(&mut config, "ghcr.io", HELPER_NAME).unwrap(); + assert_eq!( + credential_helper(&config, "ghcr.io").unwrap(), + Some("secretspec") + ); + remove_credential_helper(&mut config, "ghcr.io").unwrap(); + assert_eq!( + credential_helper(&config, "existing.example.com").unwrap(), + Some("pass") + ); + assert_eq!(config["credsStore"], "desktop"); + assert_eq!(config["auths"]["example.com"]["auth"], "encoded"); + assert_eq!(config["plugins"]["debug"]["hooks"], "exec"); + } + + #[test] + fn rejects_invalid_credential_helpers_shape() { + let config = serde_json::json!({"credHelpers": []}); + assert!(credential_helper(&config, "ghcr.io").is_err()); + } + + #[cfg(windows)] + #[test] + fn manifest_path_avoids_a_windows_verbatim_prefix() { + let directory = TempDir::new().unwrap(); + let path = directory.path().join("secretspec.toml"); + fs::write(&path, "").unwrap(); + let resolved = manifest_path(&Some(path)).unwrap(); + assert!(!resolved.to_string_lossy().starts_with(r"\\?\")); + } +} diff --git a/secretspec/src/cli/mod.rs b/secretspec/src/cli/mod.rs index a3fb0e55..cb2eb6a5 100644 --- a/secretspec/src/cli/mod.rs +++ b/secretspec/src/cli/mod.rs @@ -1,7 +1,8 @@ use crate::config::{Config, GlobalConfig, GlobalDefaults, Profile as ConfigProfile, Project}; use crate::provider::{Provider, providers, spec_names_known_provider}; use crate::{CallerContext, ExportFormat, Secrets}; -use clap::{Parser, Subcommand, ValueEnum, ValueHint}; +use clap::parser::ValueSource; +use clap::{ArgMatches, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum, ValueHint}; use miette::{IntoDiagnostic, Result, WrapErr, miette}; use std::collections::{HashMap, HashSet}; use std::fs; @@ -11,7 +12,7 @@ use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; mod completion; - +mod docker; /// Main CLI structure for the secretspec application. /// /// This is the entry point for the command-line interface, parsing user commands @@ -53,6 +54,37 @@ struct Cli { command: Commands, } +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) struct TypedArgs { + pub(crate) file: bool, + pub(crate) profile: bool, + pub(crate) provider: bool, + pub(crate) reason: bool, +} + +impl TypedArgs { + fn from_matches(matches: &ArgMatches) -> Self { + let mut typed = Self::default(); + let mut current = Some(matches); + while let Some(matches) = current { + for (id, typed) in [ + ("file", &mut typed.file), + ("profile", &mut typed.profile), + ("provider", &mut typed.provider), + ("reason", &mut typed.reason), + ] { + if matches.ids().any(|known| known.as_str() == id) + && matches.value_source(id) == Some(ValueSource::CommandLine) + { + *typed = true; + } + } + current = matches.subcommand().map(|(_, matches)| matches); + } + typed + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] enum CompletionShell { /// Bourne Again Shell @@ -227,6 +259,11 @@ enum Commands { #[command(subcommand)] action: ConfigAction, }, + /// Manage Docker registry credential integration (0.20+) + Docker { + #[command(subcommand)] + action: docker::DockerAction, + }, /// Import secrets from a provider to another provider Import { /// Provider backend to import from (secrets will be imported to the default provider) @@ -910,10 +947,16 @@ fn caller_context(cli: &Cli) -> Result> { #[doc(hidden)] pub fn main() -> Result<()> { completion::complete(); - let cli = Cli::parse(); + let matches = Cli::command().get_matches(); + let typed = TypedArgs::from_matches(&matches); + let cli = match Cli::from_arg_matches(&matches) { + Ok(cli) => cli, + Err(error) => error.exit(), + }; let caller = caller_context(&cli)?; match cli.command { + Commands::Docker { action } => docker::run(action, &cli.file, &cli.reason, &caller, typed), // Initialize a new secretspec.toml configuration file Commands::Init { from, diff --git a/secretspec/src/integration/docker.rs b/secretspec/src/integration/docker.rs new file mode 100644 index 00000000..b859d59f --- /dev/null +++ b/secretspec/src/integration/docker.rs @@ -0,0 +1,567 @@ +use crate::config::GlobalConfig; +use crate::{CallerContext, NamedResolution, RequireReason, Secret, Secrets, Spec}; +use secrecy::{ExposeSecret, SecretString}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::fs; +use std::io::{self, Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; +use url::{Host, Url}; + +pub(crate) const HELPER_NAME: &str = "secretspec"; +pub(crate) const STATE_VERSION: u8 = 1; +const MAX_INPUT_BYTES: u64 = 1_048_576; +const NOT_FOUND: &str = "credentials not found in native keychain"; +const EMBEDDED_PASSWORD: &str = "PASSWORD"; + +pub(crate) struct EmbeddedDockerCredentials { + pub(crate) secrets: Secrets, + pub(crate) password_secret: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ManagedCredential { + pub(crate) registry: String, + pub(crate) docker_config: PathBuf, + pub(crate) provider: Option, + pub(crate) reason: Option, + pub(crate) source: CredentialSource, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "snake_case", tag = "kind")] +pub(crate) enum CredentialSource { + Embedded { + username: String, + }, + Manifest { + manifest: PathBuf, + profile: String, + username: UsernameSource, + password_secret: String, + }, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde( + deny_unknown_fields, + rename_all = "snake_case", + tag = "source", + content = "value" +)] +pub(crate) enum UsernameSource { + Literal(String), + Secret(String), +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ManagedState { + pub(crate) version: u8, + pub(crate) credentials: Vec, +} + +impl Default for ManagedState { + fn default() -> Self { + Self { + version: STATE_VERSION, + credentials: Vec::new(), + } + } +} + +pub(crate) fn state_entry_path() -> Result { + let config = GlobalConfig::path().map_err(|error| error.to_string())?; + let directory = config + .parent() + .ok_or_else(|| "SecretSpec config path has no parent directory".to_string())?; + std::path::absolute(directory.join("docker-credentials.json")) + .map_err(|error| format!("Failed to resolve Docker credential state path: {error}")) +} + +pub(crate) fn state_path() -> Result { + let path = state_entry_path()?; + match fs::symlink_metadata(&path) { + Ok(metadata) if metadata.file_type().is_symlink() => dunce::canonicalize(&path) + .map_err(|error| format!("Failed to resolve {}: {error}", path.display())), + Ok(_) => Ok(path), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(path), + Err(error) => Err(format!("Failed to inspect {}: {error}", path.display())), + } +} + +pub(crate) fn docker_config_path() -> Result { + let directory = match std::env::var_os("DOCKER_CONFIG") { + Some(directory) if !directory.is_empty() => PathBuf::from(directory), + _ => etcetera::home_dir() + .map_err(|error| format!("Failed to locate the user home directory: {error}"))? + .join(".docker"), + }; + let path = std::path::absolute(directory.join("config.json")) + .map_err(|error| format!("Failed to resolve Docker configuration path: {error}"))?; + match fs::symlink_metadata(&path) { + Ok(_) => dunce::canonicalize(&path) + .map_err(|error| format!("Failed to resolve {}: {error}", path.display())), + Err(error) if error.kind() == io::ErrorKind::NotFound => canonicalize_missing_path(&path) + .map_err(|error| format!("Failed to resolve {}: {error}", path.display())), + Err(error) => Err(format!("Failed to inspect {}: {error}", path.display())), + } +} + +fn canonicalize_missing_path(path: &Path) -> io::Result { + let mut prefix = path; + let mut suffix = Vec::new(); + loop { + match dunce::canonicalize(prefix) { + Ok(mut resolved) => { + for component in suffix.iter().rev() { + resolved.push(component); + } + return Ok(resolved); + } + Err(error) if error.kind() == io::ErrorKind::NotFound => { + let Some(component) = prefix.file_name() else { + return Err(error); + }; + suffix.push(component.to_os_string()); + let Some(parent) = prefix.parent() else { + return Err(error); + }; + prefix = parent; + } + Err(error) => return Err(error), + } + } +} + +pub(crate) fn load_state() -> Result { + let path = state_path()?; + let contents = match fs::read(&path) { + Ok(contents) => contents, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(ManagedState::default()), + Err(error) => return Err(format!("Failed to read {}: {error}", path.display())), + }; + let state: ManagedState = serde_json::from_slice(&contents) + .map_err(|error| format!("Failed to parse {}: {error}", path.display()))?; + if state.version != STATE_VERSION { + return Err(format!( + "Unsupported Docker credential configuration version {} in {}", + state.version, + path.display() + )); + } + let mut configurations = std::collections::HashSet::new(); + for credential in &state.credentials { + if canonical_registry(&credential.registry).as_deref() != Ok(&credential.registry) + || !credential.docker_config.is_absolute() + || !valid_source(&credential.source) + { + return Err(format!( + "Invalid entry in Docker credential configuration {}", + path.display() + )); + } + if !configurations.insert((&credential.docker_config, &credential.registry)) { + return Err(format!( + "Duplicate registry and Docker configuration in managed credential state {}", + path.display() + )); + } + } + Ok(state) +} + +fn valid_source(source: &CredentialSource) -> bool { + match source { + CredentialSource::Embedded { username } => valid_username(username), + CredentialSource::Manifest { + manifest, + profile, + username, + password_secret, + } => { + manifest.is_absolute() + && !profile.is_empty() + && !password_secret.is_empty() + && match username { + UsernameSource::Literal(username) => valid_username(username), + UsernameSource::Secret(secret) => !secret.is_empty(), + } + } + } +} + +pub(crate) fn canonical_registry(input: &str) -> Result { + let input = input.trim(); + if input.is_empty() { + return Err("Docker registry cannot be empty".to_string()); + } + if input.chars().any(|character| character.is_ascii_control()) { + return Err("Docker registry cannot contain control characters".to_string()); + } + if matches!( + input.trim_end_matches('/').to_ascii_lowercase().as_str(), + "docker.io" + | "http://docker.io" + | "https://docker.io" + | "index.docker.io" + | "http://index.docker.io" + | "https://index.docker.io" + | "registry-1.docker.io" + | "http://registry-1.docker.io" + | "https://registry-1.docker.io" + | "https://index.docker.io/v1" + | "http://index.docker.io/v1" + ) { + return Ok("https://index.docker.io/v1/".to_string()); + } + + let parsed = if input.contains("://") { + Url::parse(input).map_err(|error| format!("Invalid Docker registry: {error}"))? + } else { + Url::parse(&format!("https://{input}")) + .map_err(|error| format!("Invalid Docker registry: {error}"))? + }; + if !matches!(parsed.scheme(), "http" | "https") + || parsed.host().is_none() + || !parsed.username().is_empty() + || parsed.password().is_some() + || !matches!(parsed.path(), "" | "/") + || parsed.query().is_some() + || parsed.fragment().is_some() + { + return Err( + "Docker registry must be a hostname with an optional port and no path".to_string(), + ); + } + let host = match parsed.host().expect("validated host") { + Host::Ipv6(address) => format!("[{address}]"), + host => host.to_string(), + }; + let authority = input + .split_once("://") + .map_or(input, |(_, authority)| authority) + .trim_end_matches('/'); + let explicit_port = if authority.starts_with('[') { + authority + .rsplit_once(']') + .and_then(|(_, remainder)| remainder.strip_prefix(':')) + } else { + authority + .rsplit_once(':') + .and_then(|(host, port)| (!host.contains(':')).then_some(port)) + }; + let explicit_port = explicit_port + .map(|port| { + port.parse::() + .map_err(|_| "Docker registry port is invalid".to_string()) + }) + .transpose()?; + Ok(match parsed.port().or(explicit_port) { + Some(port) => format!("{host}:{port}"), + None => host, + }) +} + +fn embedded_identity(registry: &str, docker_config: &Path) -> String { + let mut hasher = Sha256::new(); + hasher.update(docker_config.as_os_str().as_encoded_bytes()); + hasher.update([0]); + hasher.update(registry.as_bytes()); + data_encoding::HEXLOWER.encode(&hasher.finalize()) +} + +pub(crate) fn load_embedded_docker_credentials( + registry: &str, + docker_config: &Path, +) -> Result { + let registry = canonical_registry(registry)?; + let identity = embedded_identity(®istry, docker_config); + let password_secret = format!("{EMBEDDED_PASSWORD}_{identity}"); + let spec = Spec::builder(format!("docker-credential-{identity}")) + .require_reason(RequireReason::Never) + .secret( + password_secret.clone(), + Secret::required("Docker registry password or token"), + ) + .build() + .map_err(|error| error.to_string())?; + let config_path = GlobalConfig::path().map_err(|error| error.to_string())?; + let config_dir = config_path + .parent() + .map(Path::to_path_buf) + .ok_or_else(|| "SecretSpec config path has no parent directory".to_string())?; + let mut secrets = Secrets::from_spec_at(spec, config_dir).map_err(|error| error.to_string())?; + secrets.set_profile("default"); + secrets.set_ignore_ambient_scope(true); + Ok(EmbeddedDockerCredentials { + secrets, + password_secret, + }) +} + +fn read_input(mut input: impl Read) -> Result { + let mut bytes = Vec::new(); + input + .by_ref() + .take(MAX_INPUT_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|error| error.to_string())?; + if bytes.len() as u64 > MAX_INPUT_BYTES { + return Err("Docker credential request is too large".to_string()); + } + String::from_utf8(bytes).map_err(|_| "Docker credential request must be UTF-8".to_string()) +} + +fn resolve_secret(secrets: &Secrets, name: &str) -> Result, String> { + let config = secrets + .resolve_secret_config(name, None) + .ok_or_else(|| format!("Secret '{name}' is not declared in the selected profile"))?; + if config.as_path == Some(true) { + return Err(format!( + "Secret '{name}' uses as_path and cannot be returned as a Docker credential" + )); + } + match secrets + .resolve_named(name) + .map_err(|error| error.to_string())? + { + NamedResolution::Resolved(secret) => secret + .value + .map(|value| Some(SecretString::new(value.into()))) + .ok_or_else(|| { + format!( + "Secret '{name}' uses as_path and cannot be returned as a Docker credential" + ) + }), + NamedResolution::Missing { .. } => Ok(None), + NamedResolution::Undeclared => Err(format!( + "Secret '{name}' is not declared in the selected profile" + )), + } +} + +fn resolve(credential: &ManagedCredential) -> Result, String> { + let (mut secrets, username, password_secret) = match &credential.source { + CredentialSource::Embedded { username } => { + let embedded = + load_embedded_docker_credentials(&credential.registry, &credential.docker_config)?; + ( + embedded.secrets, + UsernameSource::Literal(username.clone()), + embedded.password_secret, + ) + } + CredentialSource::Manifest { + manifest, + profile, + username, + password_secret, + } => { + let mut secrets = Secrets::load_from(manifest).map_err(|error| error.to_string())?; + secrets.set_profile(profile); + (secrets, username.clone(), password_secret.clone()) + } + }; + if let Some(provider) = &credential.provider { + secrets.set_provider(provider); + } + if let Some(reason) = &credential.reason { + secrets = secrets.with_reason(reason); + } + secrets = secrets.with_caller( + CallerContext::new("docker") + .with_operation("credential_get") + .with_resource(&credential.registry), + ); + secrets.set_ignore_ambient_scope(true); + + let Some(password) = resolve_secret(&secrets, &password_secret)? else { + return Ok(None); + }; + let username = match username { + UsernameSource::Literal(username) => username, + UsernameSource::Secret(name) => { + let Some(username) = resolve_secret(&secrets, &name)? else { + return Ok(None); + }; + username.expose_secret().to_string() + } + }; + if !valid_username(&username) { + return Err("Docker username cannot be empty or contain control characters".to_string()); + } + Ok(Some((username, password))) +} + +pub(crate) fn valid_username(username: &str) -> bool { + !username.is_empty() + && !username + .chars() + .any(|character| character.is_ascii_control()) +} + +fn get(input: impl Read, mut output: impl Write) -> Result<(), String> { + let registry = canonical_registry(&read_input(input)?)?; + let docker_config = docker_config_path()?; + let state = load_state()?; + let credential = state + .credentials + .iter() + .find(|credential| { + credential.registry == registry && credential.docker_config == docker_config + }) + .ok_or_else(|| NOT_FOUND.to_string())?; + let Some((username, password)) = resolve(credential)? else { + return Err(NOT_FOUND.to_string()); + }; + serde_json::to_writer( + &mut output, + &serde_json::json!({ + "Username": username, + "Secret": password.expose_secret(), + }), + ) + .map_err(|error| error.to_string())?; + writeln!(output).map_err(|error| error.to_string()) +} + +fn run(operation: &str, input: impl Read, output: impl Write) -> Result<(), String> { + match operation { + "get" => get(input, output), + "store" | "erase" => Err( + "docker-credential-secretspec is read-only; manage credentials with SecretSpec" + .to_string(), + ), + "list" => Err( + "docker-credential-secretspec does not act as a global credential store".to_string(), + ), + _ => Err(format!("unknown Docker credential operation '{operation}'")), + } +} + +pub fn main() -> ExitCode { + let mut arguments = std::env::args(); + let program = arguments + .next() + .unwrap_or_else(|| "docker-credential-secretspec".to_string()); + let Some(operation) = arguments.next() else { + println!("Usage: {program} "); + return ExitCode::FAILURE; + }; + if arguments.next().is_some() { + println!("Usage: {program} "); + return ExitCode::FAILURE; + } + if matches!(operation.as_str(), "--help" | "-h") { + println!("Usage: {program} "); + return ExitCode::SUCCESS; + } + if matches!(operation.as_str(), "--version" | "-v") { + println!("docker-credential-secretspec {}", env!("CARGO_PKG_VERSION")); + return ExitCode::SUCCESS; + } + match run(&operation, io::stdin().lock(), io::stdout().lock()) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + println!("{error}"); + ExitCode::FAILURE + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + use tempfile::TempDir; + + #[test] + fn canonicalizes_docker_hub_and_registry_hosts() { + for registry in [ + "docker.io", + "https://docker.io/", + "index.docker.io", + "http://index.docker.io/", + "registry-1.docker.io", + "https://registry-1.docker.io/", + "https://index.docker.io/v1/", + ] { + assert_eq!( + canonical_registry(registry).unwrap(), + "https://index.docker.io/v1/" + ); + } + assert_eq!(canonical_registry("GHCR.IO").unwrap(), "ghcr.io"); + assert_eq!( + canonical_registry("registry.example.com:5000").unwrap(), + "registry.example.com:5000" + ); + assert_eq!( + canonical_registry("registry.example.com:443").unwrap(), + "registry.example.com:443" + ); + assert_eq!( + canonical_registry("http://registry.example.com:80").unwrap(), + "registry.example.com:80" + ); + } + + #[test] + fn rejects_registry_paths_and_credentials() { + for registry in [ + "ghcr.io/org", + "https://user@ghcr.io", + "https://ghcr.io?query=yes", + "ssh://ghcr.io", + "ghcr.io\nexample.com", + ] { + assert!(canonical_registry(registry).is_err()); + } + } + + #[test] + fn read_only_operations_fail_without_reading_configuration() { + for operation in ["store", "erase"] { + let error = run(operation, Cursor::new("invalid"), Vec::new()).unwrap_err(); + assert!(error.contains("read-only")); + } + } + + #[test] + fn resolves_configured_credentials() { + let directory = TempDir::new().unwrap(); + let manifest = directory.path().join("secretspec.toml"); + fs::write( + &manifest, + r#" +[project] +name = "docker-helper" +revision = "1.0" +require_reason = false + +[profiles.default] +DOCKER_USERNAME = { description = "Docker username", default = "registry-user", providers = ["null"] } +DOCKER_TOKEN = { description = "Docker token", default = "token=value", providers = ["null"] } +"#, + ) + .unwrap(); + let credential = ManagedCredential { + registry: "ghcr.io".to_string(), + docker_config: directory.path().join("docker/config.json"), + provider: None, + reason: None, + source: CredentialSource::Manifest { + manifest, + profile: "default".to_string(), + username: UsernameSource::Secret("DOCKER_USERNAME".to_string()), + password_secret: "DOCKER_TOKEN".to_string(), + }, + }; + let resolved = resolve(&credential).unwrap().unwrap(); + assert_eq!(resolved.0, "registry-user"); + assert_eq!(resolved.1.expose_secret(), "token=value"); + } +} diff --git a/secretspec/src/integration/mod.rs b/secretspec/src/integration/mod.rs new file mode 100644 index 00000000..18c50280 --- /dev/null +++ b/secretspec/src/integration/mod.rs @@ -0,0 +1 @@ +pub mod docker; diff --git a/secretspec/src/lib.rs b/secretspec/src/lib.rs index ab98a80e..b337374b 100644 --- a/secretspec/src/lib.rs +++ b/secretspec/src/lib.rs @@ -65,6 +65,10 @@ pub(crate) mod provider; #[cfg(feature = "cli")] pub mod cli; +#[cfg(feature = "cli")] +#[doc(hidden)] +pub mod integration; + // Re-export only the types needed by users and generated code pub use caller::CallerContext; pub use config::Resolved; diff --git a/secretspec/tests/docker_credential.rs b/secretspec/tests/docker_credential.rs new file mode 100644 index 00000000..e0cd938f --- /dev/null +++ b/secretspec/tests/docker_credential.rs @@ -0,0 +1,960 @@ +use serde_json::Value; +use std::env; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; +use tempfile::TempDir; + +struct Fixture { + _temp: TempDir, + root: PathBuf, + manifest: PathBuf, + docker_config: PathBuf, +} + +impl Fixture { + fn new() -> Self { + let temp = TempDir::new().unwrap(); + let root = temp.path().to_path_buf(); + let manifest = root.join("secretspec.toml"); + fs::write( + &manifest, + r#" +[project] +name = "docker-helper" +revision = "1.0" +require_reason = false + +[profiles.default] +DOCKER_USERNAME = { description = "Docker username", default = "registry-user", providers = ["null"] } +DOCKER_TOKEN = { description = "Docker token", default = "token=value", providers = ["null"] } +"#, + ) + .unwrap(); + let docker_directory = root.join("docker"); + fs::create_dir(&docker_directory).unwrap(); + let docker_config = docker_directory.join("config.json"); + fs::write( + &docker_config, + r#"{ + "auths": {"example.com": {"auth": "encoded"}}, + "credsStore": "desktop", + "credHelpers": {"existing.example.com": "pass"}, + "plugins": {"debug": {"hooks": "exec"}} +} +"#, + ) + .unwrap(); + Self { + _temp: temp, + root, + manifest, + docker_config, + } + } + + fn apply_environment(&self, command: &mut Command) { + command + .current_dir(&self.root) + .env("HOME", &self.root) + .env("USERPROFILE", &self.root) + .env("XDG_CONFIG_HOME", self.root.join("config")) + .env("XDG_STATE_HOME", self.root.join("state")) + .env("APPDATA", self.root.join("config")) + .env("LOCALAPPDATA", self.root.join("state")) + .env("DOCKER_CONFIG", self.root.join("docker")) + .env_remove("SECRETSPEC_FILE") + .env_remove("SECRETSPEC_PROFILE") + .env_remove("SECRETSPEC_PROVIDER") + .env_remove("SECRETSPEC_REASON"); + } + + fn secretspec(&self) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_secretspec")); + command.arg("--file").arg(&self.manifest); + self.apply_environment(&mut command); + command + } + + fn embedded_secretspec(&self) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_secretspec")); + self.apply_environment(&mut command); + command + } + + fn helper(&self, operation: &str, input: &[u8]) -> Output { + self.helper_with_docker_config(operation, input, &self.root.join("docker")) + } + + fn helper_with_docker_config( + &self, + operation: &str, + input: &[u8], + docker_config: &Path, + ) -> Output { + let mut command = Command::new(env!("CARGO_BIN_EXE_docker-credential-secretspec")); + command + .arg(operation) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + self.apply_environment(&mut command); + command.env("DOCKER_CONFIG", docker_config); + let mut child = command.spawn().unwrap(); + child.stdin.take().unwrap().write_all(input).unwrap(); + child.wait_with_output().unwrap() + } + + fn configure(&self, registry: &str) -> Output { + self.secretspec() + .args([ + "docker", + "configure", + "--registry", + registry, + "--token-secret", + "DOCKER_TOKEN", + "--username-secret", + "DOCKER_USERNAME", + "--provider", + "null", + "--yes", + ]) + .output() + .unwrap() + } +} + +fn command_with_stdin(mut command: Command, args: &[&str], input: &[u8]) -> Output { + let mut child = command + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child.stdin.take().unwrap().write_all(input).unwrap(); + child.wait_with_output().unwrap() +} + +fn assert_success(context: &str, output: &Output) { + assert!( + output.status.success(), + "{context} failed with {}:\nstdout:\n{}\nstderr:\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +fn read_json(path: &Path) -> Value { + serde_json::from_slice(&fs::read(path).unwrap()).unwrap() +} + +fn find_named(root: &Path, name: &str) -> Option { + for entry in fs::read_dir(root).ok()? { + let path = entry.ok()?.path(); + if path.file_name().is_some_and(|file_name| file_name == name) { + return Some(path); + } + if path.is_dir() + && let Some(path) = find_named(&path, name) + { + return Some(path); + } + } + None +} + +#[test] +fn configure_get_and_unconfigure_preserve_docker_configuration() { + let fixture = Fixture::new(); + let original = read_json(&fixture.docker_config); + + let output = fixture.configure("ghcr.io"); + assert_success("docker configure", &output); + let configured = read_json(&fixture.docker_config); + assert_eq!(configured["credHelpers"]["ghcr.io"], "secretspec"); + assert_eq!(configured["credHelpers"]["existing.example.com"], "pass"); + assert_eq!(configured["credsStore"], original["credsStore"]); + assert_eq!(configured["auths"], original["auths"]); + assert_eq!(configured["plugins"], original["plugins"]); + + let output = fixture.helper("get", b"ghcr.io\n"); + assert_success("docker credential get", &output); + let response: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(response["Username"], "registry-user"); + assert_eq!(response["Secret"], "token=value"); + + let output = fixture + .secretspec() + .args(["docker", "unconfigure", "--registry", "ghcr.io", "--yes"]) + .output() + .unwrap(); + assert_success("docker unconfigure", &output); + assert_eq!(read_json(&fixture.docker_config), original); + + let output = fixture.helper("get", b"ghcr.io\n"); + assert!(!output.status.success()); + assert_eq!( + String::from_utf8(output.stdout).unwrap().trim(), + "credentials not found in native keychain" + ); +} + +#[test] +fn embedded_credentials_are_isolated_by_docker_configuration() { + let fixture = Fixture::new(); + let alternate_docker = fixture.root.join("alternate-docker"); + fs::create_dir(&alternate_docker).unwrap(); + fs::write(alternate_docker.join("config.json"), "{}\n").unwrap(); + let store = fixture.root.join("multi-config.env"); + let provider = format!("dotenv://{}", store.display()); + + let output = fixture + .embedded_secretspec() + .args([ + "docker", + "configure", + "--registry", + "ghcr.io", + "--username", + "work-user", + "--provider", + &provider, + "--yes", + ]) + .output() + .unwrap(); + assert_success("work Docker configure", &output); + let output = command_with_stdin( + fixture.embedded_secretspec(), + &["docker", "login", "ghcr.io", "--provider", &provider], + b"work-token\n", + ); + assert_success("work Docker login", &output); + + let mut alternate = fixture.embedded_secretspec(); + alternate.env("DOCKER_CONFIG", &alternate_docker); + let output = alternate + .args([ + "docker", + "configure", + "--registry", + "ghcr.io", + "--username", + "personal-user", + "--provider", + &provider, + "--yes", + ]) + .output() + .unwrap(); + assert_success("personal Docker configure", &output); + let mut alternate = fixture.embedded_secretspec(); + alternate.env("DOCKER_CONFIG", &alternate_docker); + let output = command_with_stdin( + alternate, + &["docker", "login", "ghcr.io", "--provider", &provider], + b"personal-token\n", + ); + assert_success("personal Docker login", &output); + + let work = fixture.helper("get", b"ghcr.io\n"); + assert_success("work Docker get", &work); + let work: Value = serde_json::from_slice(&work.stdout).unwrap(); + assert_eq!(work["Username"], "work-user"); + assert_eq!(work["Secret"], "work-token"); + + let personal = fixture.helper_with_docker_config("get", b"ghcr.io\n", &alternate_docker); + assert_success("personal Docker get", &personal); + let personal: Value = serde_json::from_slice(&personal.stdout).unwrap(); + assert_eq!(personal["Username"], "personal-user"); + assert_eq!(personal["Secret"], "personal-token"); +} + +#[test] +fn embedded_credentials_ignore_the_cwd_manifest_and_isolate_each_registry() { + let fixture = Fixture::new(); + let store = fixture.root.join("docker-credentials.env"); + let provider = format!("dotenv://{}", store.display()); + + let output = fixture + .embedded_secretspec() + .args([ + "docker", + "configure", + "--registry", + "ghcr.io", + "--username", + "github-user", + "--provider", + &provider, + "--yes", + ]) + .output() + .unwrap(); + assert_success("embedded GHCR configure", &output); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(stdout.contains("secretspec docker login 'ghcr.io'")); + assert!(!stdout.contains("SecretSpec manifest:")); + + let output = command_with_stdin( + fixture.embedded_secretspec(), + &["docker", "login", "ghcr.io", "--provider", &provider], + b"github-token\n", + ); + assert_success("embedded GHCR login", &output); + + let output = fixture + .embedded_secretspec() + .args([ + "docker", + "configure", + "--registry", + "registry.example.com:5000", + "--username", + "private-user", + "--provider", + &provider, + "--yes", + ]) + .output() + .unwrap(); + assert_success("embedded private registry configure", &output); + let output = command_with_stdin( + fixture.embedded_secretspec(), + &[ + "docker", + "login", + "registry.example.com:5000", + "--provider", + &provider, + ], + b"private-token\n", + ); + assert_success("embedded private registry login", &output); + + let github = fixture.helper("get", b"ghcr.io\n"); + assert_success("embedded GHCR get", &github); + let github: Value = serde_json::from_slice(&github.stdout).unwrap(); + assert_eq!(github["Username"], "github-user"); + assert_eq!(github["Secret"], "github-token"); + + let private = fixture.helper("get", b"registry.example.com:5000\n"); + assert_success("embedded private registry get", &private); + let private: Value = serde_json::from_slice(&private.stdout).unwrap(); + assert_eq!(private["Username"], "private-user"); + assert_eq!(private["Secret"], "private-token"); + + let output = fixture + .embedded_secretspec() + .args(["docker", "logout", "ghcr.io", "--provider", &provider]) + .output() + .unwrap(); + assert_success("embedded GHCR logout", &output); + assert!(!fixture.helper("get", b"ghcr.io\n").status.success()); + let private = fixture.helper("get", b"registry.example.com:5000\n"); + assert_success("private registry remains after GHCR logout", &private); +} + +#[test] +fn embedded_and_custom_manifest_options_cannot_be_mixed() { + let fixture = Fixture::new(); + let output = fixture + .embedded_secretspec() + .args([ + "docker", + "configure", + "--registry", + "ghcr.io", + "--username", + "registry-user", + "--token-secret", + "DOCKER_TOKEN", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("--token-secret") && stderr.contains("require --file")); + + let output = fixture + .secretspec() + .args(["docker", "login", "ghcr.io"]) + .output() + .unwrap(); + assert!(!output.status.success()); + assert!( + String::from_utf8_lossy(&output.stderr) + .contains("manages the embedded Docker credential store") + ); +} + +#[cfg(unix)] +#[test] +fn custom_manifest_symlink_keeps_relative_extends_working() { + use std::os::unix::fs::symlink; + + let fixture = Fixture::new(); + let project = fixture.root.join("project"); + fs::create_dir(&project).unwrap(); + let shared = project.join("shared"); + fs::create_dir(&shared).unwrap(); + fs::write( + shared.join("secretspec.toml"), + r#" +[project] +name = "docker-helper-shared" +revision = "1.0" +require_reason = false + +[profiles.default] +SYMLINK_TOKEN = { description = "Symlink token", default = "symlink-token", providers = ["null"] } +"#, + ) + .unwrap(); + + let target = fixture.root.join("manifest-target.toml"); + fs::write( + &target, + r#" +[project] +name = "docker-helper-symlink" +revision = "1.0" +require_reason = false +extends = ["shared"] + +[profiles.default] +"#, + ) + .unwrap(); + let manifest_link = project.join("linked-secretspec.toml"); + symlink(&target, &manifest_link).unwrap(); + + let output = fixture + .embedded_secretspec() + .arg("--file") + .arg(&manifest_link) + .args([ + "docker", + "configure", + "--registry", + "symlink.example.com", + "--token-secret", + "SYMLINK_TOKEN", + "--username", + "registry-user", + "--provider", + "null", + "--yes", + ]) + .output() + .unwrap(); + assert_success("symlinked manifest configure", &output); + + let output = fixture.helper("get", b"symlink.example.com\n"); + assert_success("symlinked manifest credential get", &output); + let response: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(response["Secret"], "symlink-token"); +} + +#[test] +fn exported_variables_do_not_become_durable_docker_configuration() { + let fixture = Fixture::new(); + let store = format!("file://{}", fixture.root.join("ambient-store").display()); + let manifest = fixture.manifest.to_str().unwrap(); + let ambient = [ + ("SECRETSPEC_FILE", manifest), + ("SECRETSPEC_PROFILE", "production"), + ("SECRETSPEC_PROVIDER", store.as_str()), + ("SECRETSPEC_REASON", "deploy frontend"), + ]; + + let mut command = fixture.embedded_secretspec(); + command.envs(ambient); + let output = command + .args([ + "docker", + "configure", + "--registry", + "ghcr.io", + "--username", + "registry-user", + "--yes", + ]) + .output() + .unwrap(); + assert_success("ambient docker configure", &output); + assert!( + String::from_utf8_lossy(&output.stdout).contains("SECRETSPEC_PROVIDER was not recorded") + ); + + let state_path = find_named(&fixture.root, "docker-credentials.json").unwrap(); + let state = read_json(&state_path); + let credential = &state["credentials"][0]; + assert!(credential["provider"].is_null()); + assert!(credential["reason"].is_null()); + assert_eq!(credential["source"]["kind"], "embedded"); + + let mut command = fixture.embedded_secretspec(); + command.envs(ambient); + let output = command_with_stdin( + command, + &["docker", "login", "ghcr.io"], + b"registry-token\n", + ); + assert_success("ambient docker login", &output); + + let mut command = fixture.embedded_secretspec(); + command.envs(ambient); + let output = command + .args(["docker", "logout", "ghcr.io"]) + .output() + .unwrap(); + assert_success("ambient docker logout", &output); + + let mut command = fixture.embedded_secretspec(); + command + .env("SECRETSPEC_PROVIDER", "ignored") + .env("SECRETSPEC_REASON", "ignored"); + let output = command + .args([ + "docker", + "configure", + "--registry", + "ghcr.io", + "--username", + "registry-user", + "--provider", + "null", + "--reason", + "team onboarding", + "--yes", + ]) + .output() + .unwrap(); + assert_success("typed Docker overrides", &output); + let state = read_json(&state_path); + let credential = &state["credentials"][0]; + assert_eq!(credential["provider"], "null"); + assert_eq!(credential["reason"], "team onboarding"); + + let output = fixture + .embedded_secretspec() + .args([ + "docker", + "configure", + "--registry", + "ghcr.io", + "--username", + "registry-user", + "--profile", + "production", + "--yes", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("require --file")); +} + +#[test] +fn audit_context_does_not_report_the_secretspec_version_as_docker() { + let fixture = Fixture::new(); + let store = fixture.root.join("audit.env"); + let provider = format!("dotenv://{}", store.display()); + let output = fixture + .embedded_secretspec() + .args([ + "docker", + "configure", + "--registry", + "ghcr.io", + "--username", + "registry-user", + "--provider", + &provider, + "--yes", + ]) + .output() + .unwrap(); + assert_success("audit Docker configure", &output); + + let output = command_with_stdin( + fixture.embedded_secretspec(), + &["docker", "login", "ghcr.io", "--provider", &provider], + b"registry-token\n", + ); + assert_success("audit Docker login", &output); + let output = fixture.helper("get", b"ghcr.io\n"); + assert_success("audit Docker get", &output); + + let audit_path = find_named(&fixture.root, "audit.log").unwrap(); + let audit = fs::read_to_string(audit_path).unwrap(); + let events: Vec = audit + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + for operation in ["credential_login", "credential_get"] { + let event = events + .iter() + .find(|event| event["caller"]["operation"] == operation) + .unwrap(); + assert_eq!(event["caller"]["name"], "docker"); + assert_eq!(event["caller"]["resource"], "ghcr.io"); + assert!(event["caller"].get("version").is_none()); + assert!(event["version"].is_string()); + } +} + +#[test] +fn repeated_configuration_is_idempotent() { + let fixture = Fixture::new(); + assert_success("first docker configure", &fixture.configure("ghcr.io")); + let configured = fs::read(&fixture.docker_config).unwrap(); + + let output = fixture.configure("ghcr.io"); + assert_success("second docker configure", &output); + assert!(String::from_utf8_lossy(&output.stdout).contains("already configured")); + assert_eq!(fs::read(&fixture.docker_config).unwrap(), configured); +} + +#[test] +fn reconfiguration_reports_replaced_metadata_without_removing_the_secret() { + let fixture = Fixture::new(); + let store = fixture.root.join("replacement-store"); + let provider = format!("file://{}", store.display()); + let output = fixture + .embedded_secretspec() + .args([ + "docker", + "configure", + "--registry", + "ghcr.io", + "--username", + "old-user", + "--provider", + &provider, + "--yes", + ]) + .output() + .unwrap(); + assert_success("initial Docker configure", &output); + let output = command_with_stdin( + fixture.embedded_secretspec(), + &["docker", "login", "ghcr.io", "--provider", &provider], + b"stored-token\n", + ); + assert_success("Docker login", &output); + + let output = fixture + .embedded_secretspec() + .args([ + "docker", + "configure", + "--registry", + "ghcr.io", + "--username", + "new-user", + "--provider", + &provider, + "--yes", + ]) + .output() + .unwrap(); + assert_success("replacement Docker configure", &output); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Replaced the previous SecretSpec configuration")); + assert!(stdout.contains("No stored credential was removed")); + + let output = fixture.helper("get", b"ghcr.io\n"); + assert_success("Docker get after replacement", &output); + let credential: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(credential["Username"], "new-user"); + assert_eq!(credential["Secret"], "stored-token"); +} + +#[test] +fn unconfigure_all_removes_only_managed_registry_helpers() { + let fixture = Fixture::new(); + let original = read_json(&fixture.docker_config); + assert_success("first docker configure", &fixture.configure("ghcr.io")); + assert_success( + "second docker configure", + &fixture.configure("registry.example.com:5000"), + ); + + let output = fixture + .secretspec() + .args(["docker", "unconfigure", "--all", "--yes"]) + .output() + .unwrap(); + assert_success("docker unconfigure --all", &output); + assert_eq!(read_json(&fixture.docker_config), original); +} + +#[test] +fn configure_refuses_to_replace_an_existing_registry_helper() { + let fixture = Fixture::new(); + let mut config = read_json(&fixture.docker_config); + config["credHelpers"]["ghcr.io"] = Value::String("pass".to_string()); + fs::write( + &fixture.docker_config, + serde_json::to_vec_pretty(&config).unwrap(), + ) + .unwrap(); + let original = fs::read(&fixture.docker_config).unwrap(); + + let output = fixture.configure("ghcr.io"); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("already uses credential helper 'pass'")); + assert!(stderr.contains("refusing")); + assert_eq!(fs::read(&fixture.docker_config).unwrap(), original); +} + +#[test] +fn unconfigure_refuses_to_remove_an_externally_changed_helper() { + let fixture = Fixture::new(); + assert_success("docker configure", &fixture.configure("ghcr.io")); + let mut config = read_json(&fixture.docker_config); + config["credHelpers"]["ghcr.io"] = Value::String("pass".to_string()); + fs::write( + &fixture.docker_config, + serde_json::to_vec_pretty(&config).unwrap(), + ) + .unwrap(); + let changed = fs::read(&fixture.docker_config).unwrap(); + + let output = fixture + .secretspec() + .args(["docker", "unconfigure", "--registry", "ghcr.io", "--yes"]) + .output() + .unwrap(); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("changed to 'pass'")); + assert_eq!(fs::read(&fixture.docker_config).unwrap(), changed); +} + +#[test] +fn unconfigure_recovers_after_the_helper_entry_was_already_removed() { + let fixture = Fixture::new(); + assert_success("docker configure", &fixture.configure("ghcr.io")); + let state_path = find_named(&fixture.root, "docker-credentials.json").unwrap(); + + let mut config = read_json(&fixture.docker_config); + config["credHelpers"] + .as_object_mut() + .unwrap() + .remove("ghcr.io"); + fs::write( + &fixture.docker_config, + serde_json::to_vec_pretty(&config).unwrap(), + ) + .unwrap(); + + let output = fixture + .secretspec() + .args(["docker", "unconfigure", "--registry", "ghcr.io", "--yes"]) + .output() + .unwrap(); + assert_success("recovering docker unconfigure", &output); + assert!(!state_path.exists()); + assert_eq!(read_json(&fixture.docker_config), config); +} + +#[cfg(unix)] +#[test] +fn managed_state_is_owner_only_and_preserves_a_symlink() { + use std::os::unix::fs::{PermissionsExt, symlink}; + + let fixture = Fixture::new(); + let docker_mode = fs::metadata(&fixture.docker_config) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_success("first docker configure", &fixture.configure("ghcr.io")); + let state_path = find_named(&fixture.root, "docker-credentials.json").unwrap(); + assert_eq!( + fs::metadata(&state_path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + assert_eq!( + fs::metadata(&fixture.docker_config) + .unwrap() + .permissions() + .mode() + & 0o777, + docker_mode + ); + + fs::set_permissions(&state_path, fs::Permissions::from_mode(0o644)).unwrap(); + let target = state_path.with_file_name("actual-docker-credentials.json"); + fs::rename(&state_path, &target).unwrap(); + symlink(&target, &state_path).unwrap(); + + assert_success( + "second docker configure", + &fixture.configure("registry.example.com:5000"), + ); + assert!( + fs::symlink_metadata(&state_path) + .unwrap() + .file_type() + .is_symlink() + ); + assert_eq!( + fs::metadata(&target).unwrap().permissions().mode() & 0o777, + 0o600 + ); + assert_eq!( + read_json(&target)["credentials"].as_array().unwrap().len(), + 2 + ); + + let output = fixture + .secretspec() + .args(["docker", "unconfigure", "--all", "--yes"]) + .output() + .unwrap(); + assert_success("symlinked state unconfigure all", &output); + assert!( + fs::symlink_metadata(&state_path) + .unwrap() + .file_type() + .is_symlink() + ); + assert!( + read_json(&target)["credentials"] + .as_array() + .unwrap() + .is_empty() + ); + + assert_success( + "configure after symlinked state cleanup", + &fixture.configure("ghcr.io"), + ); +} + +#[test] +fn non_interactive_configuration_requires_confirmation() { + let fixture = Fixture::new(); + let original = fs::read(&fixture.docker_config).unwrap(); + let output = fixture + .secretspec() + .args([ + "docker", + "configure", + "--registry", + "ghcr.io", + "--token-secret", + "DOCKER_TOKEN", + "--username", + "registry-user", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("pass --yes")); + assert_eq!(fs::read(&fixture.docker_config).unwrap(), original); +} + +#[cfg(unix)] +#[test] +fn configuration_preserves_a_symlinked_docker_config() { + use std::os::unix::fs::symlink; + + let fixture = Fixture::new(); + let target = fixture.docker_config.with_file_name("actual-config.json"); + fs::rename(&fixture.docker_config, &target).unwrap(); + symlink(&target, &fixture.docker_config).unwrap(); + let original = read_json(&target); + + assert_success("docker configure", &fixture.configure("ghcr.io")); + assert!( + fs::symlink_metadata(&fixture.docker_config) + .unwrap() + .file_type() + .is_symlink() + ); + assert_eq!(read_json(&target)["credHelpers"]["ghcr.io"], "secretspec"); + + let output = fixture + .secretspec() + .args(["docker", "unconfigure", "--registry", "ghcr.io", "--yes"]) + .output() + .unwrap(); + assert_success("docker unconfigure", &output); + assert!( + fs::symlink_metadata(&fixture.docker_config) + .unwrap() + .file_type() + .is_symlink() + ); + assert_eq!(read_json(&target), original); +} + +#[cfg(unix)] +#[test] +fn configuration_identity_resolves_a_symlinked_parent_directory() { + use std::os::unix::fs::symlink; + + let fixture = Fixture::new(); + let original = read_json(&fixture.docker_config); + let docker_link = fixture.root.join("docker-link"); + symlink(fixture.root.join("docker"), &docker_link).unwrap(); + + let mut command = fixture.secretspec(); + command.env("DOCKER_CONFIG", &docker_link); + let output = command + .args([ + "docker", + "configure", + "--registry", + "ghcr.io", + "--token-secret", + "DOCKER_TOKEN", + "--username-secret", + "DOCKER_USERNAME", + "--provider", + "null", + "--yes", + ]) + .output() + .unwrap(); + assert_success("symlinked parent Docker configure", &output); + + let output = fixture.helper("get", b"ghcr.io\n"); + assert_success("real parent Docker get", &output); + let output = fixture.helper_with_docker_config("get", b"ghcr.io\n", &docker_link); + assert_success("symlinked parent Docker get", &output); + + let mut command = fixture.secretspec(); + command.env("DOCKER_CONFIG", &docker_link); + let output = command + .args(["docker", "unconfigure", "--registry", "ghcr.io", "--yes"]) + .output() + .unwrap(); + assert_success("symlinked parent Docker unconfigure", &output); + assert_eq!(read_json(&fixture.docker_config), original); +} + +#[test] +fn helper_is_read_only() { + let fixture = Fixture::new(); + for (operation, input) in [ + ( + "store", + br#"{"ServerURL":"ghcr.io","Username":"user","Secret":"secret"}"#.as_slice(), + ), + ("erase", b"ghcr.io".as_slice()), + ] { + let output = fixture.helper(operation, input); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stdout).contains("read-only")); + } +}