Skip to content

Integration: git credentials - #329

Open
vimjoyer wants to merge 3 commits into
mainfrom
integration-git-credentials
Open

Integration: git credentials#329
vimjoyer wants to merge 3 commits into
mainfrom
integration-git-credentials

Conversation

@vimjoyer

Copy link
Copy Markdown
Member

No description provided.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 12, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
secretspec 088fe40 Commit Preview URL

Branch Preview URL
Aug 19 2026, 11:36 AM

@domenkozar

Copy link
Copy Markdown
Member

This looks great. One UX change I would like before merging: make a small Git-specific secretspec.toml embedded in the binary the default, while keeping --file as an explicit override.

At the moment the helper does:

match &args.file {
    Some(path) => Secrets::load_from(path),
    None => Secrets::load(),
}

I would prefer:

match &args.file {
    Some(path) => Secrets::load_from(path),
    None => Secrets::load_embedded_git_credentials(),
}

In particular, the helper should not walk the current directory when no file was selected. That makes the default deterministic for clone, fetch, push, and use outside a repository.

The embedded manifest would contain declarations, not values—something like an optional USERNAME and required PASSWORD/token—and credential storage should be isolated by the canonical Git credential context (protocol, host, and path when enabled), so different hosts cannot share a value accidentally.

The resulting UX would be:

$ secretspec git configure --url https://github.com --username USER

with no manifest path recorded. Advanced/custom setups would retain the current behavior:

$ secretspec --file company-git.toml git configure \
    --url https://github.com \
    --token-secret GITHUB_TOKEN

Because the helper is intentionally read-only, the embedded default also needs an explicit way to populate and remove its values, e.g. secretspec git login <url> and secretspec git logout <url>. Automatic Git store/erase callbacks can remain ignored so a rejected authentication attempt cannot overwrite or delete a shared-provider value.

I would add coverage for both important cases: no --file uses the embedded manifest even when the CWD contains an unrelated project manifest, and explicit --file takes precedence over the embedded one.

@domenkozar

Copy link
Copy Markdown
Member

One additional integration worth supporting is SMTP credentials for git send-email.

Git already uses the credential-helper protocol when sendemail.smtpUser is set and sendemail.smtpPass is omitted. Its request is effectively:

protocol=smtp
host=smtp.example.com:587
username=user@example.com

See git-send-email.perl and the git send-email documentation.

A minimal implementation could:

  • accept smtp://host[:port] as a credential target and match protocol=smtp;
  • register the helper under credential.smtp://host[:port].helper;
  • keep the existing read-only behavior: answer get, ignore store and erase;
  • include the request username as well as protocol, host, and port in the embedded credential's storage identity, so accounts on the same SMTP server cannot share a password accidentally;
  • leave transport settings in Git's normal sendemail.* configuration and never write sendemail.smtpPass. The docs should stress that smtp is only Git's credential-context name; encryption is controlled separately by sendemail.smtpEncryption=tls|ssl;
  • test exact server/port/username matching and rejection of HTTP(S) or another SMTP account.

For example, the setup could look like:

$ git config --global sendemail.smtpServer smtp.example.com
$ git config --global sendemail.smtpServerPort 587
$ git config --global sendemail.smtpEncryption tls
$ git config --global sendemail.smtpUser user@example.com
$ secretspec git configure --url smtp://smtp.example.com:587 --username user@example.com --global
$ secretspec git login smtp://smtp.example.com:587

The explicit sendemail.smtpUser is important: without it, git send-email does not attempt SMTP authentication or query credential helpers.

@vimjoyer
vimjoyer force-pushed the integration-git-credentials branch from 8456ed4 to 394c2ec Compare August 13, 2026 12:11
@vimjoyer
vimjoyer marked this pull request as ready for review August 13, 2026 13:01
@domenkozar
domenkozar force-pushed the integration-git-credentials branch from a544f0e to 7d31cb9 Compare August 17, 2026 15:32
@domenkozar
domenkozar force-pushed the integration-git-credentials branch from 7d31cb9 to 3441d3d Compare August 17, 2026 18:44

@domenkozar domenkozar left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review at effort high over the full diff: every hunk of secretspec/src/integration/git.rs and secretspec/src/cli/git.rs, the CLI and lib wiring, both new test files, and the docs. cargo check --all-targets is clean and the 15 new tests pass.

The security critical paths hold up under probing: validate_target, canonical_target and embedded_identity, target_matches (path prefix boundaries and SMTP username scoping), Request::apply_url replacing every field, and the managed file ensure_unchanged plus rollback sequencing. I checked unicode, space, percent encoded, and double slash paths against real git config --get-urlmatch behaviour, including the cases where git itself over matches and the helper's independent URL re check is what saves it.

Six inline comments below. The two I am most confident about are the ambient SECRETSPEC_PROFILE leaking into the persisted helper command, and git login writing to a provider the helper will not read from.

Posted by Claude Code on behalf of @domenkozar; see cli/cli#13904 for why the GitHub API cannot attribute this itself.

Comment thread secretspec/src/cli/git.rs
Comment on lines +243 to +246
if let Some(profile) = &options.profile {
secrets.set_profile(profile);
}
let profile = secrets.resolve_profile_name(None);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An ambient SECRETSPEC_PROFILE gets baked into the Git helper.

git configure's profile arg carries env = "SECRETSPEC_PROFILE", and unlike file (gated on typed.file at L166) it is passed through raw, so the profile resolved here is written permanently into the helper command at L302.

That contradicts the comment at L283 (only options the user typed belong in it) and the embedded branch below, which rejects a typed --profile outright. With SECRETSPEC_PROFILE=production exported:

secretspec --file company.toml git configure --url https://github.com --token-secret GITHUB_TOKEN

writes --profile 'production' into credential.https://github.com.helper, so every later git fetch from any shell resolves the production profile.

exported_variables_neither_block_commands_nor_reach_git_configuration (tests/git_configure.rs:670) asserts no --profile leaks, but it exercises the embedded path where profile is always None, so the --file branch is uncovered. Gating on options.typed.profile the way provider and reason already are would make the three consistent.

Comment thread secretspec/src/cli/git.rs
Comment on lines +383 to +393
let mut login = format!("secretspec git login {}", shell_quote(&target));
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 Git helper; pass --provider to pin it."
);
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

git login can write to a different provider than the helper reads from.

This warning correctly flags that an ambient SECRETSPEC_PROVIDER was not recorded in the helper, but the secretspec git login command printed just above carries no --provider, and login does honour the ambient variable through embedded_cli_secrets (L414).

So in one shell with SECRETSPEC_PROVIDER set: login stores PASSWORD_<id> into the ambient provider while the helper reads the default provider. git fetch then finds nothing, and no step along the way reports an error.

tests/git_configure.rs:707 runs exactly this sequence (its ambient array points at file://.../ambient-store) and only asserts success, so the mismatch is currently exercised and accepted. Emitting the same "not recorded" note from login and logout would close the loop.

Comment on lines +3 to +5
fn main() -> Result<()> {
secretspec::integration::git::main()
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing the SIGPIPE default disposition restore.

secretspec.rs gained this in 25b752a (landed on main after this branch's base), so the two entry points diverge once this merges. Rust ignores SIGPIPE, so an early close of the helper's stdout, for example git-credential-secretspec ... get | head while debugging, or a reader that stops at the blank terminator, surfaces Broken pipe (os error 32) or a stdout panic instead of the quiet signal 13 exit the project just standardized on.

The same four libc::signal lines apply here.

Comment thread secretspec/src/cli/git.rs
Comment on lines +787 to +798
fn add_include(scope: Scope, path: &Path) -> Result<()> {
run_git(
[
"config".into(),
scope.git_arg().into(),
"--add".into(),
"include.path".into(),
path.as_os_str().into(),
],
"Failed to register the SecretSpec Git configuration",
)
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For Scope::Local this could register a relative include.path.

managed_path returns $GIT_COMMON_DIR/secretspec-credentials, which is always a sibling of the config file that includes it, and Git resolves a relative include.path against the including file's own directory. Registering the dunce::canonicalized absolute path instead means that renaming or moving the repository, or reaching it through a different symlink, leaves a dangling include: the helper silently stops being invoked, and unconfigure --all then recomputes the new path, deletes the file, and leaves the stale entry in .git/config for good.

A bare secretspec-credentials for the local scope survives the move. Global scope has no equivalent anchor, so it still needs the absolute path.

Comment thread secretspec/src/cli/git.rs
Comment on lines +279 to +281
if let Some(provider) = &options.provider {
secrets.set_provider(provider);
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This set_provider has no effect.

secrets is never read after this point: the --file branch already consumed it at L242 to L250, and the embedded branch never uses it, since the helper gets persisted_provider instead.

The visible consequence is that secretspec git configure --url ... --provider <typo> succeeds and records --provider '<typo>' in the Git helper, with nothing surfacing until a later git fetch fails. Either validate the provider here or drop the mut binding along with the call.

Comment thread secretspec/src/cli/git.rs
Comment on lines +825 to +828
let markers = config_values(path, MARKER_KEY)?;
if markers != [FORMAT_VERSION.to_string()] {
return Err(unmanaged_file_error(path));
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An unknown format version leaves no way to unconfigure.

Any marker other than the compiled in FORMAT_VERSION is a hard error for every secretspec git subcommand in that scope, unconfigure --all included. Running a newer SecretSpec once, which writes version 2, and then falling back to an older binary, trivially easy with a devenv or nix shell on PATH, leaves hand editing Git config as the only escape.

Letting unconfigure remove an unknown but well formed managed file, or at minimum printing the manual removal steps in the error, would avoid the dead end.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants