use std::path::Path;
use crate::error::PawError;
pub const BROKER_HELPER_PATH: &str = ".git-paw/scripts/broker.sh";
#[must_use]
pub fn broker_prefixes() -> Vec<String> {
vec![
BROKER_HELPER_PATH.to_string(),
format!("bash {BROKER_HELPER_PATH}"),
]
}
pub fn setup_curl_allowlist(settings_path: &Path) -> Result<(), PawError> {
let new_entries = broker_prefixes();
let mut value: serde_json::Value = if settings_path.exists() {
let raw = std::fs::read_to_string(settings_path).map_err(|e| {
PawError::ConfigError(format!("failed to read {}: {e}", settings_path.display()))
})?;
if raw.trim().is_empty() {
serde_json::Value::Object(serde_json::Map::new())
} else {
serde_json::from_str(&raw).map_err(|e| {
PawError::ConfigError(format!("{}: invalid JSON: {e}", settings_path.display()))
})?
}
} else {
serde_json::Value::Object(serde_json::Map::new())
};
let obj = value.as_object_mut().ok_or_else(|| {
PawError::ConfigError(format!(
"{}: top-level value must be a JSON object",
settings_path.display()
))
})?;
let entry = obj
.entry("allowed_bash_prefixes".to_string())
.or_insert_with(|| serde_json::Value::Array(Vec::new()));
let array = entry.as_array_mut().ok_or_else(|| {
PawError::ConfigError(format!(
"{}: allowed_bash_prefixes must be an array",
settings_path.display()
))
})?;
for new_entry in new_entries {
let already_present = array
.iter()
.any(|v| v.as_str().is_some_and(|s| s == new_entry));
if !already_present {
array.push(serde_json::Value::String(new_entry));
}
}
if let Some(parent) = settings_path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent).map_err(|e| {
PawError::ConfigError(format!("failed to create {}: {e}", parent.display()))
})?;
}
let serialized = serde_json::to_string_pretty(&value).map_err(|e| {
PawError::ConfigError(format!(
"failed to serialize {}: {e}",
settings_path.display()
))
})?;
std::fs::write(settings_path, serialized).map_err(|e| {
PawError::ConfigError(format!("failed to write {}: {e}", settings_path.display()))
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn read_array(path: &Path) -> Vec<String> {
let raw = std::fs::read_to_string(path).unwrap();
let v: serde_json::Value = serde_json::from_str(&raw).unwrap();
v.get("allowed_bash_prefixes")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|x| x.as_str().map(String::from))
.collect()
})
.unwrap_or_default()
}
#[test]
fn writes_fresh_settings_when_file_absent() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("settings.json");
setup_curl_allowlist(&path).unwrap();
let entries = read_array(&path);
assert!(
entries.iter().any(|s| s == ".git-paw/scripts/broker.sh"),
"must grant the bare helper path; got: {entries:?}"
);
assert!(
entries
.iter()
.any(|s| s == "bash .git-paw/scripts/broker.sh"),
"must grant the `bash <helper>` form; got: {entries:?}"
);
}
#[test]
fn grants_helper_path_not_broad_curl() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("settings.json");
setup_curl_allowlist(&path).unwrap();
let entries = read_array(&path);
assert!(
entries.iter().any(|s| s == ".git-paw/scripts/broker.sh"),
"helper-path grant missing: {entries:?}"
);
for e in &entries {
assert_ne!(e, "curl *", "broad `curl *` grant must never be seeded");
assert!(
!e.starts_with("curl "),
"no `curl` prefix should be seeded; found `{e}`"
);
}
}
#[test]
fn merges_with_existing_entries() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("settings.json");
std::fs::write(
&path,
r#"{"allowed_bash_prefixes":["just check","cargo build"]}"#,
)
.unwrap();
setup_curl_allowlist(&path).unwrap();
let entries = read_array(&path);
assert!(
entries.iter().any(|s| s == "just check"),
"must preserve existing entries"
);
assert!(entries.iter().any(|s| s == "cargo build"));
assert!(entries.iter().any(|s| s == ".git-paw/scripts/broker.sh"));
}
#[test]
fn preserves_stale_curl_prefix_and_adds_helper_path() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("settings.json");
std::fs::write(
&path,
r#"{"allowed_bash_prefixes":["curl -s http://127.0.0.1:9119/publish"]}"#,
)
.unwrap();
setup_curl_allowlist(&path).unwrap();
let entries = read_array(&path);
assert!(
entries
.iter()
.any(|s| s == "curl -s http://127.0.0.1:9119/publish"),
"stale prefix must be preserved (harmless)"
);
assert!(entries.iter().any(|s| s == ".git-paw/scripts/broker.sh"));
}
#[test]
fn does_not_duplicate_existing_helper_grant() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("settings.json");
std::fs::write(
&path,
r#"{"allowed_bash_prefixes":[".git-paw/scripts/broker.sh"]}"#,
)
.unwrap();
setup_curl_allowlist(&path).unwrap();
let entries = read_array(&path);
let count = entries
.iter()
.filter(|s| *s == ".git-paw/scripts/broker.sh")
.count();
assert_eq!(count, 1, "no duplicates allowed");
}
#[test]
fn re_seeding_is_idempotent() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("settings.json");
setup_curl_allowlist(&path).unwrap();
let first = std::fs::read_to_string(&path).unwrap();
setup_curl_allowlist(&path).unwrap();
let second = std::fs::read_to_string(&path).unwrap();
assert_eq!(first, second, "re-seeding must be a no-op");
}
#[test]
fn invalid_json_returns_error_not_panic() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("settings.json");
std::fs::write(&path, "not json {{{ broken").unwrap();
let err = setup_curl_allowlist(&path).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("invalid JSON"), "got: {msg}");
}
#[test]
fn creates_parent_directory_when_missing() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join(".claude").join("settings.json");
assert!(!path.parent().unwrap().exists());
setup_curl_allowlist(&path).unwrap();
assert!(path.exists());
}
#[test]
fn rejects_top_level_array() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("settings.json");
std::fs::write(&path, "[]").unwrap();
let err = setup_curl_allowlist(&path).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("must be a JSON object"), "got: {msg}");
}
}