lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit 7e99f9c93f937324564b31c4f38d1f15afca4545
parent 240d9b7f2df0218dd40c2b81c8990ab983cffc89
Author: triesap <tyson@radroots.org>
Date:   Sat, 18 Jul 2026 23:02:51 +0000

build: govern generated DTO root freshness

- generate every configured source-manifest root deterministically
- make contract and release lanes reject stale generated roots
- stage atomic writes with path and permission safeguards
- include DTO authority in filtered Nix source and command docs

Diffstat:
MAGENTS.md | 2++
MAGENT_INSTRUCTIONS.md | 2++
MCargo.lock | 2++
Mbuild/nix/common.nix | 1+
Mtools/xtask/Cargo.toml | 2++
Mtools/xtask/README | 6++++--
Atools/xtask/src/dto_roots.rs | 874+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtools/xtask/src/main.rs | 27++++++++++++++++++++++++++-
8 files changed, 913 insertions(+), 3 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md @@ -39,6 +39,8 @@ Before editing code: - `nix run .#contract` - `nix run .#release-preflight` - targeted `cargo check -p <crate>` and `cargo test -p <crate>` only inside the Nix shell +- `cargo xtask dto-roots --write` after changing configured DTO exports and + `cargo xtask dto-roots --check` for exact generated-root freshness - targeted `cargo xtask contract ...`, `cargo xtask coverage ...`, `cargo xtask release ...`, or `cargo xtask hygiene ...` only when narrowing a repo-owned workflow - if Beads is active, read `.beads/PRIME.md` diff --git a/AGENT_INSTRUCTIONS.md b/AGENT_INSTRUCTIONS.md @@ -161,6 +161,8 @@ Targeted iteration inside the Nix shell: - `cargo check -p <crate>` - `cargo test -p <crate>` - `cargo xtask contract validate` +- `cargo xtask dto-roots --check` +- `cargo xtask dto-roots --write` after changing configured DTO exports - `cargo xtask release preflight` - `cargo xtask hygiene forbidden-identifiers` diff --git a/Cargo.lock b/Cargo.lock @@ -8670,6 +8670,7 @@ dependencies = [ name = "xtask" version = "1.0.0-alpha.1" dependencies = [ + "dto_bindgen_core", "hex", "radroots_event_codec", "radroots_protocol_contract_v1", @@ -8677,6 +8678,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "tempfile", "toml", ] diff --git a/build/nix/common.nix b/build/nix/common.nix @@ -20,6 +20,7 @@ let ../../Cargo.lock ../../CHANGELOG.md ../../README + ../../dto_bindgen.toml ../../rust-toolchain.toml ../../contracts ../../crates diff --git a/tools/xtask/Cargo.toml b/tools/xtask/Cargo.toml @@ -10,6 +10,7 @@ publish = false authors = ["Tyson Lupul <tyson@radroots.org>"] [dependencies] +dto_bindgen_core = { workspace = true } radroots_event_codec = { workspace = true, features = [ "contract-manifest", ], default-features = false } @@ -18,6 +19,7 @@ semver = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } sha2 = { workspace = true } +tempfile = { workspace = true } hex = { workspace = true } toml = { workspace = true } diff --git a/tools/xtask/README b/tools/xtask/README @@ -7,8 +7,10 @@ tasks for the `radroots` core libraries. * an internal `cargo xtask` runner for workspace-oriented development and release workflows; - * `contract`, `coverage`, `release`, and `hygiene` command families for - core-library governance; + * `contract`, `coverage`, `dto-roots`, `release`, and `hygiene` command + families for core-library governance; + * deterministic `dto-roots --write|--check` generation for every + source-manifest package in the workspace DTO authority; * command-dispatch code used for contract, coverage, hygiene, and release paths inside the workspace; * a non-published binary crate used as tooling rather than as a library diff --git a/tools/xtask/src/dto_roots.rs b/tools/xtask/src/dto_roots.rs @@ -0,0 +1,874 @@ +use std::fs; +use std::io::{self, ErrorKind, Write}; +use std::path::{Component, Path, PathBuf}; + +use dto_bindgen_core::{ + Config, RootDiscoveryConfig, RootDiscoveryMode, generate_root_module, scan_rust_source, +}; +use tempfile::NamedTempFile; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Mode { + Check, + Write, +} + +#[derive(Debug)] +struct GeneratedRootFile { + path: PathBuf, + display_path: String, + contents: String, +} + +struct StagedRootFile { + path: PathBuf, + display_path: String, + temporary: NamedTempFile, + original: Option<OriginalFileState>, +} + +struct OriginalRootFile { + path: PathBuf, + display_path: String, + contents: Option<Vec<u8>>, + permissions: Option<fs::Permissions>, +} + +struct OriginalFileState { + contents: Vec<u8>, + permissions: fs::Permissions, +} + +type PersistFailure = (NamedTempFile, io::Error); + +pub(crate) fn run(args: &[String], workspace_root: &Path) -> Result<(), String> { + let mode = match args { + [arg] if arg == "--check" => Mode::Check, + [arg] if arg == "--write" => Mode::Write, + _ => return Err("usage: cargo xtask dto-roots --check|--write".to_owned()), + }; + execute(workspace_root, mode) +} + +pub(crate) fn check(workspace_root: &Path) -> Result<(), String> { + execute(workspace_root, Mode::Check) +} + +fn execute(workspace_root: &Path, mode: Mode) -> Result<(), String> { + let config_relative_path = "dto_bindgen.toml"; + let config_path = workspace_path(workspace_root, config_relative_path)?; + validate_existing_regular_file(workspace_root, config_relative_path, "DTO root authority")?; + let config = Config::from_toml_path(&config_path).map_err(|error| { + format!( + "failed to load DTO root authority `{}`: {error}", + config_path.display() + ) + })?; + let generated = generate_configured_roots(workspace_root, &config)?; + validate_output_paths(workspace_root, &generated)?; + + match mode { + Mode::Check => check_generated_roots(&generated), + Mode::Write => write_generated_roots(&generated), + } +} + +fn generate_configured_roots( + workspace_root: &Path, + config: &Config, +) -> Result<Vec<GeneratedRootFile>, String> { + let mut generated = Vec::new(); + if config.root_discovery.mode == RootDiscoveryMode::SourceManifest { + generated.push(generate_discovery_roots( + workspace_root, + config, + "top-level root discovery", + &config.root_discovery, + )?); + } + for package in &config.packages { + if package.root_discovery.mode != RootDiscoveryMode::SourceManifest { + continue; + } + let discovery = RootDiscoveryConfig { + mode: package.root_discovery.mode, + source_files: package.root_discovery.source_files.clone(), + root_module_file: package.root_discovery.root_module_file.clone(), + }; + generated.push(generate_discovery_roots( + workspace_root, + config, + &format!("package `{}`", package.key), + &discovery, + )?); + } + + if generated.is_empty() { + return Err("dto_bindgen.toml defines no source-manifest DTO roots".to_owned()); + } + + generated.sort_by(|left, right| left.display_path.cmp(&right.display_path)); + Ok(generated) +} + +fn generate_discovery_roots( + workspace_root: &Path, + config: &Config, + authority_label: &str, + discovery: &RootDiscoveryConfig, +) -> Result<GeneratedRootFile, String> { + let mut inventories = Vec::with_capacity(discovery.source_files.len()); + for source_file in &discovery.source_files { + let path = workspace_path(workspace_root, source_file)?; + validate_existing_regular_file(workspace_root, source_file, "DTO source")?; + let input = fs::read_to_string(&path).map_err(|error| { + format!("failed to read DTO source `{source_file}` for {authority_label}: {error}") + })?; + inventories.push( + scan_rust_source(source_file.clone(), &input).map_err(|error| error.to_string())?, + ); + } + + let mut package_config = config.clone(); + package_config.root_discovery = discovery.clone(); + let module = generate_root_module(&package_config, &inventories) + .map_err(|error| format!("failed to generate DTO roots for {authority_label}: {error}"))?; + let path = workspace_path(workspace_root, &module.path)?; + + Ok(GeneratedRootFile { + path, + display_path: module.path, + contents: module.contents, + }) +} + +fn workspace_path(workspace_root: &Path, configured_path: &str) -> Result<PathBuf, String> { + let relative = Path::new(configured_path); + if configured_path.is_empty() + || configured_path.contains('\\') + || configured_path + .split('/') + .any(|segment| matches!(segment, "" | "." | "..")) + || has_windows_drive_prefix(configured_path) + || relative.is_absolute() + || !relative + .components() + .all(|component| matches!(component, Component::Normal(_))) + { + return Err(format!( + "DTO root authority path must be a normalized workspace-relative path: `{configured_path}`" + )); + } + Ok(workspace_root.join(relative)) +} + +fn has_windows_drive_prefix(path: &str) -> bool { + let bytes = path.as_bytes(); + bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' +} + +fn validate_no_symlink_components( + workspace_root: &Path, + configured_path: &str, + allow_missing_final: bool, + role: &str, +) -> Result<(), String> { + let relative = Path::new(configured_path); + let component_count = relative.components().count(); + let mut current = workspace_root.to_path_buf(); + for (index, component) in relative.components().enumerate() { + let Component::Normal(segment) = component else { + return Err(format!( + "{role} path is not normalized: `{configured_path}`" + )); + }; + current.push(segment); + match current.symlink_metadata() { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err(format!( + "{role} path contains a symlink component: `{}`", + current.display() + )); + } + Ok(_) => {} + Err(error) + if error.kind() == ErrorKind::NotFound + && allow_missing_final + && index + 1 == component_count => + { + return Ok(()); + } + Err(error) => { + return Err(format!( + "failed to inspect {role} path component `{}`: {error}", + current.display() + )); + } + } + } + Ok(()) +} + +fn validate_existing_regular_file( + workspace_root: &Path, + configured_path: &str, + role: &str, +) -> Result<(), String> { + validate_no_symlink_components(workspace_root, configured_path, false, role)?; + let path = workspace_path(workspace_root, configured_path)?; + let metadata = path + .metadata() + .map_err(|error| format!("failed to inspect {role} `{configured_path}`: {error}"))?; + if !metadata.is_file() { + return Err(format!( + "{role} must be a regular file: `{configured_path}`" + )); + } + Ok(()) +} + +fn check_generated_roots(generated: &[GeneratedRootFile]) -> Result<(), String> { + let mut stale = Vec::new(); + for output in generated { + match fs::read(&output.path) { + Ok(current) if current == output.contents.as_bytes() => {} + Ok(_) => stale.push(format!("stale `{}`", output.display_path)), + Err(error) if error.kind() == ErrorKind::NotFound => { + stale.push(format!("missing `{}`", output.display_path)); + } + Err(error) => { + return Err(format!( + "failed to read generated DTO roots `{}`: {error}", + output.display_path + )); + } + } + } + + if stale.is_empty() { + Ok(()) + } else { + Err(format!( + "generated DTO roots are not fresh:\n- {}\nrun `cargo xtask dto-roots --write`", + stale.join("\n- ") + )) + } +} + +fn validate_output_paths( + workspace_root: &Path, + generated: &[GeneratedRootFile], +) -> Result<(), String> { + let canonical_root = workspace_root.canonicalize().map_err(|error| { + format!( + "failed to resolve DTO workspace root `{}`: {error}", + workspace_root.display() + ) + })?; + for output in generated { + validate_no_symlink_components( + workspace_root, + &output.display_path, + true, + "generated DTO root", + )?; + let parent = output.path.parent().ok_or_else(|| { + format!( + "generated DTO root path has no parent: `{}`", + output.display_path + ) + })?; + if !parent.is_dir() { + return Err(format!( + "generated DTO root parent does not exist: `{}`", + parent.display() + )); + } + let canonical_parent = parent.canonicalize().map_err(|error| { + format!( + "failed to resolve generated DTO root parent `{}`: {error}", + parent.display() + ) + })?; + if !canonical_parent.starts_with(&canonical_root) { + return Err(format!( + "generated DTO root parent escapes the workspace: `{}`", + parent.display() + )); + } + let metadata = match output.path.symlink_metadata() { + Ok(metadata) => Some(metadata), + Err(error) if error.kind() == ErrorKind::NotFound => None, + Err(error) => { + return Err(format!( + "failed to inspect generated DTO roots `{}`: {error}", + output.display_path + )); + } + }; + if metadata + .as_ref() + .is_some_and(|metadata| metadata.file_type().is_symlink()) + { + return Err(format!( + "generated DTO root cannot be a symlink: `{}`", + output.display_path + )); + } + if metadata + .as_ref() + .is_some_and(|metadata| !metadata.is_file()) + { + return Err(format!( + "generated DTO root is not a regular file: `{}`", + output.display_path + )); + } + } + Ok(()) +} + +fn write_generated_roots(generated: &[GeneratedRootFile]) -> Result<(), String> { + write_generated_roots_with(generated, persist_temporary_file) +} + +fn write_generated_roots_with<F>( + generated: &[GeneratedRootFile], + mut persist: F, +) -> Result<(), String> +where + F: FnMut(NamedTempFile, &Path) -> Result<(), PersistFailure>, +{ + let staged = stage_generated_roots(generated)?; + let mut committed = Vec::with_capacity(staged.len()); + for output in staged { + let original = OriginalRootFile { + path: output.path.clone(), + display_path: output.display_path.clone(), + permissions: output + .original + .as_ref() + .map(|original| original.permissions.clone()), + contents: output.original.map(|original| original.contents), + }; + if let Err((_temporary, error)) = persist(output.temporary, &output.path) { + let rollback = rollback_original_roots(&committed); + return Err(write_failure_with_rollback( + &output.display_path, + &error, + rollback, + )); + } + committed.push(original); + } + + if let Err(error) = check_generated_roots(generated) { + let rollback = rollback_original_roots(&committed); + return Err(match rollback { + Ok(()) => format!("{error}; restored all generated DTO roots"), + Err(rollback_error) => format!("{error}; rollback also failed: {rollback_error}"), + }); + } + Ok(()) +} + +fn stage_generated_roots(generated: &[GeneratedRootFile]) -> Result<Vec<StagedRootFile>, String> { + let mut staged = Vec::new(); + for output in generated { + let original = match fs::read(&output.path) { + Ok(current) if current == output.contents.as_bytes() => continue, + Ok(current) => { + let permissions = fs::metadata(&output.path) + .map_err(|error| { + format!( + "failed to inspect generated DTO roots `{}` before staging: {error}", + output.display_path + ) + })? + .permissions(); + Some(OriginalFileState { + contents: current, + permissions, + }) + } + Err(error) if error.kind() == ErrorKind::NotFound => None, + Err(error) => { + return Err(format!( + "failed to read generated DTO roots `{}` before staging: {error}", + output.display_path + )); + } + }; + let parent = output.path.parent().ok_or_else(|| { + format!( + "generated DTO root path has no parent: `{}`", + output.display_path + ) + })?; + let desired_permissions = original + .as_ref() + .map(|original| original.permissions.clone()) + .or_else(default_generated_file_permissions); + let temporary = stage_bytes( + parent, + output.contents.as_bytes(), + desired_permissions.as_ref(), + ) + .map_err(|error| { + format!( + "failed to stage generated DTO roots `{}`: {error}", + output.display_path + ) + })?; + staged.push(StagedRootFile { + path: output.path.clone(), + display_path: output.display_path.clone(), + temporary, + original, + }); + } + Ok(staged) +} + +fn stage_bytes( + parent: &Path, + contents: &[u8], + permissions: Option<&fs::Permissions>, +) -> io::Result<NamedTempFile> { + let mut temporary = NamedTempFile::new_in(parent)?; + temporary.write_all(contents)?; + temporary.flush()?; + if let Some(permissions) = permissions { + fs::set_permissions(temporary.path(), permissions.clone())?; + } + temporary.as_file().sync_all()?; + let staged = fs::read(temporary.path())?; + if staged != contents { + return Err(io::Error::other("staged DTO root bytes do not match input")); + } + Ok(temporary) +} + +#[cfg(unix)] +fn default_generated_file_permissions() -> Option<fs::Permissions> { + use std::os::unix::fs::PermissionsExt; + + Some(fs::Permissions::from_mode(0o644)) +} + +#[cfg(not(unix))] +fn default_generated_file_permissions() -> Option<fs::Permissions> { + None +} + +fn persist_temporary_file( + temporary: NamedTempFile, + destination: &Path, +) -> Result<(), PersistFailure> { + temporary + .persist(destination) + .map(|_| ()) + .map_err(|error| (error.file, error.error)) +} + +fn rollback_original_roots(committed: &[OriginalRootFile]) -> Result<(), String> { + let mut failures = Vec::new(); + for original in committed.iter().rev() { + let result = if let Some(contents) = &original.contents { + let parent = original.path.parent().ok_or_else(|| { + io::Error::other(format!( + "generated DTO root path has no parent: `{}`", + original.display_path + )) + }); + parent + .and_then(|parent| stage_bytes(parent, contents, original.permissions.as_ref())) + .and_then(|temporary| { + persist_temporary_file(temporary, &original.path) + .map_err(|(_temporary, error)| error) + }) + } else { + match fs::remove_file(&original.path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == ErrorKind::NotFound => Ok(()), + Err(error) => Err(error), + } + }; + if let Err(error) = result { + failures.push(format!("`{}`: {error}", original.display_path)); + } + } + if failures.is_empty() { + Ok(()) + } else { + Err(failures.join("; ")) + } +} + +fn write_failure_with_rollback( + display_path: &str, + error: &io::Error, + rollback: Result<(), String>, +) -> String { + match rollback { + Ok(()) => format!( + "failed to commit generated DTO roots `{display_path}`: {error}; restored all previously committed outputs" + ), + Err(rollback_error) => format!( + "failed to commit generated DTO roots `{display_path}`: {error}; rollback also failed: {rollback_error}" + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::Cell; + use tempfile::TempDir; + + fn fixture_workspace() -> TempDir { + let workspace = TempDir::new().expect("create fixture workspace"); + let root = workspace.path(); + fs::create_dir_all(root.join("crates/demo/src/generated")) + .expect("create fixture workspace"); + fs::write( + root.join("dto_bindgen.toml"), + r#"schema_version = 1 + +[[package]] +key = "demo" +rust_package = "demo" +rust_crate = "demo" +npm = "@radroots/demo" +out_dir = "target/demo" + +[package.root_discovery] +mode = "source_manifest" +source_files = ["crates/demo/src/model.rs"] +root_module_file = "crates/demo/src/generated/dto_roots.rs" +"#, + ) + .expect("write fixture config"); + fs::write( + root.join("crates/demo/src/model.rs"), + r#"#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] +#[cfg_attr(feature = "dto-bindgen", dto(export))] +pub struct DemoDto { + pub value: String, +} +"#, + ) + .expect("write fixture source"); + workspace + } + + fn add_second_package(root: &Path) { + fs::create_dir_all(root.join("crates/second/src/generated")) + .expect("create second package"); + let config_path = root.join("dto_bindgen.toml"); + let mut config = fs::read_to_string(&config_path).expect("read fixture config"); + config.push_str( + r#" +[[package]] +key = "second" +rust_package = "second" +rust_crate = "second" +npm = "@radroots/second" +out_dir = "target/second" + +[package.root_discovery] +mode = "source_manifest" +source_files = ["crates/second/src/model.rs"] +root_module_file = "crates/second/src/generated/dto_roots.rs" +"#, + ); + fs::write(config_path, config).expect("write two-package config"); + fs::write( + root.join("crates/second/src/model.rs"), + r#"#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] +#[cfg_attr(feature = "dto-bindgen", dto(export))] +pub struct SecondDto { + pub value: String, +} +"#, + ) + .expect("write second fixture source"); + } + + #[test] + fn write_then_check_is_deterministic() { + let workspace = fixture_workspace(); + let root = workspace.path(); + run(&["--write".to_owned()], root).expect("write roots"); + let output = root.join("crates/demo/src/generated/dto_roots.rs"); + let first = fs::read_to_string(&output).expect("read generated roots"); + assert!(first.contains("crate::model::DemoDto")); + + run(&["--write".to_owned()], root).expect("repeat write roots"); + assert_eq!( + fs::read_to_string(&output).expect("read repeated roots"), + first + ); + check(root).expect("fresh roots"); + } + + #[test] + fn check_aggregates_two_package_drift_without_mutation() { + let workspace = fixture_workspace(); + let root = workspace.path(); + add_second_package(root); + run(&["--write".to_owned()], root).expect("write roots"); + let demo = root.join("crates/demo/src/generated/dto_roots.rs"); + let second = root.join("crates/second/src/generated/dto_roots.rs"); + fs::write(&demo, "stale\n").expect("write drift"); + fs::remove_file(&second).expect("remove generated roots"); + + let stale = check(root).expect_err("reject stale roots"); + assert!(stale.contains("stale `crates/demo/src/generated/dto_roots.rs`")); + assert!(stale.contains("missing `crates/second/src/generated/dto_roots.rs`")); + assert!(stale.contains("cargo xtask dto-roots --write")); + assert_eq!(fs::read_to_string(&demo).expect("read drift"), "stale\n"); + assert!(!second.exists()); + + run(&["--write".to_owned()], root).expect("repair both roots"); + check(root).expect("both roots fresh"); + assert!( + fs::read_to_string(demo) + .expect("read demo roots") + .contains("DemoDto") + ); + assert!( + fs::read_to_string(second) + .expect("read second roots") + .contains("SecondDto") + ); + } + + #[test] + fn rejects_invalid_modes_paths_and_missing_authority() { + let workspace = fixture_workspace(); + let root = workspace.path(); + for args in [ + Vec::<String>::new(), + vec!["--unknown".to_owned()], + vec!["--check".to_owned(), "extra".to_owned()], + vec!["--check".to_owned(), "--write".to_owned()], + ] { + let mode = run(&args, root).expect_err("require one explicit valid mode"); + assert!(mode.contains("--check|--write")); + } + + for invalid in ["", "/tmp/output.rs", "../output.rs", "a\\b.rs", "a/./b.rs"] { + assert!(workspace_path(root, invalid).is_err(), "accepted {invalid}"); + } + + fs::remove_file(root.join("dto_bindgen.toml")).expect("remove authority"); + let missing = check(root).expect_err("reject missing authority"); + assert!(missing.contains("failed to inspect DTO root authority path component")); + } + + #[test] + fn supports_top_level_source_manifest_authority() { + let workspace = fixture_workspace(); + let root = workspace.path(); + fs::write( + root.join("dto_bindgen.toml"), + r#"schema_version = 1 + +[root_discovery] +mode = "source_manifest" +source_files = ["crates/demo/src/model.rs"] +root_module_file = "crates/demo/src/generated/dto_roots.rs" +"#, + ) + .expect("write top-level root authority"); + + run(&["--write".to_owned()], root).expect("write top-level roots"); + check(root).expect("top-level roots fresh"); + assert!( + fs::read_to_string(root.join("crates/demo/src/generated/dto_roots.rs")) + .expect("read top-level roots") + .contains("DemoDto") + ); + } + + #[test] + fn persist_failure_rolls_back_every_committed_output() { + let workspace = fixture_workspace(); + let root = workspace.path(); + add_second_package(root); + let config = Config::from_toml_path(root.join("dto_bindgen.toml")) + .expect("load two-package authority"); + let generated = generate_configured_roots(root, &config).expect("generate roots"); + validate_output_paths(root, &generated).expect("validate root paths"); + let demo = root.join("crates/demo/src/generated/dto_roots.rs"); + let second = root.join("crates/second/src/generated/dto_roots.rs"); + fs::write(&demo, "original demo\n").expect("write original demo"); + fs::write(&second, "original second\n").expect("write original second"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + fs::set_permissions(&demo, fs::Permissions::from_mode(0o640)) + .expect("set demo permissions"); + fs::set_permissions(&second, fs::Permissions::from_mode(0o604)) + .expect("set second permissions"); + } + + let persist_count = Cell::new(0_u8); + let error = write_generated_roots_with(&generated, |temporary, destination| { + persist_count.set(persist_count.get() + 1); + if persist_count.get() == 2 { + Err((temporary, io::Error::other("injected persist failure"))) + } else { + persist_temporary_file(temporary, destination) + } + }) + .expect_err("second persist must fail"); + + assert!(error.contains("injected persist failure")); + assert!(error.contains("restored all previously committed outputs")); + assert_eq!( + fs::read_to_string(&demo).expect("read demo"), + "original demo\n" + ); + assert_eq!( + fs::read_to_string(&second).expect("read second"), + "original second\n" + ); + #[cfg(unix)] + { + assert_eq!(unix_mode(&demo), 0o640); + assert_eq!(unix_mode(&second), 0o604); + } + } + + #[test] + fn persist_failure_removes_a_newly_committed_output() { + let workspace = fixture_workspace(); + let root = workspace.path(); + add_second_package(root); + let config = Config::from_toml_path(root.join("dto_bindgen.toml")) + .expect("load two-package authority"); + let generated = generate_configured_roots(root, &config).expect("generate roots"); + validate_output_paths(root, &generated).expect("validate root paths"); + let demo = root.join("crates/demo/src/generated/dto_roots.rs"); + let second = root.join("crates/second/src/generated/dto_roots.rs"); + fs::write(&second, "original second\n").expect("write original second"); + + let persist_count = Cell::new(0_u8); + write_generated_roots_with(&generated, |temporary, destination| { + persist_count.set(persist_count.get() + 1); + if persist_count.get() == 2 { + Err((temporary, io::Error::other("injected persist failure"))) + } else { + persist_temporary_file(temporary, destination) + } + }) + .expect_err("second persist must fail"); + + assert!(!demo.exists()); + assert_eq!( + fs::read_to_string(second).expect("read second"), + "original second\n" + ); + } + + #[cfg(unix)] + fn unix_mode(path: &Path) -> u32 { + use std::os::unix::fs::PermissionsExt; + + fs::metadata(path) + .expect("read permissions") + .permissions() + .mode() + & 0o777 + } + + #[cfg(unix)] + #[test] + fn write_creates_and_preserves_governed_source_permissions() { + use std::os::unix::fs::PermissionsExt; + + let workspace = fixture_workspace(); + let root = workspace.path(); + let output = root.join("crates/demo/src/generated/dto_roots.rs"); + run(&["--write".to_owned()], root).expect("create generated roots"); + assert_eq!(unix_mode(&output), 0o644); + + fs::write(&output, "stale\n").expect("make roots stale"); + fs::set_permissions(&output, fs::Permissions::from_mode(0o640)) + .expect("set custom permissions"); + run(&["--write".to_owned()], root).expect("replace generated roots"); + assert_eq!(unix_mode(&output), 0o640); + } + + #[cfg(unix)] + #[test] + fn check_rejects_generated_output_symlinks() { + use std::os::unix::fs::symlink; + + let workspace = fixture_workspace(); + let root = workspace.path(); + let output = root.join("crates/demo/src/generated/dto_roots.rs"); + let target = root.join("target.rs"); + fs::write(&target, "outside authority\n").expect("write symlink target"); + symlink(&target, &output).expect("create generated root symlink"); + + let error = check(root).expect_err("reject generated root symlink"); + assert!(error.contains("generated DTO root path contains a symlink component")); + } + + #[cfg(unix)] + #[test] + fn check_rejects_source_and_output_parent_symlinks() { + use std::os::unix::fs::symlink; + + let source_workspace = fixture_workspace(); + let source_root = source_workspace.path(); + let source = source_root.join("crates/demo/src/model.rs"); + let outside_workspace = TempDir::new().expect("create outside workspace"); + let outside_source = outside_workspace.path().join("outside_source.rs"); + fs::write( + &outside_source, + fs::read_to_string(&source).expect("read source"), + ) + .expect("write outside source"); + fs::remove_file(&source).expect("remove source"); + symlink(&outside_source, &source).expect("create source symlink"); + + let source_error = check(source_root).expect_err("reject source symlink"); + assert!(source_error.contains("DTO source path contains a symlink component")); + + let output_workspace = fixture_workspace(); + let output_root = output_workspace.path(); + let output_parent = output_root.join("crates/demo/src/generated"); + let outside_parent_workspace = TempDir::new().expect("create outside output workspace"); + let outside_parent = outside_parent_workspace.path().join("generated"); + fs::create_dir_all(&outside_parent).expect("create outside output parent"); + fs::remove_dir(&output_parent).expect("remove output parent"); + symlink(&outside_parent, &output_parent).expect("create output parent symlink"); + + let output_error = check(output_root).expect_err("reject output parent symlink"); + assert!(output_error.contains("generated DTO root path contains a symlink component")); + } + + #[test] + fn authority_and_sources_must_be_regular_files() { + let source_workspace = fixture_workspace(); + let source_root = source_workspace.path(); + let source = source_root.join("crates/demo/src/model.rs"); + fs::remove_file(&source).expect("remove source file"); + fs::create_dir(&source).expect("create source directory"); + let source_error = check(source_root).expect_err("reject source directory"); + assert!(source_error.contains("DTO source must be a regular file")); + + let authority_workspace = fixture_workspace(); + let authority_root = authority_workspace.path(); + let authority = authority_root.join("dto_bindgen.toml"); + fs::remove_file(&authority).expect("remove authority file"); + fs::create_dir(&authority).expect("create authority directory"); + let authority_error = check(authority_root).expect_err("reject authority directory"); + assert!(authority_error.contains("DTO root authority must be a regular file")); + } +} diff --git a/tools/xtask/src/main.rs b/tools/xtask/src/main.rs @@ -5,6 +5,8 @@ mod contract; mod coverage; #[cfg_attr(coverage_nightly, coverage(off))] +mod dto_roots; +#[cfg_attr(coverage_nightly, coverage(off))] mod hygiene; use std::env; @@ -15,6 +17,7 @@ fn usage() { eprintln!("usage:"); eprintln!(" cargo xtask contract validate"); eprintln!(" cargo xtask contract knowledge-manifest [--write]"); + eprintln!(" cargo xtask dto-roots --check|--write"); eprintln!(" cargo xtask release preflight"); eprintln!(" cargo xtask coverage run-crate --crate <crate> [--out <dir>]"); eprintln!(" cargo xtask coverage required-crates"); @@ -53,6 +56,7 @@ fn validate_contract() -> Result<(), String> { radroots_protocol_contract_v1::validate_protocol_contract_v1() .map_err(|error| error.to_string())?; let root = workspace_root(); + dto_roots::check(&root)?; contract::load_contract_bundle(&root) .and_then(|bundle| contract::validate_contract_bundle(&bundle)) .and_then(|_| contract::validate_canonical_event_boundary(&root)) @@ -61,7 +65,13 @@ fn validate_contract() -> Result<(), String> { #[cfg_attr(coverage_nightly, coverage(off))] fn release_preflight() -> Result<(), String> { - contract::validate_release_preflight(&workspace_root()) + let root = workspace_root(); + release_preflight_at(&root) +} + +fn release_preflight_at(root: &Path) -> Result<(), String> { + dto_roots::check(root)?; + contract::validate_release_preflight(root) } fn run_release(args: &[String]) -> Result<(), String> { @@ -90,6 +100,7 @@ fn run(args: &[String]) -> Result<(), String> { match args.first().map(String::as_str) { Some("contract") => run_contract(&args[1..]), Some("coverage") => coverage::run(&args[1..]), + Some("dto-roots") => dto_roots::run(&args[1..], &workspace_root()), Some("hygiene") => hygiene::run(&args[1..], &workspace_root()), Some("release") => run_release(&args[1..]), _ => Err("unknown command".to_string()), @@ -174,12 +185,24 @@ mod tests { let unknown_root = run(&["unknown".to_string()]).expect_err("unknown command"); assert!(unknown_root.contains("unknown command")); + let invalid_dto_roots = + run(&["dto-roots".to_string()]).expect_err("dto-roots requires an explicit mode"); + assert!(invalid_dto_roots.contains("--check|--write")); + let removed_sdk = run(&["sdk".to_string(), "validate".to_string()]) .expect_err("removed sdk command namespace"); assert!(removed_sdk.contains("unknown command")); } #[test] + fn release_preflight_checks_dto_root_authority_first() { + let workspace = tempfile::TempDir::new().expect("create empty workspace"); + let error = release_preflight_at(workspace.path()) + .expect_err("missing DTO root authority must fail first"); + assert!(error.contains("DTO root authority")); + } + + #[test] fn lock_workspace_recovers_from_poisoned_mutex() { let handle = std::thread::spawn(|| { let _guard = workspace_lock().lock().expect("lock workspace"); @@ -197,6 +220,8 @@ mod tests { fs::create_dir_all(&out_dir).expect("create out dir"); run_contract(&["validate".to_string()]).expect("validate contract"); + run(&["dto-roots".to_string(), "--check".to_string()]) + .expect("validate DTO root freshness"); coverage::run(&["help".to_string()]).expect("coverage help"); coverage::run(&["required-crates".to_string()]).expect("coverage required crates"); coverage::run(&["workspace-crates".to_string()]).expect("coverage workspace crates");