194 lines
7.1 KiB
Rust
194 lines
7.1 KiB
Rust
//! Integration coverage for `hive-forge credential-helper get` — the
|
|
//! `host=` check in `verbs::credential_helper::run` exists to close a
|
|
//! reported credential leak, and git invokes the helper fresh over
|
|
//! stdin/env on every fetch/push. The check is inlined in `run()`
|
|
//! (real stdin, real env, real stdout), so it can only be driven the way
|
|
//! git actually drives it: run the compiled binary.
|
|
|
|
use std::io::Write as _;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::{Command, Stdio};
|
|
|
|
const TOKEN: &str = "test-token-value";
|
|
|
|
/// A unique scratch `HYPERHIVE_STATE_DIR`, seeded with a `forge-token`
|
|
/// file. Built by hand under the system temp dir, since this crate carries no
|
|
/// `tempfile` dependency to reach for instead.
|
|
fn scratch_state_dir(tag: &str) -> PathBuf {
|
|
let ts = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map_or(0, |d| d.as_nanos());
|
|
let dir = std::env::temp_dir().join(format!("hive-forge-cred-test-{tag}-{ts}"));
|
|
std::fs::create_dir_all(&dir).expect("create scratch state dir");
|
|
std::fs::write(dir.join("forge-token"), format!("{TOKEN}\n")).expect("seed forge-token");
|
|
dir
|
|
}
|
|
|
|
/// Run `hive-forge credential-helper get` against `base_url` +
|
|
/// `state_dir`, feeding `request` as the git credential-protocol
|
|
/// request body on stdin. Returns `(exit success, stdout, stderr)`.
|
|
fn run_get(base_url: &str, state_dir: &Path, request: &str) -> (bool, String, String) {
|
|
run_get_with(base_url, state_dir, None, request)
|
|
}
|
|
|
|
/// [`run_get`], with `HIVE_FORGE_TOKEN_FILE` set to `fetched` when given and
|
|
/// removed otherwise, so the caller's own environment never leaks in.
|
|
fn run_get_with(
|
|
base_url: &str,
|
|
state_dir: &Path,
|
|
fetched: Option<&Path>,
|
|
request: &str,
|
|
) -> (bool, String, String) {
|
|
let mut cmd = Command::new(env!("CARGO_BIN_EXE_hive-forge"));
|
|
match fetched {
|
|
Some(p) => cmd.env("HIVE_FORGE_TOKEN_FILE", p),
|
|
None => cmd.env_remove("HIVE_FORGE_TOKEN_FILE"),
|
|
};
|
|
let mut child = cmd
|
|
.arg("credential-helper")
|
|
.arg("get")
|
|
.env("HIVE_FORGE_URL", base_url)
|
|
.env("HYPERHIVE_STATE_DIR", state_dir)
|
|
.env_remove("HIVE_LABEL")
|
|
.stdin(Stdio::piped())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.spawn()
|
|
.expect("spawn hive-forge credential-helper get");
|
|
child
|
|
.stdin
|
|
.take()
|
|
.expect("piped stdin")
|
|
.write_all(request.as_bytes())
|
|
.expect("write credential request to stdin");
|
|
let out = child.wait_with_output().expect("wait for hive-forge");
|
|
(
|
|
out.status.success(),
|
|
String::from_utf8_lossy(&out.stdout).into_owned(),
|
|
String::from_utf8_lossy(&out.stderr).into_owned(),
|
|
)
|
|
}
|
|
|
|
/// This is the check the module doc says exists to close a reported
|
|
/// credential leak: refusing to hand the forge token to a host git
|
|
/// didn't ask this invocation to serve. If the host comparison were ever
|
|
/// inverted, a mismatched host would get the token instead of being
|
|
/// refused.
|
|
#[test]
|
|
fn credential_helper_get_refuses_a_mismatched_host_and_withholds_the_token() {
|
|
let dir = scratch_state_dir("mismatch");
|
|
let (ok, stdout, stderr) = run_get(
|
|
"http://forge.internal.example",
|
|
&dir,
|
|
"protocol=https\nhost=not-the-forge.example\n",
|
|
);
|
|
assert!(!ok, "a mismatched host= must fail, stderr={stderr}");
|
|
assert!(
|
|
!stdout.contains(TOKEN),
|
|
"the token must never reach stdout on a host mismatch: stdout={stdout}"
|
|
);
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
/// A configured forge URL that doesn't parse must refuse like a genuine
|
|
/// mismatch, not silently skip the check because there was nothing to
|
|
/// compare against — the previous shape failed open exactly where this
|
|
/// check exists to close a reported credential leak.
|
|
#[test]
|
|
fn credential_helper_get_refuses_when_the_configured_forge_url_does_not_parse() {
|
|
let dir = scratch_state_dir("unparseable");
|
|
let (ok, stdout, stderr) = run_get(
|
|
"this is not a url",
|
|
&dir,
|
|
"protocol=https\nhost=not-the-forge.example\n",
|
|
);
|
|
assert!(
|
|
!ok,
|
|
"an unparseable configured URL must fail, stderr={stderr}"
|
|
);
|
|
assert!(
|
|
!stdout.contains(TOKEN),
|
|
"the token must never reach stdout when the forge URL won't parse: stdout={stdout}"
|
|
);
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
/// The other side of the same check: a `host=` that matches the
|
|
/// configured forge must still print the credentials, or every real git
|
|
/// fetch/push would break.
|
|
#[test]
|
|
fn credential_helper_get_answers_when_host_matches() {
|
|
let dir = scratch_state_dir("match");
|
|
let (ok, stdout, stderr) = run_get(
|
|
"http://forge.internal.example",
|
|
&dir,
|
|
"protocol=https\nhost=forge.internal.example\n",
|
|
);
|
|
assert!(ok, "a matching host= must succeed, stderr={stderr}");
|
|
assert!(stdout.contains("username="), "stdout={stdout}");
|
|
assert!(
|
|
stdout.contains(&format!("password={TOKEN}")),
|
|
"stdout={stdout}"
|
|
);
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
/// Backwards compatibility: the request shape git sent before this check
|
|
/// existed — no `host=` line at all — must still work.
|
|
#[test]
|
|
fn credential_helper_get_still_works_with_no_host_line() {
|
|
let dir = scratch_state_dir("nohost");
|
|
let (ok, stdout, stderr) = run_get("http://forge.internal.example", &dir, "protocol=https\n");
|
|
assert!(
|
|
ok,
|
|
"a request with no host= line must not be treated as a failure, stderr={stderr}"
|
|
);
|
|
assert!(
|
|
stdout.contains(&format!("password={TOKEN}")),
|
|
"stdout={stdout}"
|
|
);
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
/// The token the agent fetched from the swarm secret store wins over the
|
|
/// state-dir file the hive used to write, so a rotated token takes effect
|
|
/// even while the old file is still on disk.
|
|
#[test]
|
|
fn the_fetched_token_wins_over_the_state_file() {
|
|
let dir = scratch_state_dir("fetched");
|
|
let fetched = dir.join("fetched-token");
|
|
std::fs::write(&fetched, "fetched-token-value\n").expect("seed fetched token");
|
|
let (ok, stdout, stderr) = run_get_with(
|
|
"http://forge.internal.example",
|
|
&dir,
|
|
Some(&fetched),
|
|
"protocol=https\nhost=forge.internal.example\n",
|
|
);
|
|
assert!(ok, "stderr={stderr}");
|
|
assert!(
|
|
stdout.contains("password=fetched-token-value"),
|
|
"stdout={stdout}"
|
|
);
|
|
assert!(!stdout.contains(TOKEN), "stdout={stdout}");
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
/// An agent whose token was not minted yet has the variable set and no file
|
|
/// behind it; it keeps working off the state-dir file.
|
|
#[test]
|
|
fn a_fetched_token_that_is_not_there_falls_back_to_the_state_file() {
|
|
let dir = scratch_state_dir("fallback");
|
|
let missing = dir.join("never-fetched");
|
|
let (ok, stdout, stderr) = run_get_with(
|
|
"http://forge.internal.example",
|
|
&dir,
|
|
Some(&missing),
|
|
"protocol=https\nhost=forge.internal.example\n",
|
|
);
|
|
assert!(ok, "stderr={stderr}");
|
|
assert!(
|
|
stdout.contains(&format!("password={TOKEN}")),
|
|
"stdout={stdout}"
|
|
);
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|