mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-27 11:27:21 -06:00
Protect Unix bundle cleanup with pinned directory handles and remove only recognized content-addressed files. Keep legitimate linked roots and write/search-only directories working, with regression coverage for GC races and permission failures. Co-authored-by: skylar taylor-barrick <hi@skylar.cc>
177 lines
7.9 KiB
Rust
177 lines
7.9 KiB
Rust
//! Directory cleanup only owns content-addressed objects; unrelated files survive.
|
|
|
|
mod common;
|
|
|
|
use common::temp_dir;
|
|
use photocraft_color::{ColorMode, SampleType};
|
|
use photocraft_doc::Document;
|
|
use photocraft_format::{PcraftWriter, SaveOptions};
|
|
use photocraft_geom::Size;
|
|
|
|
fn empty_doc() -> Document {
|
|
Document::new("clean", Size::new(1, 1), ColorMode::Rgb, SampleType::U8)
|
|
}
|
|
|
|
#[test]
|
|
fn directory_cleanup_preserves_unrelated_zst_files() {
|
|
let root = temp_dir("unrelated-object");
|
|
let bundle = root.join("project.pcraft");
|
|
let mut writer = PcraftWriter::new();
|
|
writer.save_dir(&empty_doc(), &bundle, &SaveOptions::default()).unwrap();
|
|
|
|
let unrelated = bundle.join("tiles/personal-notes.zst");
|
|
let orphan = bundle.join("blobs").join(format!("{}.zst", "b".repeat(64)));
|
|
std::fs::write(&unrelated, b"unrelated file").unwrap();
|
|
std::fs::write(&orphan, b"unused content-addressed object").unwrap();
|
|
|
|
let stats = writer.save_dir(&empty_doc(), &bundle, &SaveOptions::default()).unwrap();
|
|
assert_eq!(std::fs::read(&unrelated).unwrap(), b"unrelated file");
|
|
assert!(!orphan.exists(), "a valid but unreferenced object is removed");
|
|
assert_eq!(stats.objects_removed, 1);
|
|
std::fs::remove_dir_all(root).unwrap();
|
|
}
|
|
|
|
#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))]
|
|
mod permissions {
|
|
use super::*;
|
|
use photocraft_format::load_path;
|
|
use photocraft_raster::Rgba8Image;
|
|
use std::os::unix::fs::{PermissionsExt, symlink};
|
|
use std::path::{Path, PathBuf};
|
|
|
|
struct RestoreMode {
|
|
path: PathBuf,
|
|
permissions: std::fs::Permissions,
|
|
}
|
|
|
|
impl RestoreMode {
|
|
fn set(path: &Path, mode: u32) -> Self {
|
|
let permissions = std::fs::metadata(path).unwrap().permissions();
|
|
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap();
|
|
Self { path: path.to_path_buf(), permissions }
|
|
}
|
|
}
|
|
|
|
impl Drop for RestoreMode {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::set_permissions(&self.path, self.permissions.clone());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn directory_save_without_previews_does_not_require_composite_read_permission() {
|
|
let root = temp_dir("composite-search-only");
|
|
let bundle = root.join("project.pcraft");
|
|
let mut writer = PcraftWriter::new();
|
|
writer.save_dir(&empty_doc(), &bundle, &SaveOptions::default()).unwrap();
|
|
let restricted = RestoreMode::set(&bundle.join("composite"), 0o300);
|
|
let mut changed = empty_doc();
|
|
changed.name = "saved without previews".into();
|
|
let result = writer.save_dir(&changed, &bundle, &SaveOptions::default());
|
|
drop(restricted);
|
|
result.expect("saving without previews does not read composite");
|
|
assert_eq!(load_path(&bundle).unwrap(), changed);
|
|
std::fs::remove_dir_all(root).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn directory_save_cleans_stale_previews_through_search_only_root_and_composite() {
|
|
let root = temp_dir("bundle-search-only");
|
|
let bundle = root.join("project.pcraft");
|
|
let link = root.join("linked.pcraft");
|
|
let mut writer = PcraftWriter::new();
|
|
writer.save_dir(&empty_doc(), &bundle, &SaveOptions::default()).unwrap();
|
|
symlink(&bundle, &link).unwrap();
|
|
std::fs::write(bundle.join("thumb.png"), b"stale thumbnail").unwrap();
|
|
std::fs::write(bundle.join("composite/preview.png"), b"stale composite").unwrap();
|
|
let orphan = bundle.join("blobs").join(format!("{}.zst", "a".repeat(64)));
|
|
std::fs::write(&orphan, b"unreferenced object").unwrap();
|
|
let composite_mode = RestoreMode::set(&bundle.join("composite"), 0o300);
|
|
let root_mode = RestoreMode::set(&bundle, 0o300);
|
|
let mut changed = empty_doc();
|
|
changed.name = "saved through linked root".into();
|
|
let result = writer.save_dir(&changed, &link, &SaveOptions::default());
|
|
drop(root_mode);
|
|
drop(composite_mode);
|
|
let stats = result.expect("cleanup needs directory traversal and write permission, not read");
|
|
assert_eq!(stats.objects_removed, 1);
|
|
assert!(!orphan.exists());
|
|
assert!(!bundle.join("thumb.png").exists());
|
|
assert!(!bundle.join("composite/preview.png").exists());
|
|
assert_eq!(load_path(&bundle).unwrap(), changed);
|
|
std::fs::remove_dir_all(root).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn directory_save_writes_current_preview_without_composite_read_permission() {
|
|
let root = temp_dir("preview-search-only");
|
|
let bundle = root.join("project.pcraft");
|
|
let mut writer = PcraftWriter::new();
|
|
writer.save_dir(&empty_doc(), &bundle, &SaveOptions::default()).unwrap();
|
|
let restricted = RestoreMode::set(&bundle.join("composite"), 0o300);
|
|
let mut changed = empty_doc();
|
|
changed.name = "saved with preview".into();
|
|
let opts = SaveOptions { composite: Some(Rgba8Image::new(2, 2)), ..Default::default() };
|
|
let result = writer.save_dir(&changed, &bundle, &opts);
|
|
drop(restricted);
|
|
result.expect("writing a preview does not require listing its directory");
|
|
assert!(std::fs::read(bundle.join("composite/preview.png")).unwrap().starts_with(b"\x89PNG\r\n\x1a\n"));
|
|
assert_eq!(load_path(&bundle).unwrap(), changed);
|
|
std::fs::remove_dir_all(root).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn denied_stale_preview_cleanup_preserves_the_previous_manifest() {
|
|
let root = temp_dir("denied-preview-cleanup");
|
|
let bundle = root.join("project.pcraft");
|
|
let mut writer = PcraftWriter::new();
|
|
let original = empty_doc();
|
|
writer.save_dir(&original, &bundle, &SaveOptions::default()).unwrap();
|
|
let preview = bundle.join("composite/preview.png");
|
|
std::fs::write(&preview, b"stale composite").unwrap();
|
|
let restricted = RestoreMode::set(&bundle.join("composite"), 0o500);
|
|
let permission_probe = bundle.join("composite/permission-probe");
|
|
if std::fs::write(&permission_probe, b"probe").is_ok() {
|
|
// An elevated test process may bypass directory permissions.
|
|
std::fs::remove_file(permission_probe).unwrap();
|
|
drop(restricted);
|
|
std::fs::remove_dir_all(root).unwrap();
|
|
return;
|
|
}
|
|
let mut changed = empty_doc();
|
|
changed.name = "must not commit".into();
|
|
let result = writer.save_dir(&changed, &bundle, &SaveOptions::default());
|
|
drop(restricted);
|
|
assert!(result.is_err(), "a required pre-commit cleanup failure is reported");
|
|
assert_eq!(std::fs::read(preview).unwrap(), b"stale composite");
|
|
assert_eq!(load_path(&bundle).unwrap(), original);
|
|
std::fs::remove_dir_all(root).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn denied_object_cleanup_remains_best_effort_after_manifest_commit() {
|
|
let root = temp_dir("denied-object-cleanup");
|
|
let bundle = root.join("project.pcraft");
|
|
let mut writer = PcraftWriter::new();
|
|
writer.save_dir(&empty_doc(), &bundle, &SaveOptions::default()).unwrap();
|
|
let orphan = bundle.join("tiles").join(format!("{}.zst", "d".repeat(64)));
|
|
std::fs::write(&orphan, b"unreferenced object").unwrap();
|
|
let restricted = RestoreMode::set(&bundle.join("tiles"), 0o500);
|
|
let permission_probe = bundle.join("tiles/permission-probe");
|
|
if std::fs::write(&permission_probe, b"probe").is_ok() {
|
|
std::fs::remove_file(permission_probe).unwrap();
|
|
drop(restricted);
|
|
std::fs::remove_dir_all(root).unwrap();
|
|
return;
|
|
}
|
|
let mut changed = empty_doc();
|
|
changed.name = "committed despite housekeeping failure".into();
|
|
let result = writer.save_dir(&changed, &bundle, &SaveOptions::default());
|
|
drop(restricted);
|
|
assert_eq!(result.unwrap().objects_removed, 0);
|
|
assert!(orphan.exists());
|
|
assert_eq!(load_path(&bundle).unwrap(), changed);
|
|
std::fs::remove_dir_all(root).unwrap();
|
|
}
|
|
}
|