diff --git a/crates/tracedecay-maintenance/src/retention/footprint.rs b/crates/tracedecay-maintenance/src/retention/footprint.rs index 7c6d4ead0c..6d586d3621 100644 --- a/crates/tracedecay-maintenance/src/retention/footprint.rs +++ b/crates/tracedecay-maintenance/src/retention/footprint.rs @@ -576,6 +576,26 @@ fn floor_note(unavailable_entry_count: usize, truncated: bool) -> String { } } +/// Only an enrolled project has a first-party destructive reclaim path. +/// An unregistered directory or unrelated profile family must never be sent +/// through `projects forget`; it only accepts registered identities. +fn registered_project_reclaim_hint(entry: &ProfileFootprintEntryV1) -> Option { + if entry.family != ProfileFamilyV1::ProjectStore || entry.bytes <= entry.bound_bytes { + return None; + } + entry.project.as_ref()?.registered_root.as_ref()?; + let id = entry.key.strip_prefix("projects/").filter(|id| { + id.starts_with("proj_") + && id.len() > "proj_".len() + && id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') + })?; + Some(format!( + "; if retention stalls, LAST RESORT (DELETES project data): `tracedecay --dry-run projects forget {id}` then `tracedecay --yes projects forget {id}`" + )) +} + fn entry_statement(entry: &ProfileFootprintEntryV1) -> String { let label = match entry.family { ProfileFamilyV1::ProjectStore => "project store", @@ -609,10 +629,40 @@ fn entry_statement(entry: &ProfileFootprintEntryV1) -> String { format_bytes(kinds.graph_replay_pool) ) }); + let reclaim = registered_project_reclaim_hint(entry).unwrap_or_default(); + let floor = floor_note(entry.unavailable_entry_count, false); + let render = |root: &str, reclaim: &str| { + format!("{label} {}{reclaim}{root}{breakdown}{floor}", entry.key) + }; + let fits = |subject: &str| { + let statement = footprint_statement(subject, entry.bytes, entry.bound_bytes); + super::diagnostics::bounded_statement(&statement) == statement + }; + let detailed = render(&root, &reclaim); + if reclaim.is_empty() || fits(&detailed) { + return detailed; + } + // Budget the complete rendered finding, including both commands, the + // deletion warning, and the measured overage. A root-length heuristic + // does not account for selector length, UTF-8, or the other evidence. + let compact = render( + " (registered root omitted from bounded diagnostic)", + &reclaim, + ); + if fits(&compact) { + compact + } else { + // Never turn a long identity into a truncated destructive command. + // Keep the pressure evidence but omit an action that cannot fit whole. + render("", "") + } +} + +fn footprint_statement(subject: &str, bytes: u64, bound_bytes: u64) -> String { format!( - "{label} {}{root}{breakdown}{}", - entry.key, - floor_note(entry.unavailable_entry_count, false) + "{subject}: {} of {} bound", + format_bytes(bytes), + format_bytes(bound_bytes) ) } @@ -639,11 +689,7 @@ fn bounded_finding( DoctorCoverageCompletenessV1::Complete, ) }; - let statement = format!( - "{subject}: {} of {} bound", - format_bytes(bytes), - format_bytes(bound_bytes) - ); + let statement = footprint_statement(subject, bytes, bound_bytes); let reference = DoctorEvidenceReferenceV1::new(key.to_owned()).ok()?; let evidence = DoctorEvidenceRefV1::new(DoctorFindingFamilyV1::Storage, reference); let coverage = DoctorCoverageStatementV1::new( @@ -997,6 +1043,163 @@ mod tests { ); } + #[test] + fn overbound_project_doctor_names_a_supported_destructive_reclaim_only_for_registered_stores() { + let fixture = profile_fixture(); + let overrides = BTreeMap::from([ + ("projects/proj_live".to_owned(), 1024), + ("projects/proj_stray".to_owned(), 1), + ]); + let mut over = census_profile_footprint(&fixture.profile, &fixture.registered, &overrides); + let over_findings = findings(&over); + let live = &over_findings["projects/proj_live"]; + assert_eq!(live.finding().state(), DoctorEvidenceStateV1::Degraded); + let statement = live.finding().coverage().statement(); + assert!( + statement.contains("tracedecay --dry-run projects forget proj_live"), + "{statement}" + ); + assert!( + statement.contains("tracedecay --yes projects forget proj_live"), + "{statement}" + ); + assert!(statement.contains("DELETES project data"), "{statement}"); + + let unregistered = &over_findings["projects/proj_stray"]; + assert_eq!( + unregistered.finding().state(), + DoctorEvidenceStateV1::Degraded + ); + assert!( + !unregistered + .finding() + .coverage() + .statement() + .contains("projects forget"), + "an unregistered directory has no safe project selector" + ); + assert!( + !over_findings["profile"] + .finding() + .coverage() + .statement() + .contains("projects forget"), + "a profile total does not identify a reclaimable project" + ); + + let long_root = "nested-repository/".repeat(70); + let registered = over + .entries + .iter_mut() + .find(|entry| entry.key == "projects/proj_live") + .expect("registered footprint"); + registered + .project + .as_mut() + .expect("registered project") + .registered_root = Some(long_root); + let bounded = findings(&over); + let bounded_statement = bounded["projects/proj_live"] + .finding() + .coverage() + .statement(); + assert!( + bounded_statement.contains("tracedecay --dry-run projects forget proj_live"), + "{bounded_statement}" + ); + assert!( + bounded_statement.contains(" of 1.0 KB bound"), + "over-budget bytes and limit must survive a long repository path: {bounded_statement}" + ); + assert!(bounded_statement.contains("registered root omitted")); + + let normal = + census_profile_footprint(&fixture.profile, &fixture.registered, &BTreeMap::new()); + let healthy = findings(&normal); + assert_eq!( + healthy["projects/proj_live"].finding().state(), + DoctorEvidenceStateV1::HealthyCompleteCoverage + ); + assert!( + !healthy["projects/proj_live"] + .finding() + .coverage() + .statement() + .contains("projects forget"), + "within-budget stores must not be offered destructive recovery" + ); + } + + #[test] + fn reclaim_guidance_omits_unusable_project_selectors() { + let fixture = profile_fixture(); + for id in [ + "proj_".to_owned(), + "proj_bad;echo".to_owned(), + "proj_bad`id".to_owned(), + format!("proj_{}", "a".repeat(160)), + ] { + let key = format!("projects/{id}"); + let store = fixture.profile.join(&key).join("tracedecay.db"); + sized(&store, 2048); + let registered = BTreeMap::from([(id, fixture.live_root.clone())]); + let overrides = BTreeMap::from([(key.clone(), 1)]); + let footprint = census_profile_footprint(&fixture.profile, ®istered, &overrides); + let observed = findings(&footprint); + let finding = &observed[&key]; + assert_eq!(finding.finding().state(), DoctorEvidenceStateV1::Degraded); + let statement = finding.finding().coverage().statement(); + assert!( + !statement.contains("projects forget"), + "unsafe or incomplete actions must be omitted: {statement}" + ); + assert!(statement.contains(" of 1 B bound"), "{statement}"); + assert_eq!( + std::fs::metadata(&store).expect("unchanged store").len(), + 2048 + ); + } + } + + #[test] + fn reclaim_guidance_preserves_real_identity_commands_and_large_store_evidence() { + let temporary = tempfile::tempdir().expect("large footprint fixture"); + let profile = temporary.path().join("profile"); + let id = "proj_0123456789abcdef"; + let key = format!("projects/{id}"); + let store = profile.join(&key).join("tracedecay.db"); + // Sparse metadata-only input: no allocation or read of the 15 GiB body. + sized(&store, 15 * GIB); + let registered = BTreeMap::from([(id.to_owned(), "界/".repeat(100))]); + let footprint = census_profile_footprint(&profile, ®istered, &BTreeMap::new()); + let observed = findings(&footprint); + let finding = &observed[&key]; + assert_eq!(finding.finding().state(), DoctorEvidenceStateV1::Degraded); + let statement = finding.finding().coverage().statement(); + assert!( + statement.contains(&format!("`tracedecay --dry-run projects forget {id}`")), + "{statement}" + ); + assert!( + statement.contains(&format!("`tracedecay --yes projects forget {id}`")), + "{statement}" + ); + assert!(statement.contains("DELETES project data"), "{statement}"); + assert!(statement.contains("registered root omitted"), "{statement}"); + assert!( + statement.ends_with(&format!( + "{} of {} bound", + format_bytes(15 * GIB), + format_bytes(default_bound_bytes(ProfileFamilyV1::ProjectStore)) + )), + "{statement}" + ); + assert_eq!( + std::fs::metadata(&store).expect("unchanged store").len(), + 15 * GIB + ); + } + #[test] fn an_owner_override_replaces_the_default_bound() { let fixture = profile_fixture();