1use std::io;
20use std::path::{Path, PathBuf};
21
22use crate::agent::{AgentProfile, Entitlements};
23
24pub const PINS_DIR: &str = "entitlement-pins";
26
27const PIN_EXT: &str = "json";
28
29const PIN_VERSION: u32 = 1;
31
32#[derive(serde::Serialize, serde::Deserialize)]
33struct PinFile {
34 version: u32,
35 entitlements: serde_json::Value,
36}
37
38pub fn pin_path(mur_home: &Path, agent: &str) -> PathBuf {
40 mur_home.join(PINS_DIR).join(format!("{agent}.{PIN_EXT}"))
41}
42
43#[derive(Debug, PartialEq, Eq)]
45pub enum PinCheck {
46 Match,
48 Missing,
50 Mismatch { changed: Vec<String> },
52}
53
54#[derive(Debug, thiserror::Error)]
55pub enum PinError {
56 #[error("io error on {path}: {source}")]
57 Io { path: PathBuf, source: io::Error },
58 #[error("invalid pin file {path}: {msg}")]
59 Invalid { path: PathBuf, msg: String },
60 #[error("invalid profile {path}: {msg}")]
61 Profile { path: PathBuf, msg: String },
62 #[error("invalid agent name '{0}'")]
63 Name(String),
64}
65
66fn canonical(ent: &Entitlements) -> serde_json::Value {
67 serde_json::to_value(ent).expect("Entitlements serializes to JSON")
69}
70
71fn checked_name(agent: &str) -> Result<(), PinError> {
72 crate::agent_name::validate_agent_name(agent).map_err(|_| PinError::Name(agent.to_string()))
73}
74
75pub fn write_pin(mur_home: &Path, agent: &str, ent: &Entitlements) -> Result<(), PinError> {
78 checked_name(agent)?;
79 let path = pin_path(mur_home, agent);
80 let io_err = |source| PinError::Io {
81 path: path.clone(),
82 source,
83 };
84 let dir = mur_home.join(PINS_DIR);
85 std::fs::create_dir_all(&dir).map_err(io_err)?;
86 let body = PinFile {
87 version: PIN_VERSION,
88 entitlements: canonical(ent),
89 };
90 let bytes = crate::jcs::to_jcs_for(&body);
91 let mut tmp = tempfile::NamedTempFile::new_in(&dir).map_err(io_err)?;
92 io::Write::write_all(&mut tmp, &bytes).map_err(io_err)?;
93 tmp.persist(&path).map_err(|e| io_err(e.error))?;
94 Ok(())
95}
96
97pub fn entitlements_from_yaml(yaml: &str, path: &Path) -> Result<Entitlements, PinError> {
102 serde_yaml_ng::from_str::<AgentProfile>(yaml)
103 .map(|p| p.entitlements)
104 .map_err(|e| PinError::Profile {
105 path: path.to_path_buf(),
106 msg: e.to_string(),
107 })
108}
109
110pub fn repin_from_profile(mur_home: &Path, agent: &str) -> Result<(), PinError> {
114 checked_name(agent)?;
115 let path = mur_home.join("agents").join(agent).join("profile.yaml");
116 let yaml = std::fs::read_to_string(&path).map_err(|source| PinError::Io {
117 path: path.clone(),
118 source,
119 })?;
120 let ent = entitlements_from_yaml(&yaml, &path)?;
121 write_pin(mur_home, agent, &ent)
122}
123
124pub fn advance_pin(
133 mur_home: &Path,
134 agent: &str,
135 prior: Option<&Entitlements>,
136 new: &Entitlements,
137) -> Result<bool, PinError> {
138 let trusted = match prior {
139 None => true,
141 Some(p) => matches!(
142 check(mur_home, agent, p)?,
143 PinCheck::Match | PinCheck::Missing
144 ),
145 };
146 if trusted {
147 write_pin(mur_home, agent, new)?;
148 }
149 Ok(trusted)
150}
151
152pub fn remove_pin(mur_home: &Path, agent: &str) -> Result<(), PinError> {
155 checked_name(agent)?;
156 let path = pin_path(mur_home, agent);
157 match std::fs::remove_file(&path) {
158 Ok(()) => Ok(()),
159 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
160 Err(source) => Err(PinError::Io { path, source }),
161 }
162}
163
164pub fn rename_pin(mur_home: &Path, old: &str, new: &str) -> Result<(), PinError> {
166 checked_name(old)?;
167 checked_name(new)?;
168 let (from, to) = (pin_path(mur_home, old), pin_path(mur_home, new));
169 match std::fs::rename(&from, &to) {
170 Ok(()) => Ok(()),
171 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
172 Err(source) => Err(PinError::Io { path: from, source }),
173 }
174}
175
176pub fn check(mur_home: &Path, agent: &str, ent: &Entitlements) -> Result<PinCheck, PinError> {
178 checked_name(agent)?;
179 let path = pin_path(mur_home, agent);
180 let raw = match std::fs::read(&path) {
181 Ok(b) => b,
182 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(PinCheck::Missing),
183 Err(source) => return Err(PinError::Io { path, source }),
184 };
185 let invalid = |msg: String| PinError::Invalid {
186 path: path.clone(),
187 msg,
188 };
189 let file: PinFile = serde_json::from_slice(&raw).map_err(|e| invalid(e.to_string()))?;
190 if file.version != PIN_VERSION {
191 return Err(invalid(format!("unsupported version {}", file.version)));
192 }
193 let pinned: Entitlements =
196 serde_json::from_value(file.entitlements).map_err(|e| invalid(e.to_string()))?;
197 let (a, b) = (canonical(&pinned), canonical(ent));
198 if a == b {
199 return Ok(PinCheck::Match);
200 }
201 Ok(PinCheck::Mismatch {
202 changed: changed_keys(&a, &b),
203 })
204}
205
206fn changed_keys(a: &serde_json::Value, b: &serde_json::Value) -> Vec<String> {
207 let (Some(a), Some(b)) = (a.as_object(), b.as_object()) else {
208 return vec!["entitlements".to_string()];
209 };
210 let mut keys: Vec<String> = a
211 .keys()
212 .chain(b.keys())
213 .filter(|k| a.get(*k) != b.get(*k))
214 .cloned()
215 .collect();
216 keys.sort();
217 keys.dedup();
218 keys
219}
220
221#[cfg(test)]
222mod tests;