diff --git a/crates/commandf-cli/src/impact.rs b/crates/commandf-cli/src/impact.rs index c0a6d2b2..f2ad954c 100644 --- a/crates/commandf-cli/src/impact.rs +++ b/crates/commandf-cli/src/impact.rs @@ -24,11 +24,8 @@ pub fn run( let before_cache = PackageCache::new(before_cache); let after_cache = PackageCache::new(after_cache); - before_cache.verify(&before_locked.sha256)?; - after_cache.verify(&after_locked.sha256)?; - - let before_bytes = read_locked_archive(&before_cache, before_locked)?; - let after_bytes = read_locked_archive(&after_cache, after_locked)?; + let before_bytes = before_cache.read_verified(&before_locked.sha256)?; + let after_bytes = after_cache.read_verified(&after_locked.sha256)?; let diff = diff_package_archives( package_name.to_string(), &before_locked.version, @@ -57,15 +54,6 @@ fn require_lock_v2(lockfile: &Lockfile, side: &'static str) -> io::Result<()> { )) } -fn read_locked_archive(cache: &PackageCache, locked: &LockedPackage) -> io::Result> { - fs::read( - cache - .root() - .join("sha256") - .join(format!("{}.tgz", locked.sha256)), - ) -} - fn select_locked_package<'a>( lockfile: &'a Lockfile, package_name: &str, diff --git a/crates/commandf-cli/src/oracle.rs b/crates/commandf-cli/src/oracle.rs index 791f88fc..b5267e0f 100644 --- a/crates/commandf-cli/src/oracle.rs +++ b/crates/commandf-cli/src/oracle.rs @@ -5,8 +5,9 @@ use std::time::Duration; use commandf_pkg::{ diff_package_archives, matched_structure_definition_pairs, reconcile_hl7_oracle, - run_hl7_oracle_adapter, validate_hl7_oracle_adapter, Hl7OracleInvocation, LockedPackage, - Lockfile, PackageCache, PackageName, ResourceKey, ResourceKeyKind, DEFAULT_ORACLE_TIMEOUT_SECS, + run_hl7_oracle_adapter, validate_hl7_oracle_adapter, Hl7OracleInvocation, + Hl7OracleStagedArchives, LockedPackage, Lockfile, PackageCache, PackageName, ResourceKey, + ResourceKeyKind, DEFAULT_ORACLE_TIMEOUT_SECS, }; const ORACLE_CORE_PACKAGE: &str = "hl7.fhir.r4.core"; @@ -44,16 +45,10 @@ pub fn run( let before_cache = PackageCache::new(before_cache); let after_cache = PackageCache::new(after_cache); - before_cache.verify(&before_locked.sha256)?; - after_cache.verify(&after_locked.sha256)?; - before_cache.verify(&before_core.sha256)?; - after_cache.verify(&after_core.sha256)?; - - let before_archive = archive_path(&before_cache, before_locked); - let after_archive = archive_path(&after_cache, after_locked); - let core_archive = archive_path(&before_cache, before_core); - let before_bytes = fs::read(&before_archive)?; - let after_bytes = fs::read(&after_archive)?; + let before_bytes = before_cache.read_verified(&before_locked.sha256)?; + let after_bytes = after_cache.read_verified(&after_locked.sha256)?; + let core_bytes = before_cache.read_verified(&before_core.sha256)?; + let _after_core_bytes = after_cache.read_verified(&after_core.sha256)?; let structural_diff = diff_package_archives( package_name.to_string(), @@ -67,6 +62,7 @@ pub fn run( let mut observations = Vec::new(); if before_locked.sha256 != after_locked.sha256 { + let staged = Hl7OracleStagedArchives::new(&core_bytes, &before_bytes, &after_bytes)?; let pairs = matched_structure_definition_pairs( package_name.as_str(), &before_locked.version, @@ -83,9 +79,9 @@ pub fn run( } let (url, version) = canonical_parts(&pair.resource)?; let invocation = Hl7OracleInvocation { - core_package: &core_archive, - left_package: &before_archive, - right_package: &after_archive, + core_package: staged.core_package(), + left_package: staged.left_package(), + right_package: staged.right_package(), left_url: url, left_version: version, right_url: url, @@ -142,13 +138,6 @@ fn select_locked_package<'a>( Ok(selected) } -fn archive_path(cache: &PackageCache, package: &LockedPackage) -> PathBuf { - cache - .root() - .join("sha256") - .join(format!("{}.tgz", package.sha256)) -} - fn canonical_parts(resource: &ResourceKey) -> Result<(&str, Option<&str>), io::Error> { if resource.kind != ResourceKeyKind::Canonical { return Err(io::Error::new( diff --git a/crates/commandf-pkg/src/lib.rs b/crates/commandf-pkg/src/lib.rs index 51f206d4..94a65fdd 100644 --- a/crates/commandf-pkg/src/lib.rs +++ b/crates/commandf-pkg/src/lib.rs @@ -109,7 +109,8 @@ pub use oracle_model::{ }; pub use oracle_process::{ run_hl7_oracle_adapter, validate_hl7_oracle_adapter, Hl7OracleInvocation, - DEFAULT_ORACLE_TIMEOUT_SECS, MAX_ORACLE_STDERR_BYTES, MAX_ORACLE_STDOUT_BYTES, + Hl7OracleStagedArchives, DEFAULT_ORACLE_TIMEOUT_SECS, MAX_ORACLE_STDERR_BYTES, + MAX_ORACLE_STDOUT_BYTES, }; pub use oracle_reconcile::{ parse_hl7_oracle_report, reconcile_hl7_oracle, validate_hl7_oracle_report, diff --git a/crates/commandf-pkg/src/oracle_process.rs b/crates/commandf-pkg/src/oracle_process.rs index bd5281bb..e427c19f 100644 --- a/crates/commandf-pkg/src/oracle_process.rs +++ b/crates/commandf-pkg/src/oracle_process.rs @@ -1,9 +1,12 @@ -use std::io::{self, Read}; -use std::path::Path; +use std::fs::OpenOptions; +use std::io::{self, Read, Write}; +use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; use std::thread; use std::time::{Duration, Instant}; +use tempfile::TempDir; + use crate::{parse_hl7_oracle_report, Hl7OracleReport, OracleError}; pub const DEFAULT_ORACLE_TIMEOUT_SECS: u64 = 60; @@ -20,6 +23,84 @@ pub struct Hl7OracleInvocation<'a> { pub right_version: Option<&'a str>, } +pub struct Hl7OracleStagedArchives { + _directory: TempDir, + core_package: PathBuf, + left_package: PathBuf, + right_package: PathBuf, +} + +impl Hl7OracleStagedArchives { + pub fn new(core: &[u8], left: &[u8], right: &[u8]) -> Result { + let directory = tempfile::tempdir().map_err(|source| OracleError::AdapterIo { + operation: "creating staged oracle directory", + source, + })?; + let core_package = stage_archive(directory.path(), "core.tgz", core)?; + let left_package = stage_archive(directory.path(), "left.tgz", left)?; + let right_package = stage_archive(directory.path(), "right.tgz", right)?; + Ok(Self { + _directory: directory, + core_package, + left_package, + right_package, + }) + } + + pub fn core_package(&self) -> &Path { + &self.core_package + } + + pub fn left_package(&self) -> &Path { + &self.left_package + } + + pub fn right_package(&self) -> &Path { + &self.right_package + } +} + +fn stage_archive(root: &Path, name: &str, bytes: &[u8]) -> Result { + let path = root.join(name); + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + .map_err(|source| OracleError::AdapterIo { + operation: "creating staged oracle archive", + source, + })?; + file.write_all(bytes) + .map_err(|source| OracleError::AdapterIo { + operation: "writing staged oracle archive", + source, + })?; + file.sync_all().map_err(|source| OracleError::AdapterIo { + operation: "syncing staged oracle archive", + source, + })?; + drop(file); + protect_staged_archive(&path)?; + Ok(path) +} + +#[cfg(unix)] +fn protect_staged_archive(path: &Path) -> Result<(), OracleError> { + use std::fs; + use std::os::unix::fs::PermissionsExt; + + let permissions = fs::Permissions::from_mode(0o400); + fs::set_permissions(path, permissions).map_err(|source| OracleError::AdapterIo { + operation: "protecting staged oracle archive", + source, + }) +} + +#[cfg(not(unix))] +fn protect_staged_archive(_path: &Path) -> Result<(), OracleError> { + Ok(()) +} + pub fn validate_hl7_oracle_adapter(adapter: &Path, java: Option<&Path>) -> Result<(), OracleError> { if !adapter.is_file() { return Err(OracleError::AdapterPath { diff --git a/crates/commandf-pkg/tests/oracle_process.rs b/crates/commandf-pkg/tests/oracle_process.rs index ac2611cf..68743194 100644 --- a/crates/commandf-pkg/tests/oracle_process.rs +++ b/crates/commandf-pkg/tests/oracle_process.rs @@ -5,7 +5,9 @@ use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; -use commandf_pkg::{run_hl7_oracle_adapter, Hl7OracleInvocation}; +use commandf_pkg::{ + run_hl7_oracle_adapter, Hl7OracleInvocation, Hl7OracleStagedArchives, +}; const GOOD_REPORT: &str = r#"{"schema":1,"oracle":{"project":"hapifhir/org.hl7.fhir.core","release":"6.10.2","source_commit":"d06577dbc5c62c74a2a8823fbc4830a3024d5b0b"},"left":{"url":"http://example.org/StructureDefinition/test","version":null,"id":"test","type":"Patient"},"right":{"url":"http://example.org/StructureDefinition/test","version":null,"id":"test","type":"Patient"},"states":{"metadata":"not_changed","definitions":"not_changed","content":"unknown","content_interpretation":"unknown"},"messages":[]}"#; @@ -72,6 +74,48 @@ fn executable_adapter_accepts_valid_pinned_json() { let _ = fs::remove_dir_all(root); } +#[test] +fn staged_archives_are_independent_of_original_source_after_boundary() { + let root = unique_temp_dir("staged-snapshot"); + fs::create_dir_all(&root).expect("create temp dir"); + let original = root.join("original-left.tgz"); + fs::write(&original, b"verified-generation").expect("write original archive"); + let verified_bytes = fs::read(&original).expect("read verified generation"); + let staged = Hl7OracleStagedArchives::new(b"core", &verified_bytes, b"right") + .expect("stage verified archives"); + + fs::write(&original, b"mutated-after-boundary").expect("mutate original cache generation"); + + let adapter = root.join("adapter.sh"); + write_executable( + &adapter, + &format!( + "left=''\nwhile [ \"$#\" -gt 0 ]; do\n case \"$1\" in\n --left-package) left=\"$2\"; shift 2 ;;\n *) shift ;;\n esac\ndone\ntest \"$(cat \"$left\")\" = 'verified-generation' || exit 9\nprintf '%s\\n' '{}'", + GOOD_REPORT + ), + ); + let invocation = Hl7OracleInvocation { + core_package: staged.core_package(), + left_package: staged.left_package(), + right_package: staged.right_package(), + left_url: "http://example.org/StructureDefinition/test", + left_version: None, + right_url: "http://example.org/StructureDefinition/test", + right_version: None, + }; + let report = run_hl7_oracle_adapter( + &adapter, + None, + &invocation, + Duration::from_secs(1), + ) + .expect("adapter must consume the staged verified snapshot"); + + assert_eq!(report.schema, 1); + assert_eq!(fs::read(&original).unwrap(), b"mutated-after-boundary"); + let _ = fs::remove_dir_all(root); +} + #[test] fn jar_adapter_requires_explicit_java_path() { let root = unique_temp_dir("java-required");