use std::collections::HashMap;
use std::io;
use std::path::{Path, PathBuf};
use hurl::util::path::ContextDir;
use super::entry::{FormFieldKind, HurlEntry};
pub fn stage_out_of_scope_form_files(
entries: &mut [HurlEntry],
file_root: Option<&Path>,
) -> io::Result<Option<PathBuf>> {
let current_dir = std::env::current_dir().unwrap_or_default();
let root = file_root
.map(PathBuf::from)
.unwrap_or_else(|| current_dir.clone());
let ctx = ContextDir::new(¤t_dir, &root);
let out_of_scope = entries.iter().any(|e| {
e.form_fields.iter().any(|f| {
f.kind == FormFieldKind::File
&& !f.value.trim().is_empty()
&& !ctx.is_access_allowed(Path::new(f.value.trim()))
})
});
if !out_of_scope {
return Ok(None);
}
let stage_dir =
std::env::temp_dir().join(format!("paperboy-form-stage-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&stage_dir)?;
let mut staged: HashMap<PathBuf, String> = HashMap::new();
let mut used_names: HashMap<String, u32> = HashMap::new();
for entry in entries.iter_mut() {
for f in entry.form_fields.iter_mut() {
if f.kind != FormFieldKind::File || f.value.trim().is_empty() {
continue;
}
let raw = f.value.trim();
let source = if Path::new(raw).is_absolute() {
PathBuf::from(raw)
} else {
root.join(raw)
};
let staged_name = match staged.get(&source) {
Some(name) => name.clone(),
None => {
let base = source
.file_name()
.map_or_else(|| "file".to_string(), |n| n.to_string_lossy().to_string());
let name = unique_name(&base, &mut used_names);
if let Err(e) = std::fs::copy(&source, stage_dir.join(&name)) {
let _ = std::fs::remove_dir_all(&stage_dir);
return Err(e);
}
staged.insert(source.clone(), name.clone());
name
}
};
f.value = staged_name;
}
}
Ok(Some(stage_dir))
}
fn unique_name(base: &str, used: &mut HashMap<String, u32>) -> String {
let count = used.entry(base.to_string()).or_insert(0);
let name = if *count == 0 {
base.to_string()
} else {
match base.rsplit_once('.') {
Some((stem, ext)) => format!("{stem}_{count}.{ext}"),
None => format!("{base}_{count}"),
}
};
*count += 1;
name
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hurl::entry::FormField;
fn file_field(key: &str, value: &str) -> FormField {
FormField {
key: key.to_string(),
value: value.to_string(),
kind: FormFieldKind::File,
content_type: None,
}
}
fn entry_with_form(fields: Vec<FormField>) -> HurlEntry {
HurlEntry {
form_fields: fields,
..Default::default()
}
}
#[test]
fn does_nothing_when_every_file_is_already_in_scope() {
let dir = std::env::temp_dir().join(format!(
"paperboy_stage_test_inscope_{}",
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("avatar.png"), b"fake").unwrap();
let mut entries = vec![entry_with_form(vec![file_field("avatar", "avatar.png")])];
let result = stage_out_of_scope_form_files(&mut entries, Some(&dir)).unwrap();
assert!(
result.is_none(),
"nothing needs staging when the file is already under file_root"
);
assert_eq!(
entries[0].form_fields[0].value, "avatar.png",
"the field is left untouched"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn copies_an_out_of_scope_file_into_a_staging_dir_and_rewrites_its_value() {
let collection_dir = std::env::temp_dir().join(format!(
"paperboyman_stage_test_coll_{}",
uuid::Uuid::new_v4()
));
let elsewhere = std::env::temp_dir().join(format!(
"paperboytage_test_elsewhere_{}",
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(&collection_dir).unwrap();
std::fs::create_dir_all(&elsewhere).unwrap();
let source = elsewhere.join("avatar.png");
std::fs::write(&source, b"fake-png-bytes").unwrap();
let mut entries = vec![entry_with_form(vec![file_field(
"avatar",
source.to_str().unwrap(),
)])];
let staged_dir = stage_out_of_scope_form_files(&mut entries, Some(&collection_dir))
.unwrap()
.expect("a file outside file_root must trigger staging");
assert_eq!(
entries[0].form_fields[0].value, "avatar.png",
"the field now points at just the staged file name"
);
let staged_path = staged_dir.join("avatar.png");
assert!(
staged_path.is_file(),
"the file must actually be copied into the staging dir"
);
assert_eq!(std::fs::read(&staged_path).unwrap(), b"fake-png-bytes");
std::fs::remove_dir_all(&collection_dir).ok();
std::fs::remove_dir_all(&elsewhere).ok();
std::fs::remove_dir_all(&staged_dir).ok();
}
#[test]
fn staging_also_brings_along_already_in_scope_files_so_one_root_covers_everything() {
let collection_dir = std::env::temp_dir().join(format!(
"paperboyan_stage_test_mixed_{}",
uuid::Uuid::new_v4()
));
let elsewhere = std::env::temp_dir().join(format!(
"paperboytage_test_mixed_out_{}",
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(&collection_dir).unwrap();
std::fs::create_dir_all(&elsewhere).unwrap();
std::fs::write(collection_dir.join("in_scope.txt"), b"in").unwrap();
std::fs::write(elsewhere.join("out_of_scope.txt"), b"out").unwrap();
let mut entries = vec![entry_with_form(vec![
file_field("a", "in_scope.txt"),
file_field("b", elsewhere.join("out_of_scope.txt").to_str().unwrap()),
])];
let staged_dir = stage_out_of_scope_form_files(&mut entries, Some(&collection_dir))
.unwrap()
.unwrap();
assert!(
staged_dir.join("in_scope.txt").is_file(),
"the already-in-scope file is copied too"
);
assert!(staged_dir.join("out_of_scope.txt").is_file());
assert_eq!(entries[0].form_fields[0].value, "in_scope.txt");
assert_eq!(entries[0].form_fields[1].value, "out_of_scope.txt");
std::fs::remove_dir_all(&collection_dir).ok();
std::fs::remove_dir_all(&elsewhere).ok();
std::fs::remove_dir_all(&staged_dir).ok();
}
#[test]
fn two_different_source_files_sharing_a_name_are_disambiguated() {
let collection_dir = std::env::temp_dir().join(format!(
"paperboybman_stage_test_dup_{}",
uuid::Uuid::new_v4()
));
let a_dir = std::env::temp_dir().join(format!(
"paperboyan_stage_test_dup_a_{}",
uuid::Uuid::new_v4()
));
let b_dir = std::env::temp_dir().join(format!(
"paperboyan_stage_test_dup_b_{}",
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(&collection_dir).unwrap();
std::fs::create_dir_all(&a_dir).unwrap();
std::fs::create_dir_all(&b_dir).unwrap();
std::fs::write(a_dir.join("photo.png"), b"AAA").unwrap();
std::fs::write(b_dir.join("photo.png"), b"BBB").unwrap();
let mut entries = vec![entry_with_form(vec![
file_field("a", a_dir.join("photo.png").to_str().unwrap()),
file_field("b", b_dir.join("photo.png").to_str().unwrap()),
])];
let staged_dir = stage_out_of_scope_form_files(&mut entries, Some(&collection_dir))
.unwrap()
.unwrap();
let val_a = entries[0].form_fields[0].value.clone();
let val_b = entries[0].form_fields[1].value.clone();
assert_ne!(
val_a, val_b,
"two different source files sharing a name must not collide in the staging dir"
);
assert_eq!(std::fs::read(staged_dir.join(&val_a)).unwrap(), b"AAA");
assert_eq!(std::fs::read(staged_dir.join(&val_b)).unwrap(), b"BBB");
std::fs::remove_dir_all(&collection_dir).ok();
std::fs::remove_dir_all(&a_dir).ok();
std::fs::remove_dir_all(&b_dir).ok();
std::fs::remove_dir_all(&staged_dir).ok();
}
#[test]
fn the_same_source_file_referenced_twice_is_only_copied_once() {
let collection_dir = std::env::temp_dir().join(format!(
"paperboyman_stage_test_same_{}",
uuid::Uuid::new_v4()
));
let elsewhere = std::env::temp_dir().join(format!(
"paperboystage_test_same_out_{}",
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(&collection_dir).unwrap();
std::fs::create_dir_all(&elsewhere).unwrap();
let source = elsewhere.join("shared.bin");
std::fs::write(&source, b"shared-bytes").unwrap();
let mut entries = vec![
entry_with_form(vec![file_field("a", source.to_str().unwrap())]),
entry_with_form(vec![file_field("b", source.to_str().unwrap())]),
];
let staged_dir = stage_out_of_scope_form_files(&mut entries, Some(&collection_dir))
.unwrap()
.unwrap();
assert_eq!(
entries[0].form_fields[0].value, entries[1].form_fields[0].value,
"both references reuse the one staged copy"
);
assert_eq!(
entries[0].form_fields[0].value, "shared.bin",
"no spurious _1 suffix for a single shared source"
);
std::fs::remove_dir_all(&collection_dir).ok();
std::fs::remove_dir_all(&elsewhere).ok();
std::fs::remove_dir_all(&staged_dir).ok();
}
#[test]
fn a_missing_source_file_returns_an_error_and_cleans_up_the_staging_dir() {
let collection_dir = std::env::temp_dir().join(format!(
"paperboy_stage_test_missing_{}",
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(&collection_dir).unwrap();
let missing = std::env::temp_dir().join(format!(
"paperboyge_test_missing_src_{}.bin",
uuid::Uuid::new_v4()
));
let mut entries = vec![entry_with_form(vec![file_field(
"a",
missing.to_str().unwrap(),
)])];
let result = stage_out_of_scope_form_files(&mut entries, Some(&collection_dir));
assert!(
result.is_err(),
"a missing source file must surface as an error, not be silently skipped"
);
std::fs::remove_dir_all(&collection_dir).ok();
}
#[test]
fn text_only_form_fields_never_trigger_staging() {
let collection_dir = std::env::temp_dir().join(format!(
"paperboystage_test_textonly_{}",
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(&collection_dir).unwrap();
let mut entries = vec![entry_with_form(vec![FormField {
key: "name".to_string(),
value: "/completely/unrelated/path".to_string(),
kind: FormFieldKind::Text,
content_type: None,
}])];
let result = stage_out_of_scope_form_files(&mut entries, Some(&collection_dir)).unwrap();
assert!(
result.is_none(),
"a Text-kind field's value is never a file path, so it must never trigger staging"
);
std::fs::remove_dir_all(&collection_dir).ok();
}
}