Pin the rule that a binding sets only names its recipe declares

The secret-files campaign rests on one admit line: a binding may bind only
names its recipe declares, so a recipe that declares a secret only as its
*_FILE name leaves a plaintext value no place in a binding. Its only test,
extra_operator_environment_is_rejected, asserted is_err(), which any other
refusal satisfies.

The replacement tests assert this refusal exactly, each against a declared
twin that admits:
- an undeclared [workload.environment] name;
- an undeclared [workload.secret_files] name;
- the plaintext twin of a declared *_FILE name, the shape of the Heimdall
  leak;
- `idunn validate` returns the refusal, as main prints it, without the bound
  value (a canary), pinning the admit layer of P6.

Tests only. The deployment test fixtures become pub(crate) so control_plane's
validate test can reuse them.

Idunn secret-files cut, Cut 2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-30 12:36:27 +02:00
commit d21ead285b
2 changed files with 101 additions and 10 deletions

View file

@ -10216,6 +10216,30 @@ mod tests {
}
}
#[test]
fn validate_refuses_an_undeclared_binding_name_without_echoing_its_value() {
use crate::deployment::tests::{
UNDECLARED_INPUT_REFUSAL, binding_with_environment, recipe_declaring_inputs,
};
let canary = "canary-bound-value-4e91c7";
let directory = tempfile::tempdir().unwrap();
let recipe = directory.path().join("recipe.toml");
fs::write(&recipe, recipe_declaring_inputs()).unwrap();
let declared = directory.path().join("declared.toml");
fs::write(&declared, binding_with_environment("DECLARED_INPUT", canary)).unwrap();
let undeclared = directory.path().join("undeclared.toml");
fs::write(&undeclared, binding_with_environment("UNDECLARED_INPUT", canary)).unwrap();
validate(&recipe, Some(&declared)).unwrap();
// `idunn` returns this error from main, which prints its Debug form,
// context chain included, and exits non-zero.
let printed = format!("{:?}", validate(&recipe, Some(&undeclared)).unwrap_err());
assert!(printed.contains(UNDECLARED_INPUT_REFUSAL), "{printed}");
assert!(!printed.contains("canary"), "validate echoed a bound value");
}
#[test]
fn cli_exposes_only_declarative_commands() {
for arguments in [

View file

@ -1928,10 +1928,11 @@ fn require_host(value: &str) -> Result<()> {
}
#[cfg(test)]
mod tests {
pub(crate) mod tests {
use super::*;
const RECIPE: &str = r#"
// `idunn validate`'s tests in control_plane reuse these two fixtures.
pub(crate) const RECIPE: &str = r#"
schema = "gamecult.idunn.target_declaration.v1"
target = "test-service"
source_stamp_environment = "TEST_SERVICE_BUILD_COMMIT"
@ -2003,7 +2004,7 @@ compatibility = "v1"
startup = "before-promotion"
"#;
const BINDING: &str = r#"
pub(crate) const BINDING: &str = r#"
schema = "gamecult.idunn.operator_binding.v2"
target = "test-service"
profiles = ["aetheria", "full-gamecult"]
@ -2175,15 +2176,81 @@ nodes = ["yggdrasil"]
assert!(TargetDeclaration::parse(&input).is_err());
}
#[test]
fn extra_operator_environment_is_rejected() {
let recipe = TargetDeclaration::parse(RECIPE).unwrap();
let input = BINDING.replace(
/// The refusal of a binding that sets a name its recipe does not declare.
/// A recipe declares a secret only as a `*_FILE` name, so this one rule is
/// what leaves a plaintext secret no place in a binding.
pub(crate) const UNDECLARED_INPUT_REFUSAL: &str =
"operator service environment contains an undeclared launch input";
/// RECIPE plus one declared plain input and one declared secret-file input.
pub(crate) fn recipe_declaring_inputs() -> String {
RECIPE.replace(
"required_environment = [",
"optional_environment = [\"DECLARED_INPUT\", \"DECLARED_SECRET_FILE\"]\nrequired_environment = [",
)
}
/// BINDING plus one `[workload.environment]` entry.
pub(crate) fn binding_with_environment(name: &str, value: &str) -> String {
BINDING.replace(
"[workload.argument_bindings]",
"[workload.environment]\nUNDECLARED = \"no\"\n\n[workload.argument_bindings]",
&format!("[workload.environment]\n{name} = \"{value}\"\n\n[workload.argument_bindings]"),
)
}
/// BINDING plus one `[workload.secret_files]` entry.
fn binding_with_secret_file(name: &str) -> String {
BINDING.replace(
"[workload.secret_files]\n",
&format!(
"[workload.secret_files]\n{name} = \"/etc/gamecult/test-service/credentials/{name}\"\n"
),
)
}
fn admit_refusal(recipe: &TargetDeclaration, binding: &str) -> String {
let binding = OperatorBinding::parse(binding).unwrap();
format!("{:#}", binding.admit(recipe).unwrap_err())
}
#[test]
fn binding_environment_sets_only_declared_names() {
let recipe = TargetDeclaration::parse(&recipe_declaring_inputs()).unwrap();
OperatorBinding::parse(&binding_with_environment("DECLARED_INPUT", "plain"))
.unwrap()
.admit(&recipe)
.unwrap();
assert_eq!(
admit_refusal(&recipe, &binding_with_environment("UNDECLARED_INPUT", "plain")),
UNDECLARED_INPUT_REFUSAL
);
}
#[test]
fn binding_secret_files_set_only_declared_names() {
let recipe = TargetDeclaration::parse(&recipe_declaring_inputs()).unwrap();
OperatorBinding::parse(&binding_with_secret_file("DECLARED_SECRET_FILE"))
.unwrap()
.admit(&recipe)
.unwrap();
assert_eq!(
admit_refusal(&recipe, &binding_with_secret_file("UNDECLARED_SECRET_FILE")),
UNDECLARED_INPUT_REFUSAL
);
}
#[test]
fn declaring_a_secret_file_does_not_admit_its_plaintext_twin() {
// The shape of the Heimdall leak: the recipe declares only
// DECLARED_SECRET_FILE, and a binding puts the value itself under
// DECLARED_SECRET in plain environment.
let recipe = TargetDeclaration::parse(&recipe_declaring_inputs()).unwrap();
assert_eq!(
admit_refusal(&recipe, &binding_with_environment("DECLARED_SECRET", "plain")),
UNDECLARED_INPUT_REFUSAL
);
let binding = OperatorBinding::parse(&input).unwrap();
assert!(binding.admit(&recipe).is_err());
}
#[test]