use super::*;
const LEGACY_SECRET_SEVERITY: Severity = Severity::Warning;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Severity {
Warning,
#[allow(dead_code)]
Error,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct LegacySecretFinding {
pub provider: Option<String>,
pub index: usize,
pub ids: Vec<String>,
}
pub(crate) fn find_legacy_required_secrets(source: &str) -> Vec<LegacySecretFinding> {
let Ok(document) = toml::from_str::<toml::Value>(source) else {
return Vec::new();
};
let Some(providers) = document.get("providers").and_then(toml::Value::as_array) else {
return Vec::new();
};
let mut findings = Vec::new();
for (index, provider) in providers.iter().enumerate() {
let ids: Vec<String> = provider
.get("setup")
.and_then(|setup| setup.get("required_secrets"))
.and_then(toml::Value::as_array)
.map(|entries| {
entries
.iter()
.filter_map(toml::Value::as_str)
.map(str::to_string)
.collect()
})
.unwrap_or_default();
if ids.is_empty() {
continue;
}
findings.push(LegacySecretFinding {
provider: provider
.get("id")
.and_then(toml::Value::as_str)
.map(str::to_string),
index,
ids,
});
}
findings
}
pub(crate) fn validate_required_secret_spelling(
manifest_path: &Path,
errors: &mut Vec<PackageCheckDiagnostic>,
warnings: &mut Vec<PackageCheckDiagnostic>,
) {
let Ok(source) = fs::read_to_string(manifest_path) else {
return;
};
record_findings(
LEGACY_SECRET_SEVERITY,
find_legacy_required_secrets(&source),
errors,
warnings,
);
}
fn record_findings(
severity: Severity,
findings: Vec<LegacySecretFinding>,
errors: &mut Vec<PackageCheckDiagnostic>,
warnings: &mut Vec<PackageCheckDiagnostic>,
) {
for finding in findings {
let field = match finding.provider.as_deref() {
Some(id) => format!("[providers.setup] ({id}).required_secrets"),
None => format!("[[providers]][{}].setup.required_secrets", finding.index),
};
let message = format!(
"declares {} as bare id string{}; use the typed form so each secret states its direction, \
for example `required_secrets = [{{ id = \"{}\", direction = \"outbound\" }}]`. \
A bare id is read as `direction = \"outbound\"` for compatibility with already-published packages",
finding.ids.join(", "),
if finding.ids.len() == 1 { "" } else { "s" },
finding.ids.first().map(String::as_str).unwrap_or("provider/secret"),
);
match severity {
Severity::Error => push_error(errors, field, message),
Severity::Warning => push_warning(warnings, field, message),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const LEGACY_MANIFEST: &str = r#"
[[providers]]
id = "github"
[providers.setup]
required_secrets = ["github/app-private-key", "github/webhook-secret"]
"#;
const TYPED_MANIFEST: &str = r#"
[[providers]]
id = "github"
[providers.setup]
required_secrets = [
{ id = "github/app-private-key", direction = "outbound" },
{ id = "github/webhook-secret", direction = "inbound" },
]
"#;
#[test]
fn legacy_bare_strings_are_found_with_their_ids() {
let findings = find_legacy_required_secrets(LEGACY_MANIFEST);
assert_eq!(
findings,
vec![LegacySecretFinding {
provider: Some("github".to_string()),
index: 0,
ids: vec![
"github/app-private-key".to_string(),
"github/webhook-secret".to_string(),
],
}]
);
}
#[test]
fn the_typed_form_produces_no_finding() {
assert!(find_legacy_required_secrets(TYPED_MANIFEST).is_empty());
}
#[test]
fn manifests_without_a_legacy_spelling_produce_no_finding() {
for source in [
"",
"[package]\nname = \"x\"\n",
"[[providers]]\nid = \"github\"\n",
"[[providers]]\nid = \"github\"\n\n[providers.setup]\nauth_type = \"token\"\n",
"[[providers]]\nid = \"github\"\n\n[providers.setup]\nrequired_secrets = []\n",
"this is not valid toml = = =",
] {
assert!(
find_legacy_required_secrets(source).is_empty(),
"unexpected finding for source: {source:?}"
);
}
}
#[test]
fn the_real_fleet_manifest_is_detected() {
let findings = find_legacy_required_secrets(
r#"
[package]
name = "harn-github-connector"
version = "0.8.6"
[[providers]]
id = "github"
connector = { harn = "src/webhooks/provider.harn" }
capabilities = ["webhook", "rate_limit", "pagination", "graphql", "oauth"]
[providers.setup]
auth_type = "github-app"
flow = "github-app"
required_secrets = ["github/app-private-key", "github/webhook-secret"]
setup_command = ["harn", "connect", "github"]
[[providers.setup.health_checks]]
id = "app-private-key"
kind = "secret"
secret = "github/app-private-key"
[providers.setup.recovery]
missing_auth = "Store github/app-private-key and github/webhook-secret before enabling GitHub App bindings."
"#,
);
assert_eq!(
findings.len(),
1,
"the pinned fleet manifest must be flagged"
);
assert_eq!(findings[0].provider.as_deref(), Some("github"));
assert_eq!(
findings[0].ids,
vec![
"github/app-private-key".to_string(),
"github/webhook-secret".to_string(),
]
);
}
#[test]
fn a_provider_without_an_id_is_reported_by_position() {
let findings = find_legacy_required_secrets(
"[[providers]]\n\n[providers.setup]\nrequired_secrets = [\"acme/token\"]\n",
);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].provider, None);
assert_eq!(findings[0].index, 0);
}
#[test]
fn only_the_bare_entries_of_a_mixed_list_are_reported() {
let findings = find_legacy_required_secrets(
r#"
[[providers]]
id = "acme"
[providers.setup]
required_secrets = [
"acme/legacy-token",
{ id = "acme/typed-token", direction = "inbound" },
]
"#,
);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].ids, vec!["acme/legacy-token".to_string()]);
}
#[test]
fn a_legacy_entry_warns_and_does_not_fail_the_check() {
let dir = tempfile::tempdir().expect("tempdir");
let manifest_path = dir.path().join("harn.toml");
fs::write(&manifest_path, LEGACY_MANIFEST).expect("write manifest");
let mut errors = Vec::new();
let mut warnings = Vec::new();
validate_required_secret_spelling(&manifest_path, &mut errors, &mut warnings);
assert!(
errors.is_empty(),
"a legacy entry must not fail the check while the fleet still ships 19 of them: {errors:?}"
);
assert_eq!(warnings.len(), 1, "expected exactly one warning");
assert!(
warnings[0].message.contains("github/app-private-key"),
"the warning must name the offending ids: {}",
warnings[0].message
);
assert!(
warnings[0].message.contains("direction"),
"the warning must point at the typed form: {}",
warnings[0].message
);
}
#[test]
fn the_typed_form_produces_no_diagnostic_at_all() {
let dir = tempfile::tempdir().expect("tempdir");
let manifest_path = dir.path().join("harn.toml");
fs::write(&manifest_path, TYPED_MANIFEST).expect("write manifest");
let mut errors = Vec::new();
let mut warnings = Vec::new();
validate_required_secret_spelling(&manifest_path, &mut errors, &mut warnings);
assert!(errors.is_empty());
assert!(warnings.is_empty());
}
#[test]
fn raising_the_severity_moves_the_diagnostic_into_errors() {
let findings = find_legacy_required_secrets(LEGACY_MANIFEST);
assert_eq!(findings.len(), 1, "fixture must produce a finding to route");
let mut errors = Vec::new();
let mut warnings = Vec::new();
record_findings(Severity::Error, findings, &mut errors, &mut warnings);
assert_eq!(errors.len(), 1, "the Error arm must populate errors");
assert!(warnings.is_empty(), "nothing should land in warnings");
assert!(errors[0].message.contains("github/app-private-key"));
}
#[test]
fn the_severity_lever_is_still_a_warning() {
assert_eq!(
LEGACY_SECRET_SEVERITY,
Severity::Warning,
"flipping this to Error fails `harn package verify .` on every unmigrated connector; \
do step 2 of harn#7587 first"
);
}
}