From ab368156f9daee1038dd13283863d545b2fee93e Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 2 Oct 2026 06:40:56 -0500 Subject: [PATCH] Avoid duplicate Darwin qualified executable materialization Co-authored-by: Paperclip --- .../runner-core/src/acpx_provider_backend.rs | 137 ++++- .../runner-core/src/process_supervisor.rs | 520 +++++++++++++++++- .../runner-core/src/qualified_launch.rs | 23 +- 3 files changed, 673 insertions(+), 7 deletions(-) diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs index 9e801699379..fc08dabd803 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs @@ -32,7 +32,7 @@ use crate::provider_bridge::{ use crate::provider_events::{ project_acpx_state_event, AcpxEventProjectionContext, NormalizedProviderEvent, }; -use crate::qualified_launch::verify_launch_artifact; +use crate::qualified_launch::{verify_executable_launch_artifact, verify_launch_artifact}; fn is_reserved_terminal_operation_id(operation_id: &str) -> bool { matches!(operation_id, "paperclip_finish" | "paperclip_block") @@ -382,7 +382,11 @@ impl AcpxProviderDescriptor { "ACPX runner launch profile repeats an artifact path", )); } - let snapshot = verify_launch_artifact(artifact, "ACPX")?; + let snapshot = if artifact.path == launch_profile.command { + verify_executable_launch_artifact(artifact, "ACPX")? + } else { + verify_launch_artifact(artifact, "ACPX")? + }; verified.insert(artifact.path.clone(), snapshot); } let command = verified @@ -3089,6 +3093,135 @@ mod tests { fs::remove_dir_all(directory).unwrap(); } + #[cfg(target_os = "macos")] + mod darwin_named_transport_cleanup { + use super::*; + + struct Fixture { + directory: PathBuf, + descriptor: AcpxProviderDescriptor, + profile: AcpxLaunchProfile, + } + + impl Fixture { + fn new(label: &str) -> Self { + let directory = temporary_directory(label); + let command = directory.join("node"); + let sidecar = directory.join("sidecar.cjs"); + write_artifact(&command, b"qualified node", true); + write_artifact(&sidecar, b"qualified sidecar", false); + let args = vec![sidecar.to_string_lossy().into_owned()]; + let profile = AcpxLaunchProfile { + authority_digest: format!("sha256:{}", "d".repeat(64)), + command: command.clone(), + args: args.clone(), + artifacts: vec![artifact(&command), artifact(&sidecar)], + }; + let mut value = descriptor("codex"); + value["sidecarCommand"] = json!(command); + value["sidecarArgs"] = json!(args); + Self { + directory, + descriptor: serde_json::from_value(value).unwrap(), + profile, + } + } + + fn named_images(&self) -> usize { + fs::read_dir(&self.directory) + .unwrap() + .map(|entry| entry.unwrap()) + .filter(|entry| { + entry + .file_name() + .to_string_lossy() + .starts_with(".paperclip-verified-executable-") + }) + .count() + } + + fn prove_transport_owns_image(&self) { + assert_eq!(self.named_images(), 0); + let transport = self + .descriptor + .verified_transport(Some(&self.profile)) + .unwrap(); + assert_eq!( + self.named_images(), + 1, + "verification must own one named Node image" + ); + drop(transport); + assert_eq!( + self.named_images(), + 0, + "dropping unstarted transport must retire its image" + ); + } + } + + impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.directory); + } + } + + // File verification only: these tests never start a transport/process. + #[test] + fn second_artifact_failure_retires_named_executable() { + let fixture = Fixture::new("darwin-second-artifact-cleanup"); + fixture.prove_transport_owns_image(); + // The command remains valid and first in the profile; the sidecar + // fails only after the command's named snapshot has been admitted. + write_artifact( + &fixture.profile.artifacts[1].path, + b"tampered sidecar", + false, + ); + let error = fixture + .descriptor + .verified_transport(Some(&fixture.profile)) + .err() + .unwrap(); + assert!(error + .to_string() + .contains("verified process artifact digest mismatch")); + assert_eq!(fixture.named_images(), 0); + assert_eq!( + fs::read(&fixture.profile.command).unwrap(), + b"qualified node" + ); + } + + #[test] + fn argv_construction_failure_retires_named_executable() { + let mut fixture = Fixture::new("darwin-argv-cleanup"); + fixture.prove_transport_owns_image(); + // Descriptor and profile agree, so this passes launch binding and + // artifact verification, then fails the absolute-argument mapping. + let unauthenticated = fixture + .directory + .join("unauthenticated.cjs") + .to_string_lossy() + .into_owned(); + fixture.profile.args.push(unauthenticated.clone()); + fixture.descriptor.sidecar_args.push(unauthenticated); + let error = fixture + .descriptor + .verified_transport(Some(&fixture.profile)) + .err() + .unwrap(); + assert!(error + .to_string() + .contains("does not authenticate an absolute argument")); + assert_eq!(fixture.named_images(), 0); + assert_eq!( + fs::read(&fixture.profile.command).unwrap(), + b"qualified node" + ); + } + } + #[cfg(unix)] #[test] fn rejects_symlinked_launch_artifacts() { diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/process_supervisor.rs b/packages/paperclip-runner/runner/crates/runner-core/src/process_supervisor.rs index af12df41e53..32532ad25fd 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/process_supervisor.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/process_supervisor.rs @@ -40,6 +40,8 @@ pub(crate) fn is_node_interpreter(path: &Path) -> bool { pub struct VerifiedProcessArtifact { display_path: PathBuf, file: Arc, + #[cfg(target_os = "macos")] + executable: Option>, } impl VerifiedProcessArtifact { @@ -65,8 +67,246 @@ impl VerifiedProcessArtifact { Ok(Self { display_path, file: Arc::new(file), + #[cfg(target_os = "macos")] + executable: None, }) } + + /// Executable snapshots need a named image on Darwin. Construct that image + /// once, beside the source runtime for loader-relative shared libraries. + pub fn snapshot_verified_executable( + display_path: PathBuf, + file: File, + expected_sha256: &str, + ) -> Result { + #[cfg(target_os = "macos")] + { + let mut file = file; + file.seek(SeekFrom::Start(0)) + .map_err(|error| snapshot_error(&display_path, error))?; + let executable = Arc::new(NamedExecutableSnapshot::create( + &display_path, + &mut file, + expected_sha256, + )?); + Ok(Self { + display_path, + file: executable.file.clone(), + executable: Some(executable), + }) + } + #[cfg(not(target_os = "macos"))] + Self::snapshot_verified(display_path, file, expected_sha256) + } +} + +#[cfg(target_os = "macos")] +#[derive(Debug)] +struct NamedExecutablePath { + parent: File, + path: PathBuf, + basename: std::ffi::OsString, + device: u64, + inode: u64, +} + +#[cfg(target_os = "macos")] +impl NamedExecutablePath { + fn open_image(&self) -> Result { + use rustix::fs::{openat, Mode, OFlags}; + let fd = openat( + &self.parent, + &self.basename, + OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC, + Mode::empty(), + ) + .map_err(|error| snapshot_error(&self.path, error))?; + Ok(File::from(fd)) + } + + fn validate(&self) -> Result { + let parent_path = self.path.parent().expect("named image has a parent"); + let visible_parent = + fs::symlink_metadata(parent_path).map_err(|error| snapshot_error(&self.path, error))?; + let held_parent = self + .parent + .metadata() + .map_err(|error| snapshot_error(&self.path, error))?; + if !visible_parent.is_dir() + || visible_parent.dev() != held_parent.dev() + || visible_parent.ino() != held_parent.ino() + || visible_parent.permissions().mode() & 0o022 != 0 + { + return Err(snapshot_error(&self.path, "executable parent changed")); + } + let image = self.open_image()?; + let metadata = image + .metadata() + .map_err(|error| snapshot_error(&self.path, error))?; + if !metadata.is_file() + || metadata.dev() != self.device + || metadata.ino() != self.inode + || metadata.permissions().mode() & 0o777 != 0o500 + { + return Err(snapshot_error(&self.path, "executable image changed")); + } + Ok(image) + } +} + +#[cfg(target_os = "macos")] +impl Drop for NamedExecutablePath { + fn drop(&mut self) { + // Resolve relative to the retained directory, never through a replaced + // parent pathname. A substituted image does not belong to this guard. + if let Ok(file) = self.open_image() { + if let Ok(metadata) = file.metadata() { + if metadata.is_file() + && metadata.dev() == self.device + && metadata.ino() == self.inode + { + let _ = rustix::fs::unlinkat( + &self.parent, + &self.basename, + rustix::fs::AtFlags::empty(), + ); + } + } + } + } +} + +#[cfg(target_os = "macos")] +#[derive(Debug)] +struct NamedExecutableSnapshot { + image: NamedExecutablePath, + file: Arc, + expected_sha256: String, + expected_len: u64, +} + +#[cfg(target_os = "macos")] +impl NamedExecutableSnapshot { + fn create( + display_path: &Path, + source: &mut File, + expected_sha256: &str, + ) -> Result { + use rustix::fs::{open, openat, Mode, OFlags}; + let parent_path = display_path + .parent() + .ok_or_else(|| snapshot_error(display_path, "executable has no parent"))?; + let parent = File::from( + open( + parent_path, + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + ) + .map_err(|error| snapshot_error(display_path, error))?, + ); + let parent_metadata = parent + .metadata() + .map_err(|error| snapshot_error(display_path, error))?; + if parent_metadata.permissions().mode() & 0o022 != 0 { + return Err(snapshot_error( + display_path, + "executable parent is group- or world-writable", + )); + } + let basename = std::ffi::OsString::from(format!( + ".paperclip-verified-executable-{}", + Uuid::new_v4().simple() + )); + let path = parent_path.join(&basename); + let mut writable = File::from( + openat( + &parent, + &basename, + OFlags::RDWR | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::RUSR | Mode::WUSR | Mode::XUSR, + ) + .map_err(|error| snapshot_error(display_path, error))?, + ); + // If fstat itself fails, ownership cannot be proven. Fail closed and + // leave the randomized entry rather than unlink a possibly substituted + // name without its inode identity. + let metadata = writable + .metadata() + .map_err(|error| snapshot_error(display_path, error))?; + let image = NamedExecutablePath { + parent, + path, + basename, + device: metadata.dev(), + inode: metadata.ino(), + }; + copy_verified(source, &mut writable, display_path, expected_sha256)?; + writable + .sync_all() + .map_err(|error| snapshot_error(display_path, error))?; + rustix::fs::fchmod(&writable, Mode::RUSR | Mode::XUSR) + .map_err(|error| snapshot_error(display_path, error))?; + let file = Arc::new(image.validate()?); + drop(writable); + let expected_len = file + .metadata() + .map_err(|error| snapshot_error(display_path, error))? + .len(); + Ok(Self { + image, + file, + expected_sha256: expected_sha256.to_owned(), + expected_len, + }) + } + + fn validate_before_spawn(&self) -> Result<(), LocalRunnerError> { + self.image.validate()?; + let before = self + .file + .metadata() + .map_err(|error| snapshot_error(&self.image.path, error))?; + if before.len() != self.expected_len { + return Err(snapshot_error( + &self.image.path, + "executable length changed", + )); + } + let mut digest = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + let mut offset = 0; + while offset < self.expected_len { + let remaining = (self.expected_len - offset).min(buffer.len() as u64) as usize; + let count = self + .file + .read_at(&mut buffer[..remaining], offset) + .map_err(|error| snapshot_error(&self.image.path, error))?; + if count == 0 { + break; + } + digest.update(&buffer[..count]); + offset += count as u64; + } + let after = self + .file + .metadata() + .map_err(|error| snapshot_error(&self.image.path, error))?; + if format!("sha256:{:x}", digest.finalize()) != self.expected_sha256 + || before.len() != after.len() + || offset != after.len() + || before.mtime() != after.mtime() + || before.mtime_nsec() != after.mtime_nsec() + || before.ctime() != after.ctime() + || before.ctime_nsec() != after.ctime_nsec() + { + return Err(snapshot_error( + &self.image.path, + "executable digest or metadata changed", + )); + } + self.image.validate()?; + Ok(()) + } } #[cfg(target_os = "linux")] @@ -232,11 +472,18 @@ impl VerifiedProcessLaunch { #[cfg(target_os = "linux")] inherited.push(program_fd); #[cfg(target_os = "macos")] - let program_snapshot = materialize_executable(&self.program)?; + let named_executable = self.program.executable.clone(); #[cfg(target_os = "macos")] - let program = program_snapshot.path.clone(); + let mut temporary_executables = Vec::new(); #[cfg(target_os = "macos")] - let mut temporary_executables = vec![program_snapshot]; + let program = if let Some(executable) = &named_executable { + executable.image.path.clone() + } else { + let snapshot = materialize_executable(&self.program)?; + let path = snapshot.path.clone(); + temporary_executables.push(snapshot); + path + }; let mut args = Vec::with_capacity(self.args.len()); for argument in &self.args { match argument { @@ -269,12 +516,18 @@ impl VerifiedProcessLaunch { } } } + #[cfg(target_os = "macos")] + if let Some(executable) = &named_executable { + executable.validate_before_spawn()?; + } Ok(InheritedCommand { program, args, _inherited: inherited, #[cfg(target_os = "macos")] temporary_executables, + #[cfg(target_os = "macos")] + named_executable, }) } } @@ -286,6 +539,8 @@ struct InheritedCommand { _inherited: Vec, #[cfg(target_os = "macos")] temporary_executables: Vec, + #[cfg(target_os = "macos")] + named_executable: Option>, } #[cfg(target_os = "macos")] @@ -641,6 +896,8 @@ pub struct SupervisedProcess { finished: bool, #[cfg(target_os = "macos")] _temporary_executables: Vec, + #[cfg(target_os = "macos")] + _named_executable: Option>, } impl SupervisedProcess { @@ -722,6 +979,7 @@ impl SupervisedProcess { if let Ok(process) = result.as_mut() { process._temporary_executables = std::mem::take(&mut inherited.temporary_executables); + process._named_executable = inherited.named_executable.take(); } drop(inherited); result @@ -824,6 +1082,8 @@ impl SupervisedProcess { finished: false, #[cfg(target_os = "macos")] _temporary_executables: Vec::new(), + #[cfg(target_os = "macos")] + _named_executable: None, }) } @@ -1098,6 +1358,260 @@ mod tests { assert!(!process.stdout_drained()); } + // These tests exercise file admission and ownership only. They never launch + // a native runner, shell, Node executable, provider, or child process. + #[cfg(target_os = "macos")] + mod darwin_named_snapshot { + use super::*; + use std::os::unix::fs::symlink; + + struct Fixture { + directory: PathBuf, + } + impl Fixture { + fn new() -> Self { + let directory = std::env::temp_dir().join(format!( + "paperclip-darwin-snapshot-{}", + Uuid::new_v4().simple() + )); + fs::create_dir(&directory).unwrap(); + fs::set_permissions(&directory, fs::Permissions::from_mode(0o700)).unwrap(); + Self { + directory: fs::canonicalize(directory).unwrap(), + } + } + fn artifact(&self) -> VerifiedProcessArtifact { + let path = self.directory.join("node"); + fs::write(&path, b"authenticated executable bytes").unwrap(); + VerifiedProcessArtifact::snapshot_verified_executable( + path.clone(), + File::open(path).unwrap(), + &format!( + "sha256:{:x}", + Sha256::digest(b"authenticated executable bytes") + ), + ) + .unwrap() + } + fn image_count(&self) -> usize { + fs::read_dir(&self.directory) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| { + entry + .file_name() + .to_string_lossy() + .starts_with(".paperclip-verified-executable-") + }) + .count() + } + } + impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.directory); + } + } + + #[test] + fn source_changes_and_closed_source_do_not_change_single_image() { + let fixture = Fixture::new(); + let artifact = fixture.artifact(); + let executable = artifact.executable.as_ref().unwrap(); + let source = fixture.directory.join("node"); + fs::write(&source, b"overwrite original inode").unwrap(); + fs::rename(&source, fixture.directory.join("retired-node")).unwrap(); + fs::write(&source, b"replacement source inode").unwrap(); + fs::remove_file(&source).unwrap(); + executable.validate_before_spawn().unwrap(); + assert_eq!( + executable.image.path.parent(), + Some(fixture.directory.as_path()) + ); + assert_eq!(fixture.image_count(), 1); + let launch = VerifiedProcessLaunch::new(artifact, vec![]); + let inherited = launch.inherited_command().unwrap(); + assert_eq!( + fixture.image_count(), + 1, + "admission must not materialize another image" + ); + assert_eq!( + fs::read(&inherited.program).unwrap(), + b"authenticated executable bytes" + ); + } + + #[test] + fn same_inode_mutation_is_rejected_even_after_restoring_readonly_mode() { + let fixture = Fixture::new(); + let artifact = fixture.artifact(); + let executable = artifact.executable.as_ref().unwrap(); + fs::set_permissions(&executable.image.path, fs::Permissions::from_mode(0o700)).unwrap(); + fs::write( + &executable.image.path, + vec![b'X'; executable.expected_len as usize], + ) + .unwrap(); + fs::set_permissions(&executable.image.path, fs::Permissions::from_mode(0o500)).unwrap(); + assert!(executable.validate_before_spawn().is_err()); + } + + #[test] + fn replacement_image_is_rejected_and_not_removed_by_drop() { + let fixture = Fixture::new(); + let artifact = fixture.artifact(); + let path = artifact.executable.as_ref().unwrap().image.path.clone(); + fs::rename(&path, fixture.directory.join("retired-image")).unwrap(); + fs::write(&path, b"replacement").unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o500)).unwrap(); + assert!(artifact + .executable + .as_ref() + .unwrap() + .validate_before_spawn() + .is_err()); + drop(artifact); + assert_eq!(fs::read(&path).unwrap(), b"replacement"); + } + + #[test] + fn symlink_image_is_rejected_and_its_target_and_link_survive_drop() { + let fixture = Fixture::new(); + let artifact = fixture.artifact(); + let path = artifact.executable.as_ref().unwrap().image.path.clone(); + fs::remove_file(&path).unwrap(); + let target = fixture.directory.join("unrelated"); + fs::write(&target, b"unrelated").unwrap(); + symlink(&target, &path).unwrap(); + assert!(artifact + .executable + .as_ref() + .unwrap() + .validate_before_spawn() + .is_err()); + drop(artifact); + assert!(fs::symlink_metadata(&path) + .unwrap() + .file_type() + .is_symlink()); + assert_eq!(fs::read(&target).unwrap(), b"unrelated"); + } + + #[test] + fn directory_image_is_rejected_and_survives_drop() { + let fixture = Fixture::new(); + let artifact = fixture.artifact(); + let executable = artifact.executable.as_ref().unwrap(); + let path = executable.image.path.clone(); + fs::remove_file(&path).unwrap(); + // rustix does not expose safe FIFO creation on Darwin. This tests + // nonregular-entry rejection; FIFO-specific calibration remains + // separate. Production opens retain NONBLOCK for FIFO substitution. + fs::create_dir(&path).unwrap(); + assert!(executable.validate_before_spawn().is_err()); + drop(artifact); + assert!(fs::symlink_metadata(&path).unwrap().is_dir()); + } + + #[test] + fn writable_mode_is_rejected_even_with_original_bytes() { + let fixture = Fixture::new(); + let artifact = fixture.artifact(); + let executable = artifact.executable.as_ref().unwrap(); + fs::set_permissions(&executable.image.path, fs::Permissions::from_mode(0o700)).unwrap(); + assert!(executable.validate_before_spawn().is_err()); + fs::set_permissions(&executable.image.path, fs::Permissions::from_mode(0o500)).unwrap(); + executable.validate_before_spawn().unwrap(); + } + + #[test] + fn replaced_parent_fails_admission_and_cleanup_uses_held_parent() { + let fixture = Fixture::new(); + let parent = fixture.directory.join("runtime"); + fs::create_dir(&parent).unwrap(); + let source = parent.join("node"); + fs::write(&source, b"original").unwrap(); + let artifact = VerifiedProcessArtifact::snapshot_verified_executable( + source.clone(), + File::open(&source).unwrap(), + &format!("sha256:{:x}", Sha256::digest(b"original")), + ) + .unwrap(); + let path = artifact.executable.as_ref().unwrap().image.path.clone(); + let retired = fixture.directory.join("retired-runtime"); + fs::rename(&parent, &retired).unwrap(); + fs::create_dir(&parent).unwrap(); + fs::write(&path, b"replacement parent image").unwrap(); + assert!(artifact + .executable + .as_ref() + .unwrap() + .validate_before_spawn() + .is_err()); + drop(artifact); + assert_eq!(fs::read(&path).unwrap(), b"replacement parent image"); + assert!(!retired.join(path.file_name().unwrap()).exists()); + } + + #[test] + fn launch_clones_and_inherited_owner_keep_image_until_last_drop() { + let fixture = Fixture::new(); + let launch = VerifiedProcessLaunch::new(fixture.artifact(), vec![]); + let clone = launch.clone(); + let mut inherited = launch.inherited_command().unwrap(); + let path = inherited.program.clone(); + // This is the same ownership transfer performed after spawn succeeds. + let process_owner = inherited.named_executable.take(); + drop(inherited); + drop(launch); + assert!(path.exists()); + drop(clone); + assert!( + path.exists(), + "descendant runtime pathname must remain live" + ); + process_owner + .as_ref() + .unwrap() + .validate_before_spawn() + .unwrap(); + drop(process_owner); + assert!(!path.exists()); + } + + #[test] + fn digest_failure_and_late_abort_remove_owned_image() { + let fixture = Fixture::new(); + let source = fixture.directory.join("node"); + fs::write(&source, b"original").unwrap(); + assert!(VerifiedProcessArtifact::snapshot_verified_executable( + source.clone(), + File::open(source).unwrap(), + "sha256:wrong" + ) + .is_err()); + assert_eq!(fixture.image_count(), 0); + let launch = VerifiedProcessLaunch::new(fixture.artifact(), vec![]); + let inherited = launch.inherited_command().unwrap(); + let path = inherited.program.clone(); + drop(launch); + assert!(path.exists()); + drop(inherited); // Includes the spawn-error/late-abort ownership path. + assert!(!path.exists()); + } + + #[test] + fn commonjs_snapshots_stay_anonymous() { + let fixture = Fixture::new(); + let script = verified_artifact( + &fixture.directory.join("sidecar.cjs"), + b"module.exports = {};\n", + ); + assert!(script.executable.is_none()); + assert_eq!(script.file.metadata().unwrap().nlink(), 0); + } + } + fn verified_artifact(path: &Path, bytes: &[u8]) -> VerifiedProcessArtifact { fs::write(path, bytes).unwrap(); let digest = format!("sha256:{:x}", Sha256::digest(bytes)); diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/qualified_launch.rs b/packages/paperclip-runner/runner/crates/runner-core/src/qualified_launch.rs index c7f72d3152b..570080ff877 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/qualified_launch.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/qualified_launch.rs @@ -9,6 +9,21 @@ use crate::process_supervisor::VerifiedProcessArtifact; pub fn verify_launch_artifact( artifact: &QualifiedLaunchArtifact, label: &str, +) -> Result { + verify_launch_artifact_with_role(artifact, label, false) +} + +pub fn verify_executable_launch_artifact( + artifact: &QualifiedLaunchArtifact, + label: &str, +) -> Result { + verify_launch_artifact_with_role(artifact, label, true) +} + +fn verify_launch_artifact_with_role( + artifact: &QualifiedLaunchArtifact, + label: &str, + executable: bool, ) -> Result { let source_metadata = fs::symlink_metadata(&artifact.path).map_err(|error| { DurableRunnerError::invalid(format!("failed to inspect qualified {label}: {error}")) @@ -49,8 +64,12 @@ pub fn verify_launch_artifact( "qualified {label} changed while it was opened" ))); } - VerifiedProcessArtifact::snapshot_verified(canonical, file, &artifact.sha256) - .map_err(|error| DurableRunnerError::invalid(error.to_string())) + let snapshot = if executable { + VerifiedProcessArtifact::snapshot_verified_executable(canonical, file, &artifact.sha256) + } else { + VerifiedProcessArtifact::snapshot_verified(canonical, file, &artifact.sha256) + }; + snapshot.map_err(|error| DurableRunnerError::invalid(error.to_string())) } #[cfg(unix)]