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