use std::collections::HashSet;
use std::path::Path;
use anyhow::{bail, Context, Result};
use iroh::EndpointId;
use crate::transport_iroh::parse_endpoint_id;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Policy {
pub read_only: bool,
pub force_command: Option<String>,
}
fn parse_options(rest: &str) -> Result<Policy> {
let mut policy = Policy::default();
if let Some((before, after)) = rest.split_once("command=") {
let (cmd, tail) = if let Some(quoted) = after.strip_prefix('"') {
match quoted.split_once('"') {
Some((inner, tail)) => (inner.to_string(), tail),
None => bail!("unterminated quoted command=\"…\""),
}
} else {
(after.trim().to_string(), "")
};
if cmd.is_empty() {
bail!("empty command= (drop it for an interactive shell)");
}
policy.force_command = Some(cmd);
for tok in before.split_whitespace().chain(tail.split_whitespace()) {
parse_bare_option(tok, &mut policy)?;
}
} else {
for tok in rest.split_whitespace() {
parse_bare_option(tok, &mut policy)?;
}
}
Ok(policy)
}
fn parse_bare_option(tok: &str, policy: &mut Policy) -> Result<()> {
match tok {
"restrict" => policy.read_only = true,
other => {
bail!("unknown allow-file option {other:?} (expected `restrict` or `command=\"…\"`)")
}
}
Ok(())
}
pub fn parse_allow_file(contents: &str) -> Result<Vec<(EndpointId, Policy)>> {
let mut out = Vec::new();
let mut seen = HashSet::new();
for (lineno, raw) in contents.lines().enumerate() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let (id_str, rest) = line
.split_once(|c: char| c.is_whitespace())
.unwrap_or((line, ""));
let one = lineno.saturating_add(1);
let id = parse_endpoint_id(id_str)
.with_context(|| format!("allow-file line {one}: bad endpoint id {id_str:?}"))?;
if !seen.insert(id) {
bail!("allow-file line {one}: duplicate endpoint id {id_str:?}");
}
let policy =
parse_options(rest).with_context(|| format!("allow-file line {one}: bad options"))?;
out.push((id, policy));
}
Ok(out)
}
pub fn load_allow_file(path: &Path) -> Result<Vec<(EndpointId, Policy)>> {
let contents = std::fs::read_to_string(path)
.with_context(|| format!("reading allow-file {}", path.display()))?;
parse_allow_file(&contents)
}
#[cfg(test)]
mod tests {
use super::*;
const ID0: &str = "0000000000000000000000000000000000000000000000000000000000000000";
const ID1: &str = "1111111111111111111111111111111111111111111111111111111111111111";
#[test]
fn bare_id_is_full_access() {
let p = parse_allow_file(ID0).expect("parse");
assert_eq!(p.len(), 1);
assert_eq!(p[0].1, Policy::default());
}
#[test]
fn restrict_sets_read_only() {
let p = parse_allow_file(&format!("{ID0} restrict")).expect("parse");
assert!(p[0].1.read_only);
assert!(p[0].1.force_command.is_none());
}
#[test]
fn quoted_command_keeps_spaces() {
let p = parse_allow_file(&format!("{ID0} command=\"tmux attach -t main\"")).expect("parse");
assert_eq!(p[0].1.force_command.as_deref(), Some("tmux attach -t main"));
assert!(!p[0].1.read_only);
}
#[test]
fn restrict_and_command_compose_either_order() {
let a = parse_allow_file(&format!("{ID0} restrict command=\"top\"")).expect("parse a");
let b = parse_allow_file(&format!("{ID1} command=\"top\" restrict")).expect("parse b");
assert!(a[0].1.read_only && a[0].1.force_command.as_deref() == Some("top"));
assert!(b[0].1.read_only && b[0].1.force_command.as_deref() == Some("top"));
}
#[test]
fn comments_and_blanks_ignored() {
let src = format!("# header\n\n {ID0} \n# trailing\n{ID1} restrict\n");
let p = parse_allow_file(&src).expect("parse");
assert_eq!(p.len(), 2);
}
#[test]
fn unquoted_command_takes_rest_of_line() {
let p = parse_allow_file(&format!("{ID0} command=/usr/bin/uptime")).expect("parse");
assert_eq!(p[0].1.force_command.as_deref(), Some("/usr/bin/uptime"));
}
#[test]
fn unknown_option_is_rejected() {
assert!(parse_allow_file(&format!("{ID0} restict")).is_err());
}
#[test]
fn unterminated_quote_is_rejected() {
assert!(parse_allow_file(&format!("{ID0} command=\"oops")).is_err());
}
#[test]
fn empty_command_is_rejected() {
assert!(parse_allow_file(&format!("{ID0} command=\"\"")).is_err());
}
#[test]
fn bad_id_is_rejected() {
assert!(parse_allow_file("not-a-valid-id restrict").is_err());
}
#[test]
fn duplicate_id_is_rejected() {
assert!(parse_allow_file(&format!("{ID0}\n{ID0} restrict")).is_err());
}
#[test]
fn load_allow_file_reads_and_parses_from_disk() {
let dir = std::env::temp_dir().join(format!("koh-allowfile-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("allow");
std::fs::write(
&path,
format!("# header\n{ID0} restrict\n{ID1} command=\"top\"\n"),
)
.unwrap();
let entries = load_allow_file(&path).expect("loads + parses");
assert_eq!(entries.len(), 2);
assert!(entries[0].1.read_only, "first entry is restrict");
assert_eq!(entries[1].1.force_command.as_deref(), Some("top"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn load_allow_file_missing_path_errors_with_path_in_context() {
let path = std::path::Path::new("/nonexistent/koh-allowfile-probe-xyz");
let err = load_allow_file(path).expect_err("a missing allow-file must error");
assert!(
format!("{err:#}").contains("koh-allowfile-probe-xyz"),
"the error should name the path; got: {err:#}"
);
}
}