use std::process::{Command, Output};
const GITHUB_ROTATION_URL: &str = "https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens";
const AWS_ROTATION_URL: &str = "https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html#Using_RotateAccessKey";
const STRIPE_ROTATION_URL: &str = "https://dashboard.stripe.com/apikeys";
fn explain(args: &[&str]) -> Output {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_keyhog"));
cmd.arg("explain");
for a in args {
cmd.arg(a);
}
cmd.env("NO_COLOR", "1");
cmd.output().expect("spawn keyhog explain")
}
fn stdout_of(out: &Output) -> String {
String::from_utf8_lossy(&out.stdout).into_owned()
}
fn stderr_of(out: &Output) -> String {
String::from_utf8_lossy(&out.stderr).into_owned()
}
#[test]
fn github_classic_pat_prints_exact_name_service_severity() {
let out = explain(&["github-classic-pat"]);
assert_eq!(out.status.code(), Some(0), "stderr={}", stderr_of(&out));
let s = stdout_of(&out);
assert!(
s.contains("Name: GitHub Classic PAT"),
"expected exact Name line; got:\n{s}"
);
assert!(
s.contains("Service: github"),
"expected exact Service line; got:\n{s}"
);
assert!(
s.contains("Severity: critical"),
"expected exact canonical Severity line; got:\n{s}"
);
}
#[test]
fn github_classic_pat_header_is_book_glyph_and_id() {
let out = explain(&["github-classic-pat"]);
assert_eq!(out.status.code(), Some(0));
let s = stdout_of(&out);
assert!(
s.contains("\u{1F4D6} github-classic-pat"),
"expected book-glyph header with canonical id; got:\n{s}"
);
}
#[test]
fn github_classic_pat_prints_github_rotation_url() {
let out = explain(&["github-classic-pat"]);
assert_eq!(out.status.code(), Some(0));
let s = stdout_of(&out);
assert!(
s.contains("Rotation guide for github:"),
"expected github rotation heading; got:\n{s}"
);
assert!(
s.contains(GITHUB_ROTATION_URL),
"expected exact github rotation URL; got:\n{s}"
);
}
#[test]
fn aws_access_key_prints_exact_name_service_severity() {
let out = explain(&["aws-access-key"]);
assert_eq!(out.status.code(), Some(0), "stderr={}", stderr_of(&out));
let s = stdout_of(&out);
assert!(
s.contains("Name: AWS Access Key"),
"expected exact Name line; got:\n{s}"
);
assert!(
s.contains("Service: aws"),
"expected exact Service line; got:\n{s}"
);
assert!(
s.contains("Severity: critical"),
"expected Severity::Critical; got:\n{s}"
);
assert!(
s.contains("simdsieve_prefixes: AKIA, ASIA"),
"explain must show detector-owned accelerator prefixes; got:\n{s}"
);
}
#[test]
fn generic_api_key_prints_entropy_policy_ownership_priority() {
let out = explain(&["generic-api-key"]);
assert_eq!(out.status.code(), Some(0), "stderr={}", stderr_of(&out));
let s = stdout_of(&out);
assert!(
s.contains("entropy_policy_priority: 80"),
"explain must show overlapping keyword policy ownership; got:\n{s}"
);
}
#[test]
fn aws_access_key_prints_aws_rotation_url() {
let out = explain(&["aws-access-key"]);
assert_eq!(out.status.code(), Some(0));
let s = stdout_of(&out);
assert!(
s.contains("Rotation guide for aws:"),
"expected aws rotation heading; got:\n{s}"
);
assert!(
s.contains(AWS_ROTATION_URL),
"expected exact aws rotation URL; got:\n{s}"
);
}
#[test]
fn stripe_secret_key_prints_exact_name_service_severity() {
let out = explain(&["stripe-secret-key"]);
assert_eq!(out.status.code(), Some(0), "stderr={}", stderr_of(&out));
let s = stdout_of(&out);
assert!(
s.contains("Name: Stripe Secret Key"),
"expected exact Name line; got:\n{s}"
);
assert!(
s.contains("Service: stripe"),
"expected exact Service line; got:\n{s}"
);
assert!(
s.contains("Severity: critical"),
"expected Severity::Critical; got:\n{s}"
);
}
#[test]
fn stripe_secret_key_prints_stripe_rotation_url() {
let out = explain(&["stripe-secret-key"]);
assert_eq!(out.status.code(), Some(0));
let s = stdout_of(&out);
assert!(
s.contains("Rotation guide for stripe:"),
"expected stripe rotation heading; got:\n{s}"
);
assert!(
s.contains(STRIPE_ROTATION_URL),
"expected exact stripe rotation URL; got:\n{s}"
);
}
#[test]
fn rotation_urls_are_per_service_not_a_shared_constant() {
let gh = stdout_of(&explain(&["github-classic-pat"]));
let aws = stdout_of(&explain(&["aws-access-key"]));
let stripe = stdout_of(&explain(&["stripe-secret-key"]));
assert!(gh.contains(GITHUB_ROTATION_URL), "github missing its URL");
assert!(
!gh.contains(AWS_ROTATION_URL),
"github output leaked the aws URL"
);
assert!(
!gh.contains(STRIPE_ROTATION_URL),
"github output leaked the stripe URL"
);
assert!(aws.contains(AWS_ROTATION_URL), "aws missing its URL");
assert!(
!aws.contains(GITHUB_ROTATION_URL),
"aws output leaked the github URL"
);
assert!(
!aws.contains(STRIPE_ROTATION_URL),
"aws output leaked the stripe URL"
);
assert!(
stripe.contains(STRIPE_ROTATION_URL),
"stripe missing its URL"
);
assert!(
!stripe.contains(GITHUB_ROTATION_URL),
"stripe output leaked the github URL"
);
assert!(
!stripe.contains(AWS_ROTATION_URL),
"stripe output leaked the aws URL"
);
assert_ne!(GITHUB_ROTATION_URL, AWS_ROTATION_URL);
assert_ne!(GITHUB_ROTATION_URL, STRIPE_ROTATION_URL);
assert_ne!(AWS_ROTATION_URL, STRIPE_ROTATION_URL);
}
#[test]
fn explain_prints_canonical_remediation_steps() {
let out = explain(&["stripe-secret-key"]);
assert_eq!(out.status.code(), Some(0));
let s = stdout_of(&out);
assert!(
s.contains("1. Treat the credential as compromised; assume it has been read."),
"missing remediation step 1; got:\n{s}"
);
assert!(
s.contains("2. Rotate it at the issuer (see rotation-guide URL above)."),
"missing remediation step 2; got:\n{s}"
);
}
#[test]
fn hot_id_github_fails_with_canonical_command() {
let out = explain(&["hot-github_pat"]);
assert_eq!(out.status.code(), Some(2), "stderr={}", stderr_of(&out));
let s = stderr_of(&out);
assert!(
s.contains("'hot-github_pat' is a retired detector id and is not accepted")
&& s.contains("keyhog explain github-classic-pat"),
"expected the exact canonical migration; got:\n{s}"
);
}
#[test]
fn hot_id_aws_fails_with_canonical_command() {
let out = explain(&["hot-aws_key"]);
assert_eq!(out.status.code(), Some(2), "stderr={}", stderr_of(&out));
let s = stderr_of(&out);
assert!(
s.contains("keyhog explain aws-access-key"),
"expected aws canonical migration; got:\n{s}"
);
}
#[test]
fn mixed_case_id_resolves_same_detector() {
let out = explain(&["GitHub-Classic-PAT"]);
assert_eq!(out.status.code(), Some(0), "stderr={}", stderr_of(&out));
let s = stdout_of(&out);
assert!(
s.contains("Name: GitHub Classic PAT"),
"mixed-case id must resolve to the same spec; got:\n{s}"
);
}
#[test]
fn unknown_detector_id_exits_two_and_names_it() {
let out = explain(&["zzznope-not-a-real-detector"]);
assert_eq!(
out.status.code(),
Some(2),
"unknown id must exit EXIT_USER_ERROR (2); stderr={}",
stderr_of(&out)
);
let err = stderr_of(&out);
assert!(
err.contains("no detector with id 'zzznope-not-a-real-detector'"),
"error must name the bad id; got:\n{err}"
);
assert!(
err.contains("keyhog detectors"),
"error must point at the list command; got:\n{err}"
);
}
#[test]
fn unknown_hot_label_exits_two_with_fastpath_message() {
let out = explain(&["hot-github_zzz_unmapped"]);
assert_eq!(
out.status.code(),
Some(2),
"unmapped hot label must exit 2; stderr={}",
stderr_of(&out)
);
let err = stderr_of(&out);
assert!(
err.contains("resembles a retired fast-path alias, not a current detector id"),
"expected the retired-alias branch; got:\n{err}"
);
assert!(
err.contains("github-classic-pat"),
"related-detector list must surface the github spec; got:\n{err}"
);
}
#[test]
fn ambiguous_substring_exits_two_with_suggestions() {
let out = explain(&["github"]);
assert_eq!(
out.status.code(),
Some(2),
"bare substring must exit 2; stderr={}",
stderr_of(&out)
);
let err = stderr_of(&out);
assert!(
err.contains("no detector with id 'github'. Did you mean:"),
"expected did-you-mean framing; got:\n{err}"
);
assert!(
err.contains("github-classic-pat"),
"suggestions must include the classic PAT id; got:\n{err}"
);
}