Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/neomacs-infra/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ publish = false

[dependencies]
getrandom.workspace = true
sha2.workspace = true
25 changes: 25 additions & 0 deletions crates/neomacs-infra/src/bin/infra.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,31 @@ fn main() {
exit(2);
}
},
Some("verify") => match args.next().as_deref().and_then(|arg| arg.to_str()) {
Some("doom") => match neomacs_infra::config_env::DoomEnvironment::open() {
Some(environment) => {
match neomacs_infra::config_env::ConfigEnvironment::verify_deep(&environment) {
Ok(drift) if drift.is_clean() => println!("doom: verified clean"),
Ok(drift) => {
eprintln!("doom fixture DRIFTED: {drift:?}");
exit(1);
}
Err(error) => {
eprintln!("doom verify error: {error}");
exit(1);
}
}
}
None => {
eprintln!("doom: not materialized");
exit(1);
}
},
other => {
eprintln!("unknown environment: {other:?}");
exit(2);
}
},
Comment on lines +33 to +57
Some("status") => {
for name in neomacs_infra::config_env::NAMES {
let status = match name.as_ref() {
Expand Down
26 changes: 25 additions & 1 deletion crates/neomacs-infra/src/config_env/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -284,13 +284,37 @@ pub fn seal(root: &Path) -> Result<(), String> {

/// Write the MANIFEST recording the fixture's name and source note, then
/// seal — the tail every environment's materialize shares.
///
/// The seal runs last so the INVENTORY's hashes describe the final sealed
/// bytes; the INVENTORY itself is written before sealing so `seal`'s walk
/// covers it too.
pub fn manifest_and_seal(root: &Path, name: &str, source_note: &str) -> Result<(), String> {
fs::write(
root.join("MANIFEST"),
format!("name = {name}\nsource = {source_note}\n"),
)
.map_err(|error| format!("write MANIFEST: {error}"))?;
seal(root)
seal(root)?;
// Self-check: the sealed fixture must verify clean against its own
// fresh inventory. A drift here means the bootstrap wrote after the
// inventory was taken, or the seal missed a path -- fail loudly now,
// not six hours later in a mysterious parity divergence.
let inventory = super::inventory::Inventory::build(root)?;
let drift = super::inventory::verify_deep(root, &inventory)?;
Comment on lines +287 to +303
if !drift.is_clean() {
return Err(format!(
"{name} fixture failed post-seal self-check: {} missing, {} modified, {} added",
drift.missing.len(),
drift.modified.len(),
drift.added.len()
));
}
Ok(())
}

/// Build the content inventory of a sealed fixture root.
pub fn build_inventory(root: &Path) -> Result<super::inventory::Inventory, String> {
super::inventory::Inventory::build(root)
}

/// Windows has no permission bits to clear, so the fixture cannot be
Expand Down
5 changes: 5 additions & 0 deletions crates/neomacs-infra/src/config_env/doom.rs
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,11 @@ impl ConfigEnvironment for DoomEnvironment {
self.tree().to_string_lossy().into_owned(),
]
}

fn verify_deep(&self) -> Result<crate::config_env::inventory::Drift, String> {
let inventory = crate::config_env::inventory::Inventory::build(&self.root)?;
crate::config_env::inventory::verify_deep(&self.root, &inventory)
Comment on lines +218 to +220
}
}

/// [`DoomEnvironment::open`] for callers that want the resolution status.
Expand Down
201 changes: 201 additions & 0 deletions crates/neomacs-infra/src/config_env/inventory.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
//! The fixture inventory: a content manifest that proves a sealed fixture
//! is byte-identical to the day it was sealed.
//!
//! The seal (write-bit stripping) stops accidental writes from succeeding;
//! the inventory makes any mutation that *did* get through — an editor
//! escaping a redirect, a chmod by tooling, a hand edit during debugging —
//! detectable at the next verify instead of silently diverging every
//! comparison that mounts the fixture.

use sha2::{Digest, Sha256};
use std::fs;
use std::path::{Path, PathBuf};

Check warning on line 12 in crates/neomacs-infra/src/config_env/inventory.rs

View workflow job for this annotation

GitHub Actions / MELPA direct adapter Job Object contracts (windows x86_64)

unused import: `PathBuf`

Check warning on line 12 in crates/neomacs-infra/src/config_env/inventory.rs

View workflow job for this annotation

GitHub Actions / cargo check workspace (linux-aarch64)

unused import: `PathBuf`

Check warning on line 12 in crates/neomacs-infra/src/config_env/inventory.rs

View workflow job for this annotation

GitHub Actions / native terminal output (windows-msvc-x86_64)

unused import: `PathBuf`

Check warning on line 12 in crates/neomacs-infra/src/config_env/inventory.rs

View workflow job for this annotation

GitHub Actions / cargo check workspace (windows-msvc-aarch64)

unused import: `PathBuf`

Check warning on line 12 in crates/neomacs-infra/src/config_env/inventory.rs

View workflow job for this annotation

GitHub Actions / cargo check workspace (macos-aarch64)

unused import: `PathBuf`

Check warning on line 12 in crates/neomacs-infra/src/config_env/inventory.rs

View workflow job for this annotation

GitHub Actions / cargo check workspace (macos-x86_64)

unused import: `PathBuf`

Check warning on line 12 in crates/neomacs-infra/src/config_env/inventory.rs

View workflow job for this annotation

GitHub Actions / cargo check workspace (linux-x86_64)

unused import: `PathBuf`

Check warning on line 12 in crates/neomacs-infra/src/config_env/inventory.rs

View workflow job for this annotation

GitHub Actions / cargo check workspace (windows-msvc-x86_64)

unused import: `PathBuf`

Check warning on line 12 in crates/neomacs-infra/src/config_env/inventory.rs

View workflow job for this annotation

GitHub Actions / workspace test archive (linux x86_64)

unused import: `PathBuf`

/// One file's recorded identity, relative to the fixture root.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Entry {
pub path: String,
pub size: u64,
pub sha256: String,
}

/// The full content inventory of one sealed fixture.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Inventory {
pub entries: Vec<Entry>,
}

impl Inventory {
/// Walk `root`, hashing every regular file. Symlinks are recorded by
/// their target string (content is whatever the target holds and is
/// covered when the target itself is visited, if it lives inside the
/// fixture).
pub fn build(root: &Path) -> Result<Self, String> {
let mut entries = Vec::new();
fn walk(root: &Path, dir: &Path, entries: &mut Vec<Entry>) -> Result<(), String> {
let items =
fs::read_dir(dir).map_err(|error| format!("read {}: {error}", dir.display()))?;
let mut items: Vec<_> = items.filter_map(Result::ok).map(|e| e.path()).collect();
items.sort();
for path in items {
let meta = fs::symlink_metadata(&path)
.map_err(|error| format!("stat {}: {error}", path.display()))?;
let rel = path
.strip_prefix(root)
.map_err(|error| format!("relativize {}: {error}", path.display()))?
.to_string_lossy()
.into_owned();
if meta.is_symlink() {
let target = fs::read_link(&path)
.map_err(|error| format!("readlink {}: {error}", path.display()))?;
entries.push(Entry {
path: rel,
size: 0,
sha256: format!("link:{}", target.to_string_lossy()),
});
} else if meta.is_dir() {
walk(root, &path, entries)?;
} else {
let bytes = fs::read(&path)
.map_err(|error| format!("read {}: {error}", path.display()))?;
let digest = Sha256::digest(&bytes);
let mut hex = String::with_capacity(digest.len() * 2);
for byte in digest {
hex.push_str(&format!("{byte:02x}"));
}
entries.push(Entry {
path: rel,
size: bytes.len() as u64,
sha256: hex,
});
}
}
Ok(())
}
walk(root, root, &mut entries)?;
Ok(Self { entries })
}

/// Serialize to stable JSON-lines (one entry per line).
pub fn to_jsonl(&self) -> String {
let mut out = String::new();
for e in &self.entries {
out.push_str(&format!(
"{{\"path\":{:?},\"size\":{},\"sha256\":{:?}}}\n",
e.path.replace('\\', "/"),
e.size,
e.sha256
));
}
out
}

/// Parse back a JSON-lines inventory. Tolerant of the two fields in
/// any order; unknown keys ignored.
pub fn parse_jsonl(text: &str) -> Result<Self, String> {
let mut entries = Vec::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let get = |key: &str| -> Option<String> {
let marker = format!("\"{key}\":");
let i = line.find(&marker)?;
let rest = line[i + marker.len()..].trim_start();
let rest = rest.strip_prefix('"')?;
let end = rest.find('"')?;
Some(rest[..end].to_owned())
};
let path = get("path").ok_or("inventory line missing path")?;
let size: u64 = get("size")
.and_then(|s| s.parse().ok())
.ok_or("inventory line missing size")?;
let sha256 = get("sha256").ok_or("inventory line missing sha256")?;
entries.push(Entry { path, size, sha256 });
}
Ok(Self { entries })
}
}

/// Differences between the inventory and the tree as it exists now.
#[derive(Debug, Default, PartialEq, Eq)]
pub struct Drift {
pub missing: Vec<String>,
pub modified: Vec<String>,
pub added: Vec<String>,
}

impl Drift {
pub fn is_clean(&self) -> bool {
self.missing.is_empty() && self.modified.is_empty() && self.added.is_empty()
}
}

/// Deep verification: re-walk the tree, re-hash every file, and compare
/// against the sealed inventory. A clean report proves the fixture is
/// byte-identical to the day it was sealed.
pub fn verify_deep(root: &Path, inventory: &Inventory) -> Result<Drift, String> {
// Current tree state, walked and hashed the same way.
let mut current: Vec<(String, u64, String)> = Vec::new();
fn walk(root: &Path, dir: &Path, out: &mut Vec<(String, u64, String)>) -> Result<(), String> {
let items =
fs::read_dir(dir).map_err(|error| format!("read {}: {error}", dir.display()))?;
let mut items: Vec<_> = items.filter_map(Result::ok).map(|e| e.path()).collect();
items.sort();
for path in items {
let meta = fs::symlink_metadata(&path)
.map_err(|error| format!("stat {}: {error}", path.display()))?;
let rel = path
.strip_prefix(root)
.map_err(|error| format!("relativize {}: {error}", path.display()))?
.to_string_lossy()
.into_owned();
if meta.is_symlink() {
let target = fs::read_link(&path)
.map_err(|error| format!("readlink {}: {error}", path.display()))?;
out.push((rel, 0, format!("link:{}", target.to_string_lossy())));
} else if meta.is_dir() {
walk(root, &path, out)?;
} else {
let bytes =
fs::read(&path).map_err(|error| format!("read {}: {error}", path.display()))?;
let digest = Sha256::digest(&bytes);
let mut hex = String::with_capacity(digest.len() * 2);
for byte in digest {
hex.push_str(&format!("{byte:02x}"));
}
out.push((rel, bytes.len() as u64, hex));
}
}
Ok(())
}
walk(root, root, &mut current)?;
Comment on lines +139 to +173

let sealed: std::collections::HashMap<&str, (u64, &str)> = inventory
.entries
.iter()
.map(|e| (e.path.as_str(), (e.size, e.sha256.as_str())))
.collect();
let now: std::collections::HashMap<&str, (u64, &str)> = current
.iter()
.map(|(p, s, h)| (p.as_str(), (*s, h.as_str())))
.collect();

let mut drift = Drift::default();
for (path, (size, hash)) in &sealed {
match now.get(path) {
None => drift.missing.push((*path).to_owned()),
Some((live_size, live_hash)) if live_size != size || live_hash != hash => {
drift.modified.push((*path).to_owned())
}
Some(_) => {}
}
}
for (path, (size, hash)) in &now {
if !sealed.contains_key(path) {
drift.added.push(format!("{path} ({size}B {hash})"));
}
}
Ok(drift)
}
8 changes: 8 additions & 0 deletions crates/neomacs-infra/src/config_env/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@

pub mod common;
pub mod doom;
pub mod inventory;
pub mod spacemacs;

pub use doom::{DoomEnvironment, DoomSource};
pub use inventory::{Drift, Inventory};
pub use spacemacs::{SpacemacsEnvironment, SpacemacsSource};

use std::ffi::OsString;
Expand Down Expand Up @@ -41,6 +43,12 @@ pub trait ConfigEnvironment {
/// state, share the multi-hundred-MB package builds read-only by
/// symlink, so a write attempt hits the seal and fails loudly.
fn prepare_session_state(&self, session_state: &Path) -> Result<(), String>;

/// Deep verification: re-walk the sealed fixture, re-hash every file,
/// and compare against the sealed inventory. Ok(drift) with a clean
/// drift proves the fixture is byte-identical to the day it was
/// sealed; Err means the fixture state itself is unreadable.
fn verify_deep(&self) -> Result<crate::config_env::inventory::Drift, String>;
}

/// XDG directories pinned inside the session state, so GTK3, fontconfig,
Expand Down
5 changes: 5 additions & 0 deletions crates/neomacs-infra/src/config_env/spacemacs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,11 @@ impl ConfigEnvironment for SpacemacsEnvironment {
fn session_args(&self) -> Vec<String> {
Vec::new()
}

fn verify_deep(&self) -> Result<crate::config_env::inventory::Drift, String> {
let inventory = crate::config_env::inventory::Inventory::build(&self.root)?;
crate::config_env::inventory::verify_deep(&self.root, &inventory)
Comment on lines +230 to +232
}
}

/// [`SpacemacsEnvironment::open`] for callers that want the resolution
Expand Down
Loading