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)
})
}
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_toml(&source, "custom-user") {
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_toml(source, "custom-project") {
insert_spec(map, spec);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[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(), &[]));
}
}