use std::{
collections::{BTreeMap, BTreeSet},
fs,
io::{Read, Write},
path::{Component, Path, PathBuf},
};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::models::{
DomainError, REPRO_ENV_SCHEMA_V1, REPRO_LOCK_SCHEMA_V1, REPRO_MANIFEST_SCHEMA_V1,
REPRO_PROVENANCE_SCHEMA_V1,
};
pub const CAPTURE_REPORT_SCHEMA_V1: &str = "ee.repro.capture.v1";
pub const REPLAY_REPORT_SCHEMA_V1: &str = "ee.repro.replay.v1";
pub const MINIMIZE_REPORT_SCHEMA_V1: &str = "ee.repro.minimize.v1";
const REQUIRED_REPRO_PACK_FILES: &[&str] =
&["env.json", "manifest.json", "repro.lock", "provenance.json"];
#[derive(Clone, Debug)]
pub struct CaptureOptions {
pub source: PathBuf,
pub output_dir: PathBuf,
pub name: Option<String>,
pub version: String,
pub description: Option<String>,
pub claim_id: Option<String>,
pub demo_id: Option<String>,
pub dry_run: bool,
pub include_env: bool,
}
impl Default for CaptureOptions {
fn default() -> Self {
Self {
source: PathBuf::from("."),
output_dir: PathBuf::from("."),
name: None,
version: "1.0.0".to_owned(),
description: None,
claim_id: None,
demo_id: None,
dry_run: false,
include_env: true,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CaptureReport {
pub schema: String,
pub pack_path: PathBuf,
pub pack_name: String,
pub pack_version: String,
pub artifacts_captured: usize,
pub total_size_bytes: u64,
pub pack_hash: Option<String>,
pub dry_run: bool,
pub files: Vec<CapturedFile>,
}
impl CaptureReport {
#[must_use]
pub fn new(pack_path: PathBuf, pack_name: String, pack_version: String) -> Self {
Self {
schema: CAPTURE_REPORT_SCHEMA_V1.to_owned(),
pack_path,
pack_name,
pack_version,
artifacts_captured: 0,
total_size_bytes: 0,
pack_hash: None,
dry_run: false,
files: Vec::new(),
}
}
pub fn add_file(&mut self, file: CapturedFile) {
self.total_size_bytes += file.size_bytes;
self.artifacts_captured += 1;
self.files.push(file);
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CapturedFile {
pub path: String,
pub hash: String,
pub size_bytes: u64,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct ManifestArtifactExpectation {
hash: String,
size_bytes: Option<u64>,
}
#[derive(Clone, Debug)]
pub struct ReplayOptions {
pub pack_path: PathBuf,
pub work_dir: PathBuf,
pub verify_hashes: bool,
pub check_env: bool,
pub dry_run: bool,
}
impl Default for ReplayOptions {
fn default() -> Self {
Self {
pack_path: PathBuf::from("."),
work_dir: PathBuf::from("."),
verify_hashes: true,
check_env: true,
dry_run: false,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ReplayReport {
pub schema: String,
pub pack_path: PathBuf,
pub pack_name: String,
pub pack_version: String,
pub status: ReplayStatus,
pub artifacts_verified: usize,
pub artifacts_failed: usize,
pub env_compatible: bool,
pub dry_run: bool,
pub verification_results: Vec<VerificationResult>,
pub warnings: Vec<String>,
}
impl ReplayReport {
#[must_use]
pub fn new(pack_path: PathBuf, pack_name: String, pack_version: String) -> Self {
Self {
schema: REPLAY_REPORT_SCHEMA_V1.to_owned(),
pack_path,
pack_name,
pack_version,
status: ReplayStatus::Pending,
artifacts_verified: 0,
artifacts_failed: 0,
env_compatible: true,
dry_run: false,
verification_results: Vec::new(),
warnings: Vec::new(),
}
}
pub fn add_verification(&mut self, result: VerificationResult) {
if result.passed {
self.artifacts_verified += 1;
} else {
self.artifacts_failed += 1;
}
self.verification_results.push(result);
}
pub fn add_warning(&mut self, warning: impl Into<String>) {
self.warnings.push(warning.into());
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ReplayStatus {
Pending,
Verified,
Failed,
EnvMismatch,
PackNotFound,
}
impl ReplayStatus {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Pending => "pending",
Self::Verified => "verified",
Self::Failed => "failed",
Self::EnvMismatch => "env_mismatch",
Self::PackNotFound => "pack_not_found",
}
}
#[must_use]
pub const fn is_success(self) -> bool {
matches!(self, Self::Verified)
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct VerificationResult {
pub path: String,
pub expected_hash: String,
pub actual_hash: Option<String>,
pub passed: bool,
pub error: Option<String>,
}
#[derive(Clone, Debug)]
pub struct MinimizeOptions {
pub pack_path: PathBuf,
pub output_dir: PathBuf,
pub remove_optional: bool,
pub remove_binaries: bool,
pub max_file_size: Option<u64>,
pub dry_run: bool,
}
impl Default for MinimizeOptions {
fn default() -> Self {
Self {
pack_path: PathBuf::from("."),
output_dir: PathBuf::from("."),
remove_optional: true,
remove_binaries: true,
max_file_size: None,
dry_run: false,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct MinimizeReport {
pub schema: String,
pub original_path: PathBuf,
pub minimized_path: PathBuf,
pub original_size_bytes: u64,
pub minimized_size_bytes: u64,
pub artifacts_kept: usize,
pub artifacts_removed: usize,
pub dry_run: bool,
pub removed_files: Vec<RemovedFile>,
}
impl MinimizeReport {
#[must_use]
pub fn new(original_path: PathBuf, minimized_path: PathBuf) -> Self {
Self {
schema: MINIMIZE_REPORT_SCHEMA_V1.to_owned(),
original_path,
minimized_path,
original_size_bytes: 0,
minimized_size_bytes: 0,
artifacts_kept: 0,
artifacts_removed: 0,
dry_run: false,
removed_files: Vec::new(),
}
}
pub fn add_removed(&mut self, file: RemovedFile) {
self.artifacts_removed += 1;
self.original_size_bytes += file.size_bytes;
self.removed_files.push(file);
}
pub fn add_kept(&mut self, size_bytes: u64) {
self.artifacts_kept += 1;
self.original_size_bytes += size_bytes;
self.minimized_size_bytes += size_bytes;
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RemovedFile {
pub path: String,
pub size_bytes: u64,
pub reason: String,
}
pub fn capture_pack(options: &CaptureOptions) -> Result<CaptureReport, DomainError> {
let pack_name = options.name.clone().unwrap_or_else(|| {
options
.source
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("repro-pack")
.to_owned()
});
let pack_path = resolve_pack_output_path(&options.output_dir, &pack_name)?;
let mut report = CaptureReport::new(
pack_path.clone(),
pack_name.clone(),
options.version.clone(),
);
report.dry_run = options.dry_run;
validate_existing_repro_path(
&options.source,
"source",
"Provide a valid source directory or fixture",
)?;
if !options.dry_run {
reject_existing_symlink_components(&pack_path)
.map_err(repro_symlink_refused_storage_error)?;
if let Err(e) = fs::create_dir_all(&pack_path) {
return Err(DomainError::Storage {
message: format!("Failed to create pack directory: {e}"),
repair: Some("Check directory permissions".to_string()),
});
}
validate_pack_root(&pack_path)?;
let now = Utc::now().to_rfc3339();
let env_json = create_env_json(options.include_env, &now);
let lock_json = create_lock_json(&now);
let prov_json = create_provenance_json(&now);
let env_file = captured_file_for_content("env.json", env_json.as_bytes());
let lock_file = captured_file_for_content("repro.lock", lock_json.as_bytes());
let provenance_file = captured_file_for_content("provenance.json", prov_json.as_bytes());
let payload_files = vec![env_file.clone(), lock_file.clone(), provenance_file.clone()];
let manifest_json = create_manifest_json(
&pack_name,
&options.version,
&now,
&payload_files,
options.description.as_deref(),
options.claim_id.as_deref(),
options.demo_id.as_deref(),
);
report.pack_hash = Some(format!("blake3:{}", hash_content(manifest_json.as_bytes())));
if let Err(e) = write_pack_file_no_symlinks(&pack_path, "env.json", env_json.as_bytes()) {
return Err(DomainError::Storage {
message: format!("Failed to write env.json: {e}"),
repair: None,
});
}
report.add_file(env_file);
if let Err(e) =
write_pack_file_no_symlinks(&pack_path, "manifest.json", manifest_json.as_bytes())
{
return Err(DomainError::Storage {
message: format!("Failed to write manifest.json: {e}"),
repair: None,
});
}
report.add_file(CapturedFile {
path: "manifest.json".to_string(),
hash: format!("blake3:{}", hash_content(manifest_json.as_bytes())),
size_bytes: len_to_u64(manifest_json.len()),
});
if let Err(e) = write_pack_file_no_symlinks(&pack_path, "repro.lock", lock_json.as_bytes())
{
return Err(DomainError::Storage {
message: format!("Failed to write repro.lock: {e}"),
repair: None,
});
}
report.add_file(lock_file);
if let Err(e) =
write_pack_file_no_symlinks(&pack_path, "provenance.json", prov_json.as_bytes())
{
return Err(DomainError::Storage {
message: format!("Failed to write provenance.json: {e}"),
repair: None,
});
}
report.add_file(provenance_file);
} else {
report.add_file(CapturedFile {
path: "env.json".to_string(),
hash: "blake3:dry_run".to_string(),
size_bytes: 0,
});
report.add_file(CapturedFile {
path: "manifest.json".to_string(),
hash: "blake3:dry_run".to_string(),
size_bytes: 0,
});
report.add_file(CapturedFile {
path: "repro.lock".to_string(),
hash: "blake3:dry_run".to_string(),
size_bytes: 0,
});
report.add_file(CapturedFile {
path: "provenance.json".to_string(),
hash: "blake3:dry_run".to_string(),
size_bytes: 0,
});
}
Ok(report)
}
pub fn replay_pack(options: &ReplayOptions) -> Result<ReplayReport, DomainError> {
validate_pack_root(&options.pack_path)?;
let manifest_bytes =
read_pack_file_no_symlinks(&options.pack_path, "manifest.json").map_err(|e| {
DomainError::Storage {
message: format!("Failed to read manifest.json: {e}"),
repair: Some("Ensure the pack contains a valid manifest.json".to_string()),
}
})?;
let manifest_json = String::from_utf8(manifest_bytes).map_err(|e| DomainError::Import {
message: format!("manifest.json is not valid UTF-8: {e}"),
repair: None,
})?;
let manifest: serde_json::Value =
serde_json::from_str(&manifest_json).map_err(|e| DomainError::Import {
message: format!("Invalid manifest.json: {e}"),
repair: None,
})?;
let expected_artifacts = manifest_artifact_expectations(&manifest)?;
let pack_name = manifest["name"].as_str().unwrap_or("unknown").to_string();
let pack_version = manifest["version"].as_str().unwrap_or("0.0.0").to_string();
let mut report = ReplayReport::new(options.pack_path.clone(), pack_name, pack_version);
report.dry_run = options.dry_run;
if options.verify_hashes && !options.dry_run {
for required_file in required_pack_member_paths(&expected_artifacts) {
let result =
verify_required_pack_file(&options.pack_path, &required_file, &expected_artifacts);
report.add_verification(result);
}
} else if options.dry_run {
report.add_verification(VerificationResult {
path: "manifest.json".to_string(),
expected_hash: "dry_run".to_string(),
actual_hash: Some("dry_run".to_string()),
passed: true,
error: None,
});
}
if options.check_env && !options.dry_run {
if let Ok(env_bytes) = read_pack_file_no_symlinks(&options.pack_path, "env.json") {
if let Ok(env_json_str) = String::from_utf8(env_bytes) {
if let Ok(pack_env) = serde_json::from_str::<serde_json::Value>(&env_json_str) {
let pack_os = pack_env["os"].as_str().unwrap_or("");
let pack_arch = pack_env["arch"].as_str().unwrap_or("");
let current_os = std::env::consts::OS;
let current_arch = std::env::consts::ARCH;
if pack_os != current_os || pack_arch != current_arch {
report.env_compatible = false;
report.add_warning(format!(
"Environment mismatch: pack is {}/{}, current is {}/{}",
pack_os, pack_arch, current_os, current_arch
));
}
}
}
}
}
report.status = if report.artifacts_failed > 0 {
ReplayStatus::Failed
} else if !report.env_compatible {
ReplayStatus::EnvMismatch
} else {
ReplayStatus::Verified
};
Ok(report)
}
pub fn minimize_pack(options: &MinimizeOptions) -> Result<MinimizeReport, DomainError> {
validate_pack_root(&options.pack_path)?;
let manifest_bytes =
read_pack_file_no_symlinks(&options.pack_path, "manifest.json").map_err(|e| {
DomainError::Storage {
message: format!("Failed to read manifest.json: {e}"),
repair: Some("Ensure the pack contains a valid manifest.json".to_string()),
}
})?;
let manifest_json = String::from_utf8(manifest_bytes).map_err(|e| DomainError::Import {
message: format!("manifest.json is not valid UTF-8: {e}"),
repair: None,
})?;
let manifest: serde_json::Value =
serde_json::from_str(&manifest_json).map_err(|e| DomainError::Import {
message: format!("Invalid manifest.json: {e}"),
repair: None,
})?;
let expected_artifacts = manifest_artifact_expectations(&manifest)?;
let required_members = required_pack_member_paths(&expected_artifacts);
let pack_members = collect_pack_member_files(&options.pack_path)?;
for member_path in &required_members {
if pack_members.contains_key(member_path) {
continue;
}
let Ok(resolved) = resolve_pack_file_path_no_symlinks(&options.pack_path, member_path)
else {
continue;
};
match fs::symlink_metadata(&resolved) {
Ok(metadata) if !metadata.file_type().is_file() => {
return Err(DomainError::Storage {
message: format!(
"pack_artifact_metadata_unavailable: {}: not a regular file",
resolved.display()
),
repair: Some("Use regular repro pack member files".to_string()),
});
}
_ => {}
}
}
let mut report = MinimizeReport::new(options.pack_path.clone(), options.output_dir.clone());
report.dry_run = options.dry_run;
if !options.dry_run {
prepare_minimized_output_dir(&options.pack_path, &options.output_dir)?;
}
for (member_path, metadata) in pack_members {
if required_members.contains(&member_path) {
report.add_kept(metadata.len());
if !options.dry_run {
copy_pack_member_no_symlinks(
&options.pack_path,
&options.output_dir,
&member_path,
)?;
}
continue;
}
if let Some(reason) = optional_pack_member_removal_reason(
options,
&options.pack_path,
&member_path,
&metadata,
)? {
report.add_removed(RemovedFile {
path: member_path,
size_bytes: metadata.len(),
reason,
});
} else {
report.add_kept(metadata.len());
if !options.dry_run {
copy_pack_member_no_symlinks(
&options.pack_path,
&options.output_dir,
&member_path,
)?;
}
}
}
Ok(report)
}
fn collect_pack_member_files(
pack_path: &Path,
) -> Result<BTreeMap<String, fs::Metadata>, DomainError> {
let mut files = BTreeMap::new();
collect_pack_member_files_inner(pack_path, Path::new(""), &mut files)?;
Ok(files)
}
fn collect_pack_member_files_inner(
pack_path: &Path,
relative_dir: &Path,
files: &mut BTreeMap<String, fs::Metadata>,
) -> Result<(), DomainError> {
let dir_path = if relative_dir.as_os_str().is_empty() {
pack_path.to_path_buf()
} else {
let relative_dir = pack_member_path_to_string(relative_dir)?;
resolve_pack_file_path_no_symlinks(pack_path, &relative_dir).map_err(|error| {
DomainError::Storage {
message: error,
repair: Some("Use real repro pack member paths without symbolic links".to_string()),
}
})?
};
let entries = fs::read_dir(&dir_path).map_err(|error| DomainError::Storage {
message: format!(
"pack_artifact_metadata_unavailable: {}: {}",
dir_path.display(),
error
),
repair: None,
})?;
for entry in entries {
let entry = entry.map_err(|error| DomainError::Storage {
message: format!(
"pack_artifact_metadata_unavailable: {}: {}",
dir_path.display(),
error
),
repair: None,
})?;
let child_relative = if relative_dir.as_os_str().is_empty() {
PathBuf::from(entry.file_name())
} else {
relative_dir.join(entry.file_name())
};
let child_relative_path = pack_member_path_to_string(&child_relative)?;
let child_path = entry.path();
let metadata = fs::symlink_metadata(&child_path).map_err(|error| DomainError::Storage {
message: format!(
"pack_artifact_metadata_unavailable: {}: {}",
child_path.display(),
error
),
repair: None,
})?;
if metadata.file_type().is_symlink() {
return Err(DomainError::Storage {
message: format!("pack_symlink_refused: {}", child_path.display()),
repair: Some(
"Use regular repro pack member files without symbolic links".to_string(),
),
});
}
if metadata.file_type().is_file() {
files.insert(child_relative_path, metadata);
} else if metadata.file_type().is_dir() {
collect_pack_member_files_inner(pack_path, &child_relative, files)?;
} else {
return Err(DomainError::Storage {
message: format!(
"pack_artifact_metadata_unavailable: {}: not a regular file",
child_path.display()
),
repair: Some("Use regular repro pack member files".to_string()),
});
}
}
Ok(())
}
fn pack_member_path_to_string(path: &Path) -> Result<String, DomainError> {
let mut segments = Vec::new();
for component in path.components() {
let Component::Normal(segment) = component else {
return Err(DomainError::Storage {
message: format!("invalid pack member path: {}", path.display()),
repair: Some("Use relative repro pack member paths".to_string()),
});
};
let Some(segment) = segment.to_str() else {
return Err(DomainError::Storage {
message: format!("invalid non-UTF-8 pack member path: {}", path.display()),
repair: Some("Use UTF-8 repro pack member paths".to_string()),
});
};
segments.push(segment.to_string());
}
if segments.is_empty() {
return Err(DomainError::Storage {
message: "invalid empty pack member path".to_string(),
repair: Some("Use relative repro pack member paths".to_string()),
});
}
let member_path = segments.join("/");
if !is_safe_pack_member_path(&member_path) {
return Err(DomainError::Storage {
message: format!("invalid pack member path: {member_path}"),
repair: Some("Use relative repro pack member paths".to_string()),
});
}
Ok(member_path)
}
fn prepare_minimized_output_dir(pack_path: &Path, output_dir: &Path) -> Result<(), DomainError> {
if output_dir == pack_path || output_dir.starts_with(pack_path) {
return Err(DomainError::Storage {
message: format!(
"Minimized pack output directory must be outside the source pack: {}",
output_dir.display()
),
repair: Some(
"Choose an empty output directory outside the source repro pack".to_string(),
),
});
}
reject_existing_symlink_components(output_dir).map_err(repro_symlink_refused_storage_error)?;
match fs::symlink_metadata(output_dir) {
Ok(metadata) if !metadata.file_type().is_dir() => Err(DomainError::Storage {
message: format!(
"Minimized pack output path is not a directory: {}",
output_dir.display()
),
repair: Some("Choose an empty output directory".to_string()),
}),
Ok(_) => {
let mut entries = fs::read_dir(output_dir).map_err(|error| DomainError::Storage {
message: format!(
"Failed to inspect minimized pack output directory: {}: {}",
output_dir.display(),
error
),
repair: Some("Choose an empty output directory".to_string()),
})?;
if entries
.next()
.transpose()
.map_err(|error| DomainError::Storage {
message: format!(
"Failed to inspect minimized pack output directory: {}: {}",
output_dir.display(),
error
),
repair: Some("Choose an empty output directory".to_string()),
})?
.is_some()
{
return Err(DomainError::Storage {
message: format!(
"Minimized pack output directory is not empty: {}",
output_dir.display()
),
repair: Some("Choose an empty output directory".to_string()),
});
}
Ok(())
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
fs::create_dir_all(output_dir).map_err(|error| DomainError::Storage {
message: format!(
"Failed to create minimized pack output directory: {}: {}",
output_dir.display(),
error
),
repair: Some("Check directory permissions".to_string()),
})?;
validate_pack_root(output_dir)
}
Err(error) => Err(DomainError::Storage {
message: format!(
"Failed to inspect minimized pack output directory: {}: {}",
output_dir.display(),
error
),
repair: Some("Choose an empty output directory".to_string()),
}),
}
}
fn optional_pack_member_removal_reason(
options: &MinimizeOptions,
pack_path: &Path,
member_path: &str,
metadata: &fs::Metadata,
) -> Result<Option<String>, DomainError> {
if options.remove_optional {
return Ok(Some("optional artifact".to_string()));
}
if let Some(max_file_size) = options.max_file_size {
if metadata.len() > max_file_size {
return Ok(Some(format!("exceeds max_file_size {max_file_size} bytes")));
}
}
if options.remove_binaries && pack_file_looks_binary(pack_path, member_path)? {
return Ok(Some("binary artifact".to_string()));
}
Ok(None)
}
fn pack_file_looks_binary(pack_path: &Path, relative_path: &str) -> Result<bool, DomainError> {
let target_path =
resolve_pack_file_path_no_symlinks(pack_path, relative_path).map_err(|error| {
DomainError::Storage {
message: error,
repair: Some("Use real repro pack member paths without symbolic links".to_string()),
}
})?;
let mut file = open_pack_file_for_read_no_symlinks(&target_path).map_err(|error| {
DomainError::Storage {
message: error,
repair: Some("Use regular repro pack member files without symbolic links".to_string()),
}
})?;
let mut sample = [0_u8; 8192];
let bytes_read = file
.read(&mut sample)
.map_err(|error| DomainError::Storage {
message: format!(
"pack_artifact_unavailable: {}: {}",
target_path.display(),
error
),
repair: None,
})?;
Ok(sample[..bytes_read].contains(&0))
}
fn copy_pack_member_no_symlinks(
source_pack_path: &Path,
output_pack_path: &Path,
relative_path: &str,
) -> Result<(), DomainError> {
let content = read_pack_file_no_symlinks(source_pack_path, relative_path).map_err(|error| {
DomainError::Storage {
message: error,
repair: Some("Use regular repro pack member files without symbolic links".to_string()),
}
})?;
create_pack_member_parent_dirs_no_symlinks(output_pack_path, relative_path).map_err(
|error| DomainError::Storage {
message: error,
repair: Some("Use an empty real output directory for the minimized pack".to_string()),
},
)?;
write_pack_file_no_symlinks(output_pack_path, relative_path, &content).map_err(|error| {
DomainError::Storage {
message: error,
repair: Some("Use an empty real output directory for the minimized pack".to_string()),
}
})
}
fn create_pack_member_parent_dirs_no_symlinks(
pack_path: &Path,
relative_path: &str,
) -> Result<(), String> {
reject_pack_symlink_component(pack_path)?;
let mut parent_path = pack_path.to_path_buf();
let mut components = Path::new(relative_path).components().peekable();
while let Some(component) = components.next() {
let Component::Normal(segment) = component else {
return Err(format!("invalid pack member path: {relative_path}"));
};
if components.peek().is_none() {
break;
}
parent_path.push(segment);
match fs::symlink_metadata(&parent_path) {
Ok(metadata) if metadata.file_type().is_symlink() => {
return Err(format!("pack_symlink_refused: {}", parent_path.display()));
}
Ok(metadata) if metadata.file_type().is_dir() => {}
Ok(_) => {
return Err(format!(
"pack_artifact_write_failed: {}: not a directory",
parent_path.display()
));
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
fs::create_dir(&parent_path).map_err(|error| {
format!(
"pack_artifact_write_failed: {}: {}",
parent_path.display(),
error
)
})?;
}
Err(error) => {
return Err(format!(
"pack_artifact_write_failed: {}: {}",
parent_path.display(),
error
));
}
}
}
Ok(())
}
fn resolve_pack_output_path(output_dir: &Path, pack_name: &str) -> Result<PathBuf, DomainError> {
if !is_single_component_pack_name(pack_name) {
return Err(DomainError::Usage {
message: format!("Invalid repro pack name `{pack_name}`."),
repair: Some(
"Use a simple directory name without path separators, roots, or `..` components."
.to_string(),
),
});
}
Ok(output_dir.join(pack_name))
}
fn is_single_component_pack_name(pack_name: &str) -> bool {
if pack_name.trim().is_empty() {
return false;
}
let mut components = Path::new(pack_name).components();
matches!(components.next(), Some(Component::Normal(_))) && components.next().is_none()
}
fn captured_file_for_content(path: &str, content: &[u8]) -> CapturedFile {
CapturedFile {
path: path.to_string(),
hash: format!("blake3:{}", hash_content(content)),
size_bytes: len_to_u64(content.len()),
}
}
fn len_to_u64(len: usize) -> u64 {
u64::try_from(len).unwrap_or(u64::MAX)
}
fn validate_existing_repro_path(
path: &Path,
resource: &str,
repair: &str,
) -> Result<(), DomainError> {
reject_existing_symlink_components(path).map_err(repro_symlink_refused_storage_error)?;
fs::symlink_metadata(path).map_err(|_| DomainError::NotFound {
resource: resource.to_string(),
id: path.display().to_string(),
repair: Some(repair.to_string()),
})?;
Ok(())
}
fn reject_existing_symlink_components(path: &Path) -> Result<(), String> {
match crate::core::path_safety::first_existing_symlink_component(path) {
Ok(Some(component)) => Err(format!(
"repro_path_symlink_refused: {}",
component.display()
)),
Ok(None) => Ok(()),
Err(error) => Err(format!(
"repro_path_unavailable: {}: {error}",
path.display()
)),
}
}
fn repro_symlink_refused_storage_error(error: String) -> DomainError {
DomainError::Storage {
message: error,
repair: Some("Use real repro pack paths without symbolic links".to_string()),
}
}
fn validate_pack_root(pack_path: &Path) -> Result<(), DomainError> {
reject_existing_symlink_components(pack_path).map_err(repro_symlink_refused_storage_error)?;
let metadata = fs::symlink_metadata(pack_path).map_err(|_| DomainError::NotFound {
resource: "pack".to_string(),
id: pack_path.display().to_string(),
repair: Some("Provide a valid repro pack path".to_string()),
})?;
if metadata.file_type().is_symlink() {
return Err(DomainError::Storage {
message: format!(
"Repro pack path traverses a symbolic link: {}",
pack_path.display()
),
repair: Some("Use the real repro pack directory path".to_string()),
});
}
if !metadata.is_dir() {
return Err(DomainError::Storage {
message: format!(
"Repro pack path is not a directory: {}",
pack_path.display()
),
repair: Some("Provide a repro pack directory".to_string()),
});
}
Ok(())
}
fn manifest_artifact_expectations(
manifest: &serde_json::Value,
) -> Result<BTreeMap<String, ManifestArtifactExpectation>, DomainError> {
let artifacts = manifest
.get("artifacts")
.and_then(serde_json::Value::as_array)
.ok_or_else(|| DomainError::Import {
message: "manifest.json must contain an artifacts array".to_string(),
repair: None,
})?;
let mut expected = BTreeMap::new();
for (index, artifact) in artifacts.iter().enumerate() {
let path = artifact
.get("path")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| DomainError::Import {
message: format!("manifest artifact at index {index} is missing path"),
repair: None,
})?;
if !is_safe_pack_member_path(path) {
return Err(DomainError::Import {
message: format!("manifest artifact path is invalid: {path}"),
repair: None,
});
}
if artifact
.get("required")
.and_then(serde_json::Value::as_bool)
== Some(false)
{
continue;
}
let hash = artifact
.get("hash")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| DomainError::Import {
message: format!("manifest artifact `{path}` is missing hash"),
repair: None,
})?
.to_string();
let size_bytes = artifact
.get("size_bytes")
.or_else(|| artifact.get("sizeBytes"))
.or_else(|| artifact.get("bytes"))
.and_then(serde_json::Value::as_u64);
expected.insert(
path.to_string(),
ManifestArtifactExpectation { hash, size_bytes },
);
}
Ok(expected)
}
fn required_pack_member_paths(
expected_artifacts: &BTreeMap<String, ManifestArtifactExpectation>,
) -> BTreeSet<String> {
let mut paths = REQUIRED_REPRO_PACK_FILES
.iter()
.map(|path| (*path).to_string())
.collect::<BTreeSet<_>>();
paths.extend(expected_artifacts.keys().cloned());
paths
}
fn verify_required_pack_file(
pack_path: &Path,
relative_path: &str,
expected_artifacts: &BTreeMap<String, ManifestArtifactExpectation>,
) -> VerificationResult {
match read_pack_file_no_symlinks(pack_path, relative_path) {
Ok(content) if relative_path == "manifest.json" => {
let actual_hash = format!("blake3:{}", hash_content(&content));
VerificationResult {
path: relative_path.to_string(),
expected_hash: "manifest:parsed".to_string(),
actual_hash: Some(actual_hash),
passed: true,
error: None,
}
}
Ok(content) => {
let actual_digest = hash_content(&content);
let actual_hash = format!("blake3:{actual_digest}");
let Some(expected) = expected_artifacts.get(relative_path) else {
return VerificationResult {
path: relative_path.to_string(),
expected_hash: String::new(),
actual_hash: Some(actual_hash),
passed: false,
error: Some("manifest is missing required artifact hash".to_string()),
};
};
let hash_matches = expected.hash.eq_ignore_ascii_case(&actual_hash)
|| expected.hash.eq_ignore_ascii_case(&actual_digest);
let size_matches = expected
.size_bytes
.is_none_or(|expected_size| expected_size == len_to_u64(content.len()));
let passed = hash_matches && size_matches;
let error = if !hash_matches {
Some("hash mismatch".to_string())
} else if !size_matches {
Some("size mismatch".to_string())
} else {
None
};
VerificationResult {
path: relative_path.to_string(),
expected_hash: expected.hash.clone(),
actual_hash: Some(actual_hash),
passed,
error,
}
}
Err(error) => {
let detail = if pack_path.join(relative_path).exists() {
format!("required artifact `{relative_path}` unreadable: {error}")
} else {
format!("missing required artifact `{relative_path}`: {error}")
};
VerificationResult {
path: relative_path.to_string(),
expected_hash: expected_artifacts
.get(relative_path)
.map(|expected| expected.hash.clone())
.unwrap_or_default(),
actual_hash: None,
passed: false,
error: Some(detail),
}
}
}
}
fn read_pack_file_no_symlinks(pack_path: &Path, relative_path: &str) -> Result<Vec<u8>, String> {
let target_path = resolve_pack_file_path_no_symlinks(pack_path, relative_path)?;
let metadata = fs::symlink_metadata(&target_path).map_err(|error| {
format!(
"pack_artifact_unavailable: {}: {}",
target_path.display(),
error
)
})?;
if !metadata.file_type().is_file() {
return Err(format!(
"pack_artifact_unavailable: {}: not a regular file",
target_path.display()
));
}
const MAX_PACK_ARTIFACT_BYTES: u64 = 100 * 1024 * 1024;
if metadata.len() > MAX_PACK_ARTIFACT_BYTES {
return Err(format!(
"pack_artifact_too_large: {}: exceeds maximum size of {} bytes",
target_path.display(),
MAX_PACK_ARTIFACT_BYTES
));
}
let file = open_pack_file_for_read_no_symlinks(&target_path)?;
let mut bytes = Vec::new();
file.take(MAX_PACK_ARTIFACT_BYTES.saturating_add(1))
.read_to_end(&mut bytes)
.map_err(|error| {
format!(
"pack_artifact_unavailable: {}: {}",
target_path.display(),
error
)
})?;
if bytes.len() as u64 > MAX_PACK_ARTIFACT_BYTES {
return Err(format!(
"pack_artifact_too_large: {}: exceeds maximum size of {} bytes",
target_path.display(),
MAX_PACK_ARTIFACT_BYTES
));
}
Ok(bytes)
}
fn open_pack_file_for_read_no_symlinks(path: &Path) -> Result<fs::File, String> {
let mut options = fs::OpenOptions::new();
options.read(true);
configure_pack_file_read_options(&mut options);
options
.open(path)
.map_err(|error| format!("pack_artifact_unavailable: {}: {}", path.display(), error))
}
#[cfg(unix)]
fn configure_pack_file_read_options(options: &mut fs::OpenOptions) {
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(rustix::fs::OFlags::NOFOLLOW.bits() as i32);
}
#[cfg(not(unix))]
fn configure_pack_file_read_options(_options: &mut fs::OpenOptions) {}
fn write_pack_file_no_symlinks(
pack_path: &Path,
relative_path: &str,
content: &[u8],
) -> Result<(), String> {
let target_path = resolve_pack_file_path_for_write_no_symlinks(pack_path, relative_path)?;
ensure_pack_write_target_is_regular_or_missing(&target_path)?;
let mut file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&target_path)
.map_err(|error| {
format!(
"pack_artifact_write_failed: {}: {}",
target_path.display(),
error
)
})?;
file.write_all(content).map_err(|error| {
format!(
"pack_artifact_write_failed: {}: {}",
target_path.display(),
error
)
})?;
file.sync_all().map_err(|error| {
format!(
"pack_artifact_write_failed: {}: {}",
target_path.display(),
error
)
})
}
fn ensure_pack_write_target_is_regular_or_missing(target_path: &Path) -> Result<(), String> {
match fs::symlink_metadata(target_path) {
Ok(metadata) if metadata.file_type().is_file() => Ok(()),
Ok(_) => Err(format!(
"pack_artifact_write_failed: {}: not a regular file",
target_path.display()
)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(format!(
"pack_artifact_write_failed: {}: {}",
target_path.display(),
error
)),
}
}
fn resolve_pack_file_path_no_symlinks(
pack_path: &Path,
relative_path: &str,
) -> Result<PathBuf, String> {
reject_pack_symlink_component(pack_path)?;
let mut target_path = pack_path.to_path_buf();
for component in Path::new(relative_path).components() {
let Component::Normal(segment) = component else {
return Err(format!("invalid pack member path: {relative_path}"));
};
target_path.push(segment);
reject_pack_symlink_component(&target_path)?;
}
Ok(target_path)
}
fn resolve_pack_file_path_for_write_no_symlinks(
pack_path: &Path,
relative_path: &str,
) -> Result<PathBuf, String> {
reject_pack_symlink_component(pack_path)?;
let mut target_path = pack_path.to_path_buf();
let mut components = Path::new(relative_path).components().peekable();
while let Some(component) = components.next() {
let Component::Normal(segment) = component else {
return Err(format!("invalid pack member path: {relative_path}"));
};
target_path.push(segment);
match fs::symlink_metadata(&target_path) {
Ok(metadata) if metadata.file_type().is_symlink() => {
return Err(format!("pack_symlink_refused: {}", target_path.display()));
}
Ok(_) => {}
Err(error)
if error.kind() == std::io::ErrorKind::NotFound && components.peek().is_none() => {}
Err(error) => {
return Err(format!(
"pack_artifact_not_found: {}: {}",
target_path.display(),
error
));
}
}
}
Ok(target_path)
}
fn reject_pack_symlink_component(path: &Path) -> Result<(), String> {
match fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_symlink() => {
Err(format!("pack_symlink_refused: {}", path.display()))
}
Ok(_) => Ok(()),
Err(error) => Err(format!(
"pack_artifact_not_found: {}: {}",
path.display(),
error
)),
}
}
fn is_safe_pack_member_path(path: &str) -> bool {
if path.trim().is_empty() {
return false;
}
Path::new(path)
.components()
.all(|component| matches!(component, Component::Normal(_)))
}
fn create_env_json(include_vars: bool, timestamp: &str) -> String {
let mut tool_versions = serde_json::Map::new();
tool_versions.insert(
"ee".to_string(),
serde_json::Value::String(env!("CARGO_PKG_VERSION").to_string()),
);
let mut env_vars = serde_json::Map::new();
if include_vars {
if let Ok(rust_version) = std::env::var("RUSTC_VERSION") {
env_vars.insert(
"RUSTC_VERSION".to_string(),
serde_json::Value::String(rust_version),
);
}
}
let env = json!({
"schema": REPRO_ENV_SCHEMA_V1,
"os": std::env::consts::OS,
"arch": std::env::consts::ARCH,
"captured_at": timestamp,
"env_vars": env_vars,
"tool_versions": tool_versions
});
crate::core::serialize_pretty_or_error(&env)
}
fn create_manifest_json(
name: &str,
version: &str,
timestamp: &str,
artifacts: &[CapturedFile],
description: Option<&str>,
claim_id: Option<&str>,
demo_id: Option<&str>,
) -> String {
let artifacts = artifacts
.iter()
.map(|artifact| {
json!({
"path": artifact.path.as_str(),
"hash": artifact.hash.as_str(),
"size_bytes": artifact.size_bytes,
"required": true
})
})
.collect::<Vec<_>>();
let mut manifest = json!({
"schema": REPRO_MANIFEST_SCHEMA_V1,
"name": name,
"version": version,
"artifacts": artifacts,
"created_at": timestamp
});
if let Some(object) = manifest.as_object_mut() {
if let Some(description) = description {
object.insert(
"description".to_string(),
serde_json::Value::String(description.to_string()),
);
}
if let Some(claim_id) = claim_id {
object.insert(
"claim_id".to_string(),
serde_json::Value::String(claim_id.to_string()),
);
}
if let Some(demo_id) = demo_id {
object.insert(
"demo_id".to_string(),
serde_json::Value::String(demo_id.to_string()),
);
}
}
crate::core::serialize_pretty_or_error(&manifest)
}
fn create_lock_json(timestamp: &str) -> String {
let lock = json!({
"schema": REPRO_LOCK_SCHEMA_V1,
"lock_version": 1,
"locked_at": timestamp,
"dependencies": []
});
crate::core::serialize_pretty_or_error(&lock)
}
fn create_provenance_json(timestamp: &str) -> String {
let provenance = json!({
"schema": REPRO_PROVENANCE_SCHEMA_V1,
"sources": [],
"events": [],
"verifications": [],
"updated_at": timestamp
});
crate::core::serialize_pretty_or_error(&provenance)
}
fn hash_content(data: &[u8]) -> String {
use blake3::Hasher;
let mut hasher = Hasher::new();
hasher.update(data);
hasher.finalize().to_hex().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
type TestResult = Result<(), String>;
fn ensure<T: std::fmt::Debug + PartialEq>(actual: T, expected: T, ctx: &str) -> TestResult {
if actual == expected {
Ok(())
} else {
Err(format!("{ctx}: expected {expected:?}, got {actual:?}"))
}
}
#[test]
fn capture_dry_run_does_not_create_files() -> TestResult {
let temp_dir = tempfile::Builder::new()
.prefix("ee_repro_capture_")
.tempdir()
.map(tempfile::TempDir::keep)
.map_err(|e| e.to_string())?;
let options = CaptureOptions {
source: temp_dir.clone(),
output_dir: temp_dir.clone(),
name: Some("test-pack".to_string()),
version: "1.0.0".to_string(),
dry_run: true,
..Default::default()
};
let report = capture_pack(&options).map_err(|e| e.message())?;
ensure(report.dry_run, true, "dry_run")?;
ensure(report.pack_name, "test-pack".to_string(), "pack_name")?;
ensure(
temp_dir.join("test-pack").join("manifest.json").exists(),
false,
"manifest.json should not exist in dry run",
)?;
Ok(())
}
#[test]
fn capture_manifest_preserves_supplied_metadata() -> TestResult {
let temp_dir = tempfile::Builder::new()
.prefix("ee_repro_capture_metadata_")
.tempdir()
.map(tempfile::TempDir::keep)
.map_err(|e| e.to_string())?;
let report = capture_pack(&CaptureOptions {
source: temp_dir.clone(),
output_dir: temp_dir.clone(),
name: Some("metadata-pack".to_string()),
version: "1.2.3".to_string(),
description: Some("release claim reproduction".to_string()),
claim_id: Some("claim_release_context_demo".to_string()),
demo_id: Some("demo_release_context_demo".to_string()),
dry_run: false,
..Default::default()
})
.map_err(|e| e.message())?;
let manifest_json = fs::read_to_string(report.pack_path.join("manifest.json"))
.map_err(|error| error.to_string())?;
let manifest: serde_json::Value =
serde_json::from_str(&manifest_json).map_err(|error| error.to_string())?;
ensure(
manifest["description"].as_str(),
Some("release claim reproduction"),
"description",
)?;
ensure(
manifest["claim_id"].as_str(),
Some("claim_release_context_demo"),
"claim_id",
)?;
ensure(
manifest["demo_id"].as_str(),
Some("demo_release_context_demo"),
"demo_id",
)
}
#[test]
fn replay_status_properties() {
assert!(ReplayStatus::Verified.is_success());
assert!(!ReplayStatus::Failed.is_success());
assert!(!ReplayStatus::EnvMismatch.is_success());
assert_eq!(ReplayStatus::Verified.as_str(), "verified");
}
#[test]
fn capture_report_tracks_files() {
let mut report = CaptureReport::new(
PathBuf::from("test"),
"test".to_string(),
"1.0.0".to_string(),
);
report.add_file(CapturedFile {
path: "file1.txt".to_string(),
hash: "hash1".to_string(),
size_bytes: 100,
});
report.add_file(CapturedFile {
path: "file2.txt".to_string(),
hash: "hash2".to_string(),
size_bytes: 200,
});
assert_eq!(report.artifacts_captured, 2);
assert_eq!(report.total_size_bytes, 300);
}
#[test]
fn replay_report_tracks_verifications() {
let mut report = ReplayReport::new(
PathBuf::from("test"),
"test".to_string(),
"1.0.0".to_string(),
);
report.add_verification(VerificationResult {
path: "file1.txt".to_string(),
expected_hash: "hash1".to_string(),
actual_hash: Some("hash1".to_string()),
passed: true,
error: None,
});
report.add_verification(VerificationResult {
path: "file2.txt".to_string(),
expected_hash: "hash2".to_string(),
actual_hash: Some("wrong".to_string()),
passed: false,
error: Some("mismatch".to_string()),
});
assert_eq!(report.artifacts_verified, 1);
assert_eq!(report.artifacts_failed, 1);
}
#[test]
fn replay_pack_verifies_required_manifest_artifacts_beyond_canonical_files() -> TestResult {
let workspace = temp_root("ee_repro_replay_extra_required_artifact_")?;
let pack = workspace.join("pack");
fs::create_dir_all(&pack).map_err(|error| error.to_string())?;
let env_json = r#"{"schema":"ee.repro_pack.env.v1","os":"linux","arch":"x86_64","captured_at":"2026-05-01T00:00:00Z","env_vars":{},"tool_versions":{"ee":"0.1.0"}}"#;
let lock_json = r#"{"schema":"ee.repro_pack.lock.v1","lock_version":1,"locked_at":"2026-05-01T00:00:00Z","dependencies":[]}"#;
let provenance_json = r#"{"schema":"ee.repro_pack.provenance.v1","sources":[],"events":[],"verifications":[],"updated_at":"2026-05-01T00:00:00Z"}"#;
let expected_stdout = r#"{"ok":true}"#;
let artifact_entry = |path: &str, payload: &str| {
serde_json::json!({
"path": path,
"hash": format!("blake3:{}", hash_content(payload.as_bytes())),
"size_bytes": len_to_u64(payload.len()),
"required": true
})
};
let manifest_json = crate::core::serialize_pretty_or_error(&serde_json::json!({
"schema": REPRO_MANIFEST_SCHEMA_V1,
"name": "extra_required_artifact",
"version": "1.0.0",
"artifacts": [
artifact_entry("env.json", env_json),
artifact_entry("repro.lock", lock_json),
artifact_entry("provenance.json", provenance_json),
artifact_entry("stdout.json", expected_stdout)
],
"created_at": "2026-05-01T00:00:00Z"
}));
fs::write(pack.join("manifest.json"), manifest_json).map_err(|error| error.to_string())?;
fs::write(pack.join("env.json"), env_json).map_err(|error| error.to_string())?;
fs::write(pack.join("repro.lock"), lock_json).map_err(|error| error.to_string())?;
fs::write(pack.join("provenance.json"), provenance_json)
.map_err(|error| error.to_string())?;
fs::write(pack.join("stdout.json"), r#"{"ok":false}"#)
.map_err(|error| error.to_string())?;
let report = replay_pack(&ReplayOptions {
pack_path: pack,
verify_hashes: true,
check_env: false,
dry_run: false,
..Default::default()
})
.map_err(|error| error.message())?;
assert_eq!(report.status, ReplayStatus::Failed);
assert_eq!(report.artifacts_failed, 1);
assert!(
report.verification_results.iter().any(|result| {
result.path == "stdout.json"
&& !result.passed
&& result.error.as_deref() == Some("hash mismatch")
}),
"required extra manifest artifact must be verified: {report:?}"
);
Ok(())
}
#[test]
fn minimize_report_tracks_removals() {
let mut report = MinimizeReport::new(PathBuf::from("original"), PathBuf::from("minimized"));
report.add_kept(100);
report.add_kept(200);
report.add_removed(RemovedFile {
path: "big.bin".to_string(),
size_bytes: 1000,
reason: "too large".to_string(),
});
assert_eq!(report.artifacts_kept, 2);
assert_eq!(report.artifacts_removed, 1);
assert_eq!(report.minimized_size_bytes, 300);
assert_eq!(report.original_size_bytes, 1300);
}
fn temp_root(prefix: &str) -> Result<PathBuf, String> {
let root = tempfile::Builder::new()
.prefix(prefix)
.tempdir()
.map(tempfile::TempDir::keep)
.map_err(|e| e.to_string())?;
fs::canonicalize(&root).map_err(|e| e.to_string())
}
fn write_minimal_manifest(pack: &Path) -> Result<(), String> {
fs::write(
pack.join("manifest.json"),
concat!(
r#"{"artifacts":[{"path":"env.json","required":true,"#,
r#""hash":"blake3:0000000000000000000000000000000000000000000000000000000000000000"}]}"#,
),
)
.map_err(|e| e.to_string())
}
#[cfg(unix)]
#[test]
fn capture_pack_rejects_symlinked_output_parent() -> TestResult {
let workspace = temp_root("ee_repro_capture_symlink_parent_")?;
let source = workspace.join("source");
let real_output = workspace.join("real-output");
let symlink_output = workspace.join("out-link");
fs::create_dir_all(&source).map_err(|e| e.to_string())?;
fs::create_dir_all(&real_output).map_err(|e| e.to_string())?;
std::os::unix::fs::symlink(&real_output, &symlink_output).map_err(|e| e.to_string())?;
let error = capture_pack(&CaptureOptions {
source,
output_dir: symlink_output,
name: Some("pack".to_string()),
version: "1.0.0".to_string(),
dry_run: false,
..Default::default()
})
.expect_err("symlinked output parent must be rejected");
assert_eq!(error.code(), "storage");
assert!(error.message().contains("symlink"));
Ok(())
}
#[cfg(unix)]
#[test]
fn capture_pack_rejects_symlinked_pack_directory() -> TestResult {
let workspace = temp_root("ee_repro_capture_symlink_pack_")?;
let source = workspace.join("source");
let output = workspace.join("output");
let real_pack = workspace.join("real-pack");
fs::create_dir_all(&source).map_err(|e| e.to_string())?;
fs::create_dir_all(&output).map_err(|e| e.to_string())?;
fs::create_dir_all(&real_pack).map_err(|e| e.to_string())?;
std::os::unix::fs::symlink(&real_pack, output.join("pack")).map_err(|e| e.to_string())?;
let error = capture_pack(&CaptureOptions {
source,
output_dir: output,
name: Some("pack".to_string()),
version: "1.0.0".to_string(),
dry_run: false,
..Default::default()
})
.expect_err("symlinked pack directory must be rejected");
assert_eq!(error.code(), "storage");
assert!(error.message().contains("symlink"));
Ok(())
}
#[cfg(unix)]
#[test]
fn replay_pack_rejects_non_regular_manifest_member() -> TestResult {
let workspace = temp_root("ee_repro_replay_non_regular_manifest_")?;
let pack = workspace.join("pack");
fs::create_dir_all(pack.join("manifest.json")).map_err(|e| e.to_string())?;
let error = replay_pack(&ReplayOptions {
pack_path: pack,
dry_run: false,
verify_hashes: true,
check_env: false,
..Default::default()
})
.expect_err("non-regular manifest member must be rejected");
assert_eq!(error.code(), "storage");
assert!(error.message().contains("regular file"));
Ok(())
}
#[cfg(unix)]
#[test]
fn minimize_pack_rejects_symlinked_required_member() -> TestResult {
let workspace = temp_root("ee_repro_minimize_symlink_member_")?;
let pack = workspace.join("pack");
fs::create_dir_all(&pack).map_err(|e| e.to_string())?;
write_minimal_manifest(&pack)?;
let external_env = workspace.join("external-env.json");
fs::write(&external_env, "{}\n").map_err(|e| e.to_string())?;
std::os::unix::fs::symlink(&external_env, pack.join("env.json"))
.map_err(|e| e.to_string())?;
let error = minimize_pack(&MinimizeOptions {
pack_path: pack,
output_dir: workspace.join("minimized"),
dry_run: true,
..Default::default()
})
.expect_err("symlinked required pack member must be rejected");
assert_eq!(error.code(), "storage");
assert!(
error.message().contains("pack_symlink_refused"),
"expected member symlink refusal, got: {}",
error.message()
);
Ok(())
}
#[cfg(unix)]
#[test]
fn open_pack_file_for_read_rejects_symlinked_final_path() -> TestResult {
let workspace = temp_root("ee_repro_read_final_symlink_")?;
let pack_member = workspace.join("env.json");
let outside_member = workspace.join("outside-env.json");
fs::write(&outside_member, "{}\n").map_err(|error| error.to_string())?;
std::os::unix::fs::symlink(&outside_member, &pack_member)
.map_err(|error| error.to_string())?;
let error = open_pack_file_for_read_no_symlinks(&pack_member)
.expect_err("final symlink read must be rejected");
if !error.contains("pack_artifact_unavailable") {
return Err(format!("unexpected final symlink read error: {error}"));
}
let outside_after =
fs::read_to_string(&outside_member).map_err(|error| error.to_string())?;
assert_eq!(outside_after, "{}\n");
assert!(
fs::symlink_metadata(&pack_member)
.map_err(|error| error.to_string())?
.file_type()
.is_symlink(),
"rejected final symlink should remain for inspection"
);
Ok(())
}
#[test]
fn minimize_pack_rejects_non_regular_required_member() -> TestResult {
let workspace = temp_root("ee_repro_minimize_non_regular_member_")?;
let pack = workspace.join("pack");
fs::create_dir_all(pack.join("env.json")).map_err(|e| e.to_string())?;
write_minimal_manifest(&pack)?;
let error = minimize_pack(&MinimizeOptions {
pack_path: pack.clone(),
output_dir: workspace.join("minimized"),
dry_run: true,
..Default::default()
})
.expect_err("non-regular required pack member must be rejected");
assert_eq!(error.code(), "storage");
assert!(
error.message().contains("not a regular file"),
"expected non-regular metadata failure, got: {}",
error.message()
);
assert!(
pack.join("env.json").is_dir(),
"non-regular repro pack member should remain untouched"
);
Ok(())
}
#[test]
fn capture_pack_rejects_non_regular_member_before_write() -> TestResult {
let workspace = temp_root("ee_repro_capture_non_regular_member_")?;
let pack = workspace.join("pack");
fs::create_dir_all(pack.join("env.json")).map_err(|error| error.to_string())?;
let error = capture_pack(&CaptureOptions {
source: workspace.clone(),
output_dir: workspace.clone(),
name: Some("pack".to_owned()),
version: "1.0.0".to_owned(),
description: Some("non-regular member guard".to_owned()),
include_env: true,
..Default::default()
})
.expect_err("existing directory member should reject repro pack write");
assert!(
error.message().contains("not a regular file"),
"expected non-regular write failure, got: {}",
error.message()
);
assert!(
pack.join("env.json").is_dir(),
"non-regular repro pack member should remain a directory"
);
Ok(())
}
#[test]
fn capture_pack_rejects_existing_regular_member_without_truncating() -> TestResult {
let workspace = temp_root("ee_repro_capture_existing_member_")?;
let pack = workspace.join("pack");
fs::create_dir_all(&pack).map_err(|error| error.to_string())?;
let env_path = pack.join("env.json");
fs::write(&env_path, b"stale env sentinel").map_err(|error| error.to_string())?;
let error = capture_pack(&CaptureOptions {
source: workspace.clone(),
output_dir: workspace.clone(),
name: Some("pack".to_owned()),
version: "1.0.0".to_owned(),
description: Some("existing member guard".to_owned()),
include_env: true,
..Default::default()
})
.expect_err("existing regular member should reject repro pack write");
assert!(
error.message().contains("File exists") || error.message().contains("exists"),
"expected exclusive create failure, got: {}",
error.message()
);
assert_eq!(
fs::read_to_string(&env_path).map_err(|error| error.to_string())?,
"stale env sentinel",
"existing repro pack member must not be truncated"
);
assert!(
!pack.join("manifest.json").exists(),
"capture should stop before publishing later pack members after stale env failure"
);
Ok(())
}
}