1use std::collections::BTreeMap;
2use std::fs;
3use std::io;
4use std::path::{Component, Path, PathBuf};
5use std::time::{Duration, SystemTime};
6
7use rusqlite::{Connection, OpenFlags};
8use serde_json::json;
9
10const ROOT_KEYED_COPY_DISK_FLOOR_NUMERATOR: u64 = 3;
11const ROOT_KEYED_COPY_DISK_FLOOR_DENOMINATOR: u64 = 2;
12const SQLITE_SUFFIXES: [&str; 4] = [".sqlite-wal", ".sqlite-shm", ".sqlite-journal", ".sqlite"];
13
14#[derive(Clone, Copy, Debug, Eq, PartialEq)]
15pub enum LegacyPartitionKind {
16 Callgraph,
17 Inspect,
18}
19
20impl LegacyPartitionKind {
21 pub fn as_str(self) -> &'static str {
22 match self {
23 Self::Callgraph => "callgraph",
24 Self::Inspect => "inspect",
25 }
26 }
27}
28
29#[derive(Clone, Debug, Eq, PartialEq)]
30pub struct LegacyPartitionInventoryEntry {
31 pub harness: String,
32 pub kind: LegacyPartitionKind,
33 pub key: String,
34 pub path: PathBuf,
37 pub bytes: u64,
38 pub callgraph_pointer_mtime: Option<SystemTime>,
39 pub inspect_tier2_last_full_run: Option<i64>,
40}
41
42#[derive(Clone, Debug, Eq, PartialEq)]
43pub struct LegacyHarnessDuplication {
44 pub harness: String,
45 pub partitions: usize,
46 pub bytes: u64,
47}
48
49#[derive(Clone, Copy, Debug, Eq, PartialEq)]
50pub struct DiskFloorDecision {
51 pub source_bytes: u64,
52 pub available_bytes: u64,
53 pub required_bytes: u64,
54}
55
56impl DiskFloorDecision {
57 pub fn allows_copy(self) -> bool {
58 self.available_bytes >= self.required_bytes
59 }
60
61 pub fn should_skip_copy(self) -> bool {
62 !self.allows_copy()
63 }
64
65 pub fn warning_message(self, source: &Path, target: &Path) -> String {
66 format!(
67 "Skipping root-keyed cache copy from {} into {}: free disk ({}) is below the required 1.5× floor ({} for {} source bytes).",
68 source.display(),
69 target.display(),
70 self.available_bytes,
71 self.required_bytes,
72 self.source_bytes
73 )
74 }
75
76 pub fn configure_warning(self, source: &Path, target: &Path) -> serde_json::Value {
77 json!({
78 "kind": "root_keyed_disk_floor",
79 "source_path": source.display().to_string(),
80 "target_path": target.display().to_string(),
81 "bytes_source": self.source_bytes,
82 "bytes_free": self.available_bytes,
83 "bytes_required": self.required_bytes,
84 "message": self.warning_message(source, target),
85 })
86 }
87}
88
89pub fn required_root_keyed_copy_free_bytes(source_bytes: u64) -> u64 {
90 source_bytes
91 .saturating_mul(ROOT_KEYED_COPY_DISK_FLOOR_NUMERATOR)
92 .saturating_add(ROOT_KEYED_COPY_DISK_FLOOR_DENOMINATOR - 1)
93 / ROOT_KEYED_COPY_DISK_FLOOR_DENOMINATOR
94}
95
96pub fn evaluate_root_keyed_copy_disk_floor(
97 source_bytes: u64,
98 available_bytes: u64,
99) -> DiskFloorDecision {
100 DiskFloorDecision {
101 source_bytes,
102 available_bytes,
103 required_bytes: required_root_keyed_copy_free_bytes(source_bytes),
104 }
105}
106
107pub fn available_disk_for(path: &Path) -> io::Result<u64> {
111 #[cfg(unix)]
112 {
113 use std::ffi::CString;
114 use std::os::unix::ffi::OsStrExt;
115
116 let probe = existing_ancestor(path);
117 let c_path = CString::new(probe.as_os_str().as_bytes())
118 .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path contains NUL byte"))?;
119 let mut stat = std::mem::MaybeUninit::<libc::statvfs>::uninit();
120 let result = unsafe { libc::statvfs(c_path.as_ptr(), stat.as_mut_ptr()) };
121 if result != 0 {
122 return Err(io::Error::last_os_error());
123 }
124 let stat = unsafe { stat.assume_init() };
125 Ok((stat.f_bavail as u64).saturating_mul(stat.f_frsize as u64))
126 }
127
128 #[cfg(windows)]
129 {
130 let _ = path;
131 Ok(u64::MAX)
135 }
136}
137
138pub fn is_legacy_harness_partition_path(storage_root: &Path, candidate: &Path) -> bool {
141 let storage_root = lexical_normalize(storage_root);
142 let candidate = if candidate.is_absolute() {
143 lexical_normalize(candidate)
144 } else {
145 lexical_normalize(&storage_root.join(candidate))
146 };
147
148 let Ok(relative) = candidate.strip_prefix(&storage_root) else {
149 return false;
150 };
151
152 let mut components = relative.components();
153 let Some(Component::Normal(_harness)) = components.next() else {
154 return false;
155 };
156 let Some(Component::Normal(domain)) = components.next() else {
157 return false;
158 };
159
160 matches!(domain.to_str(), Some("callgraph" | "inspect"))
161}
162
163#[track_caller]
164pub fn debug_assert_not_legacy_harness_partition_path(storage_root: &Path, candidate: &Path) {
165 debug_assert!(
166 !is_legacy_harness_partition_path(storage_root, candidate),
167 "new-layout write path must not point into a legacy harness partition: {}",
168 candidate.display()
169 );
170}
171
172pub fn refuse_legacy_partition_write(
173 storage_root: &Path,
174 candidate: &Path,
175 operation: &str,
176) -> io::Result<()> {
177 if is_legacy_harness_partition_path(storage_root, candidate) {
178 return Err(io::Error::new(
179 io::ErrorKind::PermissionDenied,
180 format!(
181 "refusing {operation} into legacy harness partition {}",
182 candidate.display()
183 ),
184 ));
185 }
186 Ok(())
187}
188
189#[track_caller]
190pub fn guard_new_layout_write_path(
191 storage_root: &Path,
192 candidate: &Path,
193 operation: &str,
194) -> io::Result<()> {
195 debug_assert_not_legacy_harness_partition_path(storage_root, candidate);
196 refuse_legacy_partition_write(storage_root, candidate, operation)
197}
198
199pub fn inventory_legacy_partitions(
200 storage_root: &Path,
201) -> io::Result<Vec<LegacyPartitionInventoryEntry>> {
202 let storage_root = lexical_normalize(storage_root);
203 let mut entries = Vec::new();
204 for harness_entry in sorted_read_dir(&storage_root)? {
205 if !harness_entry.file_type()?.is_dir() {
206 continue;
207 }
208 let harness = harness_entry.file_name().to_string_lossy().to_string();
209 let harness_path = harness_entry.path();
210 entries.extend(scan_legacy_callgraph_partitions(
211 &harness,
212 &harness_path.join(LegacyPartitionKind::Callgraph.as_str()),
213 )?);
214 entries.extend(scan_legacy_inspect_partitions(
215 &harness,
216 &harness_path.join(LegacyPartitionKind::Inspect.as_str()),
217 )?);
218 }
219 entries.sort_by(|left, right| {
220 left.harness
221 .cmp(&right.harness)
222 .then_with(|| left.kind.as_str().cmp(right.kind.as_str()))
223 .then_with(|| left.key.cmp(&right.key))
224 });
225 Ok(entries)
226}
227
228pub fn summarize_legacy_partition_duplication(
229 storage_root: &Path,
230) -> io::Result<Vec<LegacyHarnessDuplication>> {
231 let mut summaries = BTreeMap::<String, LegacyHarnessDuplication>::new();
232 for entry in inventory_legacy_partitions(storage_root)? {
233 let summary =
234 summaries
235 .entry(entry.harness.clone())
236 .or_insert_with(|| LegacyHarnessDuplication {
237 harness: entry.harness.clone(),
238 partitions: 0,
239 bytes: 0,
240 });
241 summary.partitions += 1;
242 summary.bytes = summary.bytes.saturating_add(entry.bytes);
243 }
244 Ok(summaries.into_values().collect())
245}
246
247#[derive(Clone, Debug, Default)]
248struct PartitionAccumulator {
249 bytes: u64,
250 callgraph_pointer_mtime: Option<SystemTime>,
251 inspect_tier2_last_full_run: Option<i64>,
252}
253
254fn scan_legacy_callgraph_partitions(
255 harness: &str,
256 callgraph_dir: &Path,
257) -> io::Result<Vec<LegacyPartitionInventoryEntry>> {
258 let mut partitions = BTreeMap::<String, PartitionAccumulator>::new();
259 for entry in sorted_read_dir(callgraph_dir)? {
260 let file_type = entry.file_type()?;
261 let name = entry.file_name().to_string_lossy().to_string();
262 if file_type.is_dir() {
263 if !looks_like_partition_key(&name) {
264 continue;
265 }
266 let partition = partitions.entry(name.clone()).or_default();
267 partition.bytes = partition.bytes.saturating_add(tree_size(&entry.path())?);
268 if partition.callgraph_pointer_mtime.is_none() {
269 partition.callgraph_pointer_mtime = callgraph_pointer_mtime(callgraph_dir, &name);
270 }
271 continue;
272 }
273
274 let Some(key) = callgraph_partition_key_from_name(&name) else {
275 continue;
276 };
277 let partition = partitions.entry(key.clone()).or_default();
278 partition.bytes = partition.bytes.saturating_add(file_size(&entry.path())?);
279 if name.ends_with(".current") {
280 partition.callgraph_pointer_mtime =
281 entry.metadata().and_then(|meta| meta.modified()).ok();
282 }
283 }
284
285 Ok(partitions
286 .into_iter()
287 .map(|(key, partition)| LegacyPartitionInventoryEntry {
288 harness: harness.to_string(),
289 kind: LegacyPartitionKind::Callgraph,
290 path: callgraph_dir.join(&key),
291 key,
292 bytes: partition.bytes,
293 callgraph_pointer_mtime: partition.callgraph_pointer_mtime,
294 inspect_tier2_last_full_run: None,
295 })
296 .collect())
297}
298
299fn scan_legacy_inspect_partitions(
300 harness: &str,
301 inspect_dir: &Path,
302) -> io::Result<Vec<LegacyPartitionInventoryEntry>> {
303 let mut partitions = BTreeMap::<String, PartitionAccumulator>::new();
304 for entry in sorted_read_dir(inspect_dir)? {
305 let file_type = entry.file_type()?;
306 let name = entry.file_name().to_string_lossy().to_string();
307 if file_type.is_dir() {
308 if !looks_like_partition_key(&name) {
309 continue;
310 }
311 let partition = partitions.entry(name.clone()).or_default();
312 partition.bytes = partition.bytes.saturating_add(tree_size(&entry.path())?);
313 if partition.inspect_tier2_last_full_run.is_none() {
314 partition.inspect_tier2_last_full_run =
315 inspect_tier2_last_full_run(inspect_dir, &name);
316 }
317 continue;
318 }
319
320 let Some(key) = inspect_partition_key_from_name(&name) else {
321 continue;
322 };
323 let partition = partitions.entry(key.clone()).or_default();
324 partition.bytes = partition.bytes.saturating_add(file_size(&entry.path())?);
325 }
326
327 Ok(partitions
328 .into_iter()
329 .map(|(key, mut partition)| {
330 if partition.inspect_tier2_last_full_run.is_none() {
331 partition.inspect_tier2_last_full_run =
332 inspect_tier2_last_full_run(inspect_dir, &key);
333 }
334 LegacyPartitionInventoryEntry {
335 harness: harness.to_string(),
336 kind: LegacyPartitionKind::Inspect,
337 path: inspect_dir.join(&key),
338 key,
339 bytes: partition.bytes,
340 callgraph_pointer_mtime: None,
341 inspect_tier2_last_full_run: partition.inspect_tier2_last_full_run,
342 }
343 })
344 .collect())
345}
346
347fn callgraph_pointer_mtime(callgraph_dir: &Path, key: &str) -> Option<SystemTime> {
348 for candidate in [
349 callgraph_dir.join(format!("{key}.current")),
350 callgraph_dir.join(key).join(format!("{key}.current")),
351 ] {
352 if let Ok(modified) = fs::metadata(candidate).and_then(|metadata| metadata.modified()) {
353 return Some(modified);
354 }
355 }
356 None
357}
358
359fn inspect_tier2_last_full_run(inspect_dir: &Path, key: &str) -> Option<i64> {
360 let sqlite_path = [
361 inspect_dir.join(format!("{key}.sqlite")),
362 inspect_dir.join(key).join(format!("{key}.sqlite")),
363 ]
364 .into_iter()
365 .find(|candidate| candidate.is_file())?;
366
367 let conn = Connection::open_with_flags(&sqlite_path, OpenFlags::SQLITE_OPEN_READ_ONLY).ok()?;
368 conn.busy_timeout(Duration::from_millis(500)).ok()?;
369 conn.query_row("SELECT MAX(last_full_run) FROM tier2_meta", [], |row| {
370 row.get::<_, Option<i64>>(0)
371 })
372 .ok()
373 .flatten()
374}
375
376fn callgraph_partition_key_from_name(name: &str) -> Option<String> {
377 if name.contains(".tmp.") {
378 return None;
379 }
380 if let Some(key) = name.strip_suffix(".current") {
381 return looks_like_partition_key(key).then(|| key.to_string());
382 }
383 let base = sqliteish_base_name(name)?;
384 let key = if let Some((candidate, generation)) = base.split_once(".g") {
385 if generation.is_empty() {
386 return None;
387 }
388 candidate
389 } else {
390 base
391 };
392 looks_like_partition_key(key).then(|| key.to_string())
393}
394
395fn inspect_partition_key_from_name(name: &str) -> Option<String> {
396 if name.contains(".tmp.") {
397 return None;
398 }
399 let base = sqliteish_base_name(name)?;
400 looks_like_partition_key(base).then(|| base.to_string())
401}
402
403fn sqliteish_base_name(name: &str) -> Option<&str> {
404 SQLITE_SUFFIXES
405 .iter()
406 .find_map(|suffix| name.strip_suffix(suffix))
407}
408
409fn looks_like_partition_key(value: &str) -> bool {
410 value.len() == 16 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
411}
412
413fn file_size(path: &Path) -> io::Result<u64> {
414 Ok(fs::metadata(path)?.len())
415}
416
417fn tree_size(path: &Path) -> io::Result<u64> {
418 if !path.exists() {
419 return Ok(0);
420 }
421 let metadata = fs::metadata(path)?;
422 if metadata.is_file() {
423 return Ok(metadata.len());
424 }
425 if !metadata.is_dir() {
426 return Ok(0);
427 }
428
429 let mut total = 0_u64;
430 for entry in fs::read_dir(path)? {
431 total = total.saturating_add(tree_size(&entry?.path())?);
432 }
433 Ok(total)
434}
435
436fn sorted_read_dir(path: &Path) -> io::Result<Vec<fs::DirEntry>> {
437 let entries = match fs::read_dir(path) {
438 Ok(entries) => entries,
439 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
440 Err(error) => return Err(error),
441 };
442 let mut entries = entries.collect::<io::Result<Vec<_>>>()?;
443 entries.sort_by_key(|entry| entry.file_name());
444 Ok(entries)
445}
446
447fn lexical_normalize(path: &Path) -> PathBuf {
448 let mut normalized = PathBuf::new();
449 for component in path.components() {
450 match component {
451 Component::ParentDir => {
452 if !normalized.pop() {
453 normalized.push(component);
454 }
455 }
456 Component::CurDir => {}
457 other => normalized.push(other.as_os_str()),
458 }
459 }
460 normalized
461}
462
463#[cfg(unix)]
464fn existing_ancestor(path: &Path) -> &Path {
465 let mut current = path;
466 while !current.exists() {
467 if let Some(parent) = current.parent() {
468 current = parent;
469 } else {
470 break;
471 }
472 }
473 current
474}
475
476#[cfg(test)]
477mod tests {
478 use super::*;
479 use filetime::FileTime;
480 use rusqlite::params;
481 use std::panic::catch_unwind;
482 use std::time::UNIX_EPOCH;
483 use tempfile::TempDir;
484
485 #[test]
486 fn legacy_partition_guard_matches_exact_domains() {
487 let storage_root = PathBuf::from("/tmp/aft-storage");
488 assert!(is_legacy_harness_partition_path(
489 &storage_root,
490 &storage_root.join("opencode/callgraph/0123456789abcdef.current")
491 ));
492 assert!(is_legacy_harness_partition_path(
493 &storage_root,
494 &storage_root.join("pi/inspect/0123456789abcdef.sqlite")
495 ));
496 assert!(!is_legacy_harness_partition_path(
497 &storage_root,
498 &storage_root.join("opencode/callgraph-old/0123456789abcdef.sqlite")
499 ));
500 assert!(!is_legacy_harness_partition_path(
501 &storage_root,
502 &storage_root.join("index/0123456789abcdef.sqlite")
503 ));
504 assert!(!is_legacy_harness_partition_path(
505 &storage_root,
506 Path::new("/elsewhere/opencode/callgraph/0123456789abcdef.sqlite")
507 ));
508 }
509
510 #[test]
511 fn debug_assert_and_refusal_cover_legacy_write_paths() {
512 let storage_root = PathBuf::from("/tmp/aft-storage");
513 let legacy_target = storage_root.join("opencode/callgraph/0123456789abcdef.sqlite");
514 let panic = catch_unwind(|| {
515 debug_assert_not_legacy_harness_partition_path(&storage_root, &legacy_target);
516 });
517 assert!(panic.is_err());
518
519 let error = refuse_legacy_partition_write(&storage_root, &legacy_target, "publish")
520 .expect_err("legacy write must be refused");
521 assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
522 assert!(error
523 .to_string()
524 .contains("refusing publish into legacy harness partition"));
525 }
526
527 #[test]
528 fn root_keyed_copy_disk_floor_boundaries() {
529 let exact = evaluate_root_keyed_copy_disk_floor(20, 30);
530 assert_eq!(exact.required_bytes, 30);
531 assert!(exact.allows_copy());
532 assert!(!exact.should_skip_copy());
533
534 let below = evaluate_root_keyed_copy_disk_floor(20, 29);
535 assert!(below.should_skip_copy());
536 assert!(below
537 .warning_message(Path::new("/legacy"), Path::new("/shared"))
538 .contains("1.5× floor"));
539
540 let above = evaluate_root_keyed_copy_disk_floor(20, 31);
541 assert!(above.allows_copy());
542 assert_eq!(
543 below.configure_warning(Path::new("/legacy"), Path::new("/shared"))["kind"],
544 "root_keyed_disk_floor"
545 );
546 }
547
548 #[test]
549 fn inventory_fixture_reports_partition_sizes_and_freshness() {
550 let fixture = write_legacy_inventory_fixture();
551 let storage_root = fixture.temp.path();
552
553 let inventory = inventory_legacy_partitions(storage_root).expect("inventory");
554 assert_eq!(inventory.len(), 2);
555
556 let callgraph = inventory
557 .iter()
558 .find(|entry| entry.kind == LegacyPartitionKind::Callgraph)
559 .expect("callgraph entry");
560 let expected_callgraph_bytes = partition_bytes_on_disk(
561 &storage_root.join("opencode/callgraph"),
562 "0123456789abcdef",
563 callgraph_partition_key_from_name,
564 );
565 assert_eq!(callgraph.harness, "opencode");
566 assert_eq!(callgraph.key, "0123456789abcdef");
567 assert_eq!(
568 callgraph.path,
569 storage_root.join("opencode/callgraph/0123456789abcdef")
570 );
571 assert_eq!(callgraph.bytes, expected_callgraph_bytes);
572 let pointer_secs = callgraph
573 .callgraph_pointer_mtime
574 .expect("pointer mtime")
575 .duration_since(UNIX_EPOCH)
576 .expect("mtime after epoch")
577 .as_secs();
578 assert_eq!(pointer_secs, 1_750_000_000);
579 assert_eq!(callgraph.inspect_tier2_last_full_run, None);
580
581 let inspect = inventory
582 .iter()
583 .find(|entry| entry.kind == LegacyPartitionKind::Inspect)
584 .expect("inspect entry");
585 let minimum_inspect_bytes =
586 file_size(&storage_root.join("pi/inspect/fedcba9876543210.sqlite"))
587 .expect("inspect sqlite size");
588 assert_eq!(inspect.harness, "pi");
589 assert_eq!(inspect.key, "fedcba9876543210");
590 assert_eq!(
591 inspect.path,
592 storage_root.join("pi/inspect/fedcba9876543210")
593 );
594 assert!(inspect.bytes >= minimum_inspect_bytes);
595 assert_eq!(inspect.callgraph_pointer_mtime, None);
596 assert_eq!(inspect.inspect_tier2_last_full_run, Some(250));
597
598 let summary = summarize_legacy_partition_duplication(storage_root).expect("summary");
599 assert_eq!(
600 summary,
601 vec![
602 LegacyHarnessDuplication {
603 harness: "opencode".to_string(),
604 partitions: 1,
605 bytes: expected_callgraph_bytes,
606 },
607 LegacyHarnessDuplication {
608 harness: "pi".to_string(),
609 partitions: 1,
610 bytes: inspect.bytes,
611 },
612 ]
613 );
614 }
615
616 struct LegacyInventoryFixture {
617 temp: TempDir,
618 }
619
620 fn write_legacy_inventory_fixture() -> LegacyInventoryFixture {
621 let temp = tempfile::tempdir().expect("tempdir");
622
623 let callgraph_dir = temp.path().join("opencode/callgraph");
624 fs::create_dir_all(&callgraph_dir).expect("create callgraph dir");
625 fs::write(
626 callgraph_dir.join("0123456789abcdef.current"),
627 b"0123456789abcdef.g1.1.sqlite\n",
628 )
629 .expect("write pointer");
630 fs::write(
631 callgraph_dir.join("0123456789abcdef.g1.1.sqlite"),
632 b"callgraph-db",
633 )
634 .expect("write generation db");
635 fs::write(
636 callgraph_dir.join("0123456789abcdef.g1.1.sqlite-wal"),
637 b"wal",
638 )
639 .expect("write generation wal");
640 fs::write(
641 callgraph_dir.join("0123456789abcdef.current.tmp.123"),
642 b"ignored-temp",
643 )
644 .expect("write ignored temp");
645 filetime::set_file_mtime(
646 callgraph_dir.join("0123456789abcdef.current"),
647 FileTime::from_unix_time(1_750_000_000, 0),
648 )
649 .expect("set pointer mtime");
650
651 let inspect_dir = temp.path().join("pi/inspect");
652 fs::create_dir_all(&inspect_dir).expect("create inspect dir");
653 let sqlite_path = inspect_dir.join("fedcba9876543210.sqlite");
654 let conn = Connection::open(&sqlite_path).expect("open inspect db");
655 conn.execute(
656 "CREATE TABLE tier2_meta (category TEXT NOT NULL, project_key TEXT NOT NULL, last_full_run INTEGER NOT NULL)",
657 [],
658 )
659 .expect("create tier2_meta");
660 conn.execute(
661 "INSERT INTO tier2_meta (category, project_key, last_full_run) VALUES (?1, ?2, ?3)",
662 params!["dead_code", "fedcba9876543210", 100_i64],
663 )
664 .expect("insert first tier2 row");
665 conn.execute(
666 "INSERT INTO tier2_meta (category, project_key, last_full_run) VALUES (?1, ?2, ?3)",
667 params!["duplicates", "fedcba9876543210", 250_i64],
668 )
669 .expect("insert second tier2 row");
670 drop(conn);
671 fs::write(inspect_dir.join("misc.txt"), b"ignored").expect("write ignored file");
672
673 LegacyInventoryFixture { temp }
674 }
675
676 fn partition_bytes_on_disk(
677 domain_dir: &Path,
678 expected_key: &str,
679 key_fn: fn(&str) -> Option<String>,
680 ) -> u64 {
681 sorted_read_dir(domain_dir)
682 .expect("read partition dir")
683 .into_iter()
684 .filter_map(|entry| {
685 let name = entry.file_name().to_string_lossy().to_string();
686 let key = key_fn(&name)?;
687 if key != expected_key {
688 return None;
689 }
690 Some(file_size(&entry.path()).expect("partition file size"))
691 })
692 .sum()
693 }
694}