Skip to main content

caboodle/
pack_trust.rs

1//! Pendant autoload trust (aegis-2eckx0): which packs and pointer endpoints may load.
2//!
3//! A pack lands in the graph agents query first, so loading it is a trust decision
4//! (design: `docs/book/src/pendant-autoload.md`, section 2):
5//!
6//! - **Your own repositories load automatically**: their remote owner is on the
7//!   `owners` list.
8//! - **A foreign pack needs an explicit allow**, recorded per repository AND pack
9//!   hash. A repository name in a configuration file is not consent, so an entry
10//!   without a hash is refused when the file is read, not silently honoured.
11//! - **A foreign pointer endpoint needs its own allow** (repository + endpoint).
12//!   Declaring an endpoint inside a foreign pack never authorises it.
13//!
14//! Trust decides only whether a pack may be composed at all. It never promotes
15//! anything to ROOT, and it does not choose shape authority: autoload composes with
16//! the store's own shapes, which is the caller's contract with quipu.
17//!
18//! The decisions live in their own file, separate from the shared stack config, so
19//! every grant is in one auditable place.
20
21use std::{
22    env, fs,
23    path::{Path, PathBuf},
24};
25
26use anyhow::{bail, Context, Result};
27use serde::{Deserialize, Serialize};
28
29/// `~/.config/caboodle/pack-trust.toml`.
30pub 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/// The whole trust file.
36#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(deny_unknown_fields)]
38pub struct TrustConfig {
39    /// Remote owners (`host/owner`, e.g. `github.com/scbrown`) whose packs autoload.
40    #[serde(default)]
41    pub owners: Vec<String>,
42    /// Foreign packs admitted one exact archive at a time.
43    #[serde(default, rename = "allow")]
44    pub packs: Vec<PackAllow>,
45    /// Foreign pointer endpoints admitted one exact URL at a time.
46    #[serde(default, rename = "allow_endpoint")]
47    pub endpoints: Vec<EndpointAllow>,
48}
49
50/// One admitted foreign pack.
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(deny_unknown_fields)]
53pub struct PackAllow {
54    /// Repository as `host/owner/name`.
55    pub repo: String,
56    /// sha256 of the exact pack archive, lowercase hex.
57    pub pack_sha256: String,
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub note: Option<String>,
60}
61
62/// One admitted foreign endpoint.
63#[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/// The outcome of one trust question.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub enum Decision {
75    /// Loads; `basis` names the rule that admitted it.
76    Allow { basis: String },
77    /// Refused; `message` names the pack (or endpoint) and how to admit it.
78    Refuse { message: String },
79}
80
81impl Decision {
82    pub fn allowed(&self) -> bool {
83        matches!(self, Decision::Allow { .. })
84    }
85}
86
87/// A repository identity: `host/owner/name`, lowercased.
88#[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    /// Parse a git remote (`https://host/owner/name(.git)`, `ssh://git@host/owner/name`,
97    /// `git@host:owner/name.git`) or a plain `host/owner/name`.
98    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            // scp-like: git@host:owner/name
109            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        // A port belongs to the transport, not the identity.
120        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    /// Read and validate. A missing file trusts nothing. An invalid one refuses
163    /// everything: a trust file that cannot be read is not permission.
164    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    /// Atomic write with read-back, like the stack config.
197    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    /// May the pack archive with this sha256, shipped by the repository at `remote`, load?
212    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    /// May a pointer endpoint declared by the pack of the repository at `remote` be federated?
249    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    /// Add an owner; idempotent.
293    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    /// Admit one exact foreign pack; idempotent.
307    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    /// Admit one exact foreign endpoint; idempotent.
326    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
345/// An endpoint is an exact `http(s)` URL with no credentials in it.
346fn 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;