use crate::common::Drip;
use serde_json::{json, Value};
use std::fs;
use std::io::Write;
use std::process::{Command, Stdio};
fn run_hook(
drip: &Drip,
agent: &str,
payload: Value,
ignore_file: Option<&std::path::Path>,
) -> String {
let mut cmd = Command::new(&drip.bin);
cmd.args(["hook", agent])
.env("DRIP_DATA_DIR", drip.data_dir.path())
.env("DRIP_SESSION_ID", &drip.session_id);
if let Some(p) = ignore_file {
cmd.env("DRIP_IGNORE_FILE", p);
}
let mut child = cmd
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
child
.stdin
.as_mut()
.unwrap()
.write_all(payload.to_string().as_bytes())
.unwrap();
let o = child.wait_with_output().unwrap();
assert!(
o.status.success(),
"hook failed: stderr={}",
String::from_utf8_lossy(&o.stderr)
);
String::from_utf8_lossy(&o.stdout).into_owned()
}
#[test]
fn read_of_lock_file_is_substituted_with_placeholder() {
let drip = Drip::new();
let dir = tempfile::tempdir().unwrap();
let f = dir.path().join("package-lock.json");
fs::write(&f, "{\"name\": \"x\", \"lockfileVersion\": 3}").unwrap();
let out = run_hook(
&drip,
"claude",
json!({
"session_id": &drip.session_id,
"tool_name": "Read",
"tool_input": { "file_path": f.to_string_lossy() }
}),
None,
);
let v: Value = serde_json::from_str(out.trim()).unwrap();
assert_eq!(
v["hookSpecificOutput"]["permissionDecision"].as_str(),
Some("deny"),
"ignored file must produce a substitute, not pass through"
);
let reason = v["hookSpecificOutput"]["permissionDecisionReason"]
.as_str()
.unwrap();
assert!(
reason.contains("ignored by .dripignore") || reason.contains("matched .dripignore"),
"unexpected reason: {reason}"
);
assert!(
!reason.contains("lockfileVersion"),
"lock-file content leaked despite .dripignore: {reason}"
);
}
#[test]
fn user_dripignore_extends_defaults() {
let drip = Drip::new();
let dir = tempfile::tempdir().unwrap();
let secret = dir.path().join("secret.env");
fs::write(&secret, "API_KEY=abc").unwrap();
let ignore_file = dir.path().join("dripignore");
fs::write(&ignore_file, "*.env\n").unwrap();
let out = run_hook(
&drip,
"claude",
json!({
"session_id": &drip.session_id,
"tool_name": "Read",
"tool_input": { "file_path": secret.to_string_lossy() }
}),
Some(&ignore_file),
);
let v: Value = serde_json::from_str(out.trim()).unwrap();
assert_eq!(
v["hookSpecificOutput"]["permissionDecision"].as_str(),
Some("deny")
);
let reason = v["hookSpecificOutput"]["permissionDecisionReason"]
.as_str()
.unwrap();
assert!(!reason.contains("API_KEY"), "secret leaked: {reason}");
}
#[test]
fn glob_hook_filters_out_node_modules() {
let drip = Drip::new();
let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(dir.path().join("src")).unwrap();
fs::create_dir_all(dir.path().join("node_modules/lib")).unwrap();
fs::write(dir.path().join("src/app.js"), "// real code").unwrap();
fs::write(dir.path().join("node_modules/lib/index.js"), "// junk").unwrap();
let out = run_hook(
&drip,
"claude-glob",
json!({
"tool_name": "Glob",
"tool_input": {
"pattern": "**/*.js",
"path": dir.path().to_string_lossy()
}
}),
None,
);
let v: Value = serde_json::from_str(out.trim()).unwrap();
assert_eq!(
v["hookSpecificOutput"]["permissionDecision"].as_str(),
Some("deny")
);
let reason = v["hookSpecificOutput"]["permissionDecisionReason"]
.as_str()
.unwrap();
assert!(
reason.contains("src/app.js"),
"real match missing: {reason}"
);
assert!(
!reason.contains("node_modules/lib/index.js"),
"node_modules leaked into glob results: {reason}"
);
}
#[test]
fn drip_disable_bypasses_dripignore_for_glob() {
let drip = Drip::new();
let dir = tempfile::tempdir().unwrap();
let mut child = Command::new(&drip.bin)
.args(["hook", "claude-glob"])
.env("DRIP_DATA_DIR", drip.data_dir.path())
.env("DRIP_DISABLE", "1")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.unwrap();
child
.stdin
.as_mut()
.unwrap()
.write_all(
json!({
"tool_name": "Glob",
"tool_input": {
"pattern": "**/*.js",
"path": dir.path().to_string_lossy()
}
})
.to_string()
.as_bytes(),
)
.unwrap();
let o = child.wait_with_output().unwrap();
let s = String::from_utf8_lossy(&o.stdout);
let v: Value = serde_json::from_str(s.trim()).unwrap();
assert_eq!(
v["hookSpecificOutput"]["permissionDecision"].as_str(),
Some("allow"),
"DRIP_DISABLE must short-circuit glob hook to allow: {s}"
);
}
#[test]
fn trailing_slash_pattern_matches_descendants_like_gitignore() {
let drip = Drip::new();
let workspace = tempfile::tempdir().unwrap();
fs::create_dir_all(workspace.path().join("playground/a")).unwrap();
fs::write(workspace.path().join("playground/foo.txt"), "immediate\n").unwrap();
fs::write(workspace.path().join("playground/a/b.txt"), "nested\n").unwrap();
fs::write(workspace.path().join("not-playground.txt"), "sibling\n").unwrap();
fs::write(workspace.path().join(".dripignore"), "playground/\n").unwrap();
let read = |relpath: &str| -> String {
let o = drip
.cmd()
.arg("read")
.arg(relpath)
.current_dir(workspace.path())
.output()
.expect("drip read");
assert!(
o.status.success(),
"drip read {relpath} failed: stderr={}",
String::from_utf8_lossy(&o.stderr)
);
String::from_utf8_lossy(&o.stdout).into_owned()
};
let immediate = read("playground/foo.txt");
assert!(
immediate.contains("ignored by .dripignore"),
"playground/foo.txt should be ignored under `playground/`, got: {immediate}"
);
let nested = read("playground/a/b.txt");
assert!(
nested.contains("ignored by .dripignore"),
"playground/a/b.txt should be ignored under `playground/`, got: {nested}"
);
let outside = read("not-playground.txt");
assert!(
!outside.contains("ignored by .dripignore"),
"not-playground.txt is a sibling, must NOT be ignored: {outside}"
);
}