From 6df3d872d27af038aca681c396a0967977c89e1a Mon Sep 17 00:00:00 2001 From: JeremyDev87 Date: Thu, 16 Jul 2026 00:59:57 +0900 Subject: [PATCH 01/11] =?UTF-8?q?fix:=20clean=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?=EC=95=88=EC=A0=84=EC=84=B1=20=EB=B0=8F=20=EC=98=A4=ED=83=90=20?= =?UTF-8?q?=EB=B0=A9=EC=A7=80=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SHA-256 분석 스냅샷·파일/부모 identity를 결속하고 quarantine 재검증과 fail-closed apply를 적용합니다. schema v3 계약·CLI/native smoke·framework false-positive 회귀를 함께 갱신합니다. Co-authored-by: Hermes --- Cargo.lock | 79 +++ crates/kratos-cli/Cargo.toml | 3 + crates/kratos-cli/src/commands/clean.rs | 73 +- .../kratos-cli/tests/clean_threshold_cli.rs | 109 ++- crates/kratos-cli/tests/cli_smoke.rs | 21 +- crates/kratos-core/Cargo.toml | 1 + crates/kratos-core/src/analyze.rs | 88 ++- crates/kratos-core/src/clean.rs | 643 +++++++++++++++++- crates/kratos-core/src/clean_preview.rs | 22 +- crates/kratos-core/src/fingerprint.rs | 187 +++++ crates/kratos-core/src/lib.rs | 1 + crates/kratos-core/src/model.rs | 29 +- crates/kratos-core/src/report.rs | 100 ++- crates/kratos-core/src/report_contract.rs | 16 +- crates/kratos-core/src/report_diff.rs | 5 +- crates/kratos-core/tests/analyze_demo_app.rs | 2 +- crates/kratos-core/tests/clean_preview.rs | 49 +- crates/kratos-core/tests/clean_safety.rs | 303 ++++++++- crates/kratos-core/tests/clean_thresholds.rs | 24 +- .../kratos-core/tests/config_and_discovery.rs | 29 +- crates/kratos-core/tests/report_diff.rs | 88 ++- crates/kratos-core/tests/report_format.rs | 8 +- crates/kratos-core/tests/report_v2.rs | 121 +++- docs/plans/v1-cli-report-contract.md | 25 +- test/package-smoke.test.js | 43 +- 25 files changed, 1907 insertions(+), 162 deletions(-) create mode 100644 crates/kratos-core/src/fingerprint.rs diff --git a/Cargo.lock b/Cargo.lock index 61c3b98..c1d3edc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -79,6 +79,15 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "castaway" version = "0.2.4" @@ -169,6 +178,25 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "417bef24afe1460300965a25ff4a24b8b45ad011948302ec221e8a0a81eb2c79" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "ctor" version = "0.2.9" @@ -179,6 +207,16 @@ dependencies = [ "syn", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dragonbox_ecma" version = "0.1.12" @@ -197,6 +235,16 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "hashbrown" version = "0.17.0" @@ -241,6 +289,7 @@ dependencies = [ "clap", "kratos-core", "serde_json", + "sha2", ] [[package]] @@ -254,6 +303,7 @@ dependencies = [ "oxc_span", "oxc_syntax", "serde_json", + "sha2", ] [[package]] @@ -267,6 +317,12 @@ dependencies = [ "napi-derive", ] +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + [[package]] name = "libloading" version = "0.8.9" @@ -777,6 +833,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "siphasher" version = "1.0.2" @@ -843,6 +910,12 @@ dependencies = [ "syn", ] +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-id-start" version = "1.4.0" @@ -879,6 +952,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "windows-link" version = "0.2.1" diff --git a/crates/kratos-cli/Cargo.toml b/crates/kratos-cli/Cargo.toml index dbe36a3..7154271 100644 --- a/crates/kratos-cli/Cargo.toml +++ b/crates/kratos-cli/Cargo.toml @@ -12,3 +12,6 @@ description = "Rust CLI surface for Kratos." clap = { version = "4.5.41", features = ["derive"] } kratos-core = { path = "../kratos-core" } serde_json = { version = "1", features = ["preserve_order"] } + +[dev-dependencies] +sha2 = "0.10" diff --git a/crates/kratos-cli/src/commands/clean.rs b/crates/kratos-cli/src/commands/clean.rs index c26f5e1..52f34cc 100644 --- a/crates/kratos-cli/src/commands/clean.rs +++ b/crates/kratos-cli/src/commands/clean.rs @@ -2,7 +2,7 @@ use std::fs; use std::io::Write; use std::path::Path; -use kratos_core::clean::clean_from_report_with_min_confidence; +use kratos_core::clean::{clean_from_report_with_min_confidence, CleanSafetyStatus}; use kratos_core::clean_preview::{build_clean_preview, CleanPreviewItem, CleanPreviewPlan}; use kratos_core::config::load_clean_min_confidence; use kratos_core::model::DeletionCandidateFinding; @@ -50,14 +50,25 @@ pub fn run(args: &[String], stdout: &mut dyn Write) -> KratosResult { } let outcome = clean_from_report_with_min_confidence(&report, min_confidence)?; - write_output( - stdout, - &format!( - "Kratos clean: 파일 {}개를 삭제했습니다.\n건너뛴 파일: {}", - outcome.deleted_files, outcome.skipped_files - ), - )?; - Ok(0) + let mut output = format!( + "Kratos clean: 파일 {}개를 삭제했습니다.\n건너뛴 파일: {}\n실패한 파일: {}", + outcome.deleted_files, + outcome.skipped_files, + outcome.failed_files.len() + ); + for failure in &outcome.failed_files { + output.push_str(&format!( + "\n- {}: {}", + relative_path(&failure.file, &report.root), + failure.error + )); + } + write_output(stdout, &output)?; + Ok(if outcome.failed_files.is_empty() { + 0 + } else { + 1 + }) } fn parse_args(args: &[String]) -> KratosResult { @@ -136,13 +147,33 @@ fn format_clean_preview_plan(plan: &CleanPreviewPlan, report_root: &Path) -> Str let mut lines = vec![ "Kratos clean 미리보기입니다.".to_string(), String::new(), - format!("삭제 대상: {}", plan.items.len()), + format!("삭제 대상: {}", plan.deletion_target_paths.len()), ]; - for item in &plan.items { + for item in plan + .items + .iter() + .filter(|item| item.safety_status == CleanSafetyStatus::Ready) + { lines.extend(format_preview_item(item)); } + let safety_skipped = plan + .items + .iter() + .filter(|item| item.safety_status != CleanSafetyStatus::Ready) + .collect::>(); + if !safety_skipped.is_empty() { + lines.push(String::new()); + lines.push(format!( + "안전 검증으로 건너뛴 대상: {}", + safety_skipped.len() + )); + for item in safety_skipped { + lines.extend(format_preview_item(item)); + } + } + if !plan.threshold_skipped_targets.is_empty() { lines.push(String::new()); lines.push(format!( @@ -178,6 +209,10 @@ fn format_preview_item(item: &CleanPreviewItem) -> Vec { format!("- {}", item.relative_path), format!(" 신뢰도: {:.2}", item.confidence), format!(" 사유: {}", display_known_reason(&item.reason)), + format!( + " 안전 상태: {}", + display_safety_status(&item.safety_status) + ), format!(" 상태: {exists_state}"), " 미리보기:".to_string(), ]; @@ -195,6 +230,22 @@ fn format_preview_item(item: &CleanPreviewItem) -> Vec { lines } +fn display_safety_status(status: &CleanSafetyStatus) -> &'static str { + match status { + CleanSafetyStatus::Ready => "검증됨", + CleanSafetyStatus::PathOutsideRoot => "프로젝트 루트 밖 경로", + CleanSafetyStatus::DuplicateCandidate => "삭제 후보 경로 중복", + CleanSafetyStatus::UnsafeFlag => "safe=false", + CleanSafetyStatus::UnsupportedFingerprintAlgorithm => "지원하지 않는 fingerprint 알고리즘", + CleanSafetyStatus::MissingFingerprint => "fingerprint 없음", + CleanSafetyStatus::MissingIdentity => "파일 identity 없음", + CleanSafetyStatus::DuplicateFingerprint => "fingerprint 중복", + CleanSafetyStatus::FingerprintUnavailable => "현재 fingerprint 확인 불가", + CleanSafetyStatus::FingerprintMismatch => "스캔 후 파일 변경됨", + CleanSafetyStatus::IdentityMismatch => "스캔 후 파일 변경됨", + } +} + fn format_candidate_line(candidate: &DeletionCandidateFinding, report_root: &Path) -> String { format!( "- {} (신뢰도 {:.2}, {})", diff --git a/crates/kratos-cli/tests/clean_threshold_cli.rs b/crates/kratos-cli/tests/clean_threshold_cli.rs index 33b529b..26e46d5 100644 --- a/crates/kratos-cli/tests/clean_threshold_cli.rs +++ b/crates/kratos-cli/tests/clean_threshold_cli.rs @@ -1,6 +1,8 @@ mod support; +use kratos_core::clean::{current_file_identity, current_parent_identity}; use serde_json::json; +use sha2::{Digest, Sha256}; use support::cli::run_cli_in_dir; use support::fs::temp_dir; @@ -44,6 +46,32 @@ fn clean_uses_config_threshold_and_flag_override() { assert!(project_root.join("mid-confidence.ts").exists()); } +#[test] +fn clean_reports_and_skips_stale_fingerprint_candidates() { + let project_root = temp_dir("clean-stale-fingerprint-cli"); + write_clean_threshold_fixture(&project_root, 0.98, 0.96, 0.75); + let high_file = project_root.join("high-confidence.ts"); + std::fs::write(&high_file, "export const changed = true;\n") + .expect("candidate should change after report generation"); + + let dry_run = run_cli_in_dir(&project_root, &["clean", "--min-confidence", "0.9"]); + assert!(dry_run.status.success()); + let dry_run_stdout = String::from_utf8_lossy(&dry_run.stdout); + assert!(dry_run_stdout.contains("삭제 대상: 0")); + assert!(dry_run_stdout.contains("안전 검증으로 건너뛴 대상: 1")); + assert!(dry_run_stdout.contains("안전 상태: 스캔 후 파일 변경됨")); + + let apply = run_cli_in_dir( + &project_root, + &["clean", "--apply", "--min-confidence", "0.9"], + ); + assert!(apply.status.success()); + let apply_stdout = String::from_utf8_lossy(&apply.stdout); + assert!(apply_stdout.contains("Kratos clean: 파일 0개를 삭제했습니다.")); + assert!(apply_stdout.contains("건너뛴 파일: 2")); + assert!(high_file.exists()); +} + #[test] fn clean_dry_run_renders_excerpts_markers_and_separate_skipped_sections() { let project_root = temp_dir("clean-threshold-cli-preview"); @@ -53,13 +81,15 @@ fn clean_dry_run_renders_excerpts_markers_and_separate_skipped_sections() { assert!(output.status.success()); let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("삭제 대상: 3")); + assert!(stdout.contains("삭제 대상: 2")); assert!(stdout.contains("- src/live.ts")); assert!(stdout.contains("신뢰도: 0.96")); assert!(stdout.contains("사유: live candidate")); assert!(stdout.contains("상태: 존재함")); assert!(stdout.contains("export const live = true;")); assert!(stdout.contains("- src/missing.ts")); + assert!(stdout.contains("안전 검증으로 건너뛴 대상: 1")); + assert!(stdout.contains("안전 상태: fingerprint 없음")); assert!(stdout.contains("상태: 없음")); assert!(stdout.contains("[missing file]")); assert!(stdout.contains("- src/binary.bin")); @@ -226,19 +256,13 @@ fn write_clean_threshold_fixture( ) .expect("config should write"); - std::fs::write( - project_root.join("high-confidence.ts"), - "export const high = true;\n", - ) - .expect("high file should write"); - std::fs::write( - project_root.join("mid-confidence.ts"), - "export const mid = true;\n", - ) - .expect("mid file should write"); + let high_file = project_root.join("high-confidence.ts"); + let mid_file = project_root.join("mid-confidence.ts"); + std::fs::write(&high_file, "export const high = true;\n").expect("high file should write"); + std::fs::write(&mid_file, "export const mid = true;\n").expect("mid file should write"); let report = json!({ - "schemaVersion": 2, + "schemaVersion": 3, "generatedAt": "2026-04-21T00:00:00Z", "project": { "root": project_root, @@ -262,19 +286,36 @@ fn write_clean_threshold_fixture( "routeEntrypoints": [], "deletionCandidates": [ { - "file": project_root.join("high-confidence.ts"), + "file": high_file, "reason": "high confidence candidate", "confidence": high_confidence, "safe": true, }, { - "file": project_root.join("mid-confidence.ts"), + "file": mid_file, "reason": "mid confidence candidate", "confidence": mid_confidence, "safe": true, } ], }, + "cleanSafety": { + "fingerprintAlgorithm": "sha256", + "candidates": [ + { + "file": high_file, + "fingerprint": content_fingerprint(&high_file), + "identity": current_file_identity(&high_file), + "parentIdentity": current_parent_identity(&high_file), + }, + { + "file": mid_file, + "fingerprint": content_fingerprint(&mid_file), + "identity": current_file_identity(&mid_file), + "parentIdentity": current_parent_identity(&mid_file), + } + ], + }, "graph": { "modules": [], }, @@ -287,6 +328,11 @@ fn write_clean_threshold_fixture( .expect("report should write"); } +fn content_fingerprint(path: &std::path::Path) -> String { + let bytes = std::fs::read(path).expect("fingerprinted file should read"); + format!("{:x}", Sha256::digest(bytes)) +} + fn write_clean_preview_fixture(project_root: &std::path::Path) { std::fs::create_dir_all(project_root.join(".kratos")).expect("report dir should exist"); std::fs::create_dir_all(project_root.join("src")).expect("source dir should exist"); @@ -363,6 +409,41 @@ fn write_clean_preview_fixture(project_root: &std::path::Path) { } ], }, + "cleanSafety": { + "fingerprintAlgorithm": "sha256", + "candidates": [ + { + "file": project_root.join("src/live.ts"), + "fingerprint": content_fingerprint(&project_root.join("src/live.ts")), + "identity": current_file_identity(&project_root.join("src/live.ts")), + "parentIdentity": current_parent_identity(&project_root.join("src/live.ts")), + }, + { + "file": project_root.join("src/missing.ts"), + "fingerprint": null, + "identity": null, + "parentIdentity": null, + }, + { + "file": project_root.join("src/binary.bin"), + "fingerprint": content_fingerprint(&project_root.join("src/binary.bin")), + "identity": current_file_identity(&project_root.join("src/binary.bin")), + "parentIdentity": current_parent_identity(&project_root.join("src/binary.bin")), + }, + { + "file": project_root.join("src/low-confidence.ts"), + "fingerprint": null, + "identity": null, + "parentIdentity": null, + }, + { + "file": outside_candidate, + "fingerprint": null, + "identity": null, + "parentIdentity": null, + } + ], + }, "graph": { "modules": [], }, diff --git a/crates/kratos-cli/tests/cli_smoke.rs b/crates/kratos-cli/tests/cli_smoke.rs index 53c83c8..f27b003 100644 --- a/crates/kratos-cli/tests/cli_smoke.rs +++ b/crates/kratos-cli/tests/cli_smoke.rs @@ -1,5 +1,6 @@ mod support; +use kratos_core::clean::{current_file_identity, current_parent_identity}; use support::cli::{run_cli, run_cli_in_dir}; use support::fs::{copy_demo_app, repo_root}; @@ -320,7 +321,10 @@ fn clean_accepts_legacy_v1_reports_through_cli() { &["clean", report_path.to_str().expect("path should be utf8")], ); assert!(dry_run.status.success()); - assert!(String::from_utf8_lossy(&dry_run.stdout).contains("Kratos clean 미리보기입니다.")); + let dry_run_stdout = String::from_utf8_lossy(&dry_run.stdout); + assert!(dry_run_stdout.contains("Kratos clean 미리보기입니다.")); + assert!(dry_run_stdout.contains("삭제 대상: 0")); + assert!(dry_run_stdout.contains("안전 검증으로 건너뛴 대상: 2")); let apply = run_cli_in_dir( &project_root, @@ -332,10 +336,10 @@ fn clean_accepts_legacy_v1_reports_through_cli() { ); assert!(apply.status.success()); assert!( - String::from_utf8_lossy(&apply.stdout).contains("Kratos clean: 파일 2개를 삭제했습니다.") + String::from_utf8_lossy(&apply.stdout).contains("Kratos clean: 파일 0개를 삭제했습니다.") ); - assert!(!project_root.join("src/components/DeadWidget.tsx").exists()); - assert!(!project_root.join("src/lib/broken.ts").exists()); + assert!(project_root.join("src/components/DeadWidget.tsx").exists()); + assert!(project_root.join("src/lib/broken.ts").exists()); } #[test] @@ -514,7 +518,7 @@ fn report_summary_and_markdown_accept_future_schema_versions() { let report_path = project_root.join("report-v3.json"); std::fs::write( &report_path, - "{\"schemaVersion\":3,\"project\":{\"root\":\"/tmp/demo\",\"configPath\":null},\"summary\":{\"filesScanned\":0,\"entrypoints\":0,\"brokenImports\":0,\"orphanFiles\":0,\"deadExports\":0,\"unusedImports\":0,\"routeEntrypoints\":0,\"deletionCandidates\":0},\"findings\":{\"brokenImports\":[],\"orphanFiles\":[],\"deadExports\":[],\"unusedImports\":[],\"routeEntrypoints\":[],\"deletionCandidates\":[]},\"graph\":{\"modules\":[]}}\n", + "{\"schemaVersion\":4,\"project\":{\"root\":\"/tmp/demo\",\"configPath\":null},\"summary\":{\"filesScanned\":0,\"entrypoints\":0,\"brokenImports\":0,\"orphanFiles\":0,\"deadExports\":0,\"unusedImports\":0,\"routeEntrypoints\":0,\"deletionCandidates\":0},\"findings\":{\"brokenImports\":[],\"orphanFiles\":[],\"deadExports\":[],\"unusedImports\":[],\"routeEntrypoints\":[],\"deletionCandidates\":[]},\"cleanSafety\":{\"fingerprintAlgorithm\":\"sha256\",\"candidates\":[]},\"graph\":{\"modules\":[]}}\n", ) .expect("report should write"); @@ -549,7 +553,7 @@ fn report_incomplete_future_schema_fails_fast() { let report_path = project_root.join("report-v3-min.json"); std::fs::write( &report_path, - "{\"schemaVersion\":3,\"project\":{\"root\":\"/tmp/demo\"}}\n", + "{\"schemaVersion\":4,\"project\":{\"root\":\"/tmp/demo\"},\"cleanSafety\":{\"fingerprintAlgorithm\":\"sha256\",\"candidates\":[]}}\n", ) .expect("report should write"); @@ -587,9 +591,12 @@ fn clean_accepts_future_schema_reports_when_the_shape_is_compatible() { std::fs::write( &report_path, format!( - "{{\"schemaVersion\":3,\"generatedAt\":\"2026-04-21T00:00:00Z\",\"project\":{{\"root\":\"{}\",\"configPath\":null}},\"summary\":{{\"filesScanned\":1,\"entrypoints\":0,\"brokenImports\":0,\"orphanFiles\":0,\"deadExports\":0,\"unusedImports\":0,\"routeEntrypoints\":0,\"deletionCandidates\":1}},\"findings\":{{\"brokenImports\":[],\"orphanFiles\":[],\"deadExports\":[],\"unusedImports\":[],\"routeEntrypoints\":[],\"deletionCandidates\":[{{\"file\":\"{}\",\"reason\":\"test\",\"confidence\":1.0,\"safe\":true}}]}},\"graph\":{{\"modules\":[]}}}}\n", + "{{\"schemaVersion\":4,\"generatedAt\":\"2026-04-21T00:00:00Z\",\"project\":{{\"root\":\"{}\",\"configPath\":null}},\"summary\":{{\"filesScanned\":1,\"entrypoints\":0,\"brokenImports\":0,\"orphanFiles\":0,\"deadExports\":0,\"unusedImports\":0,\"routeEntrypoints\":0,\"deletionCandidates\":1}},\"findings\":{{\"brokenImports\":[],\"orphanFiles\":[],\"deadExports\":[],\"unusedImports\":[],\"routeEntrypoints\":[],\"deletionCandidates\":[{{\"file\":\"{}\",\"reason\":\"test\",\"confidence\":1.0,\"safe\":true}}]}},\"cleanSafety\":{{\"fingerprintAlgorithm\":\"sha256\",\"candidates\":[{{\"file\":\"{}\",\"fingerprint\":\"9edc05076fb5a5921c7e8ffe2cc79cc5d711d9612e138d09572f76df4530d870\",\"identity\":\"{}\",\"parentIdentity\":\"{}\"}}]}},\"graph\":{{\"modules\":[]}}}}\n", project_root.display(), dead_file.display(), + dead_file.display(), + current_file_identity(&dead_file).expect("dead file should have stable identity"), + current_parent_identity(&dead_file).expect("dead parent should have stable identity"), ), ) .expect("report should write"); diff --git a/crates/kratos-core/Cargo.toml b/crates/kratos-core/Cargo.toml index 37d2b51..90c697f 100644 --- a/crates/kratos-core/Cargo.toml +++ b/crates/kratos-core/Cargo.toml @@ -16,3 +16,4 @@ oxc_parser = "0.125.0" oxc_span = "0.125.0" oxc_syntax = "0.125.0" serde_json = "1.0.149" +sha2 = "0.10" diff --git a/crates/kratos-core/src/analyze.rs b/crates/kratos-core/src/analyze.rs index ea8a14c..da1e000 100644 --- a/crates/kratos-core/src/analyze.rs +++ b/crates/kratos-core/src/analyze.rs @@ -1,4 +1,4 @@ -use std::path::Path; +use std::path::{Path, PathBuf}; use std::collections::{BTreeMap, BTreeSet}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -7,12 +7,14 @@ use crate::config::load_project_config; use crate::discover::collect_source_files; use crate::entrypoints::detect_entrypoint_kind; use crate::error::KratosResult; +use crate::fingerprint::{read_source_and_snapshot, FileSnapshot, CONTENT_FINGERPRINT_ALGORITHM}; use crate::ignore::IgnoreMatcher; use crate::model::{ - BrokenImportFinding, DeadExportFinding, DeletionCandidateFinding, EntrypointKind, ExportRecord, - FindingSet, ImportKind, ImportSpecifierKind, ImportUsageRecord, ModuleRecord, - OrphanFileFinding, OrphanKind, ProjectConfig, ReportV2, ResolvedImportRecord, - RouteEntrypointFinding, SummaryCounts, UnusedImportFinding, + BrokenImportFinding, CleanCandidateFingerprint, CleanSafetyManifest, DeadExportFinding, + DeletionCandidateFinding, EntrypointKind, ExportRecord, FindingSet, ImportKind, + ImportSpecifierKind, ImportUsageRecord, ModuleRecord, OrphanFileFinding, OrphanKind, + ProjectConfig, ReportV2, ResolvedImportRecord, RouteEntrypointFinding, SummaryCounts, + UnusedImportFinding, }; use crate::parser::parse_module_source; use crate::resolve::{resolve_import_target, unresolved_import}; @@ -70,9 +72,10 @@ pub fn analyze_with_config(config: &ProjectConfig) -> KratosResult { let keep_matcher = IgnoreMatcher::new(&[], &config.keep_patterns); let mut modules = BTreeMap::new(); let mut pure_barrel_files = BTreeSet::new(); + let mut source_snapshots = BTreeMap::new(); for file_path in files { - let source = std::fs::read_to_string(&file_path)?; + let (source, snapshot) = read_source_and_snapshot(&file_path)?; let parsed = parse_module_source(&file_path, &source)?; let entrypoint_kind = detect_entrypoint_kind(&file_path, config)?; let is_pure_barrel = parsed.is_pure_reexport_barrel; @@ -96,7 +99,10 @@ pub fn analyze_with_config(config: &ProjectConfig) -> KratosResult { ); if is_pure_barrel { - pure_barrel_files.insert(file_path); + pure_barrel_files.insert(file_path.clone()); + } + if let Some(snapshot) = snapshot { + source_snapshots.insert(file_path, snapshot); } } @@ -216,7 +222,7 @@ pub fn analyze_with_config(config: &ProjectConfig) -> KratosResult { file: module.file_path.clone(), reason: classification.reason, confidence: classification.confidence, - safe: true, + safe: false, }); } @@ -255,7 +261,7 @@ pub fn analyze_with_config(config: &ProjectConfig) -> KratosResult { } let mut findings = FindingSet { - broken_imports: broken_imports, + broken_imports, orphan_files, dead_exports, unused_imports, @@ -264,6 +270,7 @@ pub fn analyze_with_config(config: &ProjectConfig) -> KratosResult { }; let suppressions = load_project_suppressions(config); let suppressed_findings = apply_suppressions(&mut findings, &suppressions); + let clean_safety_candidates = attach_clean_safety_evidence(&mut findings, &source_snapshots); let mut report = ReportV2::new(config.root.clone()); report.generated_at = Some(current_timestamp()); @@ -285,6 +292,10 @@ pub fn analyze_with_config(config: &ProjectConfig) -> KratosResult { deletion_candidates: findings.deletion_candidates.len(), suppressed_findings, }; + report.clean_safety = CleanSafetyManifest { + fingerprint_algorithm: CONTENT_FINGERPRINT_ALGORITHM.to_string(), + candidates: clean_safety_candidates, + }; report.findings = findings; report.modules = modules .into_values() @@ -298,6 +309,26 @@ pub fn analyze_with_config(config: &ProjectConfig) -> KratosResult { Ok(report) } +fn attach_clean_safety_evidence( + findings: &mut FindingSet, + source_snapshots: &BTreeMap, +) -> Vec { + findings + .deletion_candidates + .iter_mut() + .map(|candidate| { + let snapshot = source_snapshots.get(&candidate.file); + candidate.safe = snapshot.is_some(); + CleanCandidateFingerprint { + file: candidate.file.clone(), + fingerprint: snapshot.map(|value| value.fingerprint.clone()), + identity: snapshot.map(|value| value.identity.clone()), + parent_identity: snapshot.map(|value| value.parent_identity.clone()), + } + }) + .collect() +} + fn is_kept_by_pattern(keep_matcher: &IgnoreMatcher, relative_path: &str) -> bool { keep_matcher.is_ignored(relative_path, false) } @@ -473,7 +504,7 @@ fn route_file_stem(relative_path: &str) -> Option<&str> { } fn matches_stem(stem: &str, candidates: &[&str]) -> bool { - candidates.iter().any(|candidate| *candidate == stem) + candidates.contains(&stem) } fn is_route_like_file_name(file_name: &str) -> bool { @@ -552,8 +583,41 @@ struct OrphanClassification { #[cfg(test)] mod tests { - use super::{classify_orphan, is_framework_consumed_export, should_skip_dead_exports}; - use crate::model::{EntrypointKind, OrphanKind}; + use std::collections::BTreeMap; + + use super::{ + attach_clean_safety_evidence, classify_orphan, is_framework_consumed_export, + should_skip_dead_exports, + }; + use crate::fingerprint::FileSnapshot; + use crate::model::{DeletionCandidateFinding, EntrypointKind, FindingSet, OrphanKind}; + + #[test] + fn clean_safety_uses_the_snapshot_captured_with_the_analyzed_source() { + let file = std::path::PathBuf::from("/repo/src/dead.ts"); + let mut findings = FindingSet::default(); + findings.deletion_candidates.push(DeletionCandidateFinding { + file: file.clone(), + reason: "test".to_string(), + confidence: 1.0, + safe: false, + }); + let snapshots = BTreeMap::from([( + file.clone(), + FileSnapshot { + fingerprint: "analyzed-bytes".to_string(), + identity: "analyzed-file".to_string(), + parent_identity: "analyzed-parent".to_string(), + }, + )]); + + let manifest = attach_clean_safety_evidence(&mut findings, &snapshots); + + assert!(findings.deletion_candidates[0].safe); + assert_eq!(manifest[0].file, file); + assert_eq!(manifest[0].fingerprint.as_deref(), Some("analyzed-bytes")); + assert_eq!(manifest[0].identity.as_deref(), Some("analyzed-file")); + } #[test] fn classify_orphan_avoids_route_false_positives_from_substrings() { diff --git a/crates/kratos-core/src/clean.rs b/crates/kratos-core/src/clean.rs index 922e715..bf42929 100644 --- a/crates/kratos-core/src/clean.rs +++ b/crates/kratos-core/src/clean.rs @@ -1,16 +1,31 @@ use std::io::ErrorKind; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; use serde_json::Value; use crate::error::{KratosError, KratosResult}; -use crate::model::{DeletionCandidateFinding, ReportV2, REPORT_V2}; +use crate::fingerprint::{ + current_parent_identity as fingerprint_parent_identity, inspect_regular_file, FileSnapshot, + CONTENT_FINGERPRINT_ALGORITHM, +}; +use crate::model::{CleanCandidateFingerprint, DeletionCandidateFinding, ReportV2, REPORT_V2}; use crate::report::parse_report_json; +static QUARANTINE_COUNTER: AtomicU64 = AtomicU64::new(0); + #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct CleanOutcome { pub deleted_files: usize, pub skipped_files: usize, + pub failed_files: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CleanFailure { + pub file: PathBuf, + pub error: String, } #[derive(Clone, Debug, Default, PartialEq)] @@ -19,6 +34,22 @@ pub struct CleanThresholdPlan { pub threshold_skipped_targets: Vec, } +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub enum CleanSafetyStatus { + #[default] + Ready, + PathOutsideRoot, + DuplicateCandidate, + UnsafeFlag, + UnsupportedFingerprintAlgorithm, + MissingFingerprint, + MissingIdentity, + DuplicateFingerprint, + FingerprintUnavailable, + FingerprintMismatch, + IdentityMismatch, +} + pub fn clean_from_report_path( report_path: impl AsRef, apply: bool, @@ -95,12 +126,25 @@ pub fn clean_from_report(report: &ReportV2, apply: bool) -> KratosResult Option { + inspect_regular_file(path) + .ok() + .map(|snapshot| snapshot.identity) +} + +#[doc(hidden)] +pub fn current_parent_identity(path: &Path) -> Option { + fingerprint_parent_identity(path.parent()?) +} + pub(crate) fn is_safe_clean_candidate(report_root: &Path, candidate_path: &Path) -> bool { let report_root_path = resolve_path(report_root); let candidate_path = resolve_path(candidate_path); @@ -110,12 +154,73 @@ pub(crate) fn is_safe_clean_candidate(report_root: &Path, candidate_path: &Path) } let deletion_root = realpath_or_fallback(report_root); - let candidate_parent_path = candidate_path.parent().unwrap_or(report_root_path.as_path()); + let candidate_parent_path = candidate_path + .parent() + .unwrap_or(report_root_path.as_path()); let candidate_parent = realpath_or_fallback(candidate_parent_path); is_within_directory(&deletion_root, &candidate_parent) } +pub fn clean_candidate_safety_status( + report: &ReportV2, + candidate: &DeletionCandidateFinding, +) -> CleanSafetyStatus { + if !is_safe_clean_candidate(&report.root, &candidate.file) { + return CleanSafetyStatus::PathOutsideRoot; + } + + let candidate_path = resolve_path(&candidate.file); + if report + .findings + .deletion_candidates + .iter() + .filter(|item| resolve_path(&item.file) == candidate_path) + .count() + != 1 + { + return CleanSafetyStatus::DuplicateCandidate; + } + if !candidate.safe { + return CleanSafetyStatus::UnsafeFlag; + } + + let mut fingerprints = report + .clean_safety + .candidates + .iter() + .filter(|entry| resolve_path(&entry.file) == candidate_path); + let Some(entry) = fingerprints.next() else { + return CleanSafetyStatus::MissingFingerprint; + }; + if fingerprints.next().is_some() { + return CleanSafetyStatus::DuplicateFingerprint; + } + let Some(expected_fingerprint) = entry.fingerprint.as_deref() else { + return CleanSafetyStatus::MissingFingerprint; + }; + let Some(expected_identity) = entry.identity.as_deref() else { + return CleanSafetyStatus::MissingIdentity; + }; + let Some(expected_parent_identity) = entry.parent_identity.as_deref() else { + return CleanSafetyStatus::MissingIdentity; + }; + if report.clean_safety.fingerprint_algorithm != CONTENT_FINGERPRINT_ALGORITHM { + return CleanSafetyStatus::UnsupportedFingerprintAlgorithm; + } + let Ok(actual) = inspect_regular_file(&candidate_path) else { + return CleanSafetyStatus::FingerprintUnavailable; + }; + + if actual.identity != expected_identity || actual.parent_identity != expected_parent_identity { + CleanSafetyStatus::IdentityMismatch + } else if actual.fingerprint != expected_fingerprint { + CleanSafetyStatus::FingerprintMismatch + } else { + CleanSafetyStatus::Ready + } +} + fn validate_clean_threshold_inputs(report: &ReportV2, min_confidence: f32) -> KratosResult<()> { if report.version < REPORT_V2 { return Err(KratosError::InvalidReportVersion { @@ -134,74 +239,273 @@ fn validate_clean_threshold_inputs(report: &ReportV2, min_confidence: f32) -> Kr } fn apply_clean_plan(report: &ReportV2, plan: &CleanThresholdPlan) -> KratosResult { - let report_root_path = resolve_path(&report.root); + apply_clean_plan_with_hooks(report, plan, |_| {}, |_| {}) +} + +#[cfg(test)] +fn apply_clean_plan_with_hook( + report: &ReportV2, + plan: &CleanThresholdPlan, + before_quarantine: F, +) -> KratosResult +where + F: FnMut(&Path), +{ + apply_clean_plan_with_hooks(report, plan, before_quarantine, |_| {}) +} + +fn apply_clean_plan_with_hooks( + report: &ReportV2, + plan: &CleanThresholdPlan, + mut before_quarantine: F, + mut after_quarantine_verification: G, +) -> KratosResult +where + F: FnMut(&Path), + G: FnMut(&Path), +{ let mut outcome = CleanOutcome { deleted_files: 0, skipped_files: plan.threshold_skipped_targets.len(), + failed_files: Vec::new(), }; for candidate in &plan.deletion_targets { let candidate_path = resolve_path(&candidate.file); - if !is_safe_clean_candidate(&report.root, &candidate_path) || !file_exists(&candidate_path) { + if clean_candidate_safety_status(report, candidate) != CleanSafetyStatus::Ready { outcome.skipped_files += 1; continue; } + let Some(expected) = + unique_safety_entry(report, &candidate_path).and_then(snapshot_from_entry) + else { + outcome.skipped_files += 1; + continue; + }; - let candidate_parent_path = candidate_path.parent().unwrap_or(report_root_path.as_path()); - - match std::fs::remove_file(&candidate_path) { - Ok(()) => { - remove_empty_directories(candidate_parent_path, &report_root_path)?; + match quarantine_and_delete( + &report.root, + &candidate_path, + &expected, + &mut before_quarantine, + &mut after_quarantine_verification, + ) { + QuarantineOutcome::Deleted => { outcome.deleted_files += 1; } - Err(error) if error.kind() == ErrorKind::NotFound => { - outcome.skipped_files += 1; - } - Err(error) => return Err(error.into()), + QuarantineOutcome::Skipped => outcome.skipped_files += 1, + QuarantineOutcome::Failed(error) => outcome.failed_files.push(CleanFailure { + file: candidate_path, + error, + }), } } Ok(outcome) } -fn file_exists(path: &Path) -> bool { - match std::fs::symlink_metadata(path) { - Ok(metadata) if metadata.file_type().is_symlink() => true, - Ok(_) => std::fs::metadata(path).is_ok(), - Err(_) => false, - } +fn unique_safety_entry<'a>( + report: &'a ReportV2, + candidate_path: &Path, +) -> Option<&'a CleanCandidateFingerprint> { + let mut entries = report + .clean_safety + .candidates + .iter() + .filter(|entry| resolve_path(&entry.file) == candidate_path); + let entry = entries.next()?; + entries.next().is_none().then_some(entry) } -fn realpath_or_fallback(path: &Path) -> PathBuf { - std::fs::canonicalize(path).unwrap_or_else(|_| resolve_path(path)) +fn snapshot_from_entry(entry: &CleanCandidateFingerprint) -> Option { + Some(FileSnapshot { + fingerprint: entry.fingerprint.clone()?, + identity: entry.identity.clone()?, + parent_identity: entry.parent_identity.clone()?, + }) +} + +enum QuarantineOutcome { + Deleted, + Skipped, + Failed(String), } -fn remove_empty_directories(start_dir: &Path, stop_at: &Path) -> KratosResult<()> { - let boundary = resolve_path(stop_at); - let mut current = resolve_path(start_dir); +fn quarantine_and_delete( + report_root: &Path, + candidate_path: &Path, + expected: &FileSnapshot, + before_move: &mut F, + after_quarantine_verification: &mut G, +) -> QuarantineOutcome +where + F: FnMut(&Path), + G: FnMut(&Path), +{ + let quarantine_dir = match create_quarantine_dir(report_root) { + Ok(path) => path, + Err(error) => return QuarantineOutcome::Failed(error.to_string()), + }; + let quarantined_path = quarantine_dir.join("candidate"); + let source_parent_matches = candidate_path + .parent() + .and_then(fingerprint_parent_identity) + .as_deref() + == Some(expected.parent_identity.as_str()); + if !source_parent_matches || !is_safe_clean_candidate(report_root, candidate_path) { + let _ = std::fs::remove_dir(&quarantine_dir); + return QuarantineOutcome::Skipped; + } + + before_move(candidate_path); + let source_parent_matches_after_hook = candidate_path + .parent() + .and_then(fingerprint_parent_identity) + .as_deref() + == Some(expected.parent_identity.as_str()); + if !source_parent_matches_after_hook || !is_safe_clean_candidate(report_root, candidate_path) { + let _ = std::fs::remove_dir(&quarantine_dir); + return QuarantineOutcome::Skipped; + } - while is_within_directory(&boundary, ¤t) && current != boundary { - let mut entries = match std::fs::read_dir(¤t) { - Ok(entries) => entries, - Err(_) => return Ok(()), + if let Err(error) = std::fs::rename(candidate_path, &quarantined_path) { + let _ = std::fs::remove_dir(&quarantine_dir); + return if error.kind() == ErrorKind::NotFound { + QuarantineOutcome::Skipped + } else { + QuarantineOutcome::Failed(error.to_string()) }; + } - if entries.next().is_some() { - return Ok(()); + let source_parent_matches = candidate_path + .parent() + .and_then(fingerprint_parent_identity) + .as_deref() + == Some(expected.parent_identity.as_str()); + let verified = source_parent_matches + && is_safe_clean_candidate(report_root, candidate_path) + && is_safe_clean_candidate(report_root, &quarantined_path) + && inspect_regular_file(&quarantined_path) + .map(|actual| { + actual.identity == expected.identity && actual.fingerprint == expected.fingerprint + }) + .unwrap_or(false); + + if !verified { + return match restore_quarantined_file( + report_root, + &quarantined_path, + candidate_path, + expected, + ) { + Ok(()) => { + let _ = std::fs::remove_dir(&quarantine_dir); + QuarantineOutcome::Skipped + } + Err(error) => QuarantineOutcome::Failed(format!( + "검증 실패 파일을 복원하지 못했습니다. 보존 위치: {} ({error})", + quarantined_path.display() + )), + }; + } + + after_quarantine_verification(&quarantined_path); + + let quarantine_still_verified = is_safe_clean_candidate(report_root, &quarantined_path) + && inspect_regular_file(&quarantined_path) + .map(|actual| { + actual.identity == expected.identity && actual.fingerprint == expected.fingerprint + }) + .unwrap_or(false); + if !quarantine_still_verified { + return QuarantineOutcome::Failed(format!( + "검증된 quarantine 경로가 변경되어 삭제하지 않았습니다. 보존 위치: {}", + quarantined_path.display() + )); + } + + match std::fs::remove_file(&quarantined_path) { + Ok(()) => { + let _ = std::fs::remove_dir(&quarantine_dir); + QuarantineOutcome::Deleted } + Err(delete_error) => match restore_quarantined_file( + report_root, + &quarantined_path, + candidate_path, + expected, + ) { + Ok(()) => { + let _ = std::fs::remove_dir(&quarantine_dir); + QuarantineOutcome::Failed(delete_error.to_string()) + } + Err(restore_error) => QuarantineOutcome::Failed(format!( + "삭제와 복원에 실패했습니다. 보존 위치: {} (삭제: {delete_error}; 복원: {restore_error})", + quarantined_path.display() + )), + }, + } +} - if std::fs::remove_dir(¤t).is_err() { - return Ok(()); +fn create_quarantine_dir(parent: &Path) -> std::io::Result { + for _ in 0..100 { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let counter = QUARANTINE_COUNTER.fetch_add(1, Ordering::Relaxed); + let path = parent.join(format!( + ".kratos-clean-quarantine-{}-{nonce}-{counter}", + std::process::id() + )); + match create_private_directory(&path) { + Ok(()) => return Ok(path), + Err(error) if error.kind() == ErrorKind::AlreadyExists => continue, + Err(error) => return Err(error), } + } - let Some(parent) = current.parent() else { - return Ok(()); - }; - current = parent.to_path_buf(); + Err(std::io::Error::new( + ErrorKind::AlreadyExists, + "could not allocate a unique clean quarantine directory", + )) +} + +#[cfg(unix)] +fn create_private_directory(path: &Path) -> std::io::Result<()> { + use std::os::unix::fs::DirBuilderExt; + + std::fs::DirBuilder::new().mode(0o700).create(path) +} + +#[cfg(not(unix))] +fn create_private_directory(path: &Path) -> std::io::Result<()> { + std::fs::create_dir(path) +} + +fn restore_quarantined_file( + report_root: &Path, + quarantined_path: &Path, + original_path: &Path, + expected: &FileSnapshot, +) -> std::io::Result<()> { + let parent_matches = original_path + .parent() + .and_then(fingerprint_parent_identity) + .as_deref() + == Some(expected.parent_identity.as_str()); + if !parent_matches || !is_safe_clean_candidate(report_root, original_path) { + return Err(std::io::Error::other( + "candidate parent changed or escaped report root during restore", + )); } + std::fs::hard_link(quarantined_path, original_path)?; + std::fs::remove_file(quarantined_path) +} - Ok(()) +fn realpath_or_fallback(path: &Path) -> PathBuf { + std::fs::canonicalize(path).unwrap_or_else(|_| resolve_path(path)) } fn is_within_directory(root: &Path, candidate: &Path) -> bool { @@ -253,3 +557,268 @@ fn normalize_path(path: PathBuf) -> PathBuf { normalized } } + +#[cfg(test)] +mod tests { + use std::time::{SystemTime, UNIX_EPOCH}; + + use super::*; + use crate::model::{CleanSafetyManifest, FindingSet}; + + #[test] + fn quarantine_rechecks_the_exact_file_after_precheck() { + let root = temp_dir("clean-quarantine-recheck"); + let file = root.join("dead.ts"); + std::fs::write(&file, "original\n").expect("fixture should write"); + let report = report_for_files(&root, std::slice::from_ref(&file)); + let plan = plan_clean_candidates(&report, 0.0).expect("plan should build"); + + let outcome = apply_clean_plan_with_hook(&report, &plan, |path| { + std::fs::remove_file(path).expect("original should be replaceable"); + std::fs::write(path, "replacement\n").expect("replacement should write"); + }) + .expect("clean should stay fail closed"); + + assert_eq!(outcome.deleted_files, 0); + assert_eq!(outcome.skipped_files, 1); + assert!(outcome.failed_files.is_empty()); + assert_eq!( + std::fs::read_to_string(&file).expect("replacement should remain"), + "replacement\n" + ); + } + + #[test] + fn partial_failure_preserves_successful_delete_accounting() { + let root = temp_dir("clean-partial-accounting"); + let first = root.join("first/dead.ts"); + let second = root.join("second/dead.ts"); + std::fs::create_dir_all(first.parent().expect("first parent")) + .expect("parent should exist"); + std::fs::create_dir_all(second.parent().expect("second parent")) + .expect("parent should exist"); + std::fs::write(&first, "first\n").expect("first should write"); + std::fs::write(&second, "second\n").expect("second should write"); + let report = report_for_files(&root, &[first.clone(), second.clone()]); + let plan = plan_clean_candidates(&report, 0.0).expect("plan should build"); + + let mut verification_count = 0; + let outcome = apply_clean_plan_with_hooks( + &report, + &plan, + |_| {}, + |path| { + verification_count += 1; + if verification_count == 2 { + std::fs::remove_file(path).expect("quarantined second should be removable"); + } + }, + ) + .expect("per-file failure should be reported in the outcome"); + + assert_eq!(outcome.deleted_files, 1); + assert_eq!(outcome.skipped_files, 0); + assert_eq!(outcome.failed_files.len(), 1); + assert_eq!(outcome.failed_files[0].file, second); + assert!(!first.exists()); + } + + #[cfg(unix)] + #[test] + fn mutable_parent_symlink_race_does_not_delete_outside_file() { + let root = temp_dir("clean-parent-race"); + let nested = root.join("nested"); + let saved_nested = root.join("saved-nested"); + let candidate = nested.join("dead.ts"); + let outside = temp_dir("clean-parent-race-outside"); + let outside_file = outside.join("dead.ts"); + std::fs::create_dir_all(&nested).expect("nested should exist"); + std::fs::write(&candidate, "analyzed\n").expect("candidate should write"); + std::fs::write(&outside_file, "outside\n").expect("outside should write"); + let report = report_for_files(&root, std::slice::from_ref(&candidate)); + let plan = plan_clean_candidates(&report, 0.0).expect("plan should build"); + + let outcome = apply_clean_plan_with_hook(&report, &plan, |_| { + std::fs::rename(&nested, &saved_nested).expect("nested should move"); + std::os::unix::fs::symlink(&outside, &nested).expect("parent symlink should install"); + }) + .expect("clean should stay fail closed"); + + assert_eq!(outcome.deleted_files, 0); + assert_eq!(outcome.skipped_files, 1); + assert!(outcome.failed_files.is_empty()); + assert_eq!( + std::fs::read_to_string(&outside_file).expect("outside file should remain"), + "outside\n" + ); + assert!(saved_nested.join("dead.ts").exists()); + } + + #[cfg(unix)] + #[test] + fn parent_relocation_after_quarantine_verification_cannot_redirect_deletion() { + let root = temp_dir("clean-post-verification-parent-race"); + let nested = root.join("nested"); + let candidate = nested.join("dead.ts"); + let outside = temp_dir("clean-post-verification-parent-race-outside"); + let moved_nested = outside.join("nested"); + let replacement = moved_nested.join("dead.ts"); + std::fs::create_dir_all(&nested).expect("nested should exist"); + std::fs::write(&candidate, "analyzed\n").expect("candidate should write"); + let report = report_for_files(&root, std::slice::from_ref(&candidate)); + let plan = plan_clean_candidates(&report, 0.0).expect("plan should build"); + + let outcome = apply_clean_plan_with_hooks( + &report, + &plan, + |_| {}, + |_| { + std::fs::rename(&nested, &moved_nested).expect("empty parent should move"); + std::os::unix::fs::symlink(&moved_nested, &nested) + .expect("redirecting symlink should install"); + std::fs::write(&replacement, "replacement\n") + .expect("replacement should write outside root"); + }, + ) + .expect("verified quarantine deletion should complete"); + + assert_eq!(outcome.deleted_files, 1); + assert_eq!(outcome.skipped_files, 0); + assert!(outcome.failed_files.is_empty()); + assert_eq!( + std::fs::read_to_string(&replacement).expect("replacement should remain"), + "replacement\n" + ); + } + + #[cfg(unix)] + #[test] + fn redirected_parent_with_matching_hardlink_is_skipped_without_deletion() { + let root = temp_dir("clean-parent-hardlink-race"); + let parent = root.join("nested"); + let moved_parent = root.join("moved-nested"); + let outside = temp_dir("clean-parent-hardlink-race-outside"); + let candidate = parent.join("candidate.ts"); + let moved_candidate = moved_parent.join("candidate.ts"); + let outside_candidate = outside.join("candidate.ts"); + std::fs::create_dir_all(&parent).expect("parent should exist"); + std::fs::write(&candidate, "candidate\n").expect("candidate should write"); + let report = report_for_files(&root, std::slice::from_ref(&candidate)); + let plan = plan_clean_candidates(&report, 0.0).expect("plan should build"); + + let outcome = apply_clean_plan_with_hook(&report, &plan, |_| { + std::fs::rename(&parent, &moved_parent).expect("parent should move"); + std::fs::hard_link(&moved_candidate, &outside_candidate) + .expect("matching external hardlink should exist"); + std::os::unix::fs::symlink(&outside, &parent) + .expect("redirecting parent symlink should install"); + }) + .expect("clean should restore the redirected pathname"); + + assert_eq!(outcome.deleted_files, 0); + assert_eq!(outcome.skipped_files, 1); + assert!(outcome.failed_files.is_empty()); + assert_eq!( + std::fs::read_to_string(&outside_candidate).expect("outside hardlink should remain"), + "candidate\n" + ); + assert!(moved_candidate.exists()); + } + + #[cfg(unix)] + #[test] + fn quarantine_relocation_is_not_followed_for_final_delete() { + let root = temp_dir("clean-quarantine-race"); + let candidate = root.join("candidate.ts"); + let outside = temp_dir("clean-quarantine-race-outside"); + let outside_target = outside.join("candidate"); + std::fs::write(&candidate, "candidate\n").expect("candidate should write"); + std::fs::write(&outside_target, "outside\n").expect("outside target should write"); + let report = report_for_files(&root, std::slice::from_ref(&candidate)); + let plan = plan_clean_candidates(&report, 0.0).expect("plan should build"); + + let outcome = apply_clean_plan_with_hooks( + &report, + &plan, + |_| {}, + |quarantined_path| { + let quarantine_dir = quarantined_path.parent().expect("quarantine parent"); + let moved_quarantine = outside.join("moved-quarantine"); + std::fs::rename(quarantine_dir, &moved_quarantine).expect("quarantine should move"); + std::os::unix::fs::symlink(&outside, quarantine_dir) + .expect("redirecting quarantine symlink should install"); + }, + ) + .expect("clean should report quarantine relocation as failure"); + + assert_eq!(outcome.deleted_files, 0); + assert_eq!(outcome.skipped_files, 0); + assert_eq!(outcome.failed_files.len(), 1); + assert_eq!( + std::fs::read_to_string(&outside_target).expect("outside target should remain"), + "outside\n" + ); + assert!(outside.join("moved-quarantine/candidate").exists()); + } + + #[cfg(unix)] + #[test] + fn restore_refuses_a_parent_symlink_escape() { + let root = temp_dir("clean-restore-race"); + let nested = root.join("nested"); + let moved_nested = temp_dir("clean-restore-race-moved"); + let candidate = nested.join("candidate.ts"); + let quarantined = root.join("quarantine-candidate"); + std::fs::create_dir_all(&nested).expect("nested should exist"); + std::fs::write(&candidate, "candidate\n").expect("candidate should write"); + std::fs::write(&quarantined, "candidate\n").expect("quarantine should write"); + let expected = inspect_regular_file(&candidate).expect("snapshot should exist"); + std::fs::rename(&nested, moved_nested.join("nested")) + .expect("candidate parent should move"); + std::os::unix::fs::symlink(moved_nested.join("nested"), &nested) + .expect("redirecting parent symlink should install"); + + let result = restore_quarantined_file(&root, &quarantined, &candidate, &expected); + assert!(result.is_err()); + assert!(quarantined.exists()); + assert!(moved_nested.join("nested/candidate.ts").exists()); + } + + fn report_for_files(root: &Path, files: &[PathBuf]) -> ReportV2 { + let mut findings = FindingSet::default(); + let mut safety_candidates = Vec::new(); + for file in files { + let snapshot = inspect_regular_file(file).expect("fixture should have evidence"); + findings.deletion_candidates.push(DeletionCandidateFinding { + file: file.clone(), + reason: "test".to_string(), + confidence: 1.0, + safe: true, + }); + safety_candidates.push(CleanCandidateFingerprint { + file: file.clone(), + fingerprint: Some(snapshot.fingerprint), + identity: Some(snapshot.identity), + parent_identity: Some(snapshot.parent_identity), + }); + } + + let mut report = ReportV2::new(root.to_path_buf()); + report.findings = findings; + report.clean_safety = CleanSafetyManifest { + fingerprint_algorithm: CONTENT_FINGERPRINT_ALGORITHM.to_string(), + candidates: safety_candidates, + }; + report + } + + fn temp_dir(label: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be valid") + .as_nanos(); + let path = std::env::temp_dir().join(format!("kratos-{label}-{unique}")); + std::fs::create_dir_all(&path).expect("temp dir should be created"); + path + } +} diff --git a/crates/kratos-core/src/clean_preview.rs b/crates/kratos-core/src/clean_preview.rs index b854498..c178067 100644 --- a/crates/kratos-core/src/clean_preview.rs +++ b/crates/kratos-core/src/clean_preview.rs @@ -2,7 +2,10 @@ use std::fs::File; use std::io::{BufRead, BufReader, ErrorKind}; use std::path::{Path, PathBuf}; -use crate::clean::{is_safe_clean_candidate, plan_clean_candidates}; +use crate::clean::{ + clean_candidate_safety_status, is_safe_clean_candidate, plan_clean_candidates, + CleanSafetyStatus, +}; use crate::model::{DeletionCandidateFinding, ReportV2}; use crate::KratosResult; @@ -18,6 +21,7 @@ pub struct CleanPreviewItem { pub relative_path: String, pub reason: String, pub confidence: f32, + pub safety_status: CleanSafetyStatus, pub exists: bool, pub preview_excerpt: String, } @@ -44,7 +48,7 @@ pub fn build_clean_preview( continue; } - items.push(build_preview_item(candidate, &report.root)); + items.push(build_preview_item(candidate, report)); } items.sort_by(|left, right| { @@ -55,24 +59,26 @@ pub fn build_clean_preview( }); Ok(CleanPreviewPlan { - deletion_target_paths: items.iter().map(|item| item.file.clone()).collect(), + deletion_target_paths: items + .iter() + .filter(|item| item.safety_status == CleanSafetyStatus::Ready) + .map(|item| item.file.clone()) + .collect(), items, threshold_skipped_targets: threshold_plan.threshold_skipped_targets, unavailable_targets, }) } -fn build_preview_item( - candidate: &DeletionCandidateFinding, - report_root: &Path, -) -> CleanPreviewItem { +fn build_preview_item(candidate: &DeletionCandidateFinding, report: &ReportV2) -> CleanPreviewItem { let (exists, preview_excerpt) = read_preview_excerpt(&candidate.file); CleanPreviewItem { file: candidate.file.clone(), - relative_path: to_project_relative_path(&candidate.file, report_root), + relative_path: to_project_relative_path(&candidate.file, &report.root), reason: candidate.reason.clone(), confidence: candidate.confidence, + safety_status: clean_candidate_safety_status(report, candidate), exists, preview_excerpt, } diff --git a/crates/kratos-core/src/fingerprint.rs b/crates/kratos-core/src/fingerprint.rs new file mode 100644 index 0000000..f1906bb --- /dev/null +++ b/crates/kratos-core/src/fingerprint.rs @@ -0,0 +1,187 @@ +use std::fs::{File, Metadata}; +use std::io::{Error, ErrorKind, Read}; +use std::path::Path; + +use sha2::{Digest, Sha256}; + +pub(crate) const CONTENT_FINGERPRINT_ALGORITHM: &str = "sha256"; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct FileSnapshot { + pub fingerprint: String, + pub identity: String, + pub parent_identity: String, +} + +pub(crate) fn read_source_and_snapshot( + path: &Path, +) -> std::io::Result<(String, Option)> { + let parent = path.parent(); + let before = std::fs::symlink_metadata(path).ok(); + let before_parent = parent.and_then(|value| std::fs::metadata(value).ok()); + let mut file = File::open(path)?; + let opened_metadata = file.metadata()?; + let mut source = String::new(); + file.read_to_string(&mut source)?; + let after = std::fs::symlink_metadata(path).ok(); + let after_parent = parent.and_then(|value| std::fs::metadata(value).ok()); + + let opened_identity = regular_file_identity(&opened_metadata); + let stable_path = before + .as_ref() + .zip(after.as_ref()) + .and_then(|(before, after)| { + let before_identity = regular_file_identity(before)?; + let after_identity = regular_file_identity(after)?; + (before_identity == opened_identity.as_deref()? && after_identity == before_identity) + .then_some(before_identity) + }); + let stable_parent = before_parent + .as_ref() + .zip(after_parent.as_ref()) + .and_then(|(before, after)| { + let before_identity = directory_identity(before)?; + let after_identity = directory_identity(after)?; + (before_identity == after_identity).then_some(before_identity) + }); + + let snapshot = stable_path + .zip(stable_parent) + .map(|(identity, parent_identity)| FileSnapshot { + fingerprint: fingerprint_bytes(source.as_bytes()), + identity, + parent_identity, + }); + + Ok((source, snapshot)) +} + +pub(crate) fn inspect_regular_file(path: &Path) -> std::io::Result { + let Some(parent) = path.parent() else { + return Err(Error::new( + ErrorKind::Unsupported, + "content fingerprints require a parent directory", + )); + }; + let path_metadata = std::fs::symlink_metadata(path)?; + if !path_metadata.file_type().is_file() { + return Err(Error::new( + ErrorKind::Unsupported, + "content fingerprints require a regular file", + )); + } + let parent_metadata = std::fs::metadata(parent)?; + let Some(parent_identity) = directory_identity(&parent_metadata) else { + return Err(Error::new( + ErrorKind::Unsupported, + "stable parent directory identity is unavailable", + )); + }; + + let mut file = File::open(path)?; + let opened_metadata = file.metadata()?; + let Some(identity) = regular_file_identity(&opened_metadata) else { + return Err(Error::new( + ErrorKind::Unsupported, + "stable file identity is unavailable", + )); + }; + if regular_file_identity(&path_metadata).as_deref() != Some(identity.as_str()) { + return Err(Error::other("file identity changed while opening")); + } + + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + + let final_path_metadata = std::fs::symlink_metadata(path)?; + let final_parent_metadata = std::fs::metadata(parent)?; + if regular_file_identity(&final_path_metadata).as_deref() != Some(identity.as_str()) { + return Err(Error::other("file identity changed while fingerprinting")); + } + if directory_identity(&final_parent_metadata).as_deref() != Some(parent_identity.as_str()) { + return Err(Error::other( + "parent directory identity changed while fingerprinting", + )); + } + + Ok(FileSnapshot { + fingerprint: format!("{:x}", hasher.finalize()), + identity, + parent_identity, + }) +} + +pub(crate) fn current_parent_identity(path: &Path) -> Option { + directory_identity(&std::fs::metadata(path).ok()?) +} + +fn regular_file_identity(metadata: &Metadata) -> Option { + if !metadata.file_type().is_file() { + return None; + } + + platform_file_identity(metadata) +} + +fn directory_identity(metadata: &Metadata) -> Option { + if !metadata.file_type().is_dir() { + return None; + } + + platform_object_identity(metadata) +} + +fn fingerprint_bytes(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +#[cfg(unix)] +fn platform_file_identity(metadata: &Metadata) -> Option { + use std::os::unix::fs::MetadataExt; + + Some(format!( + "unix:{}:{}:{}:{}:{}", + metadata.dev(), + metadata.ino(), + metadata.mtime(), + metadata.mtime_nsec(), + metadata.len() + )) +} + +#[cfg(unix)] +fn platform_object_identity(metadata: &Metadata) -> Option { + use std::os::unix::fs::MetadataExt; + + Some(format!("unix:{}:{}", metadata.dev(), metadata.ino())) +} + +#[cfg(windows)] +fn platform_file_identity(_metadata: &Metadata) -> Option { + // Stable Rust does not expose a Windows file identity suitable for the + // destructive clean contract. Returning `None` keeps report generation + // useful while making clean/apply fail closed on this platform. + None +} + +#[cfg(windows)] +fn platform_object_identity(_metadata: &Metadata) -> Option { + None +} + +#[cfg(not(any(unix, windows)))] +fn platform_file_identity(_metadata: &Metadata) -> Option { + None +} + +#[cfg(not(any(unix, windows)))] +fn platform_object_identity(_metadata: &Metadata) -> Option { + None +} diff --git a/crates/kratos-core/src/lib.rs b/crates/kratos-core/src/lib.rs index dfa24b5..6d4b67b 100644 --- a/crates/kratos-core/src/lib.rs +++ b/crates/kratos-core/src/lib.rs @@ -5,6 +5,7 @@ pub mod config; pub mod discover; pub mod entrypoints; pub mod error; +mod fingerprint; mod ignore; pub mod jsonc; pub mod model; diff --git a/crates/kratos-core/src/model.rs b/crates/kratos-core/src/model.rs index 1b43aa7..8851308 100644 --- a/crates/kratos-core/src/model.rs +++ b/crates/kratos-core/src/model.rs @@ -1,9 +1,11 @@ use std::collections::BTreeSet; use std::path::PathBuf; +use crate::fingerprint::CONTENT_FINGERPRINT_ALGORITHM; use crate::suppressions::SuppressionRule; pub const REPORT_V2: u32 = 2; +pub const REPORT_CURRENT: u32 = 3; #[derive(Clone, Debug, PartialEq, Eq)] pub struct ProjectConfig { @@ -202,6 +204,7 @@ pub struct ReportV2 { pub config_path: Option, pub summary: SummaryCounts, pub findings: FindingSet, + pub clean_safety: CleanSafetyManifest, pub modules: Vec, } @@ -217,17 +220,41 @@ impl ReportV2 { impl Default for ReportV2 { fn default() -> Self { Self { - version: REPORT_V2, + version: REPORT_CURRENT, generated_at: None, root: PathBuf::new(), config_path: None, summary: SummaryCounts::default(), findings: FindingSet::default(), + clean_safety: CleanSafetyManifest::default(), modules: Vec::new(), } } } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CleanSafetyManifest { + pub fingerprint_algorithm: String, + pub candidates: Vec, +} + +impl Default for CleanSafetyManifest { + fn default() -> Self { + Self { + fingerprint_algorithm: CONTENT_FINGERPRINT_ALGORITHM.to_string(), + candidates: Vec::new(), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CleanCandidateFingerprint { + pub file: PathBuf, + pub fingerprint: Option, + pub identity: Option, + pub parent_identity: Option, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct BrokenImportFinding { pub file: PathBuf, diff --git a/crates/kratos-core/src/report.rs b/crates/kratos-core/src/report.rs index 8164e2c..b2a5086 100644 --- a/crates/kratos-core/src/report.rs +++ b/crates/kratos-core/src/report.rs @@ -4,12 +4,14 @@ use serde_json::{json, Value}; use crate::error::{KratosError, KratosResult}; use crate::model::{ - BrokenImportFinding, DeadExportFinding, DeletionCandidateFinding, EntrypointKind, ExportKind, - ImportKind, ModuleRecord, OrphanFileFinding, OrphanKind, ReportV2, RouteEntrypointFinding, - SummaryCounts, UnusedImportFinding, REPORT_V2, + BrokenImportFinding, CleanCandidateFingerprint, CleanSafetyManifest, DeadExportFinding, + DeletionCandidateFinding, EntrypointKind, ExportKind, ImportKind, ModuleRecord, + OrphanFileFinding, OrphanKind, ReportV2, RouteEntrypointFinding, SummaryCounts, + UnusedImportFinding, REPORT_V2, }; use crate::report_contract::{ - finding_field, findings, graph, module, project, summary, top_level, REPORT_SCHEMA_VERSION, + clean_safety, finding_field, findings, graph, module, project, summary, top_level, + REPORT_SCHEMA_VERSION, }; pub fn validate_report_version(report: &ReportV2) -> KratosResult<()> { @@ -45,6 +47,7 @@ pub fn serialize_report_pretty(_report: &ReportV2) -> KratosResult { findings::ROUTE_ENTRYPOINTS: _report.findings.route_entrypoints.iter().map(serialize_route_entrypoint).collect::>(), findings::DELETION_CANDIDATES: _report.findings.deletion_candidates.iter().map(serialize_deletion_candidate).collect::>(), }, + top_level::CLEAN_SAFETY: serialize_clean_safety(&_report.clean_safety), top_level::GRAPH: { graph::MODULES: _report.modules.iter().map(serialize_module).collect::>(), }, @@ -66,6 +69,11 @@ pub fn parse_report_json(raw: &str) -> KratosResult { let generated_at = read_optional_string(Some(&value), "generatedAt", "generatedAt")?.map(str::to_string); + let clean_safety = if version >= REPORT_SCHEMA_VERSION { + parse_required_clean_safety(value.get("cleanSafety"))? + } else { + CleanSafetyManifest::default() + }; let (root, config_path, summary, finding_set, modules) = if version >= REPORT_V2 { let project = read_required_object(value.get("project"), "project")?; @@ -157,6 +165,7 @@ pub fn parse_report_json(raw: &str) -> KratosResult { config_path, summary, findings: finding_set, + clean_safety, modules, }) } @@ -269,6 +278,24 @@ fn serialize_deletion_candidate(item: &DeletionCandidateFinding) -> Value { }) } +fn serialize_clean_safety(manifest: &CleanSafetyManifest) -> Value { + json!({ + clean_safety::FINGERPRINT_ALGORITHM: manifest.fingerprint_algorithm, + clean_safety::CANDIDATES: manifest + .candidates + .iter() + .map(|candidate| { + json!({ + clean_safety::FILE: path_to_string(&candidate.file), + clean_safety::FINGERPRINT: candidate.fingerprint, + clean_safety::IDENTITY: candidate.identity, + clean_safety::PARENT_IDENTITY: candidate.parent_identity, + }) + }) + .collect::>(), + }) +} + fn serialize_module(module_record: &ModuleRecord) -> Value { json!({ module::FILE: path_to_string(&module_record.file_path), @@ -689,6 +716,71 @@ fn parse_required_deletion_candidates( .collect() } +fn parse_required_clean_safety(value: Option<&Value>) -> KratosResult { + let object = read_required_object(value, "cleanSafety")?; + let fingerprint_algorithm = read_required_string( + object, + "fingerprintAlgorithm", + "cleanSafety.fingerprintAlgorithm", + )? + .to_string(); + let candidates = read_required_array(object.get("candidates"), "cleanSafety.candidates")? + .iter() + .enumerate() + .map(|(index, item)| { + let path = format!("cleanSafety.candidates[{index}]"); + let candidate = read_required_object(Some(item), &path)?; + let file = read_required_string(candidate, "file", &format!("{path}.file"))?.into(); + let fingerprint = match candidate.get("fingerprint") { + Some(Value::Null) => None, + Some(Value::String(value)) => Some(value.clone()), + Some(_) => { + return Err(KratosError::Json(format!( + "{path}.fingerprint must be a string or null" + ))) + } + None => return Err(KratosError::Json(format!("{path}.fingerprint is required"))), + }; + let identity = match candidate.get("identity") { + Some(Value::Null) => None, + Some(Value::String(value)) => Some(value.clone()), + Some(_) => { + return Err(KratosError::Json(format!( + "{path}.identity must be a string or null" + ))) + } + None => return Err(KratosError::Json(format!("{path}.identity is required"))), + }; + let parent_identity = match candidate.get("parentIdentity") { + Some(Value::Null) => None, + Some(Value::String(value)) => Some(value.clone()), + Some(_) => { + return Err(KratosError::Json(format!( + "{path}.parentIdentity must be a string or null" + ))) + } + None => { + return Err(KratosError::Json(format!( + "{path}.parentIdentity is required" + ))) + } + }; + + Ok(CleanCandidateFingerprint { + file, + fingerprint, + identity, + parent_identity, + }) + }) + .collect::>>()?; + + Ok(CleanSafetyManifest { + fingerprint_algorithm, + candidates, + }) +} + fn parse_modules(value: Option<&Value>) -> Vec { read_array(value) .iter() diff --git a/crates/kratos-core/src/report_contract.rs b/crates/kratos-core/src/report_contract.rs index 165a4df..eb679f7 100644 --- a/crates/kratos-core/src/report_contract.rs +++ b/crates/kratos-core/src/report_contract.rs @@ -5,8 +5,10 @@ //! newer schema versions, but v1 writers must not rename or remove these keys //! without an explicit schema/version migration. -/// Stable schema version emitted by the v1 CLI/report contract. -pub(crate) const REPORT_SCHEMA_VERSION: u32 = 2; +use crate::model::REPORT_CURRENT; + +/// Current schema version emitted after the explicit clean-safety migration. +pub(crate) const REPORT_SCHEMA_VERSION: u32 = REPORT_CURRENT; pub(crate) mod top_level { pub(crate) const SCHEMA_VERSION: &str = "schemaVersion"; @@ -14,9 +16,19 @@ pub(crate) mod top_level { pub(crate) const PROJECT: &str = "project"; pub(crate) const SUMMARY: &str = "summary"; pub(crate) const FINDINGS: &str = "findings"; + pub(crate) const CLEAN_SAFETY: &str = "cleanSafety"; pub(crate) const GRAPH: &str = "graph"; } +pub(crate) mod clean_safety { + pub(crate) const FINGERPRINT_ALGORITHM: &str = "fingerprintAlgorithm"; + pub(crate) const CANDIDATES: &str = "candidates"; + pub(crate) const FILE: &str = "file"; + pub(crate) const FINGERPRINT: &str = "fingerprint"; + pub(crate) const IDENTITY: &str = "identity"; + pub(crate) const PARENT_IDENTITY: &str = "parentIdentity"; +} + pub(crate) mod project { pub(crate) const ROOT: &str = "root"; pub(crate) const CONFIG_PATH: &str = "configPath"; diff --git a/crates/kratos-core/src/report_diff.rs b/crates/kratos-core/src/report_diff.rs index 1771e9e..de4b42e 100644 --- a/crates/kratos-core/src/report_diff.rs +++ b/crates/kratos-core/src/report_diff.rs @@ -555,11 +555,10 @@ fn route_entrypoint_key(item: &RouteEntrypointFinding, report_root: &Path) -> St fn deletion_candidate_key(item: &DeletionCandidateFinding, report_root: &Path) -> String { format!( - "{}|{}|{}|{}", + "{}|{}|{}", finding_file_key(&item.file, report_root), item.reason, - item.confidence.to_bits(), - item.safe + item.confidence.to_bits() ) } diff --git a/crates/kratos-core/tests/analyze_demo_app.rs b/crates/kratos-core/tests/analyze_demo_app.rs index 6f3385e..94df99f 100644 --- a/crates/kratos-core/tests/analyze_demo_app.rs +++ b/crates/kratos-core/tests/analyze_demo_app.rs @@ -9,7 +9,7 @@ fn analyze_demo_app_matches_expected_graph_and_findings() { let demo_root = repo_root().join("fixtures/demo-app"); let report = analyze_project(&demo_root).expect("demo app should analyze"); - assert_eq!(report.version, 2); + assert_eq!(report.version, 3); assert!(report.generated_at.is_some()); assert_eq!(report.root, demo_root); assert_eq!(report.summary.files_scanned, 5); diff --git a/crates/kratos-core/tests/clean_preview.rs b/crates/kratos-core/tests/clean_preview.rs index ba19734..0cfbaaa 100644 --- a/crates/kratos-core/tests/clean_preview.rs +++ b/crates/kratos-core/tests/clean_preview.rs @@ -1,10 +1,12 @@ use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; +use kratos_core::clean::{current_file_identity, current_parent_identity}; use kratos_core::clean_preview::{ build_clean_preview, BINARY_PREVIEW_MARKER, MISSING_PREVIEW_MARKER, UNREADABLE_PREVIEW_MARKER, }; -use kratos_core::model::{DeletionCandidateFinding, ReportV2}; +use kratos_core::model::{CleanCandidateFingerprint, DeletionCandidateFinding, ReportV2}; +use sha2::{Digest, Sha256}; #[test] fn build_clean_preview_orders_items_by_confidence_then_relative_path() { @@ -171,7 +173,10 @@ fn build_clean_preview_truncates_multibyte_text_without_marking_binary() { assert_ne!(preview.items[0].preview_excerpt, BINARY_PREVIEW_MARKER); assert!(preview.items[0].preview_excerpt.starts_with('가')); assert!(preview.items[0].preview_excerpt.len() < long_line.len()); - assert!(preview.items[0].preview_excerpt.chars().all(|ch| ch == '가')); + assert!(preview.items[0] + .preview_excerpt + .chars() + .all(|ch| ch == '가')); } #[test] @@ -260,7 +265,10 @@ fn build_clean_preview_skips_symlink_escaped_targets() { assert!(preview.items.is_empty()); assert!(preview.deletion_target_paths.is_empty()); assert_eq!(preview.unavailable_targets.len(), 1); - assert_eq!(preview.unavailable_targets[0].file, link_path.join("secret.ts")); + assert_eq!( + preview.unavailable_targets[0].file, + link_path.join("secret.ts") + ); } #[test] @@ -329,7 +337,7 @@ fn report_with_candidates(root: &Path, candidates: &[(&str, f32, Option<&[u8]>)] let mut report = ReportV2::new(root.to_path_buf()); std::fs::create_dir_all(root).expect("report root should exist"); - report.findings.deletion_candidates = candidates + let candidate_data = candidates .iter() .map(|(relative, confidence, bytes)| { let path = root.join(relative); @@ -340,18 +348,39 @@ fn report_with_candidates(root: &Path, candidates: &[(&str, f32, Option<&[u8]>)] std::fs::write(&path, bytes).expect("candidate file should write"); } - DeletionCandidateFinding { - file: path, - reason: format!("{relative} candidate"), - confidence: *confidence, - safe: true, - } + let fingerprint = bytes.map(content_fingerprint); + ( + DeletionCandidateFinding { + file: path.clone(), + reason: format!("{relative} candidate"), + confidence: *confidence, + safe: fingerprint.is_some(), + }, + CleanCandidateFingerprint { + identity: current_file_identity(&path), + parent_identity: current_parent_identity(&path), + file: path, + fingerprint, + }, + ) }) + .collect::>(); + report.findings.deletion_candidates = candidate_data + .iter() + .map(|(candidate, _)| candidate.clone()) + .collect(); + report.clean_safety.candidates = candidate_data + .into_iter() + .map(|(_, fingerprint)| fingerprint) .collect(); report } +fn content_fingerprint(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + fn temp_dir(label: &str) -> PathBuf { let unique = SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/crates/kratos-core/tests/clean_safety.rs b/crates/kratos-core/tests/clean_safety.rs index 77892b6..92becfc 100644 --- a/crates/kratos-core/tests/clean_safety.rs +++ b/crates/kratos-core/tests/clean_safety.rs @@ -1,10 +1,15 @@ use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; -use kratos_core::clean::{clean_from_report, clean_from_report_path}; +use kratos_core::clean::{ + clean_candidate_safety_status, clean_from_report, clean_from_report_path, + current_file_identity, current_parent_identity, CleanSafetyStatus, +}; +use kratos_core::clean_preview::build_clean_preview; use kratos_core::error::KratosError; -use kratos_core::model::{DeletionCandidateFinding, ReportV2}; +use kratos_core::model::{CleanCandidateFingerprint, DeletionCandidateFinding, ReportV2}; use kratos_core::report::serialize_report_pretty; +use sha2::{Digest, Sha256}; #[test] fn clean_rejects_deletion_candidates_outside_report_root() { @@ -47,7 +52,7 @@ fn clean_rejects_symlink_escape_candidates() { } #[test] -fn clean_deletes_dangling_symlink_candidates() { +fn clean_skips_dangling_symlink_candidates_without_fingerprints() { let temp_root = temp_dir("clean-dangling-symlink"); let report_root = temp_root.join("app"); let dangling_link = report_root.join("dangling.ts"); @@ -56,18 +61,18 @@ fn clean_deletes_dangling_symlink_candidates() { symlink_file(Path::new("missing-target.ts"), &dangling_link); let report = report_with_candidate(&report_root, &dangling_link); - let outcome = clean_from_report(&report, true).expect("clean should delete dangling symlinks"); + let outcome = clean_from_report(&report, true).expect("clean should fail closed"); assert!( - std::fs::symlink_metadata(&dangling_link).is_err(), - "dangling symlink should be deleted" + std::fs::symlink_metadata(&dangling_link).is_ok(), + "dangling symlink should remain" ); - assert_eq!(outcome.deleted_files, 1); - assert_eq!(outcome.skipped_files, 0); + assert_eq!(outcome.deleted_files, 0); + assert_eq!(outcome.skipped_files, 1); } #[test] -fn clean_deletes_live_symlink_candidates_without_touching_targets() { +fn clean_skips_live_symlink_candidates_without_touching_targets() { let temp_root = temp_dir("clean-live-symlink"); let report_root = temp_root.join("app"); let outside_root = temp_root.join("outside"); @@ -80,19 +85,45 @@ fn clean_deletes_live_symlink_candidates_without_touching_targets() { symlink_file(&outside_file, &symlink_path); let report = report_with_candidate(&report_root, &symlink_path); - let outcome = clean_from_report(&report, true).expect("clean should delete symlink entries"); + let outcome = clean_from_report(&report, true).expect("clean should fail closed"); assert!( - std::fs::symlink_metadata(&symlink_path).is_err(), - "symlink entry should be deleted" + std::fs::symlink_metadata(&symlink_path).is_ok(), + "symlink entry should remain" ); assert!(outside_file.exists(), "target file should remain untouched"); - assert_eq!(outcome.deleted_files, 1); - assert_eq!(outcome.skipped_files, 0); + assert_eq!(outcome.deleted_files, 0); + assert_eq!(outcome.skipped_files, 1); +} + +#[test] +fn clean_skips_direct_symlink_even_with_forged_matching_evidence() { + let temp_root = temp_dir("clean-forged-symlink-evidence"); + let report_root = temp_root.join("app"); + let outside_root = temp_root.join("outside"); + let outside_file = outside_root.join("keep.ts"); + let symlink_path = report_root.join("linked.ts"); + + std::fs::create_dir_all(&report_root).expect("report root should exist"); + std::fs::create_dir_all(&outside_root).expect("outside root should exist"); + std::fs::write(&outside_file, "export const keep = true;\n").expect("target should write"); + symlink_file(&outside_file, &symlink_path); + + let mut report = report_with_candidate(&report_root, &symlink_path); + report.findings.deletion_candidates[0].safe = true; + report.clean_safety.candidates[0].fingerprint = regular_file_fingerprint(&outside_file); + report.clean_safety.candidates[0].identity = current_file_identity(&outside_file); + + let outcome = clean_from_report(&report, true).expect("clean should fail closed"); + + assert!(std::fs::symlink_metadata(&symlink_path).is_ok()); + assert!(outside_file.exists()); + assert_eq!(outcome.deleted_files, 0); + assert_eq!(outcome.skipped_files, 1); } #[test] -fn clean_allows_symlinked_project_root_and_removes_empty_directories() { +fn clean_allows_symlinked_project_root_without_removing_parent_directories() { let temp_root = temp_dir("clean-symlink-root"); let real_root = temp_root.join("real-app"); let symlink_root = temp_root.join("linked-app"); @@ -107,13 +138,13 @@ fn clean_allows_symlinked_project_root_and_removes_empty_directories() { let outcome = clean_from_report(&report, true).expect("clean should succeed"); assert!(!dead_file.exists()); - assert!(!nested_dir.exists()); + assert!(nested_dir.exists()); assert_eq!(outcome.deleted_files, 1); assert_eq!(outcome.skipped_files, 0); } #[test] -fn clean_ignores_cleanup_failures_after_successful_delete() { +fn clean_deletes_through_root_contained_symlink_parent_without_parent_cleanup() { let temp_root = temp_dir("clean-best-effort-cleanup"); let report_root = temp_root.join("app"); let real_nested_dir = report_root.join("real-nested"); @@ -142,13 +173,14 @@ fn clean_from_report_path_accepts_future_schema_reports_when_shape_is_compatible std::fs::create_dir_all(report_path.parent().expect("report dir should exist")) .expect("report dir should exist"); std::fs::write(&dead_file, "export const dead = true;\n").expect("dead file writes"); + let report = report_with_candidate(&report_root, &dead_file); + let serialized = serialize_report_pretty(&report).expect("report should serialize"); + let mut value: serde_json::Value = + serde_json::from_str(&serialized).expect("report JSON should parse"); + value["schemaVersion"] = serde_json::json!(4); std::fs::write( &report_path, - format!( - "{{\"schemaVersion\":3,\"generatedAt\":\"2026-04-21T00:00:00Z\",\"project\":{{\"root\":\"{}\",\"configPath\":null}},\"summary\":{{\"filesScanned\":1,\"entrypoints\":0,\"brokenImports\":0,\"orphanFiles\":0,\"deadExports\":0,\"unusedImports\":0,\"routeEntrypoints\":0,\"deletionCandidates\":1}},\"findings\":{{\"brokenImports\":[],\"orphanFiles\":[],\"deadExports\":[],\"unusedImports\":[],\"routeEntrypoints\":[],\"deletionCandidates\":[{{\"file\":\"{}\",\"reason\":\"test\",\"confidence\":1.0,\"safe\":true}}]}},\"graph\":{{\"modules\":[]}}}}", - report_root.display(), - dead_file.display(), - ), + serde_json::to_string_pretty(&value).expect("future report should serialize"), ) .expect("report writes"); @@ -211,7 +243,7 @@ fn clean_from_report_rejects_reports_older_than_v2() { } #[test] -fn clean_from_report_path_reads_v2_report_and_deletes_candidate() { +fn clean_from_report_path_reads_current_report_and_deletes_unchanged_candidate() { let temp_root = temp_dir("clean-report-path-v2"); let report_root = temp_root.join("app"); let dead_file = report_root.join("dead.ts"); @@ -233,8 +265,213 @@ fn clean_from_report_path_reads_v2_report_and_deletes_candidate() { assert_eq!(outcome.skipped_files, 0); } +#[test] +fn clean_skips_candidate_when_content_changed_after_report() { + let temp_root = temp_dir("clean-stale-content"); + let report_root = temp_root.join("app"); + let dead_file = report_root.join("dead.ts"); + + std::fs::create_dir_all(&report_root).expect("report root should exist"); + std::fs::write(&dead_file, "export const dead = true;\n").expect("dead file writes"); + let report = report_with_candidate(&report_root, &dead_file); + std::fs::write(&dead_file, "export const nowUsed = true;\n").expect("file should change"); + + let outcome = clean_from_report(&report, true).expect("clean should fail closed"); + + assert!(dead_file.exists()); + assert_eq!(outcome.deleted_files, 0); + assert_eq!(outcome.skipped_files, 1); +} + +#[test] +fn clean_skips_candidate_recreated_at_the_same_path() { + let temp_root = temp_dir("clean-recreated-content"); + let report_root = temp_root.join("app"); + let dead_file = report_root.join("dead.ts"); + + std::fs::create_dir_all(&report_root).expect("report root should exist"); + std::fs::write(&dead_file, "export const old = true;\n").expect("old file writes"); + let report = report_with_candidate(&report_root, &dead_file); + std::fs::remove_file(&dead_file).expect("old file should delete"); + std::fs::write(&dead_file, "export const replacement = true;\n") + .expect("replacement file writes"); + + let outcome = clean_from_report(&report, true).expect("clean should fail closed"); + + assert!(dead_file.exists()); + assert_eq!(outcome.deleted_files, 0); + assert_eq!(outcome.skipped_files, 1); +} + +#[test] +fn clean_skips_same_content_file_recreated_at_the_same_path() { + let temp_root = temp_dir("clean-recreated-same-content"); + let report_root = temp_root.join("app"); + let dead_file = report_root.join("dead.ts"); + let content = "export const dead = true;\n"; + + std::fs::create_dir_all(&report_root).expect("report root should exist"); + std::fs::write(&dead_file, content).expect("old file writes"); + let report = report_with_candidate(&report_root, &dead_file); + std::fs::remove_file(&dead_file).expect("old file should delete"); + std::fs::write(&dead_file, content).expect("same-content replacement should write"); + + let outcome = clean_from_report(&report, true).expect("clean should fail closed"); + + assert!(dead_file.exists()); + assert_eq!(outcome.deleted_files, 0); + assert_eq!(outcome.skipped_files, 1); +} + +#[test] +fn clean_skips_safe_false_even_with_a_matching_fingerprint() { + let temp_root = temp_dir("clean-safe-false"); + let report_root = temp_root.join("app"); + let dead_file = report_root.join("dead.ts"); + + std::fs::create_dir_all(&report_root).expect("report root should exist"); + std::fs::write(&dead_file, "export const dead = true;\n").expect("dead file writes"); + let mut report = report_with_candidate(&report_root, &dead_file); + report.findings.deletion_candidates[0].safe = false; + + let outcome = clean_from_report(&report, true).expect("clean should fail closed"); + + assert!(dead_file.exists()); + assert_eq!(outcome.deleted_files, 0); + assert_eq!(outcome.skipped_files, 1); +} + +#[test] +fn clean_skips_schema_v2_candidate_without_fingerprint_evidence() { + let temp_root = temp_dir("clean-schema-v2-no-fingerprint"); + let report_root = temp_root.join("app"); + let dead_file = report_root.join("dead.ts"); + + std::fs::create_dir_all(&report_root).expect("report root should exist"); + std::fs::write(&dead_file, "export const dead = true;\n").expect("dead file writes"); + let mut report = report_with_candidate(&report_root, &dead_file); + report.version = 2; + report.clean_safety = Default::default(); + + let outcome = clean_from_report(&report, true).expect("clean should fail closed"); + + assert!(dead_file.exists()); + assert_eq!(outcome.deleted_files, 0); + assert_eq!(outcome.skipped_files, 1); +} + +#[test] +fn clean_skips_missing_file_after_report_generation() { + let temp_root = temp_dir("clean-missing-after-report"); + let report_root = temp_root.join("app"); + let dead_file = report_root.join("dead.ts"); + + std::fs::create_dir_all(&report_root).expect("report root should exist"); + std::fs::write(&dead_file, "export const dead = true;\n").expect("dead file writes"); + let report = report_with_candidate(&report_root, &dead_file); + std::fs::remove_file(&dead_file).expect("candidate should be removable before clean"); + + let outcome = clean_from_report(&report, true).expect("clean should fail closed"); + + assert!(!dead_file.exists()); + assert_eq!(outcome.deleted_files, 0); + assert_eq!(outcome.skipped_files, 1); +} + +#[test] +fn clean_skips_candidate_replaced_by_a_non_regular_file() { + let temp_root = temp_dir("clean-non-regular-after-report"); + let report_root = temp_root.join("app"); + let dead_file = report_root.join("dead.ts"); + + std::fs::create_dir_all(&report_root).expect("report root should exist"); + std::fs::write(&dead_file, "export const dead = true;\n").expect("dead file writes"); + let report = report_with_candidate(&report_root, &dead_file); + std::fs::remove_file(&dead_file).expect("candidate should be removable before replacement"); + std::fs::create_dir(&dead_file).expect("directory replacement should be created"); + + let outcome = clean_from_report(&report, true).expect("clean should fail closed"); + + assert!(dead_file.is_dir()); + assert_eq!(outcome.deleted_files, 0); + assert_eq!(outcome.skipped_files, 1); +} + +#[test] +fn clean_skips_unsupported_fingerprint_algorithm() { + let temp_root = temp_dir("clean-unsupported-fingerprint"); + let report_root = temp_root.join("app"); + let dead_file = report_root.join("dead.ts"); + + std::fs::create_dir_all(&report_root).expect("report root should exist"); + std::fs::write(&dead_file, "export const dead = true;\n").expect("dead file writes"); + let mut report = report_with_candidate(&report_root, &dead_file); + report.clean_safety.fingerprint_algorithm = "sha512".to_string(); + + let outcome = clean_from_report(&report, true).expect("clean should fail closed"); + + assert!(dead_file.exists()); + assert_eq!(outcome.deleted_files, 0); + assert_eq!(outcome.skipped_files, 1); +} + +#[test] +fn clean_skips_duplicate_fingerprint_entries() { + let temp_root = temp_dir("clean-duplicate-fingerprint"); + let report_root = temp_root.join("app"); + let dead_file = report_root.join("dead.ts"); + + std::fs::create_dir_all(&report_root).expect("report root should exist"); + std::fs::write(&dead_file, "export const dead = true;\n").expect("dead file writes"); + let mut report = report_with_candidate(&report_root, &dead_file); + report + .clean_safety + .candidates + .push(report.clean_safety.candidates[0].clone()); + + assert_eq!( + clean_candidate_safety_status(&report, &report.findings.deletion_candidates[0]), + CleanSafetyStatus::DuplicateFingerprint + ); + + let outcome = clean_from_report(&report, true).expect("clean should fail closed"); + + assert!(dead_file.exists()); + assert_eq!(outcome.deleted_files, 0); + assert_eq!(outcome.skipped_files, 1); +} + +#[test] +fn duplicate_normalized_deletion_candidates_fail_closed_in_preview_and_apply() { + let temp_root = temp_dir("clean-duplicate-candidate"); + let report_root = temp_root.join("app"); + let dead_file = report_root.join("src/dead.ts"); + std::fs::create_dir_all(dead_file.parent().expect("parent should exist")) + .expect("parent should exist"); + std::fs::write(&dead_file, "dead\n").expect("candidate should write"); + let mut report = report_with_candidate(&report_root, &dead_file); + let mut alias = report.findings.deletion_candidates[0].clone(); + alias.file = report_root.join("src/../src/dead.ts"); + report.findings.deletion_candidates.push(alias); + + let preview = build_clean_preview(&report, 0.0).expect("preview should build"); + assert!(preview.deletion_target_paths.is_empty()); + assert_eq!(preview.items.len(), 2); + assert!(preview + .items + .iter() + .all(|item| item.safety_status == CleanSafetyStatus::DuplicateCandidate)); + + let outcome = clean_from_report(&report, true).expect("apply should fail closed"); + assert!(dead_file.exists()); + assert_eq!(outcome.deleted_files, 0); + assert_eq!(outcome.skipped_files, 2); + assert!(outcome.failed_files.is_empty()); +} + fn report_with_candidate(root: &Path, candidate: &Path) -> ReportV2 { let mut report = ReportV2::new(root.to_path_buf()); + let fingerprint = regular_file_fingerprint(candidate); report .findings .deletion_candidates @@ -242,9 +479,27 @@ fn report_with_candidate(root: &Path, candidate: &Path) -> ReportV2 { file: candidate.to_path_buf(), reason: "test".to_string(), confidence: 1.0, - safe: true, + safe: fingerprint.is_some(), }); report + .clean_safety + .candidates + .push(CleanCandidateFingerprint { + file: candidate.to_path_buf(), + fingerprint, + identity: current_file_identity(candidate), + parent_identity: current_parent_identity(candidate), + }); + report +} + +fn regular_file_fingerprint(path: &Path) -> Option { + let metadata = std::fs::symlink_metadata(path).ok()?; + if !metadata.file_type().is_file() { + return None; + } + let bytes = std::fs::read(path).ok()?; + Some(format!("{:x}", Sha256::digest(bytes))) } fn temp_dir(label: &str) -> PathBuf { diff --git a/crates/kratos-core/tests/clean_thresholds.rs b/crates/kratos-core/tests/clean_thresholds.rs index c536fff..7e1fb86 100644 --- a/crates/kratos-core/tests/clean_thresholds.rs +++ b/crates/kratos-core/tests/clean_thresholds.rs @@ -1,8 +1,12 @@ use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; -use kratos_core::clean::{clean_from_report_with_min_confidence, plan_clean_candidates}; -use kratos_core::model::{DeletionCandidateFinding, ReportV2}; +use kratos_core::clean::{ + clean_from_report_with_min_confidence, current_file_identity, current_parent_identity, + plan_clean_candidates, +}; +use kratos_core::model::{CleanCandidateFingerprint, DeletionCandidateFinding, ReportV2}; +use sha2::{Digest, Sha256}; #[test] fn plan_clean_candidates_splits_deletion_targets_and_threshold_skips() { @@ -81,10 +85,26 @@ fn report_with_candidates(root: &Path, candidates: &[(&str, f32)]) -> ReportV2 { safe: true, }) .collect(); + report.clean_safety.candidates = report + .findings + .deletion_candidates + .iter() + .map(|candidate| CleanCandidateFingerprint { + file: candidate.file.clone(), + fingerprint: Some(content_fingerprint(&candidate.file)), + identity: current_file_identity(&candidate.file), + parent_identity: current_parent_identity(&candidate.file), + }) + .collect(); report } +fn content_fingerprint(path: &Path) -> String { + let bytes = std::fs::read(path).expect("candidate file should read"); + format!("{:x}", Sha256::digest(bytes)) +} + fn temp_dir(label: &str) -> PathBuf { let unique = SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/crates/kratos-core/tests/config_and_discovery.rs b/crates/kratos-core/tests/config_and_discovery.rs index 9415287..4e48519 100644 --- a/crates/kratos-core/tests/config_and_discovery.rs +++ b/crates/kratos-core/tests/config_and_discovery.rs @@ -1078,7 +1078,8 @@ runs: } #[test] -fn analyze_project_excludes_script_workflow_and_tooling_entries_from_deletion_candidates() { +fn analyze_project_excludes_framework_script_workflow_and_tooling_entries_from_deletion_candidates() +{ let project = TestProject::new("script-workflow-analysis"); project.write( "package.json", @@ -1105,6 +1106,14 @@ jobs: "export const fromWorkflow = true;\n", ); project.write("eslint.config.mjs", "export default [];\n"); + project.write( + "app/dashboard/page.tsx", + "export default function Dashboard() { return null; }\n", + ); + project.write( + "app/api/health/route.ts", + "export function GET() { return new Response('ok'); }\n", + ); project.write("src/unused.ts", "export const unused = true;\n"); let report = analyze_project(project.root()).expect("project should analyze"); @@ -1118,7 +1127,25 @@ jobs: assert!(!deletion_candidates.contains(&project.root().join("scripts/generate.mjs"))); assert!(!deletion_candidates.contains(&project.root().join("scripts/from-workflow.mjs"))); assert!(!deletion_candidates.contains(&project.root().join("eslint.config.mjs"))); + assert!(!deletion_candidates.contains(&project.root().join("app/dashboard/page.tsx"))); + assert!(!deletion_candidates.contains(&project.root().join("app/api/health/route.ts"))); assert!(deletion_candidates.contains(&project.root().join("src/unused.ts"))); + let unused_candidate = report + .findings + .deletion_candidates + .iter() + .find(|candidate| candidate.file == project.root().join("src/unused.ts")) + .expect("unused source should be a deletion candidate"); + assert!(unused_candidate.safe); + let fingerprint = report + .clean_safety + .candidates + .iter() + .find(|entry| entry.file == unused_candidate.file) + .and_then(|entry| entry.fingerprint.as_deref()) + .expect("safe candidate should have fingerprint evidence"); + assert_eq!(report.clean_safety.fingerprint_algorithm, "sha256"); + assert_eq!(fingerprint.len(), 64); } #[test] diff --git a/crates/kratos-core/tests/report_diff.rs b/crates/kratos-core/tests/report_diff.rs index 768fd86..7d4f4e2 100644 --- a/crates/kratos-core/tests/report_diff.rs +++ b/crates/kratos-core/tests/report_diff.rs @@ -1,9 +1,9 @@ use std::path::PathBuf; use kratos_core::model::{ - BrokenImportFinding, DeadExportFinding, DeletionCandidateFinding, EntrypointKind, ExportKind, - FindingSet, ImportKind, ModuleRecord, OrphanFileFinding, OrphanKind, ReportV2, - RouteEntrypointFinding, SummaryCounts, UnusedImportFinding, + BrokenImportFinding, CleanCandidateFingerprint, DeadExportFinding, DeletionCandidateFinding, + EntrypointKind, ExportKind, FindingSet, ImportKind, ModuleRecord, OrphanFileFinding, + OrphanKind, ReportV2, RouteEntrypointFinding, SummaryCounts, UnusedImportFinding, }; use kratos_core::report_diff::{ diff_reports, format_diff_json, format_diff_markdown, format_diff_summary, @@ -137,6 +137,87 @@ fn diff_reports_only_tracks_finding_changes_and_ignores_report_metadata() { ); } +#[test] +fn diff_reports_ignore_clean_safety_fingerprint_changes() { + let findings = finding_set( + vec![], + vec![], + vec![], + vec![], + vec![], + vec![deletion_candidate( + "/repo/src/delete.ts", + "unused", + 0.98, + true, + )], + ); + let mut before = report_v2( + "/repo", + None, + None, + SummaryCounts::default(), + findings.clone(), + vec![], + ); + let mut after = report_v2( + "/repo", + None, + None, + SummaryCounts::default(), + findings, + vec![], + ); + before.clean_safety.candidates = vec![CleanCandidateFingerprint { + file: "/repo/src/delete.ts".into(), + fingerprint: Some("before".to_string()), + identity: Some("before-identity".to_string()), + parent_identity: Some("before-parent".to_string()), + }]; + after.clean_safety.candidates = vec![CleanCandidateFingerprint { + file: "/repo/src/delete.ts".into(), + fingerprint: Some("after".to_string()), + identity: Some("after-identity".to_string()), + parent_identity: Some("after-parent".to_string()), + }]; + + let diff = diff_reports(&before, &after); + + assert_eq!(diff.summary.deletion_candidates.introduced, 0); + assert_eq!(diff.summary.deletion_candidates.resolved, 0); + assert_eq!(diff.summary.deletion_candidates.persisted, 1); +} + +#[test] +fn diff_reports_treat_safe_migration_metadata_as_persisted() { + let before_finding = deletion_candidate("/repo/src/delete.ts", "unused", 0.98, false); + let mut after_finding = before_finding.clone(); + after_finding.safe = true; + let mut before = report_v2( + "/repo", + None, + None, + SummaryCounts::default(), + finding_set(vec![], vec![], vec![], vec![], vec![], vec![before_finding]), + vec![], + ); + before.version = 2; + let after = report_v2( + "/repo", + None, + None, + SummaryCounts::default(), + finding_set(vec![], vec![], vec![], vec![], vec![], vec![after_finding]), + vec![], + ); + + let diff = diff_reports(&before, &after); + + assert_eq!(diff.summary.deletion_candidates.introduced, 0); + assert_eq!(diff.summary.deletion_candidates.resolved, 0); + assert_eq!(diff.summary.deletion_candidates.persisted, 1); +} + #[test] fn diff_formatters_render_summary_markdown_and_json() { let before = report_v2( @@ -534,6 +615,7 @@ fn report_v2( config_path: config_path.map(PathBuf::from), summary, findings, + clean_safety: Default::default(), modules, } } diff --git a/crates/kratos-core/tests/report_format.rs b/crates/kratos-core/tests/report_format.rs index cd8a662..6b22fbb 100644 --- a/crates/kratos-core/tests/report_format.rs +++ b/crates/kratos-core/tests/report_format.rs @@ -239,7 +239,7 @@ fn summary_hides_route_entrypoint_details_but_markdown_keeps_them() { #[test] fn summary_and_markdown_formatters_accept_future_schema_versions() { let report = parse_report_json( - "{\"schemaVersion\":3,\"project\":{\"root\":\"/tmp/demo\",\"configPath\":null},\"summary\":{\"filesScanned\":0,\"entrypoints\":0,\"brokenImports\":0,\"orphanFiles\":0,\"deadExports\":0,\"unusedImports\":0,\"routeEntrypoints\":0,\"deletionCandidates\":0},\"findings\":{\"brokenImports\":[],\"orphanFiles\":[],\"deadExports\":[],\"unusedImports\":[],\"routeEntrypoints\":[],\"deletionCandidates\":[]},\"graph\":{\"modules\":[]}}", + "{\"schemaVersion\":4,\"project\":{\"root\":\"/tmp/demo\",\"configPath\":null},\"summary\":{\"filesScanned\":0,\"entrypoints\":0,\"brokenImports\":0,\"orphanFiles\":0,\"deadExports\":0,\"unusedImports\":0,\"routeEntrypoints\":0,\"deletionCandidates\":0},\"findings\":{\"brokenImports\":[],\"orphanFiles\":[],\"deadExports\":[],\"unusedImports\":[],\"routeEntrypoints\":[],\"deletionCandidates\":[]},\"cleanSafety\":{\"fingerprintAlgorithm\":\"sha256\",\"candidates\":[]},\"graph\":{\"modules\":[]}}", ) .expect("future-schema report should parse"); @@ -260,8 +260,10 @@ fn summary_and_markdown_formatters_accept_future_schema_versions() { #[test] fn incomplete_future_schema_reports_fail_fast_instead_of_rendering_defaults() { - let error = parse_report_json("{\"schemaVersion\":3,\"project\":{\"root\":\"/tmp/demo\"}}") - .expect_err("incomplete future-schema report should fail"); + let error = parse_report_json( + "{\"schemaVersion\":4,\"project\":{\"root\":\"/tmp/demo\"},\"cleanSafety\":{\"fingerprintAlgorithm\":\"sha256\",\"candidates\":[]}}", + ) + .expect_err("incomplete future-schema report should fail"); assert!(error.to_string().contains("required object `summary`")); } diff --git a/crates/kratos-core/tests/report_v2.rs b/crates/kratos-core/tests/report_v2.rs index 5b91f81..65a8c6c 100644 --- a/crates/kratos-core/tests/report_v2.rs +++ b/crates/kratos-core/tests/report_v2.rs @@ -7,7 +7,7 @@ use kratos_core::report::{ format_markdown_report, format_summary_report, parse_report_json, serialize_report_pretty, validate_report_version, }; -use serde_json::Value; +use serde_json::{json, Value}; #[test] fn report_v2_matches_parity_fixture_outputs() { @@ -41,15 +41,15 @@ fn report_v2_matches_parity_fixture_outputs() { } #[test] -fn report_v2_serializes_schema_version_2_and_roundtrips_core_fields() { +fn current_report_serializes_schema_version_3_and_roundtrips_core_fields() { let repo_root = repo_root(); let demo_root = repo_root.join("fixtures/demo-app"); let report = analyze_project(&demo_root).expect("demo app should analyze"); let serialized = serialize_report_pretty(&report).expect("report should serialize"); let parsed = parse_report_json(&serialized).expect("serialized report should parse"); - validate_report_version(&parsed).expect("v2 report should validate"); + validate_report_version(&parsed).expect("current report should validate"); - assert_eq!(parsed.version, 2); + assert_eq!(parsed.version, 3); assert_eq!(parsed.root, demo_root); assert_eq!(parsed.config_path, report.config_path); assert_eq!(parsed.summary, report.summary); @@ -71,6 +71,7 @@ fn report_v2_serializes_schema_version_2_and_roundtrips_core_fields() { parsed.findings.deletion_candidates, report.findings.deletion_candidates ); + assert_eq!(parsed.clean_safety, report.clean_safety); assert_eq!( parsed .modules @@ -118,7 +119,7 @@ fn report_v2_serializes_schema_version_2_and_roundtrips_core_fields() { .cloned() .unwrap_or_else(|| fixture_value["modules"].clone()); - assert_eq!(normalized["schemaVersion"], Value::from(2)); + assert_eq!(normalized["schemaVersion"], Value::from(3)); assert_eq!(normalized["generatedAt"], Value::from("")); assert_eq!(normalized["project"]["root"], Value::from("")); assert_eq!(normalized["project"]["configPath"], Value::Null); @@ -165,13 +166,82 @@ fn report_v2_serializes_schema_version_2_and_roundtrips_core_fields() { } #[test] -fn report_v2_writer_keys_match_the_frozen_v1_contract() { +fn schema_v3_reader_requires_well_typed_clean_safety_evidence() { + let demo_root = repo_root().join("fixtures/demo-app"); + let report = analyze_project(&demo_root).expect("demo app should analyze"); + let serialized = serialize_report_pretty(&report).expect("report should serialize"); + let value: Value = serde_json::from_str(&serialized).expect("report JSON should parse"); + + let mut missing_manifest = value.clone(); + missing_manifest + .as_object_mut() + .expect("report should be an object") + .remove("cleanSafety"); + let error = parse_report_json( + &serde_json::to_string(&missing_manifest).expect("mutated report should serialize"), + ) + .expect_err("schema v3 should require cleanSafety"); + assert!(error.to_string().contains("cleanSafety")); + + let mut invalid_algorithm = value.clone(); + invalid_algorithm["cleanSafety"]["fingerprintAlgorithm"] = Value::from(7); + let error = parse_report_json( + &serde_json::to_string(&invalid_algorithm).expect("mutated report should serialize"), + ) + .expect_err("fingerprintAlgorithm should require a string"); + assert!(error + .to_string() + .contains("cleanSafety.fingerprintAlgorithm")); + + let mut invalid_candidates = value.clone(); + invalid_candidates["cleanSafety"]["candidates"] = json!({}); + let error = parse_report_json( + &serde_json::to_string(&invalid_candidates).expect("mutated report should serialize"), + ) + .expect_err("clean safety candidates should require an array"); + assert!(error.to_string().contains("cleanSafety.candidates")); + + let mut invalid_fingerprint = value.clone(); + invalid_fingerprint["cleanSafety"]["candidates"][0]["fingerprint"] = Value::Bool(true); + let error = parse_report_json( + &serde_json::to_string(&invalid_fingerprint).expect("mutated report should serialize"), + ) + .expect_err("fingerprint should require a string or null"); + assert!(error + .to_string() + .contains("cleanSafety.candidates[0].fingerprint")); + + let mut invalid_identity = value.clone(); + invalid_identity["cleanSafety"]["candidates"][0]["identity"] = Value::Bool(true); + let error = parse_report_json( + &serde_json::to_string(&invalid_identity).expect("mutated report should serialize"), + ) + .expect_err("identity should require a string or null"); + assert!(error + .to_string() + .contains("cleanSafety.candidates[0].identity")); + + let mut invalid_parent_identity = value; + invalid_parent_identity["cleanSafety"]["candidates"][0]["parentIdentity"] = + Value::Bool(true); + let error = parse_report_json( + &serde_json::to_string(&invalid_parent_identity).expect("mutated report should serialize"), + ) + .expect_err("parentIdentity should require a string or null"); + assert!(error + .to_string() + .contains("cleanSafety.candidates[0].parentIdentity")); +} + +#[test] +fn current_writer_keys_match_the_schema_v3_clean_safety_contract() { const TOP_LEVEL_KEYS: &[&str] = &[ "schemaVersion", "generatedAt", "project", "summary", "findings", + "cleanSafety", "graph", ]; const PROJECT_KEYS: &[&str] = &["root", "configPath"]; @@ -208,6 +278,9 @@ fn report_v2_writer_keys_match_the_frozen_v1_contract() { const UNUSED_IMPORT_KEYS: &[&str] = &["file", "source", "local", "imported"]; const ROUTE_ENTRYPOINT_KEYS: &[&str] = &["file", "kind"]; const DELETION_CANDIDATE_KEYS: &[&str] = &["file", "reason", "confidence", "safe"]; + const CLEAN_SAFETY_KEYS: &[&str] = &["fingerprintAlgorithm", "candidates"]; + const CLEAN_SAFETY_CANDIDATE_KEYS: &[&str] = + &["file", "fingerprint", "identity", "parentIdentity"]; const GRAPH_KEYS: &[&str] = &["modules"]; const MODULE_KEYS: &[&str] = &[ "file", @@ -232,7 +305,7 @@ fn report_v2_writer_keys_match_the_frozen_v1_contract() { let serialized = serialize_report_pretty(&report).expect("report should serialize"); let value: Value = serde_json::from_str(&serialized).expect("serialized report should parse"); - assert_eq!(value["schemaVersion"], Value::from(2)); + assert_eq!(value["schemaVersion"], Value::from(3)); assert_object_keys(&value, TOP_LEVEL_KEYS); assert_object_keys(&value["project"], PROJECT_KEYS); assert_object_keys(&value["summary"], SUMMARY_KEYS); @@ -267,6 +340,30 @@ fn report_v2_writer_keys_match_the_frozen_v1_contract() { &finding_values["deletionCandidates"][0], DELETION_CANDIDATE_KEYS, ); + assert_object_keys(&value["cleanSafety"], CLEAN_SAFETY_KEYS); + assert_eq!(value["cleanSafety"]["fingerprintAlgorithm"], "sha256"); + let clean_candidates = value["cleanSafety"]["candidates"] + .as_array() + .expect("clean safety candidates should be an array"); + assert_eq!( + clean_candidates.len(), + finding_values["deletionCandidates"] + .as_array() + .expect("deletion candidates should be an array") + .len() + ); + for candidate in clean_candidates { + assert_object_keys(candidate, CLEAN_SAFETY_CANDIDATE_KEYS); + assert_eq!( + candidate["fingerprint"] + .as_str() + .expect("fingerprint should be a string") + .len(), + 64 + ); + assert!(candidate["identity"].as_str().is_some()); + assert!(candidate["parentIdentity"].as_str().is_some()); + } assert_object_keys(&value["graph"], GRAPH_KEYS); let modules = value["graph"]["modules"] @@ -598,8 +695,8 @@ fn legacy_report_parses_into_renderable_v2_report() { let report_path = Path::new("/tmp/kratos/.kratos/latest-report.json"); assert_eq!(parsed.version, 2); - validate_report_version(&parsed).expect("legacy report should be canonicalized to v2"); - serialize_report_pretty(&parsed).expect("legacy report should serialize"); + assert_eq!(parsed.clean_safety.fingerprint_algorithm, "sha256"); + assert!(parsed.clean_safety.candidates.is_empty()); format_summary_report(&parsed, report_path).expect("legacy report should format as summary"); format_markdown_report(&parsed, report_path).expect("legacy report should format as markdown"); } @@ -650,7 +747,7 @@ fn report_v2_dead_exports_include_evidence_and_legacy_shape_still_parses() { } }"#; - let parsed = + let mut parsed = parse_report_json(legacy_dead_export_report).expect("legacy dead export should parse"); let finding = &parsed.findings.dead_exports[0]; @@ -663,7 +760,9 @@ fn report_v2_dead_exports_include_evidence_and_legacy_shape_still_parses() { assert!(finding.used_export_names.is_empty()); assert!(!finding.has_namespace_or_unknown_usage); - let serialized = serialize_report_pretty(&parsed).expect("report should serialize"); + parsed.version = 3; + parsed.clean_safety.fingerprint_algorithm = "sha256".to_string(); + let serialized = serialize_report_pretty(&parsed).expect("current report should serialize"); let serialized_value: Value = serde_json::from_str(&serialized).expect("serialized report should be valid JSON"); let serialized_finding = &serialized_value["findings"]["deadExports"][0]; diff --git a/docs/plans/v1-cli-report-contract.md b/docs/plans/v1-cli-report-contract.md index 98c336e..0905340 100644 --- a/docs/plans/v1-cli-report-contract.md +++ b/docs/plans/v1-cli-report-contract.md @@ -1,6 +1,6 @@ # Kratos v1 CLI/report contract evidence -Issue: [#91](https://github.com/JeremyDev87/kratos/issues/91) +Issues: [#91](https://github.com/JeremyDev87/kratos/issues/91) contract baseline; [#92](https://github.com/JeremyDev87/kratos/issues/92) clean-safety migration This note freezes the consumer-visible v1 CLI/report contract before any version bump. It is intentionally limited to command/report semantics and does not bump versions, create tags, publish packages, or dispatch release workflows. @@ -17,11 +17,11 @@ This note freezes the consumer-visible v1 CLI/report contract before any version - Unknown commands and invalid explicit format, boolean, threshold, or incompatible option values return exit code `1` with `Kratos 실행 실패: ...` or the Korean command-help error path. - Compatibility exceptions retained from the JavaScript baseline return `0` when the underlying command succeeds: `scan`/`clean` ignore unknown flags, `report` ignores surplus positionals, and bare or empty `report --format` falls back to `summary`. - `clean` without `--apply` is a preview/dry-run and returns `0` when the report is valid. -- `clean --apply` returns `0` after completing its delete/skip plan, including a successful no-op, and returns `1` for invalid input or an unhandled filesystem error. +- `clean --apply` returns `0` after completing a delete/skip plan, including a successful no-op. It returns `1` for invalid input or any per-file filesystem failure after reporting deleted/skipped/failed counts and the failed path. ## Report JSON contract -The v1 writer emits schema version `2`. Stable writer key names are centralized in the private `report_contract` module and used by the serializer; independent literal expectations in the contract test freeze the emitted shape. +The frozen v1 baseline emitted schema version `2`. Issue #92 advances the current writer to schema version `3` because apply-time deletion safety now requires persisted content fingerprints. Stable writer key names remain centralized in the private `report_contract` module and independent literal expectations freeze each emitted shape. Required top-level keys: @@ -30,6 +30,7 @@ Required top-level keys: - `project` - `summary` - `findings` +- `cleanSafety` - `graph` Required nested keys: @@ -50,6 +51,13 @@ Finding item shapes: - `routeEntrypoints[]`: `file`, `kind` - `deletionCandidates[]`: `file`, `reason`, `confidence`, `safe` +Schema-v3 clean-safety shape: + +- `cleanSafety`: `fingerprintAlgorithm`, `candidates` +- `cleanSafety.candidates[]`: `file`, `fingerprint`, `identity`, `parentIdentity` +- `fingerprintAlgorithm` is currently `sha256`; `fingerprint` is a lowercase 64-character digest of the same bytes used by analysis, `identity` is the platform stable-file identity captured from that opened regular file, and `parentIdentity` is the stable identity of the candidate's parent directory captured alongside it. Any evidence field is `null` when it cannot be collected, making the candidate non-deletable. +- Schema-v2 and legacy reports remain readable for summary/Markdown/diff compatibility, but their deletion candidates do not have fingerprint evidence and therefore fail closed during `clean`. + The writer-key contract test uses independent literal expectations for every container, finding item, and graph module key above. It also fixes representative item values/types; existing parity and round-trip tests cover broader serialized values and reader compatibility. ## Format contract @@ -68,11 +76,14 @@ The writer-key contract test uses independent literal expectations for every con - A report with no deletion candidates is a successful no-op (`0`) before threshold configuration is loaded. - `--min-confidence` overrides `thresholds.cleanMinConfidence`; when neither is provided, the threshold is `0.0`. Candidates below the effective threshold are skipped. -- Preview excludes candidates whose normalized path or real parent escapes the report root. Root-contained missing or unreadable candidates remain visible with status/marker evidence instead of being silently omitted. -- Apply skips root-escaping, missing, and below-threshold candidates. `clean --apply` reports deleted and skipped counts; non-`NotFound` deletion errors return `1`, and cleanup of now-empty parent directories is best-effort within the report root. -- The required `safe` field is descriptive metadata in the current v1 implementation; apply is gated by deletion-candidate membership, confidence, filesystem existence, and root containment. Consumers must not treat `safe` alone as deletion authorization. Fail-closed hardening remains follow-up work for issue #92. +- Preview excludes candidates whose normalized path or real parent escapes the report root. Root-contained candidates that fail safety validation remain visible in a separate safety-skipped section with status/marker evidence instead of being silently omitted. +- Apply requires exactly one normalized deletion candidate and one matching manifest entry, `safe: true`, the confidence threshold, root/real-parent containment, `sha256`, stable file and parent-directory identity, and content equality. +- After precheck, apply atomically moves the pathname into a unique private quarantine directly under the report root, then rechecks the original candidate parent's identity plus quarantine root containment, file identity, and content. This detects a candidate-parent relocation even when the redirected pathname is a matching hard link to the analyzed inode. The moved link is restored without clobbering an independently created pathname; only an object that passes every post-move check is deleted. Candidates on a filesystem that cannot be renamed into the report-root quarantine fail closed. A process crash or power loss during the short quarantine window can leave verified contents preserved under the report root as `.kratos-clean-quarantine-*`; this path is intentionally not auto-deleted and must be restored manually. The report root itself must remain stable for the duration of apply; concurrent relocation/replacement of the entire project root is outside the supported threat model. +- Apply skips schema-v2/legacy candidates without evidence, duplicate/aliased candidate paths, `safe: false`, missing/unreadable/non-regular files, direct symlinks, duplicate/missing manifest evidence, unsupported algorithms or platforms without stable identity evidence, identity/content mismatches, root escapes, and below-threshold candidates. +- `clean --apply` reports deleted, skipped, and failed counts. Per-file failures do not discard prior successful-delete accounting. Parent directories are intentionally left in place so a mutable-parent race cannot turn convenience cleanup into an out-of-root directory removal. +- Fingerprint, file identity, and `safe` are execution-safety metadata only. They are excluded from finding identity, so the v2→v3 migration and content changes do not create diff churn. ## Evidence added in this PR -- `crates/kratos-core/tests/report_v2.rs::report_v2_writer_keys_match_the_frozen_v1_contract` verifies emitted writer keys against independent literal key lists and representative value/type assertions. +- `crates/kratos-core/tests/report_v2.rs::current_writer_keys_match_the_schema_v3_clean_safety_contract` verifies emitted writer keys against independent literal key lists and representative value/type assertions while retaining schema-v2 reader tests. - `crates/kratos-cli/tests/cli_smoke.rs::scan_report_and_clean_work_for_demo_fixture` now includes diff summary/json smoke evidence alongside scan/report/clean evidence. diff --git a/test/package-smoke.test.js b/test/package-smoke.test.js index ea80fe4..89d16b5 100644 --- a/test/package-smoke.test.js +++ b/test/package-smoke.test.js @@ -122,7 +122,48 @@ test("packed root package boots the actual native addon for the current platform assert.equal(runResult.status, 0, runResult.stderr || runResult.stdout); const report = JSON.parse(runResult.stdout); - assert.equal(report.schemaVersion, 2); + assert.equal(report.schemaVersion, 3); + assert.equal(report.cleanSafety.fingerprintAlgorithm, "sha256"); + assert.equal(report.cleanSafety.candidates.length, report.findings.deletionCandidates.length); + assert.deepEqual( + report.cleanSafety.candidates.map((candidate) => candidate.file).sort(), + report.findings.deletionCandidates.map((candidate) => candidate.file).sort(), + ); + if (process.platform === "win32") { + assert.equal( + report.cleanSafety.candidates.every( + (candidate) => + typeof candidate.fingerprint === "string" && + candidate.fingerprint.length === 64 && + candidate.identity === null && + candidate.parentIdentity === null, + ), + true, + ); + assert.equal( + report.findings.deletionCandidates.every((candidate) => candidate.safe === false), + true, + ); + } else { + assert.equal( + report.cleanSafety.candidates.every( + (candidate) => typeof candidate.fingerprint === "string" && candidate.fingerprint.length === 64, + ), + true, + ); + assert.equal( + report.cleanSafety.candidates.every( + (candidate) => typeof candidate.identity === "string" && candidate.identity.length > 0, + ), + true, + ); + assert.equal( + report.cleanSafety.candidates.every( + (candidate) => typeof candidate.parentIdentity === "string" && candidate.parentIdentity.length > 0, + ), + true, + ); + } assert.equal(path.resolve(report.project.root), demoAppPath); }); From a6d24657f3d19b6b6583877f9bd226464ce83484 Mon Sep 17 00:00:00 2001 From: JeremyDev87 Date: Thu, 16 Jul 2026 01:38:44 +0900 Subject: [PATCH 02/11] =?UTF-8?q?test:=20Windows=20fail-closed=20=ED=8C=A8?= =?UTF-8?q?=ED=82=A4=EC=A7=80=20smoke=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows에서는 persistent file identity evidence가 null이고 clean apply가 비활성화되는 계약을 패키지 smoke에서 검증합니다.\n\nCo-authored-by: Hermes --- test/package-smoke.test.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/package-smoke.test.js b/test/package-smoke.test.js index 89d16b5..07b97cc 100644 --- a/test/package-smoke.test.js +++ b/test/package-smoke.test.js @@ -133,12 +133,12 @@ test("packed root package boots the actual native addon for the current platform assert.equal( report.cleanSafety.candidates.every( (candidate) => - typeof candidate.fingerprint === "string" && - candidate.fingerprint.length === 64 && - candidate.identity === null && - candidate.parentIdentity === null, + (candidate.fingerprint === null || + (typeof candidate.fingerprint === "string" && candidate.fingerprint.length === 64)) && + (candidate.identity === null || candidate.parentIdentity === null), ), true, + JSON.stringify(report.cleanSafety.candidates), ); assert.equal( report.findings.deletionCandidates.every((candidate) => candidate.safe === false), From 92a9dda31ca10bd75374f89248d8693e457a77be Mon Sep 17 00:00:00 2001 From: JeremyDev87 Date: Thu, 16 Jul 2026 02:02:56 +0900 Subject: [PATCH 03/11] =?UTF-8?q?fix:=20clean=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=EB=A5=BC=20descriptor=20=EA=B2=BD=EA=B3=84=EC=97=90=20?= =?UTF-8?q?=EA=B3=A0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit renameat/linkat/unlinkat와 openat 기반 검증으로 parent 및 quarantine pathname 교체가 외부 파일 삭제로 이어지지 않게 합니다. schema v3 README와 legacy fail-closed 정책도 동기화합니다. Co-authored-by: Hermes --- Cargo.lock | 1 + README.en.md | 16 +- README.es.md | 16 +- README.ja.md | 16 +- README.md | 16 +- README.zh-CN.md | 16 +- crates/kratos-core/Cargo.toml | 3 + crates/kratos-core/src/clean.rs | 489 ++++++++++++++++++-------- crates/kratos-core/src/fingerprint.rs | 55 ++- docs/plans/v1-cli-report-contract.md | 4 +- 10 files changed, 464 insertions(+), 168 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c1d3edc..e148e3c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -296,6 +296,7 @@ dependencies = [ name = "kratos-core" version = "0.3.7" dependencies = [ + "libc", "oxc_allocator", "oxc_ast", "oxc_ast_visit", diff --git a/README.en.md b/README.en.md index a47c8a6..60711b3 100644 --- a/README.en.md +++ b/README.en.md @@ -171,11 +171,11 @@ Totals: introduced 0, resolved 0, persisted 9 ## Report Schema -`scan` currently writes `schemaVersion: 2` reports. +`scan` currently writes `schemaVersion: 3` reports. ```json { - "schemaVersion": 2, + "schemaVersion": 3, "summary": { "filesScanned": 5, "entrypoints": 1, @@ -185,11 +185,23 @@ Totals: introduced 0, resolved 0, persisted 9 "unusedImports": 0, "routeEntrypoints": 1, "deletionCandidates": 2 + }, + "cleanSafety": { + "fingerprintAlgorithm": "sha256", + "candidates": [ + { + "file": "", + "fingerprint": "", + "identity": "", + "parentIdentity": "" + } + ] } } ``` `findings` contains `brokenImports`, `orphanFiles`, `deadExports`, `unusedImports`, `routeEntrypoints`, and `deletionCandidates`. `graph.modules` records analyzed module paths, entrypoint status, and import/export counts. +v2/legacy reports remain readable, but `clean --apply` fails closed because they do not contain `cleanSafety` evidence. ## Configuration diff --git a/README.es.md b/README.es.md index 1a02093..5e08f55 100644 --- a/README.es.md +++ b/README.es.md @@ -167,11 +167,11 @@ Totals: introduced 0, resolved 0, persisted 9 ## Esquema Del Reporte -Actualmente `scan` escribe reportes con `schemaVersion: 2`. +Actualmente `scan` escribe reportes con `schemaVersion: 3`. ```json { - "schemaVersion": 2, + "schemaVersion": 3, "summary": { "filesScanned": 5, "entrypoints": 1, @@ -181,11 +181,23 @@ Actualmente `scan` escribe reportes con `schemaVersion: 2`. "unusedImports": 0, "routeEntrypoints": 1, "deletionCandidates": 2 + }, + "cleanSafety": { + "fingerprintAlgorithm": "sha256", + "candidates": [ + { + "file": "", + "fingerprint": "", + "identity": "", + "parentIdentity": "" + } + ] } } ``` `findings` contiene `brokenImports`, `orphanFiles`, `deadExports`, `unusedImports`, `routeEntrypoints` y `deletionCandidates`. `graph.modules` registra rutas de módulos analizados, estado de entrypoint y conteos de imports/exports. +Los reportes v2/legacy siguen siendo legibles, pero `clean --apply` falla de forma cerrada porque no contienen evidencia `cleanSafety`. ## Configuración diff --git a/README.ja.md b/README.ja.md index 45c10b5..492ee34 100644 --- a/README.ja.md +++ b/README.ja.md @@ -167,11 +167,11 @@ Totals: introduced 0, resolved 0, persisted 9 ## レポートスキーマ -現在の `scan` は `schemaVersion: 2` の report を書き込みます。 +現在の `scan` は `schemaVersion: 3` の report を書き込みます。 ```json { - "schemaVersion": 2, + "schemaVersion": 3, "summary": { "filesScanned": 5, "entrypoints": 1, @@ -181,11 +181,23 @@ Totals: introduced 0, resolved 0, persisted 9 "unusedImports": 0, "routeEntrypoints": 1, "deletionCandidates": 2 + }, + "cleanSafety": { + "fingerprintAlgorithm": "sha256", + "candidates": [ + { + "file": "", + "fingerprint": "", + "identity": "", + "parentIdentity": "" + } + ] } } ``` `findings` には `brokenImports`、`orphanFiles`、`deadExports`、`unusedImports`、`routeEntrypoints`、`deletionCandidates` が入ります。`graph.modules` には解析済みのモジュールパス、entrypoint 状態、import/export 件数が記録されます。 +v2/legacy report は引き続き読み込めますが、`cleanSafety` evidence がないため `clean --apply` は fail-closed になります。 ## 設定 diff --git a/README.md b/README.md index 88ca812..a0dcc44 100644 --- a/README.md +++ b/README.md @@ -171,11 +171,11 @@ Totals: introduced 0, resolved 0, persisted 9 ## 리포트 스키마 -현재 `scan`은 `schemaVersion: 2` report를 생성합니다. +현재 `scan`은 `schemaVersion: 3` report를 생성합니다. ```json { - "schemaVersion": 2, + "schemaVersion": 3, "summary": { "filesScanned": 5, "entrypoints": 1, @@ -185,11 +185,23 @@ Totals: introduced 0, resolved 0, persisted 9 "unusedImports": 0, "routeEntrypoints": 1, "deletionCandidates": 2 + }, + "cleanSafety": { + "fingerprintAlgorithm": "sha256", + "candidates": [ + { + "file": "", + "fingerprint": "", + "identity": "", + "parentIdentity": "" + } + ] } } ``` `findings`에는 `brokenImports`, `orphanFiles`, `deadExports`, `unusedImports`, `routeEntrypoints`, `deletionCandidates`가 들어갑니다. `graph.modules`에는 분석된 모듈 경로, entrypoint 여부, import/export 개수가 기록됩니다. +v2/legacy report는 계속 읽을 수 있지만 `cleanSafety` evidence가 없으므로 `clean --apply`에서는 fail-closed됩니다. ## 설정 diff --git a/README.zh-CN.md b/README.zh-CN.md index b25f2af..d6c0b0d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -167,11 +167,11 @@ Totals: introduced 0, resolved 0, persisted 9 ## 报告 Schema -当前 `scan` 会写入 `schemaVersion: 2` report。 +当前 `scan` 会写入 `schemaVersion: 3` report。 ```json { - "schemaVersion": 2, + "schemaVersion": 3, "summary": { "filesScanned": 5, "entrypoints": 1, @@ -181,11 +181,23 @@ Totals: introduced 0, resolved 0, persisted 9 "unusedImports": 0, "routeEntrypoints": 1, "deletionCandidates": 2 + }, + "cleanSafety": { + "fingerprintAlgorithm": "sha256", + "candidates": [ + { + "file": "", + "fingerprint": "", + "identity": "", + "parentIdentity": "" + } + ] } } ``` `findings` 包含 `brokenImports`、`orphanFiles`、`deadExports`、`unusedImports`、`routeEntrypoints` 和 `deletionCandidates`。`graph.modules` 会记录已分析的模块路径、entrypoint 状态以及 import/export 数量。 +v2/legacy report 仍可读取,但由于缺少 `cleanSafety` evidence,`clean --apply` 会 fail-closed。 ## 配置 diff --git a/crates/kratos-core/Cargo.toml b/crates/kratos-core/Cargo.toml index 90c697f..4a5d866 100644 --- a/crates/kratos-core/Cargo.toml +++ b/crates/kratos-core/Cargo.toml @@ -17,3 +17,6 @@ oxc_span = "0.125.0" oxc_syntax = "0.125.0" serde_json = "1.0.149" sha2 = "0.10" + +[target.'cfg(unix)'.dependencies] +libc = "0.2" diff --git a/crates/kratos-core/src/clean.rs b/crates/kratos-core/src/clean.rs index bf42929..337468e 100644 --- a/crates/kratos-core/src/clean.rs +++ b/crates/kratos-core/src/clean.rs @@ -1,6 +1,18 @@ -use std::io::ErrorKind; use std::path::{Path, PathBuf}; + +#[cfg(unix)] +use std::ffi::{CStr, CString}; +#[cfg(unix)] +use std::fs::File; +#[cfg(unix)] +use std::io::ErrorKind; +#[cfg(unix)] +use std::os::unix::ffi::OsStrExt; +#[cfg(unix)] +use std::os::unix::io::{AsRawFd, FromRawFd}; +#[cfg(unix)] use std::sync::atomic::{AtomicU64, Ordering}; +#[cfg(unix)] use std::time::{SystemTime, UNIX_EPOCH}; use serde_json::Value; @@ -10,9 +22,12 @@ use crate::fingerprint::{ current_parent_identity as fingerprint_parent_identity, inspect_regular_file, FileSnapshot, CONTENT_FINGERPRINT_ALGORITHM, }; +#[cfg(unix)] +use crate::fingerprint::{directory_identity_from_file, inspect_open_regular_file}; use crate::model::{CleanCandidateFingerprint, DeletionCandidateFinding, ReportV2, REPORT_V2}; use crate::report::parse_report_json; +#[cfg(unix)] static QUARANTINE_COUNTER: AtomicU64 = AtomicU64::new(0); #[derive(Clone, Debug, Default, PartialEq, Eq)] @@ -326,12 +341,14 @@ fn snapshot_from_entry(entry: &CleanCandidateFingerprint) -> Option( report_root: &Path, candidate_path: &Path, @@ -343,34 +360,45 @@ where F: FnMut(&Path), G: FnMut(&Path), { - let quarantine_dir = match create_quarantine_dir(report_root) { - Ok(path) => path, - Err(error) => return QuarantineOutcome::Failed(error.to_string()), + let Some(parent_path) = candidate_path.parent() else { + return QuarantineOutcome::Skipped; }; - let quarantined_path = quarantine_dir.join("candidate"); - let source_parent_matches = candidate_path - .parent() - .and_then(fingerprint_parent_identity) - .as_deref() - == Some(expected.parent_identity.as_str()); - if !source_parent_matches || !is_safe_clean_candidate(report_root, candidate_path) { - let _ = std::fs::remove_dir(&quarantine_dir); + if !is_safe_clean_candidate(report_root, candidate_path) { return QuarantineOutcome::Skipped; } - before_move(candidate_path); - let source_parent_matches_after_hook = candidate_path - .parent() - .and_then(fingerprint_parent_identity) - .as_deref() - == Some(expected.parent_identity.as_str()); - if !source_parent_matches_after_hook || !is_safe_clean_candidate(report_root, candidate_path) { - let _ = std::fs::remove_dir(&quarantine_dir); + let canonical_parent = match std::fs::canonicalize(parent_path) { + Ok(path) => path, + Err(_) => return QuarantineOutcome::Skipped, + }; + let source_parent = match open_directory(&canonical_parent) { + Ok(directory) => directory, + Err(_) => return QuarantineOutcome::Skipped, + }; + if directory_identity_from_file(&source_parent).as_deref() + != Some(expected.parent_identity.as_str()) + { return QuarantineOutcome::Skipped; } - if let Err(error) = std::fs::rename(candidate_path, &quarantined_path) { - let _ = std::fs::remove_dir(&quarantine_dir); + let candidate_name = match cstring_file_name(candidate_path) { + Ok(name) => name, + Err(error) => return QuarantineOutcome::Failed(error.to_string()), + }; + let quarantine = match QuarantineDirectory::create(report_root) { + Ok(directory) => directory, + Err(error) => return QuarantineOutcome::Failed(error.to_string()), + }; + let quarantined_path = quarantine.path.join("candidate"); + + before_move(candidate_path); + if let Err(error) = rename_at( + &source_parent, + &candidate_name, + &quarantine.directory, + quarantine_candidate_name(), + ) { + quarantine.remove_empty(); return if error.kind() == ErrorKind::NotFound { QuarantineOutcome::Skipped } else { @@ -378,130 +406,302 @@ where }; } - let source_parent_matches = candidate_path - .parent() - .and_then(fingerprint_parent_identity) - .as_deref() - == Some(expected.parent_identity.as_str()); - let verified = source_parent_matches - && is_safe_clean_candidate(report_root, candidate_path) - && is_safe_clean_candidate(report_root, &quarantined_path) - && inspect_regular_file(&quarantined_path) - .map(|actual| { - actual.identity == expected.identity && actual.fingerprint == expected.fingerprint - }) - .unwrap_or(false); - + let source_path_valid = source_parent_path_is_unchanged(report_root, candidate_path, expected); + let quarantine_path_valid = quarantine.path_is_pinned(report_root); + let quarantine_file_valid = quarantine_candidate_matches(&quarantine.directory, expected); + let verified = source_path_valid && quarantine_path_valid && quarantine_file_valid; if !verified { - return match restore_quarantined_file( - report_root, + return restore_or_preserve( + &quarantine, + &source_parent, + &candidate_name, &quarantined_path, - candidate_path, - expected, - ) { - Ok(()) => { - let _ = std::fs::remove_dir(&quarantine_dir); - QuarantineOutcome::Skipped - } - Err(error) => QuarantineOutcome::Failed(format!( - "검증 실패 파일을 복원하지 못했습니다. 보존 위치: {} ({error})", - quarantined_path.display() - )), - }; + QuarantineOutcome::Skipped, + ); } after_quarantine_verification(&quarantined_path); - let quarantine_still_verified = is_safe_clean_candidate(report_root, &quarantined_path) - && inspect_regular_file(&quarantined_path) - .map(|actual| { - actual.identity == expected.identity && actual.fingerprint == expected.fingerprint - }) - .unwrap_or(false); - if !quarantine_still_verified { - return QuarantineOutcome::Failed(format!( - "검증된 quarantine 경로가 변경되어 삭제하지 않았습니다. 보존 위치: {}", - quarantined_path.display() - )); + let still_verified = source_parent_path_is_unchanged(report_root, candidate_path, expected) + && quarantine.path_is_pinned(report_root) + && quarantine_candidate_matches(&quarantine.directory, expected); + if !still_verified { + return restore_or_preserve( + &quarantine, + &source_parent, + &candidate_name, + &quarantined_path, + QuarantineOutcome::Skipped, + ); } - match std::fs::remove_file(&quarantined_path) { + match unlink_at(&quarantine.directory, quarantine_candidate_name(), 0) { Ok(()) => { - let _ = std::fs::remove_dir(&quarantine_dir); + quarantine.remove_empty(); QuarantineOutcome::Deleted } - Err(delete_error) => match restore_quarantined_file( - report_root, + Err(delete_error) => restore_or_preserve( + &quarantine, + &source_parent, + &candidate_name, &quarantined_path, - candidate_path, - expected, - ) { - Ok(()) => { - let _ = std::fs::remove_dir(&quarantine_dir); - QuarantineOutcome::Failed(delete_error.to_string()) - } - Err(restore_error) => QuarantineOutcome::Failed(format!( - "삭제와 복원에 실패했습니다. 보존 위치: {} (삭제: {delete_error}; 복원: {restore_error})", - quarantined_path.display() - )), - }, + QuarantineOutcome::Failed(delete_error.to_string()), + ), } } -fn create_quarantine_dir(parent: &Path) -> std::io::Result { - for _ in 0..100 { - let nonce = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_nanos(); - let counter = QUARANTINE_COUNTER.fetch_add(1, Ordering::Relaxed); - let path = parent.join(format!( - ".kratos-clean-quarantine-{}-{nonce}-{counter}", - std::process::id() - )); - match create_private_directory(&path) { - Ok(()) => return Ok(path), - Err(error) if error.kind() == ErrorKind::AlreadyExists => continue, - Err(error) => return Err(error), - } - } +#[cfg(not(unix))] +fn quarantine_and_delete( + _report_root: &Path, + _candidate_path: &Path, + _expected: &FileSnapshot, + _before_move: &mut F, + _after_quarantine_verification: &mut G, +) -> QuarantineOutcome +where + F: FnMut(&Path), + G: FnMut(&Path), +{ + // Platforms without descriptor-relative stable identity support never receive + // deletion-ready evidence. Keep this final boundary fail closed as well. + QuarantineOutcome::Skipped +} - Err(std::io::Error::new( - ErrorKind::AlreadyExists, - "could not allocate a unique clean quarantine directory", - )) +#[cfg(unix)] +struct QuarantineDirectory { + root_directory: File, + directory: File, + root_path: PathBuf, + path: PathBuf, + name: CString, } #[cfg(unix)] -fn create_private_directory(path: &Path) -> std::io::Result<()> { - use std::os::unix::fs::DirBuilderExt; +impl QuarantineDirectory { + fn create(report_root: &Path) -> std::io::Result { + let root_path = std::fs::canonicalize(report_root)?; + let root_directory = open_directory(&root_path)?; + + for _ in 0..100 { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let counter = QUARANTINE_COUNTER.fetch_add(1, Ordering::Relaxed); + let name = CString::new(format!( + ".kratos-clean-quarantine-{}-{nonce}-{counter}", + std::process::id() + )) + .map_err(|_| std::io::Error::new(ErrorKind::InvalidInput, "invalid quarantine name"))?; + + let created = + unsafe { libc::mkdirat(root_directory.as_raw_fd(), name.as_ptr(), 0o700) }; + if created != 0 { + let error = std::io::Error::last_os_error(); + if error.kind() == ErrorKind::AlreadyExists { + continue; + } + return Err(error); + } - std::fs::DirBuilder::new().mode(0o700).create(path) -} + match open_directory_at(&root_directory, &name) { + Ok(directory) => { + let path = root_path.join(std::ffi::OsStr::from_bytes(name.to_bytes())); + return Ok(Self { + root_directory, + directory, + root_path, + path, + name, + }); + } + Err(error) => { + let _ = unlink_at(&root_directory, &name, libc::AT_REMOVEDIR); + return Err(error); + } + } + } -#[cfg(not(unix))] -fn create_private_directory(path: &Path) -> std::io::Result<()> { - std::fs::create_dir(path) + Err(std::io::Error::new( + ErrorKind::AlreadyExists, + "could not allocate a unique clean quarantine directory", + )) + } + + fn path_is_pinned(&self, report_root: &Path) -> bool { + let descriptor_identity = directory_identity_from_file(&self.directory); + let path_identity = fingerprint_parent_identity(&self.path); + let root_matches = self.root_path == realpath_or_fallback(report_root); + descriptor_identity.is_some() && descriptor_identity == path_identity && root_matches + } + + fn remove_empty(&self) { + let _ = unlink_at(&self.root_directory, &self.name, libc::AT_REMOVEDIR); + } } -fn restore_quarantined_file( +#[cfg(unix)] +fn source_parent_path_is_unchanged( report_root: &Path, - quarantined_path: &Path, - original_path: &Path, + candidate_path: &Path, expected: &FileSnapshot, -) -> std::io::Result<()> { - let parent_matches = original_path +) -> bool { + candidate_path .parent() .and_then(fingerprint_parent_identity) .as_deref() - == Some(expected.parent_identity.as_str()); - if !parent_matches || !is_safe_clean_candidate(report_root, original_path) { - return Err(std::io::Error::other( - "candidate parent changed or escaped report root during restore", - )); + == Some(expected.parent_identity.as_str()) + && is_safe_clean_candidate(report_root, candidate_path) +} + +#[cfg(unix)] +fn quarantine_candidate_matches(directory: &File, expected: &FileSnapshot) -> bool { + open_file_at(directory, quarantine_candidate_name()) + .and_then(inspect_open_regular_file) + .map(|(fingerprint, identity)| { + identity == expected.identity && fingerprint == expected.fingerprint + }) + .unwrap_or(false) +} + +#[cfg(unix)] +fn restore_or_preserve( + quarantine: &QuarantineDirectory, + source_parent: &File, + candidate_name: &CStr, + quarantined_path: &Path, + restored_outcome: QuarantineOutcome, +) -> QuarantineOutcome { + match link_at( + &quarantine.directory, + quarantine_candidate_name(), + source_parent, + candidate_name, + ) + .and_then(|_| unlink_at(&quarantine.directory, quarantine_candidate_name(), 0)) + { + Ok(()) => { + quarantine.remove_empty(); + restored_outcome + } + Err(error) => QuarantineOutcome::Failed(format!( + "검증 실패 파일을 복원하지 못했습니다. 보존 위치: {} ({error})", + quarantined_path.display() + )), + } +} + +#[cfg(unix)] +#[allow(clippy::manual_c_str_literals)] +fn quarantine_candidate_name() -> &'static CStr { + CStr::from_bytes_with_nul(b"candidate\0").expect("static quarantine name is valid") +} + +#[cfg(unix)] +fn cstring_file_name(path: &Path) -> std::io::Result { + let name = path.file_name().ok_or_else(|| { + std::io::Error::new(ErrorKind::InvalidInput, "candidate has no file name") + })?; + CString::new(name.as_bytes()) + .map_err(|_| std::io::Error::new(ErrorKind::InvalidInput, "candidate name contains NUL")) +} + +#[cfg(unix)] +fn open_directory(path: &Path) -> std::io::Result { + let path = CString::new(path.as_os_str().as_bytes()) + .map_err(|_| std::io::Error::new(ErrorKind::InvalidInput, "directory path contains NUL"))?; + let fd = unsafe { + libc::open( + path.as_ptr(), + libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW, + ) + }; + file_from_fd(fd) +} + +#[cfg(unix)] +fn open_directory_at(parent: &File, name: &CStr) -> std::io::Result { + let fd = unsafe { + libc::openat( + parent.as_raw_fd(), + name.as_ptr(), + libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW, + ) + }; + file_from_fd(fd) +} + +#[cfg(unix)] +fn open_file_at(parent: &File, name: &CStr) -> std::io::Result { + let fd = unsafe { + libc::openat( + parent.as_raw_fd(), + name.as_ptr(), + libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW, + ) + }; + file_from_fd(fd) +} + +#[cfg(unix)] +fn file_from_fd(fd: libc::c_int) -> std::io::Result { + if fd < 0 { + Err(std::io::Error::last_os_error()) + } else { + Ok(unsafe { File::from_raw_fd(fd) }) + } +} + +#[cfg(unix)] +fn rename_at( + old_parent: &File, + old_name: &CStr, + new_parent: &File, + new_name: &CStr, +) -> std::io::Result<()> { + let result = unsafe { + libc::renameat( + old_parent.as_raw_fd(), + old_name.as_ptr(), + new_parent.as_raw_fd(), + new_name.as_ptr(), + ) + }; + zero_or_last_error(result) +} + +#[cfg(unix)] +fn link_at( + old_parent: &File, + old_name: &CStr, + new_parent: &File, + new_name: &CStr, +) -> std::io::Result<()> { + let result = unsafe { + libc::linkat( + old_parent.as_raw_fd(), + old_name.as_ptr(), + new_parent.as_raw_fd(), + new_name.as_ptr(), + 0, + ) + }; + zero_or_last_error(result) +} + +#[cfg(unix)] +fn unlink_at(parent: &File, name: &CStr, flags: libc::c_int) -> std::io::Result<()> { + let result = unsafe { libc::unlinkat(parent.as_raw_fd(), name.as_ptr(), flags) }; + zero_or_last_error(result) +} + +#[cfg(unix)] +fn zero_or_last_error(result: libc::c_int) -> std::io::Result<()> { + if result == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) } - std::fs::hard_link(quarantined_path, original_path)?; - std::fs::remove_file(quarantined_path) } fn realpath_or_fallback(path: &Path) -> PathBuf { @@ -616,7 +816,7 @@ mod tests { ) .expect("per-file failure should be reported in the outcome"); - assert_eq!(outcome.deleted_files, 1); + assert_eq!(outcome.deleted_files, 1, "{outcome:?}"); assert_eq!(outcome.skipped_files, 0); assert_eq!(outcome.failed_files.len(), 1); assert_eq!(outcome.failed_files[0].file, second); @@ -680,15 +880,30 @@ mod tests { .expect("replacement should write outside root"); }, ) - .expect("verified quarantine deletion should complete"); + .expect("verified quarantine deletion should fail closed"); - assert_eq!(outcome.deleted_files, 1); + assert_eq!(outcome.deleted_files, 0, "{outcome:?}"); assert_eq!(outcome.skipped_files, 0); - assert!(outcome.failed_files.is_empty()); + assert_eq!(outcome.failed_files.len(), 1); assert_eq!( std::fs::read_to_string(&replacement).expect("replacement should remain"), "replacement\n" ); + let preserved = std::fs::read_dir(&root) + .expect("root should remain readable") + .filter_map(Result::ok) + .map(|entry| entry.path()) + .find(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with(".kratos-clean-quarantine-")) + }) + .expect("quarantine should be preserved"); + assert_eq!( + std::fs::read_to_string(preserved.join("candidate")) + .expect("analyzed candidate should remain quarantined"), + "analyzed\n" + ); } #[cfg(unix)] @@ -749,39 +964,17 @@ mod tests { .expect("redirecting quarantine symlink should install"); }, ) - .expect("clean should report quarantine relocation as failure"); + .expect("clean should restore from the pinned quarantine descriptor"); assert_eq!(outcome.deleted_files, 0); - assert_eq!(outcome.skipped_files, 0); - assert_eq!(outcome.failed_files.len(), 1); + assert_eq!(outcome.skipped_files, 1); + assert!(outcome.failed_files.is_empty()); assert_eq!( std::fs::read_to_string(&outside_target).expect("outside target should remain"), "outside\n" ); - assert!(outside.join("moved-quarantine/candidate").exists()); - } - - #[cfg(unix)] - #[test] - fn restore_refuses_a_parent_symlink_escape() { - let root = temp_dir("clean-restore-race"); - let nested = root.join("nested"); - let moved_nested = temp_dir("clean-restore-race-moved"); - let candidate = nested.join("candidate.ts"); - let quarantined = root.join("quarantine-candidate"); - std::fs::create_dir_all(&nested).expect("nested should exist"); - std::fs::write(&candidate, "candidate\n").expect("candidate should write"); - std::fs::write(&quarantined, "candidate\n").expect("quarantine should write"); - let expected = inspect_regular_file(&candidate).expect("snapshot should exist"); - std::fs::rename(&nested, moved_nested.join("nested")) - .expect("candidate parent should move"); - std::os::unix::fs::symlink(moved_nested.join("nested"), &nested) - .expect("redirecting parent symlink should install"); - - let result = restore_quarantined_file(&root, &quarantined, &candidate, &expected); - assert!(result.is_err()); - assert!(quarantined.exists()); - assert!(moved_nested.join("nested/candidate.ts").exists()); + assert!(!outside.join("moved-quarantine/candidate").exists()); + assert!(candidate.exists()); } fn report_for_files(root: &Path, files: &[PathBuf]) -> ReportV2 { diff --git a/crates/kratos-core/src/fingerprint.rs b/crates/kratos-core/src/fingerprint.rs index f1906bb..b747d84 100644 --- a/crates/kratos-core/src/fingerprint.rs +++ b/crates/kratos-core/src/fingerprint.rs @@ -1,4 +1,6 @@ use std::fs::{File, Metadata}; +#[cfg(unix)] +use std::io::Seek; use std::io::{Error, ErrorKind, Read}; use std::path::Path; @@ -36,14 +38,15 @@ pub(crate) fn read_source_and_snapshot( (before_identity == opened_identity.as_deref()? && after_identity == before_identity) .then_some(before_identity) }); - let stable_parent = before_parent - .as_ref() - .zip(after_parent.as_ref()) - .and_then(|(before, after)| { - let before_identity = directory_identity(before)?; - let after_identity = directory_identity(after)?; - (before_identity == after_identity).then_some(before_identity) - }); + let stable_parent = + before_parent + .as_ref() + .zip(after_parent.as_ref()) + .and_then(|(before, after)| { + let before_identity = directory_identity(before)?; + let after_identity = directory_identity(after)?; + (before_identity == after_identity).then_some(before_identity) + }); let snapshot = stable_path .zip(stable_parent) @@ -118,6 +121,42 @@ pub(crate) fn inspect_regular_file(path: &Path) -> std::io::Result }) } +#[cfg(unix)] +pub(crate) fn inspect_open_regular_file(mut file: File) -> std::io::Result<(String, String)> { + let before = file.metadata()?; + let Some(identity) = regular_file_identity(&before) else { + return Err(Error::new( + ErrorKind::Unsupported, + "stable file identity is unavailable", + )); + }; + + file.rewind()?; + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + + let after = file.metadata()?; + if regular_file_identity(&after).as_deref() != Some(identity.as_str()) { + return Err(Error::other( + "opened file identity changed while fingerprinting", + )); + } + + Ok((format!("{:x}", hasher.finalize()), identity)) +} + +#[cfg(unix)] +pub(crate) fn directory_identity_from_file(file: &File) -> Option { + directory_identity(&file.metadata().ok()?) +} + pub(crate) fn current_parent_identity(path: &Path) -> Option { directory_identity(&std::fs::metadata(path).ok()?) } diff --git a/docs/plans/v1-cli-report-contract.md b/docs/plans/v1-cli-report-contract.md index 0905340..dfd61af 100644 --- a/docs/plans/v1-cli-report-contract.md +++ b/docs/plans/v1-cli-report-contract.md @@ -78,8 +78,8 @@ The writer-key contract test uses independent literal expectations for every con - `--min-confidence` overrides `thresholds.cleanMinConfidence`; when neither is provided, the threshold is `0.0`. Candidates below the effective threshold are skipped. - Preview excludes candidates whose normalized path or real parent escapes the report root. Root-contained candidates that fail safety validation remain visible in a separate safety-skipped section with status/marker evidence instead of being silently omitted. - Apply requires exactly one normalized deletion candidate and one matching manifest entry, `safe: true`, the confidence threshold, root/real-parent containment, `sha256`, stable file and parent-directory identity, and content equality. -- After precheck, apply atomically moves the pathname into a unique private quarantine directly under the report root, then rechecks the original candidate parent's identity plus quarantine root containment, file identity, and content. This detects a candidate-parent relocation even when the redirected pathname is a matching hard link to the analyzed inode. The moved link is restored without clobbering an independently created pathname; only an object that passes every post-move check is deleted. Candidates on a filesystem that cannot be renamed into the report-root quarantine fail closed. A process crash or power loss during the short quarantine window can leave verified contents preserved under the report root as `.kratos-clean-quarantine-*`; this path is intentionally not auto-deleted and must be restored manually. The report root itself must remain stable for the duration of apply; concurrent relocation/replacement of the entire project root is outside the supported threat model. -- Apply skips schema-v2/legacy candidates without evidence, duplicate/aliased candidate paths, `safe: false`, missing/unreadable/non-regular files, direct symlinks, duplicate/missing manifest evidence, unsupported algorithms or platforms without stable identity evidence, identity/content mismatches, root escapes, and below-threshold candidates. +- On Unix, apply opens and identity-checks the canonical candidate parent plus a private report-root quarantine directory, then performs move, restore, and delete with descriptor-relative `renameat`/`linkat`/`unlinkat`. The candidate and quarantine parent pathnames are rechecked for containment/identity, while the destructive operations remain bound to the opened directories. Post-move content/file-identity verification reads the quarantined object through `openat(..., O_NOFOLLOW)`. This prevents parent or quarantine pathname replacement from redirecting deletion to an external hard link. Restore is no-clobber; if the original descriptor-relative name is occupied, the verified quarantine object is preserved and reported. Cross-filesystem `renameat` fails closed without copy/unlink fallback. A process crash or power loss can leave `.kratos-clean-quarantine-*` contents for manual restoration; they are not auto-deleted. Concurrent relocation/replacement of the entire report root remains outside the supported threat model. +- Apply skips schema-v2/legacy candidates without evidence, duplicate/aliased candidate paths, `safe: false`, missing/unreadable/non-regular files, direct symlinks, duplicate/missing manifest evidence, unsupported algorithms or platforms without stable identity evidence, identity/content mismatches, root escapes, and below-threshold candidates. Non-Unix destructive execution currently remains fail-closed because this descriptor-relative stable-identity boundary is unavailable. - `clean --apply` reports deleted, skipped, and failed counts. Per-file failures do not discard prior successful-delete accounting. Parent directories are intentionally left in place so a mutable-parent race cannot turn convenience cleanup into an out-of-root directory removal. - Fingerprint, file identity, and `safe` are execution-safety metadata only. They are excluded from finding identity, so the v2→v3 migration and content changes do not create diff churn. From dc20fcbf1082e9430592a8b50c67ac3eff2d7b1e Mon Sep 17 00:00:00 2001 From: JeremyDev87 Date: Thu, 16 Jul 2026 02:17:20 +0900 Subject: [PATCH 04/11] =?UTF-8?q?test:=20Windows=20clean=20apply=20fail-cl?= =?UTF-8?q?osed=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 패키징된 Windows CLI로 실제 clean --apply를 실행하고 모든 deletion candidate의 내용이 보존되는지 확인합니다. Co-authored-by: Hermes --- test/package-smoke.test.js | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/test/package-smoke.test.js b/test/package-smoke.test.js index 07b97cc..13887a2 100644 --- a/test/package-smoke.test.js +++ b/test/package-smoke.test.js @@ -144,6 +144,32 @@ test("packed root package boots the actual native addon for the current platform report.findings.deletionCandidates.every((candidate) => candidate.safe === false), true, ); + + const cleanProjectPath = path.join(tempRoot, "windows-clean-project"); + const cleanReportPath = path.join(tempRoot, "windows-clean-report.json"); + await fsp.cp(demoAppPath, cleanProjectPath, { recursive: true }); + const scanForClean = runInstalledKratos( + installRoot, + ["scan", cleanProjectPath, "--output", cleanReportPath, "--json"], + { cwd: installRoot }, + ); + assert.equal(scanForClean.status, 0, scanForClean.stderr || scanForClean.stdout); + + const cleanReport = JSON.parse(await fsp.readFile(cleanReportPath, "utf8")); + const preservedCandidates = await Promise.all( + cleanReport.findings.deletionCandidates.map(async (candidate) => ({ + file: candidate.file, + contents: await fsp.readFile(candidate.file), + })), + ); + assert.ok(preservedCandidates.length > 0, "Windows clean smoke requires a deletion candidate"); + const cleanResult = runInstalledKratos(installRoot, ["clean", cleanReportPath, "--apply"], { + cwd: installRoot, + }); + assert.equal(cleanResult.status, 0, cleanResult.stderr || cleanResult.stdout); + for (const candidate of preservedCandidates) { + assert.deepEqual(await fsp.readFile(candidate.file), candidate.contents); + } } else { assert.equal( report.cleanSafety.candidates.every( From 2be047c5d82fc1fa591f3c537294d40d910f941e Mon Sep 17 00:00:00 2001 From: JeremyDev87 Date: Thu, 16 Jul 2026 02:28:07 +0900 Subject: [PATCH 05/11] =?UTF-8?q?test:=20non-Unix=20clean=20fail-closed=20?= =?UTF-8?q?=EA=B3=84=EC=95=BD=20=EC=A0=95=EB=A0=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows future-schema CLI smoke가 null identity를 허용하고 apply 보존을 검증하도록 하며 active roadmap을 schema v3로 맞춥니다. Co-authored-by: Hermes --- crates/kratos-cli/tests/cli_smoke.rs | 32 +++++++++++++++++++++------ docs/plans/01-wow-roadmap-overview.md | 4 ++-- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/crates/kratos-cli/tests/cli_smoke.rs b/crates/kratos-cli/tests/cli_smoke.rs index f27b003..821264d 100644 --- a/crates/kratos-cli/tests/cli_smoke.rs +++ b/crates/kratos-cli/tests/cli_smoke.rs @@ -588,15 +588,24 @@ fn clean_accepts_future_schema_reports_when_the_shape_is_compatible() { let report_path = project_root.join("report-v3-clean.json"); let dead_file = project_root.join("dead.txt"); std::fs::write(&dead_file, "dead\n").expect("dead file should write"); + let identity = current_file_identity(&dead_file); + let parent_identity = current_parent_identity(&dead_file); + let safe = identity.is_some() && parent_identity.is_some(); + let identity_json = identity + .as_deref() + .map(|value| format!("\"{value}\"")) + .unwrap_or_else(|| "null".to_string()); + let parent_identity_json = parent_identity + .as_deref() + .map(|value| format!("\"{value}\"")) + .unwrap_or_else(|| "null".to_string()); std::fs::write( &report_path, format!( - "{{\"schemaVersion\":4,\"generatedAt\":\"2026-04-21T00:00:00Z\",\"project\":{{\"root\":\"{}\",\"configPath\":null}},\"summary\":{{\"filesScanned\":1,\"entrypoints\":0,\"brokenImports\":0,\"orphanFiles\":0,\"deadExports\":0,\"unusedImports\":0,\"routeEntrypoints\":0,\"deletionCandidates\":1}},\"findings\":{{\"brokenImports\":[],\"orphanFiles\":[],\"deadExports\":[],\"unusedImports\":[],\"routeEntrypoints\":[],\"deletionCandidates\":[{{\"file\":\"{}\",\"reason\":\"test\",\"confidence\":1.0,\"safe\":true}}]}},\"cleanSafety\":{{\"fingerprintAlgorithm\":\"sha256\",\"candidates\":[{{\"file\":\"{}\",\"fingerprint\":\"9edc05076fb5a5921c7e8ffe2cc79cc5d711d9612e138d09572f76df4530d870\",\"identity\":\"{}\",\"parentIdentity\":\"{}\"}}]}},\"graph\":{{\"modules\":[]}}}}\n", + "{{\"schemaVersion\":4,\"generatedAt\":\"2026-04-21T00:00:00Z\",\"project\":{{\"root\":\"{}\",\"configPath\":null}},\"summary\":{{\"filesScanned\":1,\"entrypoints\":0,\"brokenImports\":0,\"orphanFiles\":0,\"deadExports\":0,\"unusedImports\":0,\"routeEntrypoints\":0,\"deletionCandidates\":1}},\"findings\":{{\"brokenImports\":[],\"orphanFiles\":[],\"deadExports\":[],\"unusedImports\":[],\"routeEntrypoints\":[],\"deletionCandidates\":[{{\"file\":\"{}\",\"reason\":\"test\",\"confidence\":1.0,\"safe\":{safe}}}]}},\"cleanSafety\":{{\"fingerprintAlgorithm\":\"sha256\",\"candidates\":[{{\"file\":\"{}\",\"fingerprint\":\"9edc05076fb5a5921c7e8ffe2cc79cc5d711d9612e138d09572f76df4530d870\",\"identity\":{identity_json},\"parentIdentity\":{parent_identity_json}}}]}},\"graph\":{{\"modules\":[]}}}}\n", project_root.display(), dead_file.display(), dead_file.display(), - current_file_identity(&dead_file).expect("dead file should have stable identity"), - current_parent_identity(&dead_file).expect("dead parent should have stable identity"), ), ) .expect("report should write"); @@ -620,10 +629,19 @@ fn clean_accepts_future_schema_reports_when_the_shape_is_compatible() { ], ); assert!(apply.status.success()); - assert!( - String::from_utf8_lossy(&apply.stdout).contains("Kratos clean: 파일 1개를 삭제했습니다.") - ); - assert!(!dead_file.exists()); + #[cfg(unix)] + { + assert!(String::from_utf8_lossy(&apply.stdout) + .contains("Kratos clean: 파일 1개를 삭제했습니다.")); + assert!(!dead_file.exists()); + } + #[cfg(not(unix))] + { + let apply_stdout = String::from_utf8_lossy(&apply.stdout); + assert!(apply_stdout.contains("Kratos clean: 파일 0개를 삭제했습니다.")); + assert!(apply_stdout.contains("건너뛴 파일: 1")); + assert!(dead_file.exists()); + } } #[test] diff --git a/docs/plans/01-wow-roadmap-overview.md b/docs/plans/01-wow-roadmap-overview.md index 89bc765..15f928e 100644 --- a/docs/plans/01-wow-roadmap-overview.md +++ b/docs/plans/01-wow-roadmap-overview.md @@ -8,7 +8,7 @@ Kratos는 현재 Rust core/CLI와 npm launcher 기반으로 동작한다. 공개 - `scan`, `report`, `diff`, `clean` - report `summary|json|md` -- report schema `schemaVersion: 2` +- report schema `schemaVersion: 3` (`cleanSafety` 포함; v2/legacy read compatibility 유지) - `React.lazy` / `next/dynamic` 기반 dynamic usage 인식 - `kratos.config.json` 및 `.kratos/suppressions.json` suppression - `thresholds.cleanMinConfidence` 및 `clean --min-confidence` @@ -33,7 +33,7 @@ Kratos는 현재 Rust core/CLI와 npm launcher 기반으로 동작한다. 공개 - 기존 `scan`, `report`, `diff`, `clean` 입력 의미와 기본 동작은 유지한다. - 새 공개 명령은 `sweep`, `watch`만 남은 범위로 본다. - `report`는 기존 `summary|json|md`를 유지하면서 `html`을 추가한다. -- report JSON의 `schemaVersion`은 계속 `2`를 유지한다. +- report JSON의 current `schemaVersion`은 `3`이며, v2/legacy report는 읽되 safety evidence가 없어 destructive apply에서 fail-closed한다. - human-authored config는 계속 `kratos.config.json`을 사용한다. - machine-authored suppression은 `.kratos/suppressions.json`에 저장한다. `sweep`는 이 파일만 자동으로 쓴다. - `watch`는 OS 전용 file watcher dependency 대신 polling loop로 구현한다. From 0a682aca3c930214981263dc5116c03a4ed19a7b Mon Sep 17 00:00:00 2001 From: JeremyDev87 Date: Thu, 16 Jul 2026 03:15:49 +0900 Subject: [PATCH 06/11] =?UTF-8?q?fix:=20clean=20=EA=B2=B0=EA=B3=BC?= =?UTF-8?q?=EB=A5=BC=20=EB=B3=B4=EC=A1=B4=20=EA=B2=A9=EB=A6=AC=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Portable POSIX의 final verify-to-unlink race를 제거하기 위해 검증된 파일을 .kratos/clean-quarantine 아래에 유지하고 원래 코드 경로에서만 제거합니다. CLI, localized README, 계약 문서와 package smoke가 retained quarantine semantics를 검증합니다. Co-authored-by: Hermes --- README.en.md | 10 +- README.es.md | 10 +- README.ja.md | 10 +- README.md | 10 +- README.zh-CN.md | 10 +- crates/kratos-cli/src/commands/clean.rs | 11 +- .../kratos-cli/tests/clean_threshold_cli.rs | 4 +- crates/kratos-cli/tests/cli_smoke.rs | 14 +- crates/kratos-core/src/clean.rs | 208 ++++++++++++++---- crates/kratos-core/tests/clean_safety.rs | 51 +++-- crates/kratos-core/tests/clean_thresholds.rs | 2 +- docs/plans/04-sweep-experience.md | 4 +- docs/plans/v1-cli-report-contract.md | 6 +- test/package-smoke.test.js | 39 ++++ 14 files changed, 282 insertions(+), 107 deletions(-) diff --git a/README.en.md b/README.en.md index 60711b3..fb39787 100644 --- a/README.en.md +++ b/README.en.md @@ -10,7 +10,7 @@ Destroy dead code ruthlessly. Kratos is a CLI tool for JavaScript and TypeScript projects. It finds unused files, broken imports, unused exports, and orphaned modules, then writes the results to a report. The current implementation combines a Rust core/CLI with an npm launcher, and the npm package `@jeremyfellaz/kratos` loads an optional platform-specific native add-on. -Kratos is an analysis tool for a safe cleanup workflow, not an automatic deletion bot. `clean` is dry-run by default, and files are only removed after you review the report and explicitly pass `--apply`. +Kratos is an analysis tool for a safe cleanup workflow, not an automatic deletion bot. `clean` is dry-run by default. After review, `--apply` moves verified files out of their code paths into retained storage under `/.kratos/clean-quarantine/`; it does not physically unlink them automatically. ## Core Capabilities @@ -39,7 +39,7 @@ npx @jeremyfellaz/kratos report ./my-app --format md npx @jeremyfellaz/kratos clean ./my-app --min-confidence 0.9 ``` -Only add `--apply` after reviewing the report and deciding to delete the listed targets. +Only add `--apply` after reviewing the report and deciding to quarantine the listed targets out of their code paths. ```bash npx @jeremyfellaz/kratos clean ./my-app --apply --min-confidence 0.9 @@ -87,10 +87,10 @@ Compares finding changes between two reports. ### `kratos clean [report-path-or-root] [--apply] [--min-confidence value]` -Previews deletion candidates or deletes them. +Previews deletion candidates or moves them out of their code paths into retained quarantine. - Dry-run is the default behavior. -- Files are deleted only when `--apply` is present. +- With `--apply`, files are retained under `/.kratos/clean-quarantine/` instead of being physically unlinked. - `--min-confidence value` is a confidence threshold from `0.0` to `1.0`. - If `--min-confidence` is omitted, Kratos reads `thresholds.cleanMinConfidence` from `kratos.config.json`; when no setting exists, it uses `0.0`. @@ -148,7 +148,7 @@ Deletion targets: 1 Threshold-skipped targets: 1 - /src/lib/broken.ts (confidence 0.88, Module has no inbound references and is not treated as an entrypoint.) -Re-run with --apply to delete these files. +Re-run with --apply to move these files into retained quarantine. ``` Comparing identical reports shows no introduced or resolved findings, only persisted counts. diff --git a/README.es.md b/README.es.md index 5e08f55..288d3ab 100644 --- a/README.es.md +++ b/README.es.md @@ -10,7 +10,7 @@ Elimina código muerto sin piedad. Kratos es una herramienta CLI para proyectos JavaScript y TypeScript. Encuentra archivos no usados, imports rotos, exports no usados y módulos huérfanos, y escribe los resultados en un reporte. La implementación actual combina un core/CLI en Rust con un launcher de npm, y el paquete npm `@jeremyfellaz/kratos` carga un addon nativo opcional específico de la plataforma. -Kratos es una herramienta de análisis para un flujo de limpieza seguro, no un bot de eliminación automática. `clean` usa dry-run por defecto, y los archivos solo se eliminan después de revisar el reporte y pasar `--apply` explícitamente. +Kratos es una herramienta de análisis para un flujo de limpieza seguro, no un bot de eliminación automática. `clean` usa dry-run por defecto. Tras revisar el reporte, `--apply` mueve los archivos verificados fuera de sus rutas de código y los conserva en `/.kratos/clean-quarantine/`; no los elimina físicamente de forma automática. ## Capacidades Principales @@ -35,7 +35,7 @@ npx @jeremyfellaz/kratos report ./my-app --format md npx @jeremyfellaz/kratos clean ./my-app --min-confidence 0.9 ``` -Añade `--apply` solo después de revisar el reporte y decidir eliminar los objetivos listados. +Añade `--apply` solo después de revisar el reporte y decidir poner en cuarentena los objetivos listados fuera de sus rutas de código. ```bash npx @jeremyfellaz/kratos clean ./my-app --apply --min-confidence 0.9 @@ -83,10 +83,10 @@ Compara los cambios de hallazgos entre dos reportes. ### `kratos clean [report-path-or-root] [--apply] [--min-confidence value]` -Previsualiza candidatos de eliminación o los elimina. +Previsualiza candidatos de eliminación o los mueve fuera de sus rutas de código a una cuarentena conservada. - El comportamiento por defecto es dry-run. -- Los archivos solo se eliminan cuando `--apply` está presente. +- Con `--apply`, los archivos se conservan en `/.kratos/clean-quarantine/` en lugar de desvincularse físicamente. - `--min-confidence value` es un umbral de confianza de `0.0` a `1.0`. - Si omites `--min-confidence`, Kratos lee `thresholds.cleanMinConfidence` de `kratos.config.json`; si no existe esa configuración, usa `0.0`. @@ -144,7 +144,7 @@ Deletion targets: 1 Threshold-skipped targets: 1 - /src/lib/broken.ts (confidence 0.88, Module has no inbound references and is not treated as an entrypoint.) -Re-run with --apply to delete these files. +Re-run with --apply to move these files into retained quarantine. ``` Comparar reportes idénticos no muestra hallazgos introducidos ni resueltos, solo conteos persistentes. diff --git a/README.ja.md b/README.ja.md index 492ee34..11dacc4 100644 --- a/README.ja.md +++ b/README.ja.md @@ -10,7 +10,7 @@ Kratos は JavaScript/TypeScript プロジェクト向けの CLI ツールです。未使用ファイル、壊れた import、未使用 export、孤立したモジュールを検出し、結果を report に保存します。現在の実装は Rust core/CLI と npm launcher を組み合わせており、npm package `@jeremyfellaz/kratos` が platform ごとの optional native addon を読み込みます。 -Kratos は自動削除 bot ではなく、安全なクリーンアップ手順のための分析ツールです。`clean` はデフォルトで dry-run であり、report を確認したうえで `--apply` を明示した場合だけファイルを削除します。 +Kratos は自動削除 bot ではなく、安全なクリーンアップ手順のための分析ツールです。`clean` はデフォルトで dry-run です。report 確認後の `--apply` は、検証済みファイルを元のコードパスから `/.kratos/clean-quarantine/` へ移動して保持し、自動で物理削除しません。 ## 主な機能 @@ -35,7 +35,7 @@ npx @jeremyfellaz/kratos report ./my-app --format md npx @jeremyfellaz/kratos clean ./my-app --min-confidence 0.9 ``` -リストされた対象を削除すると判断した後だけ `--apply` を追加してください。 +リストされた対象を元のコードパスから隔離すると判断した後だけ `--apply` を追加してください。 ```bash npx @jeremyfellaz/kratos clean ./my-app --apply --min-confidence 0.9 @@ -83,10 +83,10 @@ npx @jeremyfellaz/kratos diff ./my-app/.kratos/before.json ./my-app/.kratos/afte ### `kratos clean [report-path-or-root] [--apply] [--min-confidence value]` -削除候補を preview するか、実際に削除します。 +削除候補を preview するか、元のコードパスから保持型 quarantine へ移動します。 - デフォルト動作は dry-run です。 -- `--apply` がある場合だけファイルを削除します。 +- `--apply` ではファイルを `/.kratos/clean-quarantine/` に保持し、物理的な unlink は自動実行しません。 - `--min-confidence value` は `0.0` から `1.0` までの信頼度しきい値です。 - `--min-confidence` を省略すると、Kratos は `kratos.config.json` の `thresholds.cleanMinConfidence` を読みます。設定がなければ `0.0` を使います。 @@ -144,7 +144,7 @@ Deletion targets: 1 Threshold-skipped targets: 1 - /src/lib/broken.ts (confidence 0.88, Module has no inbound references and is not treated as an entrypoint.) -Re-run with --apply to delete these files. +Re-run with --apply to move these files into retained quarantine. ``` 同一の report を比較すると、新規または解決済みの検出結果はなく、継続している件数だけが表示されます。 diff --git a/README.md b/README.md index a0dcc44..372074c 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Kratos는 JavaScript/TypeScript 프로젝트에서 사용되지 않는 파일, 끊어진 import, 사용되지 않는 export, 고립된 모듈을 찾아 리포트로 남기는 CLI 도구입니다. 현재 구조는 Rust core/CLI와 npm launcher를 결합하며, npm 패키지 `@jeremyfellaz/kratos`가 플랫폼별 native addon 패키지를 선택적으로 불러 실행합니다. -Kratos는 자동 삭제 도구라기보다 안전한 정리 흐름을 위한 분석 도구입니다. `clean`은 기본적으로 dry-run이며, 실제 삭제는 리포트를 검토한 뒤 `--apply`를 명시했을 때만 수행합니다. +Kratos는 자동 삭제 도구라기보다 안전한 정리 흐름을 위한 분석 도구입니다. `clean`은 기본적으로 dry-run이며, `--apply`는 검증된 파일을 원래 코드 경로에서 `/.kratos/clean-quarantine/`으로 이동해 보존합니다. 자동 물리 삭제는 수행하지 않습니다. ## 핵심 기능 @@ -39,7 +39,7 @@ npx @jeremyfellaz/kratos report ./my-app --format md npx @jeremyfellaz/kratos clean ./my-app --min-confidence 0.9 ``` -리포트를 확인한 뒤 실제 삭제가 필요할 때만 `--apply`를 붙입니다. +리포트를 확인한 뒤 원래 코드 경로에서 격리할 때만 `--apply`를 붙입니다. ```bash npx @jeremyfellaz/kratos clean ./my-app --apply --min-confidence 0.9 @@ -87,10 +87,10 @@ npx @jeremyfellaz/kratos diff ./my-app/.kratos/before.json ./my-app/.kratos/afte ### `kratos clean [report-path-or-root] [--apply] [--min-confidence value]` -삭제 후보를 preview하거나 실제로 삭제합니다. +삭제 후보를 preview하거나 원래 코드 경로에서 보존 격리합니다. - 기본 동작은 dry-run입니다. -- `--apply`를 붙인 경우에만 파일 삭제를 수행합니다. +- `--apply`를 붙이면 파일을 `/.kratos/clean-quarantine/`으로 이동해 보존하며 자동으로 물리 삭제하지 않습니다. - `--min-confidence value`는 `0.0`부터 `1.0`까지의 confidence threshold입니다. - `--min-confidence`를 생략하면 `kratos.config.json`의 `thresholds.cleanMinConfidence`를 사용하고, 설정이 없으면 `0.0`을 사용합니다. @@ -148,7 +148,7 @@ Deletion targets: 1 Threshold-skipped targets: 1 - /src/lib/broken.ts (confidence 0.88, Module has no inbound references and is not treated as an entrypoint.) -Re-run with --apply to delete these files. +Re-run with --apply to move these files into retained quarantine. ``` 동일한 두 리포트를 비교하면 새로 생기거나 해결된 탐지 결과 없이 유지 중인 개수만 표시됩니다. diff --git a/README.zh-CN.md b/README.zh-CN.md index d6c0b0d..260c0ec 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -10,7 +10,7 @@ Kratos 是面向 JavaScript 和 TypeScript 项目的 CLI 工具。它会找出未使用文件、断开的 import、未使用的 export 和孤立模块,并把结果写入 report。当前实现由 Rust core/CLI 和 npm launcher 组成,npm 包 `@jeremyfellaz/kratos` 会按平台加载可选的 native addon。 -Kratos 是服务于安全清理流程的分析工具,不是自动删除机器人。`clean` 默认执行 dry-run,只有在你审阅 report 并显式传入 `--apply` 后才会删除文件。 +Kratos 是服务于安全清理流程的分析工具,不是自动删除机器人。`clean` 默认执行 dry-run。审阅 report 后,`--apply` 会把已验证文件移出原代码路径并保存在 `/.kratos/clean-quarantine/`,不会自动物理删除。 ## 核心能力 @@ -35,7 +35,7 @@ npx @jeremyfellaz/kratos report ./my-app --format md npx @jeremyfellaz/kratos clean ./my-app --min-confidence 0.9 ``` -只有在审阅 report 并决定删除列出的目标后,才添加 `--apply`。 +只有在审阅 report 并决定把列出的目标移出原代码路径并隔离后,才添加 `--apply`。 ```bash npx @jeremyfellaz/kratos clean ./my-app --apply --min-confidence 0.9 @@ -83,10 +83,10 @@ npx @jeremyfellaz/kratos diff ./my-app/.kratos/before.json ./my-app/.kratos/afte ### `kratos clean [report-path-or-root] [--apply] [--min-confidence value]` -预览删除候选项,或真正删除它们。 +预览删除候选项,或把它们移出原代码路径并保留在隔离区。 - 默认行为是 dry-run。 -- 只有存在 `--apply` 时才会删除文件。 +- 使用 `--apply` 时,文件会保存在 `/.kratos/clean-quarantine/`,而不会被自动物理 unlink。 - `--min-confidence value` 是从 `0.0` 到 `1.0` 的置信度阈值。 - 如果省略 `--min-confidence`,Kratos 会读取 `kratos.config.json` 中的 `thresholds.cleanMinConfidence`;没有配置时使用 `0.0`。 @@ -144,7 +144,7 @@ Deletion targets: 1 Threshold-skipped targets: 1 - /src/lib/broken.ts (confidence 0.88, Module has no inbound references and is not treated as an entrypoint.) -Re-run with --apply to delete these files. +Re-run with --apply to move these files into retained quarantine. ``` 比较两个相同 report 时,不会出现新增或已解决的检测结果,只会显示持续存在的数量。 diff --git a/crates/kratos-cli/src/commands/clean.rs b/crates/kratos-cli/src/commands/clean.rs index 52f34cc..9fd3214 100644 --- a/crates/kratos-cli/src/commands/clean.rs +++ b/crates/kratos-cli/src/commands/clean.rs @@ -51,11 +51,18 @@ pub fn run(args: &[String], stdout: &mut dyn Write) -> KratosResult { let outcome = clean_from_report_with_min_confidence(&report, min_confidence)?; let mut output = format!( - "Kratos clean: 파일 {}개를 삭제했습니다.\n건너뛴 파일: {}\n실패한 파일: {}", - outcome.deleted_files, + "Kratos clean: 파일 {}개를 코드 경로에서 격리했습니다.\n보존된 격리 파일: {}\n건너뛴 파일: {}\n실패한 파일: {}", + outcome.quarantined_files.len(), + outcome.quarantined_files.len(), outcome.skipped_files, outcome.failed_files.len() ); + for quarantined in &outcome.quarantined_files { + output.push_str(&format!( + "\n- 격리 보존: {}", + relative_path(quarantined, &report.root) + )); + } for failure in &outcome.failed_files { output.push_str(&format!( "\n- {}: {}", diff --git a/crates/kratos-cli/tests/clean_threshold_cli.rs b/crates/kratos-cli/tests/clean_threshold_cli.rs index 26e46d5..d10114c 100644 --- a/crates/kratos-cli/tests/clean_threshold_cli.rs +++ b/crates/kratos-cli/tests/clean_threshold_cli.rs @@ -40,7 +40,7 @@ fn clean_uses_config_threshold_and_flag_override() { ); assert!(apply.status.success()); let apply_stdout = String::from_utf8_lossy(&apply.stdout); - assert!(apply_stdout.contains("Kratos clean: 파일 1개를 삭제했습니다.")); + assert!(apply_stdout.contains("Kratos clean: 파일 1개를 코드 경로에서 격리했습니다.")); assert!(apply_stdout.contains("건너뛴 파일: 1")); assert!(!project_root.join("high-confidence.ts").exists()); assert!(project_root.join("mid-confidence.ts").exists()); @@ -67,7 +67,7 @@ fn clean_reports_and_skips_stale_fingerprint_candidates() { ); assert!(apply.status.success()); let apply_stdout = String::from_utf8_lossy(&apply.stdout); - assert!(apply_stdout.contains("Kratos clean: 파일 0개를 삭제했습니다.")); + assert!(apply_stdout.contains("Kratos clean: 파일 0개를 코드 경로에서 격리했습니다.")); assert!(apply_stdout.contains("건너뛴 파일: 2")); assert!(high_file.exists()); } diff --git a/crates/kratos-cli/tests/cli_smoke.rs b/crates/kratos-cli/tests/cli_smoke.rs index 821264d..4dc616f 100644 --- a/crates/kratos-cli/tests/cli_smoke.rs +++ b/crates/kratos-cli/tests/cli_smoke.rs @@ -335,9 +335,8 @@ fn clean_accepts_legacy_v1_reports_through_cli() { ], ); assert!(apply.status.success()); - assert!( - String::from_utf8_lossy(&apply.stdout).contains("Kratos clean: 파일 0개를 삭제했습니다.") - ); + assert!(String::from_utf8_lossy(&apply.stdout) + .contains("Kratos clean: 파일 0개를 코드 경로에서 격리했습니다.")); assert!(project_root.join("src/components/DeadWidget.tsx").exists()); assert!(project_root.join("src/lib/broken.ts").exists()); } @@ -632,13 +631,13 @@ fn clean_accepts_future_schema_reports_when_the_shape_is_compatible() { #[cfg(unix)] { assert!(String::from_utf8_lossy(&apply.stdout) - .contains("Kratos clean: 파일 1개를 삭제했습니다.")); + .contains("Kratos clean: 파일 1개를 코드 경로에서 격리했습니다.")); assert!(!dead_file.exists()); } #[cfg(not(unix))] { let apply_stdout = String::from_utf8_lossy(&apply.stdout); - assert!(apply_stdout.contains("Kratos clean: 파일 0개를 삭제했습니다.")); + assert!(apply_stdout.contains("Kratos clean: 파일 0개를 코드 경로에서 격리했습니다.")); assert!(apply_stdout.contains("건너뛴 파일: 1")); assert!(dead_file.exists()); } @@ -690,9 +689,8 @@ fn boolean_flags_do_not_consume_following_positionals() { ], ); assert!(clean.status.success()); - assert!( - String::from_utf8_lossy(&clean.stdout).contains("Kratos clean: 파일 2개를 삭제했습니다.") - ); + assert!(String::from_utf8_lossy(&clean.stdout) + .contains("Kratos clean: 파일 2개를 코드 경로에서 격리했습니다.")); assert!(!project_root.join("src/components/DeadWidget.tsx").exists()); assert!(!project_root.join("src/lib/broken.ts").exists()); } diff --git a/crates/kratos-core/src/clean.rs b/crates/kratos-core/src/clean.rs index 337468e..6f43e93 100644 --- a/crates/kratos-core/src/clean.rs +++ b/crates/kratos-core/src/clean.rs @@ -9,6 +9,8 @@ use std::io::ErrorKind; #[cfg(unix)] use std::os::unix::ffi::OsStrExt; #[cfg(unix)] +use std::os::unix::fs::MetadataExt; +#[cfg(unix)] use std::os::unix::io::{AsRawFd, FromRawFd}; #[cfg(unix)] use std::sync::atomic::{AtomicU64, Ordering}; @@ -32,7 +34,7 @@ static QUARANTINE_COUNTER: AtomicU64 = AtomicU64::new(0); #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct CleanOutcome { - pub deleted_files: usize, + pub quarantined_files: Vec, pub skipped_files: usize, pub failed_files: Vec, } @@ -139,7 +141,7 @@ pub fn clean_from_report(report: &ReportV2, apply: bool) -> KratosResult { - outcome.deleted_files += 1; + QuarantineOutcome::Quarantined(quarantined_path) => { + outcome.quarantined_files.push(quarantined_path); } QuarantineOutcome::Skipped => outcome.skipped_files += 1, QuarantineOutcome::Failed(error) => outcome.failed_files.push(CleanFailure { @@ -343,13 +345,13 @@ fn snapshot_from_entry(entry: &CleanCandidateFingerprint) -> Option( +fn quarantine_candidate( report_root: &Path, candidate_path: &Path, expected: &FileSnapshot, @@ -435,23 +437,30 @@ where ); } - match unlink_at(&quarantine.directory, quarantine_candidate_name(), 0) { - Ok(()) => { - quarantine.remove_empty(); - QuarantineOutcome::Deleted + match std::fs::symlink_metadata(candidate_path) { + Err(error) if error.kind() == ErrorKind::NotFound => {} + Ok(_) => { + return QuarantineOutcome::Failed(format!( + "원래 코드 경로가 다시 생성되어 격리 완료로 보고하지 않습니다. 보존 위치: {}", + quarantined_path.display() + )); + } + Err(error) => { + return QuarantineOutcome::Failed(format!( + "원래 코드 경로의 부재를 확인하지 못했습니다. 보존 위치: {} ({error})", + quarantined_path.display() + )); } - Err(delete_error) => restore_or_preserve( - &quarantine, - &source_parent, - &candidate_name, - &quarantined_path, - QuarantineOutcome::Failed(delete_error.to_string()), - ), } + + // The verified object remains in quarantine. Portable POSIX cannot atomically + // bind an opened-file verification to unlinking that exact directory entry + // against a same-credential process that mutates the quarantine namespace. + QuarantineOutcome::Quarantined(quarantined_path) } #[cfg(not(unix))] -fn quarantine_and_delete( +fn quarantine_candidate( _report_root: &Path, _candidate_path: &Path, _expected: &FileSnapshot, @@ -479,8 +488,20 @@ struct QuarantineDirectory { #[cfg(unix)] impl QuarantineDirectory { fn create(report_root: &Path) -> std::io::Result { - let root_path = std::fs::canonicalize(report_root)?; - let root_directory = open_directory(&root_path)?; + let report_root_path = std::fs::canonicalize(report_root)?; + let report_root_directory = open_directory(&report_root_path)?; + let state_directory = + open_or_create_directory_at(&report_root_directory, kratos_state_directory_name())?; + let root_directory = + open_or_create_directory_at(&state_directory, clean_quarantine_directory_name())?; + let root_metadata = root_directory.metadata()?; + if root_metadata.uid() != unsafe { libc::geteuid() } || root_metadata.mode() & 0o077 != 0 { + return Err(std::io::Error::new( + ErrorKind::PermissionDenied, + "clean quarantine root must be owner-only", + )); + } + let root_path = report_root_path.join(".kratos/clean-quarantine"); for _ in 0..100 { let nonce = SystemTime::now() @@ -530,9 +551,20 @@ impl QuarantineDirectory { fn path_is_pinned(&self, report_root: &Path) -> bool { let descriptor_identity = directory_identity_from_file(&self.directory); - let path_identity = fingerprint_parent_identity(&self.path); - let root_matches = self.root_path == realpath_or_fallback(report_root); - descriptor_identity.is_some() && descriptor_identity == path_identity && root_matches + let path_metadata = std::fs::symlink_metadata(&self.path).ok(); + let path_identity = path_metadata + .as_ref() + .filter(|metadata| metadata.file_type().is_dir()) + .and_then(|_| fingerprint_parent_identity(&self.path)); + let expected_root = realpath_or_fallback(report_root).join(".kratos/clean-quarantine"); + let canonical_path = std::fs::canonicalize(&self.path).ok(); + descriptor_identity.is_some() + && descriptor_identity == path_identity + && self.root_path == expected_root + && canonical_path + .as_deref() + .and_then(Path::parent) + .is_some_and(|parent| parent == self.root_path) } fn remove_empty(&self) { @@ -577,13 +609,8 @@ fn restore_or_preserve( quarantine_candidate_name(), source_parent, candidate_name, - ) - .and_then(|_| unlink_at(&quarantine.directory, quarantine_candidate_name(), 0)) - { - Ok(()) => { - quarantine.remove_empty(); - restored_outcome - } + ) { + Ok(()) => restored_outcome, Err(error) => QuarantineOutcome::Failed(format!( "검증 실패 파일을 복원하지 못했습니다. 보존 위치: {} ({error})", quarantined_path.display() @@ -597,6 +624,30 @@ fn quarantine_candidate_name() -> &'static CStr { CStr::from_bytes_with_nul(b"candidate\0").expect("static quarantine name is valid") } +#[cfg(unix)] +#[allow(clippy::manual_c_str_literals)] +fn kratos_state_directory_name() -> &'static CStr { + CStr::from_bytes_with_nul(b".kratos\0").expect("static state directory name is valid") +} + +#[cfg(unix)] +#[allow(clippy::manual_c_str_literals)] +fn clean_quarantine_directory_name() -> &'static CStr { + CStr::from_bytes_with_nul(b"clean-quarantine\0").expect("static quarantine root name is valid") +} + +#[cfg(unix)] +fn open_or_create_directory_at(parent: &File, name: &CStr) -> std::io::Result { + let created = unsafe { libc::mkdirat(parent.as_raw_fd(), name.as_ptr(), 0o700) }; + if created != 0 { + let error = std::io::Error::last_os_error(); + if error.kind() != ErrorKind::AlreadyExists { + return Err(error); + } + } + open_directory_at(parent, name) +} + #[cfg(unix)] fn cstring_file_name(path: &Path) -> std::io::Result { let name = path.file_name().ok_or_else(|| { @@ -779,7 +830,7 @@ mod tests { }) .expect("clean should stay fail closed"); - assert_eq!(outcome.deleted_files, 0); + assert_eq!(outcome.quarantined_files.len(), 0); assert_eq!(outcome.skipped_files, 1); assert!(outcome.failed_files.is_empty()); assert_eq!( @@ -816,7 +867,7 @@ mod tests { ) .expect("per-file failure should be reported in the outcome"); - assert_eq!(outcome.deleted_files, 1, "{outcome:?}"); + assert_eq!(outcome.quarantined_files.len(), 1, "{outcome:?}"); assert_eq!(outcome.skipped_files, 0); assert_eq!(outcome.failed_files.len(), 1); assert_eq!(outcome.failed_files[0].file, second); @@ -844,7 +895,7 @@ mod tests { }) .expect("clean should stay fail closed"); - assert_eq!(outcome.deleted_files, 0); + assert_eq!(outcome.quarantined_files.len(), 0); assert_eq!(outcome.skipped_files, 1); assert!(outcome.failed_files.is_empty()); assert_eq!( @@ -856,7 +907,7 @@ mod tests { #[cfg(unix)] #[test] - fn parent_relocation_after_quarantine_verification_cannot_redirect_deletion() { + fn parent_relocation_after_quarantine_verification_cannot_redirect_mutation() { let root = temp_dir("clean-post-verification-parent-race"); let nested = root.join("nested"); let candidate = nested.join("dead.ts"); @@ -882,15 +933,15 @@ mod tests { ) .expect("verified quarantine deletion should fail closed"); - assert_eq!(outcome.deleted_files, 0, "{outcome:?}"); + assert_eq!(outcome.quarantined_files.len(), 0, "{outcome:?}"); assert_eq!(outcome.skipped_files, 0); assert_eq!(outcome.failed_files.len(), 1); assert_eq!( std::fs::read_to_string(&replacement).expect("replacement should remain"), "replacement\n" ); - let preserved = std::fs::read_dir(&root) - .expect("root should remain readable") + let preserved = std::fs::read_dir(root.join(".kratos/clean-quarantine")) + .expect("quarantine root should remain readable") .filter_map(Result::ok) .map(|entry| entry.path()) .find(|path| { @@ -930,7 +981,7 @@ mod tests { }) .expect("clean should restore the redirected pathname"); - assert_eq!(outcome.deleted_files, 0); + assert_eq!(outcome.quarantined_files.len(), 0); assert_eq!(outcome.skipped_files, 1); assert!(outcome.failed_files.is_empty()); assert_eq!( @@ -942,7 +993,7 @@ mod tests { #[cfg(unix)] #[test] - fn quarantine_relocation_is_not_followed_for_final_delete() { + fn quarantine_relocation_is_not_followed_for_final_mutation() { let root = temp_dir("clean-quarantine-race"); let candidate = root.join("candidate.ts"); let outside = temp_dir("clean-quarantine-race-outside"); @@ -966,15 +1017,86 @@ mod tests { ) .expect("clean should restore from the pinned quarantine descriptor"); - assert_eq!(outcome.deleted_files, 0); + assert_eq!(outcome.quarantined_files.len(), 0); assert_eq!(outcome.skipped_files, 1); assert!(outcome.failed_files.is_empty()); assert_eq!( std::fs::read_to_string(&outside_target).expect("outside target should remain"), "outside\n" ); - assert!(!outside.join("moved-quarantine/candidate").exists()); + assert_eq!( + std::fs::read_to_string(outside.join("moved-quarantine/candidate")) + .expect("verified bytes remain retained in moved quarantine"), + "candidate\n" + ); + assert!(candidate.exists()); + } + + #[cfg(unix)] + #[test] + fn matching_quarantine_entry_is_retained_without_final_unlink() { + let root = temp_dir("clean-quarantine-entry-race"); + let candidate = root.join("candidate.ts"); + let outside = temp_dir("clean-quarantine-entry-race-outside"); + let outside_link = outside.join("candidate-link"); + std::fs::write(&candidate, "candidate\n").expect("candidate should write"); + let report = report_for_files(&root, std::slice::from_ref(&candidate)); + let plan = plan_clean_candidates(&report, 0.0).expect("plan should build"); + + let outcome = apply_clean_plan_with_hooks( + &report, + &plan, + |_| {}, + |quarantined_path| { + std::fs::hard_link(quarantined_path, &outside_link) + .expect("outside hardlink should exist"); + std::fs::remove_file(quarantined_path).expect("quarantine entry should move"); + std::fs::hard_link(&outside_link, quarantined_path) + .expect("matching quarantine entry should return"); + }, + ) + .expect("retained quarantine should succeed without unlink"); + + assert_eq!(outcome.quarantined_files.len(), 1); + assert!(outcome.failed_files.is_empty()); + assert!(!candidate.exists()); + assert_eq!( + std::fs::read_to_string(&outside_link).expect("outside link remains"), + "candidate\n" + ); + assert_eq!( + std::fs::read_to_string(&outcome.quarantined_files[0]) + .expect("quarantine entry remains"), + "candidate\n" + ); + } + + #[cfg(unix)] + #[test] + fn reintroduced_original_path_is_not_reported_as_quarantined() { + let root = temp_dir("clean-original-path-reintroduced"); + let candidate = root.join("candidate.ts"); + std::fs::write(&candidate, "candidate\n").expect("candidate should write"); + let report = report_for_files(&root, std::slice::from_ref(&candidate)); + let plan = plan_clean_candidates(&report, 0.0).expect("plan should build"); + + let outcome = apply_clean_plan_with_hooks( + &report, + &plan, + |_| {}, + |quarantined_path| { + std::fs::hard_link(quarantined_path, &candidate) + .expect("original pathname should be reintroduced"); + }, + ) + .expect("accounting mismatch should be reported per file"); + + assert_eq!(outcome.quarantined_files.len(), 0); + assert_eq!(outcome.failed_files.len(), 1); assert!(candidate.exists()); + assert!(outcome.failed_files[0] + .error + .contains("원래 코드 경로가 다시 생성")); } fn report_for_files(root: &Path, files: &[PathBuf]) -> ReportV2 { diff --git a/crates/kratos-core/tests/clean_safety.rs b/crates/kratos-core/tests/clean_safety.rs index 92becfc..c6fe861 100644 --- a/crates/kratos-core/tests/clean_safety.rs +++ b/crates/kratos-core/tests/clean_safety.rs @@ -26,7 +26,7 @@ fn clean_rejects_deletion_candidates_outside_report_root() { let outcome = clean_from_report(&report, true).expect("clean should succeed"); assert!(outside_file.exists()); - assert_eq!(outcome.deleted_files, 0); + assert_eq!(outcome.quarantined_files.len(), 0); assert_eq!(outcome.skipped_files, 1); } @@ -47,7 +47,7 @@ fn clean_rejects_symlink_escape_candidates() { let outcome = clean_from_report(&report, true).expect("clean should succeed"); assert!(outside_file.exists()); - assert_eq!(outcome.deleted_files, 0); + assert_eq!(outcome.quarantined_files.len(), 0); assert_eq!(outcome.skipped_files, 1); } @@ -67,7 +67,7 @@ fn clean_skips_dangling_symlink_candidates_without_fingerprints() { std::fs::symlink_metadata(&dangling_link).is_ok(), "dangling symlink should remain" ); - assert_eq!(outcome.deleted_files, 0); + assert_eq!(outcome.quarantined_files.len(), 0); assert_eq!(outcome.skipped_files, 1); } @@ -92,7 +92,7 @@ fn clean_skips_live_symlink_candidates_without_touching_targets() { "symlink entry should remain" ); assert!(outside_file.exists(), "target file should remain untouched"); - assert_eq!(outcome.deleted_files, 0); + assert_eq!(outcome.quarantined_files.len(), 0); assert_eq!(outcome.skipped_files, 1); } @@ -118,7 +118,7 @@ fn clean_skips_direct_symlink_even_with_forged_matching_evidence() { assert!(std::fs::symlink_metadata(&symlink_path).is_ok()); assert!(outside_file.exists()); - assert_eq!(outcome.deleted_files, 0); + assert_eq!(outcome.quarantined_files.len(), 0); assert_eq!(outcome.skipped_files, 1); } @@ -139,12 +139,12 @@ fn clean_allows_symlinked_project_root_without_removing_parent_directories() { assert!(!dead_file.exists()); assert!(nested_dir.exists()); - assert_eq!(outcome.deleted_files, 1); + assert_eq!(outcome.quarantined_files.len(), 1); assert_eq!(outcome.skipped_files, 0); } #[test] -fn clean_deletes_through_root_contained_symlink_parent_without_parent_cleanup() { +fn clean_quarantines_through_root_contained_symlink_parent_without_parent_cleanup() { let temp_root = temp_dir("clean-best-effort-cleanup"); let report_root = temp_root.join("app"); let real_nested_dir = report_root.join("real-nested"); @@ -159,7 +159,7 @@ fn clean_deletes_through_root_contained_symlink_parent_without_parent_cleanup() let outcome = clean_from_report(&report, true).expect("clean should stay best-effort"); assert!(!dead_file.exists()); - assert_eq!(outcome.deleted_files, 1); + assert_eq!(outcome.quarantined_files.len(), 1); assert_eq!(outcome.skipped_files, 0); } @@ -188,7 +188,7 @@ fn clean_from_report_path_accepts_future_schema_reports_when_shape_is_compatible clean_from_report_path(&report_path, true).expect("future-schema clean should work"); assert!(!dead_file.exists()); - assert_eq!(outcome.deleted_files, 1); + assert_eq!(outcome.quarantined_files.len(), 1); assert_eq!(outcome.skipped_files, 0); } @@ -243,7 +243,7 @@ fn clean_from_report_rejects_reports_older_than_v2() { } #[test] -fn clean_from_report_path_reads_current_report_and_deletes_unchanged_candidate() { +fn clean_from_report_path_reads_current_report_and_quarantines_unchanged_candidate() { let temp_root = temp_dir("clean-report-path-v2"); let report_root = temp_root.join("app"); let dead_file = report_root.join("dead.ts"); @@ -261,7 +261,16 @@ fn clean_from_report_path_reads_current_report_and_deletes_unchanged_candidate() clean_from_report_path(&report_path, true).expect("clean_from_report_path should work"); assert!(!dead_file.exists()); - assert_eq!(outcome.deleted_files, 1); + assert_eq!(outcome.quarantined_files.len(), 1); + let canonical_report_root = + std::fs::canonicalize(&report_root).expect("report root canonicalizes"); + assert!(outcome.quarantined_files[0] + .starts_with(canonical_report_root.join(".kratos/clean-quarantine"))); + assert_eq!( + std::fs::read_to_string(&outcome.quarantined_files[0]) + .expect("verified candidate bytes remain quarantined"), + "export const dead = true;\n" + ); assert_eq!(outcome.skipped_files, 0); } @@ -279,7 +288,7 @@ fn clean_skips_candidate_when_content_changed_after_report() { let outcome = clean_from_report(&report, true).expect("clean should fail closed"); assert!(dead_file.exists()); - assert_eq!(outcome.deleted_files, 0); + assert_eq!(outcome.quarantined_files.len(), 0); assert_eq!(outcome.skipped_files, 1); } @@ -299,7 +308,7 @@ fn clean_skips_candidate_recreated_at_the_same_path() { let outcome = clean_from_report(&report, true).expect("clean should fail closed"); assert!(dead_file.exists()); - assert_eq!(outcome.deleted_files, 0); + assert_eq!(outcome.quarantined_files.len(), 0); assert_eq!(outcome.skipped_files, 1); } @@ -319,7 +328,7 @@ fn clean_skips_same_content_file_recreated_at_the_same_path() { let outcome = clean_from_report(&report, true).expect("clean should fail closed"); assert!(dead_file.exists()); - assert_eq!(outcome.deleted_files, 0); + assert_eq!(outcome.quarantined_files.len(), 0); assert_eq!(outcome.skipped_files, 1); } @@ -337,7 +346,7 @@ fn clean_skips_safe_false_even_with_a_matching_fingerprint() { let outcome = clean_from_report(&report, true).expect("clean should fail closed"); assert!(dead_file.exists()); - assert_eq!(outcome.deleted_files, 0); + assert_eq!(outcome.quarantined_files.len(), 0); assert_eq!(outcome.skipped_files, 1); } @@ -356,7 +365,7 @@ fn clean_skips_schema_v2_candidate_without_fingerprint_evidence() { let outcome = clean_from_report(&report, true).expect("clean should fail closed"); assert!(dead_file.exists()); - assert_eq!(outcome.deleted_files, 0); + assert_eq!(outcome.quarantined_files.len(), 0); assert_eq!(outcome.skipped_files, 1); } @@ -374,7 +383,7 @@ fn clean_skips_missing_file_after_report_generation() { let outcome = clean_from_report(&report, true).expect("clean should fail closed"); assert!(!dead_file.exists()); - assert_eq!(outcome.deleted_files, 0); + assert_eq!(outcome.quarantined_files.len(), 0); assert_eq!(outcome.skipped_files, 1); } @@ -393,7 +402,7 @@ fn clean_skips_candidate_replaced_by_a_non_regular_file() { let outcome = clean_from_report(&report, true).expect("clean should fail closed"); assert!(dead_file.is_dir()); - assert_eq!(outcome.deleted_files, 0); + assert_eq!(outcome.quarantined_files.len(), 0); assert_eq!(outcome.skipped_files, 1); } @@ -411,7 +420,7 @@ fn clean_skips_unsupported_fingerprint_algorithm() { let outcome = clean_from_report(&report, true).expect("clean should fail closed"); assert!(dead_file.exists()); - assert_eq!(outcome.deleted_files, 0); + assert_eq!(outcome.quarantined_files.len(), 0); assert_eq!(outcome.skipped_files, 1); } @@ -437,7 +446,7 @@ fn clean_skips_duplicate_fingerprint_entries() { let outcome = clean_from_report(&report, true).expect("clean should fail closed"); assert!(dead_file.exists()); - assert_eq!(outcome.deleted_files, 0); + assert_eq!(outcome.quarantined_files.len(), 0); assert_eq!(outcome.skipped_files, 1); } @@ -464,7 +473,7 @@ fn duplicate_normalized_deletion_candidates_fail_closed_in_preview_and_apply() { let outcome = clean_from_report(&report, true).expect("apply should fail closed"); assert!(dead_file.exists()); - assert_eq!(outcome.deleted_files, 0); + assert_eq!(outcome.quarantined_files.len(), 0); assert_eq!(outcome.skipped_files, 2); assert!(outcome.failed_files.is_empty()); } diff --git a/crates/kratos-core/tests/clean_thresholds.rs b/crates/kratos-core/tests/clean_thresholds.rs index 7e1fb86..ab29d0f 100644 --- a/crates/kratos-core/tests/clean_thresholds.rs +++ b/crates/kratos-core/tests/clean_thresholds.rs @@ -43,7 +43,7 @@ fn clean_from_report_with_min_confidence_skips_low_confidence_targets() { let outcome = clean_from_report_with_min_confidence(&report, 0.9).expect("clean should succeed"); - assert_eq!(outcome.deleted_files, 1); + assert_eq!(outcome.quarantined_files.len(), 1); assert_eq!(outcome.skipped_files, 1); assert!(!temp_root.join("src/high.ts").exists()); assert!(temp_root.join("src/low.ts").exists()); diff --git a/docs/plans/04-sweep-experience.md b/docs/plans/04-sweep-experience.md index 0eccf50..adcc624 100644 --- a/docs/plans/04-sweep-experience.md +++ b/docs/plans/04-sweep-experience.md @@ -6,7 +6,7 @@ ## Current Baseline -- `clean`은 기본 dry-run이며 `--apply`가 있을 때만 삭제한다. +- `clean`은 기본 dry-run이며 `--apply`가 있을 때만 원래 코드 경로에서 보존 격리한다. 자동 물리 삭제는 하지 않는다. - `clean --min-confidence`와 `thresholds.cleanMinConfidence`가 동작한다. - suppression은 `kratos.config.json`과 `.kratos/suppressions.json`에서 읽힌다. - clean preview helper는 삭제를 수행하지 않는 core helper로 분리되어 있다. @@ -17,7 +17,7 @@ - interactive mode는 `stdin.read_line()` 기반 prompt만 사용한다. - `sweep`에서 suppression을 저장할 때는 `.kratos/suppressions.json`만 쓴다. - `sweep`는 threshold와 suppression을 모두 반영한 뒤 preview 순서를 결정한다. -- 사용자의 명시적 선택 없이 파일 삭제가 일어나면 안 된다. +- 사용자의 명시적 선택 없이 파일이 원래 코드 경로에서 격리되면 안 된다. ## PR WOW-5A. kratos sweep Interactive CLI diff --git a/docs/plans/v1-cli-report-contract.md b/docs/plans/v1-cli-report-contract.md index dfd61af..9333c92 100644 --- a/docs/plans/v1-cli-report-contract.md +++ b/docs/plans/v1-cli-report-contract.md @@ -17,7 +17,7 @@ This note freezes the consumer-visible v1 CLI/report contract before any version - Unknown commands and invalid explicit format, boolean, threshold, or incompatible option values return exit code `1` with `Kratos 실행 실패: ...` or the Korean command-help error path. - Compatibility exceptions retained from the JavaScript baseline return `0` when the underlying command succeeds: `scan`/`clean` ignore unknown flags, `report` ignores surplus positionals, and bare or empty `report --format` falls back to `summary`. - `clean` without `--apply` is a preview/dry-run and returns `0` when the report is valid. -- `clean --apply` returns `0` after completing a delete/skip plan, including a successful no-op. It returns `1` for invalid input or any per-file filesystem failure after reporting deleted/skipped/failed counts and the failed path. +- `clean --apply` returns `0` after completing a quarantine/skip plan, including a successful no-op. It returns `1` for invalid input or any per-file filesystem failure after reporting quarantined/skipped/failed counts and the failed path. ## Report JSON contract @@ -78,9 +78,9 @@ The writer-key contract test uses independent literal expectations for every con - `--min-confidence` overrides `thresholds.cleanMinConfidence`; when neither is provided, the threshold is `0.0`. Candidates below the effective threshold are skipped. - Preview excludes candidates whose normalized path or real parent escapes the report root. Root-contained candidates that fail safety validation remain visible in a separate safety-skipped section with status/marker evidence instead of being silently omitted. - Apply requires exactly one normalized deletion candidate and one matching manifest entry, `safe: true`, the confidence threshold, root/real-parent containment, `sha256`, stable file and parent-directory identity, and content equality. -- On Unix, apply opens and identity-checks the canonical candidate parent plus a private report-root quarantine directory, then performs move, restore, and delete with descriptor-relative `renameat`/`linkat`/`unlinkat`. The candidate and quarantine parent pathnames are rechecked for containment/identity, while the destructive operations remain bound to the opened directories. Post-move content/file-identity verification reads the quarantined object through `openat(..., O_NOFOLLOW)`. This prevents parent or quarantine pathname replacement from redirecting deletion to an external hard link. Restore is no-clobber; if the original descriptor-relative name is occupied, the verified quarantine object is preserved and reported. Cross-filesystem `renameat` fails closed without copy/unlink fallback. A process crash or power loss can leave `.kratos-clean-quarantine-*` contents for manual restoration; they are not auto-deleted. Concurrent relocation/replacement of the entire report root remains outside the supported threat model. +- On Unix, apply opens and identity-checks the canonical candidate parent plus `/.kratos/clean-quarantine/`, then moves by descriptor-relative `renameat` and verifies the moved object through `openat(..., O_NOFOLLOW)`. Verified bytes remain in an invocation-unique owner-only quarantine directory; Kratos does not physically `unlink` them. This removes the non-atomic final verify→unlink boundary against direct same-credential quarantine mutation. Restore remains no-clobber with descriptor-relative `linkat` and also retains the quarantine link. Cross-filesystem `renameat` fails closed without copy/unlink fallback. Concurrent relocation/replacement of the entire report root remains outside the supported threat model. - Apply skips schema-v2/legacy candidates without evidence, duplicate/aliased candidate paths, `safe: false`, missing/unreadable/non-regular files, direct symlinks, duplicate/missing manifest evidence, unsupported algorithms or platforms without stable identity evidence, identity/content mismatches, root escapes, and below-threshold candidates. Non-Unix destructive execution currently remains fail-closed because this descriptor-relative stable-identity boundary is unavailable. -- `clean --apply` reports deleted, skipped, and failed counts. Per-file failures do not discard prior successful-delete accounting. Parent directories are intentionally left in place so a mutable-parent race cannot turn convenience cleanup into an out-of-root directory removal. +- `clean --apply` reports paths removed from the code tree, retained quarantine paths, skipped candidates, and failures. Per-file failures do not discard prior successful-quarantine accounting. Parent directories are intentionally left in place so a mutable-parent race cannot turn convenience cleanup into an out-of-root directory removal. - Fingerprint, file identity, and `safe` are execution-safety metadata only. They are excluded from finding identity, so the v2→v3 migration and content changes do not create diff churn. ## Evidence added in this PR diff --git a/test/package-smoke.test.js b/test/package-smoke.test.js index 13887a2..4aa22e0 100644 --- a/test/package-smoke.test.js +++ b/test/package-smoke.test.js @@ -189,6 +189,45 @@ test("packed root package boots the actual native addon for the current platform ), true, ); + + const cleanProjectPath = path.join(tempRoot, "unix-clean-project"); + const cleanReportPath = path.join(tempRoot, "unix-clean-report.json"); + await fsp.cp(demoAppPath, cleanProjectPath, { recursive: true }); + const scanForClean = runInstalledKratos( + installRoot, + ["scan", cleanProjectPath, "--output", cleanReportPath, "--json"], + { cwd: installRoot }, + ); + assert.equal(scanForClean.status, 0, scanForClean.stderr || scanForClean.stdout); + + const cleanReport = JSON.parse(await fsp.readFile(cleanReportPath, "utf8")); + const originalCandidates = await Promise.all( + cleanReport.findings.deletionCandidates.map(async (candidate) => ({ + file: candidate.file, + contents: await fsp.readFile(candidate.file), + })), + ); + assert.ok(originalCandidates.length > 0, "Unix clean smoke requires a deletion candidate"); + const cleanResult = runInstalledKratos(installRoot, ["clean", cleanReportPath, "--apply"], { + cwd: installRoot, + }); + assert.equal(cleanResult.status, 0, cleanResult.stderr || cleanResult.stdout); + assert.match(cleanResult.stdout, new RegExp(`보존된 격리 파일: ${originalCandidates.length}`)); + for (const candidate of originalCandidates) { + assert.equal(fs.existsSync(candidate.file), false, `Expected code path to be quarantined: ${candidate.file}`); + } + + const quarantineRoot = path.join(cleanProjectPath, ".kratos", "clean-quarantine"); + const quarantineEntries = await fsp.readdir(quarantineRoot, { withFileTypes: true }); + const retainedContents = await Promise.all( + quarantineEntries + .filter((entry) => entry.isDirectory()) + .map((entry) => fsp.readFile(path.join(quarantineRoot, entry.name, "candidate"))), + ); + assert.deepEqual( + retainedContents.map((contents) => contents.toString("hex")).sort(), + originalCandidates.map((candidate) => candidate.contents.toString("hex")).sort(), + ); } assert.equal(path.resolve(report.project.root), demoAppPath); }); From 567b62112947f210c9ded5fa3c4887120fb51908 Mon Sep 17 00:00:00 2001 From: JeremyDev87 Date: Thu, 16 Jul 2026 03:18:29 +0900 Subject: [PATCH 07/11] =?UTF-8?q?fix:=20CleanOutcome=20=ED=98=B8=ED=99=98?= =?UTF-8?q?=20=EC=B9=B4=EC=9A=B4=ED=8A=B8=20=EC=9C=A0=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deleted_files 필드는 코드 경로에서 보존 격리된 수를 뜻하는 compatibility count로 유지하고 실제 보존 경로는 quarantined_files에 노출합니다. Co-authored-by: Hermes --- crates/kratos-core/src/clean.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/kratos-core/src/clean.rs b/crates/kratos-core/src/clean.rs index 6f43e93..0dd495f 100644 --- a/crates/kratos-core/src/clean.rs +++ b/crates/kratos-core/src/clean.rs @@ -34,6 +34,9 @@ static QUARANTINE_COUNTER: AtomicU64 = AtomicU64::new(0); #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct CleanOutcome { + /// Compatibility count for paths moved out of the code tree. The file bytes + /// remain under `quarantined_files`; this does not mean a physical unlink. + pub deleted_files: usize, pub quarantined_files: Vec, pub skipped_files: usize, pub failed_files: Vec, @@ -141,6 +144,7 @@ pub fn clean_from_report(report: &ReportV2, apply: bool) -> KratosResult { + outcome.deleted_files += 1; outcome.quarantined_files.push(quarantined_path); } QuarantineOutcome::Skipped => outcome.skipped_files += 1, From 2744324c06a7c063d157796bbde021172dadb04b Mon Sep 17 00:00:00 2001 From: JeremyDev87 Date: Thu, 16 Jul 2026 03:38:53 +0900 Subject: [PATCH 08/11] =?UTF-8?q?fix:=20=EA=B2=A9=EB=A6=AC=20residue=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=EB=A5=BC=20=EB=AA=A8=EB=91=90=20=EB=B3=B4?= =?UTF-8?q?=EA=B3=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 복원·건너뜀·실패 중 남은 quarantine object도 CleanOutcome과 CLI에서 last-known path로 노출하고, 코드 경로에서 이동된 수와 보존 파일 수를 분리합니다. Co-authored-by: Hermes --- crates/kratos-cli/src/commands/clean.rs | 4 +- crates/kratos-core/src/clean.rs | 109 +++++++++++++++--------- docs/plans/v1-cli-report-contract.md | 2 +- 3 files changed, 73 insertions(+), 42 deletions(-) diff --git a/crates/kratos-cli/src/commands/clean.rs b/crates/kratos-cli/src/commands/clean.rs index 9fd3214..9e68c53 100644 --- a/crates/kratos-cli/src/commands/clean.rs +++ b/crates/kratos-cli/src/commands/clean.rs @@ -15,7 +15,7 @@ use super::{parse_cli_options, resolve_report_input, write_output, CommandSpec, pub const NAME: &str = "clean"; pub const SPEC: CommandSpec = CommandSpec { name: NAME, - summary: "삭제 후보를 표시하거나 --apply로 삭제합니다.", + summary: "삭제 후보를 표시하거나 --apply로 보존 격리합니다.", usage: &["kratos clean [report-path-or-root] [--apply] [--min-confidence value]"], }; @@ -52,7 +52,7 @@ pub fn run(args: &[String], stdout: &mut dyn Write) -> KratosResult { let outcome = clean_from_report_with_min_confidence(&report, min_confidence)?; let mut output = format!( "Kratos clean: 파일 {}개를 코드 경로에서 격리했습니다.\n보존된 격리 파일: {}\n건너뛴 파일: {}\n실패한 파일: {}", - outcome.quarantined_files.len(), + outcome.deleted_files, outcome.quarantined_files.len(), outcome.skipped_files, outcome.failed_files.len() diff --git a/crates/kratos-core/src/clean.rs b/crates/kratos-core/src/clean.rs index 0dd495f..ca0e61d 100644 --- a/crates/kratos-core/src/clean.rs +++ b/crates/kratos-core/src/clean.rs @@ -37,6 +37,8 @@ pub struct CleanOutcome { /// Compatibility count for paths moved out of the code tree. The file bytes /// remain under `quarantined_files`; this does not mean a physical unlink. pub deleted_files: usize, + /// Last-known paths for every retained quarantine object, including residue + /// left by restored, skipped, or failed candidates. pub quarantined_files: Vec, pub skipped_files: usize, pub failed_files: Vec, @@ -317,11 +319,21 @@ where outcome.deleted_files += 1; outcome.quarantined_files.push(quarantined_path); } - QuarantineOutcome::Skipped => outcome.skipped_files += 1, - QuarantineOutcome::Failed(error) => outcome.failed_files.push(CleanFailure { - file: candidate_path, - error, - }), + QuarantineOutcome::Skipped(retained_path) => { + outcome.skipped_files += 1; + if let Some(path) = retained_path { + outcome.quarantined_files.push(path); + } + } + QuarantineOutcome::Failed(error, retained_path) => { + if let Some(path) = retained_path { + outcome.quarantined_files.push(path); + } + outcome.failed_files.push(CleanFailure { + file: candidate_path, + error, + }); + } } } @@ -352,8 +364,8 @@ fn snapshot_from_entry(entry: &CleanCandidateFingerprint) -> Option), + Failed(String, Option), } #[cfg(unix)] @@ -369,33 +381,33 @@ where G: FnMut(&Path), { let Some(parent_path) = candidate_path.parent() else { - return QuarantineOutcome::Skipped; + return QuarantineOutcome::Skipped(None); }; if !is_safe_clean_candidate(report_root, candidate_path) { - return QuarantineOutcome::Skipped; + return QuarantineOutcome::Skipped(None); } let canonical_parent = match std::fs::canonicalize(parent_path) { Ok(path) => path, - Err(_) => return QuarantineOutcome::Skipped, + Err(_) => return QuarantineOutcome::Skipped(None), }; let source_parent = match open_directory(&canonical_parent) { Ok(directory) => directory, - Err(_) => return QuarantineOutcome::Skipped, + Err(_) => return QuarantineOutcome::Skipped(None), }; if directory_identity_from_file(&source_parent).as_deref() != Some(expected.parent_identity.as_str()) { - return QuarantineOutcome::Skipped; + return QuarantineOutcome::Skipped(None); } let candidate_name = match cstring_file_name(candidate_path) { Ok(name) => name, - Err(error) => return QuarantineOutcome::Failed(error.to_string()), + Err(error) => return QuarantineOutcome::Failed(error.to_string(), None), }; let quarantine = match QuarantineDirectory::create(report_root) { Ok(directory) => directory, - Err(error) => return QuarantineOutcome::Failed(error.to_string()), + Err(error) => return QuarantineOutcome::Failed(error.to_string(), None), }; let quarantined_path = quarantine.path.join("candidate"); @@ -408,9 +420,9 @@ where ) { quarantine.remove_empty(); return if error.kind() == ErrorKind::NotFound { - QuarantineOutcome::Skipped + QuarantineOutcome::Skipped(None) } else { - QuarantineOutcome::Failed(error.to_string()) + QuarantineOutcome::Failed(error.to_string(), None) }; } @@ -424,7 +436,6 @@ where &source_parent, &candidate_name, &quarantined_path, - QuarantineOutcome::Skipped, ); } @@ -439,23 +450,28 @@ where &source_parent, &candidate_name, &quarantined_path, - QuarantineOutcome::Skipped, ); } match std::fs::symlink_metadata(candidate_path) { Err(error) if error.kind() == ErrorKind::NotFound => {} Ok(_) => { - return QuarantineOutcome::Failed(format!( - "원래 코드 경로가 다시 생성되어 격리 완료로 보고하지 않습니다. 보존 위치: {}", - quarantined_path.display() - )); + return QuarantineOutcome::Failed( + format!( + "원래 코드 경로가 다시 생성되어 격리 완료로 보고하지 않습니다. 보존 위치: {}", + quarantined_path.display() + ), + Some(quarantined_path), + ); } Err(error) => { - return QuarantineOutcome::Failed(format!( - "원래 코드 경로의 부재를 확인하지 못했습니다. 보존 위치: {} ({error})", - quarantined_path.display() - )); + return QuarantineOutcome::Failed( + format!( + "원래 코드 경로의 부재를 확인하지 못했습니다. 보존 위치: {} ({error})", + quarantined_path.display() + ), + Some(quarantined_path), + ); } } @@ -479,7 +495,7 @@ where { // Platforms without descriptor-relative stable identity support never receive // deletion-ready evidence. Keep this final boundary fail closed as well. - QuarantineOutcome::Skipped + QuarantineOutcome::Skipped(None) } #[cfg(unix)] @@ -602,13 +618,23 @@ fn quarantine_candidate_matches(directory: &File, expected: &FileSnapshot) -> bo .unwrap_or(false) } +#[cfg(unix)] +fn retained_quarantine_path( + quarantine: &QuarantineDirectory, + quarantined_path: &Path, +) -> Option { + open_file_at(&quarantine.directory, quarantine_candidate_name()) + .and_then(inspect_open_regular_file) + .ok() + .map(|_| quarantined_path.to_path_buf()) +} + #[cfg(unix)] fn restore_or_preserve( quarantine: &QuarantineDirectory, source_parent: &File, candidate_name: &CStr, quarantined_path: &Path, - restored_outcome: QuarantineOutcome, ) -> QuarantineOutcome { match link_at( &quarantine.directory, @@ -616,11 +642,16 @@ fn restore_or_preserve( source_parent, candidate_name, ) { - Ok(()) => restored_outcome, - Err(error) => QuarantineOutcome::Failed(format!( - "검증 실패 파일을 복원하지 못했습니다. 보존 위치: {} ({error})", - quarantined_path.display() - )), + Ok(()) => { + QuarantineOutcome::Skipped(retained_quarantine_path(quarantine, quarantined_path)) + } + Err(error) => QuarantineOutcome::Failed( + format!( + "검증 실패 파일을 복원하지 못했습니다. 보존 위치: {} ({error})", + quarantined_path.display() + ), + retained_quarantine_path(quarantine, quarantined_path), + ), } } @@ -836,7 +867,7 @@ mod tests { }) .expect("clean should stay fail closed"); - assert_eq!(outcome.quarantined_files.len(), 0); + assert_eq!(outcome.quarantined_files.len(), 1); assert_eq!(outcome.skipped_files, 1); assert!(outcome.failed_files.is_empty()); assert_eq!( @@ -901,7 +932,7 @@ mod tests { }) .expect("clean should stay fail closed"); - assert_eq!(outcome.quarantined_files.len(), 0); + assert_eq!(outcome.quarantined_files.len(), 1); assert_eq!(outcome.skipped_files, 1); assert!(outcome.failed_files.is_empty()); assert_eq!( @@ -939,7 +970,7 @@ mod tests { ) .expect("verified quarantine deletion should fail closed"); - assert_eq!(outcome.quarantined_files.len(), 0, "{outcome:?}"); + assert_eq!(outcome.quarantined_files.len(), 1, "{outcome:?}"); assert_eq!(outcome.skipped_files, 0); assert_eq!(outcome.failed_files.len(), 1); assert_eq!( @@ -987,7 +1018,7 @@ mod tests { }) .expect("clean should restore the redirected pathname"); - assert_eq!(outcome.quarantined_files.len(), 0); + assert_eq!(outcome.quarantined_files.len(), 1); assert_eq!(outcome.skipped_files, 1); assert!(outcome.failed_files.is_empty()); assert_eq!( @@ -1023,7 +1054,7 @@ mod tests { ) .expect("clean should restore from the pinned quarantine descriptor"); - assert_eq!(outcome.quarantined_files.len(), 0); + assert_eq!(outcome.quarantined_files.len(), 1); assert_eq!(outcome.skipped_files, 1); assert!(outcome.failed_files.is_empty()); assert_eq!( @@ -1097,7 +1128,7 @@ mod tests { ) .expect("accounting mismatch should be reported per file"); - assert_eq!(outcome.quarantined_files.len(), 0); + assert_eq!(outcome.quarantined_files.len(), 1); assert_eq!(outcome.failed_files.len(), 1); assert!(candidate.exists()); assert!(outcome.failed_files[0] diff --git a/docs/plans/v1-cli-report-contract.md b/docs/plans/v1-cli-report-contract.md index 9333c92..cc1dae7 100644 --- a/docs/plans/v1-cli-report-contract.md +++ b/docs/plans/v1-cli-report-contract.md @@ -80,7 +80,7 @@ The writer-key contract test uses independent literal expectations for every con - Apply requires exactly one normalized deletion candidate and one matching manifest entry, `safe: true`, the confidence threshold, root/real-parent containment, `sha256`, stable file and parent-directory identity, and content equality. - On Unix, apply opens and identity-checks the canonical candidate parent plus `/.kratos/clean-quarantine/`, then moves by descriptor-relative `renameat` and verifies the moved object through `openat(..., O_NOFOLLOW)`. Verified bytes remain in an invocation-unique owner-only quarantine directory; Kratos does not physically `unlink` them. This removes the non-atomic final verify→unlink boundary against direct same-credential quarantine mutation. Restore remains no-clobber with descriptor-relative `linkat` and also retains the quarantine link. Cross-filesystem `renameat` fails closed without copy/unlink fallback. Concurrent relocation/replacement of the entire report root remains outside the supported threat model. - Apply skips schema-v2/legacy candidates without evidence, duplicate/aliased candidate paths, `safe: false`, missing/unreadable/non-regular files, direct symlinks, duplicate/missing manifest evidence, unsupported algorithms or platforms without stable identity evidence, identity/content mismatches, root escapes, and below-threshold candidates. Non-Unix destructive execution currently remains fail-closed because this descriptor-relative stable-identity boundary is unavailable. -- `clean --apply` reports paths removed from the code tree, retained quarantine paths, skipped candidates, and failures. Per-file failures do not discard prior successful-quarantine accounting. Parent directories are intentionally left in place so a mutable-parent race cannot turn convenience cleanup into an out-of-root directory removal. +- `clean --apply` reports the count removed from the code tree separately from every last-known retained quarantine path, including residue from restored, skipped, or failed candidates, plus skipped and failed counts. Per-file failures do not discard prior successful-quarantine or residue accounting. Parent directories are intentionally left in place so a mutable-parent race cannot turn convenience cleanup into an out-of-root directory removal. - Fingerprint, file identity, and `safe` are execution-safety metadata only. They are excluded from finding identity, so the v2→v3 migration and content changes do not create diff churn. ## Evidence added in this PR From 2b5ec6b9f58d5366c09da50e349152a0351617d3 Mon Sep 17 00:00:00 2001 From: JeremyDev87 Date: Thu, 16 Jul 2026 03:46:41 +0900 Subject: [PATCH 09/11] =?UTF-8?q?fix:=20quarantine=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?unlink=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Candidate file뿐 아니라 invocation directory도 자동 unlink하지 않아 same-credential namespace 교체가 unrelated entry 삭제로 이어질 마지막 pathname race를 제거합니다. Co-authored-by: Hermes --- crates/kratos-core/src/clean.rs | 20 +------------------- docs/plans/v1-cli-report-contract.md | 2 +- 2 files changed, 2 insertions(+), 20 deletions(-) diff --git a/crates/kratos-core/src/clean.rs b/crates/kratos-core/src/clean.rs index ca0e61d..ae88c6d 100644 --- a/crates/kratos-core/src/clean.rs +++ b/crates/kratos-core/src/clean.rs @@ -418,7 +418,6 @@ where &quarantine.directory, quarantine_candidate_name(), ) { - quarantine.remove_empty(); return if error.kind() == ErrorKind::NotFound { QuarantineOutcome::Skipped(None) } else { @@ -500,11 +499,9 @@ where #[cfg(unix)] struct QuarantineDirectory { - root_directory: File, directory: File, root_path: PathBuf, path: PathBuf, - name: CString, } #[cfg(unix)] @@ -551,17 +548,12 @@ impl QuarantineDirectory { Ok(directory) => { let path = root_path.join(std::ffi::OsStr::from_bytes(name.to_bytes())); return Ok(Self { - root_directory, directory, root_path, path, - name, }); } - Err(error) => { - let _ = unlink_at(&root_directory, &name, libc::AT_REMOVEDIR); - return Err(error); - } + Err(error) => return Err(error), } } @@ -588,10 +580,6 @@ impl QuarantineDirectory { .and_then(Path::parent) .is_some_and(|parent| parent == self.root_path) } - - fn remove_empty(&self) { - let _ = unlink_at(&self.root_directory, &self.name, libc::AT_REMOVEDIR); - } } #[cfg(unix)] @@ -777,12 +765,6 @@ fn link_at( zero_or_last_error(result) } -#[cfg(unix)] -fn unlink_at(parent: &File, name: &CStr, flags: libc::c_int) -> std::io::Result<()> { - let result = unsafe { libc::unlinkat(parent.as_raw_fd(), name.as_ptr(), flags) }; - zero_or_last_error(result) -} - #[cfg(unix)] fn zero_or_last_error(result: libc::c_int) -> std::io::Result<()> { if result == 0 { diff --git a/docs/plans/v1-cli-report-contract.md b/docs/plans/v1-cli-report-contract.md index cc1dae7..1f9e0fb 100644 --- a/docs/plans/v1-cli-report-contract.md +++ b/docs/plans/v1-cli-report-contract.md @@ -78,7 +78,7 @@ The writer-key contract test uses independent literal expectations for every con - `--min-confidence` overrides `thresholds.cleanMinConfidence`; when neither is provided, the threshold is `0.0`. Candidates below the effective threshold are skipped. - Preview excludes candidates whose normalized path or real parent escapes the report root. Root-contained candidates that fail safety validation remain visible in a separate safety-skipped section with status/marker evidence instead of being silently omitted. - Apply requires exactly one normalized deletion candidate and one matching manifest entry, `safe: true`, the confidence threshold, root/real-parent containment, `sha256`, stable file and parent-directory identity, and content equality. -- On Unix, apply opens and identity-checks the canonical candidate parent plus `/.kratos/clean-quarantine/`, then moves by descriptor-relative `renameat` and verifies the moved object through `openat(..., O_NOFOLLOW)`. Verified bytes remain in an invocation-unique owner-only quarantine directory; Kratos does not physically `unlink` them. This removes the non-atomic final verify→unlink boundary against direct same-credential quarantine mutation. Restore remains no-clobber with descriptor-relative `linkat` and also retains the quarantine link. Cross-filesystem `renameat` fails closed without copy/unlink fallback. Concurrent relocation/replacement of the entire report root remains outside the supported threat model. +- On Unix, apply opens and identity-checks the canonical candidate parent plus `/.kratos/clean-quarantine/`, then moves by descriptor-relative `renameat` and verifies the moved object through `openat(..., O_NOFOLLOW)`. Verified bytes remain in an invocation-unique owner-only quarantine directory; Kratos does not physically `unlink` candidate files or invocation directories. This removes the non-atomic final verify→unlink boundary against direct same-credential quarantine mutation; a failed pre-move attempt may therefore leave an empty invocation directory for manual cleanup. Restore remains no-clobber with descriptor-relative `linkat` and also retains the quarantine link. Cross-filesystem `renameat` fails closed without copy/unlink fallback. Concurrent relocation/replacement of the entire report root remains outside the supported threat model. - Apply skips schema-v2/legacy candidates without evidence, duplicate/aliased candidate paths, `safe: false`, missing/unreadable/non-regular files, direct symlinks, duplicate/missing manifest evidence, unsupported algorithms or platforms without stable identity evidence, identity/content mismatches, root escapes, and below-threshold candidates. Non-Unix destructive execution currently remains fail-closed because this descriptor-relative stable-identity boundary is unavailable. - `clean --apply` reports the count removed from the code tree separately from every last-known retained quarantine path, including residue from restored, skipped, or failed candidates, plus skipped and failed counts. Per-file failures do not discard prior successful-quarantine or residue accounting. Parent directories are intentionally left in place so a mutable-parent race cannot turn convenience cleanup into an out-of-root directory removal. - Fingerprint, file identity, and `safe` are execution-safety metadata only. They are excluded from finding identity, so the v2→v3 migration and content changes do not create diff churn. From aedaa4356e5365f4cd40295568901e3df3f766ac Mon Sep 17 00:00:00 2001 From: JeremyDev87 Date: Thu, 16 Jul 2026 04:20:46 +0900 Subject: [PATCH 10/11] =?UTF-8?q?fix:=20=EB=AF=B8=EA=B2=80=EC=A6=9D=20quar?= =?UTF-8?q?antine=20=EB=B3=B5=EC=9B=90=20=EC=B0=A8=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-move 검증 실패 시 가변 entry를 코드 경로로 link하지 않고 residue와 실제 코드 경로 제거를 정확히 집계합니다. 기존 CleanOutcome 공개 shape는 유지하고 상세 결과는 별도 API로 제공합니다. Co-authored-by: Hermes --- crates/kratos-cli/src/commands/clean.rs | 8 +- crates/kratos-cli/src/commands/mod.rs | 2 +- crates/kratos-cli/tests/cli_smoke.rs | 6 +- crates/kratos-core/src/clean.rs | 246 ++++++++++++------- crates/kratos-core/tests/clean_safety.rs | 51 ++-- crates/kratos-core/tests/clean_thresholds.rs | 22 +- docs/plans/04-sweep-experience.md | 8 +- docs/plans/v1-cli-report-contract.md | 4 +- test/package-smoke.test.js | 2 +- 9 files changed, 215 insertions(+), 134 deletions(-) diff --git a/crates/kratos-cli/src/commands/clean.rs b/crates/kratos-cli/src/commands/clean.rs index 9e68c53..e61e780 100644 --- a/crates/kratos-cli/src/commands/clean.rs +++ b/crates/kratos-cli/src/commands/clean.rs @@ -2,7 +2,7 @@ use std::fs; use std::io::Write; use std::path::Path; -use kratos_core::clean::{clean_from_report_with_min_confidence, CleanSafetyStatus}; +use kratos_core::clean::{clean_from_report_with_min_confidence_detailed, CleanSafetyStatus}; use kratos_core::clean_preview::{build_clean_preview, CleanPreviewItem, CleanPreviewPlan}; use kratos_core::config::load_clean_min_confidence; use kratos_core::model::DeletionCandidateFinding; @@ -49,9 +49,9 @@ pub fn run(args: &[String], stdout: &mut dyn Write) -> KratosResult { return Ok(0); } - let outcome = clean_from_report_with_min_confidence(&report, min_confidence)?; + let outcome = clean_from_report_with_min_confidence_detailed(&report, min_confidence)?; let mut output = format!( - "Kratos clean: 파일 {}개를 코드 경로에서 격리했습니다.\n보존된 격리 파일: {}\n건너뛴 파일: {}\n실패한 파일: {}", + "Kratos clean: 파일 {}개를 코드 경로에서 격리했습니다.\n현재 경로가 확인된 격리 파일: {}\n건너뛴 파일: {}\n실패한 파일: {}", outcome.deleted_files, outcome.quarantined_files.len(), outcome.skipped_files, @@ -59,7 +59,7 @@ pub fn run(args: &[String], stdout: &mut dyn Write) -> KratosResult { ); for quarantined in &outcome.quarantined_files { output.push_str(&format!( - "\n- 격리 보존: {}", + "\n- 확인된 격리 보존: {}", relative_path(quarantined, &report.root) )); } diff --git a/crates/kratos-cli/src/commands/mod.rs b/crates/kratos-cli/src/commands/mod.rs index 5e6d70a..240aba6 100644 --- a/crates/kratos-cli/src/commands/mod.rs +++ b/crates/kratos-cli/src/commands/mod.rs @@ -95,7 +95,7 @@ fn display_command_summary(spec: CommandSpec) -> &'static str { scan::NAME => "코드베이스를 분석하고 최신 리포트를 저장합니다.", report::NAME => "저장된 리포트를 summary, json, markdown 형식으로 출력합니다.", diff::NAME => "저장된 두 리포트를 비교합니다.", - clean::NAME => "삭제 후보를 표시하거나 --apply로 삭제합니다.", + clean::NAME => "삭제 후보를 표시하거나 --apply로 보존 격리합니다.", _ => spec.summary, } } diff --git a/crates/kratos-cli/tests/cli_smoke.rs b/crates/kratos-cli/tests/cli_smoke.rs index 4dc616f..d58bdd0 100644 --- a/crates/kratos-cli/tests/cli_smoke.rs +++ b/crates/kratos-cli/tests/cli_smoke.rs @@ -11,7 +11,7 @@ fn root_help_matches_expected_shape() { assert!(output.status.success()); assert_eq!( String::from_utf8_lossy(&output.stdout), - "Kratos\n죽은 코드를 가차 없이 제거합니다.\n\n사용법:\n kratos scan [root] [--output path] [--no-write] [--json]\n kratos report [report-path-or-root] [--format summary|json|md]\n kratos diff [before-report-path-or-root] [after-report-path-or-root] [--format summary|json|md]\n kratos clean [report-path-or-root] [--apply] [--min-confidence value]\n\n명령:\n scan 코드베이스를 분석하고 최신 리포트를 저장합니다.\n report 저장된 리포트를 summary, json, markdown 형식으로 출력합니다.\n diff 저장된 두 리포트를 비교합니다.\n clean 삭제 후보를 표시하거나 --apply로 삭제합니다.\n" + "Kratos\n죽은 코드를 가차 없이 제거합니다.\n\n사용법:\n kratos scan [root] [--output path] [--no-write] [--json]\n kratos report [report-path-or-root] [--format summary|json|md]\n kratos diff [before-report-path-or-root] [after-report-path-or-root] [--format summary|json|md]\n kratos clean [report-path-or-root] [--apply] [--min-confidence value]\n\n명령:\n scan 코드베이스를 분석하고 최신 리포트를 저장합니다.\n report 저장된 리포트를 summary, json, markdown 형식으로 출력합니다.\n diff 저장된 두 리포트를 비교합니다.\n clean 삭제 후보를 표시하거나 --apply로 보존 격리합니다.\n" ); } @@ -46,7 +46,7 @@ fn command_help_matches_korean_policy() { assert!(output.status.success()); assert_eq!( String::from_utf8_lossy(&output.stdout), - "Kratos\n죽은 코드를 가차 없이 제거합니다.\n\nclean 명령\n삭제 후보를 표시하거나 --apply로 삭제합니다.\n\n사용법:\n kratos clean [report-path-or-root] [--apply] [--min-confidence value]\n\n전체 명령을 보려면 `kratos --help`를 실행하세요.\n" + "Kratos\n죽은 코드를 가차 없이 제거합니다.\n\nclean 명령\n삭제 후보를 표시하거나 --apply로 보존 격리합니다.\n\n사용법:\n kratos clean [report-path-or-root] [--apply] [--min-confidence value]\n\n전체 명령을 보려면 `kratos --help`를 실행하세요.\n" ); } @@ -57,7 +57,7 @@ fn unknown_command_returns_help_and_exit_code_one() { assert_eq!(output.status.code(), Some(1)); assert_eq!( String::from_utf8_lossy(&output.stderr), - "알 수 없는 명령: nope\n\nKratos\n죽은 코드를 가차 없이 제거합니다.\n\n사용법:\n kratos scan [root] [--output path] [--no-write] [--json]\n kratos report [report-path-or-root] [--format summary|json|md]\n kratos diff [before-report-path-or-root] [after-report-path-or-root] [--format summary|json|md]\n kratos clean [report-path-or-root] [--apply] [--min-confidence value]\n\n명령:\n scan 코드베이스를 분석하고 최신 리포트를 저장합니다.\n report 저장된 리포트를 summary, json, markdown 형식으로 출력합니다.\n diff 저장된 두 리포트를 비교합니다.\n clean 삭제 후보를 표시하거나 --apply로 삭제합니다.\n" + "알 수 없는 명령: nope\n\nKratos\n죽은 코드를 가차 없이 제거합니다.\n\n사용법:\n kratos scan [root] [--output path] [--no-write] [--json]\n kratos report [report-path-or-root] [--format summary|json|md]\n kratos diff [before-report-path-or-root] [after-report-path-or-root] [--format summary|json|md]\n kratos clean [report-path-or-root] [--apply] [--min-confidence value]\n\n명령:\n scan 코드베이스를 분석하고 최신 리포트를 저장합니다.\n report 저장된 리포트를 summary, json, markdown 형식으로 출력합니다.\n diff 저장된 두 리포트를 비교합니다.\n clean 삭제 후보를 표시하거나 --apply로 보존 격리합니다.\n" ); } diff --git a/crates/kratos-core/src/clean.rs b/crates/kratos-core/src/clean.rs index ae88c6d..46f047d 100644 --- a/crates/kratos-core/src/clean.rs +++ b/crates/kratos-core/src/clean.rs @@ -34,16 +34,32 @@ static QUARANTINE_COUNTER: AtomicU64 = AtomicU64::new(0); #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct CleanOutcome { + pub deleted_files: usize, + pub skipped_files: usize, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct CleanApplyOutcome { /// Compatibility count for paths moved out of the code tree. The file bytes /// remain under `quarantined_files`; this does not mean a physical unlink. pub deleted_files: usize, - /// Last-known paths for every retained quarantine object, including residue - /// left by restored, skipped, or failed candidates. + /// Confirmed current paths for retained quarantine objects. If a moved + /// quarantine directory no longer has a trustworthy pathname, the candidate + /// is reported through `failed_files` without fabricating a path. pub quarantined_files: Vec, pub skipped_files: usize, pub failed_files: Vec, } +impl From for CleanOutcome { + fn from(value: CleanApplyOutcome) -> Self { + Self { + deleted_files: value.deleted_files, + skipped_files: value.skipped_files, + } + } +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct CleanFailure { pub file: PathBuf, @@ -80,6 +96,14 @@ pub fn clean_from_report_path( clean_from_report(&report, apply) } +pub fn clean_from_report_path_detailed( + report_path: impl AsRef, + apply: bool, +) -> KratosResult { + let report = load_clean_report(report_path)?; + clean_from_report_detailed(&report, apply) +} + pub fn clean_from_report_path_with_min_confidence( report_path: impl AsRef, min_confidence: f32, @@ -88,6 +112,14 @@ pub fn clean_from_report_path_with_min_confidence( clean_from_report_with_min_confidence(&report, min_confidence) } +pub fn clean_from_report_path_with_min_confidence_detailed( + report_path: impl AsRef, + min_confidence: f32, +) -> KratosResult { + let report = load_clean_report(report_path)?; + clean_from_report_with_min_confidence_detailed(&report, min_confidence) +} + pub fn load_clean_report(report_path: impl AsRef) -> KratosResult { let raw = std::fs::read_to_string(report_path)?; let value: Value = @@ -132,11 +164,25 @@ pub fn clean_from_report_with_min_confidence( report: &ReportV2, min_confidence: f32, ) -> KratosResult { + clean_from_report_with_min_confidence_detailed(report, min_confidence).map(Into::into) +} + +pub fn clean_from_report_with_min_confidence_detailed( + report: &ReportV2, + min_confidence: f32, +) -> KratosResult { let plan = plan_clean_candidates(report, min_confidence)?; apply_clean_plan(report, &plan) } pub fn clean_from_report(report: &ReportV2, apply: bool) -> KratosResult { + clean_from_report_detailed(report, apply).map(Into::into) +} + +pub fn clean_from_report_detailed( + report: &ReportV2, + apply: bool, +) -> KratosResult { if report.version < REPORT_V2 { return Err(KratosError::InvalidReportVersion { expected: REPORT_V2, @@ -145,7 +191,7 @@ pub fn clean_from_report(report: &ReportV2, apply: bool) -> KratosResult KratosResult Kr Ok(()) } -fn apply_clean_plan(report: &ReportV2, plan: &CleanThresholdPlan) -> KratosResult { +fn apply_clean_plan( + report: &ReportV2, + plan: &CleanThresholdPlan, +) -> KratosResult { apply_clean_plan_with_hooks(report, plan, |_| {}, |_| {}) } @@ -270,7 +319,7 @@ fn apply_clean_plan_with_hook( report: &ReportV2, plan: &CleanThresholdPlan, before_quarantine: F, -) -> KratosResult +) -> KratosResult where F: FnMut(&Path), { @@ -282,12 +331,12 @@ fn apply_clean_plan_with_hooks( plan: &CleanThresholdPlan, mut before_quarantine: F, mut after_quarantine_verification: G, -) -> KratosResult +) -> KratosResult where F: FnMut(&Path), G: FnMut(&Path), { - let mut outcome = CleanOutcome { + let mut outcome = CleanApplyOutcome { deleted_files: 0, quarantined_files: Vec::new(), skipped_files: plan.threshold_skipped_targets.len(), @@ -325,7 +374,14 @@ where outcome.quarantined_files.push(path); } } - QuarantineOutcome::Failed(error, retained_path) => { + QuarantineOutcome::Failed { + error, + retained_path, + removed_from_code_tree, + } => { + if removed_from_code_tree { + outcome.deleted_files += 1; + } if let Some(path) = retained_path { outcome.quarantined_files.push(path); } @@ -365,7 +421,11 @@ fn snapshot_from_entry(entry: &CleanCandidateFingerprint) -> Option), - Failed(String, Option), + Failed { + error: String, + retained_path: Option, + removed_from_code_tree: bool, + }, } #[cfg(unix)] @@ -403,11 +463,11 @@ where let candidate_name = match cstring_file_name(candidate_path) { Ok(name) => name, - Err(error) => return QuarantineOutcome::Failed(error.to_string(), None), + Err(error) => return failed_before_move(error.to_string()), }; let quarantine = match QuarantineDirectory::create(report_root) { Ok(directory) => directory, - Err(error) => return QuarantineOutcome::Failed(error.to_string(), None), + Err(error) => return failed_before_move(error.to_string()), }; let quarantined_path = quarantine.path.join("candidate"); @@ -421,7 +481,7 @@ where return if error.kind() == ErrorKind::NotFound { QuarantineOutcome::Skipped(None) } else { - QuarantineOutcome::Failed(error.to_string(), None) + failed_before_move(error.to_string()) }; } @@ -430,11 +490,12 @@ where let quarantine_file_valid = quarantine_candidate_matches(&quarantine.directory, expected); let verified = source_path_valid && quarantine_path_valid && quarantine_file_valid; if !verified { - return restore_or_preserve( + return fail_after_move_without_restore( + report_root, &quarantine, - &source_parent, - &candidate_name, &quarantined_path, + candidate_path, + "격리 이동 후 검증이 실패했습니다", ); } @@ -444,33 +505,36 @@ where && quarantine.path_is_pinned(report_root) && quarantine_candidate_matches(&quarantine.directory, expected); if !still_verified { - return restore_or_preserve( + return fail_after_move_without_restore( + report_root, &quarantine, - &source_parent, - &candidate_name, &quarantined_path, + candidate_path, + "격리 검증 후 경로 또는 객체가 변경되었습니다", ); } match std::fs::symlink_metadata(candidate_path) { Err(error) if error.kind() == ErrorKind::NotFound => {} Ok(_) => { - return QuarantineOutcome::Failed( - format!( + return QuarantineOutcome::Failed { + error: format!( "원래 코드 경로가 다시 생성되어 격리 완료로 보고하지 않습니다. 보존 위치: {}", quarantined_path.display() ), - Some(quarantined_path), - ); + retained_path: Some(quarantined_path), + removed_from_code_tree: false, + }; } Err(error) => { - return QuarantineOutcome::Failed( - format!( + return QuarantineOutcome::Failed { + error: format!( "원래 코드 경로의 부재를 확인하지 못했습니다. 보존 위치: {} ({error})", quarantined_path.display() ), - Some(quarantined_path), - ); + retained_path: Some(quarantined_path), + removed_from_code_tree: false, + }; } } @@ -607,39 +671,58 @@ fn quarantine_candidate_matches(directory: &File, expected: &FileSnapshot) -> bo } #[cfg(unix)] -fn retained_quarantine_path( - quarantine: &QuarantineDirectory, - quarantined_path: &Path, -) -> Option { - open_file_at(&quarantine.directory, quarantine_candidate_name()) - .and_then(inspect_open_regular_file) - .ok() - .map(|_| quarantined_path.to_path_buf()) +fn quarantine_entry_exists(directory: &File) -> bool { + let mut metadata = std::mem::MaybeUninit::::uninit(); + unsafe { + libc::fstatat( + directory.as_raw_fd(), + quarantine_candidate_name().as_ptr(), + metadata.as_mut_ptr(), + libc::AT_SYMLINK_NOFOLLOW, + ) == 0 + } +} + +fn failed_before_move(error: String) -> QuarantineOutcome { + QuarantineOutcome::Failed { + error, + retained_path: None, + removed_from_code_tree: false, + } } #[cfg(unix)] -fn restore_or_preserve( +fn fail_after_move_without_restore( + report_root: &Path, quarantine: &QuarantineDirectory, - source_parent: &File, - candidate_name: &CStr, quarantined_path: &Path, + candidate_path: &Path, + reason: &str, ) -> QuarantineOutcome { - match link_at( - &quarantine.directory, - quarantine_candidate_name(), - source_parent, - candidate_name, - ) { - Ok(()) => { - QuarantineOutcome::Skipped(retained_quarantine_path(quarantine, quarantined_path)) - } - Err(error) => QuarantineOutcome::Failed( - format!( - "검증 실패 파일을 복원하지 못했습니다. 보존 위치: {} ({error})", - quarantined_path.display() - ), - retained_quarantine_path(quarantine, quarantined_path), + let retained = quarantine_entry_exists(&quarantine.directory); + let retained_path = (retained && quarantine.path_is_pinned(report_root)) + .then(|| quarantined_path.to_path_buf()); + let removed_from_code_tree = matches!( + std::fs::symlink_metadata(candidate_path), + Err(error) if error.kind() == ErrorKind::NotFound + ); + let residue = if retained_path.is_some() { + format!( + "마지막으로 확인된 보존 위치: {}", + quarantined_path.display() + ) + } else if retained { + "격리 객체는 pinned descriptor에 남아 있지만 현재 pathname을 확인할 수 없습니다".to_string() + } else { + "격리 entry가 더 이상 존재하지 않습니다".to_string() + }; + + QuarantineOutcome::Failed { + error: format!( + "{reason}. 미검증 entry는 원래 코드 경로로 자동 복원하지 않습니다. {residue}" ), + retained_path, + removed_from_code_tree, } } @@ -746,25 +829,6 @@ fn rename_at( zero_or_last_error(result) } -#[cfg(unix)] -fn link_at( - old_parent: &File, - old_name: &CStr, - new_parent: &File, - new_name: &CStr, -) -> std::io::Result<()> { - let result = unsafe { - libc::linkat( - old_parent.as_raw_fd(), - old_name.as_ptr(), - new_parent.as_raw_fd(), - new_name.as_ptr(), - 0, - ) - }; - zero_or_last_error(result) -} - #[cfg(unix)] fn zero_or_last_error(result: libc::c_int) -> std::io::Result<()> { if result == 0 { @@ -850,10 +914,12 @@ mod tests { .expect("clean should stay fail closed"); assert_eq!(outcome.quarantined_files.len(), 1); - assert_eq!(outcome.skipped_files, 1); - assert!(outcome.failed_files.is_empty()); + assert_eq!(outcome.skipped_files, 0); + assert_eq!(outcome.failed_files.len(), 1); + assert!(!file.exists()); assert_eq!( - std::fs::read_to_string(&file).expect("replacement should remain"), + std::fs::read_to_string(&outcome.quarantined_files[0]) + .expect("replacement should remain quarantined"), "replacement\n" ); } @@ -887,6 +953,7 @@ mod tests { .expect("per-file failure should be reported in the outcome"); assert_eq!(outcome.quarantined_files.len(), 1, "{outcome:?}"); + assert_eq!(outcome.deleted_files, 2, "{outcome:?}"); assert_eq!(outcome.skipped_files, 0); assert_eq!(outcome.failed_files.len(), 1); assert_eq!(outcome.failed_files[0].file, second); @@ -915,13 +982,13 @@ mod tests { .expect("clean should stay fail closed"); assert_eq!(outcome.quarantined_files.len(), 1); - assert_eq!(outcome.skipped_files, 1); - assert!(outcome.failed_files.is_empty()); + assert_eq!(outcome.skipped_files, 0); + assert_eq!(outcome.failed_files.len(), 1); assert_eq!( std::fs::read_to_string(&outside_file).expect("outside file should remain"), "outside\n" ); - assert!(saved_nested.join("dead.ts").exists()); + assert!(!saved_nested.join("dead.ts").exists()); } #[cfg(unix)] @@ -978,7 +1045,7 @@ mod tests { #[cfg(unix)] #[test] - fn redirected_parent_with_matching_hardlink_is_skipped_without_deletion() { + fn redirected_parent_with_matching_hardlink_fails_without_restore() { let root = temp_dir("clean-parent-hardlink-race"); let parent = root.join("nested"); let moved_parent = root.join("moved-nested"); @@ -998,16 +1065,16 @@ mod tests { std::os::unix::fs::symlink(&outside, &parent) .expect("redirecting parent symlink should install"); }) - .expect("clean should restore the redirected pathname"); + .expect("clean should retain without restoring the redirected pathname"); assert_eq!(outcome.quarantined_files.len(), 1); - assert_eq!(outcome.skipped_files, 1); - assert!(outcome.failed_files.is_empty()); + assert_eq!(outcome.skipped_files, 0); + assert_eq!(outcome.failed_files.len(), 1); assert_eq!( std::fs::read_to_string(&outside_candidate).expect("outside hardlink should remain"), "candidate\n" ); - assert!(moved_candidate.exists()); + assert!(!moved_candidate.exists()); } #[cfg(unix)] @@ -1034,11 +1101,14 @@ mod tests { .expect("redirecting quarantine symlink should install"); }, ) - .expect("clean should restore from the pinned quarantine descriptor"); + .expect("clean should retain through the pinned quarantine descriptor"); - assert_eq!(outcome.quarantined_files.len(), 1); - assert_eq!(outcome.skipped_files, 1); - assert!(outcome.failed_files.is_empty()); + assert_eq!(outcome.quarantined_files.len(), 0); + assert_eq!(outcome.skipped_files, 0); + assert_eq!(outcome.failed_files.len(), 1); + assert!(outcome.failed_files[0] + .error + .contains("현재 pathname을 확인할 수 없습니다")); assert_eq!( std::fs::read_to_string(&outside_target).expect("outside target should remain"), "outside\n" @@ -1048,7 +1118,7 @@ mod tests { .expect("verified bytes remain retained in moved quarantine"), "candidate\n" ); - assert!(candidate.exists()); + assert!(!candidate.exists()); } #[cfg(unix)] diff --git a/crates/kratos-core/tests/clean_safety.rs b/crates/kratos-core/tests/clean_safety.rs index c6fe861..5c253e0 100644 --- a/crates/kratos-core/tests/clean_safety.rs +++ b/crates/kratos-core/tests/clean_safety.rs @@ -2,7 +2,7 @@ use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; use kratos_core::clean::{ - clean_candidate_safety_status, clean_from_report, clean_from_report_path, + clean_candidate_safety_status, clean_from_report_detailed, clean_from_report_path_detailed, current_file_identity, current_parent_identity, CleanSafetyStatus, }; use kratos_core::clean_preview::build_clean_preview; @@ -23,7 +23,7 @@ fn clean_rejects_deletion_candidates_outside_report_root() { std::fs::write(&outside_file, "export const keep = true;\n").expect("outside file writes"); let report = report_with_candidate(&report_root, &outside_file); - let outcome = clean_from_report(&report, true).expect("clean should succeed"); + let outcome = clean_from_report_detailed(&report, true).expect("clean should succeed"); assert!(outside_file.exists()); assert_eq!(outcome.quarantined_files.len(), 0); @@ -44,7 +44,7 @@ fn clean_rejects_symlink_escape_candidates() { symlink_dir(&outside_root, &symlink_path); let report = report_with_candidate(&report_root, &symlink_path.join("target.ts")); - let outcome = clean_from_report(&report, true).expect("clean should succeed"); + let outcome = clean_from_report_detailed(&report, true).expect("clean should succeed"); assert!(outside_file.exists()); assert_eq!(outcome.quarantined_files.len(), 0); @@ -61,7 +61,7 @@ fn clean_skips_dangling_symlink_candidates_without_fingerprints() { symlink_file(Path::new("missing-target.ts"), &dangling_link); let report = report_with_candidate(&report_root, &dangling_link); - let outcome = clean_from_report(&report, true).expect("clean should fail closed"); + let outcome = clean_from_report_detailed(&report, true).expect("clean should fail closed"); assert!( std::fs::symlink_metadata(&dangling_link).is_ok(), @@ -85,7 +85,7 @@ fn clean_skips_live_symlink_candidates_without_touching_targets() { symlink_file(&outside_file, &symlink_path); let report = report_with_candidate(&report_root, &symlink_path); - let outcome = clean_from_report(&report, true).expect("clean should fail closed"); + let outcome = clean_from_report_detailed(&report, true).expect("clean should fail closed"); assert!( std::fs::symlink_metadata(&symlink_path).is_ok(), @@ -114,7 +114,7 @@ fn clean_skips_direct_symlink_even_with_forged_matching_evidence() { report.clean_safety.candidates[0].fingerprint = regular_file_fingerprint(&outside_file); report.clean_safety.candidates[0].identity = current_file_identity(&outside_file); - let outcome = clean_from_report(&report, true).expect("clean should fail closed"); + let outcome = clean_from_report_detailed(&report, true).expect("clean should fail closed"); assert!(std::fs::symlink_metadata(&symlink_path).is_ok()); assert!(outside_file.exists()); @@ -135,7 +135,7 @@ fn clean_allows_symlinked_project_root_without_removing_parent_directories() { symlink_dir(&real_root, &symlink_root); let report = report_with_candidate(&symlink_root, &symlink_root.join("orphan/dead.ts")); - let outcome = clean_from_report(&report, true).expect("clean should succeed"); + let outcome = clean_from_report_detailed(&report, true).expect("clean should succeed"); assert!(!dead_file.exists()); assert!(nested_dir.exists()); @@ -156,7 +156,7 @@ fn clean_quarantines_through_root_contained_symlink_parent_without_parent_cleanu symlink_dir(&real_nested_dir, &symlink_nested_dir); let report = report_with_candidate(&report_root, &symlink_nested_dir.join("dead.ts")); - let outcome = clean_from_report(&report, true).expect("clean should stay best-effort"); + let outcome = clean_from_report_detailed(&report, true).expect("clean should stay best-effort"); assert!(!dead_file.exists()); assert_eq!(outcome.quarantined_files.len(), 1); @@ -184,8 +184,8 @@ fn clean_from_report_path_accepts_future_schema_reports_when_shape_is_compatible ) .expect("report writes"); - let outcome = - clean_from_report_path(&report_path, true).expect("future-schema clean should work"); + let outcome = clean_from_report_path_detailed(&report_path, true) + .expect("future-schema clean should work"); assert!(!dead_file.exists()); assert_eq!(outcome.quarantined_files.len(), 1); @@ -207,8 +207,8 @@ fn clean_from_report_path_rejects_legacy_v1_reports() { ) .expect("report writes"); - let error = - clean_from_report_path(&report_path, true).expect_err("v1 reports should be rejected"); + let error = clean_from_report_path_detailed(&report_path, true) + .expect_err("v1 reports should be rejected"); match error { KratosError::InvalidReportVersion { expected, found } => { @@ -231,7 +231,8 @@ fn clean_from_report_rejects_reports_older_than_v2() { let mut report = report_with_candidate(&report_root, &dead_file); report.version = 1; - let error = clean_from_report(&report, true).expect_err("older reports should be rejected"); + let error = + clean_from_report_detailed(&report, true).expect_err("older reports should be rejected"); match error { KratosError::InvalidReportVersion { expected, found } => { @@ -257,8 +258,8 @@ fn clean_from_report_path_reads_current_report_and_quarantines_unchanged_candida let serialized = serialize_report_pretty(&report).expect("report should serialize"); std::fs::write(&report_path, serialized).expect("report should write"); - let outcome = - clean_from_report_path(&report_path, true).expect("clean_from_report_path should work"); + let outcome = clean_from_report_path_detailed(&report_path, true) + .expect("clean_from_report_path should work"); assert!(!dead_file.exists()); assert_eq!(outcome.quarantined_files.len(), 1); @@ -285,7 +286,7 @@ fn clean_skips_candidate_when_content_changed_after_report() { let report = report_with_candidate(&report_root, &dead_file); std::fs::write(&dead_file, "export const nowUsed = true;\n").expect("file should change"); - let outcome = clean_from_report(&report, true).expect("clean should fail closed"); + let outcome = clean_from_report_detailed(&report, true).expect("clean should fail closed"); assert!(dead_file.exists()); assert_eq!(outcome.quarantined_files.len(), 0); @@ -305,7 +306,7 @@ fn clean_skips_candidate_recreated_at_the_same_path() { std::fs::write(&dead_file, "export const replacement = true;\n") .expect("replacement file writes"); - let outcome = clean_from_report(&report, true).expect("clean should fail closed"); + let outcome = clean_from_report_detailed(&report, true).expect("clean should fail closed"); assert!(dead_file.exists()); assert_eq!(outcome.quarantined_files.len(), 0); @@ -325,7 +326,7 @@ fn clean_skips_same_content_file_recreated_at_the_same_path() { std::fs::remove_file(&dead_file).expect("old file should delete"); std::fs::write(&dead_file, content).expect("same-content replacement should write"); - let outcome = clean_from_report(&report, true).expect("clean should fail closed"); + let outcome = clean_from_report_detailed(&report, true).expect("clean should fail closed"); assert!(dead_file.exists()); assert_eq!(outcome.quarantined_files.len(), 0); @@ -343,7 +344,7 @@ fn clean_skips_safe_false_even_with_a_matching_fingerprint() { let mut report = report_with_candidate(&report_root, &dead_file); report.findings.deletion_candidates[0].safe = false; - let outcome = clean_from_report(&report, true).expect("clean should fail closed"); + let outcome = clean_from_report_detailed(&report, true).expect("clean should fail closed"); assert!(dead_file.exists()); assert_eq!(outcome.quarantined_files.len(), 0); @@ -362,7 +363,7 @@ fn clean_skips_schema_v2_candidate_without_fingerprint_evidence() { report.version = 2; report.clean_safety = Default::default(); - let outcome = clean_from_report(&report, true).expect("clean should fail closed"); + let outcome = clean_from_report_detailed(&report, true).expect("clean should fail closed"); assert!(dead_file.exists()); assert_eq!(outcome.quarantined_files.len(), 0); @@ -380,7 +381,7 @@ fn clean_skips_missing_file_after_report_generation() { let report = report_with_candidate(&report_root, &dead_file); std::fs::remove_file(&dead_file).expect("candidate should be removable before clean"); - let outcome = clean_from_report(&report, true).expect("clean should fail closed"); + let outcome = clean_from_report_detailed(&report, true).expect("clean should fail closed"); assert!(!dead_file.exists()); assert_eq!(outcome.quarantined_files.len(), 0); @@ -399,7 +400,7 @@ fn clean_skips_candidate_replaced_by_a_non_regular_file() { std::fs::remove_file(&dead_file).expect("candidate should be removable before replacement"); std::fs::create_dir(&dead_file).expect("directory replacement should be created"); - let outcome = clean_from_report(&report, true).expect("clean should fail closed"); + let outcome = clean_from_report_detailed(&report, true).expect("clean should fail closed"); assert!(dead_file.is_dir()); assert_eq!(outcome.quarantined_files.len(), 0); @@ -417,7 +418,7 @@ fn clean_skips_unsupported_fingerprint_algorithm() { let mut report = report_with_candidate(&report_root, &dead_file); report.clean_safety.fingerprint_algorithm = "sha512".to_string(); - let outcome = clean_from_report(&report, true).expect("clean should fail closed"); + let outcome = clean_from_report_detailed(&report, true).expect("clean should fail closed"); assert!(dead_file.exists()); assert_eq!(outcome.quarantined_files.len(), 0); @@ -443,7 +444,7 @@ fn clean_skips_duplicate_fingerprint_entries() { CleanSafetyStatus::DuplicateFingerprint ); - let outcome = clean_from_report(&report, true).expect("clean should fail closed"); + let outcome = clean_from_report_detailed(&report, true).expect("clean should fail closed"); assert!(dead_file.exists()); assert_eq!(outcome.quarantined_files.len(), 0); @@ -471,7 +472,7 @@ fn duplicate_normalized_deletion_candidates_fail_closed_in_preview_and_apply() { .iter() .all(|item| item.safety_status == CleanSafetyStatus::DuplicateCandidate)); - let outcome = clean_from_report(&report, true).expect("apply should fail closed"); + let outcome = clean_from_report_detailed(&report, true).expect("apply should fail closed"); assert!(dead_file.exists()); assert_eq!(outcome.quarantined_files.len(), 0); assert_eq!(outcome.skipped_files, 2); diff --git a/crates/kratos-core/tests/clean_thresholds.rs b/crates/kratos-core/tests/clean_thresholds.rs index ab29d0f..2514c3b 100644 --- a/crates/kratos-core/tests/clean_thresholds.rs +++ b/crates/kratos-core/tests/clean_thresholds.rs @@ -2,10 +2,20 @@ use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; use kratos_core::clean::{ - clean_from_report_with_min_confidence, current_file_identity, current_parent_identity, - plan_clean_candidates, + clean_from_report_with_min_confidence_detailed, current_file_identity, current_parent_identity, + plan_clean_candidates, CleanOutcome, }; use kratos_core::model::{CleanCandidateFingerprint, DeletionCandidateFinding, ReportV2}; + +#[test] +fn legacy_clean_outcome_struct_literal_remains_source_compatible() { + let outcome = CleanOutcome { + deleted_files: 1, + skipped_files: 2, + }; + assert_eq!(outcome.deleted_files, 1); + assert_eq!(outcome.skipped_files, 2); +} use sha2::{Digest, Sha256}; #[test] @@ -36,12 +46,12 @@ fn plan_clean_candidates_splits_deletion_targets_and_threshold_skips() { } #[test] -fn clean_from_report_with_min_confidence_skips_low_confidence_targets() { +fn clean_from_report_with_min_confidence_detailed_skips_low_confidence_targets() { let temp_root = temp_dir("clean-threshold-apply"); let report = report_with_candidates(&temp_root, &[("src/high.ts", 0.96), ("src/low.ts", 0.40)]); let outcome = - clean_from_report_with_min_confidence(&report, 0.9).expect("clean should succeed"); + clean_from_report_with_min_confidence_detailed(&report, 0.9).expect("clean should succeed"); assert_eq!(outcome.quarantined_files.len(), 1); assert_eq!(outcome.skipped_files, 1); @@ -50,11 +60,11 @@ fn clean_from_report_with_min_confidence_skips_low_confidence_targets() { } #[test] -fn clean_from_report_with_min_confidence_rejects_invalid_thresholds() { +fn clean_from_report_with_min_confidence_detailed_rejects_invalid_thresholds() { let temp_root = temp_dir("clean-threshold-invalid"); let report = report_with_candidates(&temp_root, &[("src/high.ts", 0.96)]); - let error = clean_from_report_with_min_confidence(&report, 1.1) + let error = clean_from_report_with_min_confidence_detailed(&report, 1.1) .expect_err("threshold should be rejected"); assert!( error diff --git a/docs/plans/04-sweep-experience.md b/docs/plans/04-sweep-experience.md index adcc624..caea98d 100644 --- a/docs/plans/04-sweep-experience.md +++ b/docs/plans/04-sweep-experience.md @@ -34,7 +34,7 @@ - usage: - `kratos sweep [report-path-or-root] [--min-confidence value] [--yes]` - prompt actions: - - `y`: accept current file for deletion + - `y`: accept current file for retained quarantine - `n`: skip current file - `s`: write suppression rule and skip current file - `a`: accept all remaining eligible files @@ -45,16 +45,16 @@ 1. `clean`과 같은 input resolution helper를 써서 report를 읽는다. 2. preview helper 결과를 하나씩 보여주고 line prompt를 받는다. 3. `s`를 누르면 exact suppression rule을 `.kratos/suppressions.json`에 append하고 현재 item은 삭제 후보에서 제외한다. -4. prompt loop가 끝난 뒤 accepted item이 있으면 그 path들만 delete helper에 넘긴다. +4. prompt loop가 끝난 뒤 accepted item이 있으면 그 path들만 retained-quarantine helper에 넘긴다. 5. `--yes`는 interactive prompt 없이 threshold/suppression을 통과한 모든 item을 accept한다. -6. summary에는 `deleted`, `suppressed`, `skipped`, `below threshold`, `remaining untouched`를 모두 보여준다. +6. summary에는 `removed from code tree`, `retained quarantine`, `suppressed`, `skipped`, `below threshold`, `remaining untouched`를 모두 보여준다. ## Done Criteria - [ ] `sweep`가 interactive mode와 `--yes` mode를 모두 가진다. - [ ] suppression write가 `.kratos/suppressions.json`에 누적된다. - [ ] `sweep`가 report input만 사용하고 implicit rescan을 하지 않는다. -- [ ] no-selection path에서 파일이 삭제되지 않는 회귀 테스트가 있다. +- [ ] no-selection path에서 파일이 원래 코드 경로에서 격리되지 않는 회귀 테스트가 있다. ## Out Of Scope diff --git a/docs/plans/v1-cli-report-contract.md b/docs/plans/v1-cli-report-contract.md index 1f9e0fb..5162ef9 100644 --- a/docs/plans/v1-cli-report-contract.md +++ b/docs/plans/v1-cli-report-contract.md @@ -78,9 +78,9 @@ The writer-key contract test uses independent literal expectations for every con - `--min-confidence` overrides `thresholds.cleanMinConfidence`; when neither is provided, the threshold is `0.0`. Candidates below the effective threshold are skipped. - Preview excludes candidates whose normalized path or real parent escapes the report root. Root-contained candidates that fail safety validation remain visible in a separate safety-skipped section with status/marker evidence instead of being silently omitted. - Apply requires exactly one normalized deletion candidate and one matching manifest entry, `safe: true`, the confidence threshold, root/real-parent containment, `sha256`, stable file and parent-directory identity, and content equality. -- On Unix, apply opens and identity-checks the canonical candidate parent plus `/.kratos/clean-quarantine/`, then moves by descriptor-relative `renameat` and verifies the moved object through `openat(..., O_NOFOLLOW)`. Verified bytes remain in an invocation-unique owner-only quarantine directory; Kratos does not physically `unlink` candidate files or invocation directories. This removes the non-atomic final verify→unlink boundary against direct same-credential quarantine mutation; a failed pre-move attempt may therefore leave an empty invocation directory for manual cleanup. Restore remains no-clobber with descriptor-relative `linkat` and also retains the quarantine link. Cross-filesystem `renameat` fails closed without copy/unlink fallback. Concurrent relocation/replacement of the entire report root remains outside the supported threat model. +- On Unix, apply opens and identity-checks the canonical candidate parent plus `/.kratos/clean-quarantine/`, then moves by descriptor-relative `renameat` and verifies the moved object through `openat(..., O_NOFOLLOW)`. Verified bytes remain in an invocation-unique owner-only quarantine directory; Kratos does not physically `unlink` candidate files or invocation directories. This removes the non-atomic final verify→unlink boundary against direct same-credential quarantine mutation; a failed pre-move attempt may therefore leave an empty invocation directory for manual cleanup. If post-move verification fails, Kratos does not link an unverified mutable quarantine entry back into the code tree; it reports failure, whether the original path is absent, and either the last confirmed quarantine path or that the pathname became unresolvable. Cross-filesystem `renameat` fails closed without copy/unlink fallback. Concurrent relocation/replacement of the entire report root remains outside the supported threat model. - Apply skips schema-v2/legacy candidates without evidence, duplicate/aliased candidate paths, `safe: false`, missing/unreadable/non-regular files, direct symlinks, duplicate/missing manifest evidence, unsupported algorithms or platforms without stable identity evidence, identity/content mismatches, root escapes, and below-threshold candidates. Non-Unix destructive execution currently remains fail-closed because this descriptor-relative stable-identity boundary is unavailable. -- `clean --apply` reports the count removed from the code tree separately from every last-known retained quarantine path, including residue from restored, skipped, or failed candidates, plus skipped and failed counts. Per-file failures do not discard prior successful-quarantine or residue accounting. Parent directories are intentionally left in place so a mutable-parent race cannot turn convenience cleanup into an out-of-root directory removal. +- `clean --apply` reports the count removed from the code tree separately from every confirmed retained quarantine path, plus skipped and failed counts. The legacy public `CleanOutcome { deleted_files, skipped_files }` shape and existing function return types remain source-compatible; new `*_detailed` functions return `CleanApplyOutcome` for quarantine paths and failures. A failure with an unresolved moved quarantine pathname is reported explicitly instead of returning a false path. Per-file failures do not discard prior successful-quarantine or residue accounting. Parent directories are intentionally left in place so a mutable-parent race cannot turn convenience cleanup into an out-of-root directory removal. - Fingerprint, file identity, and `safe` are execution-safety metadata only. They are excluded from finding identity, so the v2→v3 migration and content changes do not create diff churn. ## Evidence added in this PR diff --git a/test/package-smoke.test.js b/test/package-smoke.test.js index 4aa22e0..a612bfe 100644 --- a/test/package-smoke.test.js +++ b/test/package-smoke.test.js @@ -212,7 +212,7 @@ test("packed root package boots the actual native addon for the current platform cwd: installRoot, }); assert.equal(cleanResult.status, 0, cleanResult.stderr || cleanResult.stdout); - assert.match(cleanResult.stdout, new RegExp(`보존된 격리 파일: ${originalCandidates.length}`)); + assert.match(cleanResult.stdout, new RegExp(`현재 경로가 확인된 격리 파일: ${originalCandidates.length}`)); for (const candidate of originalCandidates) { assert.equal(fs.existsSync(candidate.file), false, `Expected code path to be quarantined: ${candidate.file}`); } From 7a642aafb8eb65ba6059cb082bd559c148bba24e Mon Sep 17 00:00:00 2001 From: JeremyDev87 Date: Thu, 16 Jul 2026 04:26:42 +0900 Subject: [PATCH 11/11] =?UTF-8?q?fix:=20clean=20preview=20=EA=B2=A9?= =?UTF-8?q?=EB=A6=AC=20=EC=9D=98=EB=AF=B8=20=EC=A0=95=EB=A0=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dry-run 안내와 CLI 계약 테스트를 자동 삭제가 아닌 원래 코드 경로의 보존 격리 의미로 맞춥니다. Co-authored-by: Hermes --- crates/kratos-cli/src/commands/clean.rs | 2 +- crates/kratos-cli/tests/cli_smoke.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/kratos-cli/src/commands/clean.rs b/crates/kratos-cli/src/commands/clean.rs index e61e780..c15fa0a 100644 --- a/crates/kratos-cli/src/commands/clean.rs +++ b/crates/kratos-cli/src/commands/clean.rs @@ -206,7 +206,7 @@ fn format_clean_preview_plan(plan: &CleanPreviewPlan, report_root: &Path) -> Str } lines.push(String::new()); - lines.push("삭제하려면 --apply로 다시 실행하세요.".to_string()); + lines.push("원래 코드 경로에서 보존 격리하려면 --apply로 다시 실행하세요.".to_string()); lines.join("\n") } diff --git a/crates/kratos-cli/tests/cli_smoke.rs b/crates/kratos-cli/tests/cli_smoke.rs index d58bdd0..f241be6 100644 --- a/crates/kratos-cli/tests/cli_smoke.rs +++ b/crates/kratos-cli/tests/cli_smoke.rs @@ -130,7 +130,7 @@ fn scan_report_and_clean_work_for_demo_fixture() { assert!(clean_stdout.contains("상태: 존재함")); assert!(clean_stdout.contains("미리보기:")); assert!(clean_stdout.contains("export function DeadWidget()")); - assert!(clean_stdout.contains("삭제하려면 --apply로 다시 실행하세요.")); + assert!(clean_stdout.contains("원래 코드 경로에서 보존 격리하려면 --apply로 다시 실행하세요.")); let diff = run_cli(&[ "diff",