use std::{
fs,
io,
path::{Path, PathBuf},
sync::Once,
};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use ed25519_dalek::VerifyingKey;
use serde::{Deserialize, Serialize};
static WARN_TRUST_PATH_OVERRIDE_ONCE: Once = Once::new();
static WARN_INSECURE_PERMS_ONCE: Once = Once::new();
fn warn_trust_path_override_if_set() {
if let Some(p) = std::env::var_os("TREESHIP_TRUST_ROOTS") {
WARN_TRUST_PATH_OVERRIDE_ONCE.call_once(|| {
eprintln!(
"treeship: WARNING: trust store path overridden by TREESHIP_TRUST_ROOTS={} (not the default ~/.treeship/trust_roots.json)",
std::path::Path::new(&p).display(),
);
});
}
}
fn warn_insecure_perms_if_bypassed() {
if std::env::var_os("TREESHIP_ALLOW_INSECURE_KEY_PERMS")
.map(|v| v == "1")
.unwrap_or(false)
{
WARN_INSECURE_PERMS_ONCE.call_once(|| {
eprintln!(
"treeship: WARNING: trust file permission check bypassed by TREESHIP_ALLOW_INSECURE_KEY_PERMS=1 -- this opens a supply-chain hole if not a deliberate CI sandbox override"
);
});
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TrustRootKind {
HubCheckpoint,
Ship,
AgentCert,
}
impl TrustRootKind {
pub fn as_str(self) -> &'static str {
match self {
Self::HubCheckpoint => "hub_checkpoint",
Self::Ship => "ship",
Self::AgentCert => "agent_cert",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s {
"hub_checkpoint" => Some(Self::HubCheckpoint),
"ship" => Some(Self::Ship),
"agent_cert" => Some(Self::AgentCert),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TrustRoot {
pub key_id: String,
pub public_key: String,
pub kind: TrustRootKind,
#[serde(default)]
pub label: String,
#[serde(default)]
pub added_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct TrustRootFile {
pub version: u8,
pub roots: Vec<TrustRoot>,
}
const SCHEMA_VERSION: u8 = 1;
#[derive(Debug, Clone, Default)]
pub struct TrustRootStore {
roots: Vec<TrustRoot>,
}
#[derive(Debug)]
pub enum TrustRootError {
NotConfigured { path: PathBuf },
Malformed { path: PathBuf, msg: String },
Empty { path: PathBuf },
PermissionsTooOpen { path: PathBuf, mode: u32 },
Io(io::Error),
}
impl std::fmt::Display for TrustRootError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NotConfigured { path } => write!(
f,
"no trust roots configured (looked for {}). \
Run `treeship trust add <key_id> <pubkey> --kind <kind>` \
or sync from your hub via `treeship hub sync-trust`.",
path.display(),
),
Self::Malformed { path, msg } => write!(
f,
"trust root file {} is malformed: {msg}",
path.display(),
),
Self::Empty { path } => write!(
f,
"trust root file {} has no roots configured. \
Run `treeship trust add <key_id> <pubkey> --kind <kind>` \
to add an issuer.",
path.display(),
),
Self::PermissionsTooOpen { path, mode } => write!(
f,
"trust root file {} has insecure permissions (mode {:o}); \
chmod 600 the file and try again.",
path.display(),
mode & 0o777,
),
Self::Io(e) => write!(f, "trust root io: {e}"),
}
}
}
impl std::error::Error for TrustRootError {}
impl From<io::Error> for TrustRootError {
fn from(e: io::Error) -> Self { Self::Io(e) }
}
impl TrustRootStore {
pub fn default_path() -> PathBuf {
warn_trust_path_override_if_set();
std::env::var_os("TREESHIP_TRUST_ROOTS")
.map(PathBuf::from)
.unwrap_or_else(|| {
let home = std::env::var("HOME").unwrap_or_default();
PathBuf::from(home).join(".treeship").join("trust_roots.json")
})
}
pub fn empty() -> Self {
Self { roots: Vec::new() }
}
pub fn with_roots(roots: Vec<TrustRoot>) -> Self {
Self { roots }
}
pub fn open_or_empty(path: &Path) -> Result<Self, TrustRootError> {
match Self::open(path) {
Ok(s) => Ok(s),
Err(TrustRootError::NotConfigured { .. }) => Ok(Self::empty()),
Err(TrustRootError::Empty { .. }) => Ok(Self::empty()),
Err(e) => Err(e),
}
}
pub fn open_default_or_empty() -> Result<Self, TrustRootError> {
Self::open_or_empty(&Self::default_path())
}
pub fn open(path: &Path) -> Result<Self, TrustRootError> {
if !path.exists() {
return Err(TrustRootError::NotConfigured { path: path.to_path_buf() });
}
check_trust_file_perms(path)?;
let bytes = fs::read(path)?;
let file: TrustRootFile = serde_json::from_slice(&bytes)
.map_err(|e| TrustRootError::Malformed {
path: path.to_path_buf(),
msg: e.to_string(),
})?;
if file.version != SCHEMA_VERSION {
return Err(TrustRootError::Malformed {
path: path.to_path_buf(),
msg: format!(
"schema version mismatch: file has v{}, this binary supports v{}",
file.version, SCHEMA_VERSION,
),
});
}
for root in &file.roots {
decode_ed25519_pubkey(&root.public_key)
.map_err(|msg| TrustRootError::Malformed {
path: path.to_path_buf(),
msg: format!("root {}: {msg}", root.key_id),
})?;
}
if file.roots.is_empty() {
return Err(TrustRootError::Empty { path: path.to_path_buf() });
}
Ok(Self { roots: file.roots })
}
pub fn save(&self, path: &Path) -> Result<(), TrustRootError> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(parent, fs::Permissions::from_mode(0o700));
}
}
let file = TrustRootFile {
version: SCHEMA_VERSION,
roots: self.roots.clone(),
};
let json = serde_json::to_vec_pretty(&file)
.map_err(|e| TrustRootError::Malformed {
path: path.to_path_buf(),
msg: e.to_string(),
})?;
fs::write(path, &json)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
}
Ok(())
}
pub fn contains(&self, key: &VerifyingKey, kind: TrustRootKind) -> bool {
let key_bytes = key.to_bytes();
self.roots.iter().any(|r| {
r.kind == kind
&& decode_ed25519_pubkey(&r.public_key)
.map(|k| k.to_bytes() == key_bytes)
.unwrap_or(false)
})
}
pub fn contains_bytes(&self, key_bytes: &[u8; 32], kind: TrustRootKind) -> bool {
match VerifyingKey::from_bytes(key_bytes) {
Ok(vk) => self.contains(&vk, kind),
Err(_) => false,
}
}
pub fn is_empty(&self) -> bool {
self.roots.is_empty()
}
pub fn is_empty_for_kind(&self, kind: TrustRootKind) -> bool {
!self.roots.iter().any(|r| r.kind == kind)
}
pub fn add(&mut self, root: TrustRoot) {
self.roots.retain(|r| !(r.key_id == root.key_id && r.kind == root.kind));
self.roots.push(root);
}
pub fn remove(&mut self, key_id: &str) -> bool {
let before = self.roots.len();
self.roots.retain(|r| r.key_id != key_id);
self.roots.len() != before
}
pub fn roots(&self) -> &[TrustRoot] {
&self.roots
}
pub fn len(&self) -> usize {
self.roots.len()
}
}
pub fn decode_ed25519_pubkey(s: &str) -> Result<VerifyingKey, String> {
let b64 = s.strip_prefix("ed25519:").unwrap_or(s);
let bytes = URL_SAFE_NO_PAD
.decode(b64)
.map_err(|e| format!("base64url decode failed: {e}"))?;
let arr: [u8; 32] = bytes
.as_slice()
.try_into()
.map_err(|_| format!("expected 32-byte public key, got {} bytes", bytes.len()))?;
VerifyingKey::from_bytes(&arr).map_err(|e| format!("not a valid Ed25519 public key: {e}"))
}
pub fn encode_ed25519_pubkey(key: &VerifyingKey) -> String {
format!("ed25519:{}", URL_SAFE_NO_PAD.encode(key.to_bytes()))
}
fn check_trust_file_perms(path: &Path) -> Result<(), TrustRootError> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if std::env::var_os("TREESHIP_ALLOW_INSECURE_KEY_PERMS")
.map(|v| v == "1")
.unwrap_or(false)
{
warn_insecure_perms_if_bypassed();
return Ok(());
}
let meta = fs::metadata(path)?;
let mode = meta.permissions().mode();
if mode & 0o077 != 0 {
return Err(TrustRootError::PermissionsTooOpen {
path: path.to_path_buf(),
mode,
});
}
}
let _ = path;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::SigningKey;
fn tmp_dir(tag: &str) -> PathBuf {
let mut p = std::env::temp_dir();
let mut b = [0u8; 4];
use rand::RngCore;
rand::thread_rng().fill_bytes(&mut b);
p.push(format!("treeship-trust-test-{tag}-{}", hex::encode(b)));
std::fs::create_dir_all(&p).unwrap();
p
}
fn cleanup(p: &Path) {
let _ = fs::remove_dir_all(p);
}
fn fresh_root(key_id: &str, kind: TrustRootKind) -> (SigningKey, TrustRoot) {
let sk = SigningKey::generate(&mut rand::thread_rng());
let pk = sk.verifying_key();
let root = TrustRoot {
key_id: key_id.into(),
public_key: encode_ed25519_pubkey(&pk),
kind,
label: format!("test root {key_id}"),
added_at: "2026-05-15T00:00:00Z".into(),
};
(sk, root)
}
#[test]
fn roundtrip_save_load() {
let dir = tmp_dir("roundtrip");
let path = dir.join("trust_roots.json");
let (_, r1) = fresh_root("hub_a", TrustRootKind::HubCheckpoint);
let (_, r2) = fresh_root("ship_b", TrustRootKind::Ship);
let store = TrustRootStore::with_roots(vec![r1.clone(), r2.clone()]);
store.save(&path).unwrap();
let loaded = TrustRootStore::open(&path).unwrap();
assert_eq!(loaded.roots().len(), 2);
assert_eq!(loaded.roots()[0], r1);
assert_eq!(loaded.roots()[1], r2);
cleanup(&dir);
}
#[test]
fn rejects_missing_file() {
let dir = tmp_dir("missing");
let path = dir.join("nope.json");
match TrustRootStore::open(&path).unwrap_err() {
TrustRootError::NotConfigured { path: p } => assert_eq!(p, path),
other => panic!("expected NotConfigured, got {other:?}"),
}
cleanup(&dir);
}
#[test]
fn rejects_malformed_json() {
let dir = tmp_dir("malformed");
let path = dir.join("trust_roots.json");
fs::write(&path, b"{ this is not json").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
}
match TrustRootStore::open(&path).unwrap_err() {
TrustRootError::Malformed { path: p, .. } => assert_eq!(p, path),
other => panic!("expected Malformed, got {other:?}"),
}
cleanup(&dir);
}
#[test]
fn rejects_empty_roots() {
let dir = tmp_dir("empty");
let path = dir.join("trust_roots.json");
let file = serde_json::json!({"version": 1, "roots": []});
fs::write(&path, serde_json::to_vec_pretty(&file).unwrap()).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
}
match TrustRootStore::open(&path).unwrap_err() {
TrustRootError::Empty { path: p } => assert_eq!(p, path),
other => panic!("expected Empty, got {other:?}"),
}
cleanup(&dir);
}
#[test]
#[cfg(unix)]
fn permission_too_open_warns() {
use std::os::unix::fs::PermissionsExt;
std::env::remove_var("TREESHIP_ALLOW_INSECURE_KEY_PERMS");
let dir = tmp_dir("perms");
let path = dir.join("trust_roots.json");
let (_, r) = fresh_root("hub_a", TrustRootKind::HubCheckpoint);
let file = TrustRootFile { version: SCHEMA_VERSION, roots: vec![r] };
fs::write(&path, serde_json::to_vec_pretty(&file).unwrap()).unwrap();
fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
match TrustRootStore::open(&path).unwrap_err() {
TrustRootError::PermissionsTooOpen { path: p, mode } => {
assert_eq!(p, path);
assert_eq!(mode & 0o777, 0o644);
}
other => panic!("expected PermissionsTooOpen, got {other:?}"),
}
cleanup(&dir);
}
#[test]
fn contains_matches_kind_correctly() {
let (sk, r) = fresh_root("hub_a", TrustRootKind::HubCheckpoint);
let store = TrustRootStore::with_roots(vec![r]);
let vk = sk.verifying_key();
assert!(store.contains(&vk, TrustRootKind::HubCheckpoint),
"must accept matching kind");
assert!(!store.contains(&vk, TrustRootKind::Ship),
"must reject mismatching kind");
assert!(!store.contains(&vk, TrustRootKind::AgentCert),
"must reject mismatching kind");
}
#[test]
fn add_replaces_same_key_id_and_kind() {
let mut store = TrustRootStore::empty();
let (_, r1) = fresh_root("hub_a", TrustRootKind::HubCheckpoint);
let (_, r1b) = fresh_root("hub_a", TrustRootKind::HubCheckpoint);
store.add(r1);
store.add(r1b.clone());
assert_eq!(store.len(), 1, "same (id, kind) replaces previous");
assert_eq!(&store.roots()[0], &r1b);
}
#[test]
fn add_keeps_same_key_id_across_kinds() {
let mut store = TrustRootStore::empty();
let (_, r_hub) = fresh_root("issuer_x", TrustRootKind::HubCheckpoint);
let (_, r_ship) = fresh_root("issuer_x", TrustRootKind::Ship);
store.add(r_hub);
store.add(r_ship);
assert_eq!(store.len(), 2, "same id is allowed across different kinds");
}
#[test]
fn remove_strips_all_kinds_for_id() {
let mut store = TrustRootStore::empty();
let (_, r_hub) = fresh_root("issuer_x", TrustRootKind::HubCheckpoint);
let (_, r_ship) = fresh_root("issuer_x", TrustRootKind::Ship);
store.add(r_hub);
store.add(r_ship);
assert!(store.remove("issuer_x"));
assert!(store.is_empty());
assert!(!store.remove("issuer_x"), "second remove is a no-op");
}
#[test]
fn encode_decode_roundtrip() {
let sk = SigningKey::generate(&mut rand::thread_rng());
let pk = sk.verifying_key();
let encoded = encode_ed25519_pubkey(&pk);
assert!(encoded.starts_with("ed25519:"));
let decoded = decode_ed25519_pubkey(&encoded).unwrap();
assert_eq!(decoded.to_bytes(), pk.to_bytes());
}
#[test]
fn decode_accepts_bare_base64() {
let sk = SigningKey::generate(&mut rand::thread_rng());
let pk = sk.verifying_key();
let bare = URL_SAFE_NO_PAD.encode(pk.to_bytes());
let decoded = decode_ed25519_pubkey(&bare).unwrap();
assert_eq!(decoded.to_bytes(), pk.to_bytes());
}
}