Property-test secret redaction and mirror URL credential rejection
Assisted-by: Claude Opus 4.7 via Claude Code
change rkkkswoxqvquoqpzowlpwwwqtzvyzxto
commit 49ad136f4e702b02ba8848a9ea27ac37fda203c3
author Alpha Chen <alpha@kejadlen.dev>
date
parent yukuznzk
diff --git a/tests/property.rs b/tests/property.rs
index 59ca149..22229ba 100644
--- a/tests/property.rs
+++ b/tests/property.rs
@@ -1,13 +1,19 @@
 use hegel::TestCase;
-use hegel::generators::{integers, text, vecs};
+use hegel::generators::{from_regex, integers, just, sampled_from, text, vecs};
+use hegel::one_of;
 use quire::event::{PushEvent, PushRef};
+use quire::quire::MirrorConfig;
+use quire::secret::SecretString;
+
+const ZERO_SHA: &str = "0000000000000000000000000000000000000000";
 
 #[hegel::composite]
 fn push_ref(tc: TestCase) -> PushRef {
     PushRef {
         r#ref: tc.draw(text()),
         old_sha: tc.draw(text()),
-        new_sha: tc.draw(text()),
+        // Mix in zero-shas so updated_refs() actually exercises its filter.
+        new_sha: tc.draw(one_of![text(), just(ZERO_SHA.to_string())]),
     }
 }
 
@@ -34,3 +40,80 @@ fn push_event_round_trips_json(tc: TestCase) {
     let parsed: PushEvent = serde_json::from_str(&json).expect("deserialize");
     assert_eq!(event, parsed);
 }
+
+#[hegel::test]
+fn updated_refs_excludes_only_zero_sha_deletions(tc: TestCase) {
+    let event = tc.draw(push_event());
+    let kept = event.updated_refs();
+
+    let expected: Vec<&PushRef> = event.refs.iter().filter(|r| r.new_sha != ZERO_SHA).collect();
+    assert_eq!(kept, expected);
+
+    let deleted = event.refs.iter().filter(|r| r.new_sha == ZERO_SHA).count();
+    assert_eq!(kept.len() + deleted, event.refs.len());
+}
+
+#[hegel::test]
+fn secret_string_debug_never_leaks_plain_value(tc: TestCase) {
+    let value = tc.draw(text());
+    let secret = SecretString::from_plain(value.clone());
+    let debug = format!("{secret:?}");
+    assert_eq!(debug, "SecretString(\"<redacted>\")");
+}
+
+#[hegel::test]
+fn secret_string_plain_json_round_trips(tc: TestCase) {
+    let value = tc.draw(text());
+    let json = serde_json::to_string(&value).expect("serialize string");
+    let secret: SecretString = serde_json::from_str(&json).expect("deserialize SecretString");
+    assert_eq!(secret.reveal().expect("plain reveal"), value);
+}
+
+#[hegel::test]
+fn secret_string_from_file_strips_one_trailing_newline(tc: TestCase) {
+    let content = tc.draw(text());
+    let dir = tempfile::tempdir().expect("tempdir");
+    let path = dir.path().join("secret");
+    fs_err::write(&path, &content).expect("write");
+
+    let revealed = SecretString::from_file(&path)
+        .reveal()
+        .expect("reveal")
+        .to_string();
+    let expected = content.strip_suffix('\n').unwrap_or(&content).to_string();
+    assert_eq!(revealed, expected);
+}
+
+fn deserialize_mirror(url: &str) -> Result<MirrorConfig, serde_json::Error> {
+    serde_json::from_value(serde_json::json!({ "url": url }))
+}
+
+#[hegel::test]
+fn mirror_url_rejects_embedded_credentials(tc: TestCase) {
+    // Build `scheme://user@host/path`: the deserializer must reject because the
+    // `@` sits before any `/` in the authority section.
+    let scheme = tc.draw(sampled_from(&["https", "http", "ssh", "git"]));
+    let user = tc.draw(from_regex(r"\A[A-Za-z0-9_:.-]+\Z"));
+    let host = tc.draw(from_regex(r"\A[A-Za-z0-9.-]+\Z"));
+    let path = tc.draw(from_regex(r"\A[A-Za-z0-9/_.-]*\Z"));
+    let url = format!("{scheme}://{user}@{host}/{path}");
+
+    let err = deserialize_mirror(&url).expect_err(&format!("must reject {url}"));
+    assert!(
+        err.to_string().contains("must not embed credentials"),
+        "wrong rejection reason for {url}: {err}",
+    );
+}
+
+#[hegel::test]
+fn mirror_url_accepts_at_in_path(tc: TestCase) {
+    // `@` after the first `/` is in the path, not the authority — must accept.
+    let scheme = tc.draw(sampled_from(&["https", "http", "ssh", "git"]));
+    let host = tc.draw(from_regex(r"\A[A-Za-z0-9.-]+\Z"));
+    let before_at = tc.draw(from_regex(r"\A[A-Za-z0-9/_.-]*\Z"));
+    let after_at = tc.draw(from_regex(r"\A[A-Za-z0-9/_.-]*\Z"));
+    let url = format!("{scheme}://{host}/{before_at}@{after_at}");
+
+    let cfg = deserialize_mirror(&url).expect("must accept");
+    assert_eq!(cfg.url, url);
+}