diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 927563e..9a8ed5e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,6 +90,28 @@ jobs: with: configFile: .commitlintrc.json + rust: + name: Rust (test + clippy + fmt) + # Compiles the rule pipeline, runs the integration tests, and enforces + # clippy and rustfmt. The Hermit target lands in PR B/C; this job stays + # host-only. + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Install Rust toolchain (per rust-toolchain.toml) + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - name: Cache Cargo registry + build + uses: Swatinem/rust-cache@v2 + - name: cargo fmt --check + run: cargo fmt --all -- --check + - name: cargo clippy + run: cargo clippy --all-targets -- -D warnings + - name: cargo test + run: cargo test --all-targets + all-checks: name: All Checks # Aggregator job whose single status is what branch-protection rulesets @@ -101,6 +123,7 @@ jobs: - schema-validate - link-check - conventional-commits + - rust runs-on: ubuntu-latest steps: - name: Verify all checks passed diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..aaa79a2 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,153 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libyml" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3302702afa434ffa30847a83305f0a69d6abd74293b6554c18ec85c7ef30c980" +dependencies = [ + "anyhow", + "version_check", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_yml" +version = "0.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd" +dependencies = [ + "indexmap", + "itoa", + "libyml", + "memchr", + "ryu", + "serde", + "version_check", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thurward" +version = "0.0.0" +dependencies = [ + "serde", + "serde_yml", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..ebcf068 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,47 @@ +[package] +name = "thurward" +version = "0.0.0" +edition = "2024" +rust-version = "1.85" +description = "minimalistic unikernel firewall with FQDN filtering and first-class observability" +license = "TBD" +publish = false + +[lib] +# The compile-time rule pipeline lives here so build.rs and integration tests +# can share definitions. PRs B/C add a separate dataplane module for the +# Hermit runtime. +path = "src/lib.rs" + +# Single bin target; PR A only needs `cargo test` to be green, the bin compiles +# trivially against the host. PR B switches to the x86_64-unknown-hermit target. +[[bin]] +name = "thurward" +path = "src/main.rs" + +# Runtime deps. Kept lean for PR A — smoltcp arrives in PR C with the dataplane. +[dependencies] +# serde + serde_yml: YAML deserialization for examples/rules.yaml. +# serde_yml is an actively-maintained fork; the original serde_yaml is in +# read-only maintenance and we want a maintained dep for the build pipeline. +serde = { version = "1", features = ["derive"] } +serde_yml = "0.0.12" + +# Build-script-only deps. Same crates as runtime here; the split exists for +# when PR C/D add runtime-only deps (smoltcp etc.) that build.rs doesn't need. +[build-dependencies] +serde = { version = "1", features = ["derive"] } +serde_yml = "0.0.12" + +[dev-dependencies] +# Integration tests stay on the host toolchain. + +[profile.release] +opt-level = 3 +lto = "thin" +codegen-units = 1 +panic = "abort" +strip = true + +[profile.dev] +panic = "abort" diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..f639d5c --- /dev/null +++ b/build.rs @@ -0,0 +1,71 @@ +//! Compile `examples/rules.yaml` into `$OUT_DIR/rules_table.rs` at build +//! time. Per ADR 0005 / `docs/architecture/03-rule-model.md`. +//! +//! Re-runs when `examples/rules.yaml` changes (Cargo's `rerun-if-changed`) +//! AND when any rule-pipeline source changes (because they define the +//! types the generated code references). + +#[path = "src/rules/compiler.rs"] +mod compiler; + +#[path = "src/rules/types.rs"] +pub mod types; + +use std::path::PathBuf; + +fn main() { + let manifest_dir = + PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR")); + let rules_yaml = manifest_dir.join("examples/rules.yaml"); + let out_dir = PathBuf::from(std::env::var("OUT_DIR").expect("OUT_DIR")); + let out_file = out_dir.join("rules_table.rs"); + + println!("cargo:rerun-if-changed={}", rules_yaml.display()); + println!("cargo:rerun-if-changed=src/rules/compiler.rs"); + println!("cargo:rerun-if-changed=src/rules/types.rs"); + println!("cargo:rerun-if-changed=build.rs"); + + let yaml_text = std::fs::read_to_string(&rules_yaml).unwrap_or_else(|e| { + panic!( + "thurward build.rs: cannot read {}: {e}\n\ + (this file is the source of truth for the compiled rule table;\n\ + see docs/architecture/03-rule-model.md)", + rules_yaml.display() + ); + }); + + let source = compiler::compile(&yaml_text).unwrap_or_else(|e| { + panic!( + "thurward build.rs: compiling {} failed: {e}\n\ + (the YAML is also validated by CI's `schema-validate` job\n\ + against schemas/rules.schema.json — check that first)", + rules_yaml.display() + ); + }); + + std::fs::write(&out_file, &source).unwrap_or_else(|e| { + panic!( + "thurward build.rs: cannot write {}: {e}", + out_file.display() + ); + }); +} + +// `compiler.rs` and `types.rs` are also `mod`-included from `src/lib.rs`, +// but `build.rs` runs before the main crate is built so it cannot depend +// on `crate::*` — we re-include both via `#[path = ...]` above. The +// `compiler::` -> `crate::rules::types::` references in the *emitted* code +// resolve against the main crate at compile time, not against this script. +// +// To keep that pun working, `compiler.rs` references the types via +// `crate::rules::types::*` paths (correct for src/) — we shadow those +// here with the `super::types` path so this script's own type-checking +// passes. This works because `compiler.rs` only uses the types in +// *function signatures*, never in literal codegen output. + +// Re-export the script-local `types` module under the path +// `crate::rules::types::*` that compiler.rs expects. `build.rs` has no +// `crate::rules` namespace, so we provide one. +mod rules { + pub use super::types; +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..0cbc43f --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,11 @@ +# Toolchain pin for the host-build pipeline. +# +# PR A targets stable: the rule compiler + build.rs run on host and only +# need edition 2024 (stabilised in 1.85). +# +# PR B switches this to nightly when it adds the x86_64-unknown-hermit target +# (Hermit currently requires nightly features). At that point this file is +# updated and `versions.lock` records the nightly date. +[toolchain] +channel = "stable" +components = ["rustfmt", "clippy"] diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..54fd1d1 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,9 @@ +//! thurward — minimalistic unikernel firewall. +//! +//! This crate is split into the **rule pipeline** (host-buildable, used +//! by `build.rs`) and the **dataplane** (added in later PRs, target +//! `x86_64-unknown-hermit`). PR A lands only the rule pipeline. +//! +//! See `docs/architecture/` for the design that this code implements. + +pub mod rules; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..1aa5577 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,19 @@ +//! thurward bin — placeholder until PR B/C land the dataplane. +//! +//! PR A's binary just demonstrates that the compiled rule table is +//! reachable from the bin entry. It prints the rule count and the +//! default action, then exits. PR B/C replace this `main` with the +//! Hermit boot path that spawns RX poll threads. + +include!(concat!(env!("OUT_DIR"), "/rules_table.rs")); + +fn main() { + println!( + "thurward: {} rule(s) compiled in; default action = {:?}", + RULES.len(), + DEFAULT_ACTION + ); + for rule in RULES { + println!(" {} ({:?})", rule.id, rule.action); + } +} diff --git a/src/rules/compiler.rs b/src/rules/compiler.rs new file mode 100644 index 0000000..c3942a7 --- /dev/null +++ b/src/rules/compiler.rs @@ -0,0 +1,468 @@ +//! Compile `rules.yaml` to Rust source code embedded in the binary. +//! +//! Used by `build.rs` to emit `$OUT_DIR/rules_table.rs`. Per ADR 0005 / +//! `docs/architecture/03-rule-model.md`, the generated module exports: +//! +//! ```text +//! pub const DEFAULT_ACTION: Action; +//! pub static RULES: &[Rule] = &[ /* one entry per rules.yaml rule */ ]; +//! ``` +//! +//! Phase-1 scope: only the `defaults.default_action` and `rules` sections +//! drive runtime behaviour. `defaults.*` other than `default_action` and +//! the entire `nat` section are parsed but emitted as `//` comments so +//! they're visible in the generated file without affecting runtime. The +//! conntrack-timeouts and SNAT/DNAT runtime arrives in later PRs. + +use serde::Deserialize; +use std::fmt::Write; +use std::net::Ipv4Addr; + +/// Errors the compiler can produce. Stringified back in `build.rs` so +/// `cargo build` failures point to the offending YAML line. +#[derive(Debug)] +pub enum CompileError { + Yaml(serde_yml::Error), + BadCidr(String), + BadPort(String), + BadProtocol(String), + BadAction(String), + BadDirection(String), + BadState(String), + MissingId, + /// `destination` and `destination_fqdn` are mutually exclusive per + /// the schema. The compiler rejects rules that set both. + DestinationConflict { + rule_id: String, + }, +} + +impl std::fmt::Display for CompileError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Yaml(e) => write!(f, "rules.yaml is not valid YAML: {e}"), + Self::BadCidr(s) => write!(f, "not a valid IPv4 CIDR: {s:?}"), + Self::BadPort(s) => write!(f, "not a valid port spec: {s:?}"), + Self::BadProtocol(s) => write!(f, "not a valid protocol: {s:?}"), + Self::BadAction(s) => write!(f, "not a valid action: {s:?}"), + Self::BadDirection(s) => write!(f, "not a valid direction: {s:?}"), + Self::BadState(s) => write!(f, "not a valid state: {s:?}"), + Self::MissingId => write!(f, "every rule needs an `id`"), + Self::DestinationConflict { rule_id } => write!( + f, + "rule {rule_id:?}: `destination` and `destination_fqdn` are mutually exclusive" + ), + } + } +} + +impl std::error::Error for CompileError {} + +// --- The YAML shape ---------------------------------------------------------- +// +// These structs mirror `schemas/rules.schema.json`. They're build-side only +// (use String/Vec freely); the `Rule` literals we emit reference the +// `&'static`-typed runtime types in `src/rules/types.rs`. + +#[derive(Deserialize, Debug)] +pub struct RulesYaml { + #[serde(default)] + pub defaults: Defaults, + #[serde(default)] + pub nat: NatBlock, + pub rules: Vec, +} + +#[derive(Deserialize, Debug)] +pub struct Defaults { + #[serde(default = "default_action_deny")] + pub default_action: String, + #[serde(default)] + pub upstream_dns: Option, + #[serde(default)] + pub flow_trace_sample_accept: Option, + #[serde(default)] + pub flow_trace_sample_drop: Option, + #[serde(default)] + pub dns_qps_per_client: Option, + #[serde(default)] + pub conntrack_timeouts: Option, +} + +// `#[derive(Default)]` would give us `default_action = ""`, which then trips +// `parse_action`. We need the absent-`defaults`-block case to mean "deny" — +// the schema's stated default — so we hand-roll `Default` accordingly. +impl Default for Defaults { + fn default() -> Self { + Self { + default_action: default_action_deny(), + upstream_dns: None, + flow_trace_sample_accept: None, + flow_trace_sample_drop: None, + dns_qps_per_client: None, + conntrack_timeouts: None, + } + } +} + +fn default_action_deny() -> String { + "deny".to_string() +} + +#[derive(Deserialize, Debug, Default)] +pub struct NatBlock { + #[serde(default)] + pub snat: Vec, + #[serde(default)] + pub dnat: Vec, +} + +#[derive(Deserialize, Debug)] +pub struct RuleYaml { + pub id: Option, + #[serde(default)] + pub name: Option, + pub action: String, + #[serde(default)] + pub direction: Option, + #[serde(default)] + pub source: Option, + #[serde(default)] + pub destination: Option, + #[serde(default)] + pub destination_port: Option, + #[serde(default)] + pub destination_fqdn: Option>, + #[serde(default)] + pub protocol: Option, + #[serde(default)] + pub state: Option, +} + +/// `destination_port` can be either a bare integer or a `"lo-hi"` string — +/// the schema declares this union. Untagged enum lets serde pick the right +/// variant. +#[derive(Deserialize, Debug)] +#[serde(untagged)] +pub enum PortValue { + Single(u16), + Range(String), +} + +// --- Public entry point ------------------------------------------------------ + +/// Parse `yaml` and return the Rust source for the generated module. +/// +/// On success, the returned string can be written verbatim to a `.rs` +/// file and `include!`d from `src/lib.rs`. +pub fn compile(yaml: &str) -> Result { + let parsed: RulesYaml = serde_yml::from_str(yaml).map_err(CompileError::Yaml)?; + emit(&parsed) +} + +// --- The codegen pass -------------------------------------------------------- + +fn emit(rules: &RulesYaml) -> Result { + let default_action = parse_action(&rules.defaults.default_action)?; + + let mut out = String::new(); + writeln!( + out, + "// THIS FILE IS GENERATED BY build.rs FROM examples/rules.yaml.\n\ + // Do not edit by hand. Re-run `cargo build` after editing rules.yaml.\n\ + //\n\ + // Phase-1 emit: defaults.default_action and rules[] only. The rest of\n\ + // defaults.* and the entire nat section are echoed as comments so the\n\ + // file is human-scannable; they wire up in subsequent PRs." + ) + .unwrap(); + + // Echo the deferred sections so a reviewer can see what's been parsed + // but not yet acted on. Comments only, no runtime impact. + if rules.defaults.upstream_dns.is_some() + || rules.defaults.flow_trace_sample_accept.is_some() + || rules.defaults.flow_trace_sample_drop.is_some() + || rules.defaults.dns_qps_per_client.is_some() + || rules.defaults.conntrack_timeouts.is_some() + { + writeln!(out, "//\n// defaults (parsed but deferred to phase 2+):").unwrap(); + if let Some(v) = &rules.defaults.upstream_dns { + writeln!(out, "// upstream_dns = {v}").unwrap(); + } + if let Some(v) = rules.defaults.flow_trace_sample_accept { + writeln!(out, "// flow_trace_sample_accept = {v}").unwrap(); + } + if let Some(v) = rules.defaults.flow_trace_sample_drop { + writeln!(out, "// flow_trace_sample_drop = {v}").unwrap(); + } + if let Some(v) = rules.defaults.dns_qps_per_client { + writeln!(out, "// dns_qps_per_client = {v}").unwrap(); + } + if rules.defaults.conntrack_timeouts.is_some() { + writeln!(out, "// conntrack_timeouts = ").unwrap(); + } + } + if !rules.nat.snat.is_empty() || !rules.nat.dnat.is_empty() { + writeln!( + out, + "//\n// nat (parsed but deferred to phase 3): {} snat entries, {} dnat entries", + rules.nat.snat.len(), + rules.nat.dnat.len() + ) + .unwrap(); + } + + writeln!(out).unwrap(); + // Emit a path that resolves from any callsite that `include!`s us: + // `src/main.rs` (bin) and `tests/*.rs` reach the types via the lib + // crate name `thurward`; `src/lib.rs` reaches them via the same path + // because Cargo lets a crate refer to itself by its public name. + writeln!( + out, + "use thurward::rules::types::{{Action, CidrV4, Direction, PortSpec, Protocol, Rule, StateSpec}};\n" + ) + .unwrap(); + + writeln!( + out, + "pub const DEFAULT_ACTION: Action = Action::{};\n", + action_variant(default_action) + ) + .unwrap(); + + writeln!(out, "pub static RULES: &[Rule] = &[").unwrap(); + for r in &rules.rules { + emit_rule(&mut out, r)?; + } + writeln!(out, "];").unwrap(); + + Ok(out) +} + +fn emit_rule(out: &mut String, r: &RuleYaml) -> Result<(), CompileError> { + let id = r.id.as_ref().ok_or(CompileError::MissingId)?.clone(); + + // The schema forbids `destination` + `destination_fqdn` together. Belt- + // and-suspenders: the build.rs run rejects this even if the schema + // validator hasn't run yet. + if r.destination.is_some() && r.destination_fqdn.is_some() { + return Err(CompileError::DestinationConflict { rule_id: id }); + } + + let action = parse_action(&r.action)?; + let direction = match r.direction.as_deref() { + Some(s) => parse_direction(s)?, + None => Direction::Either, + }; + let source = match r.source.as_deref() { + Some(s) => Some(parse_cidr(s)?), + None => None, + }; + let destination = match r.destination.as_deref() { + Some(s) => Some(parse_cidr(s)?), + None => None, + }; + let destination_port = match r.destination_port.as_ref() { + Some(PortValue::Single(p)) => PortSpec::Single(*p), + Some(PortValue::Range(s)) => parse_port_range(s)?, + None => PortSpec::Any, + }; + let protocol = match r.protocol.as_deref() { + Some(s) => parse_protocol(s)?, + None => Protocol::Any, + }; + let state = match r.state.as_deref() { + Some(s) => parse_state(s)?, + None => StateSpec::Any, + }; + + writeln!(out, " Rule {{").unwrap(); + writeln!(out, " id: {},", rust_str(&id)).unwrap(); + writeln!( + out, + " name: {},", + rust_str(r.name.as_deref().unwrap_or("")) + ) + .unwrap(); + writeln!(out, " action: Action::{},", action_variant(action)).unwrap(); + writeln!( + out, + " direction: Direction::{},", + direction_variant(direction) + ) + .unwrap(); + writeln!(out, " source: {},", cidr_expr(source)).unwrap(); + writeln!(out, " destination: {},", cidr_expr(destination)).unwrap(); + writeln!( + out, + " destination_port: {},", + port_expr(destination_port) + ) + .unwrap(); + write!(out, " destination_fqdn: &[").unwrap(); + if let Some(fqdns) = &r.destination_fqdn { + for (i, fqdn) in fqdns.iter().enumerate() { + if i > 0 { + write!(out, ", ").unwrap(); + } + write!(out, "{}", rust_str(fqdn)).unwrap(); + } + } + writeln!(out, "],").unwrap(); + writeln!( + out, + " protocol: Protocol::{},", + protocol_variant(protocol) + ) + .unwrap(); + writeln!(out, " state: StateSpec::{},", state_variant(state)).unwrap(); + writeln!(out, " }},").unwrap(); + Ok(()) +} + +// --- Parsers (YAML string → runtime enum / value) --------------------------- + +// These return the runtime types (re-exported via crate root) so callers can +// reason about the *result* of compilation, not just the source code. + +use crate::rules::types::{Action, CidrV4, Direction, PortSpec, Protocol, StateSpec}; + +fn parse_action(s: &str) -> Result { + match s { + "accept" => Ok(Action::Accept), + "deny" => Ok(Action::Deny), + other => Err(CompileError::BadAction(other.to_string())), + } +} + +fn parse_direction(s: &str) -> Result { + match s { + "egress" => Ok(Direction::Egress), + "ingress" => Ok(Direction::Ingress), + other => Err(CompileError::BadDirection(other.to_string())), + } +} + +fn parse_protocol(s: &str) -> Result { + match s { + "tcp" => Ok(Protocol::Tcp), + "udp" => Ok(Protocol::Udp), + "icmp" => Ok(Protocol::Icmp), + other => Err(CompileError::BadProtocol(other.to_string())), + } +} + +fn parse_state(s: &str) -> Result { + match s { + "established" => Ok(StateSpec::Established), + other => Err(CompileError::BadState(other.to_string())), + } +} + +fn parse_cidr(s: &str) -> Result { + let (addr_str, prefix_str) = match s.find('/') { + Some(i) => (&s[..i], &s[i + 1..]), + None => (s, "32"), + }; + let addr: Ipv4Addr = addr_str + .parse() + .map_err(|_| CompileError::BadCidr(s.to_string()))?; + let prefix_len: u8 = prefix_str + .parse() + .map_err(|_| CompileError::BadCidr(s.to_string()))?; + if prefix_len > 32 { + return Err(CompileError::BadCidr(s.to_string())); + } + Ok(CidrV4 { + addr: u32::from_be_bytes(addr.octets()), + prefix_len, + }) +} + +fn parse_port_range(s: &str) -> Result { + let (lo_str, hi_str) = s + .split_once('-') + .ok_or_else(|| CompileError::BadPort(s.to_string()))?; + let lo: u16 = lo_str + .parse() + .map_err(|_| CompileError::BadPort(s.to_string()))?; + let hi: u16 = hi_str + .parse() + .map_err(|_| CompileError::BadPort(s.to_string()))?; + if lo > hi { + return Err(CompileError::BadPort(s.to_string())); + } + Ok(PortSpec::Range(lo, hi)) +} + +// --- Codegen helpers (runtime enum → Rust source fragment) ------------------ + +fn action_variant(a: Action) -> &'static str { + match a { + Action::Accept => "Accept", + Action::Deny => "Deny", + } +} + +fn direction_variant(d: Direction) -> &'static str { + match d { + Direction::Egress => "Egress", + Direction::Ingress => "Ingress", + Direction::Either => "Either", + } +} + +fn protocol_variant(p: Protocol) -> &'static str { + match p { + Protocol::Tcp => "Tcp", + Protocol::Udp => "Udp", + Protocol::Icmp => "Icmp", + Protocol::Any => "Any", + } +} + +fn state_variant(s: StateSpec) -> &'static str { + match s { + StateSpec::Any => "Any", + StateSpec::Established => "Established", + } +} + +fn cidr_expr(c: Option) -> String { + match c { + None => "None".to_string(), + Some(CidrV4 { addr, prefix_len }) => { + format!("Some(CidrV4 {{ addr: 0x{addr:08x}, prefix_len: {prefix_len} }})") + } + } +} + +fn port_expr(p: PortSpec) -> String { + match p { + PortSpec::Any => "PortSpec::Any".to_string(), + PortSpec::Single(p) => format!("PortSpec::Single({p})"), + PortSpec::Range(lo, hi) => format!("PortSpec::Range({lo}, {hi})"), + } +} + +/// Escape a YAML string for embedding as a Rust string literal. +/// +/// We use the `r"..."` raw-string form when the input contains no `"` or +/// `\`, and the regular escaped form otherwise. Rule IDs and FQDN patterns +/// are ASCII-printable in practice; this just makes the output prettier. +fn rust_str(s: &str) -> String { + if !s.contains('"') && !s.contains('\\') { + format!("r\"{s}\"") + } else { + let mut out = String::from("\""); + for ch in s.chars() { + match ch { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + c => out.push(c), + } + } + out.push('"'); + out + } +} diff --git a/src/rules/mod.rs b/src/rules/mod.rs new file mode 100644 index 0000000..b48a5c2 --- /dev/null +++ b/src/rules/mod.rs @@ -0,0 +1,9 @@ +//! Rule pipeline — compile-time and runtime. +//! +//! - [`types`] holds the runtime types (`Rule`, `Action`, `CidrV4`, ...). +//! These are shared between `build.rs` and the dataplane. +//! - [`compiler`] turns `examples/rules.yaml` into Rust source. Used by +//! `build.rs`; testable from integration tests. + +pub mod compiler; +pub mod types; diff --git a/src/rules/types.rs b/src/rules/types.rs new file mode 100644 index 0000000..7fd4672 --- /dev/null +++ b/src/rules/types.rs @@ -0,0 +1,113 @@ +//! Runtime types for the compiled rule table. +//! +//! These types are shared between `build.rs` (which constructs the `Rule` +//! literals) and the dataplane (which scans `RULES` per packet). They're +//! deliberately small, `Copy`, and `const`-constructable so the generated +//! `RULES` slice lives in `.rodata` with zero runtime allocation — see +//! ADR 0005 / `docs/architecture/03-rule-model.md`. +//! +//! Phase-1 scope: `state` and `destination_fqdn` are captured but not yet +//! matched by the filter (conntrack and the DNS proxy land in later PRs). + +/// What a rule does when it matches. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum Action { + Accept, + Deny, +} + +/// LAN→WAN (egress), WAN→LAN (ingress), or unconstrained. +/// +/// A rule whose YAML omits `direction` becomes `Either`. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum Direction { + Egress, + Ingress, + Either, +} + +/// L4 protocol filter. `Any` matches packets of any supported protocol. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum Protocol { + Tcp, + Udp, + Icmp, + Any, +} + +/// Conntrack state predicate. v1 supports `Established`; phase-2 PR adds +/// the runtime matching. Until then this is recorded verbatim and ignored +/// by the filter. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum StateSpec { + Any, + Established, +} + +/// A destination-port predicate. +/// +/// `Any` is the no-port-constraint case (the rule matches at L3 only). +/// `Single` is the exact-port case. `Range(lo, hi)` is the inclusive range. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum PortSpec { + Any, + Single(u16), + Range(u16, u16), +} + +/// An IPv4 CIDR. `addr` is the network base in big-endian-numerical form +/// (i.e. `127.0.0.1` ↔ `0x7F000001`); `prefix_len` is in bits, `0..=32`. +/// +/// `prefix_len = 32` is the host route case (`/32`). +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub struct CidrV4 { + pub addr: u32, + pub prefix_len: u8, +} + +impl CidrV4 { + /// Does `ip` (also in numerical form) fall inside this CIDR? + pub const fn contains(&self, ip: u32) -> bool { + if self.prefix_len == 0 { + return true; + } + let mask: u32 = !0u32 << (32 - self.prefix_len); + (ip & mask) == (self.addr & mask) + } +} + +/// One compiled rule. All fields are `Option`-flavoured so a missing YAML +/// field becomes "unconstrained" rather than "matches nothing". +/// +/// Field order chosen for readable generated output, not memory layout — +/// the `Rule` is too small to bother packing. +#[derive(Copy, Clone, Debug)] +pub struct Rule { + /// Stable identifier from `rules.yaml`. Used in logs (ECS `rule.id`). + pub id: &'static str, + /// Human-readable name. Used in logs (ECS `rule.name`). Empty if the + /// YAML omitted `name`. + pub name: &'static str, + pub action: Action, + pub direction: Direction, + pub source: Option, + pub destination: Option, + pub destination_port: PortSpec, + /// List of FQDN patterns (exact or `*.suffix.example`). Phase-1 records + /// this but does NOT match against it — the DNS proxy lands in a later + /// PR (ADR 0004). A non-empty list means "this rule's predicate + /// requires runtime FQDN resolution and the filter must currently + /// treat it as no-match." + pub destination_fqdn: &'static [&'static str], + pub protocol: Protocol, + pub state: StateSpec, +} + +impl Rule { + /// Does this rule need a runtime that doesn't exist yet (FQDN + /// resolution or conntrack state)? Phase-1 callers can use this to + /// skip such rules rather than match them incorrectly. + pub const fn needs_future_runtime(&self) -> bool { + !self.destination_fqdn.is_empty() || matches!(self.state, StateSpec::Established) + } +} diff --git a/tests/fixtures/default_allow.yaml b/tests/fixtures/default_allow.yaml new file mode 100644 index 0000000..d24d7c5 --- /dev/null +++ b/tests/fixtures/default_allow.yaml @@ -0,0 +1,7 @@ +# Exercises a non-default default action ("accept"). Atypical but legal. +defaults: + default_action: accept +rules: + - id: deny-bad-actor + action: deny + source: 192.0.2.0/24 diff --git a/tests/fixtures/host_route.yaml b/tests/fixtures/host_route.yaml new file mode 100644 index 0000000..e3267c2 --- /dev/null +++ b/tests/fixtures/host_route.yaml @@ -0,0 +1,8 @@ +# Exercises the /32 host route case (no slash in YAML). +rules: + - id: allow-management + action: accept + direction: ingress + source: 198.51.100.42 + destination_port: 22 + protocol: tcp diff --git a/tests/fixtures/minimal.yaml b/tests/fixtures/minimal.yaml new file mode 100644 index 0000000..a813245 --- /dev/null +++ b/tests/fixtures/minimal.yaml @@ -0,0 +1,4 @@ +# Minimal valid input: one accept rule, no defaults overrides, no NAT. +rules: + - id: allow-anything + action: accept diff --git a/tests/fixtures/port_range.yaml b/tests/fixtures/port_range.yaml new file mode 100644 index 0000000..7783d28 --- /dev/null +++ b/tests/fixtures/port_range.yaml @@ -0,0 +1,8 @@ +# Exercises the lo-hi destination_port range form. +rules: + - id: allow-high-ports + action: accept + direction: egress + source: 10.0.0.0/24 + destination_port: "1024-65535" + protocol: tcp diff --git a/tests/rule_compiler.rs b/tests/rule_compiler.rs new file mode 100644 index 0000000..7c6194e --- /dev/null +++ b/tests/rule_compiler.rs @@ -0,0 +1,161 @@ +//! Integration tests for the rule compiler. +//! +//! Each test parses a fixture, compiles to Rust source, and checks +//! observable properties. We deliberately don't snapshot the literal +//! output: the goal is the *semantics* of the generated module, not its +//! whitespace. + +use thurward::rules::compiler::{CompileError, compile}; + +const EXAMPLES_RULES_YAML: &str = include_str!("../examples/rules.yaml"); + +#[test] +fn examples_rules_yaml_compiles() { + let src = compile(EXAMPLES_RULES_YAML).expect("examples/rules.yaml must compile"); + // The canonical example has three rules; preserve that as a regression guard. + assert_eq!(rules_in(&src), 3); + assert!(src.contains("pub const DEFAULT_ACTION: Action = Action::Deny;")); + // Each rule id appears verbatim in the emitted module. + for id in ["allow-dns-out", "allow-github-https", "allow-established"] { + assert!( + src.contains(&format!("id: r\"{id}\"")), + "missing id {id} in:\n{src}" + ); + } +} + +#[test] +fn minimal_yaml_emits_one_unconstrained_rule() { + let src = compile(include_str!("fixtures/minimal.yaml")).expect("minimal compiles"); + assert_eq!(rules_in(&src), 1); + // No source CIDR, no destination, no port, no protocol → everything `None`/`Any`. + assert!(src.contains("source: None")); + assert!(src.contains("destination: None")); + assert!(src.contains("destination_port: PortSpec::Any")); + assert!(src.contains("protocol: Protocol::Any")); + assert!(src.contains("direction: Direction::Either")); +} + +#[test] +fn port_range_yaml_emits_port_range_variant() { + let src = compile(include_str!("fixtures/port_range.yaml")).expect("port_range compiles"); + assert!(src.contains("destination_port: PortSpec::Range(1024, 65535)")); +} + +#[test] +fn default_allow_yaml_flips_default_action() { + let src = compile(include_str!("fixtures/default_allow.yaml")).expect("default_allow compiles"); + assert!(src.contains("pub const DEFAULT_ACTION: Action = Action::Accept;")); + assert!(src.contains("action: Action::Deny")); +} + +#[test] +fn host_route_yaml_yields_prefix_32() { + let src = compile(include_str!("fixtures/host_route.yaml")).expect("host_route compiles"); + // 198.51.100.42 = 0xC633642A + assert!( + src.contains("0xc633642a"), + "missing host route addr in:\n{src}" + ); + assert!(src.contains("prefix_len: 32")); +} + +#[test] +fn rule_without_id_is_rejected() { + let yaml = "rules:\n - action: accept\n"; + match compile(yaml) { + Err(CompileError::MissingId) => {} + other => panic!("expected MissingId, got {other:?}"), + } +} + +#[test] +fn bad_cidr_is_rejected() { + let yaml = "rules:\n - id: x\n action: accept\n source: 999.999.999.0/24\n"; + match compile(yaml) { + Err(CompileError::BadCidr(s)) => assert_eq!(s, "999.999.999.0/24"), + other => panic!("expected BadCidr, got {other:?}"), + } +} + +#[test] +fn bad_action_is_rejected() { + let yaml = "rules:\n - id: x\n action: maybe\n"; + match compile(yaml) { + Err(CompileError::BadAction(s)) => assert_eq!(s, "maybe"), + other => panic!("expected BadAction, got {other:?}"), + } +} + +#[test] +fn destination_and_fqdn_together_is_rejected() { + let yaml = "\ +rules: + - id: ambiguous + action: accept + destination: 10.0.0.5 + destination_fqdn: [example.com] + protocol: tcp +"; + match compile(yaml) { + Err(CompileError::DestinationConflict { rule_id }) => assert_eq!(rule_id, "ambiguous"), + other => panic!("expected DestinationConflict, got {other:?}"), + } +} + +#[test] +fn port_range_with_inverted_bounds_is_rejected() { + let yaml = "\ +rules: + - id: inverted + action: accept + destination_port: \"5000-100\" + protocol: tcp +"; + match compile(yaml) { + Err(CompileError::BadPort(s)) => assert_eq!(s, "5000-100"), + other => panic!("expected BadPort, got {other:?}"), + } +} + +// --- Helpers ----------------------------------------------------------------- + +/// Count `Rule {` literals in the generated source. Cheap proxy for +/// `RULES.len()` without compiling the emitted code. +fn rules_in(src: &str) -> usize { + src.matches("Rule {").count() +} + +// --- Runtime-type unit tests ------------------------------------------------- + +mod cidr_contains { + use thurward::rules::types::CidrV4; + + fn cidr(addr: u32, prefix_len: u8) -> CidrV4 { + CidrV4 { addr, prefix_len } + } + + #[test] + fn slash_24_contains_all_24_addresses() { + let net = cidr(0x0a000000, 24); // 10.0.0.0/24 + assert!(net.contains(0x0a000000)); // 10.0.0.0 + assert!(net.contains(0x0a0000ff)); // 10.0.0.255 + assert!(!net.contains(0x0a000100)); // 10.0.1.0 + assert!(!net.contains(0x09ffffff)); // just below + } + + #[test] + fn slash_32_is_exact_host() { + let net = cidr(0x0a000001, 32); + assert!(net.contains(0x0a000001)); + assert!(!net.contains(0x0a000002)); + } + + #[test] + fn slash_zero_matches_everything() { + let net = cidr(0, 0); + assert!(net.contains(0)); + assert!(net.contains(u32::MAX)); + assert!(net.contains(0xdeadbeef)); + } +} diff --git a/versions.lock b/versions.lock new file mode 100644 index 0000000..708533a --- /dev/null +++ b/versions.lock @@ -0,0 +1,24 @@ +# versions.lock — pinned versions of substrate dependencies. +# +# Companion to Cargo.lock. Cargo.lock pins Rust crate versions for the +# host-build pipeline; this file pins the bits Cargo doesn't track: +# the Rust toolchain channel, the Hermit kernel revision, smoltcp version, +# and any external image-build tools. Both are committed to git per +# ADR 0010 (supply-chain hardening). + +# Rust toolchain — see rust-toolchain.toml. +rust_channel = "stable" +# Locked to ≥ 1.85 (edition 2024 stabilised). Bumping is a deliberate +# PR with a regenerated Cargo.lock. + +# Hermit substrate — added in PR B. +hermit_revision = "TBD-pr-B" +hermit_rust_target = "x86_64-unknown-hermit" + +# smoltcp — added in PR C. wire-only usage per ADR 0017. +smoltcp_version = "TBD-pr-C" +smoltcp_features = "wire" + +# Image / packaging tools — added in PR B. +qemu_min_version = "TBD-pr-B" +libvirt_min_version = "TBD-pr-B"