1use chrono::{DateTime, FixedOffset, Local};
2use fs2::FileExt;
3use serde::{Deserialize, Serialize};
4use serde_json::{Value, json};
5use sha2::{Digest, Sha256};
6use std::fs;
7use std::io::{ErrorKind, Read, Write};
8use std::path::{Component, Path, PathBuf};
9
10#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11pub enum State {
12 Archived,
13 Queued,
14 Conflict,
15 Refused,
16 Skipped,
17}
18
19#[derive(Debug)]
20enum Fail {
21 Conflict(String),
22 Retry(String),
23}
24
25impl Fail {
26 fn message(&self) -> &str {
27 match self {
28 Self::Conflict(message) | Self::Retry(message) => message,
29 }
30 }
31}
32
33impl From<String> for Fail {
34 fn from(message: String) -> Self {
35 Self::Retry(message)
36 }
37}
38
39impl From<&str> for Fail {
40 fn from(message: &str) -> Self {
41 Self::Retry(message.into())
42 }
43}
44
45impl State {
46 pub fn name(self) -> &'static str {
47 match self {
48 Self::Archived => "archived",
49 Self::Queued => "queued",
50 Self::Conflict => "conflict",
51 Self::Refused => "refused",
52 Self::Skipped => "skipped",
53 }
54 }
55}
56
57#[derive(Serialize, Deserialize)]
58struct Pending {
59 manifest: PathBuf,
60 folder: PathBuf,
61 root: PathBuf,
62}
63
64#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)]
65struct CatalogFile {
66 name: String,
67 bytes: u64,
68 sha256: String,
69}
70
71#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)]
72struct Catalog {
73 v: u8,
74 tool: String,
75 product: String,
76 shot: String,
77 version: String,
78 caller: String,
79 made: String,
80 path: String,
81 files: Vec<CatalogFile>,
82}
83
84pub fn local_made() -> String {
85 Local::now().to_rfc3339()
86}
87
88fn hash_io(path: &Path) -> std::io::Result<String> {
89 let mut file = fs::File::open(path)?;
90 let mut digest = Sha256::new();
91 let mut buffer = [0u8; 65536];
92 loop {
93 let read = file.read(&mut buffer)?;
94 if read == 0 {
95 break;
96 }
97 digest.update(&buffer[..read]);
98 }
99 Ok(hex::encode(digest.finalize()))
100}
101
102fn hash(path: &Path) -> Result<String, String> {
103 hash_io(path).map_err(|e| format!("{}: {e}", path.display()))
104}
105
106fn safe(part: &str) -> bool {
107 !part.is_empty()
108 && part != "."
109 && part != ".."
110 && part
111 .chars()
112 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
113}
114
115const BUILT_ROOT: Option<&str> = option_env!("PFX_ARCHIVE_ROOT");
116
117fn root_online(root: &Path) -> bool {
118 if root.as_os_str().is_empty() || !root.exists() {
119 return false;
120 }
121 let Some(built) = BUILT_ROOT.filter(|built| root == Path::new(built)) else {
122 return true;
123 };
124 if !cfg!(target_os = "linux") {
125 return true;
126 }
127 fs::read_to_string("/proc/self/mountinfo").is_ok_and(|text| {
128 text.lines()
129 .any(|line| line.split_whitespace().nth(4) == Some(built))
130 })
131}
132
133pub fn folder(manifest: &Value) -> Result<PathBuf, String> {
134 let root = archive_root();
135 folder_at(manifest, &root)
136}
137
138fn root_in(env: &impl Fn(&str) -> Option<std::ffi::OsString>) -> Option<PathBuf> {
139 env("PITO_ARCHIVE")
140 .filter(|root| !root.is_empty())
141 .map(PathBuf::from)
142 .or_else(|| BUILT_ROOT.map(PathBuf::from))
143}
144
145fn archive_root() -> PathBuf {
146 root_in(&|key| std::env::var_os(key)).unwrap_or_default()
147}
148
149const TOOL: &str = "pfx";
150
151fn masters(root: &Path) -> PathBuf {
152 root.join("Masters")
153}
154
155fn catalog_file(root: &Path) -> PathBuf {
156 masters(root).join("catalog.jsonl")
157}
158
159pub const SAMPLES: &[&str] = &["checker", "sample"];
160
161pub fn is_tag(version: &str) -> bool {
162 let Some(rest) = version.strip_prefix('v') else {
163 return false;
164 };
165 let (numbers, pre) = match rest.split_once('-') {
166 Some((numbers, pre)) => (numbers, Some(pre)),
167 None => (rest, None),
168 };
169 let parts: Vec<&str> = numbers.split('.').collect();
170 parts.len() == 3
171 && parts
172 .iter()
173 .all(|part| !part.is_empty() && part.chars().all(|c| c.is_ascii_digit()))
174 && pre.is_none_or(|pre| {
175 pre.split('.').all(|part| {
176 !part.is_empty() && part.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
177 })
178 })
179}
180
181pub fn pinned(manifest: &Value) -> bool {
182 manifest["pinned"]
183 .as_bool()
184 .unwrap_or_else(|| manifest["version"].as_str().is_some_and(is_tag))
185}
186
187fn has_master(manifest: &Value) -> bool {
188 manifest["files"].as_array().is_some_and(|files| {
189 files.iter().any(|item| {
190 item["file"].as_str().is_some_and(|name| {
191 name.ends_with(".master.mp4")
192 || name.ends_with(".master.png")
193 || name.ends_with(".alpha.master.mov")
194 })
195 })
196 })
197}
198
199fn under_tmp(root: &Path) -> bool {
200 root.components().any(|part| part.as_os_str() == "tmp")
201}
202
203pub fn skip_reason(manifest: &Value, no_archive: bool) -> Option<&'static str> {
204 skip_reason_in(manifest, no_archive, |key| std::env::var_os(key))
205}
206
207fn run_reason(env: &impl Fn(&str) -> Option<std::ffi::OsString>) -> Option<&'static str> {
208 if env("PFX_DIRECT").is_some_and(|value| value == "1")
209 || env("PITO_ARCHIVE").is_some_and(|root| under_tmp(Path::new(&root)))
210 {
211 return Some("test");
212 }
213 if env("RIDE_APP").is_some_and(|value| !value.is_empty()) {
214 return Some("ride");
215 }
216 None
217}
218
219fn skip_reason_in(
220 manifest: &Value,
221 no_archive: bool,
222 env: impl Fn(&str) -> Option<std::ffi::OsString>,
223) -> Option<&'static str> {
224 if no_archive {
225 return Some("no-archive");
226 }
227 if let Some(reason) = run_reason(&env) {
228 return Some(reason);
229 }
230 if root_in(&env).is_none() {
231 return Some("no-root");
232 }
233 if manifest["product"]
234 .as_str()
235 .is_some_and(|product| SAMPLES.contains(&product))
236 {
237 return Some("sample");
238 }
239 if !has_master(manifest) {
240 return Some("frames-only");
241 }
242 if !pinned(manifest) {
243 return Some("unpinned");
244 }
245 None
246}
247
248pub fn reason(manifest_path: &Path, no_archive: bool) -> Option<&'static str> {
249 let manifest: Value = fs::read(manifest_path)
250 .ok()
251 .and_then(|bytes| serde_json::from_slice(&bytes).ok())
252 .unwrap_or(Value::Null);
253 skip_reason(&manifest, no_archive)
254}
255
256fn caller(value: Option<&str>) -> String {
257 let mut clean = String::new();
258 for character in value.unwrap_or("").chars() {
259 let lower = character.to_ascii_lowercase();
260 if lower.is_ascii_lowercase() || lower.is_ascii_digit() {
261 clean.push(lower);
262 } else if !clean.is_empty() && !clean.ends_with('-') {
263 clean.push('-');
264 }
265 }
266 let clean = clean.trim_end_matches('-');
267 if clean.is_empty() {
268 "manual".into()
269 } else {
270 clean.into()
271 }
272}
273
274#[cfg(not(test))]
275fn invoker() -> Option<String> {
276 std::env::var("PITO_INVOKER").ok()
277}
278
279#[cfg(test)]
280fn invoker() -> Option<String> {
281 None
282}
283
284fn folder_at(manifest: &Value, root: &Path) -> Result<PathBuf, String> {
285 folder_with_caller(manifest, root, invoker().as_deref())
286}
287
288fn folder_with_caller(
289 manifest: &Value,
290 root: &Path,
291 invoker: Option<&str>,
292) -> Result<PathBuf, String> {
293 let product = manifest["product"]
294 .as_str()
295 .ok_or("archive: missing product")?;
296 let kind = manifest["kind"].as_str().ok_or("archive: missing kind")?;
297 let shot = manifest["shot"].as_str().unwrap_or(kind);
298 let version = manifest["version"]
299 .as_str()
300 .ok_or("archive: missing version")?;
301 let made = manifest["made"].as_str().ok_or("archive: missing made")?;
302 leaf_under(root, product, shot, version, made, &caller(invoker))
303}
304
305fn leaf_under(
306 root: &Path,
307 product: &str,
308 shot: &str,
309 version: &str,
310 made: &str,
311 caller: &str,
312) -> Result<PathBuf, String> {
313 for (what, part) in [
314 ("product", product),
315 ("shot", shot),
316 ("version", version),
317 ("caller", caller),
318 ] {
319 if !safe(part) {
320 return Err(format!("the {what} {part:?} is not a safe folder name"));
321 }
322 }
323 let date = DateTime::parse_from_rfc3339(made).map_err(|e| format!("archive made: {e}"))?;
324 Ok(masters(root)
325 .join(TOOL)
326 .join(product)
327 .join(shot)
328 .join(format!(
329 "{}_{}_{}_{}",
330 date.format("%Y-%m-%d"),
331 date.format("%H%M%S"),
332 version,
333 caller
334 )))
335}
336
337fn selected(name: &str, still: bool) -> bool {
338 name.ends_with(".master.mp4")
339 || name.ends_with(".alpha.master.mov")
340 || name.ends_with(".master.png")
341 || name.ends_with(".poster.png")
342 || name.contains(".cutouts/")
343 || (still && !name.contains('/') && name.ends_with(".png"))
344}
345
346fn files(manifest: &Value) -> Result<Vec<(PathBuf, String)>, String> {
347 let mut out = Vec::new();
348 let listed = manifest["files"]
349 .as_array()
350 .ok_or("archive: missing files")?;
351 let still = listed.iter().any(|item| {
352 item["file"]
353 .as_str()
354 .is_some_and(|name| !name.contains('/') && name.ends_with(".master.png"))
355 });
356 for item in listed {
357 let name = item["file"].as_str().ok_or("archive: file has no name")?;
358 if !selected(name, still) {
359 continue;
360 }
361 let path = Path::new(name);
362 if path
363 .components()
364 .any(|part| !matches!(part, Component::Normal(_)))
365 {
366 return Err(format!("archive: unsafe file {name}"));
367 }
368 let checksum = item["sha256"]
369 .as_str()
370 .ok_or("archive: file has no sha256")?;
371 out.push((path.to_path_buf(), checksum.to_owned()));
372 }
373 Ok(out)
374}
375
376fn write_manifest(path: &Path, manifest: &Value) -> Result<(), String> {
377 fs::write(
378 path,
379 serde_json::to_vec_pretty(manifest).map_err(|e| e.to_string())?,
380 )
381 .map_err(|e| format!("{}: {e}", path.display()))
382}
383
384fn leaf_name(root: &Path, folder: &Path) -> String {
385 folder
386 .strip_prefix(masters(root))
387 .unwrap_or(folder)
388 .to_string_lossy()
389 .into_owned()
390}
391
392fn catalog_of(manifest: &Value) -> Result<Option<Catalog>, String> {
393 match &manifest["archive"]["catalog"] {
394 Value::Null => Ok(None),
395 Value::String(line) => serde_json::from_str(line)
396 .map(Some)
397 .map_err(|e| format!("archive catalog: {e}")),
398 object => serde_json::from_value(object.clone())
399 .map(Some)
400 .map_err(|e| format!("archive catalog: {e}")),
401 }
402}
403
404fn set_failure(manifest: &mut Value, root: &Path, folder: &Path, state: State, error: &str) {
405 set_state(manifest, root, folder, state);
406 manifest["archive"]["error"] = json!(error);
407}
408
409fn set_state(manifest: &mut Value, root: &Path, folder: &Path, state: State) {
410 let catalog = match catalog_of(manifest) {
411 Ok(Some(catalog)) => serde_json::to_value(catalog).unwrap_or(Value::Null),
412 _ => manifest["archive"]["catalog"].clone(),
413 };
414 manifest["archive"] =
415 json!({"leaf": leaf_name(root, folder), "state": state.name(), "catalog": catalog});
416}
417
418fn instant(value: &Value) -> Result<DateTime<FixedOffset>, String> {
419 let text = value.as_str().ok_or("archive: missing made")?;
420 DateTime::parse_from_rfc3339(text).map_err(|e| format!("archive made: {e}"))
421}
422
423fn numbered(base: &Path, number: usize) -> Result<PathBuf, String> {
424 if number < 2 {
425 return Ok(base.to_path_buf());
426 }
427 Ok(base.with_file_name(format!(
428 "{}-{number}",
429 base.file_name()
430 .ok_or("archive: invalid leaf")?
431 .to_string_lossy()
432 )))
433}
434
435fn same_render(leaf: &Path, manifest: &Value) -> Result<bool, Fail> {
436 let at = |why: &dyn std::fmt::Display| format!("{}: {why}", leaf.display());
437 let conflict = |why: &dyn std::fmt::Display| {
438 Fail::Conflict(format!("archive conflict at {}: {why}", leaf.display()))
439 };
440 let bytes = match fs::read(leaf.join("manifest.json")) {
441 Ok(bytes) => bytes,
442 Err(e) if matches!(e.kind(), ErrorKind::NotFound | ErrorKind::NotADirectory) => {
443 return Ok(false);
444 }
445 Err(e) => return Err(Fail::Retry(at(&e))),
446 };
447 let old: Value = serde_json::from_slice(&bytes).map_err(|e| conflict(&e))?;
448 let theirs = instant(&old["made"]).map_err(|e| conflict(&e))?;
449 if theirs != instant(&manifest["made"]).map_err(|e| conflict(&e))? {
450 return Ok(false);
451 }
452 let ours = files(manifest)?;
453 for (name, _) in &ours {
454 match fs::metadata(leaf.join(name)) {
455 Ok(meta) if !meta.is_file() => return Ok(false),
456 Ok(_) => {}
457 Err(e) if e.kind() == ErrorKind::NotFound => return Ok(false),
458 Err(e) => return Err(Fail::Retry(at(&e))),
459 }
460 }
461 for (name, checksum) in &ours {
462 match hash_io(&leaf.join(name)) {
463 Ok(found) if found == *checksum => {}
464 Ok(_) => {
465 return Err(conflict(&format!(
466 "{} differs from this render's manifest",
467 name.display()
468 )));
469 }
470 Err(e) => return Err(Fail::Retry(at(&e))),
471 }
472 }
473 Ok(true)
474}
475
476fn resolve_leaf(base: &Path, manifest: &Value) -> Result<(PathBuf, bool), Fail> {
477 let mut number = 1;
478 loop {
479 let candidate = numbered(base, number)?;
480 if !candidate
481 .try_exists()
482 .map_err(|e| format!("{}: {e}", candidate.display()))?
483 {
484 return Ok((candidate, false));
485 }
486 if same_render(&candidate, manifest)? {
487 return Ok((candidate, true));
488 }
489 number += 1;
490 }
491}
492
493fn held_or_conflict(destination: &Path, checksum: &str) -> Result<bool, Fail> {
494 if hash(destination)? == checksum {
495 Ok(false)
496 } else {
497 Err(Fail::Conflict(format!(
498 "archive conflict at {}",
499 destination.display()
500 )))
501 }
502}
503
504fn put(source: &Path, destination: &Path, checksum: &str) -> Result<bool, Fail> {
505 if destination.exists() {
506 return held_or_conflict(destination, checksum);
507 }
508 let parent = destination.parent().ok_or("archive: no parent")?;
509 fs::create_dir_all(parent).map_err(|e| e.to_string())?;
510 let name = destination
511 .file_name()
512 .ok_or("archive: no file name")?
513 .to_string_lossy();
514 let partial = parent.join(format!(".{name}.partial"));
515 if partial.exists() {
516 fs::remove_file(&partial).map_err(|e| e.to_string())?;
517 }
518 fs::copy(source, &partial).map_err(|e| format!("{}: {e}", partial.display()))?;
519 if hash(&partial)? != checksum {
520 fs::remove_file(&partial).ok();
521 return Err(format!("archive checksum mismatch for {}", source.display()).into());
522 }
523 place(&partial, destination, checksum)
524}
525
526fn link_unsupported(error: &std::io::Error) -> bool {
527 if error.kind() == ErrorKind::Unsupported {
528 return true;
529 }
530 #[cfg(target_os = "linux")]
531 {
532 matches!(error.raw_os_error(), Some(libc::ENOSYS | libc::EOPNOTSUPP))
533 }
534 #[cfg(not(target_os = "linux"))]
535 {
536 false
537 }
538}
539
540fn place(partial: &Path, destination: &Path, checksum: &str) -> Result<bool, Fail> {
541 match fs::hard_link(partial, destination) {
542 Ok(()) => {
543 fs::remove_file(partial).map_err(|e| format!("{}: {e}", partial.display()))?;
544 return Ok(true);
545 }
546 Err(error) if error.kind() == ErrorKind::AlreadyExists => {
547 fs::remove_file(partial).ok();
548 return held_or_conflict(destination, checksum);
549 }
550 Err(error) if link_unsupported(&error) => {}
551 Err(error) => {
552 fs::remove_file(partial).ok();
553 return Err(format!("{}: {error}", destination.display()).into());
554 }
555 }
556 if destination.exists() {
557 fs::remove_file(partial).ok();
558 return held_or_conflict(destination, checksum);
559 }
560 fs::rename(partial, destination).map_err(|e| format!("{}: {e}", destination.display()))?;
561 Ok(true)
562}
563
564fn copy_files(source: &Path, destination: &Path, manifest: &Value) -> Result<(), Fail> {
565 for (name, checksum) in files(manifest)? {
566 let from = source.join(&name);
567 if hash(&from)? != checksum {
568 return Err(format!("archive source checksum mismatch for {}", from.display()).into());
569 }
570 put(&from, &destination.join(&name), &checksum)?;
571 }
572 Ok(())
573}
574
575fn catalog_for(
576 manifest: &Value,
577 root: &Path,
578 folder: &Path,
579 source: &Path,
580) -> Result<Catalog, String> {
581 let mut entries = Vec::new();
582 for (name, sha256) in files(manifest)? {
583 let from = source.join(&name);
584 if hash(&from)? != sha256 {
585 return Err(format!(
586 "archive source checksum mismatch for {}",
587 from.display()
588 ));
589 }
590 entries.push(CatalogFile {
591 name: name.to_string_lossy().into_owned(),
592 bytes: fs::metadata(&from).map_err(|e| e.to_string())?.len(),
593 sha256,
594 });
595 }
596 let path = folder
597 .strip_prefix(masters(root))
598 .map_err(|e| e.to_string())?;
599 let parts: Vec<_> = path.components().collect();
600 Ok(Catalog {
601 v: 1,
602 tool: TOOL.into(),
603 product: manifest["product"]
604 .as_str()
605 .ok_or("archive: missing product")?
606 .into(),
607 shot: parts
608 .get(2)
609 .ok_or("archive: missing shot")?
610 .as_os_str()
611 .to_string_lossy()
612 .into_owned(),
613 version: manifest["version"]
614 .as_str()
615 .ok_or("archive: missing version")?
616 .into(),
617 caller: parts
618 .get(3)
619 .and_then(|_| folder.file_name())
620 .and_then(|n| n.to_str())
621 .and_then(|n| n.rsplit('_').next())
622 .ok_or("archive: missing caller")?
623 .into(),
624 made: manifest["made"]
625 .as_str()
626 .ok_or("archive: missing made")?
627 .into(),
628 path: path.to_string_lossy().into_owned(),
629 files: entries,
630 })
631}
632
633fn lock_path() -> Result<PathBuf, String> {
634 let runtime = match std::env::var_os("XDG_RUNTIME_DIR") {
635 Some(dir) => PathBuf::from(dir),
636 None => {
637 use std::os::unix::fs::MetadataExt;
638 let uid = fs::metadata("/proc/self")
639 .map_err(|e| format!("archive: no XDG_RUNTIME_DIR and no /proc/self: {e}"))?
640 .uid();
641 PathBuf::from(format!("/run/user/{uid}"))
642 }
643 };
644 Ok(runtime.join("pito-archive/catalog.lock"))
645}
646
647fn with_lock<T>(path: &Path, action: impl FnOnce() -> Result<T, Fail>) -> Result<T, Fail> {
648 fs::create_dir_all(path.parent().ok_or("archive: lock has no parent")?)
649 .map_err(|e| e.to_string())?;
650 let file = fs::OpenOptions::new()
651 .create(true)
652 .write(true)
653 .truncate(false)
654 .open(path)
655 .map_err(|e| e.to_string())?;
656 file.lock_exclusive().map_err(|e| e.to_string())?;
657 let result = action();
658 FileExt::unlock(&file).map_err(|e| e.to_string())?;
659 result
660}
661
662fn append_catalog_unlocked(root: &Path, catalog: &Catalog) -> Result<(), String> {
663 let mut line = serde_json::to_vec(catalog).map_err(|e| e.to_string())?;
664 line.push(b'\n');
665 let mut file = fs::OpenOptions::new()
666 .create(true)
667 .append(true)
668 .open(catalog_file(root))
669 .map_err(|e| e.to_string())?;
670 let written = file.write(&line).map_err(|e| e.to_string())?;
671 if written != line.len() {
672 return Err("archive: short catalog write".into());
673 }
674 Ok(())
675}
676
677fn catalog_present(root: &Path, catalog: &Catalog) -> Result<bool, String> {
678 let text = match fs::read_to_string(catalog_file(root)) {
679 Ok(text) => text,
680 Err(error) if error.kind() == ErrorKind::NotFound => return Ok(false),
681 Err(error) => return Err(error.to_string()),
682 };
683 let line = serde_json::to_string(catalog).map_err(|e| e.to_string())?;
684 Ok(text.lines().any(|item| item == line))
685}
686
687fn partial_folder(folder: &Path) -> Result<PathBuf, String> {
688 Ok(folder.with_file_name(format!(
689 "{}.partial",
690 folder
691 .file_name()
692 .ok_or("archive: invalid leaf")?
693 .to_string_lossy()
694 )))
695}
696
697fn stage_leaf(source: &Path, folder: &Path, manifest: &Value) -> Result<PathBuf, Fail> {
698 let partial = partial_folder(folder)?;
699 if partial.exists() {
700 fs::remove_dir_all(&partial).map_err(|e| e.to_string())?;
701 }
702 fs::create_dir_all(&partial).map_err(|e| e.to_string())?;
703 let result = (|| -> Result<PathBuf, Fail> {
704 copy_files(source, &partial, manifest)?;
705 let manifest_bytes = serde_json::to_vec_pretty(manifest).map_err(|e| e.to_string())?;
706 fs::write(partial.join("manifest.json"), &manifest_bytes).map_err(|e| e.to_string())?;
707 let manifest_sha = hex::encode(Sha256::digest(&manifest_bytes));
708 if hash(&partial.join("manifest.json"))? != manifest_sha {
709 return Err("archive manifest checksum mismatch".into());
710 }
711 let saved: Value = serde_json::from_slice(
712 &fs::read(partial.join("manifest.json")).map_err(|e| e.to_string())?,
713 )
714 .map_err(|e| e.to_string())?;
715 if saved != *manifest {
716 return Err("archive manifest verification failed".into());
717 }
718 Ok(partial.clone())
719 })();
720 if result.is_err() && partial.exists() {
721 fs::remove_dir_all(partial).map_err(|e| e.to_string())?;
722 }
723 result
724}
725
726fn taken(folder: &Path) -> Fail {
727 Fail::Retry(format!(
728 "{} was taken by another render meanwhile",
729 folder.display()
730 ))
731}
732
733fn finalize_leaf(partial: &Path, folder: &Path, manifest: &Value) -> Result<bool, Fail> {
734 if folder
735 .try_exists()
736 .map_err(|e| format!("{}: {e}", folder.display()))?
737 {
738 return if same_render(folder, manifest)? {
739 Ok(false)
740 } else {
741 Err(taken(folder))
742 };
743 }
744 fs::rename(partial, folder).map_err(|e| format!("{}: {e}", folder.display()))?;
745 Ok(true)
746}
747
748fn publish_leaf(source: &Path, folder: &Path, manifest: &Value) -> Result<bool, Fail> {
749 if folder
750 .try_exists()
751 .map_err(|e| format!("{}: {e}", folder.display()))?
752 {
753 return if same_render(folder, manifest)? {
754 Ok(false)
755 } else {
756 Err(taken(folder))
757 };
758 }
759 let partial = stage_leaf(source, folder, manifest)?;
760 let result = finalize_leaf(&partial, folder, manifest);
761 if partial.exists() {
762 fs::remove_dir_all(&partial).map_err(|e| e.to_string())?;
763 }
764 result
765}
766
767fn publish(
768 entry: &Path,
769 base: &Path,
770 root: &Path,
771 manifest: &Value,
772) -> Result<(Value, bool), Fail> {
773 let mut catalog = catalog_of(manifest)?.ok_or("archive: missing catalog")?;
774 let (leaf, held) = resolve_leaf(base, manifest)?;
775 catalog.path = leaf_name(root, &leaf);
776 let mut staged = manifest.clone();
777 staged["archive"]["catalog"] = serde_json::to_value(&catalog).map_err(|e| e.to_string())?;
778 set_state(&mut staged, root, &leaf, State::Archived);
779 let added = if held {
780 false
781 } else {
782 publish_leaf(entry, &leaf, &staged)?
783 };
784 if added || !catalog_present(root, &catalog)? {
785 append_catalog_unlocked(root, &catalog)?;
786 }
787 Ok((staged, added))
788}
789
790fn clone_or_copy(from: &Path, to: &Path) -> std::io::Result<()> {
791 #[cfg(target_os = "linux")]
792 {
793 let source = fs::File::open(from)?;
794 let target = fs::File::create(to)?;
795 if rustix::fs::ioctl_ficlone(&target, &source).is_ok() {
796 return Ok(());
797 }
798 }
799 fs::copy(from, to).map(|_| ())
800}
801
802fn queue_path(manifest: &Path, folder: &Path, cache: &Path) -> PathBuf {
803 let key = hex::encode(Sha256::digest(
804 format!("{}:{}", manifest.display(), folder.display()).as_bytes(),
805 ));
806 cache.join("archive-queue").join(&key[..24])
807}
808
809fn queue(
810 manifest_path: &Path,
811 folder: &Path,
812 root: &Path,
813 manifest: &Value,
814 cache: &Path,
815) -> Result<(), String> {
816 let directory = queue_path(manifest_path, folder, cache);
817 fs::create_dir_all(&directory).map_err(|e| e.to_string())?;
818 let source = manifest_path
819 .parent()
820 .ok_or("archive: manifest has no folder")?;
821 for (name, checksum) in files(manifest)? {
822 let from = source.join(&name);
823 let to = directory.join(&name);
824 if to.is_file() && hash(&to)? == checksum {
825 continue;
826 }
827 fs::create_dir_all(to.parent().ok_or("archive: no parent")?).map_err(|e| e.to_string())?;
828 if to.exists() {
829 fs::remove_file(&to).map_err(|e| e.to_string())?;
830 }
831 clone_or_copy(&from, &to).map_err(|e| format!("{}: {e}", to.display()))?;
832 if hash(&to)? != checksum {
833 return Err(format!(
834 "archive queue checksum mismatch for {}",
835 to.display()
836 ));
837 }
838 }
839 write_manifest(&directory.join("manifest.json"), manifest)?;
840 let pending = Pending {
841 manifest: manifest_path.to_path_buf(),
842 folder: folder.to_path_buf(),
843 root: root.to_path_buf(),
844 };
845 fs::write(
846 directory.join("pending.json"),
847 serde_json::to_vec(&pending).map_err(|e| e.to_string())?,
848 )
849 .map_err(|e| e.to_string())
850}
851
852pub fn archive(manifest_path: &Path, no_archive: bool) -> State {
853 let root = archive_root();
854 if let Some(reason) = reason(manifest_path, no_archive) {
855 record_skip(manifest_path, &root, reason);
856 return State::Skipped;
857 }
858 let cache = match crate::ffmpeg::cache() {
859 Ok(cache) => cache,
860 Err(message) => {
861 eprintln!("pfx: archive cache unavailable: {message}");
862 return State::Queued;
863 }
864 };
865 let lock = match lock_path() {
866 Ok(lock) => lock,
867 Err(message) => {
868 eprintln!("pfx: archive lock unavailable: {message}");
869 return State::Queued;
870 }
871 };
872 archive_in(manifest_path, no_archive, &root, &cache, &lock)
873}
874
875fn record_skip(manifest_path: &Path, root: &Path, reason: &str) {
876 let attempt = || -> Result<(), String> {
877 let mut manifest: Value =
878 serde_json::from_slice(&fs::read(manifest_path).map_err(|e| e.to_string())?)
879 .map_err(|e| e.to_string())?;
880 let folder = folder_at(&manifest, root)
881 .ok()
882 .filter(|_| !root.as_os_str().is_empty());
883 manifest["archive"] = match folder {
884 Some(folder) => {
885 json!({"leaf": leaf_name(root, &folder), "state": State::Skipped.name(), "reason": reason})
886 }
887 None => json!({"state": State::Skipped.name(), "reason": reason}),
888 };
889 write_manifest(manifest_path, &manifest)
890 };
891 if let Err(message) = attempt() {
892 eprintln!("pfx: could not record skipped archive: {message}");
893 }
894}
895
896fn archive_in(
897 manifest_path: &Path,
898 no_archive: bool,
899 root: &Path,
900 cache: &Path,
901 lock: &Path,
902) -> State {
903 if no_archive {
904 record_skip(manifest_path, root, "no-archive");
905 return State::Skipped;
906 }
907 let attempt = || -> Result<State, String> {
908 let mut manifest: Value =
909 serde_json::from_slice(&fs::read(manifest_path).map_err(|e| e.to_string())?)
910 .map_err(|e| e.to_string())?;
911 if manifest["made"].is_null() {
912 manifest["made"] = json!(local_made());
913 }
914 let folder = match folder_at(&manifest, root) {
915 Ok(folder) => folder,
916 Err(error) => {
917 manifest["archive"] = json!({"state": State::Refused.name(), "error": error});
918 write_manifest(manifest_path, &manifest)?;
919 eprintln!("pfx: archive refused: {error}");
920 return Ok(State::Refused);
921 }
922 };
923 let source = manifest_path
924 .parent()
925 .ok_or("archive: manifest has no folder")?;
926 let catalog = catalog_for(&manifest, root, &folder, source)?;
927 manifest["archive"]["catalog"] =
928 serde_json::to_value(&catalog).map_err(|e| e.to_string())?;
929 set_state(&mut manifest, root, &folder, State::Queued);
930 write_manifest(manifest_path, &manifest)?;
931 queue(manifest_path, &folder, root, &manifest, cache)?;
932 if !root_online(root) {
933 return Ok(State::Queued);
934 }
935 let entry = queue_path(manifest_path, &folder, cache);
936 match with_lock(lock, || publish(&entry, &folder, root, &manifest)) {
937 Ok((archived, added)) => {
938 write_manifest(manifest_path, &archived)?;
939 fs::remove_dir_all(&entry).map_err(|e| e.to_string())?;
940 Ok(if added {
941 State::Archived
942 } else {
943 State::Skipped
944 })
945 }
946 Err(Fail::Conflict(message)) => {
947 set_failure(&mut manifest, root, &folder, State::Conflict, &message);
948 write_manifest(manifest_path, &manifest)?;
949 fs::remove_dir_all(&entry).map_err(|e| e.to_string())?;
950 eprintln!("pfx: {message}");
951 Ok(State::Conflict)
952 }
953 Err(Fail::Retry(message)) => {
954 set_state(&mut manifest, root, &folder, State::Queued);
955 write_manifest(manifest_path, &manifest)?;
956 eprintln!("pfx: archive queued: {message}");
957 Ok(State::Queued)
958 }
959 }
960 };
961 match attempt() {
962 Ok(state) => state,
963 Err(message) => {
964 eprintln!("pfx: archive queued, but could not save queue: {message}");
965 State::Queued
966 }
967 }
968}
969
970pub struct Import {
971 pub app: String,
972 pub shot: String,
973 pub still: bool,
974 pub version: String,
975 pub files: Vec<PathBuf>,
976}
977
978pub fn import(import: &Import) -> Result<(PathBuf, State), String> {
979 let manifest_path = import_manifest(import, &local_made())?;
980 let state = archive(&manifest_path, false);
981 Ok((manifest_path, state))
982}
983
984fn commit(version: &str) -> bool {
985 version.len() == 40 && version.chars().all(|c| c.is_ascii_hexdigit())
986}
987
988fn import_manifest(import: &Import, made: &str) -> Result<PathBuf, String> {
989 for (what, part) in [("app", &import.app), ("shot", &import.shot)] {
990 if !safe(part) {
991 return Err(format!("the {what} {part:?} is not a safe folder name"));
992 }
993 }
994 if !is_tag(&import.version) && !commit(&import.version) {
995 return Err(format!(
996 "--rev {} is neither a tag vX.Y.Z (or vX.Y.Z-rc.N) nor a full commit: only pinned masters are archived",
997 import.version
998 ));
999 }
1000 let folder = |path: &Path| match path.parent() {
1001 Some(parent) if !parent.as_os_str().is_empty() => parent.to_path_buf(),
1002 _ => PathBuf::from("."),
1003 };
1004 let first = import.files.first().ok_or("no files to import")?;
1005 let source = folder(first);
1006 let kind = if import.still { "still" } else { "clip" };
1007 let mut listed = Vec::new();
1008 let mut master = false;
1009 for path in &import.files {
1010 if folder(path) != source {
1011 return Err(format!(
1012 "every file sits in one folder: {} is not in {}",
1013 path.display(),
1014 source.display()
1015 ));
1016 }
1017 let name = path
1018 .file_name()
1019 .and_then(|name| name.to_str())
1020 .ok_or_else(|| format!("{}: not a file name", path.display()))?;
1021 let bytes = fs::metadata(path)
1022 .map_err(|e| format!("{}: {e}", path.display()))?
1023 .len();
1024 master |= if import.still {
1025 name.ends_with(".master.png")
1026 } else {
1027 name.ends_with(".master.mp4") || name.ends_with(".alpha.master.mov")
1028 };
1029 listed.push(json!({"file": name, "bytes": bytes, "sha256": hash(path)?}));
1030 }
1031 if !master {
1032 let wanted = if import.still {
1033 "<name>.master.png"
1034 } else {
1035 "<name>.master.mp4"
1036 };
1037 return Err(format!("a {kind} import needs {wanted} among its files"));
1038 }
1039 let manifest = json!({
1040 "tool": {"name": TOOL, "version": env!("CARGO_PKG_VERSION")},
1041 "kind": kind,
1042 "product": import.app,
1043 "app": import.app,
1044 "shot": import.shot,
1045 "version": import.version,
1046 "pinned": true,
1047 "made": made,
1048 "imported": true,
1049 "files": listed,
1050 });
1051 let path = source.join(format!("{}.{kind}.import.json", import.shot));
1052 write_manifest(&path, &manifest)?;
1053 Ok(path)
1054}
1055
1056pub fn retry() -> Result<(usize, usize), String> {
1057 retry_in(&crate::ffmpeg::cache()?, &lock_path()?)
1058}
1059
1060pub fn retry_at_startup() -> Result<(usize, usize), String> {
1061 startup_in(|key| std::env::var_os(key), retry)
1062}
1063
1064fn startup_in(
1065 env: impl Fn(&str) -> Option<std::ffi::OsString>,
1066 retry: impl FnOnce() -> Result<(usize, usize), String>,
1067) -> Result<(usize, usize), String> {
1068 if run_reason(&env).is_some() {
1069 return Ok((0, 0));
1070 }
1071 retry()
1072}
1073
1074fn rewrite_local(path: &Path, queued: &Value, manifest: &Value) -> Result<(), String> {
1075 let Ok(bytes) = fs::read(path) else {
1076 return Ok(());
1077 };
1078 let Ok(local) = serde_json::from_slice::<Value>(&bytes) else {
1079 return Ok(());
1080 };
1081 let (Ok(theirs), Ok(ours)) = (instant(&local["made"]), instant(&queued["made"])) else {
1082 return Ok(());
1083 };
1084 if theirs == ours {
1085 write_manifest(path, manifest)?;
1086 }
1087 Ok(())
1088}
1089
1090fn read_json(path: &Path) -> Result<Value, String> {
1091 serde_json::from_slice(&fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?)
1092 .map_err(|e| format!("{}: {e}", path.display()))
1093}
1094
1095fn retry_in(cache: &Path, lock: &Path) -> Result<(usize, usize), String> {
1096 let queue_root = cache.join("archive-queue");
1097 if !queue_root.exists() {
1098 return Ok((0, 0));
1099 }
1100 let mut completed = 0;
1101 let mut pending_count = 0;
1102 for entry in fs::read_dir(&queue_root).map_err(|e| e.to_string())? {
1103 let entry = entry.map_err(|e| e.to_string())?;
1104 if !entry.path().is_dir() {
1105 continue;
1106 }
1107 let result = (|| -> Result<(), Fail> {
1108 let pending: Pending = serde_json::from_slice(
1109 &fs::read(entry.path().join("pending.json")).map_err(|e| e.to_string())?,
1110 )
1111 .map_err(|e| e.to_string())?;
1112 let manifest = read_json(&entry.path().join("manifest.json"))?;
1113 if !root_online(&pending.root) {
1114 return Err(format!("{} is offline", pending.root.display()).into());
1115 }
1116 let (archived, _) = with_lock(lock, || {
1117 publish(&entry.path(), &pending.folder, &pending.root, &manifest)
1118 })?;
1119 Ok(rewrite_local(&pending.manifest, &manifest, &archived)?)
1120 })();
1121 match result {
1122 Ok(()) => {
1123 fs::remove_dir_all(entry.path()).map_err(|e| e.to_string())?;
1124 completed += 1;
1125 }
1126 Err(Fail::Conflict(message)) => {
1127 eprintln!("pfx: archive retry: {message}");
1128 let pending: Pending = serde_json::from_slice(
1129 &fs::read(entry.path().join("pending.json")).map_err(|e| e.to_string())?,
1130 )
1131 .map_err(|e| e.to_string())?;
1132 let manifest = read_json(&entry.path().join("manifest.json"))?;
1133 let mut conflicted = manifest.clone();
1134 set_failure(
1135 &mut conflicted,
1136 &pending.root,
1137 &pending.folder,
1138 State::Conflict,
1139 &message,
1140 );
1141 rewrite_local(&pending.manifest, &manifest, &conflicted)?;
1142 fs::remove_dir_all(entry.path()).map_err(|e| e.to_string())?;
1143 completed += 1;
1144 }
1145 Err(fail) => {
1146 eprintln!("pfx: archive retry: {}", fail.message());
1147 pending_count += 1;
1148 }
1149 }
1150 }
1151 Ok((completed, pending_count))
1152}
1153
1154fn filed(catalog: &mut Catalog, masters: &Path, manifest: &Path) {
1155 let Some(leaf) = manifest
1156 .parent()
1157 .and_then(|leaf| leaf.strip_prefix(masters).ok())
1158 else {
1159 return;
1160 };
1161 if let Some(tool) = leaf.components().next() {
1162 catalog.tool = tool.as_os_str().to_string_lossy().into_owned();
1163 }
1164 catalog.path = leaf.to_string_lossy().into_owned();
1165}
1166
1167fn collect_catalogs(
1168 masters: &Path,
1169 path: &Path,
1170 entries: &mut Vec<(DateTime<FixedOffset>, Catalog)>,
1171) -> Result<(), String> {
1172 for item in fs::read_dir(path).map_err(|e| format!("{}: {e}", path.display()))? {
1173 let item = item.map_err(|e| e.to_string())?;
1174 let item_path = item.path();
1175 if item.file_type().map_err(|e| e.to_string())?.is_dir() {
1176 if !item.file_name().to_string_lossy().ends_with(".partial") {
1177 collect_catalogs(masters, &item_path, entries)?;
1178 }
1179 } else if item.file_name().to_string_lossy() == "manifest.json" {
1180 let manifest = read_json(&item_path)?;
1181 let named = |why: String| format!("{}: {why}", item_path.display());
1182 if let Some(mut catalog) = catalog_of(&manifest).map_err(&named)? {
1183 let made = instant(&json!(catalog.made)).map_err(&named)?;
1184 filed(&mut catalog, masters, &item_path);
1185 entries.push((made, catalog));
1186 }
1187 }
1188 }
1189 Ok(())
1190}
1191
1192pub fn reindex() -> Result<usize, String> {
1193 let root = archive_root();
1194 if root.as_os_str().is_empty() {
1195 return Err("archive: no root; set PITO_ARCHIVE".into());
1196 }
1197 reindex_in(&root, &lock_path()?)
1198}
1199
1200fn reindex_in(root: &Path, lock: &Path) -> Result<usize, String> {
1201 if !root_online(root) {
1202 return Err(format!("archive: {} is offline", root.display()));
1203 }
1204 with_lock(lock, || {
1205 let masters = masters(root);
1206 fs::create_dir_all(&masters).map_err(|e| format!("{}: {e}", masters.display()))?;
1207 let mut entries = Vec::new();
1208 collect_catalogs(&masters, &masters, &mut entries)?;
1209 entries.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.path.cmp(&b.1.path)));
1210 let partial = masters.join("catalog.jsonl.partial");
1211 let mut file = fs::File::create(&partial).map_err(|e| e.to_string())?;
1212 for (_, entry) in &entries {
1213 serde_json::to_writer(&mut file, entry).map_err(|e| e.to_string())?;
1214 file.write_all(b"\n").map_err(|e| e.to_string())?;
1215 }
1216 file.sync_all().map_err(|e| e.to_string())?;
1217 fs::rename(&partial, catalog_file(root)).map_err(|e| e.to_string())?;
1218 Ok(entries.len())
1219 })
1220 .map_err(|fail| fail.message().to_owned())
1221}
1222
1223#[cfg(test)]
1224mod tests {
1225 use super::*;
1226 fn reason_with(
1227 manifest: &Value,
1228 no_archive: bool,
1229 pairs: &[(&str, &str)],
1230 ) -> Option<&'static str> {
1231 let owned: Vec<(String, String)> = pairs
1232 .iter()
1233 .map(|(key, value)| ((*key).to_string(), (*value).to_string()))
1234 .collect();
1235 skip_reason_in(manifest, no_archive, move |key| {
1236 owned
1237 .iter()
1238 .find(|(name, _)| name == key)
1239 .map(|(_, value)| std::ffi::OsString::from(value))
1240 })
1241 }
1242
1243 fn pinned_master(product: &str) -> Value {
1244 json!({
1245 "product": product,
1246 "version": "aabbccddeeff",
1247 "pinned": true,
1248 "files": [{"file": "alpha-clip-a-1280x720-60fps.master.mp4", "sha256": "00"}],
1249 })
1250 }
1251
1252 #[test]
1253 fn only_a_pinned_master_outside_tests_and_rides_is_archived() {
1254 let master = pinned_master("alpha");
1255 let root = ("PITO_ARCHIVE", "/srv/archive");
1256 assert_eq!(reason_with(&master, false, &[root]), None);
1257 assert_eq!(
1258 reason_with(&master, false, &[]),
1259 BUILT_ROOT.map_or(Some("no-root"), |_| None)
1260 );
1261 assert_eq!(reason_with(&master, true, &[root]), Some("no-archive"));
1262 assert_eq!(
1263 reason_with(&master, false, &[("PFX_DIRECT", "1"), root]),
1264 Some("test")
1265 );
1266 assert_eq!(
1267 reason_with(&master, false, &[("PITO_ARCHIVE", "/work/app/tmp/archive")]),
1268 Some("test")
1269 );
1270 assert_eq!(
1271 reason_with(&master, false, &[("RIDE_APP", "beta"), root]),
1272 Some("ride")
1273 );
1274 assert_eq!(reason_with(&master, false, &[("RIDE_APP", ""), root]), None);
1275 assert_eq!(
1276 reason_with(&pinned_master("checker"), false, &[root]),
1277 Some("sample")
1278 );
1279 assert_eq!(
1280 reason_with(&pinned_master("sample"), false, &[root]),
1281 Some("sample")
1282 );
1283 let mut unpinned = master.clone();
1284 unpinned["pinned"] = json!(false);
1285 assert_eq!(reason_with(&unpinned, false, &[root]), Some("unpinned"));
1286 let mut frames = master.clone();
1287 frames["files"] = json!([{"file": "frames/00000.png", "sha256": "00"}]);
1288 assert_eq!(reason_with(&frames, false, &[root]), Some("frames-only"));
1289 }
1290
1291 #[test]
1292 fn an_older_manifest_is_pinned_only_by_a_tag() {
1293 assert!(pinned(&json!({"version": "v1.2.3"})));
1294 assert!(!pinned(&json!({"version": "aabbccddeeff"})));
1295 assert!(!pinned(&json!({"version": "aabbccddeeff-dirty"})));
1296 assert!(!pinned(&json!({"version": "vpreview"})));
1297 assert!(pinned(&json!({"version": "aabbccddeeff", "pinned": true})));
1298 }
1299
1300 #[test]
1301 fn a_pre_release_tag_is_a_tag() {
1302 for tag in ["v1.0.0-rc.1", "v1.0.0-alpha", "v0.27.0-beta.2.x-y"] {
1303 assert!(is_tag(tag), "{tag}");
1304 }
1305 for not in [
1306 "v1.0.0-",
1307 "v1.0.0-rc..1",
1308 "v1.0-rc.1",
1309 "v1.0.0-rc_1",
1310 "1.0.0-rc.1",
1311 ] {
1312 assert!(!is_tag(not), "{not}");
1313 }
1314 }
1315
1316 #[test]
1317 fn verified_partial_is_linked_without_clobbering_an_existing_file() {
1318 let dir = Path::new(env!("CARGO_MANIFEST_DIR"))
1319 .join("../../tmp")
1320 .join(format!("archive-link-test-{}", std::process::id()));
1321 fs::create_dir_all(&dir).unwrap();
1322 let partial = dir.join(".master.mp4.partial");
1323 let final_path = dir.join("master.mp4");
1324 fs::write(&partial, b"first").unwrap();
1325 assert!(place(&partial, &final_path, &hash(&partial).unwrap()).unwrap());
1326 assert!(!partial.exists());
1327 fs::write(&partial, b"second").unwrap();
1328 assert!(place(&partial, &final_path, &hash(&partial).unwrap()).is_err());
1329 assert_eq!(fs::read(&final_path).unwrap(), b"first");
1330 assert!(!partial.exists());
1331 fs::remove_dir_all(dir).unwrap();
1332 }
1333
1334 #[test]
1335 fn leaf_is_staged_before_one_rename() {
1336 let base = Path::new(env!("CARGO_MANIFEST_DIR"))
1337 .join("../../tmp")
1338 .join(format!("archive-stage-test-{}", std::process::id()));
1339 let source = base.join("source");
1340 let folder = base.join("archive/leaf");
1341 fs::create_dir_all(&source).unwrap();
1342 fs::write(source.join("scene.master.mp4"), b"master").unwrap();
1343 let manifest = json!({"files": [{"file": "scene.master.mp4", "sha256": hash(&source.join("scene.master.mp4")).unwrap()}]});
1344 let partial = stage_leaf(&source, &folder, &manifest).unwrap();
1345 assert!(partial.join("scene.master.mp4").exists());
1346 assert!(partial.join("manifest.json").exists());
1347 assert!(!folder.exists());
1348 assert!(finalize_leaf(&partial, &folder, &manifest).unwrap());
1349 assert!(!partial.exists());
1350 assert!(folder.join("manifest.json").exists());
1351 fs::remove_dir_all(base).unwrap();
1352 }
1353
1354 #[test]
1355 fn a_still_archives_its_png_deliveries_beside_its_master() {
1356 let base = Path::new(env!("CARGO_MANIFEST_DIR"))
1357 .join("../../tmp")
1358 .join(format!("archive-still-{}", std::process::id()));
1359 let source = base.join("source");
1360 let root = base.join("archive");
1361 let cache = base.join("cache");
1362 let lock = base.join("runtime/catalog.lock");
1363 fs::create_dir_all(&source).unwrap();
1364 fs::create_dir_all(&root).unwrap();
1365 let names = [
1366 "sample-still-shapes-1920x1080.master.png",
1367 "sample-still-shapes-1920x1080.png",
1368 "sample-still-shapes-960x540.png",
1369 "sample-still-shapes-1920x1080-web-320.png",
1370 "sample-still-shapes-1920x1080-web-320@2x.png",
1371 "sample-still-shapes-1920x1080.linear.exr",
1372 ];
1373 let mut listed = Vec::new();
1374 for (i, name) in names.iter().enumerate() {
1375 let path = source.join(name);
1376 fs::write(&path, format!("bytes {i}")).unwrap();
1377 listed.push(json!({"file": name, "sha256": hash(&path).unwrap()}));
1378 }
1379 let manifest_path = source.join("sample-still-shapes-1920x1080.json");
1380 let manifest = json!({
1381 "product": "neutral", "kind": "still", "shot": "shapes", "version": "v1",
1382 "made": "2026-10-05T19:00:00+02:00",
1383 "files": listed,
1384 });
1385 write_manifest(&manifest_path, &manifest).unwrap();
1386 assert_eq!(
1387 archive_in(&manifest_path, false, &root, &cache, &lock),
1388 State::Archived
1389 );
1390 let leaf = root.join("Masters/pfx/neutral/shapes/2026-10-05_190000_v1_manual");
1391 for name in &names[..5] {
1392 assert_eq!(
1393 hash(&leaf.join(name)).unwrap(),
1394 hash(&source.join(name)).unwrap(),
1395 "{name}"
1396 );
1397 }
1398 assert!(!leaf.join(names[5]).exists());
1399 let catalog: Catalog = serde_json::from_str(
1400 fs::read_to_string(root.join("Masters/catalog.jsonl"))
1401 .unwrap()
1402 .trim(),
1403 )
1404 .unwrap();
1405 let mut caught: Vec<&str> = catalog.files.iter().map(|f| f.name.as_str()).collect();
1406 caught.sort_unstable();
1407 let mut wanted = names[..5].to_vec();
1408 wanted.sort_unstable();
1409 assert_eq!(caught, wanted);
1410 let clip = json!({"files": [
1411 {"file": "c.master.mp4", "sha256": "0"},
1412 {"file": "c.poster.png", "sha256": "0"},
1413 {"file": "c-640.png", "sha256": "0"},
1414 {"file": "c.frames/00000.png", "sha256": "0"}
1415 ]});
1416 let kept: Vec<PathBuf> = files(&clip).unwrap().into_iter().map(|(p, _)| p).collect();
1417 assert_eq!(
1418 kept,
1419 [PathBuf::from("c.master.mp4"), PathBuf::from("c.poster.png")]
1420 );
1421 fs::remove_dir_all(&base).ok();
1422 }
1423
1424 #[test]
1425 fn copies_verifies_skips_conflicts_and_retries() {
1426 let base = Path::new(env!("CARGO_MANIFEST_DIR"))
1427 .join("../../tmp")
1428 .join(format!("archive-test-{}", std::process::id()));
1429 let source = base.join("source");
1430 let root = base.join("archive");
1431 let cache = base.join("cache");
1432 let lock = base.join("runtime/catalog.lock");
1433 fs::create_dir_all(&source).unwrap();
1434 fs::create_dir_all(&root).unwrap();
1435 let master = source.join("clip.master.mp4");
1436 fs::write(&master, b"first master").unwrap();
1437 let manifest_path = source.join("clip.json");
1438 let mut manifest = json!({
1439 "product": "alpha", "kind": "clip", "shot": "checker", "version": "v1",
1440 "made": "2026-10-02T09:14:00+02:00",
1441 "files": [{"file": "clip.master.mp4", "sha256": hash(&master).unwrap()}]
1442 });
1443 write_manifest(&manifest_path, &manifest).unwrap();
1444 assert_eq!(
1445 archive_in(&manifest_path, true, &root, &cache, &lock),
1446 State::Skipped
1447 );
1448 let skipped: Value = serde_json::from_slice(&fs::read(&manifest_path).unwrap()).unwrap();
1449 assert_eq!(skipped["archive"]["state"], "skipped");
1450 assert!(!root.join("Masters").exists());
1451 assert_eq!(
1452 archive_in(&manifest_path, false, &root, &cache, &lock),
1453 State::Archived
1454 );
1455 let target = root.join("Masters/pfx/alpha/checker/2026-10-02_091400_v1_manual");
1456 assert_eq!(
1457 hash(&target.join("clip.master.mp4")).unwrap(),
1458 hash(&master).unwrap()
1459 );
1460 assert!(target.join("manifest.json").exists());
1461 assert!(
1462 !target
1463 .with_file_name("2026-10-02_091400_v1_manual.partial")
1464 .exists()
1465 );
1466 let first_catalog = fs::read(root.join("Masters/catalog.jsonl")).unwrap();
1467 assert_eq!(
1468 first_catalog.iter().filter(|byte| **byte == b'\n').count(),
1469 1
1470 );
1471 assert_eq!(
1472 archive_in(&manifest_path, false, &root, &cache, &lock),
1473 State::Skipped
1474 );
1475 assert_eq!(
1476 fs::read(root.join("Masters/catalog.jsonl")).unwrap(),
1477 first_catalog
1478 );
1479 fs::write(&master, b"different master").unwrap();
1480 manifest["files"][0]["sha256"] = json!(hash(&master).unwrap());
1481 manifest["made"] = json!("2026-10-02T09:14:00.500+02:00");
1482 write_manifest(&manifest_path, &manifest).unwrap();
1483 assert_eq!(
1484 archive_in(&manifest_path, false, &root, &cache, &lock),
1485 State::Archived
1486 );
1487 let second = target.with_file_name("2026-10-02_091400_v1_manual-2");
1488 assert_eq!(
1489 fs::read(second.join("clip.master.mp4")).unwrap(),
1490 b"different master"
1491 );
1492 assert_eq!(
1493 fs::read(target.join("clip.master.mp4")).unwrap(),
1494 b"first master"
1495 );
1496 let both = fs::read_to_string(root.join("Masters/catalog.jsonl")).unwrap();
1497 assert_eq!(both.lines().count(), 2);
1498 assert!(both.starts_with(&String::from_utf8(first_catalog.clone()).unwrap()));
1499 let offline = base.join("offline");
1500 manifest["version"] = json!("v2");
1501 write_manifest(&manifest_path, &manifest).unwrap();
1502 assert_eq!(
1503 archive_in(&manifest_path, false, &offline, &cache, &lock),
1504 State::Queued
1505 );
1506 let queued: Value = serde_json::from_slice(&fs::read(&manifest_path).unwrap()).unwrap();
1507 assert_eq!(queued["archive"]["state"], "queued");
1508 fs::create_dir_all(&offline).unwrap();
1509 assert_eq!(retry_in(&cache, &lock).unwrap(), (1, 0));
1510 let completed: Value = serde_json::from_slice(&fs::read(&manifest_path).unwrap()).unwrap();
1511 assert_eq!(completed["archive"]["state"], "archived");
1512 assert_eq!(
1513 fs::read(
1514 offline
1515 .join("Masters/pfx/alpha/checker/2026-10-02_091400_v2_manual/clip.master.mp4")
1516 )
1517 .unwrap(),
1518 b"different master"
1519 );
1520 let replay = root.join("Masters/prec/gamma/tour/2026-10-02_1015_v1_gamma");
1521 fs::create_dir_all(&replay).unwrap();
1522 let replay_catalog = json!({
1523 "v": 1, "tool": "prec", "product": "gamma", "shot": "tour",
1524 "version": "v1", "caller": "gamma", "made": "2026-10-02T10:15:00+02:00",
1525 "path": "prec/gamma/tour/2026-10-02_1015_v1_gamma",
1526 "files": [{"name": "tour.master.mp4", "bytes": 3, "sha256": "abc"}]
1527 });
1528 write_manifest(
1529 &replay.join("manifest.json"),
1530 &json!({"archive": {"catalog": replay_catalog}}),
1531 )
1532 .unwrap();
1533 let replay_line = format!(
1534 "{}\n",
1535 serde_json::to_string(&serde_json::from_value::<Catalog>(replay_catalog).unwrap())
1536 .unwrap()
1537 );
1538 fs::write(root.join("Masters/catalog.jsonl"), b"stale\n").unwrap();
1539 assert_eq!(reindex_in(&root, &lock).unwrap(), 3);
1540 assert_eq!(
1541 fs::read_to_string(root.join("Masters/catalog.jsonl")).unwrap(),
1542 format!("{both}{replay_line}")
1543 );
1544 fs::remove_dir_all(base).unwrap();
1545 }
1546
1547 struct Fixture {
1548 base: PathBuf,
1549 source: PathBuf,
1550 root: PathBuf,
1551 cache: PathBuf,
1552 lock: PathBuf,
1553 master: PathBuf,
1554 manifest_path: PathBuf,
1555 manifest: Value,
1556 }
1557
1558 impl Fixture {
1559 fn new(label: &str) -> Fixture {
1560 let base = Path::new(env!("CARGO_MANIFEST_DIR"))
1561 .join("../../tmp")
1562 .join(format!("archive-{label}-{}", std::process::id()));
1563 let source = base.join("source");
1564 let root = base.join("archive");
1565 fs::create_dir_all(&source).unwrap();
1566 fs::create_dir_all(&root).unwrap();
1567 let master = source.join("clip.master.mp4");
1568 fs::write(&master, b"first master").unwrap();
1569 let manifest_path = source.join("clip.json");
1570 let manifest = json!({
1571 "product": "alpha", "kind": "clip", "shot": "checker", "version": "v1",
1572 "made": "2026-10-02T09:14:00+02:00",
1573 "files": [{"file": "clip.master.mp4", "sha256": hash(&master).unwrap()}]
1574 });
1575 write_manifest(&manifest_path, &manifest).unwrap();
1576 Fixture {
1577 cache: base.join("cache"),
1578 lock: base.join("runtime/catalog.lock"),
1579 base,
1580 source,
1581 root,
1582 master,
1583 manifest_path,
1584 manifest,
1585 }
1586 }
1587
1588 fn leaf(&self) -> PathBuf {
1589 self.root
1590 .join("Masters/pfx/alpha/checker/2026-10-02_091400_v1_manual")
1591 }
1592
1593 fn entries(&self) -> usize {
1594 fs::read_dir(self.cache.join("archive-queue"))
1595 .map(|items| items.count())
1596 .unwrap_or(0)
1597 }
1598
1599 fn manifest_state(&self) -> String {
1600 let saved: Value =
1601 serde_json::from_slice(&fs::read(&self.manifest_path).unwrap()).unwrap();
1602 saved["archive"]["state"].as_str().unwrap().to_owned()
1603 }
1604
1605 fn queue_now(&self) -> PathBuf {
1606 let mut queued = self.manifest.clone();
1607 let catalog = catalog_for(&queued, &self.root, &self.leaf(), &self.source).unwrap();
1608 queued["archive"]["catalog"] = serde_json::to_value(&catalog).unwrap();
1609 set_state(&mut queued, &self.root, &self.leaf(), State::Queued);
1610 write_manifest(&self.manifest_path, &queued).unwrap();
1611 queue(
1612 &self.manifest_path,
1613 &self.leaf(),
1614 &self.root,
1615 &queued,
1616 &self.cache,
1617 )
1618 .unwrap();
1619 queue_path(&self.manifest_path, &self.leaf(), &self.cache)
1620 }
1621 }
1622
1623 impl Drop for Fixture {
1624 fn drop(&mut self) {
1625 fs::remove_dir_all(&self.base).ok();
1626 }
1627 }
1628
1629 #[test]
1630 fn a_successful_archive_leaves_no_queue_entry() {
1631 let fixture = Fixture::new("queue-clean");
1632 assert_eq!(
1633 archive_in(
1634 &fixture.manifest_path,
1635 false,
1636 &fixture.root,
1637 &fixture.cache,
1638 &fixture.lock
1639 ),
1640 State::Archived
1641 );
1642 assert_eq!(fixture.entries(), 0);
1643 assert_eq!(fixture.manifest_state(), "archived");
1644 assert_eq!(
1645 fs::read(fixture.leaf().join("clip.master.mp4")).unwrap(),
1646 b"first master"
1647 );
1648 }
1649
1650 #[test]
1651 fn a_conflict_removes_the_queue_entry() {
1652 let fixture = Fixture::new("queue-conflict");
1653 let leaf = fixture.leaf();
1654 fs::create_dir_all(&leaf).unwrap();
1655 fs::write(leaf.join("clip.master.mp4"), b"another master").unwrap();
1656 fs::write(leaf.join("manifest.json"), b"{}").unwrap();
1657 assert_eq!(
1658 archive_in(
1659 &fixture.manifest_path,
1660 false,
1661 &fixture.root,
1662 &fixture.cache,
1663 &fixture.lock
1664 ),
1665 State::Conflict
1666 );
1667 assert_eq!(fixture.entries(), 0);
1668 assert_eq!(fixture.manifest_state(), "conflict");
1669 assert_eq!(
1670 fs::read(leaf.join("clip.master.mp4")).unwrap(),
1671 b"another master"
1672 );
1673 }
1674
1675 #[test]
1676 fn a_copy_interrupted_after_queueing_is_finished_by_retry() {
1677 let fixture = Fixture::new("queue-killed");
1678 let entry = fixture.queue_now();
1679 assert!(entry.join("pending.json").is_file());
1680 assert!(!fixture.leaf().exists());
1681 assert_eq!(fixture.manifest_state(), "queued");
1682 assert_eq!(retry_in(&fixture.cache, &fixture.lock).unwrap(), (1, 0));
1683 assert_eq!(fixture.entries(), 0);
1684 assert_eq!(fixture.manifest_state(), "archived");
1685 assert_eq!(
1686 fs::read(fixture.leaf().join("clip.master.mp4")).unwrap(),
1687 b"first master"
1688 );
1689 let catalog = fs::read_to_string(fixture.root.join("Masters/catalog.jsonl")).unwrap();
1690 assert_eq!(catalog.lines().count(), 1);
1691 }
1692
1693 #[test]
1694 fn a_copy_that_fails_stays_queued_until_retry_finishes_it() {
1695 let fixture = Fixture::new("queue-failed");
1696 let blocker = partial_folder(&fixture.leaf()).unwrap();
1697 fs::create_dir_all(blocker.parent().unwrap()).unwrap();
1698 fs::write(&blocker, b"in the way").unwrap();
1699 assert_eq!(
1700 archive_in(
1701 &fixture.manifest_path,
1702 false,
1703 &fixture.root,
1704 &fixture.cache,
1705 &fixture.lock
1706 ),
1707 State::Queued
1708 );
1709 assert_eq!(fixture.entries(), 1);
1710 assert_eq!(fixture.manifest_state(), "queued");
1711 fs::remove_file(&blocker).unwrap();
1712 assert_eq!(retry_in(&fixture.cache, &fixture.lock).unwrap(), (1, 0));
1713 assert_eq!(fixture.entries(), 0);
1714 assert!(fixture.leaf().join("manifest.json").is_file());
1715 }
1716
1717 #[test]
1718 fn a_source_rewritten_after_queueing_does_not_change_the_queued_copy() {
1719 let fixture = Fixture::new("queue-rewrite");
1720 let entry = fixture.queue_now();
1721 fs::write(&fixture.master, b"rewritten in place").unwrap();
1722 assert_eq!(
1723 fs::read(entry.join("clip.master.mp4")).unwrap(),
1724 b"first master"
1725 );
1726 assert_eq!(retry_in(&fixture.cache, &fixture.lock).unwrap(), (1, 0));
1727 assert_eq!(
1728 fs::read(fixture.leaf().join("clip.master.mp4")).unwrap(),
1729 b"first master"
1730 );
1731 }
1732
1733 #[cfg(unix)]
1734 #[test]
1735 fn the_queued_copy_is_never_a_hard_link() {
1736 use std::os::unix::fs::MetadataExt;
1737 let fixture = Fixture::new("queue-links");
1738 let entry = fixture.queue_now();
1739 let queued = fs::metadata(entry.join("clip.master.mp4")).unwrap();
1740 let original = fs::metadata(&fixture.master).unwrap();
1741 assert_eq!(queued.nlink(), 1);
1742 assert_eq!(original.nlink(), 1);
1743 assert_ne!(queued.ino(), original.ino());
1744 }
1745
1746 #[test]
1747 fn leaf_uses_made_offset_and_sanitized_caller() {
1748 let root = Path::new("tmp/archive");
1749 let manifest = json!({"product": "beta", "kind": "still", "shot": "page", "version": "v1.2.3", "made": "2026-10-02T00:30:00+02:00"});
1750 assert_eq!(
1751 folder_with_caller(&manifest, root, Some("PITO Invoker!2")).unwrap(),
1752 root.join("Masters/pfx/beta/page/2026-10-02_003000_v1.2.3_pito-invoker-2")
1753 );
1754 assert_eq!(
1755 folder_with_caller(&manifest, root, None).unwrap(),
1756 root.join("Masters/pfx/beta/page/2026-10-02_003000_v1.2.3_manual")
1757 );
1758 }
1759
1760 #[test]
1761 fn concurrent_catalog_appends_are_whole_lines() {
1762 let base = Path::new(env!("CARGO_MANIFEST_DIR"))
1763 .join("../../tmp")
1764 .join(format!("archive-lock-test-{}", std::process::id()));
1765 fs::create_dir_all(masters(&base)).unwrap();
1766 let lock = base.join("runtime/catalog.lock");
1767 let template = Catalog {
1768 v: 1,
1769 tool: "pfx".into(),
1770 product: "alpha".into(),
1771 shot: "scene".into(),
1772 version: "v1".into(),
1773 caller: "manual".into(),
1774 made: "2026-10-02T09:14:00+02:00".into(),
1775 path: "a".into(),
1776 files: vec![],
1777 };
1778 std::thread::scope(|scope| {
1779 for index in 0..2 {
1780 let mut item = template.clone();
1781 item.path = format!("leaf-{index}");
1782 let root = &base;
1783 let lock = &lock;
1784 scope.spawn(move || {
1785 with_lock(lock, || Ok(append_catalog_unlocked(root, &item)?)).unwrap()
1786 });
1787 }
1788 });
1789 let lines = fs::read_to_string(base.join("Masters/catalog.jsonl")).unwrap();
1790 assert_eq!(lines.lines().count(), 2);
1791 let paths: Vec<String> = lines
1792 .lines()
1793 .map(|line| serde_json::from_str::<Catalog>(line).unwrap().path)
1794 .collect();
1795 assert!(paths.contains(&"leaf-0".into()));
1796 assert!(paths.contains(&"leaf-1".into()));
1797 fs::remove_dir_all(base).unwrap();
1798 }
1799
1800 impl Fixture {
1801 fn archive(&self) -> State {
1802 archive_in(
1803 &self.manifest_path,
1804 false,
1805 &self.root,
1806 &self.cache,
1807 &self.lock,
1808 )
1809 }
1810
1811 fn rerender(&mut self, contents: &[u8], made: &str) {
1812 fs::write(&self.master, contents).unwrap();
1813 self.manifest["made"] = json!(made);
1814 self.manifest["files"][0]["sha256"] = json!(hash(&self.master).unwrap());
1815 write_manifest(&self.manifest_path, &self.manifest).unwrap();
1816 }
1817
1818 fn saved(&self) -> Value {
1819 serde_json::from_slice(&fs::read(&self.manifest_path).unwrap()).unwrap()
1820 }
1821
1822 fn catalog_paths(&self) -> Vec<String> {
1823 fs::read_to_string(self.root.join("Masters/catalog.jsonl"))
1824 .unwrap_or_default()
1825 .lines()
1826 .map(|line| serde_json::from_str::<Catalog>(line).unwrap().path)
1827 .collect()
1828 }
1829 }
1830
1831 fn leaf_manifest(catalog: Value, extra: Value) -> Value {
1832 let mut manifest = json!({"archive": {"catalog": catalog}});
1833 for (key, value) in extra.as_object().unwrap() {
1834 manifest["archive"][key] = value.clone();
1835 }
1836 manifest
1837 }
1838
1839 fn line(tool: &str, made: &str, path: &str) -> Value {
1840 json!({
1841 "v": 1, "tool": tool, "product": "beta", "shot": "tour", "version": "v1",
1842 "caller": "gamma", "made": made, "path": path,
1843 "files": [{"name": "tour.master.mp4", "bytes": 3, "sha256": "abc"}]
1844 })
1845 }
1846
1847 fn put_leaf(root: &Path, path: &str, manifest: &Value) {
1848 let leaf = masters(root).join(path);
1849 fs::create_dir_all(&leaf).unwrap();
1850 write_manifest(&leaf.join("manifest.json"), manifest).unwrap();
1851 }
1852
1853 #[test]
1854 fn reindex_reads_both_tools_and_both_shapes_sorted_by_instant_then_path() {
1855 let fixture = Fixture::new("reindex-mix");
1856 let root = &fixture.root;
1857 let early = line(
1858 "prec",
1859 "2026-10-02T10:00:00+05:00",
1860 "prec/beta/tour/2026-10-02_1000_v1_gamma",
1861 );
1862 let late_a = line(
1863 "pfx",
1864 "2026-10-02T09:00:00+00:00",
1865 "pfx/beta/tour/2026-10-02_090000_v1_gamma",
1866 );
1867 let late_b = line(
1868 "prec",
1869 "2026-10-02T09:00:00+00:00",
1870 "prec/beta/tour/2026-10-02_090000_v1_gamma",
1871 );
1872 let newest = line(
1873 "pito-engine",
1874 "2026-10-03T08:15:00.123456789+02:00",
1875 "pito-engine/beta/tour/2026-10-03_0815_v1_gamma",
1876 );
1877 let string = |value: &Value| json!(serde_json::to_string(value).unwrap());
1878 put_leaf(
1879 root,
1880 "prec/beta/tour/2026-10-02_1000_v1_gamma",
1881 &leaf_manifest(string(&early), json!({"leaf": "x"})),
1882 );
1883 put_leaf(
1884 root,
1885 "prec/beta/tour/2026-10-02_090000_v1_gamma",
1886 &leaf_manifest(string(&late_b), json!({"folder": "x"})),
1887 );
1888 put_leaf(
1889 root,
1890 "pfx/beta/tour/2026-10-02_090000_v1_gamma",
1891 &leaf_manifest(late_a.clone(), json!({"leaf": "x"})),
1892 );
1893 put_leaf(
1894 root,
1895 "pfx/beta/tour/2026-10-03_0815_v1_gamma",
1896 &leaf_manifest(newest, json!({"folder": "/old/absolute/x"})),
1897 );
1898 put_leaf(
1899 root,
1900 "pfx/beta/tour/skipped",
1901 &json!({"archive": {"state": "skipped", "reason": "unpinned"}}),
1902 );
1903 fs::create_dir_all(root.join("Masters/pfx/beta/tour/half.partial")).unwrap();
1904 fs::write(
1905 root.join("Masters/pfx/beta/tour/half.partial/manifest.json"),
1906 b"not json",
1907 )
1908 .unwrap();
1909 assert_eq!(reindex_in(root, &fixture.lock).unwrap(), 4);
1910 let order: Vec<(String, String)> = fs::read_to_string(root.join("Masters/catalog.jsonl"))
1911 .unwrap()
1912 .lines()
1913 .map(|line| {
1914 let catalog = serde_json::from_str::<Catalog>(line).unwrap();
1915 (catalog.tool, catalog.path)
1916 })
1917 .collect();
1918 assert_eq!(
1919 order,
1920 [
1921 ("prec", "prec/beta/tour/2026-10-02_1000_v1_gamma"),
1922 ("pfx", "pfx/beta/tour/2026-10-02_090000_v1_gamma"),
1923 ("prec", "prec/beta/tour/2026-10-02_090000_v1_gamma"),
1924 ("pfx", "pfx/beta/tour/2026-10-03_0815_v1_gamma"),
1925 ]
1926 .map(|(tool, path)| (tool.to_owned(), path.to_owned()))
1927 );
1928 let first = fs::read_to_string(root.join("Masters/catalog.jsonl")).unwrap();
1929 assert_eq!(reindex_in(root, &fixture.lock).unwrap(), 4);
1930 assert_eq!(
1931 fs::read_to_string(root.join("Masters/catalog.jsonl")).unwrap(),
1932 first
1933 );
1934 }
1935
1936 #[test]
1937 fn an_unreadable_manifest_fails_the_reindex_by_name() {
1938 let fixture = Fixture::new("reindex-bad");
1939 let good = line("pfx", "2026-10-02T09:00:00+00:00", "pfx/beta/tour/ok");
1940 put_leaf(
1941 &fixture.root,
1942 "pfx/beta/tour/ok",
1943 &leaf_manifest(good, json!({})),
1944 );
1945 fs::write(fixture.root.join("Masters/catalog.jsonl"), b"kept\n").unwrap();
1946 let broken = fixture.root.join("Masters/prec/beta/tour/broken");
1947 fs::create_dir_all(&broken).unwrap();
1948 fs::write(broken.join("manifest.json"), b"{ not json").unwrap();
1949 let error = reindex_in(&fixture.root, &fixture.lock).unwrap_err();
1950 assert!(
1951 error.contains("prec/beta/tour/broken/manifest.json"),
1952 "{error}"
1953 );
1954 assert_eq!(
1955 fs::read(fixture.root.join("Masters/catalog.jsonl")).unwrap(),
1956 b"kept\n"
1957 );
1958 fs::write(
1959 broken.join("manifest.json"),
1960 b"{\"archive\": {\"catalog\": 7}}",
1961 )
1962 .unwrap();
1963 let error = reindex_in(&fixture.root, &fixture.lock).unwrap_err();
1964 assert!(error.contains("broken/manifest.json"), "{error}");
1965 }
1966
1967 #[test]
1968 fn a_catalog_is_read_as_an_object_or_as_a_string() {
1969 let entry = line("pfx", "2026-10-02T09:00:00+00:00", "a/b/c/d");
1970 let object = json!({"archive": {"catalog": entry.clone()}});
1971 let string = json!({"archive": {"catalog": serde_json::to_string(&entry).unwrap()}});
1972 assert_eq!(
1973 catalog_of(&object).unwrap().unwrap().path,
1974 catalog_of(&string).unwrap().unwrap().path
1975 );
1976 assert!(catalog_of(&json!({})).unwrap().is_none());
1977 }
1978
1979 #[test]
1980 fn two_renders_in_the_same_second_get_their_own_leaves() {
1981 let mut fixture = Fixture::new("same-second");
1982 fixture.rerender(b"first master", "2026-10-02T09:14:00.100+02:00");
1983 assert_eq!(fixture.archive(), State::Archived);
1984 fixture.rerender(b"second master", "2026-10-02T09:14:00.900+02:00");
1985 assert_eq!(fixture.archive(), State::Archived);
1986 fixture.rerender(b"third master", "2026-10-02T09:14:00.950+02:00");
1987 assert_eq!(fixture.archive(), State::Archived);
1988 let base = "pfx/alpha/checker/2026-10-02_091400_v1_manual";
1989 assert_eq!(
1990 fixture.catalog_paths(),
1991 [base.to_string(), format!("{base}-2"), format!("{base}-3")]
1992 );
1993 for (suffix, contents) in [
1994 ("", &b"first master"[..]),
1995 ("-2", b"second master"),
1996 ("-3", b"third master"),
1997 ] {
1998 let leaf = masters(&fixture.root).join(format!("{base}{suffix}"));
1999 assert_eq!(fs::read(leaf.join("clip.master.mp4")).unwrap(), contents);
2000 }
2001 let saved = fixture.saved();
2002 assert_eq!(saved["archive"]["leaf"], format!("{base}-3"));
2003 assert_eq!(saved["archive"]["catalog"]["path"], format!("{base}-3"));
2004 assert_eq!(saved["archive"]["catalog"]["caller"], "manual");
2005 assert_eq!(saved["archive"]["state"], "archived");
2006 assert!(saved["archive"]["folder"].is_null());
2007 let leaf_manifest: Value = serde_json::from_slice(
2008 &fs::read(masters(&fixture.root).join(format!("{base}-3/manifest.json"))).unwrap(),
2009 )
2010 .unwrap();
2011 assert_eq!(leaf_manifest, saved);
2012 assert_eq!(fixture.entries(), 0);
2013 }
2014
2015 #[test]
2016 fn a_free_gap_is_taken_before_a_higher_suffix() {
2017 let mut fixture = Fixture::new("gap");
2018 fixture.rerender(b"first master", "2026-10-02T09:14:00.100+02:00");
2019 assert_eq!(fixture.archive(), State::Archived);
2020 fixture.rerender(b"second master", "2026-10-02T09:14:00.200+02:00");
2021 assert_eq!(fixture.archive(), State::Archived);
2022 fixture.rerender(b"third master", "2026-10-02T09:14:00.300+02:00");
2023 assert_eq!(fixture.archive(), State::Archived);
2024 let base = fixture.leaf();
2025 fs::remove_dir_all(base.with_file_name("2026-10-02_091400_v1_manual-2")).unwrap();
2026 fixture.rerender(b"fourth master", "2026-10-02T09:14:00.400+02:00");
2027 assert_eq!(fixture.archive(), State::Archived);
2028 assert!(
2029 fixture.saved()["archive"]["leaf"]
2030 .as_str()
2031 .unwrap()
2032 .ends_with("_manual-2")
2033 );
2034 }
2035
2036 #[test]
2037 fn a_retry_of_an_archived_render_leaves_it_alone() {
2038 let fixture = Fixture::new("retry-archived");
2039 assert_eq!(fixture.archive(), State::Archived);
2040 let catalog = fs::read(fixture.root.join("Masters/catalog.jsonl")).unwrap();
2041 let leaf_manifest = fs::read(fixture.leaf().join("manifest.json")).unwrap();
2042 fixture.queue_now();
2043 assert_eq!(retry_in(&fixture.cache, &fixture.lock).unwrap(), (1, 0));
2044 assert_eq!(fixture.entries(), 0);
2045 assert_eq!(
2046 fs::read(fixture.root.join("Masters/catalog.jsonl")).unwrap(),
2047 catalog
2048 );
2049 assert_eq!(
2050 fs::read(fixture.leaf().join("manifest.json")).unwrap(),
2051 leaf_manifest
2052 );
2053 assert!(
2054 !fixture
2055 .leaf()
2056 .with_file_name("2026-10-02_091400_v1_manual-2")
2057 .exists()
2058 );
2059 assert_eq!(fixture.manifest_state(), "archived");
2060 }
2061
2062 #[test]
2063 fn a_retry_of_a_render_missing_from_the_catalog_adds_its_line_once() {
2064 let fixture = Fixture::new("retry-catalog");
2065 assert_eq!(fixture.archive(), State::Archived);
2066 fs::remove_file(fixture.root.join("Masters/catalog.jsonl")).unwrap();
2067 fixture.queue_now();
2068 assert_eq!(retry_in(&fixture.cache, &fixture.lock).unwrap(), (1, 0));
2069 assert_eq!(fixture.catalog_paths().len(), 1);
2070 }
2071
2072 #[test]
2073 fn a_retry_never_rewrites_a_newer_local_manifest() {
2074 let mut fixture = Fixture::new("retry-newer");
2075 fixture.queue_now();
2076 fixture.rerender(b"newer master", "2026-10-02T09:20:00+02:00");
2077 let newer = fs::read(&fixture.manifest_path).unwrap();
2078 assert_eq!(retry_in(&fixture.cache, &fixture.lock).unwrap(), (1, 0));
2079 assert_eq!(fs::read(&fixture.manifest_path).unwrap(), newer);
2080 assert_eq!(fixture.entries(), 0);
2081 assert_eq!(
2082 fs::read(fixture.leaf().join("clip.master.mp4")).unwrap(),
2083 b"first master"
2084 );
2085 assert_eq!(fixture.catalog_paths().len(), 1);
2086 }
2087
2088 #[test]
2089 fn a_retry_rewrites_the_local_manifest_of_its_own_render() {
2090 let fixture = Fixture::new("retry-own");
2091 fixture.queue_now();
2092 assert_eq!(fixture.manifest_state(), "queued");
2093 assert_eq!(retry_in(&fixture.cache, &fixture.lock).unwrap(), (1, 0));
2094 assert_eq!(fixture.manifest_state(), "archived");
2095 assert_eq!(
2096 fixture.saved()["archive"]["leaf"],
2097 "pfx/alpha/checker/2026-10-02_091400_v1_manual"
2098 );
2099 }
2100
2101 #[test]
2102 fn a_test_or_ride_run_does_not_retry_the_queue_at_startup() {
2103 let fixture = Fixture::new("startup");
2104 fixture.queue_now();
2105 let run = |pairs: &'static [(&'static str, &'static str)]| {
2106 startup_in(
2107 move |key| {
2108 pairs
2109 .iter()
2110 .find(|(name, _)| *name == key)
2111 .map(|(_, value)| std::ffi::OsString::from(value))
2112 },
2113 || retry_in(&fixture.cache, &fixture.lock),
2114 )
2115 .unwrap()
2116 };
2117 for pairs in [
2118 &[("PFX_DIRECT", "1")][..],
2119 &[("PITO_ARCHIVE", "/work/app/tmp/archive")],
2120 &[("RIDE_APP", "alpha")],
2121 ] {
2122 assert_eq!(run(pairs), (0, 0));
2123 assert_eq!(fixture.entries(), 1);
2124 assert!(!fixture.leaf().exists());
2125 }
2126 assert_eq!(run(&[("PITO_ARCHIVE", "/srv/archive")]), (1, 0));
2127 assert_eq!(fixture.entries(), 0);
2128 assert!(fixture.leaf().join("manifest.json").is_file());
2129 }
2130
2131 #[test]
2132 fn an_older_hhmm_leaf_is_read_and_left_alone() {
2133 let fixture = Fixture::new("old-leaf");
2134 let old = fixture
2135 .root
2136 .join("Masters/pfx/alpha/checker/2026-10-02_0914_v1_manual");
2137 fs::create_dir_all(&old).unwrap();
2138 fs::write(old.join("clip.master.mp4"), b"first master").unwrap();
2139 let entry = catalog_for(&fixture.manifest, &fixture.root, &old, &fixture.source).unwrap();
2140 let mut stored = fixture.manifest.clone();
2141 stored["archive"] = json!({
2142 "folder": old,
2143 "state": "archived",
2144 "catalog": serde_json::to_string(&entry).unwrap(),
2145 });
2146 write_manifest(&old.join("manifest.json"), &stored).unwrap();
2147 assert_eq!(reindex_in(&fixture.root, &fixture.lock).unwrap(), 1);
2148 assert_eq!(
2149 fixture.catalog_paths(),
2150 ["pfx/alpha/checker/2026-10-02_0914_v1_manual"]
2151 );
2152 assert_eq!(fixture.archive(), State::Archived);
2153 assert_eq!(fixture.catalog_paths().len(), 2);
2154 assert_eq!(fixture.entries(), 0);
2155 assert!(old.join("clip.master.mp4").is_file());
2156 }
2157
2158 #[test]
2159 fn a_leaf_that_cannot_be_checked_is_a_conflict() {
2160 let fixture = Fixture::new("leaf-checks");
2161 assert_eq!(fixture.archive(), State::Archived);
2162 fs::write(fixture.leaf().join("clip.master.mp4"), b"damaged").unwrap();
2163 fixture.queue_now();
2164 assert_eq!(retry_in(&fixture.cache, &fixture.lock).unwrap(), (1, 0));
2165 assert_eq!(fixture.manifest_state(), "conflict");
2166 assert_eq!(fixture.catalog_paths().len(), 1);
2167 }
2168
2169 #[test]
2170 fn an_unsafe_name_is_refused() {
2171 let root = Path::new("tmp/archive");
2172 let made = "2026-10-02T09:14:00+02:00";
2173 for (product, shot, version, caller) in [
2174 ("alpha", "checker", "release/1.0", "manual"),
2175 ("..", "checker", "v1", "manual"),
2176 ("alpha", "", "v1", "manual"),
2177 ("alpha", "checker", "v1", "a b"),
2178 ("stu/dio", "checker", "v1", "manual"),
2179 ("alpha", ".", "v1", "manual"),
2180 ] {
2181 assert!(leaf_under(root, product, shot, version, made, caller).is_err());
2182 }
2183 for (key, value) in [
2184 ("product", "../alpha"),
2185 ("shot", "a b"),
2186 ("version", "release/1.0"),
2187 ] {
2188 let mut fixture = Fixture::new("refused");
2189 fixture.manifest[key] = json!(value);
2190 write_manifest(&fixture.manifest_path, &fixture.manifest).unwrap();
2191 assert_eq!(fixture.archive(), State::Refused);
2192 let saved = fixture.saved();
2193 assert_eq!(saved["archive"]["state"], "refused");
2194 assert!(saved["archive"]["leaf"].is_null());
2195 assert!(saved["archive"]["error"].as_str().unwrap().contains(value));
2196 assert!(!fixture.root.join("Masters").exists());
2197 assert_eq!(fixture.entries(), 0);
2198 }
2199 }
2200
2201 #[test]
2202 fn a_transient_read_error_leaves_the_render_queued_and_a_retry_archives_it() {
2203 let fixture = Fixture::new("transient");
2204 assert_eq!(fixture.archive(), State::Archived);
2205 let stored = fixture.leaf().join("manifest.json");
2206 let aside = fixture.base.join("manifest.aside");
2207 fs::rename(&stored, &aside).unwrap();
2208 fs::create_dir(&stored).unwrap();
2209 assert_eq!(fixture.archive(), State::Queued);
2210 assert_eq!(fixture.manifest_state(), "queued");
2211 assert_eq!(fixture.entries(), 1);
2212 fs::remove_dir(&stored).unwrap();
2213 fs::rename(&aside, &stored).unwrap();
2214 assert_eq!(retry_in(&fixture.cache, &fixture.lock).unwrap(), (1, 0));
2215 assert_eq!(fixture.manifest_state(), "archived");
2216 assert_eq!(fixture.entries(), 0);
2217 assert_eq!(fixture.catalog_paths().len(), 1);
2218 let siblings = fixture.leaf().parent().unwrap().to_path_buf();
2219 assert_eq!(fs::read_dir(siblings).unwrap().count(), 1);
2220 }
2221
2222 #[test]
2223 fn a_read_error_on_a_retry_keeps_the_entry_pending() {
2224 let fixture = Fixture::new("transient-retry");
2225 assert_eq!(fixture.archive(), State::Archived);
2226 let stored = fixture.leaf().join("manifest.json");
2227 let aside = fixture.base.join("manifest.aside");
2228 fs::rename(&stored, &aside).unwrap();
2229 fs::create_dir(&stored).unwrap();
2230 fixture.queue_now();
2231 assert_eq!(retry_in(&fixture.cache, &fixture.lock).unwrap(), (0, 1));
2232 assert_eq!(fixture.entries(), 1);
2233 assert_eq!(fixture.manifest_state(), "queued");
2234 fs::remove_dir(&stored).unwrap();
2235 fs::rename(&aside, &stored).unwrap();
2236 assert_eq!(retry_in(&fixture.cache, &fixture.lock).unwrap(), (1, 0));
2237 assert_eq!(fixture.manifest_state(), "archived");
2238 }
2239
2240 #[test]
2241 fn a_real_sha256_mismatch_is_still_a_conflict() {
2242 let fixture = Fixture::new("mismatch");
2243 assert_eq!(fixture.archive(), State::Archived);
2244 fs::write(fixture.leaf().join("clip.master.mp4"), b"damaged").unwrap();
2245 assert_eq!(fixture.archive(), State::Conflict);
2246 assert_eq!(fixture.manifest_state(), "conflict");
2247 assert!(
2248 fixture.saved()["archive"]["error"]
2249 .as_str()
2250 .unwrap()
2251 .contains("clip.master.mp4")
2252 );
2253 assert_eq!(fixture.entries(), 0);
2254 assert_eq!(
2255 fs::read(fixture.leaf().join("clip.master.mp4")).unwrap(),
2256 b"damaged"
2257 );
2258 }
2259
2260 #[test]
2261 fn a_leaf_manifest_that_cannot_be_this_render_is_a_conflict() {
2262 let fixture = Fixture::new("foreign-manifest");
2263 let leaf = fixture.leaf();
2264 fs::create_dir_all(&leaf).unwrap();
2265 fs::write(leaf.join("manifest.json"), b"not json").unwrap();
2266 assert_eq!(fixture.archive(), State::Conflict);
2267 assert_eq!(fixture.entries(), 0);
2268 assert_eq!(fixture.catalog_paths().len(), 0);
2269 }
2270
2271 #[test]
2272 fn an_imported_master_lands_in_the_app_s_leaf_with_its_manifest_and_catalog_line() {
2273 let base = Path::new(env!("CARGO_MANIFEST_DIR"))
2274 .join("../../tmp")
2275 .join(format!("archive-import-{}", std::process::id()));
2276 let source = base.join("source");
2277 let root = base.join("archive");
2278 fs::create_dir_all(&source).unwrap();
2279 fs::create_dir_all(masters(&root)).unwrap();
2280 let master = source.join("desk-3840x2160.master.png");
2281 let web = source.join("desk-1920x1080.png");
2282 fs::write(&master, b"master").unwrap();
2283 fs::write(&web, b"web").unwrap();
2284 let import = |version: &str, still: bool, files: Vec<PathBuf>| Import {
2285 app: "splicer".into(),
2286 shot: "desk".into(),
2287 still,
2288 version: version.into(),
2289 files,
2290 };
2291 let made = "2026-10-12T05:00:00+02:00";
2292 let refused = |import: Import| import_manifest(&import, made).unwrap_err();
2293 assert!(refused(import("aabbccddeeff", true, vec![master.clone()])).contains("pinned"));
2294 assert!(refused(import("v1.2.3", true, vec![web.clone()])).contains(".master.png"));
2295 assert!(refused(import("v1.2.3", false, vec![master.clone()])).contains(".master.mp4"));
2296 let elsewhere = base.join("other.master.png");
2297 fs::write(&elsewhere, b"other").unwrap();
2298 assert!(
2299 refused(import("v1.2.3", true, vec![master.clone(), elsewhere])).contains("one folder")
2300 );
2301 let manifest_path = import_manifest(
2302 &import("v1.2.3", true, vec![master.clone(), web.clone()]),
2303 made,
2304 )
2305 .unwrap();
2306 let state = archive_in(
2307 &manifest_path,
2308 false,
2309 &root,
2310 &base.join("cache"),
2311 &base.join("runtime/catalog.lock"),
2312 );
2313 assert_eq!(state, State::Archived);
2314 let leaf = masters(&root).join("pfx/splicer/desk/2026-10-12_050000_v1.2.3_manual");
2315 for name in [
2316 "manifest.json",
2317 "desk-3840x2160.master.png",
2318 "desk-1920x1080.png",
2319 ] {
2320 assert!(leaf.join(name).is_file(), "{name}");
2321 }
2322 let catalog = fs::read_to_string(catalog_file(&root)).unwrap();
2323 let line: Catalog = serde_json::from_str(catalog.trim()).unwrap();
2324 assert_eq!(
2325 (
2326 line.product.as_str(),
2327 line.shot.as_str(),
2328 line.version.as_str(),
2329 line.files.len()
2330 ),
2331 ("splicer", "desk", "v1.2.3", 2)
2332 );
2333 let _ = fs::remove_dir_all(&base);
2334 }
2335}