diff --git a/Cargo.toml b/Cargo.toml index e2874e6..3f79916 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ A Rust library for using Sigsum transparency logs. # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +base16ct = "0.3.0" base64ct = { version = "1.8.0", features = ["alloc"] } ed25519-dalek = "2.2.0" sha2 = "0.10.8" diff --git a/src/policy/mod.rs b/src/policy/mod.rs index 0afd0d9..e27c63b 100644 --- a/src/policy/mod.rs +++ b/src/policy/mod.rs @@ -2,6 +2,10 @@ use std::collections::HashMap; use crate::crypto::{Hash, PublicKey}; +mod parsing; + +pub use parsing::ParsePolicyError; + /// A Sigsum policy. /// /// The Sigsum policy dictates if a signed tree head is considered valid (and by extension, if a diff --git a/src/policy/parsing.rs b/src/policy/parsing.rs new file mode 100644 index 0000000..91b077b --- /dev/null +++ b/src/policy/parsing.rs @@ -0,0 +1,368 @@ +use std::iter::Enumerate; +use std::str::Lines; + +use super::{Policy, PolicyBuilder}; +use crate::crypto::PublicKey; + +#[derive(Debug, thiserror::Error)] +#[error("line {lineno}: {reason}")] +pub struct ParsePolicyError { + lineno: usize, + reason: String, +} + +macro_rules! bail { + ($lineno:expr, $($farg:tt)*) => { + return Err(ParsePolicyError{lineno: $lineno, reason: format!($($farg)*)}) + }; +} + +type Result = std::result::Result; + +impl Policy { + /// Parse a policy from its string representation according to the + /// [sigsum-go spec](https://git.glasklar.is/sigsum/core/sigsum-go/-/blob/main/doc/policy.md). + pub fn parse(data: &str) -> Result { + parse_policy(data) + } +} + +fn parse_policy(data: &str) -> Result { + let mut builder = PolicyBuilder::new(); + let lines = PolicyLines::new(data); + for (lineno, tokens) in lines { + match tokens[..] { + ["log", ..] => { + let nargs = tokens.len() - 1; + if !(1..=2).contains(&nargs) { + bail!( + lineno, + "invalid log rule: expected 1 or 2 arguments, got {nargs}" + ); + } + let pubkey = parse_key(tokens[1], lineno)?; + let url = tokens.get(2).map(|s| (*s).into()); + if let Err(err) = builder.add_log(pubkey, url) { + bail!(lineno, "invalid log rule: {err}") + } + } + ["witness", ..] => { + let nargs = tokens.len() - 1; + if !(2..=3).contains(&nargs) { + bail!( + lineno, + "invalid witness rule: expected 2 or 3 arguments, got {nargs}" + ); + } + let name = tokens[1]; + let pubkey = parse_key(tokens[2], lineno)?; + let url = tokens.get(3).map(|s| (*s).into()); + if let Err(err) = builder.add_witness(name.into(), pubkey, url) { + bail!(lineno, "invalid witness rule: {err}") + } + } + ["group", ..] => { + let nargs = tokens.len() - 1; + if nargs < 3 { + bail!( + lineno, + "invalid group rule: expected at least 3 arguments, got {nargs}" + ); + } + let name = tokens[1]; + let k = tokens[2]; + let members: Vec<_> = tokens[3..].iter().map(|n| (*n).into()).collect(); + let Some(k) = parse_k(k, members.len()) else { + bail!(lineno, "invalid group rule: cannot parse {k} as an integer"); + }; + if let Err(err) = builder.add_group(name.into(), k, members) { + bail!(lineno, "invalid group rule: {err}"); + } + } + ["quorum", name] => { + if name != "none" { + if let Err(err) = builder.set_quorum(name.into()) { + bail!(lineno, "invalid quorum rule: {err}"); + } + } + } + ["quorum", ..] => bail!(lineno, "invalid quorum rule: expected exactly one argument"), + [unknown, ..] => bail!(lineno, "unknown keyword `{unknown}`"), + [] => unreachable!("PolicyLines skips empty lines"), + } + } + Ok(builder.build()) +} + +fn parse_k(k: &str, n: usize) -> Option { + if k == "any" { + Some(1) + } else if k == "all" { + Some(n) + } else { + k.parse().ok() + } +} + +fn parse_key(hex: &str, lineno: usize) -> Result { + if base16ct::decoded_len(hex.as_bytes()) != Ok(32) { + return Err(ParsePolicyError { + lineno, + reason: String::from("invalid public key: wrong length"), + }); + } + let mut buf = [0; 32]; + match base16ct::mixed::decode(hex, &mut buf) { + Err(_) => Err(ParsePolicyError { + lineno, + reason: "invalid public key: invalid hex encoding".to_string(), + }), + Ok(_) => Ok(buf.into()), + } +} + +// PolicyLines is essentially a lexer for parsing policy files. +// +// It implements a custom iterator that handles most low-level details of parsing policy files, +// splitting, numbering, filtering and tokenizing lines. +struct PolicyLines<'a>(Enumerate>); + +impl<'a> PolicyLines<'a> { + fn new(input: &'a str) -> Self { + Self(input.lines().enumerate()) + } +} + +impl<'a> Iterator for PolicyLines<'a> { + // Return a pair line number + tokens + type Item = (usize, Vec<&'a str>); + + // next() is the only required method + fn next(&mut self) -> Option { + loop { + let (i, line) = self.0.next()?; + let line = line.trim_ascii(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let tokens = line.split_ascii_whitespace().collect(); + return Some((i + 1, tokens)); + } + } +} + +#[cfg(test)] +mod tests { + use hex_literal::hex; + + use super::*; + + #[test] + fn policy_lines_empty() { + let pls: Vec<(usize, Vec<&str>)> = PolicyLines::new("").collect(); + assert_eq!(pls, vec![]); + } + + #[test] + fn policy_lines() { + let pls: Vec<(usize, Vec<&str>)> = PolicyLines::new( + "# This is a comment\n\ + foo bar\n\ + \n\ + # The next line uses tabs\n\ + foo\tbar\n\ + \n\ + # This one mixes whitespaces\n\ + foo \tbar\t baz \t biz\n\ + \n\ + # Heading/trailing spaces\n\ + \t\n\ + \t \n\ + \t# Indented comment\n\ + foo\t \n\ + \n\ + # Unicode space\n\ + foo\u{2003}bar\n\ + \u{2003}\n\ + ", + ) + .collect(); + assert_eq!( + pls, + vec![ + (2, vec!["foo", "bar"]), + (5, vec!["foo", "bar"]), + (8, vec!["foo", "bar", "baz", "biz"]), + (14, vec!["foo"]), + (17, vec!["foo\u{2003}bar"]), + (18, vec!["\u{2003}"]), + ] + ); + } + + #[test] + fn parse_policy_empty() { + let expected = PolicyBuilder::new().build(); + let actual = parse_policy("").unwrap(); + assert_eq!(expected, actual); + } + + #[test] + fn parse_logs() { + let expected = { + let mut b = PolicyBuilder::new(); + b.add_log( + hex!("fd07e34679d68f7042f0d2d3d21e956abdd0b56b1abc3d659b2b51f8e40a113e").into(), + None, + ) + .unwrap(); + b.add_log( + hex!("348129817da7b3cbf1f87e0d82b65bc300869e4b65ba40f0f0c23588c5279d2b").into(), + Some("http://example.com".into()), + ) + .unwrap(); + b.build() + }; + let actual = + parse_policy("\ + log fd07e34679d68f7042f0d2d3d21e956abdd0b56b1abc3d659b2b51f8e40a113e\n\ + log 348129817da7b3cbf1f87e0d82b65bc300869e4b65ba40f0f0c23588c5279d2b http://example.com\n\ + quorum none + ").unwrap(); + assert_eq!(expected, actual); + } + + #[test] + fn parse_witnesses() { + let expected = { + let mut b = PolicyBuilder::new(); + b.add_witness( + "WIT-1".into(), + hex!("acf9f514e85ba44ebf00faf1bc36a16c42f2915c7b2728b489223fdcf3f2b6e3").into(), + None, + ) + .unwrap(); + b.add_witness( + "WIT-2".into(), + hex!("df329030f76b3616f1c50f3f8ae7ce6cf3fa92905ab7ce47bbc8be71226b65c9").into(), + Some("http://example.com/witness".into()), + ) + .unwrap(); + b.build() + }; + let actual = parse_policy("\ + witness WIT-1 acf9f514e85ba44ebf00faf1bc36a16c42f2915c7b2728b489223fdcf3f2b6e3\n\ + witness WIT-2 df329030f76b3616f1c50f3f8ae7ce6cf3fa92905ab7ce47bbc8be71226b65c9 http://example.com/witness\n\ + quorum none\n\ + ").unwrap(); + assert_eq!(expected, actual); + } + + #[test] + fn parse_group() { + let expected = { + let mut b = PolicyBuilder::new(); + b.add_witness( + "WIT-1".into(), + hex!("acf9f514e85ba44ebf00faf1bc36a16c42f2915c7b2728b489223fdcf3f2b6e3").into(), + None, + ) + .unwrap(); + b.add_witness( + "WIT-2".into(), + hex!("df329030f76b3616f1c50f3f8ae7ce6cf3fa92905ab7ce47bbc8be71226b65c9").into(), + Some("http://example.com/witness".into()), + ) + .unwrap(); + b.add_group("GRP".into(), 1, vec!["WIT-1".into(), "WIT-2".into()]) + .unwrap(); + b.set_quorum("GRP".into()).unwrap(); + b.build() + }; + let actual = parse_policy("\ + witness WIT-1 acf9f514e85ba44ebf00faf1bc36a16c42f2915c7b2728b489223fdcf3f2b6e3\n\ + witness WIT-2 df329030f76b3616f1c50f3f8ae7ce6cf3fa92905ab7ce47bbc8be71226b65c9 http://example.com/witness\n\ + group GRP 1 WIT-1 WIT-2\n\ + quorum GRP\n\ + ").unwrap(); + assert_eq!(expected, actual); + } + + #[test] + fn parse_policy_invalid_public_key() { + insta::assert_snapshot!( + parse_policy("log fd07e34679d68f7042f0d2d3d21e956abdd0b56b1abc3d659b2b51f8e40a11").unwrap_err(), + @"line 1: invalid public key: wrong length", + ); + insta::assert_snapshot!( + parse_policy("log xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx").unwrap_err(), + @"line 1: invalid public key: invalid hex encoding", + ); + } + + #[test] + fn parse_policy_invalid_log_rule() { + insta::assert_snapshot!( + parse_policy("log").unwrap_err(), + @"line 1: invalid log rule: expected 1 or 2 arguments, got 0", + ); + insta::assert_snapshot!( + parse_policy("log fd07e34679d68f7042f0d2d3d21e956abdd0b56b1abc3d659b2b51f8e40a113e https://log.io/ xxx").unwrap_err(), + @"line 1: invalid log rule: expected 1 or 2 arguments, got 3", + ); + } + + #[test] + fn parse_policy_invalid_witness_rule() { + insta::assert_snapshot!( + parse_policy("witness foo").unwrap_err(), + @"line 1: invalid witness rule: expected 2 or 3 arguments, got 1", + ); + insta::assert_snapshot!( + parse_policy("witness foo fd07e34679d68f7042f0d2d3d21e956abdd0b56b1abc3d659b2b51f8e40a113e https://log.io/ xxx").unwrap_err(), + @"line 1: invalid witness rule: expected 2 or 3 arguments, got 4", + ); + } + + #[test] + fn parse_policy_invalid_group_rule() { + insta::assert_snapshot!( + parse_policy("group foo any").unwrap_err(), + @"line 1: invalid group rule: expected at least 3 arguments, got 2", + ); + } + + #[test] + fn parse_policy_invalid_quorum_rule() { + insta::assert_snapshot!( + parse_policy("quorum").unwrap_err(), + @"line 1: invalid quorum rule: expected exactly one argument", + ); + insta::assert_snapshot!( + parse_policy("quorum foo bar").unwrap_err(), + @"line 1: invalid quorum rule: expected exactly one argument", + ); + insta::assert_snapshot!( + parse_policy("quorum foo").unwrap_err(), + @"line 1: invalid quorum rule: foo: no sutch witness", + ); + } + + #[test] + fn parse_policy_multiple_quorum() { + insta::assert_snapshot!( + parse_policy("\ + witness WIT-1 acf9f514e85ba44ebf00faf1bc36a16c42f2915c7b2728b489223fdcf3f2b6e3\n\ + witness WIT-2 df329030f76b3616f1c50f3f8ae7ce6cf3fa92905ab7ce47bbc8be71226b65c9\n\ + quorum WIT-1\n\ + quorum WIT-2\n\ + ").unwrap_err(), + @"line 4: invalid quorum rule: quorum already set", + ); + } + + #[test] + fn parse_policy_unknown_keyword() { + insta::assert_snapshot!(parse_policy("foo bar").unwrap_err(), @"line 1: unknown keyword `foo`") + } +}