use assert_cmd::Command;
use std::fs;
use std::path::Path;
use std::process::Command as StdCommand;
type TestResult = Result<(), Box<dyn std::error::Error>>;
const MANIFEST: &str = r#"
version = 1
canonical = "json-schema-2020-12"
authoring = "zod"
[[contracts]]
name = "user"
source = "contracts/user.zod.ts"
emit = ["zod"]
[[mappings]]
glob = "app/api/**/*.ts"
contracts = ["user"]
require = "boundary-validation"
[gates]
suppression_comments = "deny"
protected_paths = ["pushkin.toml"]
read_only_paths = ["crates/**/tests/**"]
"#;
const PROTECTED: &str = "pushkin.toml";
const COMMITTED_TEST: &str = "crates/pushkin-cli/tests/committed_suite.rs";
const MAPPED: &str = "app/api/users/route.ts";
const ORDINARY: &str = "docs/notes.md";
const RULE_PROTECTED: &str = "pushkin.protected_path";
const RULE_READ_ONLY: &str = "pushkin.read_only_path";
const RULE_UNVALIDATED: &str = "contract.boundary.unvalidated_input";
const RULE_CONTENT_UNAVAILABLE: &str = "pushkin.content_unavailable";
const FAIL_OPEN: &str = "failing open";
fn git(dir: &Path, args: &[&str]) -> TestResult {
let status = StdCommand::new("git")
.current_dir(dir)
.args(args)
.status()?;
assert!(status.success(), "git {args:?} failed");
Ok(())
}
fn repo() -> Result<tempfile::TempDir, Box<dyn std::error::Error>> {
let dir = tempfile::tempdir()?;
fs::write(dir.path().join(PROTECTED), MANIFEST)?;
fs::create_dir_all(dir.path().join("contracts"))?;
fs::write(
dir.path().join("contracts/user.zod.ts"),
"export const user = 1;\n",
)?;
fs::create_dir_all(dir.path().join("app/api/users"))?;
fs::write(dir.path().join(MAPPED), "export const handler = 1;\n")?;
fs::create_dir_all(dir.path().join("crates/pushkin-cli/tests"))?;
fs::write(dir.path().join(COMMITTED_TEST), "// committed suite\n")?;
fs::create_dir_all(dir.path().join("docs"))?;
fs::write(dir.path().join(ORDINARY), "notes\n")?;
git(dir.path(), &["init", "-q", "."])?;
git(dir.path(), &["add", "-A"])?;
git(
dir.path(),
&[
"-c",
"user.name=F58 Suite",
"-c",
"user.email=f58@test",
"commit",
"-qm",
"fixture",
],
)?;
Ok(dir)
}
fn patch_payload(body: &str) -> String {
serde_json::json!({
"session_id": "f58",
"hook_event_name": "PreToolUse",
"tool_name": "apply_patch",
"tool_input": { "command": format!("*** Begin Patch\n{body}*** End Patch\n") },
})
.to_string()
}
fn hook(
dir: &Path,
payload: &str,
daemon: Option<&str>,
) -> Result<String, Box<dyn std::error::Error>> {
let mut cmd = Command::cargo_bin("pushkin")?;
cmd.current_dir(dir).write_stdin(payload.to_owned());
if let Some(mode) = daemon {
cmd.env("PUSHKIN_DAEMON", mode);
}
let output = cmd.args(["hook", "codex"]).output()?;
Ok(format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
))
}
fn stop_daemon(dir: &Path) {
let _ = Command::cargo_bin("pushkin")
.map(|mut c| c.current_dir(dir).args(["daemon", "stop"]).output());
}
#[test]
fn deleting_a_protected_file_is_denied() -> TestResult {
let dir = repo()?;
let output = hook(
dir.path(),
&patch_payload(&format!("*** Delete File: {PROTECTED}\n")),
Some("off"),
)?;
assert!(
output.contains(RULE_PROTECTED),
"a delete is a write to the protected path: {output}"
);
Ok(())
}
#[test]
fn deleting_a_protected_file_is_not_an_unparsed_fail_open() -> TestResult {
let dir = repo()?;
let output = hook(
dir.path(),
&patch_payload(&format!("*** Delete File: {PROTECTED}\n")),
Some("off"),
)?;
assert!(
!output.contains(FAIL_OPEN),
"the parser must RECOGNIZE the delete, not decline to judge it: {output}"
);
Ok(())
}
#[test]
fn deleting_a_committed_test_is_denied() -> TestResult {
let dir = repo()?;
let output = hook(
dir.path(),
&patch_payload(&format!("*** Delete File: {COMMITTED_TEST}\n")),
Some("off"),
)?;
assert!(
output.contains(RULE_READ_ONLY),
"N10 covers deleting a committed suite, not just editing it: {output}"
);
Ok(())
}
#[test]
fn deleting_an_ordinary_file_is_allowed() -> TestResult {
let dir = repo()?;
let output = hook(
dir.path(),
&patch_payload(&format!("*** Delete File: {ORDINARY}\n")),
Some("off"),
)?;
assert!(
!output.contains(RULE_PROTECTED)
&& !output.contains(RULE_READ_ONLY)
&& !output.contains(RULE_CONTENT_UNAVAILABLE),
"an ungated delete has nothing to object to: {output}"
);
Ok(())
}
#[test]
fn deleting_a_mapped_file_is_not_refused_as_content_unavailable() -> TestResult {
let dir = repo()?;
let output = hook(
dir.path(),
&patch_payload(&format!("*** Delete File: {MAPPED}\n")),
Some("off"),
)?;
assert!(
!output.contains(RULE_CONTENT_UNAVAILABLE),
"a delete has no content to evaluate, so this refusal would misdescribe \
its own reason: {output}"
);
Ok(())
}
#[test]
fn renaming_onto_a_protected_path_is_denied() -> TestResult {
let dir = repo()?;
let output = hook(
dir.path(),
&patch_payload(&format!(
"*** Update File: {ORDINARY}\n*** Move to: {PROTECTED}\n@@\n-notes\n+tampered\n"
)),
Some("off"),
)?;
assert!(
output.contains(RULE_PROTECTED),
"the rename DESTINATION is a write target and must be gated: {output}"
);
Ok(())
}
#[test]
fn renaming_onto_a_committed_test_is_denied() -> TestResult {
let dir = repo()?;
let output = hook(
dir.path(),
&patch_payload(&format!(
"*** Update File: {ORDINARY}\n*** Move to: {COMMITTED_TEST}\n@@\n-notes\n+tampered\n"
)),
Some("off"),
)?;
assert!(
output.contains(RULE_READ_ONLY),
"overwriting a committed suite by rename is still overwriting it: {output}"
);
Ok(())
}
#[test]
fn renaming_a_protected_file_away_is_denied() -> TestResult {
let dir = repo()?;
let output = hook(
dir.path(),
&patch_payload(&format!(
"*** Update File: {PROTECTED}\n*** Move to: {ORDINARY}\n@@\n-a\n+b\n"
)),
Some("off"),
)?;
assert!(
output.contains(RULE_PROTECTED),
"the source is still the protected path: {output}"
);
Ok(())
}
#[test]
fn an_ordinary_rename_is_not_denied_on_path_rules() -> TestResult {
let dir = repo()?;
let output = hook(
dir.path(),
&patch_payload(&format!(
"*** Update File: {ORDINARY}\n*** Move to: docs/renamed.md\n@@\n-notes\n+edited\n"
)),
Some("off"),
)?;
assert!(
!output.contains(RULE_PROTECTED) && !output.contains(RULE_READ_ONLY),
"neither path is gated: {output}"
);
Ok(())
}
#[test]
fn a_patch_mixing_a_delete_and_an_add_still_judges_the_added_content() -> TestResult {
let dir = repo()?;
let output = hook(
dir.path(),
&patch_payload(&format!(
"*** Delete File: {ORDINARY}\n*** Add File: app/api/new/route.ts\n\
+export async function POST(req: Request) {{\n\
+ const body = await req.json();\n\
+ return Response.json(body);\n\
+}}\n"
)),
Some("off"),
)?;
assert!(
output.contains(RULE_UNVALIDATED),
"the added handler parses no contract and must still be caught: {output}"
);
Ok(())
}
#[test]
fn a_delete_alongside_an_update_still_judges_the_update() -> TestResult {
let dir = repo()?;
let output = hook(
dir.path(),
&patch_payload(&format!(
"*** Delete File: {ORDINARY}\n\
*** Update File: {MAPPED}\n\
@@\n\
-export const handler = 1;\n\
+export async function POST(req: Request) {{\n\
+ const body = await req.json();\n\
+ return Response.json(body);\n\
+}}\n"
)),
Some("off"),
)?;
assert!(
output.contains(RULE_UNVALIDATED),
"the update introduces an unvalidated handler and must be caught: {output}"
);
assert!(
!output.contains(RULE_CONTENT_UNAVAILABLE),
"and judged, not deferred — the hunk is reconstructable now: {output}"
);
Ok(())
}
#[test]
fn the_delete_verdict_is_identical_warm_and_cold() -> TestResult {
let dir = repo()?;
let payload = patch_payload(&format!("*** Delete File: {PROTECTED}\n"));
let cold = hook(dir.path(), &payload, Some("off"))?;
let warm = hook(dir.path(), &payload, Some("auto"))?;
stop_daemon(dir.path());
assert!(cold.contains(RULE_PROTECTED), "cold: {cold}");
assert!(warm.contains(RULE_PROTECTED), "warm: {warm}");
Ok(())
}
#[test]
fn an_add_only_patch_is_unaffected() -> TestResult {
let dir = repo()?;
let output = hook(
dir.path(),
&patch_payload(
"*** Add File: app/api/other/route.ts\n\
+export async function POST(req: Request) {\n\
+ const body = await req.json();\n\
+ return Response.json(body);\n\
+}\n",
),
Some("off"),
)?;
assert!(
output.contains(RULE_UNVALIDATED),
"Add sections are whole files and keep their content verdict: {output}"
);
Ok(())
}