use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use sha2::{Digest, Sha256};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct PathStatus {
pub path: PathBuf,
pub exists: bool,
pub writable: bool,
pub changed: bool,
}
pub fn status(workspace_root: &Path, forbidden: &[String]) -> Vec<PathStatus> {
forbidden
.iter()
.map(|rel| {
let p = workspace_root.join(rel);
let (exists, writable) = inspect(&p);
PathStatus { path: p, exists, writable, changed: false }
})
.collect()
}
pub fn apply(workspace_root: &Path, forbidden: &[String]) -> Result<Vec<PathStatus>> {
chmod_each(workspace_root, forbidden, false)
}
pub fn release(workspace_root: &Path, forbidden: &[String]) -> Result<Vec<PathStatus>> {
chmod_each(workspace_root, forbidden, true)
}
fn inspect(p: &Path) -> (bool, bool) {
match std::fs::metadata(p) {
Ok(m) => (true, !m.permissions().readonly()),
Err(_) => (false, false),
}
}
#[cfg(unix)]
fn chmod_each(
workspace_root: &Path,
forbidden: &[String],
writable: bool,
) -> Result<Vec<PathStatus>> {
use std::os::unix::fs::PermissionsExt;
let mut out = Vec::new();
for rel in forbidden {
let p = workspace_root.join(rel);
if !p.exists() {
out.push(PathStatus { path: p, exists: false, writable: false, changed: false });
continue;
}
let meta = std::fs::metadata(&p)
.with_context(|| format!("stat {}", p.display()))?;
let before = !meta.permissions().readonly();
let mut perms = meta.permissions();
let mode = perms.mode();
let new_mode = if writable {
mode | 0o200 } else {
mode & !0o222 };
perms.set_mode(new_mode);
std::fs::set_permissions(&p, perms)
.with_context(|| format!("chmod {}", p.display()))?;
let (_, after) = inspect(&p);
out.push(PathStatus {
path: p,
exists: true,
writable: after,
changed: before != after,
});
}
Ok(out)
}
#[cfg(not(unix))]
fn chmod_each(
workspace_root: &Path,
forbidden: &[String],
writable: bool,
) -> Result<Vec<PathStatus>> {
let mut out = Vec::new();
for rel in forbidden {
let p = workspace_root.join(rel);
if !p.exists() {
out.push(PathStatus { path: p, exists: false, writable: false, changed: false });
continue;
}
let meta = std::fs::metadata(&p)?;
let before = !meta.permissions().readonly();
let mut perms = meta.permissions();
perms.set_readonly(!writable);
std::fs::set_permissions(&p, perms)?;
let (_, after) = inspect(&p);
out.push(PathStatus {
path: p,
exists: true,
writable: after,
changed: before != after,
});
}
Ok(out)
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct ManifestEntry {
pub rel: String,
pub exists: bool,
pub is_dir: bool,
pub mode: u32,
pub sha256: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Manifest {
pub recorded_at: chrono::DateTime<chrono::Utc>,
pub entries: Vec<ManifestEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case", tag = "kind")]
pub enum Drift {
Vanished,
Appeared,
Mode { recorded: u32, current: u32 },
Content { recorded: Option<String>, current: Option<String> },
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct VerifyStatus {
pub rel: String,
pub drift: Vec<Drift>,
}
impl VerifyStatus {
pub fn ok(&self) -> bool {
self.drift.is_empty()
}
}
pub fn manifest_path(workspace_root: &Path) -> PathBuf {
workspace_root.join(".nornir").join("guard-manifest.json")
}
pub fn policy_path(workspace_root: &Path) -> PathBuf {
workspace_root.join(".nornir").join("guard-policy.json")
}
pub fn manifest(workspace_root: &Path, forbidden: &[String]) -> Manifest {
let entries = forbidden
.iter()
.map(|rel| entry_for(&workspace_root.join(rel), rel))
.collect();
Manifest { recorded_at: chrono::Utc::now(), entries }
}
fn entry_for(abs: &Path, rel: &str) -> ManifestEntry {
match std::fs::symlink_metadata(abs) {
Err(_) => ManifestEntry { rel: rel.to_string(), exists: false, is_dir: false, mode: 0, sha256: None },
Ok(meta) => {
let is_dir = meta.is_dir();
let mode = mode_of(&meta);
let sha256 = if is_dir || meta.file_type().is_symlink() {
None
} else {
sha256_file(abs).ok()
};
ManifestEntry { rel: rel.to_string(), exists: true, is_dir, mode, sha256 }
}
}
}
#[cfg(unix)]
fn mode_of(meta: &std::fs::Metadata) -> u32 {
use std::os::unix::fs::PermissionsExt;
meta.permissions().mode() & 0o7777
}
#[cfg(not(unix))]
fn mode_of(meta: &std::fs::Metadata) -> u32 {
if meta.permissions().readonly() { 0o444 } else { 0o644 }
}
fn sha256_file(path: &Path) -> Result<String> {
let bytes = std::fs::read(path).with_context(|| format!("read {}", path.display()))?;
let mut h = Sha256::new();
h.update(&bytes);
Ok(hex_encode(&h.finalize()))
}
fn hex_encode(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut s = String::with_capacity(bytes.len() * 2);
for &b in bytes {
s.push(HEX[(b >> 4) as usize] as char);
s.push(HEX[(b & 0x0f) as usize] as char);
}
s
}
pub fn write_manifest(workspace_root: &Path, m: &Manifest) -> Result<PathBuf> {
let path = manifest_path(workspace_root);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("mkdir {}", parent.display()))?;
}
let json = serde_json::to_string_pretty(m).context("serialize guard manifest")?;
std::fs::write(&path, json).with_context(|| format!("write {}", path.display()))?;
Ok(path)
}
pub fn read_manifest(workspace_root: &Path) -> Result<Manifest> {
let path = manifest_path(workspace_root);
let text = std::fs::read_to_string(&path)
.with_context(|| format!("read guard manifest {} (run guard_apply first)", path.display()))?;
serde_json::from_str(&text).with_context(|| format!("parse {}", path.display()))
}
pub fn verify(workspace_root: &Path, recorded: &Manifest) -> Vec<VerifyStatus> {
recorded
.entries
.iter()
.map(|rec| {
let cur = entry_for(&workspace_root.join(&rec.rel), &rec.rel);
let mut drift = Vec::new();
match (rec.exists, cur.exists) {
(true, false) => drift.push(Drift::Vanished),
(false, true) => drift.push(Drift::Appeared),
(false, false) => {}
(true, true) => {
if rec.mode != cur.mode {
drift.push(Drift::Mode { recorded: rec.mode, current: cur.mode });
}
if !rec.is_dir && !cur.is_dir && rec.sha256 != cur.sha256 {
drift.push(Drift::Content {
recorded: rec.sha256.clone(),
current: cur.sha256.clone(),
});
}
}
}
VerifyStatus { rel: rec.rel.clone(), drift }
})
.collect()
}
pub fn intact(workspace_root: &Path, recorded: &Manifest) -> Result<()> {
let report = verify(workspace_root, recorded);
let drifted: Vec<&VerifyStatus> = report.iter().filter(|v| !v.ok()).collect();
if drifted.is_empty() {
return Ok(());
}
let detail = drifted
.iter()
.map(|v| format!("{} ({} drift)", v.rel, v.drift.len()))
.collect::<Vec<_>>()
.join(", ");
anyhow::bail!("guard manifest drift on {} path(s): {detail}", drifted.len())
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Policy {
pub forbidden: Vec<String>,
pub manifest: PathBuf,
pub note: String,
}
pub fn write_policy(workspace_root: &Path, forbidden: &[String]) -> Result<PathBuf> {
let policy = Policy {
forbidden: forbidden.to_vec(),
manifest: manifest_path(workspace_root),
note: "Paths are workspace-root-relative and chmod -w by nornir guard. \
Deny writes from agent runtimes; nornir's guard_intact gate verifies \
sha256+mode at release time."
.to_string(),
};
let path = policy_path(workspace_root);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("mkdir {}", parent.display()))?;
}
let json = serde_json::to_string_pretty(&policy).context("serialize guard policy")?;
std::fs::write(&path, json).with_context(|| format!("write {}", path.display()))?;
Ok(path)
}
pub fn apply_and_record(workspace_root: &Path, forbidden: &[String]) -> Result<Vec<PathStatus>> {
let report = apply(workspace_root, forbidden)?;
let m = manifest(workspace_root, forbidden);
write_manifest(workspace_root, &m)?;
write_policy(workspace_root, forbidden)?;
Ok(report)
}
#[cfg(test)]
mod tests {
use super::*;
fn write(p: &Path, s: &str) {
std::fs::write(p, s).unwrap();
}
#[test]
fn manifest_then_verify_clean() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(&root.join("a.txt"), "hello");
std::fs::create_dir(root.join("d")).unwrap();
let forbidden = vec!["a.txt".to_string(), "d".to_string(), "missing.txt".to_string()];
let m = manifest(root, &forbidden);
let report = verify(root, &m);
assert!(report.iter().all(|v| v.ok()), "freshly recorded tree must be intact");
assert!(intact(root, &m).is_ok());
}
#[test]
fn verify_detects_content_drift() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(&root.join("a.txt"), "hello");
let forbidden = vec!["a.txt".to_string()];
let m = manifest(root, &forbidden);
write(&root.join("a.txt"), "tampered");
let report = verify(root, &m);
let a = report.iter().find(|v| v.rel == "a.txt").unwrap();
assert!(matches!(a.drift.as_slice(), [Drift::Content { .. }]));
assert!(intact(root, &m).is_err());
}
#[test]
fn verify_detects_vanish_and_appear() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(&root.join("a.txt"), "hi");
let forbidden = vec!["a.txt".to_string(), "later.txt".to_string()];
let m = manifest(root, &forbidden);
std::fs::remove_file(root.join("a.txt")).unwrap();
write(&root.join("later.txt"), "now here");
let report = verify(root, &m);
let a = report.iter().find(|v| v.rel == "a.txt").unwrap();
let l = report.iter().find(|v| v.rel == "later.txt").unwrap();
assert!(matches!(a.drift.as_slice(), [Drift::Vanished]));
assert!(matches!(l.drift.as_slice(), [Drift::Appeared]));
}
#[cfg(unix)]
#[test]
fn verify_detects_mode_drift() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let f = root.join("a.txt");
write(&f, "x");
let mut perms = std::fs::metadata(&f).unwrap().permissions();
perms.set_mode(0o444);
std::fs::set_permissions(&f, perms).unwrap();
let forbidden = vec!["a.txt".to_string()];
let m = manifest(root, &forbidden);
let mut perms = std::fs::metadata(&f).unwrap().permissions();
perms.set_mode(0o644);
std::fs::set_permissions(&f, perms).unwrap();
let report = verify(root, &m);
let a = report.iter().find(|v| v.rel == "a.txt").unwrap();
assert!(a.drift.iter().any(|d| matches!(d, Drift::Mode { .. })));
}
#[test]
fn write_and_read_manifest_roundtrips() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(&root.join("a.txt"), "hello");
let forbidden = vec!["a.txt".to_string()];
let report = apply_and_record(root, &forbidden).unwrap();
assert_eq!(report.len(), 1);
assert!(manifest_path(root).exists());
assert!(policy_path(root).exists());
let loaded = read_manifest(root).unwrap();
assert!(intact(root, &loaded).is_ok());
}
}