use std::collections::BTreeMap;
use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context as _, Result, bail};
use openmoat_core::{Decision, Verdict};
use serde::{Deserialize, Serialize};
use sha2::{Digest as _, Sha256};
use crate::home::{Home, write_private};
use crate::install::{HookState, HostConfig};
const LOCK_VERSION: u32 = 1;
pub const INTEGRITY_RULE: &str = "kernel-integrity";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Lock {
pub version: u32,
pub pinned_at_ms: i64,
pub binary: String,
pub entries: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub codex_profiles: BTreeMap<String, String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HookPins {
Adopt,
Keep,
}
pub fn repin(home: &Home, binary: &Path, hooks: HookPins) -> Result<Lock> {
repin_with(home, binary, hooks, &[], &[])
}
pub fn repin_with(
home: &Home,
binary: &Path,
hooks: HookPins,
files: &[PathBuf],
codex_configs: &[PathBuf],
) -> Result<Lock> {
let mut paths = state_files(home);
paths.extend_from_slice(files);
let mut profiles = codex_configs.to_vec();
let lock_path = home.lock_path();
let had_lock = lock_path.exists();
if had_lock {
match (Lock::load(&lock_path), hooks) {
(Ok(old), _) => {
paths.extend(old.entries.into_keys().map(PathBuf::from));
profiles.extend(old.codex_profiles.into_keys().map(PathBuf::from));
}
(Err(e), HookPins::Keep) => {
return Err(e.context("cannot tell which hook files are pinned; run `moat init`"));
}
(Err(_), HookPins::Adopt) => {}
}
}
if hooks == HookPins::Adopt || !had_lock {
paths.extend(installed_hook_files(binary)?);
profiles.extend(crate::sandbox::install::codex_profile_files()?);
}
let mut lock = Lock::pin(binary, &paths)?;
for path in profiles.iter().filter(|p| p.is_file()) {
let digest = crate::sandbox::install::codex_part_digest(path)?;
lock.codex_profiles.insert(key(path), digest);
}
lock.save(&lock_path)?;
Ok(lock)
}
fn state_files(home: &Home) -> Vec<PathBuf> {
vec![
home.policy_path(),
home.environment_path(),
home.grants_path(),
home.overlay_path(),
home.trust_path(),
]
}
pub fn installed_hook_files(binary: &Path) -> Result<Vec<PathBuf>> {
let mut paths = Vec::new();
for host in openmoat_hosts::Host::ALL {
let config = HostConfig::for_host(host)?;
if config.state(binary) == HookState::Installed {
paths.push(config.settings_path);
}
}
Ok(paths)
}
#[derive(Debug, Default, PartialEq, Eq)]
pub struct HookPinGap {
pub unpinned: Vec<PathBuf>,
pub elsewhere: Vec<PathBuf>,
}
impl HookPinGap {
pub fn new(lock: &Lock, home: &Home, installed: &[PathBuf]) -> Self {
let state: Vec<String> = state_files(home).iter().map(|p| key(p)).collect();
let here: Vec<String> = installed.iter().map(|p| key(p)).collect();
Self {
unpinned: installed
.iter()
.filter(|p| !lock.pins(p))
.cloned()
.collect(),
elsewhere: lock
.entries
.keys()
.filter(|k| !state.contains(k) && !here.contains(k))
.map(PathBuf::from)
.collect(),
}
}
}
pub fn violation(home: &Home) -> Result<Option<Decision>> {
let lock_path = home.lock_path();
if !lock_path.is_file() {
bail!("no policy lock at {}; run `moat init`", lock_path.display());
}
let drift = Lock::load(&lock_path)?.verify();
if drift.is_empty() {
return Ok(None);
}
let mut decision = Decision::new(Verdict::Deny);
decision.rules.push(INTEGRITY_RULE.to_owned());
for d in drift {
decision.reasons.push(format!("{d}"));
}
decision.reasons.push(
"run `moat doctor` to inspect; `moat doctor --accept` or `moat init` to re-pin".to_owned(),
);
Ok(Some(decision))
}
pub fn refuse_drift(home: &Home, doing: &str) -> Result<()> {
if let Some(deny) = violation(home)? {
bail!(
"refusing to {doing} while the policy lock shows drift ({INTEGRITY_RULE}):\n {}",
deny.reasons.join("\n ")
);
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Drift {
Modified(PathBuf),
Missing(PathBuf),
Unreadable(PathBuf, String),
Proposed {
proposal: PathBuf,
target: PathBuf,
},
}
impl fmt::Display for Drift {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Modified(p) => write!(f, "{} was modified", p.display()),
Self::Missing(p) => write!(f, "{} is missing", p.display()),
Self::Unreadable(p, why) => write!(f, "{} is unreadable: {why}", p.display()),
Self::Proposed { proposal, target } => write!(
f,
"{} would change {}",
proposal.display(),
target.display()
),
}
}
}
impl Lock {
pub fn pin(binary: &Path, paths: &[PathBuf]) -> Result<Self> {
let mut entries = BTreeMap::new();
for path in paths {
if path.is_file() {
entries.insert(key(path), digest(path)?);
}
}
Ok(Self {
version: LOCK_VERSION,
pinned_at_ms: crate::time::now_ms(),
binary: binary.to_string_lossy().into_owned(),
entries,
codex_profiles: BTreeMap::new(),
})
}
pub fn load(path: &Path) -> Result<Self> {
let text =
fs::read_to_string(path).with_context(|| format!("reading lock {}", path.display()))?;
let lock: Self = serde_json::from_str(&text)
.with_context(|| format!("parsing lock {}", path.display()))?;
if lock.version != LOCK_VERSION {
bail!(
"lock {} has version {}; this build supports {LOCK_VERSION}",
path.display(),
lock.version
);
}
Ok(lock)
}
pub fn save(&self, path: &Path) -> Result<()> {
let text = serde_json::to_string_pretty(self)? + "\n";
write_private(path, text.as_bytes())
}
pub fn verify(&self) -> Vec<Drift> {
let files = self.entries.keys().filter_map(|stored| {
if key(Path::new(stored)) != *stored {
return Some(Drift::Modified(PathBuf::from(stored)));
}
self.verify_one(Path::new(stored))
});
let profiles = self.codex_profiles.iter().filter_map(|(stored, expected)| {
let path = PathBuf::from(stored);
if key(&path) != *stored {
return Some(Drift::Modified(path));
}
if fs::symlink_metadata(&path).is_err() {
return Some(Drift::Missing(path));
}
match crate::sandbox::install::codex_part_digest(&path) {
Ok(actual) if &actual == expected => None,
Ok(_) => Some(Drift::Modified(path)),
Err(e) => Some(Drift::Unreadable(path, format!("{e:#}"))),
}
});
files.chain(profiles).collect()
}
pub fn pins_codex_profile(&self, path: &Path) -> bool {
self.codex_profiles.contains_key(&key(path))
}
pub fn verify_one(&self, path: &Path) -> Option<Drift> {
let expected = self.entries.get(&key(path))?;
let path = PathBuf::from(key(path));
if fs::symlink_metadata(&path).is_err() {
return Some(Drift::Missing(path));
}
match digest(&path) {
Ok(actual) if &actual == expected => None,
Ok(_) => Some(Drift::Modified(path)),
Err(e) => Some(Drift::Unreadable(path, format!("{e:#}"))),
}
}
pub fn verify_proposal(&self, proposal: &Path, target: &Path) -> Option<Drift> {
let expected = self.entries.get(&key(target))?;
if let Some(drift) = self.verify_one(target) {
return Some(drift);
}
match digest(proposal) {
Ok(actual) if &actual == expected => None,
Ok(_) => Some(Drift::Proposed {
proposal: proposal.to_path_buf(),
target: PathBuf::from(key(target)),
}),
Err(e) => Some(Drift::Unreadable(proposal.to_path_buf(), format!("{e:#}"))),
}
}
pub fn pins(&self, path: &Path) -> bool {
self.entries.contains_key(&key(path))
}
}
fn key(path: &Path) -> String {
let located = match (path.parent(), path.file_name()) {
(Some(parent), Some(name)) => {
fs::canonicalize(parent).map_or_else(|_| path.to_path_buf(), |p| p.join(name))
}
_ => path.to_path_buf(),
};
let text = located.to_string_lossy();
if cfg!(windows) {
crate::context::strip_verbatim(&text)
} else {
text.into_owned()
}
}
fn digest(path: &Path) -> Result<String> {
let meta = fs::symlink_metadata(path).with_context(|| format!("reading {}", path.display()))?;
let mut bytes = Vec::new();
if meta.file_type().is_symlink() {
let target =
fs::read_link(path).with_context(|| format!("reading link {}", path.display()))?;
bytes.extend_from_slice(b"symlink:");
bytes.extend_from_slice(target.to_string_lossy().as_bytes());
bytes.push(b'\n');
}
bytes.extend(fs::read(path).with_context(|| format!("reading {}", path.display()))?);
Ok(sha256_hex(&bytes))
}
pub fn sha256_hex(bytes: &[u8]) -> String {
Sha256::digest(bytes)
.iter()
.fold(String::with_capacity(64), |mut hex, b| {
use std::fmt::Write as _;
let _ = write!(hex, "{b:02x}");
hex
})
}
#[cfg(test)]
mod tests {
use super::*;
fn write(dir: &Path, name: &str, contents: &str) -> PathBuf {
let path = dir.join(name);
fs::write(&path, contents).unwrap();
path
}
#[test]
fn pin_verify_and_detect_drift() {
let dir = tempfile::tempdir().unwrap();
let policy = write(dir.path(), "policy.yaml", "version: 1\n");
let hook = write(dir.path(), "settings.json", "{}");
let absent = dir.path().join("never-written.json");
let lock = Lock::pin(
Path::new("/x/moat"),
&[policy.clone(), hook.clone(), absent],
)
.unwrap();
assert_eq!(lock.entries.len(), 2, "absent files are not pinned");
assert!(lock.entries.keys().any(|k| k.ends_with("policy.yaml")));
assert!(lock.verify().is_empty());
fs::write(&policy, "version: 1\nallow: []\n").unwrap();
fs::remove_file(&hook).unwrap();
let drift = lock.verify();
assert_eq!(drift.len(), 2);
assert!(
drift
.iter()
.any(|d| matches!(d, Drift::Modified(p) if p.ends_with("policy.yaml")))
);
assert!(
drift
.iter()
.any(|d| matches!(d, Drift::Missing(p) if p.ends_with("settings.json")))
);
}
#[cfg(unix)]
#[test]
fn replacing_a_pinned_file_with_a_symlink_is_drift() {
let dir = tempfile::tempdir().unwrap();
let policy = write(dir.path(), "policy.yaml", "version: 1\n");
let other = write(dir.path(), "other.yaml", "version: 1\n");
let lock = Lock::pin(Path::new("/x/moat"), std::slice::from_ref(&policy)).unwrap();
fs::remove_file(&policy).unwrap();
std::os::unix::fs::symlink(&other, &policy).unwrap();
assert!(lock.pins(&policy), "a symlinked leaf keeps its identity");
assert!(
matches!(lock.verify_one(&policy), Some(Drift::Modified(p)) if p.ends_with("policy.yaml")),
"same bytes through a link must still count as modified"
);
fs::remove_file(&policy).unwrap();
assert!(matches!(lock.verify_one(&policy), Some(Drift::Missing(_))));
}
#[test]
fn round_trips_through_disk_and_rejects_other_versions() {
let dir = tempfile::tempdir().unwrap();
let policy = write(dir.path(), "policy.yaml", "version: 1\n");
let lock_path = dir.path().join("policy.lock");
let lock = Lock::pin(Path::new("/x/moat"), &[policy]).unwrap();
lock.save(&lock_path).unwrap();
assert_eq!(Lock::load(&lock_path).unwrap(), lock);
fs::write(
&lock_path,
r#"{"version":9,"pinned_at_ms":0,"binary":"","entries":{}}"#,
)
.unwrap();
assert!(
Lock::load(&lock_path)
.unwrap_err()
.to_string()
.contains("version 9")
);
}
}