use std::collections::HashMap;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use sha2::{Digest, Sha256};
use super::build::{insert_spec, load_toml};
use super::types::CommandSpec;
const REPO_FILENAME: &str = ".safe-chains.toml";
const USER_FILENAME: &str = "safe-chains.toml";
#[derive(Deserialize)]
struct TrustedEntry {
path: String,
sha256: String,
}
#[derive(Deserialize)]
struct TrustedConfig {
#[serde(default)]
trusted: Vec<TrustedEntry>,
#[serde(default)]
level: Option<String>,
}
fn find_repo_custom() -> Option<PathBuf> {
let mut dir = env::current_dir().ok()?;
loop {
let candidate = dir.join(REPO_FILENAME);
if candidate.is_file() {
return Some(candidate);
}
if !dir.pop() {
return None;
}
}
}
fn find_user_custom() -> Option<PathBuf> {
let dir = env::var_os("HOME").map(|h| PathBuf::from(h).join(".config"))?;
let candidate = dir.join(USER_FILENAME);
candidate.is_file().then_some(candidate)
}
fn parse_trusted(source: &str) -> Vec<TrustedEntry> {
toml::from_str::<TrustedConfig>(source)
.map(|c| c.trusted)
.unwrap_or_default()
}
fn parse_level(source: &str) -> Option<String> {
toml::from_str::<TrustedConfig>(source).ok()?.level
}
pub(crate) fn user_config_level() -> Option<String> {
if env::var_os("SAFE_CHAINS_NO_LOCAL").is_some() {
return None;
}
let path = find_user_custom()?;
let source = fs::read_to_string(&path).ok()?;
parse_level(&source)
}
fn sha256_hex(bytes: &[u8]) -> String {
Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect()
}
fn repo_is_trusted(repo_file: &Path, bytes: &[u8], trusted: &[TrustedEntry]) -> bool {
let Some(parent) = repo_file.parent() else {
return false;
};
let Ok(dir) = fs::canonicalize(parent) else {
return false;
};
let hash = sha256_hex(bytes);
trusted.iter().any(|t| {
t.sha256.trim().eq_ignore_ascii_case(&hash)
&& fs::canonicalize(&t.path).map(|p| p == dir).unwrap_or(false)
})
}
#[doc(hidden)]
pub fn fuzz_load_config(source: &str, repo_scope: bool) -> usize {
let path = Path::new("<fuzz>");
let category = if repo_scope { "custom-project" } else { "custom-user" };
let specs = load_custom_file(source, category, path);
if repo_scope {
return specs.into_iter().map(repo_scoped).filter(|s| s.output.is_none()).count();
}
specs.len()
}
fn load_custom_file(source: &str, category: &str, path: &Path) -> Vec<CommandSpec> {
if let Err(e) = toml::from_str::<toml::Value>(source) {
return skip(path, &e.to_string());
}
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| load_toml(source, category))) {
Ok(specs) => specs,
Err(payload) => {
let why = payload
.downcast_ref::<String>()
.map(String::as_str)
.or_else(|| payload.downcast_ref::<&str>().copied())
.unwrap_or("unrecognized command definition");
skip(path, why)
}
}
}
fn skip(path: &Path, why: &str) -> Vec<CommandSpec> {
eprintln!(
"safe-chains: ignoring {} — {why}\n Built-in commands are unaffected; fix the file to \
re-enable your custom ones.",
path.display()
);
Vec::new()
}
fn repo_scoped(mut spec: CommandSpec) -> CommandSpec {
spec.output = None;
spec
}
pub(super) fn apply_custom(map: &mut HashMap<String, CommandSpec>) {
if env::var_os("SAFE_CHAINS_NO_LOCAL").is_some() {
return;
}
let mut trusted = Vec::new();
if let Some(path) = find_user_custom()
&& let Ok(source) = fs::read_to_string(&path)
{
for spec in load_custom_file(&source, "custom-user", &path) {
insert_spec(map, spec);
}
trusted = parse_trusted(&source);
}
if let Some(repo_file) = find_repo_custom()
&& let Ok(bytes) = fs::read(&repo_file)
&& repo_is_trusted(&repo_file, &bytes, &trusted)
&& let Ok(source) = std::str::from_utf8(&bytes)
{
for spec in load_custom_file(source, "custom-project", &repo_file) {
insert_spec(map, repo_scoped(spec));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_config_that_does_not_parse_loads_nothing() {
for broken in [
"this is not valid toml [[[",
"[[command]]\nname = 12345\n",
"[[command]]\nname = \"x\"\nlevel = \"not-a-level\"\n",
"[[command]]\nname = \"x\"\nbehavior = { hook = \"no-such-hook\" }\n",
"\u{0}\u{1}\u{2}",
"[[command]]",
] {
for repo_scope in [false, true] {
assert_eq!(
super::fuzz_load_config(broken, repo_scope),
0,
"a broken config loaded definitions (repo_scope={repo_scope}): {broken:?}"
);
}
}
let good = "[[command]]\nname = \"fuzzprobe\"\nmax_positional = 1\nlevel = \"SafeWrite\"\n";
assert!(
super::fuzz_load_config(good, false) > 0,
"a valid config must load; otherwise the fail-safe assertions prove nothing"
);
}
#[test]
fn an_invalid_custom_config_is_skipped_not_fatal() {
let path = Path::new("/tmp/does-not-matter.toml");
let specs = load_custom_file("this is not valid toml [[[", "custom-user", path);
assert!(specs.is_empty(), "an unparseable file must contribute no commands");
let bogus = "[[command]]\nname = \"zz\"\nlevel = \"Inert\"\n\n\
[command.behavior]\noperation = \"observe\"\npositionals = \"read\"\n\
hook = \"bogus\"\n";
let specs = load_custom_file(bogus, "custom-user", path);
assert!(specs.is_empty(), "an unvalidatable file must contribute no commands");
let good = "[[command]]\nname = \"frobnicate\"\nlevel = \"Inert\"\nbare = true\n";
let specs = load_custom_file(good, "custom-user", path);
assert_eq!(specs.len(), 1, "a valid custom file must still load");
assert_eq!(specs[0].name, "frobnicate");
}
#[test]
fn repo_custom_toml_cannot_declare_command_output() {
let source = r#"
[[command]]
name = "echo"
description = "hijacked"
level = "Inert"
bare = true
[command.output]
locus_from = "cwd"
"#;
let user: Vec<_> = load_toml(source, "custom-user").into_iter().collect();
assert!(
user.iter().any(|s| s.output.is_some()),
"the user config must still be able to declare an output locus, or this guard is \
testing the parser rather than the restriction",
);
let stripped: Vec<_> =
load_toml(source, "custom-project").into_iter().map(repo_scoped).collect();
assert!(
stripped.iter().all(|s| s.output.is_none()),
"a repo-level custom TOML must not be able to declare `[command.output]`",
);
}
#[test]
fn sha256_hex_known_vectors() {
assert_eq!(
sha256_hex(b""),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
assert_eq!(
sha256_hex(b"abc"),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
}
#[test]
fn parse_trusted_reads_entries() {
let src = r#"
[[trusted]]
path = "/a/b"
sha256 = "abc123"
[[trusted]]
path = "/c/d"
sha256 = "def456"
"#;
let t = parse_trusted(src);
assert_eq!(t.len(), 2);
assert_eq!(t[0].path, "/a/b");
assert_eq!(t[1].sha256, "def456");
}
#[test]
fn parse_level_reads_the_ceiling() {
assert_eq!(parse_level("level = \"network-admin\"").as_deref(), Some("network-admin"));
assert_eq!(
parse_level("level = \"yolo\"\n[[trusted]]\npath = \"/a\"\nsha256 = \"x\"\n").as_deref(),
Some("yolo"),
);
assert!(parse_level("[[trusted]]\npath = \"/a\"\nsha256 = \"x\"\n").is_none());
assert!(parse_level("not valid toml {{{").is_none());
assert!(parse_level("").is_none());
}
#[test]
fn parse_trusted_absent_or_malformed_is_empty() {
assert!(parse_trusted("[[command]]\nname = \"x\"").is_empty());
assert!(parse_trusted("not valid toml {{{").is_empty());
assert!(parse_trusted("").is_empty());
}
#[test]
fn load_toml_tolerates_trusted_sections() {
assert!(load_toml("[[trusted]]\npath = \"/a\"\nsha256 = \"x\"\n", "custom-user").is_empty());
let specs = load_toml(
"[[command]]\nname = \"myco\"\nbare = true\n\n[[trusted]]\npath = \"/a\"\nsha256 = \"x\"\n",
"custom-user",
);
assert_eq!(specs.len(), 1);
}
fn write_repo_file(dir: &Path, body: &str) -> PathBuf {
let f = dir.join(REPO_FILENAME);
fs::write(&f, body).unwrap();
f
}
#[test]
fn repo_trusted_when_path_and_hash_match() {
let dir = tempfile::tempdir().unwrap();
let body = "[[command]]\nname = \"myco\"\n";
let f = write_repo_file(dir.path(), body);
let canon = fs::canonicalize(dir.path()).unwrap();
let trusted = vec![TrustedEntry {
path: canon.to_string_lossy().into_owned(),
sha256: sha256_hex(body.as_bytes()),
}];
assert!(repo_is_trusted(&f, body.as_bytes(), &trusted));
}
#[test]
fn repo_untrusted_when_hash_differs() {
let dir = tempfile::tempdir().unwrap();
let f = write_repo_file(dir.path(), "[[command]]\nname = \"myco\"\n");
let canon = fs::canonicalize(dir.path()).unwrap();
let trusted = vec![TrustedEntry {
path: canon.to_string_lossy().into_owned(),
sha256: sha256_hex(b"different content"),
}];
let tampered = b"[[command]]\nname = \"curl\"\nlevel = \"Inert\"\n";
assert!(!repo_is_trusted(&f, tampered, &trusted));
}
#[test]
fn repo_untrusted_when_path_not_listed() {
let dir = tempfile::tempdir().unwrap();
let body = "[[command]]\nname = \"myco\"\n";
let f = write_repo_file(dir.path(), body);
let trusted = vec![TrustedEntry {
path: "/some/other/dir".to_string(),
sha256: sha256_hex(body.as_bytes()),
}];
assert!(!repo_is_trusted(&f, body.as_bytes(), &trusted));
}
#[test]
fn repo_untrusted_when_list_empty() {
let dir = tempfile::tempdir().unwrap();
let body = "[[command]]\nname = \"myco\"\n";
let f = write_repo_file(dir.path(), body);
assert!(!repo_is_trusted(&f, body.as_bytes(), &[]));
}
}