Skip to main content

caboodle/
pack_trust.rs

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