1use std::{
24 env, fs,
25 path::{Path, PathBuf},
26};
27
28use anyhow::{bail, Context, Result};
29use serde::{Deserialize, Serialize};
30
31pub fn default_path() -> Result<PathBuf> {
33 let home = env::var_os("HOME").context("HOME is unset; cannot locate pack trust config")?;
34 Ok(PathBuf::from(home).join(".config/caboodle/pack-trust.toml"))
35}
36
37#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(deny_unknown_fields)]
40pub struct TrustConfig {
41 #[serde(default)]
43 pub owners: Vec<String>,
44 #[serde(default, rename = "allow")]
46 pub packs: Vec<PackAllow>,
47 #[serde(default, rename = "allow_endpoint")]
49 pub endpoints: Vec<EndpointAllow>,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(deny_unknown_fields)]
55pub struct PackAllow {
56 pub repo: String,
58 pub pack_sha256: String,
60 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub note: Option<String>,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(deny_unknown_fields)]
67pub struct EndpointAllow {
68 pub repo: String,
69 pub endpoint: String,
70 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub note: Option<String>,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq)]
76pub enum Decision {
77 Allow { basis: String },
79 Refuse { message: String },
81}
82
83impl Decision {
84 pub fn allowed(&self) -> bool {
85 matches!(self, Decision::Allow { .. })
86 }
87}
88
89#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct Repo {
92 pub host: String,
93 pub owner: String,
94 pub name: String,
95}
96
97impl Repo {
98 pub fn parse(remote: &str) -> Result<Repo> {
101 let trimmed = remote.trim().trim_end_matches('/');
102 let rest = if let Some((scheme, rest)) = trimmed.split_once("://") {
103 if !matches!(scheme, "https" | "http" | "ssh" | "git") {
104 bail!("unsupported remote scheme in {remote:?}");
105 }
106 rest.rsplit_once('@')
107 .map_or(rest, |(_, after)| after)
108 .to_string()
109 } else if let Some((user_host, path)) = trimmed.split_once(':') {
110 let host = user_host.rsplit_once('@').map_or(user_host, |(_, h)| h);
112 format!("{host}/{path}")
113 } else {
114 trimmed.to_string()
115 };
116 let rest = rest.strip_suffix(".git").unwrap_or(&rest);
117 let parts: Vec<&str> = rest.split('/').collect();
118 let [host, owner, name] = parts.as_slice() else {
119 bail!("remote {remote:?} is not host/owner/name");
120 };
121 let host = host.split(':').next().unwrap_or(host);
123 let valid = |s: &str| {
124 !s.is_empty()
125 && s.chars()
126 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
127 };
128 if !(valid(host) && valid(owner) && valid(name)) {
129 bail!("remote {remote:?} has an invalid host, owner or name");
130 }
131 Ok(Repo {
132 host: host.to_ascii_lowercase(),
133 owner: owner.to_ascii_lowercase(),
134 name: name.to_ascii_lowercase(),
135 })
136 }
137
138 pub fn owner_key(&self) -> String {
139 format!("{}/{}", self.host, self.owner)
140 }
141
142 pub fn key(&self) -> String {
143 format!("{}/{}/{}", self.host, self.owner, self.name)
144 }
145}
146
147fn check_sha256(value: &str) -> Result<String> {
148 let lower = value.trim().to_ascii_lowercase();
149 if lower.len() != 64 || !lower.chars().all(|c| c.is_ascii_hexdigit()) {
150 bail!("pack hash {value:?} is not a 64-character sha256");
151 }
152 Ok(lower)
153}
154
155fn normalize_owner(owner: &str) -> Result<String> {
156 let parts: Vec<&str> = owner.trim().trim_matches('/').split('/').collect();
157 let [host, name] = parts.as_slice() else {
158 bail!("owner {owner:?} must be host/owner, e.g. github.com/scbrown");
159 };
160 Ok(Repo::parse(&format!("{host}/{name}/x"))?.owner_key())
161}
162
163impl TrustConfig {
164 pub fn load(path: &Path) -> Result<TrustConfig> {
167 if !path.exists() {
168 return Ok(TrustConfig::default());
169 }
170 let body = fs::read_to_string(path)
171 .with_context(|| format!("read pack trust {}", path.display()))?;
172 let config: TrustConfig = toml::from_str(&body).with_context(|| {
173 format!(
174 "parse pack trust {}; every pack is refused until it is fixed (an allow \
175 entry needs repo AND pack_sha256)",
176 path.display()
177 )
178 })?;
179 config.validate()?;
180 Ok(config)
181 }
182
183 fn validate(&self) -> Result<()> {
184 for owner in &self.owners {
185 normalize_owner(owner)?;
186 }
187 for allow in &self.packs {
188 Repo::parse(&allow.repo)?;
189 check_sha256(&allow.pack_sha256)?;
190 }
191 for allow in &self.endpoints {
192 Repo::parse(&allow.repo)?;
193 check_endpoint(&allow.endpoint)?;
194 }
195 Ok(())
196 }
197
198 pub fn save(&self, path: &Path) -> Result<()> {
200 self.validate()?;
201 let rendered = toml::to_string_pretty(self).context("render pack trust")?;
202 let parent = path.parent().context("pack trust path has no parent")?;
203 fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
204 let temp = path.with_extension("toml.tmp");
205 fs::write(&temp, &rendered).with_context(|| format!("write {}", temp.display()))?;
206 fs::rename(&temp, path).with_context(|| format!("replace {}", path.display()))?;
207 if &TrustConfig::load(path)? != self {
208 bail!("pack trust read-back did not match what was written");
209 }
210 Ok(())
211 }
212
213 pub fn decide_pack(&self, remote: &str, pack: &str, pack_sha256: &str) -> Decision {
215 let (repo, sha) = match (Repo::parse(remote), check_sha256(pack_sha256)) {
216 (Ok(repo), Ok(sha)) => (repo, sha),
217 (Err(e), _) | (_, Err(e)) => {
218 return Decision::Refuse {
219 message: format!("pack {pack} refused: {e}"),
220 }
221 }
222 };
223 if self.owns(&repo) {
224 return Decision::Allow {
225 basis: format!(
226 "own repository ({} is on the owners list)",
227 repo.owner_key()
228 ),
229 };
230 }
231 let pinned = self.packs.iter().any(|a| {
232 Repo::parse(&a.repo).is_ok_and(|r| r == repo)
233 && check_sha256(&a.pack_sha256).is_ok_and(|s| s == sha)
234 });
235 if pinned {
236 return Decision::Allow {
237 basis: format!("explicit allow for {} at sha256 {sha}", repo.key()),
238 };
239 }
240 Decision::Refuse {
241 message: format!(
242 "foreign pack {pack} (sha256 {sha}) from {} has no allow entry. Admit exactly \
243 this archive with: tambo packs trust allow {} {sha}",
244 repo.key(),
245 repo.key()
246 ),
247 }
248 }
249
250 pub fn decide_endpoint(&self, remote: &str, endpoint: &str) -> Decision {
252 let (repo, url) = match (Repo::parse(remote), check_endpoint(endpoint)) {
253 (Ok(repo), Ok(url)) => (repo, url),
254 (Err(e), _) | (_, Err(e)) => {
255 return Decision::Refuse {
256 message: format!("endpoint {endpoint} refused: {e}"),
257 }
258 }
259 };
260 if self.owns(&repo) {
261 return Decision::Allow {
262 basis: format!(
263 "own repository ({} is on the owners list)",
264 repo.owner_key()
265 ),
266 };
267 }
268 let allowed = self.endpoints.iter().any(|a| {
269 Repo::parse(&a.repo).is_ok_and(|r| r == repo)
270 && check_endpoint(&a.endpoint).is_ok_and(|e| e == url)
271 });
272 if allowed {
273 return Decision::Allow {
274 basis: format!("explicit endpoint allow for {}", repo.key()),
275 };
276 }
277 Decision::Refuse {
278 message: format!(
279 "endpoint {url} declared by foreign repository {} has no allow entry; a pack \
280 declaring it is not consent. Admit it with: tambo packs trust \
281 allow-endpoint {} {url}",
282 repo.key(),
283 repo.key()
284 ),
285 }
286 }
287
288 fn owns(&self, repo: &Repo) -> bool {
289 self.owners
290 .iter()
291 .any(|o| normalize_owner(o).is_ok_and(|o| o == repo.owner_key()))
292 }
293
294 pub fn add_owner(&mut self, owner: &str) -> Result<bool> {
296 let key = normalize_owner(owner)?;
297 if self
298 .owners
299 .iter()
300 .any(|o| normalize_owner(o).is_ok_and(|o| o == key))
301 {
302 return Ok(false);
303 }
304 self.owners.push(key);
305 Ok(true)
306 }
307
308 pub fn add_pack(&mut self, repo: &str, pack_sha256: &str, note: Option<&str>) -> Result<bool> {
310 let repo = Repo::parse(repo)?.key();
311 let sha = check_sha256(pack_sha256)?;
312 if self
313 .packs
314 .iter()
315 .any(|a| a.repo == repo && a.pack_sha256 == sha)
316 {
317 return Ok(false);
318 }
319 self.packs.push(PackAllow {
320 repo,
321 pack_sha256: sha,
322 note: note.map(str::to_string),
323 });
324 Ok(true)
325 }
326
327 pub fn add_endpoint(&mut self, repo: &str, endpoint: &str, note: Option<&str>) -> Result<bool> {
329 let repo = Repo::parse(repo)?.key();
330 let url = check_endpoint(endpoint)?;
331 if self
332 .endpoints
333 .iter()
334 .any(|a| a.repo == repo && a.endpoint == url)
335 {
336 return Ok(false);
337 }
338 self.endpoints.push(EndpointAllow {
339 repo,
340 endpoint: url,
341 note: note.map(str::to_string),
342 });
343 Ok(true)
344 }
345}
346
347fn check_endpoint(endpoint: &str) -> Result<String> {
349 let url = endpoint.trim();
350 let Some((scheme, rest)) = url.split_once("://") else {
351 bail!("endpoint {endpoint:?} is not an http(s) URL");
352 };
353 if !matches!(scheme, "https" | "http") || rest.is_empty() {
354 bail!("endpoint {endpoint:?} is not an http(s) URL");
355 }
356 let authority = rest.split('/').next().unwrap_or_default();
357 if authority.contains('@') {
358 bail!("endpoint {endpoint:?} carries credentials; trust is recorded without secrets");
359 }
360 Ok(url.to_string())
361}
362
363#[cfg(test)]
364#[path = "pack_trust_tests.rs"]
365mod tests;