Add property-based tests for SecretRegistry and redaction
Generates random secret entries with unique names and ASCII values, then
exercises redaction invariants: idempotency, no leakage of long values,
identity on empty/unresolved registries, and consistent resolution.
Exports SecretRegistry and redact from the ci module for integration test
access, and adds a Debug impl for SecretRegistry (required by hegel
TestCase::draw bound).
Assisted-by: GLM-5.1 via pi
diff --git a/src/ci/mod.rs b/src/ci/mod.rs
index 562ad55..1d75abf 100644
--- a/src/ci/mod.rs
+++ b/src/ci/mod.rs
@@ -15,6 +15,7 @@ pub(crate) mod error;
pub use error::{Error, Result};
pub use pipeline::{DefinitionError, Diagnostic, Job, Pipeline, PipelineError, StructureError};
+pub use redact::{SecretRegistry, redact};
pub use run::{Executor, Run, RunMeta, RunState, Runs, materialize_workspace, reconcile_orphans};
/// A resolved commit reference.
diff --git a/src/ci/redact.rs b/src/ci/redact.rs
index a93e17c..54ca4be 100644
--- a/src/ci/redact.rs
+++ b/src/ci/redact.rs
@@ -50,6 +50,15 @@ pub struct SecretRegistry {
revealed: HashMap<String, Revealed>,
}
+impl std::fmt::Debug for SecretRegistry {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("SecretRegistry")
+ .field("declared", &self.declared.keys().collect::<Vec<_>>())
+ .field("revealed", &self.revealed.keys().collect::<Vec<_>>())
+ .finish()
+ }
+}
+
impl SecretRegistry {
pub fn new(declared: HashMap<String, SecretString>) -> Self {
Self {
diff --git a/tests/property.rs b/tests/property.rs
index d489b64..e7d0dbf 100644
--- a/tests/property.rs
+++ b/tests/property.rs
@@ -1,11 +1,16 @@
+use std::collections::HashMap;
+
use hegel::TestCase;
use hegel::generators::{integers, just, text, vecs};
use hegel::one_of;
+use quire::ci::{SecretRegistry, redact};
use quire::event::{PushEvent, PushRef};
use quire::secret::SecretString;
const ZERO_SHA: &str = "0000000000000000000000000000000000000000";
+const MIN_REDACT_LEN: usize = 8;
+
#[hegel::composite]
fn push_ref(tc: TestCase) -> PushRef {
PushRef {
@@ -113,3 +118,208 @@ fn push_event_updated_refs_is_subtractive(tc: TestCase) {
);
}
}
+
+// ── Secret registry helpers ──────────────────────────────────────
+
+fn plain_secrets(pairs: &[(&str, &str)]) -> HashMap<String, SecretString> {
+ pairs
+ .iter()
+ .map(|(k, v)| (k.to_string(), SecretString::from_plain(*v)))
+ .collect()
+}
+
+/// Generate a secret name from an index.
+fn secret_name(i: usize) -> String {
+ format!("secret_{i}")
+}
+
+#[hegel::composite]
+fn unique_secret_entries(tc: TestCase) -> Vec<(String, String)> {
+ let count = tc.draw(integers::<usize>().min_value(1).max_value(8));
+ let mut seen = std::collections::HashSet::new();
+ let mut entries = Vec::new();
+ for i in 0..count {
+ let value = tc.draw(text().alphabet("abcdefghijklmnopqrstuvwxyz0123456789"));
+ let name = secret_name(i);
+ if seen.insert(name.clone()) {
+ entries.push((name, value));
+ }
+ }
+ entries
+}
+
+#[hegel::composite]
+fn resolved_registry(tc: TestCase) -> SecretRegistry {
+ let entries = tc.draw(unique_secret_entries());
+ let mut map = HashMap::new();
+ for (name, value) in &entries {
+ map.insert(name.clone(), SecretString::from_plain(value.clone()));
+ }
+ let mut reg = SecretRegistry::new(map);
+ // Resolve all secrets so they're registered for redaction.
+ for (name, _) in &entries {
+ let _ = reg.resolve(name);
+ }
+ reg
+}
+
+#[hegel::composite]
+fn text_with_secrets(tc: TestCase) -> (SecretRegistry, String) {
+ let entries = tc.draw(unique_secret_entries());
+ let mut map = HashMap::new();
+ let mut long_values: Vec<String> = Vec::new();
+ for (name, value) in &entries {
+ map.insert(name.clone(), SecretString::from_plain(value.clone()));
+ if value.len() >= MIN_REDACT_LEN {
+ long_values.push(value.clone());
+ }
+ }
+ let mut reg = SecretRegistry::new(map);
+ for (name, _) in &entries {
+ let _ = reg.resolve(name);
+ }
+
+ // Build text that intersperses random noise with secret values.
+ let mut body = tc.draw(text());
+ for (_, value) in &entries {
+ if value.len() >= MIN_REDACT_LEN {
+ body.push_str(value);
+ body.push_str(&tc.draw(text()));
+ }
+ }
+ (reg, body)
+}
+
+// ── Secret registry property tests ────────────────────────────────
+
+#[hegel::test]
+fn redact_never_contains_revealed_long_values(tc: TestCase) {
+ let entries = tc.draw(unique_secret_entries());
+ let mut map = HashMap::new();
+ let mut long_values: Vec<String> = Vec::new();
+ for (name, value) in &entries {
+ map.insert(name.clone(), SecretString::from_plain(value.clone()));
+ if value.len() >= MIN_REDACT_LEN {
+ long_values.push(value.clone());
+ }
+ }
+ let mut reg = SecretRegistry::new(map);
+ for (name, _) in &entries {
+ let _ = reg.resolve(name);
+ }
+
+ // Build text containing all the long values.
+ let mut body = tc.draw(text());
+ for value in &long_values {
+ body.push_str(value);
+ body.push_str(&tc.draw(text()));
+ }
+ let result = redact(&body, ®);
+ for value in &long_values {
+ assert!(
+ !result.contains(value),
+ "redacted text still contains secret value: {value}"
+ );
+ }
+}
+
+#[hegel::test]
+fn redact_is_idempotent(tc: TestCase) {
+ let (reg, text) = tc.draw(text_with_secrets());
+ let first = redact(&text, ®);
+ let second = redact(&first, ®);
+ assert_eq!(first, second);
+}
+
+#[hegel::test]
+fn redact_preserves_text_without_secrets(tc: TestCase) {
+ let reg = tc.draw(resolved_registry());
+ let text = tc.draw(text());
+ let result = redact(&text, ®);
+ // If no secret value happens to appear in the random text, output is
+ // identical. This won't always hold (random text might contain a secret),
+ // so only assert when no redaction actually occurred.
+ if !reg.has_redactions() {
+ assert_eq!(result, text);
+ }
+}
+
+#[hegel::test]
+fn redact_empty_registry_is_identity(tc: TestCase) {
+ let reg = SecretRegistry::new(HashMap::new());
+ let text = tc.draw(text());
+ assert_eq!(redact(&text, ®), text);
+}
+
+#[hegel::test]
+fn redact_unresolved_registry_is_identity(tc: TestCase) {
+ let entries = tc.draw(unique_secret_entries());
+ let map: HashMap<String, SecretString> = entries
+ .into_iter()
+ .map(|(k, v)| (k, SecretString::from_plain(v)))
+ .collect();
+ let reg = SecretRegistry::new(map);
+ let text = tc.draw(text());
+ assert_eq!(redact(&text, ®), text);
+}
+
+#[hegel::test]
+fn resolve_returns_consistent_value(tc: TestCase) {
+ let entries = tc.draw(unique_secret_entries());
+ let (name, value) = entries.into_iter().next().unwrap();
+ let mut reg = SecretRegistry::new(plain_secrets(&[(&name, &value)]));
+ let first = reg.resolve(&name).unwrap();
+ let second = reg.resolve(&name).unwrap();
+ assert_eq!(first, second);
+ assert_eq!(first, value);
+}
+
+#[hegel::test]
+fn resolve_unknown_name_errors(tc: TestCase) {
+ let name = tc.draw(text());
+ let mut reg = SecretRegistry::new(HashMap::new());
+ assert!(reg.resolve(&name).is_err());
+}
+
+#[hegel::test]
+fn redact_output_never_shows_long_secret_values(tc: TestCase) {
+ let entries = tc.draw(unique_secret_entries());
+ let mut map = HashMap::new();
+ for (name, value) in &entries {
+ map.insert(name.clone(), SecretString::from_plain(value.clone()));
+ }
+ let mut reg = SecretRegistry::new(map);
+ for (name, _) in &entries {
+ let _ = reg.resolve(name);
+ }
+
+ // Concatenate all secret values into one string.
+ let mut body = String::new();
+ for (_, value) in &entries {
+ body.push_str(value);
+ body.push(' ');
+ }
+ let result = redact(&body, ®);
+
+ // No secret value >= 8 chars should survive.
+ for (_, value) in &entries {
+ if value.len() >= MIN_REDACT_LEN {
+ assert!(!result.contains(value), "long secret leaked: {value}");
+ }
+ }
+}
+
+#[hegel::test]
+fn short_secrets_are_never_redacted(tc: TestCase) {
+ // Generate values of 1-7 bytes. With ASCII alphabet, char count == byte count.
+ let value = tc.draw(text().alphabet("abcdefgh").max_size(7));
+ if value.is_empty() {
+ return;
+ }
+ let mut reg = SecretRegistry::new(plain_secrets(&[("short", &value)]));
+ let _ = reg.resolve("short");
+ let body = value.clone();
+ let result = redact(&body, ®);
+ // Values < 8 bytes are never registered for redaction.
+ assert_eq!(result, body, "short secret was incorrectly redacted");
+}