use crate::manifest::MsgType;
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Subject {
Key(String),
Name(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Grant {
pub subject: Subject,
pub msg_type: Option<MsgType>,
pub in_reply_only: bool,
pub auto_accept: bool,
pub max_exchanges: Option<u64>,
pub line: usize,
}
impl fmt::Display for Grant {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let who = match &self.subject {
Subject::Key(k) => k.as_str(),
Subject::Name(n) => n.as_str(),
};
write!(f, "allow {who} {}", self.msg_type.map_or("any", |t| t.label()))?;
if self.in_reply_only {
write!(f, " in-reply-only")?;
}
if self.auto_accept {
write!(f, " auto-accept")?;
}
if let Some(n) = self.max_exchanges {
write!(f, " max-exchanges {n}")?;
}
Ok(())
}
}
#[derive(Debug, Default)]
pub struct Authority {
pub grants: Vec<Grant>,
pub present: bool,
pub error: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Denied {
NoFile,
NoMatch,
Unreadable,
NotVerified,
}
pub fn path() -> std::path::PathBuf {
crate::identity::home().join("authority")
}
impl Authority {
pub fn load() -> Self {
let Ok(text) = std::fs::read_to_string(path()) else {
return Self::default();
};
match parse(&text) {
Ok(grants) => Self { grants, present: true, error: None },
Err(e) => Self { grants: Vec::new(), present: true, error: Some(e) },
}
}
pub fn lookup(
&self,
contacts: &crate::trust::Contacts,
trust: crate::trust::Trust,
key: &str,
msg_type: MsgType,
is_reply: bool,
) -> Result<&Grant, Denied> {
if trust != crate::trust::Trust::Known {
return Err(Denied::NotVerified);
}
if let Some(_) = &self.error {
return Err(Denied::Unreadable);
}
if !self.present {
return Err(Denied::NoFile);
}
self.grants
.iter()
.find(|g| {
let who = match &g.subject {
Subject::Key(k) => k == key,
Subject::Name(n) => contacts.name_for(key) == Some(n.as_str()),
};
let kind = g.msg_type.is_none_or(|t| t == msg_type);
who && kind && (!g.in_reply_only || is_reply)
})
.ok_or(Denied::NoMatch)
}
}
pub fn answers_something_i_sent(
home: &std::path::Path,
m: &crate::manifest::Manifest,
) -> Option<Parent> {
let parent = m.in_reply_to.as_deref()?;
if !crate::manifest::id_is_safe(parent) {
return None;
}
let p = crate::routes::sent_dir(home).join(parent).join("manifest.json");
let bytes = std::fs::read(&p).ok()?;
let parent_m = serde_json::from_slice::<crate::manifest::Manifest>(&bytes).ok()?;
let me = crate::identity::Identity::load().ok()?;
if parent_m.from.key != me.public_b64() {
return None;
}
Some(Parent { id: parent.to_string(), thread: parent_m.thread_id().to_string() })
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Parent {
pub id: String,
pub thread: String,
}
fn looks_like_a_key(tok: &str) -> bool {
tok.len() >= 20 && tok.chars().all(|c| c.is_ascii_alphanumeric() || "+/=_-".contains(c))
}
pub fn parse(text: &str) -> Result<Vec<Grant>, String> {
let mut out = Vec::new();
for (i, raw) in text.lines().enumerate() {
let line = i + 1;
let body = raw.split('#').next().unwrap_or("").trim();
if body.is_empty() {
continue;
}
let mut tok = body.split_whitespace();
let bad = |msg: &str| Err(format!("{}: line {line}: {msg}", path().display()));
if tok.next() != Some("allow") {
return bad("every line must start with `allow`");
}
let Some(who) = tok.next() else {
return bad("`allow` needs a contact name or a key");
};
let Some(kind) = tok.next() else {
return bad("`allow <who>` needs a message kind, or the word `any`");
};
let msg_type = if kind == "any" {
None
} else {
match MsgType::parse(kind) {
Some(t) => Some(t),
None => return bad(&format!("unknown message kind `{kind}`")),
}
};
let mut in_reply_only = false;
let mut auto_accept = false;
let mut max_exchanges = None;
while let Some(extra) = tok.next() {
match extra {
"in-reply-only" => in_reply_only = true,
"auto-accept" => auto_accept = true,
"max-exchanges" => {
let Some(n) = tok.next() else {
return bad("`max-exchanges` needs a number");
};
match n.parse::<u64>() {
Ok(0) => return bad("`max-exchanges 0` grants nothing; delete the line"),
Ok(v) => max_exchanges = Some(v),
Err(_) => return bad(&format!("`max-exchanges {n}` is not a number")),
}
}
other => return bad(&format!("unknown modifier `{other}`")),
}
}
if auto_accept {
if max_exchanges.is_none() {
return bad(
"`auto-accept` needs `max-exchanges N`: unbounded unattended \
acceptance is not something to grant by omission",
);
}
if msg_type.is_none() {
return bad("`auto-accept` cannot be combined with `any`: name the one kind");
}
in_reply_only = true;
}
out.push(Grant {
subject: if looks_like_a_key(who) {
Subject::Key(who.to_string())
} else {
Subject::Name(who.to_string())
},
msg_type,
in_reply_only,
auto_accept,
max_exchanges,
line,
});
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::trust::{Contacts, Trust};
fn contacts() -> Contacts {
let mut c = Contacts::default();
c.keys.insert("CODEXKEY".into(), "codex".into());
c.keys.insert("STRANGERKEY".into(), "mallory".into());
c
}
fn authority(text: &str) -> Authority {
Authority { grants: parse(text).expect("fixture parses"), present: true, error: None }
}
#[test]
fn a_grant_covers_the_kind_and_the_key_it_names() {
let a = authority("allow codex review-request\n");
let c = contacts();
assert!(a
.lookup(&c, Trust::Known, "CODEXKEY", MsgType::ReviewRequest, false)
.is_ok());
assert_eq!(
a.lookup(&c, Trust::Known, "CODEXKEY", MsgType::Question, false),
Err(Denied::NoMatch)
);
assert_eq!(
a.lookup(&c, Trust::Known, "STRANGERKEY", MsgType::ReviewRequest, false),
Err(Denied::NoMatch)
);
}
#[test]
fn no_verdict_short_of_verified_reaches_a_grant() {
let a = authority("allow codex any\n");
let c = contacts();
for t in [
Trust::Unknown,
Trust::Unverified,
Trust::Retired,
Trust::Revoked,
Trust::Unwitnessed,
] {
assert_eq!(
a.lookup(&c, t, "CODEXKEY", MsgType::ReviewRequest, false),
Err(Denied::NotVerified),
"{t:?} reached a grant"
);
}
assert!(a
.lookup(&c, Trust::Known, "CODEXKEY", MsgType::ReviewRequest, false)
.is_ok());
}
#[test]
fn one_bad_line_voids_the_whole_file() {
let text = "allow codex review-request\nallow codex reviewrequest\n";
let e = parse(text).expect_err("second line is not a kind");
assert!(e.contains("line 2"), "{e}");
let a = Authority { grants: Vec::new(), present: true, error: Some(e) };
assert_eq!(
a.lookup(&contacts(), Trust::Known, "CODEXKEY", MsgType::ReviewRequest, false),
Err(Denied::Unreadable)
);
}
#[test]
fn a_name_is_resolved_through_your_own_contacts_only() {
let a = authority("allow codex any\n");
let mut c = contacts();
assert!(a.lookup(&c, Trust::Known, "CODEXKEY", MsgType::Share, false).is_ok());
c.keys.insert("CODEXKEY".into(), "codex-old".into());
assert_eq!(
a.lookup(&c, Trust::Known, "CODEXKEY", MsgType::Share, false),
Err(Denied::NoMatch)
);
}
#[test]
fn in_reply_only_cannot_be_used_to_start_something() {
let a = authority("allow codex question in-reply-only\n");
let c = contacts();
assert_eq!(
a.lookup(&c, Trust::Known, "CODEXKEY", MsgType::Question, false),
Err(Denied::NoMatch)
);
assert!(a.lookup(&c, Trust::Known, "CODEXKEY", MsgType::Question, true).is_ok());
}
#[test]
fn an_absent_file_is_distinguishable_from_an_empty_one() {
let c = contacts();
let missing = Authority::default();
assert_eq!(
missing.lookup(&c, Trust::Known, "CODEXKEY", MsgType::Share, false),
Err(Denied::NoFile)
);
let empty = authority("# nothing granted yet\n");
assert_eq!(
empty.lookup(&c, Trust::Known, "CODEXKEY", MsgType::Share, false),
Err(Denied::NoMatch)
);
}
#[test]
fn a_key_is_accepted_in_place_of_a_name() {
let a = authority("allow CODEXKEY0000000000000000 answer\n");
let mut c = contacts();
c.keys.insert("CODEXKEY0000000000000000".into(), "whatever".into());
assert!(a
.lookup(&c, Trust::Known, "CODEXKEY0000000000000000", MsgType::Answer, false)
.is_ok());
}
#[test]
fn comments_and_blank_lines_are_not_grants() {
let g = parse("\n# allow mallory any\n \nallow codex ack # trailing\n").unwrap();
assert_eq!(g.len(), 1);
assert_eq!(g[0].line, 4);
assert_eq!(g[0].msg_type, Some(MsgType::Ack));
}
#[test]
fn a_line_that_is_not_a_grant_is_refused_rather_than_skipped() {
for text in [
"deny codex any\n",
"allow\n",
"allow codex\n",
"allow codex any forever\n",
] {
assert!(parse(text).is_err(), "accepted {text:?}");
}
}
}