Skip to main content

harness_core/
pack.rs

1// SPDX-License-Identifier: Apache-2.0 OR MIT
2//! Policy-pack types + loader.
3//!
4//! Ports `load_pack` from `harness.py`. Keeps the loose serde shape (extra
5//! fields tolerated; unknown rule fields ignored) so example packs that ship
6//! `_note` / future fields still load.
7
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11use std::fs;
12use std::path::Path;
13
14use crate::SPEC_VERSION;
15
16#[derive(Debug)]
17pub enum PackError {
18    Io(String),
19    Json(String),
20    MissingField(String),
21    SpecMismatch { got: String, want: &'static str },
22    ExpiresAtUnparseable(String),
23    Expired(String),
24    SignatureAlgUnsupported(String),
25}
26
27impl std::fmt::Display for PackError {
28    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29        match self {
30            PackError::Io(s) => write!(f, "pack_load_failed:{s}"),
31            PackError::Json(s) => write!(f, "pack_load_failed:json:{s}"),
32            PackError::MissingField(s) => write!(f, "pack_missing_fields: {s}"),
33            PackError::SpecMismatch { got, want } => {
34                write!(f, "pack_spec_version_mismatch: got {got:?}, daemon supports {want:?}")
35            }
36            PackError::ExpiresAtUnparseable(s) => write!(f, "pack_expires_at_unparseable:{s}"),
37            PackError::Expired(s) => write!(f, "pack_expired: {s}"),
38            PackError::SignatureAlgUnsupported(s) => {
39                write!(f, "pack_signature_alg_unsupported: {s:?}")
40            }
41        }
42    }
43}
44
45impl std::error::Error for PackError {}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct PolicyPack {
49    pub spec_version: String,
50    pub pack_id: String,
51    pub pack_name: String,
52    pub issuer: String,
53    pub issued_at: String,
54    pub expires_at: String,
55    pub jurisdictions: Vec<String>,
56    pub rules: Vec<Rule>,
57    pub default_decision: String,
58    pub signature: Signature,
59    /// Anything else (e.g. forward-compat fields) is preserved here.
60    #[serde(flatten)]
61    pub extra: serde_json::Map<String, Value>,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct Rule {
66    pub rule_id: String,
67    pub r#match: Match,
68    pub policy: Policy,
69    #[serde(default)]
70    pub deny_reason: Option<String>,
71    #[serde(default)]
72    pub layer: Option<String>,
73    #[serde(default)]
74    pub warn_only: bool,
75    #[serde(flatten)]
76    pub extra: serde_json::Map<String, Value>,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct Match {
81    /// Either a single action string or a list of action strings; the daemon
82    /// treats both shapes identically (see `_match_action` in the Python ref).
83    #[serde(default)]
84    pub action: Value,
85    #[serde(default)]
86    pub fields: Option<Vec<String>>,
87    #[serde(flatten)]
88    pub extra: serde_json::Map<String, Value>,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct Policy {
93    pub language: String,
94    pub module: String,
95    #[serde(flatten)]
96    pub extra: serde_json::Map<String, Value>,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct Signature {
101    pub alg: String,
102    #[serde(default)]
103    pub kid: Option<String>,
104    #[serde(default)]
105    pub value: Option<String>,
106}
107
108/// Load + validate a policy pack from disk.
109///
110/// Ports `load_pack` from `harness.py` line-for-line:
111/// - JSON parse
112/// - required top-level fields present
113/// - spec_version matches `ahp-policy-pack/0.1`
114/// - `expires_at` parses + is in the future
115/// - signature.alg is EdDSA; the sentinel `REFERENCE_PACK_NOT_SIGNED_LOCAL_DEV_ONLY`
116///   value is allowed (caller may emit a warning).
117pub fn load_pack(path: &Path) -> Result<PolicyPack, PackError> {
118    let bytes = fs::read(path).map_err(|e| PackError::Io(format!("{e}")))?;
119    // Validate-shape first via Value, then re-parse to the typed struct. This lets
120    // us produce the same "pack_missing_fields: [...]" error shape as the Python
121    // ref (sorted list of missing required keys), regardless of struct order.
122    let raw: Value =
123        serde_json::from_slice(&bytes).map_err(|e| PackError::Json(format!("{e}")))?;
124    let obj = raw
125        .as_object()
126        .ok_or_else(|| PackError::Json("top-level not an object".into()))?;
127
128    const REQUIRED: &[&str] = &[
129        "spec_version",
130        "pack_id",
131        "pack_name",
132        "issuer",
133        "issued_at",
134        "expires_at",
135        "jurisdictions",
136        "rules",
137        "default_decision",
138        "signature",
139    ];
140    let mut missing: Vec<&str> = REQUIRED.iter().copied().filter(|k| !obj.contains_key(*k)).collect();
141    if !missing.is_empty() {
142        missing.sort();
143        let formatted = format!(
144            "[{}]",
145            missing.iter().map(|s| format!("'{s}'")).collect::<Vec<_>>().join(", ")
146        );
147        return Err(PackError::MissingField(formatted));
148    }
149
150    let pack: PolicyPack = serde_json::from_value(raw).map_err(|e| PackError::Json(format!("{e}")))?;
151
152    if pack.spec_version != SPEC_VERSION {
153        return Err(PackError::SpecMismatch {
154            got: pack.spec_version.clone(),
155            want: SPEC_VERSION,
156        });
157    }
158
159    // Expiry. Accept trailing 'Z' the same way the Python ref does.
160    let expires_normalized = pack.expires_at.replace('Z', "+00:00");
161    let exp: DateTime<Utc> = DateTime::parse_from_rfc3339(&expires_normalized)
162        .map_err(|e| PackError::ExpiresAtUnparseable(format!("{e}")))?
163        .with_timezone(&Utc);
164    if exp < Utc::now() {
165        return Err(PackError::Expired(pack.expires_at.clone()));
166    }
167
168    if pack.signature.alg != "EdDSA" {
169        return Err(PackError::SignatureAlgUnsupported(pack.signature.alg.clone()));
170    }
171    // The `REFERENCE_PACK_NOT_SIGNED_LOCAL_DEV_ONLY` sentinel is accepted; the
172    // CLI layer warns. Production must verify against the issuer JWKS.
173
174    Ok(pack)
175}
176