use std::{
env, fs,
path::{Path, PathBuf},
};
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
pub fn default_path() -> Result<PathBuf> {
let home = env::var_os("HOME").context("HOME is unset; cannot locate pack trust config")?;
Ok(PathBuf::from(home).join(".config/caboodle/pack-trust.toml"))
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TrustConfig {
#[serde(default)]
pub owners: Vec<String>,
#[serde(default, rename = "allow")]
pub packs: Vec<PackAllow>,
#[serde(default, rename = "allow_endpoint")]
pub endpoints: Vec<EndpointAllow>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PackAllow {
pub repo: String,
pub pack_sha256: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EndpointAllow {
pub repo: String,
pub endpoint: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Decision {
Allow { basis: String },
Refuse { message: String },
}
impl Decision {
pub fn allowed(&self) -> bool {
matches!(self, Decision::Allow { .. })
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Repo {
pub host: String,
pub owner: String,
pub name: String,
}
impl Repo {
pub fn parse(remote: &str) -> Result<Repo> {
let trimmed = remote.trim().trim_end_matches('/');
let rest = if let Some((scheme, rest)) = trimmed.split_once("://") {
if !matches!(scheme, "https" | "http" | "ssh" | "git") {
bail!("unsupported remote scheme in {remote:?}");
}
rest.rsplit_once('@')
.map_or(rest, |(_, after)| after)
.to_string()
} else if let Some((user_host, path)) = trimmed.split_once(':') {
let host = user_host.rsplit_once('@').map_or(user_host, |(_, h)| h);
format!("{host}/{path}")
} else {
trimmed.to_string()
};
let rest = rest.strip_suffix(".git").unwrap_or(&rest);
let parts: Vec<&str> = rest.split('/').collect();
let [host, owner, name] = parts.as_slice() else {
bail!("remote {remote:?} is not host/owner/name");
};
let host = host.split(':').next().unwrap_or(host);
let valid = |s: &str| {
!s.is_empty()
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
};
if !(valid(host) && valid(owner) && valid(name)) {
bail!("remote {remote:?} has an invalid host, owner or name");
}
Ok(Repo {
host: host.to_ascii_lowercase(),
owner: owner.to_ascii_lowercase(),
name: name.to_ascii_lowercase(),
})
}
pub fn owner_key(&self) -> String {
format!("{}/{}", self.host, self.owner)
}
pub fn key(&self) -> String {
format!("{}/{}/{}", self.host, self.owner, self.name)
}
}
fn check_sha256(value: &str) -> Result<String> {
let lower = value.trim().to_ascii_lowercase();
if lower.len() != 64 || !lower.chars().all(|c| c.is_ascii_hexdigit()) {
bail!("pack hash {value:?} is not a 64-character sha256");
}
Ok(lower)
}
fn normalize_owner(owner: &str) -> Result<String> {
let parts: Vec<&str> = owner.trim().trim_matches('/').split('/').collect();
let [host, name] = parts.as_slice() else {
bail!("owner {owner:?} must be host/owner, e.g. github.com/scbrown");
};
Ok(Repo::parse(&format!("{host}/{name}/x"))?.owner_key())
}
impl TrustConfig {
pub fn load(path: &Path) -> Result<TrustConfig> {
if !path.exists() {
return Ok(TrustConfig::default());
}
let body = fs::read_to_string(path)
.with_context(|| format!("read pack trust {}", path.display()))?;
let config: TrustConfig = toml::from_str(&body).with_context(|| {
format!(
"parse pack trust {}; every pack is refused until it is fixed (an allow \
entry needs repo AND pack_sha256)",
path.display()
)
})?;
config.validate()?;
Ok(config)
}
fn validate(&self) -> Result<()> {
for owner in &self.owners {
normalize_owner(owner)?;
}
for allow in &self.packs {
Repo::parse(&allow.repo)?;
check_sha256(&allow.pack_sha256)?;
}
for allow in &self.endpoints {
Repo::parse(&allow.repo)?;
check_endpoint(&allow.endpoint)?;
}
Ok(())
}
pub fn save(&self, path: &Path) -> Result<()> {
self.validate()?;
let rendered = toml::to_string_pretty(self).context("render pack trust")?;
let parent = path.parent().context("pack trust path has no parent")?;
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
let temp = path.with_extension("toml.tmp");
fs::write(&temp, &rendered).with_context(|| format!("write {}", temp.display()))?;
fs::rename(&temp, path).with_context(|| format!("replace {}", path.display()))?;
if &TrustConfig::load(path)? != self {
bail!("pack trust read-back did not match what was written");
}
Ok(())
}
pub fn decide_pack(&self, remote: &str, pack: &str, pack_sha256: &str) -> Decision {
let (repo, sha) = match (Repo::parse(remote), check_sha256(pack_sha256)) {
(Ok(repo), Ok(sha)) => (repo, sha),
(Err(e), _) | (_, Err(e)) => {
return Decision::Refuse {
message: format!("pack {pack} refused: {e}"),
}
}
};
if self.owns(&repo) {
return Decision::Allow {
basis: format!(
"own repository ({} is on the owners list)",
repo.owner_key()
),
};
}
let pinned = self.packs.iter().any(|a| {
Repo::parse(&a.repo).is_ok_and(|r| r == repo)
&& check_sha256(&a.pack_sha256).is_ok_and(|s| s == sha)
});
if pinned {
return Decision::Allow {
basis: format!("explicit allow for {} at sha256 {sha}", repo.key()),
};
}
Decision::Refuse {
message: format!(
"foreign pack {pack} (sha256 {sha}) from {} has no allow entry. Admit exactly \
this archive with: tambo packs trust allow {} {sha}",
repo.key(),
repo.key()
),
}
}
pub fn decide_endpoint(&self, remote: &str, endpoint: &str) -> Decision {
let (repo, url) = match (Repo::parse(remote), check_endpoint(endpoint)) {
(Ok(repo), Ok(url)) => (repo, url),
(Err(e), _) | (_, Err(e)) => {
return Decision::Refuse {
message: format!("endpoint {endpoint} refused: {e}"),
}
}
};
if self.owns(&repo) {
return Decision::Allow {
basis: format!(
"own repository ({} is on the owners list)",
repo.owner_key()
),
};
}
let allowed = self.endpoints.iter().any(|a| {
Repo::parse(&a.repo).is_ok_and(|r| r == repo)
&& check_endpoint(&a.endpoint).is_ok_and(|e| e == url)
});
if allowed {
return Decision::Allow {
basis: format!("explicit endpoint allow for {}", repo.key()),
};
}
Decision::Refuse {
message: format!(
"endpoint {url} declared by foreign repository {} has no allow entry; a pack \
declaring it is not consent. Admit it with: tambo packs trust \
allow-endpoint {} {url}",
repo.key(),
repo.key()
),
}
}
fn owns(&self, repo: &Repo) -> bool {
self.owners
.iter()
.any(|o| normalize_owner(o).is_ok_and(|o| o == repo.owner_key()))
}
pub fn add_owner(&mut self, owner: &str) -> Result<bool> {
let key = normalize_owner(owner)?;
if self
.owners
.iter()
.any(|o| normalize_owner(o).is_ok_and(|o| o == key))
{
return Ok(false);
}
self.owners.push(key);
Ok(true)
}
pub fn add_pack(&mut self, repo: &str, pack_sha256: &str, note: Option<&str>) -> Result<bool> {
let repo = Repo::parse(repo)?.key();
let sha = check_sha256(pack_sha256)?;
if self
.packs
.iter()
.any(|a| a.repo == repo && a.pack_sha256 == sha)
{
return Ok(false);
}
self.packs.push(PackAllow {
repo,
pack_sha256: sha,
note: note.map(str::to_string),
});
Ok(true)
}
pub fn add_endpoint(&mut self, repo: &str, endpoint: &str, note: Option<&str>) -> Result<bool> {
let repo = Repo::parse(repo)?.key();
let url = check_endpoint(endpoint)?;
if self
.endpoints
.iter()
.any(|a| a.repo == repo && a.endpoint == url)
{
return Ok(false);
}
self.endpoints.push(EndpointAllow {
repo,
endpoint: url,
note: note.map(str::to_string),
});
Ok(true)
}
}
fn check_endpoint(endpoint: &str) -> Result<String> {
let url = endpoint.trim();
let Some((scheme, rest)) = url.split_once("://") else {
bail!("endpoint {endpoint:?} is not an http(s) URL");
};
if !matches!(scheme, "https" | "http") || rest.is_empty() {
bail!("endpoint {endpoint:?} is not an http(s) URL");
}
let authority = rest.split('/').next().unwrap_or_default();
if authority.contains('@') {
bail!("endpoint {endpoint:?} carries credentials; trust is recorded without secrets");
}
Ok(url.to_string())
}
#[cfg(test)]
#[path = "pack_trust_tests.rs"]
mod tests;