From 088c1981e560733855b0aaba18afc0d6e680849d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 12:34:38 +0900 Subject: [PATCH 01/17] feat(organize): preserve companion files and report retained items --- docs/organization-research-implementation.md | 27 ++++ src-tauri/src/commands.rs | 35 +++++- src-tauri/src/organization_boundary.rs | 124 +++++++++++++++++++ src-tauri/src/organize.rs | 105 ++++++++++++++++ src/lib/Organize.svelte | 35 +++++- src/lib/api.ts | 9 +- 6 files changed, 326 insertions(+), 9 deletions(-) create mode 100644 docs/organization-research-implementation.md create mode 100644 src-tauri/src/organization_boundary.rs diff --git a/docs/organization-research-implementation.md b/docs/organization-research-implementation.md new file mode 100644 index 000000000..3e275ffa9 --- /dev/null +++ b/docs/organization-research-implementation.md @@ -0,0 +1,27 @@ +# Evidence-based organization implementation + +Status: in progress. Research observations are not shipped capability evidence. + +The organization planner and execution source validator now preserve package descendants and companion-file relationships. Same-basename files with different extensions are retained, not treated as duplicate content. Execution inspects current siblings so a companion omitted from a bounded inventory cannot be silently separated. Incomplete sibling inspection refuses the individual move. + +## Remaining product work + +- Validate retained-item rendering and empty preview behavior in the actual app. The response and Organize now include package, companion and unplanned-item reasons. +- Represent content-grounded project/activity bundles, document roles, concept facets and unresolved evidence without inventing topic confidence. +- Preserve all distinct drafts; identify final versions only from authoritative evidence. +- Keep low-context transcripts unclassified and preserve source-relative audio/transcript/metadata relationships. +- Implement native coordinated iCloud bundle moves with exclusive destination creation, content-bound manifests, fresh shared/upload/conflict checks, durable receipts and verified undo. The operational Swift helper is not yet part of the application. +- Keep local move verification distinct from provider upload completion and remote-device verification; moving files contributes zero reclaimed bytes. +- Exercise the public planning, execution and undo paths against realistic synthetic fixtures; do not commit private user documents as fixtures. + +The first boundary guard is a safety prerequisite, not completion of semantic organization or the 300GiB goal. Existing filename/extension classification is not a content-based ontology implementation. + +## Validation checkpoint + +- Standalone production boundary tests: 2 passed, including a symlink alias into an application package. +- Existing frontend API wrapper tests: 10 passed. These verify command forwarding, not native move safety. +- Svelte diagnostics: 0 errors and 0 warnings; the latest Organize component also compiled without warnings. +- Rust organization tests: 25 passed, including metadata binding, companion preservation, package exclusions and retained-item reporting. The latest command-path suite passed 29 tests, including preservation of an unlisted companion, no move journal on refusal, ordinary moves and undo collision handling. +- Repository-wide format checking reports pre-existing differences outside this change; no mass formatting was applied. + +The current sibling observation is a pre-execution check, not a filesystem transaction: an uncoordinated writer can still add a companion after it. Native coordinated bundle execution and late-writer handling remain required. The preview covers a bounded inventory and explicitly does not attest whole-tree completeness. Shared/session/project-marker boundaries beyond recognized package suffixes remain part of the pending implementation, not inferred coverage from these tests. diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 4265d7751..24acb6a1f 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -2935,7 +2935,7 @@ pub fn plan_organize( root: String, app: AppHandle, state: State, -) -> Result, String> { +) -> Result { let onto = load_ontology_from(&bundled_ontology_ttl(&app)?)?; let rules = crate::userrules::parse_rules(&user_rules_json(&app))?; let files = dupes::collect_files_bounded(Path::new(&root), 10_000, Duration::from_secs(10))?; @@ -2965,7 +2965,7 @@ pub fn plan_organize( } crate::llm::pick_class(engine, &meta, cands) }; - return Ok(organize::plan_moves_with_metadata( + return Ok(organize::organization_preview(&files, organize::plan_moves_with_metadata( &files, &onto, &home, @@ -2973,11 +2973,11 @@ pub fn plan_organize( &rules, &pick, &organize::lineage_metadata_for_path, - )); + ))); } } } - Ok(organize::plan_moves_with_metadata( + Ok(organize::organization_preview(&files, organize::plan_moves_with_metadata( &files, &onto, &home, @@ -2985,7 +2985,7 @@ pub fn plan_organize( &rules, &|_, _| None, &organize::lineage_metadata_for_path, - )) + ))) } #[cfg(not(coverage))] @@ -3644,6 +3644,31 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko . assert!(artifact.join("payload.bin").exists()); } + #[test] + fn execute_moves_preserves_unlisted_companion_and_creates_no_move_journal() { + let tmp = tempfile::tempdir().unwrap(); + let source = tmp.path().join("recording.wav"); + let companion = tmp.path().join("recording.tmk"); + let destination = tmp.path().join("organized/recording.wav"); + let journal = tmp.path().join("operations.jsonl"); + std::fs::write(&source, b"audio").unwrap(); + let plans = vec![organize::MovePlan { + src: source.to_string_lossy().into_owned(), + dst: destination.to_string_lossy().into_owned(), + ..Default::default() + }]; + // The frontend plan contains only the audio; a later companion must still protect it. + std::fs::write(&companion, b"markers").unwrap(); + let results = execute_moves_inner(&plans, &journal, 1); + assert_eq!(results.len(), 1); + assert!(!results[0].ok); + assert_eq!(results[0].error, "organize-companion-bundle-required"); + assert_eq!(std::fs::read(source).unwrap(), b"audio"); + assert_eq!(std::fs::read(companion).unwrap(), b"markers"); + assert!(!destination.exists()); + assert!(!journal.exists()); + } + #[test] fn execute_moves_inner_reports_per_item_and_isolates_failures() { let tmp = tempfile::tempdir().unwrap(); diff --git a/src-tauri/src/organization_boundary.rs b/src-tauri/src/organization_boundary.rs new file mode 100644 index 000000000..7440641f4 --- /dev/null +++ b/src-tauri/src/organization_boundary.rs @@ -0,0 +1,124 @@ +//! Preserve package and companion-file relationships before individual organization moves. +use std::path::Path; + +/// A package descendant must not become an independently movable document. +pub fn package_ancestor(path: &Path) -> bool { + path.ancestors().any(|part| { + part.extension() + .and_then(|value| value.to_str()) + .is_some_and(|value| { + [ + "app", + "bundle", + "framework", + "photoslibrary", + "xcodeproj", + "xcworkspace", + ] + .iter() + .any(|suffix| value.eq_ignore_ascii_case(suffix)) + }) + }) +} + +/// Shared basename is only a preservation hint, never evidence of duplicate content. +pub fn companion_paths(left: &Path, right: &Path) -> bool { + left != right + && left.parent() == right.parent() + && left.file_stem().is_some() + && left.file_stem() == right.file_stem() + && left.extension() != right.extension() +} + +/// Recheck siblings at execution: a bounded scan snapshot may omit a companion. +/// An unreadable directory cannot establish that moving one member is safe. +pub fn validate_individual_move(path: &Path) -> Result<(), String> { + if package_ancestor(path) { + return Err("organize-package-boundary".into()); + } + let resolved = std::fs::canonicalize(path).map_err(|_| "organize-source-unavailable")?; + if package_ancestor(&resolved) { + return Err("organize-package-boundary".into()); + } + let parent = path.parent().ok_or("organize-parent-unavailable")?; + let siblings = std::fs::read_dir(parent).map_err(|_| "organize-parent-unavailable")?; + for (index, sibling) in siblings.enumerate() { + // ponytail: refuse oversized sibling sets; a bundle-aware planner can handle them later. + if index >= 10_000 { + return Err("organize-sibling-scope-incomplete".into()); + } + let sibling = sibling.map_err(|_| "organize-sibling-unavailable")?; + if companion_paths(path, &sibling.path()) { + return Err("organize-companion-bundle-required".into()); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[cfg(unix)] + #[test] + fn package_alias_cannot_bypass_preservation() { + let root = + std::env::temp_dir().join(format!("disksage-package-alias-{}", std::process::id())); + std::fs::create_dir(&root).unwrap(); + let package = root.join("Editor.app"); + std::fs::create_dir(&package).unwrap(); + let file = package.join("document.txt"); + std::fs::write(&file, b"retain").unwrap(); + let alias = root.join("ordinary-folder"); + std::os::unix::fs::symlink(&package, &alias).unwrap(); + assert_eq!( + validate_individual_move(&alias.join("document.txt")).unwrap_err(), + "organize-package-boundary" + ); + assert_eq!(std::fs::read(&file).unwrap(), b"retain"); + std::fs::remove_file(alias).unwrap(); + std::fs::remove_file(file).unwrap(); + std::fs::remove_dir(package).unwrap(); + std::fs::remove_dir(root).unwrap(); + } + + #[test] + fn package_descendants_and_companion_roles_are_preserved() { + assert!(package_ancestor(Path::new( + "/a/Editor.APP/Contents/document.txt" + ))); + assert!(!package_ancestor(Path::new( + "/a/Editor.app-notes/document.txt" + ))); + assert!(companion_paths( + Path::new("/a/recording.wav"), + Path::new("/a/recording.tmk") + )); + assert!(companion_paths( + Path::new("/a/transcript.json"), + Path::new("/a/transcript.txt") + )); + assert!(!companion_paths( + Path::new("/a/transcript.txt"), + Path::new("/b/transcript.json") + )); + assert!(!companion_paths( + Path::new("/a/transcript.txt"), + Path::new("/a/transcript.txt") + )); + let root = std::env::temp_dir().join(format!("disksage-bundle-{}", std::process::id())); + std::fs::create_dir(&root).unwrap(); + let original = root.join("recording.wav"); + std::fs::write(&original, b"preserve original").unwrap(); + assert!(validate_individual_move(&original).is_ok()); + let companion = root.join("recording.tmk"); + std::fs::write(&companion, b"preserve markers").unwrap(); + assert_eq!( + validate_individual_move(&original).unwrap_err(), + "organize-companion-bundle-required" + ); + assert_eq!(std::fs::read(&original).unwrap(), b"preserve original"); + std::fs::remove_file(companion).unwrap(); + std::fs::remove_file(original).unwrap(); + std::fs::remove_dir(root).unwrap(); + } +} diff --git a/src-tauri/src/organize.rs b/src-tauri/src/organize.rs index fe7b0e638..395b0d019 100644 --- a/src-tauri/src/organize.rs +++ b/src-tauri/src/organize.rs @@ -1,3 +1,6 @@ +#[path = "organization_boundary.rs"] +mod organization_boundary; + use std::path::{Component, Path, PathBuf}; use crate::dupes::FileEntry; @@ -29,6 +32,56 @@ pub struct MovePlan { pub lineage: LineageMetadata, } +/// Preview preserves unplanned items explicitly; omission is not a deletion recommendation. +#[derive(Debug, serde::Serialize)] +pub struct OrganizationPreview { + /// The bounded collector cannot attest that it visited the entire requested tree. + pub whole_tree_verified: bool, + pub observed_file_count: usize, + pub moves: Vec, + pub retained: Vec, +} + +#[derive(Debug, serde::Serialize)] +pub struct RetainedItem { + pub path: String, + pub reason: &'static str, +} + +fn companion_sources(files: &[FileEntry]) -> std::collections::HashSet { + let mut companion_extensions = std::collections::HashMap::new(); + for file in files { + if let (Some(parent), Some(stem)) = (file.path.parent(), file.path.file_stem()) { + companion_extensions + .entry((parent, stem)) + .or_insert_with(std::collections::HashSet::new) + .insert(file.path.extension()); + } + } + files.iter().filter(|file| { + file.path.parent().zip(file.path.file_stem()).is_some_and(|key| { + companion_extensions.get(&key).is_some_and(|extensions| extensions.len() > 1) + }) + }).map(|file| file.path.clone()).collect() +} + +pub fn organization_preview(files: &[FileEntry], moves: Vec) -> OrganizationPreview { + let companions = companion_sources(files); + let planned: std::collections::HashSet<&str> = moves.iter().map(|p| p.src.as_str()).collect(); + let retained = files.iter().filter(|f| !planned.contains(f.path.to_string_lossy().as_ref())) + .map(|f| RetainedItem { + path: f.path.to_string_lossy().into_owned(), + reason: if organization_boundary::package_ancestor(&f.path) { + "package_boundary" + } else if companions.contains(&f.path) { + "companion_bundle" + } else { + "not_planned" + }, + }).collect(); + OrganizationPreview { whole_tree_verified: false, observed_file_count: files.len(), moves, retained } +} + #[cfg(not(coverage))] pub fn lineage_metadata_for_path(path: &Path) -> Option { if crate::cloud::source_content_is_dataless(path) { @@ -113,10 +166,16 @@ fn plan_moves_impl( ) -> Vec { let candidates: Vec<&str> = onto.classes.iter().map(|c| local_name(&c.id)).collect(); let reasoner = crate::ontology::Reasoner::build(onto); + let companions = companion_sources(files); let mut plans = Vec::new(); let mut lineage_probe_count = 0; for f in files { let Some(name) = f.path.file_name() else { continue }; + if organization_boundary::package_ancestor(&f.path) + || companions.contains(&f.path) + { + continue; + } let age_days = now_ms.saturating_sub(f.mtime_ms) / 86_400_000; let local: String = match crate::userrules::classify_by_rules(rules, &f.path, f.size, age_days) { Some(c) => c, @@ -240,6 +299,7 @@ pub fn plan_moves_with_metadata( pub fn validate_move_source(plan: &MovePlan) -> Result<(), String> { let path = Path::new(&plan.src); + organization_boundary::validate_individual_move(path)?; let metadata = std::fs::symlink_metadata(path) .map_err(|_| "organize-source-unavailable".to_string())?; if metadata.file_type().is_symlink() || !metadata.is_file() { @@ -310,6 +370,51 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko ; dm:targetFolder "TARGET" . parse_ttl(&ttl.replace("TARGET", target)).unwrap() } + #[test] + fn preview_explains_preserved_relationships_without_making_move_plans() { + let files = vec![fe("/a/recording.wav", 1), fe("/a/recording.tmk", 2), + fe("/a/Editor.app/Contents/readme.txt", 3), fe("/a/unknown.bin", 4)]; + let preview = organization_preview(&files, Vec::new()); + assert!(preview.moves.is_empty()); + assert!(!preview.whole_tree_verified); + assert_eq!(preview.observed_file_count, 4); + assert_eq!(preview.retained.iter().map(|item| item.reason).collect::>(), + vec!["companion_bundle", "companion_bundle", "package_boundary", "not_planned"]); + } + + #[test] + fn planner_preserves_companions_and_package_descendants_before_picking() { + let onto = parse_ttl(ONTO).unwrap(); + let files = vec![ + fe("/downloads/recording.png", 10), + fe("/downloads/recording.json", 20), + fe("/downloads/Editor.app/Contents/image.png", 30), + ]; + let calls = Cell::new(0); + let plans = plan_moves_with(&files, &onto, Path::new("/home/u"), 0, &[], &|_, _| { + calls.set(calls.get() + 1); + Some("Image".into()) + }); + assert!(plans.is_empty()); + assert_eq!(calls.get(), 0); + } + + #[test] + fn execution_rejects_companion_created_after_planning() { + let tmp = tempfile::tempdir().unwrap(); + let source = tmp.path().join("recording.png"); + std::fs::write(&source, b"original").unwrap(); + let files = vec![FileEntry { path: source.clone(), size: 8, mtime_ms: 0 }]; + let plans = plan_moves(&files, &parse_ttl(ONTO).unwrap(), tmp.path()); + assert_eq!(plans.len(), 1); + assert!(validate_move_source(&plans[0]).is_ok()); + let companion = tmp.path().join("recording.json"); + std::fs::write(&companion, b"metadata").unwrap(); + assert_eq!(validate_move_source(&plans[0]).unwrap_err(), "organize-companion-bundle-required"); + assert_eq!(std::fs::read(source).unwrap(), b"original"); + assert_eq!(std::fs::read(companion).unwrap(), b"metadata"); + } + #[test] fn plans_move_to_resolved_target_folder() { let onto = parse_ttl(ONTO).unwrap(); diff --git a/src/lib/Organize.svelte b/src/lib/Organize.svelte index adac77371..042bbb221 100644 --- a/src/lib/Organize.svelte +++ b/src/lib/Organize.svelte @@ -7,6 +7,9 @@ let { scannedRoot }: { scannedRoot: string | null } = $props(); let plans: api.MovePlan[] = $state([]); + let previewLoaded = $state(false); + let observedFileCount = $state(0); + let retained: api.OrganizationPreview["retained"] = $state([]); let busy = $state(false); let loadError = $state(""); let results: api.CleanResult[] = $state([]); @@ -27,8 +30,15 @@ busy = true; loadError = ""; results = []; + plans = []; + retained = []; + previewLoaded = false; try { - plans = await api.planOrganize(scannedRoot); + const preview = await api.planOrganize(scannedRoot); + plans = preview.moves; + observedFileCount = preview.observed_file_count; + retained = preview.retained; + previewLoaded = true; loadVerdicts(plans.map((p) => p.src)); } catch (e) { loadError = String(e); @@ -50,7 +60,7 @@ async function executeSelected() { if (plans.length === 0) return; const okay = await confirm( - `${plans.length}개 파일을 정리합니다 (온톨로지 targetFolder로 이동).\n` + + `${plans.length}개 파일을 미리보기에 표시된 폴더로 옮깁니다.\n` + `되돌리기 버튼으로 복원할 수 있습니다.`, { title: "DiskSage", kind: "warning" }, ); @@ -106,7 +116,26 @@ {#if loadError}

{loadError}

{/if} {#if plans.length === 0 && !busy} -

미리보기를 눌러 정리 계획을 확인하세요.

+

{previewLoaded ? "이번 미리보기에서 이동할 파일은 없습니다." : "미리보기를 눌러 정리 계획을 확인하세요."}

+ {/if} + + {#if previewLoaded} +

확인한 파일 {observedFileCount}개를 바탕으로 한 미리보기입니다. 전체 폴더 조사가 완료됐다는 뜻은 아닙니다.

+ {/if} + + {#if retained.length > 0} +
+ 현재 위치에 유지할 파일 {retained.length}개 +
    + {#each retained as item (item.path)} +
  • {item.path} — {item.reason === "package_boundary" + ? "앱이나 프로젝트 묶음 내부 파일이므로 따로 옮기지 않습니다." + : item.reason === "companion_bundle" + ? "함께 보존할 파일이 있어 한 파일만 따로 옮기지 않습니다." + : "이번 미리보기에는 이동 계획이 없습니다. 현재 위치에 보존합니다."}
  • + {/each} +
+
{/if} {#each grouped as [classId, group] (classId)} diff --git a/src/lib/api.ts b/src/lib/api.ts index 7f0e79b13..9cc63d23c 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -279,8 +279,15 @@ export interface MovePlan { }; } +export interface OrganizationPreview { + whole_tree_verified: boolean; + observed_file_count: number; + moves: MovePlan[]; + retained: { path: string; reason: "package_boundary" | "companion_bundle" | "not_planned" }[]; +} + export const planOrganize = (root: string) => - invoke("plan_organize", { root }); + invoke("plan_organize", { root }); export interface OrganizationLineageItem { lineage_fingerprint: string; From 77e30d6125b3ead2967b44da17c23bc34ef8ebdd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 12:41:28 +0900 Subject: [PATCH 02/17] fix(organize): reject destinations inside package aliases --- docs/organization-research-implementation.md | 10 ++++++++ src-tauri/src/organization_boundary.rs | 27 ++++++++++++++++++++ src-tauri/src/organize.rs | 10 ++++++++ 3 files changed, 47 insertions(+) diff --git a/docs/organization-research-implementation.md b/docs/organization-research-implementation.md index 3e275ffa9..1d4d2c782 100644 --- a/docs/organization-research-implementation.md +++ b/docs/organization-research-implementation.md @@ -25,3 +25,13 @@ The first boundary guard is a safety prerequisite, not completion of semantic or - Repository-wide format checking reports pre-existing differences outside this change; no mass formatting was applied. The current sibling observation is a pre-execution check, not a filesystem transaction: an uncoordinated writer can still add a companion after it. Native coordinated bundle execution and late-writer handling remain required. The preview covers a bounded inventory and explicitly does not attest whole-tree completeness. Shared/session/project-marker boundaries beyond recognized package suffixes remain part of the pending implementation, not inferred coverage from these tests. + +## Native integration findings + +The product already depends on objc2 0.6.4 and objc2-foundation 0.3.2 and calls Foundation directly for iCloud state/eviction. Reuse that boundary for moves instead of invoking a Swift interpreter on the user's machine. The installed Foundation bindings expose NSFileCoordinator's two-writing-URL accessor, ForMoving option, and willMoveTo/didMoveTo notifications. Their coordinator, presenter and block features are not currently enabled in this product. + +The accessor must use the URLs supplied by Foundation, revalidate the approved content manifest inside coordination, and perform an exclusive rename. Coordination is not evidence that an uncoordinated writer cannot modify contents. Persist a pending receipt before mutation and retain an inspection-required result on ambiguous completion; do not automatically replay a move. Existing single-file hard-link execution does not satisfy this bundle contract. + +Context7 documentation lookup returned a quota error. These findings come from the installed version's generated bindings and existing product source; they do not constitute a native runtime test. + +Destination follow-up: package destinations are excluded during planning; execution also resolves the nearest existing destination ancestor to reject aliases into packages. The updated organization suite passed 28 tests, including the destination-plan regression and native symlink fixture. This remains a pre-execution check, not an atomic filesystem guarantee. diff --git a/src-tauri/src/organization_boundary.rs b/src-tauri/src/organization_boundary.rs index 7440641f4..97af09dc4 100644 --- a/src-tauri/src/organization_boundary.rs +++ b/src-tauri/src/organization_boundary.rs @@ -30,6 +30,28 @@ pub fn companion_paths(left: &Path, right: &Path) -> bool { && left.extension() != right.extension() } +/// Reject destinations inside packages, including aliases through an existing ancestor. +pub fn validate_destination(path: &Path) -> Result<(), String> { + if package_ancestor(path) { + return Err("organize-destination-package-boundary".into()); + } + for ancestor in path.ancestors().skip(1) { + match std::fs::symlink_metadata(ancestor) { + Ok(_) => { + let resolved = std::fs::canonicalize(ancestor) + .map_err(|_| "organize-destination-unavailable")?; + if package_ancestor(&resolved) { + return Err("organize-destination-package-boundary".into()); + } + return Ok(()); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(_) => return Err("organize-destination-unavailable".into()), + } + } + Err("organize-destination-unavailable".into()) +} + /// Recheck siblings at execution: a bounded scan snapshot may omit a companion. /// An unreadable directory cannot establish that moving one member is safe. pub fn validate_individual_move(path: &Path) -> Result<(), String> { @@ -74,6 +96,11 @@ mod tests { validate_individual_move(&alias.join("document.txt")).unwrap_err(), "organize-package-boundary" ); + assert_eq!( + validate_destination(&alias.join("new/sub/document.txt")).unwrap_err(), + "organize-destination-package-boundary" + ); + assert!(validate_destination(&root.join("ordinary/new/document.txt")).is_ok()); assert_eq!(std::fs::read(&file).unwrap(), b"retain"); std::fs::remove_file(alias).unwrap(); std::fs::remove_file(file).unwrap(); diff --git a/src-tauri/src/organize.rs b/src-tauri/src/organize.rs index 395b0d019..bc0959589 100644 --- a/src-tauri/src/organize.rs +++ b/src-tauri/src/organize.rs @@ -193,6 +193,9 @@ fn plan_moves_impl( continue; }; let dst = folder_path.join(name); + if organization_boundary::package_ancestor(&dst) { + continue; + } if f.path.parent() == Some(folder_path.as_path()) { continue; } @@ -300,6 +303,7 @@ pub fn plan_moves_with_metadata( pub fn validate_move_source(plan: &MovePlan) -> Result<(), String> { let path = Path::new(&plan.src); organization_boundary::validate_individual_move(path)?; + organization_boundary::validate_destination(Path::new(&plan.dst))?; let metadata = std::fs::symlink_metadata(path) .map_err(|_| "organize-source-unavailable".to_string())?; if metadata.file_type().is_symlink() || !metadata.is_file() { @@ -415,6 +419,12 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko ; dm:targetFolder "TARGET" . assert_eq!(std::fs::read(companion).unwrap(), b"metadata"); } + #[test] + fn planner_rejects_destination_inside_package() { + let ontology = onto_with_target("/Applications/Editor.app/Contents/Documents"); + assert!(plan_moves(&[fe("/downloads/photo.png", 1)], &ontology, Path::new("/home/u")).is_empty()); + } + #[test] fn plans_move_to_resolved_target_folder() { let onto = parse_ttl(ONTO).unwrap(); From b3dcd2f331e39798c0f3af37fba5943ea452b492 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 12:45:34 +0900 Subject: [PATCH 03/17] fix(organize): retain files beyond metadata probe budget --- docs/organization-research-implementation.md | 2 ++ src-tauri/src/organize.rs | 10 +++++----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/organization-research-implementation.md b/docs/organization-research-implementation.md index 1d4d2c782..0071db74d 100644 --- a/docs/organization-research-implementation.md +++ b/docs/organization-research-implementation.md @@ -35,3 +35,5 @@ The accessor must use the URLs supplied by Foundation, revalidate the approved c Context7 documentation lookup returned a quota error. These findings come from the installed version's generated bindings and existing product source; they do not constitute a native runtime test. Destination follow-up: package destinations are excluded during planning; execution also resolves the nearest existing destination ancestor to reject aliases into packages. The updated organization suite passed 28 tests, including the destination-plan regression and native symlink fixture. This remains a pre-execution check, not an atomic filesystem guarantee. + +Probe-budget regression: a 201-item fixture with a 200-probe budget reproduced 201 executable plans (RED, expected 200). The exhausted-budget branch now withholds a plan instead of substituting empty metadata. The omitted item remains visible in the retained preview. Post-fix organization tests passed 28/28; this change does not claim semantic classification for the first 200 items. diff --git a/src-tauri/src/organize.rs b/src-tauri/src/organize.rs index bc0959589..8cf101d6e 100644 --- a/src-tauri/src/organize.rs +++ b/src-tauri/src/organize.rs @@ -204,7 +204,7 @@ fn plan_moves_impl( lineage_probe_count += 1; probe(&f.path) } - Some(_) => Some(LineageMetadata::default()), + Some(_) => None, None => Some(LineageMetadata::default()), }; let Some(lineage) = lineage else { continue }; @@ -499,10 +499,10 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko ; dm:targetFolder "TARGET" . }, ); assert_eq!(probes.get(), MAX_LINEAGE_PROBES); - assert_eq!(plans.len(), MAX_LINEAGE_PROBES + 1); - assert_eq!(plans[MAX_LINEAGE_PROBES].src, format!("/downloads/{}.png", MAX_LINEAGE_PROBES)); - assert_eq!(plans[MAX_LINEAGE_PROBES].source_size, Some(1)); - assert!(plans[MAX_LINEAGE_PROBES].lineage.lineage_fingerprint.is_empty()); + assert_eq!(plans.len(), MAX_LINEAGE_PROBES); + let preview = organization_preview(&files, plans); + assert_eq!(preview.retained.len(), 1); + assert_eq!(preview.retained[0].path, format!("/downloads/{}.png", MAX_LINEAGE_PROBES)); } #[test] From 5146ebc4e183773c3108f183e42fc2a467cc9466 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 13:00:43 +0900 Subject: [PATCH 04/17] fix(organize): retain agent state before classification --- docs/organization-research-implementation.md | 6 +++++ src-tauri/src/organize.rs | 25 +++++++++++++++++--- src/lib/Organize.svelte | 4 +++- src/lib/api.ts | 2 +- 4 files changed, 32 insertions(+), 5 deletions(-) diff --git a/docs/organization-research-implementation.md b/docs/organization-research-implementation.md index 0071db74d..8532729e7 100644 --- a/docs/organization-research-implementation.md +++ b/docs/organization-research-implementation.md @@ -37,3 +37,9 @@ Context7 documentation lookup returned a quota error. These findings come from t Destination follow-up: package destinations are excluded during planning; execution also resolves the nearest existing destination ancestor to reject aliases into packages. The updated organization suite passed 28 tests, including the destination-plan regression and native symlink fixture. This remains a pre-execution check, not an atomic filesystem guarantee. Probe-budget regression: a 201-item fixture with a 200-probe budget reproduced 201 executable plans (RED, expected 200). The exhausted-budget branch now withholds a plan instead of substituting empty metadata. The omitted item remains visible in the retained preview. Post-fix organization tests passed 28/28; this change does not claim semantic classification for the first 200 items. + +## Session preservation during preview + +The planner now reuses the shared agent-state guard before classification and rejects destinations in protected state. The preview explains retained session files. Execution continues to use the same protected move path supplied by PR #345; PR #346 is stacked on that branch until its protected merge. A regression case checks both source preservation before the picker and protected destination rejection. + +Validation after shared-guard integration: 29 organization tests passed with `cargo test --manifest-path src-tauri/Cargo.toml --lib organize:: --no-default-features --offline`; `npm run check` reported zero errors and warnings. The metadata-budget fixture took over 60 seconds after path resolution was added; this is a latency observation requiring investigation, not a failed test or a throughput guarantee. diff --git a/src-tauri/src/organize.rs b/src-tauri/src/organize.rs index 8cf101d6e..bd89b5e5e 100644 --- a/src-tauri/src/organize.rs +++ b/src-tauri/src/organize.rs @@ -71,7 +71,9 @@ pub fn organization_preview(files: &[FileEntry], moves: Vec) -> Organi let retained = files.iter().filter(|f| !planned.contains(f.path.to_string_lossy().as_ref())) .map(|f| RetainedItem { path: f.path.to_string_lossy().into_owned(), - reason: if organization_boundary::package_ancestor(&f.path) { + reason: if crate::safety::agent_state_guard::is_agent_state(&f.path) { + "agent_state" + } else if organization_boundary::package_ancestor(&f.path) { "package_boundary" } else if companions.contains(&f.path) { "companion_bundle" @@ -171,7 +173,8 @@ fn plan_moves_impl( let mut lineage_probe_count = 0; for f in files { let Some(name) = f.path.file_name() else { continue }; - if organization_boundary::package_ancestor(&f.path) + if crate::safety::agent_state_guard::is_agent_state(&f.path) + || organization_boundary::package_ancestor(&f.path) || companions.contains(&f.path) { continue; @@ -193,7 +196,8 @@ fn plan_moves_impl( continue; }; let dst = folder_path.join(name); - if organization_boundary::package_ancestor(&dst) { + if crate::safety::agent_state_guard::is_agent_state(&dst) + || organization_boundary::package_ancestor(&dst) { continue; } if f.path.parent() == Some(folder_path.as_path()) { @@ -403,6 +407,21 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko ; dm:targetFolder "TARGET" . assert_eq!(calls.get(), 0); } + #[test] + fn planner_retains_agent_sessions_before_classification_and_rejects_state_destinations() { + let files = vec![fe("/project/.codex/sessions/session.png", 10), + fe("/project/.claude/projects/conversation.png", 20)]; + let plans = plan_moves_with(&files, &parse_ttl(ONTO).unwrap(), Path::new("/home/u"), + 0, &[], &|_, _| panic!("session must not reach classification")); + let preview = organization_preview(&files, plans); + assert!(preview.moves.is_empty()); + assert_eq!(preview.retained.len(), 2); + assert!(preview.retained.iter().all(|item| item.reason == "agent_state")); + let ontology = onto_with_target("/project/.claude/archive"); + assert!(plan_moves(&[fe("/downloads/photo.png", 1)], &ontology, + Path::new("/home/u")).is_empty()); + } + #[test] fn execution_rejects_companion_created_after_planning() { let tmp = tempfile::tempdir().unwrap(); diff --git a/src/lib/Organize.svelte b/src/lib/Organize.svelte index 042bbb221..96eb80101 100644 --- a/src/lib/Organize.svelte +++ b/src/lib/Organize.svelte @@ -128,7 +128,9 @@ 현재 위치에 유지할 파일 {retained.length}개
    {#each retained as item (item.path)} -
  • {item.path} — {item.reason === "package_boundary" +
  • {item.path} — {item.reason === "agent_state" + ? "대화와 작업 상태를 보존하기 위해 현재 위치에 유지합니다." + : item.reason === "package_boundary" ? "앱이나 프로젝트 묶음 내부 파일이므로 따로 옮기지 않습니다." : item.reason === "companion_bundle" ? "함께 보존할 파일이 있어 한 파일만 따로 옮기지 않습니다." diff --git a/src/lib/api.ts b/src/lib/api.ts index 9cc63d23c..6b814ea10 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -283,7 +283,7 @@ export interface OrganizationPreview { whole_tree_verified: boolean; observed_file_count: number; moves: MovePlan[]; - retained: { path: string; reason: "package_boundary" | "companion_bundle" | "not_planned" }[]; + retained: { path: string; reason: "agent_state" | "package_boundary" | "companion_bundle" | "not_planned" }[]; } export const planOrganize = (root: string) => From 680ce17ae2e129a6db4ff6bc49f97e1af657b554 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 13:08:28 +0900 Subject: [PATCH 05/17] fix(organize): preserve exact paths in undo receipts --- docs/organization-research-implementation.md | 6 ++++ src-tauri/src/commands.rs | 33 ++++++++++++++------ src-tauri/src/organize.rs | 3 +- src-tauri/src/safety.rs | 14 +++++++++ src/lib/api.ts | 1 + 5 files changed, 46 insertions(+), 11 deletions(-) diff --git a/docs/organization-research-implementation.md b/docs/organization-research-implementation.md index 8532729e7..69807cc24 100644 --- a/docs/organization-research-implementation.md +++ b/docs/organization-research-implementation.md @@ -43,3 +43,9 @@ Probe-budget regression: a 201-item fixture with a 200-probe budget reproduced 2 The planner now reuses the shared agent-state guard before classification and rejects destinations in protected state. The preview explains retained session files. Execution continues to use the same protected move path supplied by PR #345; PR #346 is stacked on that branch until its protected merge. A regression case checks both source preservation before the picker and protected destination rejection. Validation after shared-guard integration: 29 organization tests passed with `cargo test --manifest-path src-tauri/Cargo.toml --lib organize:: --no-default-features --offline`; `npm run check` reported zero errors and warnings. The metadata-budget fixture took over 60 seconds after path resolution was added; this is a latency observation requiring investigation, not a failed test or a throughput guarantee. + +## Exact undo paths + +A Unix filename containing ` -> ` reproduced a failed undo in the public command core. New move receipts now contain separate source and destination fields; the display string is no longer parsed for new receipts. Legacy receipts remain readable, but ambiguous legacy path strings are skipped instead of guessed. The focused command regression failed before this fix; all 29 command tests passed after the fix. This change alone does not provide coordinated iCloud transactions, crash-durable receipts, or protection against replacement of a moved file before undo. + +The exact shared guard measured 458 ms for the synthetic `/home/u/Media/Image/0.png` path, versus less than 1 ms for `/downloads/0.png` and a `/tmp` path in the same process. The metadata-budget fixture now supplies an actual temporary home directory. Production protection remains unchanged; this observation does not establish production throughput. diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 24acb6a1f..75f4577f3 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -185,11 +185,13 @@ pub fn clean_dev_artifacts_inner( .collect() } -/// 저널의 move 경로 필드 "src -> dst"를 분리 (순수 함수 — 테스트 대상). 구분자 없으면 None. +/// Read unambiguous legacy move paths. New entries use structured paths. pub fn parse_move_entry(path_field: &str) -> Option<(String, String)> { - path_field - .split_once(" -> ") - .map(|(s, d)| (s.to_string(), d.to_string())) + let (source, destination) = path_field.split_once(" -> ")?; + if source.is_empty() || destination.is_empty() || destination.contains(" -> ") { + return None; + } + Some((source.to_owned(), destination.to_owned())) } /// MovePlan을 safety::move_file로 실행하는 순수 코어 — 항목별 결과, 하나 실패해도 나머지는 진행 (M2와 동일 원칙) @@ -230,16 +232,19 @@ pub fn undo_last_moves_inner(limit: usize, journal_path: &Path, now_ms: u64) -> .iter() .filter(|e| e.op == "move" && e.outcome == "ok") .take(limit) - .filter_map(|e| parse_move_entry(&e.path)) + .filter_map(|e| { + e.move_paths.as_ref().map(|paths| (paths.source.clone(), paths.destination.clone())) + .or_else(|| parse_move_entry(&e.path).map(|(src, dst)| (src.into(), dst.into()))) + }) .map(|(src, dst)| { match safety::move_file(Path::new(&dst), Path::new(&src), journal_path, now_ms) { Ok(()) => CleanResult { - path: src, + path: src.to_string_lossy().into_owned(), ok: true, error: String::new(), }, Err(e) => CleanResult { - path: src, + path: src.to_string_lossy().into_owned(), ok: false, error: e.to_string(), }, @@ -3503,6 +3508,10 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko . #[test] fn parse_move_entry_splits_valid_entry() { + let legacy: safety::JournalEntry = serde_json::from_str( + r#"{"ts_ms":1,"op":"move","path":"/a/b -> /c/d","bytes":1,"outcome":"ok"}"#, + ).unwrap(); + assert!(legacy.move_paths.is_none()); assert_eq!( parse_move_entry("/a/b -> /c/d"), Some(("/a/b".to_string(), "/c/d".to_string())) @@ -3512,6 +3521,8 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko . #[test] fn parse_move_entry_malformed_is_none() { assert_eq!(parse_move_entry("no arrow here"), None); + assert_eq!(parse_move_entry("/a/draft -> revised.bin -> /b/draft -> revised.bin"), None); + assert_eq!(parse_move_entry(" -> /b"), None); } #[test] @@ -3702,9 +3713,11 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko . fn undo_last_moves_inner_reverses_recent_moves_newest_first() { let tmp = tempfile::tempdir().unwrap(); let jp = tmp.path().join("j.jsonl"); - let a = tmp.path().join("a.bin"); + // Windows forbids > in filenames; the legacy parser regression runs on every OS. + let name = if cfg!(unix) { "draft -> revised.bin" } else { "draft revised.bin" }; + let a = tmp.path().join(name); std::fs::write(&a, vec![2u8; 8]).unwrap(); - let a_moved = tmp.path().join("dest").join("a.bin"); + let a_moved = tmp.path().join("dest").join(name); let plans = vec![organize::MovePlan { src: a.to_string_lossy().into(), dst: a_moved.to_string_lossy().into(), @@ -3717,7 +3730,7 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko . let undone = undo_last_moves_inner(10, &jp, 6); assert_eq!(undone.len(), 1); assert!(undone[0].ok); - assert!(a.exists()); + assert_eq!(std::fs::read(&a).unwrap(), vec![2u8; 8]); assert!(!a_moved.exists()); } diff --git a/src-tauri/src/organize.rs b/src-tauri/src/organize.rs index bd89b5e5e..6e2f1f43b 100644 --- a/src-tauri/src/organize.rs +++ b/src-tauri/src/organize.rs @@ -501,6 +501,7 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko ; dm:targetFolder "TARGET" . #[test] fn metadata_probe_is_bounded_per_plan() { let onto = parse_ttl(ONTO).unwrap(); + let home = tempfile::tempdir().unwrap(); let files = (0..MAX_LINEAGE_PROBES + 1) .map(|i| fe(&format!("/downloads/{i}.png"), 1)) .collect::>(); @@ -508,7 +509,7 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko ; dm:targetFolder "TARGET" . let plans = plan_moves_with_metadata( &files, &onto, - Path::new("/home/u"), + home.path(), 1_800_000_000_000, &[], &|_, _| None, diff --git a/src-tauri/src/safety.rs b/src-tauri/src/safety.rs index 959bff1c5..d5ace725b 100644 --- a/src-tauri/src/safety.rs +++ b/src-tauri/src/safety.rs @@ -176,6 +176,12 @@ pub fn filesystem_object_id(path: &Path) -> std::io::Result { } } +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct MovePaths { + pub source: PathBuf, + pub destination: PathBuf, +} + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct JournalEntry { pub ts_ms: u64, @@ -183,6 +189,8 @@ pub struct JournalEntry { pub path: String, pub bytes: u64, pub outcome: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub move_paths: Option, } /// std::io 오류를 SafetyError::Journal로 감싸는 공용 매퍼. @@ -316,6 +324,7 @@ pub fn trash_delete( path: path.to_string_lossy().into_owned(), bytes, outcome: "pending".into(), + move_paths: None, }; journal_append(journal_path, &entry)?; // fsync 없음(의식적 선택): 삭제는 휴지통 경유라 전원 단절로 pending 기록을 잃어도 복구 가능 @@ -490,6 +499,7 @@ pub fn trash_delete_if_identity( path: path.to_string_lossy().into_owned(), bytes, outcome: "pending".into(), + move_paths: None, }; if let Err(error) = journal_append(journal_path, &entry) { let _ = std::fs::remove_dir(&staging_dir); @@ -690,6 +700,7 @@ fn do_move( path: format!("{} -> {}", src.display(), dst.display()), bytes: std::fs::metadata(src).map(|m| m.len()).unwrap_or(0), outcome: "pending".into(), + move_paths: Some(MovePaths { source: src.to_path_buf(), destination: dst.to_path_buf() }), }; journal_append(journal_path, &entry)?; @@ -854,6 +865,7 @@ mod tests { path: format!("/x/{i}"), bytes: i * 10, outcome: "ok".into(), + move_paths: None, }, ) .unwrap(); @@ -882,6 +894,7 @@ mod tests { path: "/x".into(), bytes: 0, outcome: "ok".into(), + move_paths: None, }, ); assert!(matches!(err, Err(SafetyError::Journal(_)))); @@ -1122,6 +1135,7 @@ mod tests { path: "/x".into(), bytes: 0, outcome: "ok".into(), + move_paths: None, }, ) .unwrap(); diff --git a/src/lib/api.ts b/src/lib/api.ts index 6b814ea10..d5d452ad7 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -126,6 +126,7 @@ export interface JournalEntry { path: string; bytes: number; outcome: string; + move_paths?: { source: string; destination: string }; } export interface DupeGroup { hash: string; From cbe7ea30c8426d9e20f221a44d02c70a75947bc7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 13:11:03 +0900 Subject: [PATCH 06/17] fix(safety): reuse exclusive rename for same-volume moves --- docs/organization-research-implementation.md | 2 ++ src-tauri/src/safety.rs | 25 ++++---------------- 2 files changed, 6 insertions(+), 21 deletions(-) diff --git a/docs/organization-research-implementation.md b/docs/organization-research-implementation.md index 69807cc24..fee33c053 100644 --- a/docs/organization-research-implementation.md +++ b/docs/organization-research-implementation.md @@ -49,3 +49,5 @@ Validation after shared-guard integration: 29 organization tests passed with `ca A Unix filename containing ` -> ` reproduced a failed undo in the public command core. New move receipts now contain separate source and destination fields; the display string is no longer parsed for new receipts. Legacy receipts remain readable, but ambiguous legacy path strings are skipped instead of guessed. The focused command regression failed before this fix; all 29 command tests passed after the fix. This change alone does not provide coordinated iCloud transactions, crash-durable receipts, or protection against replacement of a moved file before undo. The exact shared guard measured 458 ms for the synthetic `/home/u/Media/Image/0.png` path, versus less than 1 ms for `/downloads/0.png` and a `/tmp` path in the same process. The metadata-budget fixture now supplies an actual temporary home directory. Production protection remains unchanged; this observation does not establish production throughput. + +Follow-up validation: all 47 shared safety tests passed for structured undo receipts. The metadata-budget regression passed in 0.06 seconds with the actual temporary home, compared with the previous 91.41-second organization run. Same-volume movement now calls the existing exclusive rename primitive instead of linking then unlinking; all 47 shared safety and 29 command regressions passed after this change. Native iCloud coordination remains unfinished. diff --git a/src-tauri/src/safety.rs b/src-tauri/src/safety.rs index d5ace725b..fdf931f6d 100644 --- a/src-tauri/src/safety.rs +++ b/src-tauri/src/safety.rs @@ -676,16 +676,6 @@ fn copy_verified_io(src: &Path, dst: &Path) -> std::io::Result<()> { Ok(()) } -/// 분기 결정(same_vol)을 파라미터로 받아 양 경로를 플랫폼 무관하게 테스트 가능하게 한다. -/// 같은 볼륨 이동 io — hard_link(create-only) 후 원본 링크 제거. 두 io 에러 모두 `?`로 -/// 전파(커버리지 규율: happy path에서 map_err 클로저가 미실행 라인으로 남지 않도록). -/// dst가 이미 있으면 hard_link가 AlreadyExists로 실패해 덮어쓰지 않는다. -fn hardlink_move_io(src: &Path, dst: &Path) -> std::io::Result<()> { - std::fs::hard_link(src, dst)?; - std::fs::remove_file(src)?; - Ok(()) -} - /// move_file이 same_volume()로 실제 결정을 주입한다. fn do_move( src: &Path, @@ -705,13 +695,8 @@ fn do_move( journal_append(journal_path, &entry)?; let result = if same_vol { - // rename은 dst를 원자적으로 덮어쓴다(REPLACE) → dst.exists() 체크 이후 경합으로 생긴 - // 파일이 휴지통도 안 거치고 영구 소실될 수 있다. hard_link는 create-only라 dst가 이미 - // 있으면 AlreadyExists로 실패(덮어쓰지 않음) — 링크 성공 후 원본 링크만 제거한다. - // 두 단계 사이 크래시 시엔 양쪽이 같은 inode를 가리키는 무해한 중복이 남는다(손실 아님). - // io는 헬퍼가 `?`로 전파 → happy path에서 map_err 클로저가 미실행 라인으로 남지 않는다. - // 단일 경계 map_err은 hard_link 실패 테스트(dest-exists)가 커버한다. - hardlink_move_io(src, dst).map_err(|e| SafetyError::Trash(e.to_string())) + // One exclusive rename avoids the intermediate two-name state of link/unlink. + rename_noreplace(src, dst).map_err(|e| SafetyError::Trash(e.to_string())) } else { // 크로스 볼륨: 복사+검증 후 원본 휴지통 (영구 삭제 없음) copy_verified_io(src, dst) @@ -1218,11 +1203,9 @@ mod tests { assert_eq!(std::fs::read(&dst).unwrap().len(), 30); } - // Fix 1 회귀 테스트: hard_link는 create-only라 dst가 이미 있으면(TOCTOU 경합으로 그 사이 - // 생긴 파일 시뮬레이션) AlreadyExists로 실패해야 하며, 그 경합 상대의 dst도 원본 src도 - // 절대 건드리면 안 된다 — rename의 REPLACE 시맨틱이었다면 여기서 dst가 파괴됐을 것. + // A destination created after admission must survive the exclusive move unchanged. #[test] - fn do_move_same_volume_hard_link_fails_when_dest_exists() { + fn do_move_same_volume_fails_when_dest_exists() { let tmp = tempfile::tempdir().unwrap(); let jp = tmp.path().join("j.jsonl"); let src = tmp.path().join("a.bin"); From 28b7b8faff62c9c30ffa6fc84f319bae9c829dcc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 13:19:22 +0900 Subject: [PATCH 07/17] feat(safety): coordinate native macOS file moves --- docs/organization-research-implementation.md | 8 ++ src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 3 +- src-tauri/src/safety.rs | 88 +++++++++++++++++--- 4 files changed, 88 insertions(+), 12 deletions(-) diff --git a/docs/organization-research-implementation.md b/docs/organization-research-implementation.md index fee33c053..2d1f52619 100644 --- a/docs/organization-research-implementation.md +++ b/docs/organization-research-implementation.md @@ -51,3 +51,11 @@ A Unix filename containing ` -> ` reproduced a failed undo in the public command The exact shared guard measured 458 ms for the synthetic `/home/u/Media/Image/0.png` path, versus less than 1 ms for `/downloads/0.png` and a `/tmp` path in the same process. The metadata-budget fixture now supplies an actual temporary home directory. Production protection remains unchanged; this observation does not establish production throughput. Follow-up validation: all 47 shared safety tests passed for structured undo receipts. The metadata-budget regression passed in 0.06 seconds with the actual temporary home, compared with the previous 91.41-second organization run. Same-volume movement now calls the existing exclusive rename primitive instead of linking then unlinking; all 47 shared safety and 29 command regressions passed after this change. Native iCloud coordination remains unfinished. + +## Native coordinated move integration + +Same-volume macOS movement now uses Foundation file coordination before the exclusive rename. The callback rejects changed URLs, rechecks agent-state protection and the source and resolved destination-parent identities, then announces the successful native move before writing its final journal outcome. Forward moves and undo share this path. Cross-volume movement is unchanged. + +This uses the installed objc2-foundation 0.3.2 bindings and block2 0.6.2 already present in the lockfile; no runtime Swift compiler or helper process is needed. Context7 was unavailable because its monthly quota was exhausted, so the exact installed bindings and [Apple's coordination reference](https://developer.apple.com/documentation/foundation/nsfilecoordinator) and [move notification reference](https://developer.apple.com/documentation/foundation/nsfilecoordinator/item(at:willmoveto:)) were inspected. + +The native safety suite passed 47 tests, and the command suite passed 29 tests including forward movement and undo. Foundation emitted sandbox-extension diagnostic messages in the test process even though the operations and preservation assertions passed; this is not evidence of signed-app sandbox entitlements. Local coordinated movement does not prove iCloud upload completion, cross-device consistency, crash-durable recovery, or exclusion of writers that do not participate in file coordination. Bundle-content validation after coordination and durable receipts remain unfinished. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index bcf4c1e71..537956f9c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -979,6 +979,7 @@ version = "0.1.0" dependencies = [ "base64 0.23.1", "blake3", + "block2", "calamine", "csv", "embed_plist", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 15cc5b18c..854134742 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -186,7 +186,8 @@ winapi-util = "0.1.11" [target.'cfg(target_os = "macos")'.dependencies] embed_plist = "1.2.2" objc2 = "0.6.4" -objc2-foundation = { version = "0.3.2", default-features = false, features = ["std", "NSError", "NSFileManager", "NSObject", "NSString", "NSURL", "NSValue"] } +block2 = "0.6.2" +objc2-foundation = { version = "0.3.2", default-features = false, features = ["std", "block2", "NSError", "NSFileCoordinator", "NSFilePresenter", "NSFileManager", "NSObject", "NSString", "NSURL", "NSValue"] } plist = "1" [features] diff --git a/src-tauri/src/safety.rs b/src-tauri/src/safety.rs index fdf931f6d..3ff6350cf 100644 --- a/src-tauri/src/safety.rs +++ b/src-tauri/src/safety.rs @@ -696,7 +696,10 @@ fn do_move( let result = if same_vol { // One exclusive rename avoids the intermediate two-name state of link/unlink. - rename_noreplace(src, dst).map_err(|e| SafetyError::Trash(e.to_string())) + #[cfg(target_os = "macos")] + { coordinated_rename(src, dst) } + #[cfg(not(target_os = "macos"))] + { rename_noreplace(src, dst).map_err(|e| SafetyError::Trash(e.to_string())) } } else { // 크로스 볼륨: 복사+검증 후 원본 휴지통 (영구 삭제 없음) copy_verified_io(src, dst) @@ -722,6 +725,21 @@ pub fn move_file( journal_path: &Path, now_ms: u64, ) -> Result<(), SafetyError> { + validate_move_paths(src, dst)?; + // 목적지 충돌 금지 (덮어쓰기 방지) + if dst.exists() { + return Err(SafetyError::Trash(format!("목적지가 이미 존재: {}", dst.display()))); + } + // 목적지 부모 디렉토리 생성. 위 protected 검사가 parent 없는 경로를 이미 거부했으므로 + // parent는 항상 Some — 폴백(dst 자신)은 실제로 도달 불가지만, 패닉(expect) 대신 한 줄 + // unwrap_or로 두어 라인 커버리지를 유지하면서 방어한다(도달 시 create_dir_all이 에러로 귀결). + let dst_parent = dst.parent().unwrap_or(dst); + std::fs::create_dir_all(dst_parent).map_err(|e| SafetyError::Trash(e.to_string()))?; + + do_move(src, dst, same_volume(src, dst), journal_path, now_ms) +} + +fn validate_move_paths(src: &Path, dst: &Path) -> Result<(), SafetyError> { // 보호: src·dst 양쪽, ParentDir 거부, verbatim 정규화 — trash_delete와 동일 리거 for p in [src, dst] { if p.components().any(|c| matches!(c, std::path::Component::ParentDir)) { @@ -734,17 +752,65 @@ pub fn move_file( return Err(SafetyError::Protected(p.to_path_buf())); } } - // 목적지 충돌 금지 (덮어쓰기 방지) - if dst.exists() { - return Err(SafetyError::Trash(format!("목적지가 이미 존재: {}", dst.display()))); - } - // 목적지 부모 디렉토리 생성. 위 protected 검사가 parent 없는 경로를 이미 거부했으므로 - // parent는 항상 Some — 폴백(dst 자신)은 실제로 도달 불가지만, 패닉(expect) 대신 한 줄 - // unwrap_or로 두어 라인 커버리지를 유지하면서 방어한다(도달 시 create_dir_all이 에러로 귀결). - let dst_parent = dst.parent().unwrap_or(dst); - std::fs::create_dir_all(dst_parent).map_err(|e| SafetyError::Trash(e.to_string()))?; + Ok(()) +} - do_move(src, dst, same_volume(src, dst), journal_path, now_ms) +#[cfg(target_os = "macos")] +fn coordinated_rename(src: &Path, dst: &Path) -> Result<(), SafetyError> { + use std::{cell::Cell, ptr::NonNull}; + use block2::StackBlock; + use objc2::rc::autoreleasepool; + use objc2_foundation::{NSFileCoordinator, NSFileCoordinatorWritingOptions, NSString, NSURL}; + + let io_error = |e: std::io::Error| SafetyError::Trash(e.to_string()); + let source = std::path::absolute(src).map_err(io_error)?; + let destination = std::path::absolute(dst).map_err(io_error)?; + let source_id = filesystem_object_id(&source).map_err(io_error)?; + let parent = destination.parent().ok_or_else(|| SafetyError::Protected(destination.clone()))?; + let parent_id = filesystem_object_id(&std::fs::canonicalize(parent).map_err(io_error)?).map_err(io_error)?; + let source_text = source.to_str().ok_or_else(|| SafetyError::Protected(source.clone()))?; + let destination_text = destination.to_str().ok_or_else(|| SafetyError::Protected(destination.clone()))?; + autoreleasepool(|_| { + let source_url = NSURL::fileURLWithPath(&NSString::from_str(source_text)); + let destination_url = NSURL::fileURLWithPath(&NSString::from_str(destination_text)); + let coordinator = NSFileCoordinator::new(); + let outcome = Cell::new(None); + let invoked = Cell::new(false); + let accessor = StackBlock::new(|from: NonNull, to: NonNull| { + if invoked.replace(true) { + outcome.set(Some(Err(SafetyError::Trash("file coordination invoked twice".into())))); + return; + } + // SAFETY: Foundation lends these non-null URLs for the synchronous accessor call. + let (from, to) = unsafe { (from.as_ref(), to.as_ref()) }; + let result = (|| { + let actual_source = from.path().map(|p| PathBuf::from(p.to_string())); + let actual_destination = to.path().map(|p| PathBuf::from(p.to_string())); + if actual_source.as_ref() != Some(&source) || actual_destination.as_ref() != Some(&destination) { + return Err(SafetyError::Trash("file coordination changed the planned paths".into())); + } + validate_move_paths(&source, &destination)?; + if filesystem_object_id(&source).map_err(io_error)? != source_id + || filesystem_object_id(&std::fs::canonicalize(parent).map_err(io_error)?).map_err(io_error)? != parent_id { + return Err(SafetyError::Trash("file identity changed while waiting for coordination".into())); + } + coordinator.itemAtURL_willMoveToURL(from, to); + rename_noreplace(&source, &destination).map_err(io_error)?; + coordinator.itemAtURL_didMoveToURL(from, to); + Ok(()) + })(); + outcome.set(Some(result)); + }); + let mut error = None; + coordinator.coordinateWritingItemAtURL_options_writingItemAtURL_options_error_byAccessor( + &source_url, NSFileCoordinatorWritingOptions::ForMoving, + &destination_url, NSFileCoordinatorWritingOptions::empty(), Some(&mut error), &accessor, + ); + if let Some(error) = error { + return Err(SafetyError::Trash(error.localizedDescription().to_string())); + } + outcome.take().unwrap_or_else(|| Err(SafetyError::Trash("file coordination did not run".into()))) + }) } #[cfg(test)] From 2117a8a88d1010b43d11b208367b72a556ff79b3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 13:23:55 +0900 Subject: [PATCH 08/17] fix(organize): revalidate plans inside coordinated moves --- docs/organization-research-implementation.md | 4 +- src-tauri/src/commands.rs | 36 ++++++++++++++-- src-tauri/src/safety.rs | 44 ++++++++++++-------- 3 files changed, 61 insertions(+), 23 deletions(-) diff --git a/docs/organization-research-implementation.md b/docs/organization-research-implementation.md index 2d1f52619..6623d90ac 100644 --- a/docs/organization-research-implementation.md +++ b/docs/organization-research-implementation.md @@ -58,4 +58,6 @@ Same-volume macOS movement now uses Foundation file coordination before the excl This uses the installed objc2-foundation 0.3.2 bindings and block2 0.6.2 already present in the lockfile; no runtime Swift compiler or helper process is needed. Context7 was unavailable because its monthly quota was exhausted, so the exact installed bindings and [Apple's coordination reference](https://developer.apple.com/documentation/foundation/nsfilecoordinator) and [move notification reference](https://developer.apple.com/documentation/foundation/nsfilecoordinator/item(at:willmoveto:)) were inspected. -The native safety suite passed 47 tests, and the command suite passed 29 tests including forward movement and undo. Foundation emitted sandbox-extension diagnostic messages in the test process even though the operations and preservation assertions passed; this is not evidence of signed-app sandbox entitlements. Local coordinated movement does not prove iCloud upload completion, cross-device consistency, crash-durable recovery, or exclusion of writers that do not participate in file coordination. Bundle-content validation after coordination and durable receipts remain unfinished. +The native safety suite passed 47 tests, and the command suite passed 29 tests including forward movement and undo. Foundation emitted sandbox-extension diagnostic messages in the test process even though the operations and preservation assertions passed; this is not evidence of signed-app sandbox entitlements. Local coordinated movement does not prove iCloud upload completion, cross-device consistency, crash-durable recovery, or exclusion of writers that do not participate in file coordination. Whole-bundle manifests and durable receipts remain unfinished. + +The organizing command now supplies its existing plan validator to the shared move transaction. It runs before preparation and again inside the native accessor (or immediately before non-macOS/cross-volume mutation). A synthetic companion arriving after preflight first reproduced a missing revalidation, then passed with source and companion contents intact and no destination file. All 30 command and 47 safety tests passed after the fix. This does not exclude uncoordinated writers racing after the final validation. diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 75f4577f3..6ea442b4d 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -203,10 +203,10 @@ pub fn execute_moves_inner( plans .iter() .map(|p| { - match organize::validate_move_source(p).and_then(|_| { - safety::move_file(Path::new(&p.src), Path::new(&p.dst), journal_path, now_ms) - .map_err(|error| error.to_string()) - }) { + match safety::move_file_checked( + Path::new(&p.src), Path::new(&p.dst), journal_path, now_ms, + &|| organize::validate_move_source(p).map_err(safety::SafetyError::Validation), + ) { Ok(()) => CleanResult { path: p.src.clone(), ok: true, @@ -3680,6 +3680,34 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko . assert!(!journal.exists()); } + #[test] + fn checked_move_retains_companion_arriving_after_preflight() { + let tmp = tempfile::tempdir().unwrap(); + let source = tmp.path().join("recording.wav"); + let companion = tmp.path().join("recording.json"); + let destination = tmp.path().join("dest/recording.wav"); + let journal = tmp.path().join("journal.jsonl"); + std::fs::write(&source, b"original").unwrap(); + let plan = organize::MovePlan { + src: source.to_string_lossy().into_owned(), + dst: destination.to_string_lossy().into_owned(), + ..Default::default() + }; + let checks = std::cell::Cell::new(0); + let result = safety::move_file_checked(&source, &destination, &journal, 1, &|| { + checks.set(checks.get() + 1); + if checks.get() == 2 { + std::fs::write(&companion, b"metadata").unwrap(); + } + organize::validate_move_source(&plan).map_err(safety::SafetyError::Validation) + }); + assert_eq!(checks.get(), 2); + assert!(result.is_err()); + assert_eq!(std::fs::read(&source).unwrap(), b"original"); + assert_eq!(std::fs::read(&companion).unwrap(), b"metadata"); + assert!(!destination.exists()); + } + #[test] fn execute_moves_inner_reports_per_item_and_isolates_failures() { let tmp = tempfile::tempdir().unwrap(); diff --git a/src-tauri/src/safety.rs b/src-tauri/src/safety.rs index 3ff6350cf..99080dbd0 100644 --- a/src-tauri/src/safety.rs +++ b/src-tauri/src/safety.rs @@ -9,6 +9,7 @@ pub enum SafetyError { Protected(PathBuf), Trash(String), Journal(String), + Validation(String), } impl std::fmt::Display for SafetyError { @@ -17,6 +18,7 @@ impl std::fmt::Display for SafetyError { SafetyError::Protected(p) => write!(f, "보호된 경로: {}", p.display()), SafetyError::Trash(e) => write!(f, "휴지통 이동 실패: {e}"), SafetyError::Journal(e) => write!(f, "저널 기록 실패: {e}"), + SafetyError::Validation(e) => f.write_str(e), } } } @@ -683,6 +685,7 @@ fn do_move( same_vol: bool, journal_path: &Path, now_ms: u64, + validate: &dyn Fn() -> Result<(), SafetyError>, ) -> Result<(), SafetyError> { let mut entry = JournalEntry { ts_ms: now_ms, @@ -697,13 +700,13 @@ fn do_move( let result = if same_vol { // One exclusive rename avoids the intermediate two-name state of link/unlink. #[cfg(target_os = "macos")] - { coordinated_rename(src, dst) } + { coordinated_rename(src, dst, validate) } #[cfg(not(target_os = "macos"))] - { rename_noreplace(src, dst).map_err(|e| SafetyError::Trash(e.to_string())) } + { validate().and_then(|()| rename_noreplace(src, dst).map_err(|e| SafetyError::Trash(e.to_string()))) } } else { // 크로스 볼륨: 복사+검증 후 원본 휴지통 (영구 삭제 없음) - copy_verified_io(src, dst) - .map_err(|e| SafetyError::Trash(e.to_string())) + validate().and_then(|()| copy_verified_io(src, dst) + .map_err(|e| SafetyError::Trash(e.to_string()))) .and_then(|()| { let bytes = std::fs::metadata(dst).map(|m| m.len()).unwrap_or(0); trash_delete(src, bytes, journal_path, now_ms) @@ -720,23 +723,24 @@ fn do_move( /// 앱 유일의 이동 경로 (스펙 §7-2). 영구 삭제 없음 — 원본 제거는 trash_delete 경유. pub fn move_file( - src: &Path, - dst: &Path, - journal_path: &Path, - now_ms: u64, + src: &Path, dst: &Path, journal_path: &Path, now_ms: u64, +) -> Result<(), SafetyError> { + move_file_checked(src, dst, journal_path, now_ms, &|| Ok(())) +} + +/// Validate before preparation and again inside the native move accessor. +pub(crate) fn move_file_checked( + src: &Path, dst: &Path, journal_path: &Path, now_ms: u64, + validate: &dyn Fn() -> Result<(), SafetyError>, ) -> Result<(), SafetyError> { validate_move_paths(src, dst)?; - // 목적지 충돌 금지 (덮어쓰기 방지) + validate()?; if dst.exists() { return Err(SafetyError::Trash(format!("목적지가 이미 존재: {}", dst.display()))); } - // 목적지 부모 디렉토리 생성. 위 protected 검사가 parent 없는 경로를 이미 거부했으므로 - // parent는 항상 Some — 폴백(dst 자신)은 실제로 도달 불가지만, 패닉(expect) 대신 한 줄 - // unwrap_or로 두어 라인 커버리지를 유지하면서 방어한다(도달 시 create_dir_all이 에러로 귀결). let dst_parent = dst.parent().unwrap_or(dst); std::fs::create_dir_all(dst_parent).map_err(|e| SafetyError::Trash(e.to_string()))?; - - do_move(src, dst, same_volume(src, dst), journal_path, now_ms) + do_move(src, dst, same_volume(src, dst), journal_path, now_ms, validate) } fn validate_move_paths(src: &Path, dst: &Path) -> Result<(), SafetyError> { @@ -756,7 +760,9 @@ fn validate_move_paths(src: &Path, dst: &Path) -> Result<(), SafetyError> { } #[cfg(target_os = "macos")] -fn coordinated_rename(src: &Path, dst: &Path) -> Result<(), SafetyError> { +fn coordinated_rename( + src: &Path, dst: &Path, validate: &dyn Fn() -> Result<(), SafetyError>, +) -> Result<(), SafetyError> { use std::{cell::Cell, ptr::NonNull}; use block2::StackBlock; use objc2::rc::autoreleasepool; @@ -794,6 +800,7 @@ fn coordinated_rename(src: &Path, dst: &Path) -> Result<(), SafetyError> { || filesystem_object_id(&std::fs::canonicalize(parent).map_err(io_error)?).map_err(io_error)? != parent_id { return Err(SafetyError::Trash("file identity changed while waiting for coordination".into())); } + validate()?; coordinator.itemAtURL_willMoveToURL(from, to); rename_noreplace(&source, &destination).map_err(io_error)?; coordinator.itemAtURL_didMoveToURL(from, to); @@ -862,6 +869,7 @@ mod tests { assert!(SafetyError::Protected(PathBuf::from("/x")).to_string().contains("보호")); assert!(SafetyError::Trash("boom".into()).to_string().contains("휴지통")); assert!(SafetyError::Journal("boom".into()).to_string().contains("저널")); + assert_eq!(SafetyError::Validation("source changed".into()).to_string(), "source changed"); } #[test] @@ -1264,7 +1272,7 @@ mod tests { let src = tmp.path().join("a.bin"); let dst = tmp.path().join("b.bin"); std::fs::write(&src, vec![7u8; 30]).unwrap(); - do_move(&src, &dst, true, &jp, 1).unwrap(); + do_move(&src, &dst, true, &jp, 1, &|| Ok(())).unwrap(); assert!(!src.exists()); assert_eq!(std::fs::read(&dst).unwrap().len(), 30); } @@ -1278,7 +1286,7 @@ mod tests { let dst = tmp.path().join("b.bin"); std::fs::write(&src, b"original").unwrap(); std::fs::write(&dst, b"pre-existing").unwrap(); // TOCTOU 경합에서 먼저 생긴 것처럼 시뮬레이션 - let err = do_move(&src, &dst, true, &jp, 1); + let err = do_move(&src, &dst, true, &jp, 1, &|| Ok(())); assert!(matches!(err, Err(SafetyError::Trash(_)))); assert!(src.exists(), "원본은 실패 시 보존"); assert_eq!( @@ -1297,7 +1305,7 @@ mod tests { let dst = tmp.path().join("moved-disksage-xvol-fixture.bin"); std::fs::write(&src, vec![9u8; 40]).unwrap(); // same_vol=false 강제 → 실제 같은 볼륨이어도 copy+verify+trash 경로 실행 - do_move(&src, &dst, false, &jp, 2).unwrap(); + do_move(&src, &dst, false, &jp, 2, &|| Ok(())).unwrap(); assert!(!src.exists(), "원본은 휴지통으로"); assert_eq!(std::fs::read(&dst).unwrap().len(), 40); // 원본이 휴지통에 있음 확인 후 테스트 픽스처만 purge From f84206a9880be61d2edfd806b3d5fd693661a0d8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 13:29:38 +0900 Subject: [PATCH 09/17] docs(organize): record verified whole-folder capability gaps --- docs/organization-research-implementation.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/organization-research-implementation.md b/docs/organization-research-implementation.md index 6623d90ac..e1edcb5e0 100644 --- a/docs/organization-research-implementation.md +++ b/docs/organization-research-implementation.md @@ -61,3 +61,9 @@ This uses the installed objc2-foundation 0.3.2 bindings and block2 0.6.2 already The native safety suite passed 47 tests, and the command suite passed 29 tests including forward movement and undo. Foundation emitted sandbox-extension diagnostic messages in the test process even though the operations and preservation assertions passed; this is not evidence of signed-app sandbox entitlements. Local coordinated movement does not prove iCloud upload completion, cross-device consistency, crash-durable recovery, or exclusion of writers that do not participate in file coordination. Whole-bundle manifests and durable receipts remain unfinished. The organizing command now supplies its existing plan validator to the shared move transaction. It runs before preparation and again inside the native accessor (or immediately before non-macOS/cross-volume mutation). A synthetic companion arriving after preflight first reproduced a missing revalidation, then passed with source and companion contents intact and no destination file. All 30 command and 47 safety tests passed after the fix. This does not exclude uncoordinated writers racing after the final validation. + +## Whole-folder capability audit + +The current `organization_lineage` export explicitly carries path-free per-file metadata and cannot authorize a folder move. `MovePlan` has no membership manifest. The existing orphan and developer-artifact manifests are metadata-only cleanup guards, so they do not establish content preservation for document bundles. These are verified implementation gaps, not evidence that semantic grouping is complete. + +The next integration must bind complete bundle membership, raw relative names, file contents, and modification metadata to the move plan; revalidate that evidence inside the shared coordinated move; and use it for undo. It must preserve packages, project and sharing boundaries and retain incomplete or unavailable cloud content. Content/ontology grouping needs a separate evidence-backed decision: equal extensions, shared basenames, or an existing parent alone cannot establish semantic equivalence. Existing companion preservation remains a veto, not a grouping verdict. Folder movement must not be counted as reclaimed storage. From 461666bee324991691aa682246e5797e38221a03 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 13:56:10 +0900 Subject: [PATCH 10/17] feat(organize): bind document bundle moves and undo to content --- docs/organization-research-implementation.md | 12 +- src-tauri/src/commands.rs | 88 +++++++- src-tauri/src/lib.rs | 1 + src-tauri/src/organization_bundle.rs | 206 +++++++++++++++++++ src-tauri/src/organization_lineage.rs | 1 + src-tauri/src/organize.rs | 12 ++ src-tauri/src/safety.rs | 19 +- src/lib/Organize.svelte | 51 ++++- src/lib/api.ts | 4 + 9 files changed, 372 insertions(+), 22 deletions(-) create mode 100644 src-tauri/src/organization_bundle.rs diff --git a/docs/organization-research-implementation.md b/docs/organization-research-implementation.md index e1edcb5e0..2007e87cc 100644 --- a/docs/organization-research-implementation.md +++ b/docs/organization-research-implementation.md @@ -58,12 +58,18 @@ Same-volume macOS movement now uses Foundation file coordination before the excl This uses the installed objc2-foundation 0.3.2 bindings and block2 0.6.2 already present in the lockfile; no runtime Swift compiler or helper process is needed. Context7 was unavailable because its monthly quota was exhausted, so the exact installed bindings and [Apple's coordination reference](https://developer.apple.com/documentation/foundation/nsfilecoordinator) and [move notification reference](https://developer.apple.com/documentation/foundation/nsfilecoordinator/item(at:willmoveto:)) were inspected. -The native safety suite passed 47 tests, and the command suite passed 29 tests including forward movement and undo. Foundation emitted sandbox-extension diagnostic messages in the test process even though the operations and preservation assertions passed; this is not evidence of signed-app sandbox entitlements. Local coordinated movement does not prove iCloud upload completion, cross-device consistency, crash-durable recovery, or exclusion of writers that do not participate in file coordination. Whole-bundle manifests and durable receipts remain unfinished. +The native safety suite passed 47 tests, and the command suite passed 29 tests including forward movement and undo. Foundation emitted sandbox-extension diagnostic messages in the test process even though the operations and preservation assertions passed; this is not evidence of signed-app sandbox entitlements. Local coordinated movement does not prove iCloud upload completion, cross-device consistency, crash-durable recovery, or exclusion of writers that do not participate in file coordination. General recursive bundle manifests and crash-durable receipts remain unfinished. The organizing command now supplies its existing plan validator to the shared move transaction. It runs before preparation and again inside the native accessor (or immediately before non-macOS/cross-volume mutation). A synthetic companion arriving after preflight first reproduced a missing revalidation, then passed with source and companion contents intact and no destination file. All 30 command and 47 safety tests passed after the fix. This does not exclude uncoordinated writers racing after the final validation. ## Whole-folder capability audit -The current `organization_lineage` export explicitly carries path-free per-file metadata and cannot authorize a folder move. `MovePlan` has no membership manifest. The existing orphan and developer-artifact manifests are metadata-only cleanup guards, so they do not establish content preservation for document bundles. These are verified implementation gaps, not evidence that semantic grouping is complete. +The `organization_lineage` export carries path-free per-file metadata and cannot authorize a folder move. The initial audit found no content-bound membership manifest in `MovePlan`; the bounded pilot below now adds one. Existing orphan and developer-artifact manifests remain metadata-only cleanup guards and are not used as proof of document content preservation. Semantic grouping is still incomplete. -The next integration must bind complete bundle membership, raw relative names, file contents, and modification metadata to the move plan; revalidate that evidence inside the shared coordinated move; and use it for undo. It must preserve packages, project and sharing boundaries and retain incomplete or unavailable cloud content. Content/ontology grouping needs a separate evidence-backed decision: equal extensions, shared basenames, or an existing parent alone cannot establish semantic equivalence. Existing companion preservation remains a veto, not a grouping verdict. Folder movement must not be counted as reclaimed storage. +The bounded pilot binds complete membership, raw relative names, file contents, and modification metadata to its supported bundles and revalidates this for movement and undo. General recursive bundles remain unsupported. It must preserve packages, project and sharing boundaries and retain incomplete or unavailable cloud content. Content/ontology grouping needs a separate evidence-backed decision: equal extensions, shared basenames, or an existing parent alone cannot establish semantic equivalence. Existing companion preservation remains a veto, not a grouping verdict. Folder movement must not be counted as reclaimed storage. + +## Existing-folder movement pilot + +The UI now previews an explicitly selected existing folder and destination parent. It does not infer a topic or ontology class. The current supported scope is a flat bundle of at most 32 local regular files totaling 512 KiB. Nested, unavailable, linked, cloud-only, recognized project, and protected package/session scopes are retained. General recursive and semantic grouping remain unfinished. + +The move plan and undo receipt now bind member names, file identities, sizes, exact modification timestamps, and content digests. The same plan validator runs inside native coordination. Movement across volumes is unavailable for these bundles. Tests have verified a decomposed-Hangul folder name, complete companion movement and undo, refusal after a new member appears, and same-size/same-mtime content drift. The first completed command run passed 31 tests and failed the unchanged live cache-cleanup assertion. After adding error diagnostics and the project-boundary regression, all 33 command tests passed. The intermittent cache assertion was not reproduced in that run; its underlying cause is not established by this result. All 47 shared safety tests also passed. The latest frontend check reported zero errors and warnings. No real user folder was moved by this pilot validation. diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 6ea442b4d..bd729668a 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -205,7 +205,7 @@ pub fn execute_moves_inner( .map(|p| { match safety::move_file_checked( Path::new(&p.src), Path::new(&p.dst), journal_path, now_ms, - &|| organize::validate_move_source(p).map_err(safety::SafetyError::Validation), + &|| organize::validate_move_source(p).map_err(safety::SafetyError::Validation), p.bundle.as_ref(), ) { Ok(()) => CleanResult { path: p.src.clone(), @@ -233,11 +233,20 @@ pub fn undo_last_moves_inner(limit: usize, journal_path: &Path, now_ms: u64) -> .filter(|e| e.op == "move" && e.outcome == "ok") .take(limit) .filter_map(|e| { - e.move_paths.as_ref().map(|paths| (paths.source.clone(), paths.destination.clone())) - .or_else(|| parse_move_entry(&e.path).map(|(src, dst)| (src.into(), dst.into()))) + e.move_paths.as_ref().map(|paths| (paths.source.clone(), paths.destination.clone(), paths.bundle.clone())) + .or_else(|| parse_move_entry(&e.path).map(|(src, dst)| (src.into(), dst.into(), None))) }) - .map(|(src, dst)| { - match safety::move_file(Path::new(&dst), Path::new(&src), journal_path, now_ms) { + .map(|(src, dst, bundle)| { + let inverse = organize::MovePlan { src: dst.to_string_lossy().into_owned(), + dst: src.to_string_lossy().into_owned(), bundle, ..Default::default() }; + let moved = if inverse.bundle.is_some() { + safety::move_file_checked(&dst, &src, journal_path, now_ms, + &|| organize::validate_move_source(&inverse).map_err(safety::SafetyError::Validation), + inverse.bundle.as_ref()) + } else { + safety::move_file(&dst, &src, journal_path, now_ms) + }; + match moved { Ok(()) => CleanResult { path: src.to_string_lossy().into_owned(), ok: true, @@ -2993,6 +3002,12 @@ pub fn plan_organize( ))) } +#[cfg(not(coverage))] +#[tauri::command(async)] +pub fn plan_bundle_organize(root: String, target_parent: String) -> Result { + organize::organization_bundle::plan(Path::new(&root), Path::new(&target_parent)) +} + #[cfg(not(coverage))] #[tauri::command] pub fn export_organization_lineage( @@ -3624,7 +3639,8 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko . } let results = clean_regenerable_caches_inner(&bases, &tmp.path().join("journal.jsonl"), 7); assert_eq!(results.len(), 6); - assert!(results.iter().all(|result| result.ok)); + assert!(results.iter().all(|result| result.ok), "{:?}", + results.iter().map(|result| (&result.path, &result.error)).collect::>()); } #[test] @@ -3680,6 +3696,64 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko . assert!(!journal.exists()); } + #[test] + fn bundle_move_and_undo_preserve_members_and_reject_new_members() { + let tmp = tempfile::tempdir().unwrap(); + let source = tmp.path().join("\u{1106}\u{116e}\u{11ab}\u{1109}\u{1165} bundle"); + std::fs::create_dir(&source).unwrap(); + std::fs::write(source.join("recording.wav"), b"original audio").unwrap(); + std::fs::write(source.join("recording.json"), b"original metadata").unwrap(); + let target = tmp.path().join("organized"); + let journal = tmp.path().join("journal.jsonl"); + let plan = organize::organization_bundle::plan(&source, &target).unwrap(); + let destination = Path::new(&plan.dst); + assert_eq!(plan.bundle.as_ref().unwrap().files.len(), 2); + assert!(execute_moves_inner(std::slice::from_ref(&plan), &journal, 1)[0].ok); + assert!(!source.exists()); + assert!(undo_last_moves_inner(1, &journal, 2)[0].ok); + assert_eq!(std::fs::read(source.join("recording.wav")).unwrap(), b"original audio"); + assert_eq!(std::fs::read(source.join("recording.json")).unwrap(), b"original metadata"); + assert!(!destination.exists()); + assert!(execute_moves_inner(std::slice::from_ref(&plan), &journal, 3)[0].ok); + std::fs::write(destination.join("new note.txt"), b"keep this too").unwrap(); + assert!(!undo_last_moves_inner(1, &journal, 4)[0].ok); + assert!(!source.exists()); + assert_eq!(std::fs::read(destination.join("new note.txt")).unwrap(), b"keep this too"); + } + + #[test] + fn bundle_preview_retains_nested_folders_and_project_ancestors() { + let tmp = tempfile::tempdir().unwrap(); + let source = tmp.path().join("notes"); + std::fs::create_dir(&source).unwrap(); + std::fs::write(source.join("draft.txt"), b"keep").unwrap(); + let target = tmp.path().join("organized"); + std::fs::create_dir(source.join("nested")).unwrap(); + assert!(organize::organization_bundle::plan(&source, &target).is_err()); + std::fs::remove_dir(source.join("nested")).unwrap(); + std::fs::create_dir(tmp.path().join(".git")).unwrap(); + assert!(organize::organization_bundle::plan(&source, &target).is_err()); + assert_eq!(std::fs::read(source.join("draft.txt")).unwrap(), b"keep"); + assert!(!target.exists()); + } + + #[test] + fn bundle_move_rejects_same_size_content_drift_before_mutation() { + let tmp = tempfile::tempdir().unwrap(); + let source = tmp.path().join("notes"); + std::fs::create_dir(&source).unwrap(); + std::fs::write(source.join("draft.txt"), b"first").unwrap(); + let plan = organize::organization_bundle::plan(&source, &tmp.path().join("organized")).unwrap(); + let modified = std::fs::metadata(source.join("draft.txt")).unwrap().modified().unwrap(); + std::fs::write(source.join("draft.txt"), b"other").unwrap(); + std::fs::OpenOptions::new().write(true).open(source.join("draft.txt")).unwrap() + .set_times(std::fs::FileTimes::new().set_modified(modified)).unwrap(); + let journal = tmp.path().join("journal.jsonl"); + assert!(!execute_moves_inner(&[plan], &journal, 1)[0].ok); + assert_eq!(std::fs::read(source.join("draft.txt")).unwrap(), b"other"); + assert!(!journal.exists()); + } + #[test] fn checked_move_retains_companion_arriving_after_preflight() { let tmp = tempfile::tempdir().unwrap(); @@ -3700,7 +3774,7 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko . std::fs::write(&companion, b"metadata").unwrap(); } organize::validate_move_source(&plan).map_err(safety::SafetyError::Validation) - }); + }, None); assert_eq!(checks.get(), 2); assert!(result.is_err()); assert_eq!(std::fs::read(&source).unwrap(), b"original"); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ad9481876..fc3a6e9ee 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -125,6 +125,7 @@ pub fn run() { commands::disk_inventory, commands::ontology_coherence, commands::plan_organize, + commands::plan_bundle_organize, commands::export_organization_lineage, commands::user_rules, commands::execute_moves, diff --git a/src-tauri/src/organization_bundle.rs b/src-tauri/src/organization_bundle.rs new file mode 100644 index 000000000..b95249735 --- /dev/null +++ b/src-tauri/src/organization_bundle.rs @@ -0,0 +1,206 @@ +//! Content-bound movement of an existing small document bundle, without classifying its topic. +use std::{fs, io::Read, path::Path, time::UNIX_EPOCH}; + +use super::{organization_boundary, MovePlan}; + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BundleManifest { + pub root_object_id: String, + pub files: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BundleFile { + pub name: String, + pub object_id: String, + pub bytes: u64, + pub modified_ns: String, + pub content_blake3: String, +} + +// ponytail: flat, 32-file/512-KiB bundles; recursive membership and streaming need separate validation. +const MAX_FILES: usize = 32; +const MAX_BYTES: u64 = 512 * 1024; + +fn unavailable(_: impl std::fmt::Display) -> String { + "폴더 구성이나 파일 상태를 확인하지 못해 현재 위치에 보존합니다.".into() +} + +pub fn observe(source: &Path) -> Result { + let root = fs::symlink_metadata(source).map_err(unavailable)?; + if !source.is_absolute() + || !root.is_dir() + || root.file_type().is_symlink() + || crate::safety::agent_state_guard::is_agent_state(source) + || organization_boundary::package_ancestor(source) + || organization_boundary::package_ancestor(&fs::canonicalize(source).map_err(unavailable)?) + { + return Err("보호 대상이나 일반 폴더가 아닌 항목은 묶음으로 옮기지 않습니다.".into()); + } + for ancestor in fs::canonicalize(source).map_err(unavailable)?.ancestors() { + for marker in [ + ".git", + ".hg", + ".svn", + "Cargo.toml", + "package.json", + "pyproject.toml", + "go.mod", + "CMakeLists.txt", + ] { + match fs::symlink_metadata(ancestor.join(marker)) { + Ok(_) => { + return Err( + "프로젝트 내부 폴더는 기존 관계를 보존하기 위해 따로 옮기지 않습니다." + .into(), + ) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(unavailable(error)), + } + } + } + let root_object_id = crate::safety::filesystem_object_id(source).map_err(unavailable)?; + let mut files = Vec::new(); + let mut total = 0u64; + for entry in fs::read_dir(source).map_err(unavailable)? { + let entry = entry.map_err(unavailable)?; + let path = entry.path(); + let name = entry + .file_name() + .into_string() + .map_err(|_| unavailable("name"))?; + if files.len() >= MAX_FILES + || [ + "Cargo.toml", + "package.json", + "pyproject.toml", + "go.mod", + "CMakeLists.txt", + "wscript", + "SConstruct", + "configure.ac", + ] + .contains(&name.as_str()) + { + return Err("지원 범위를 넘거나 프로젝트 경계가 있는 폴더는 그대로 보존합니다.".into()); + } + let before = fs::symlink_metadata(&path).map_err(unavailable)?; + if !before.is_file() + || before.file_type().is_symlink() + || crate::cloud::metadata_is_dataless(&before) + || crate::safety::agent_state_guard::is_agent_state(&path) + { + return Err( + "하위 폴더·연결 파일·클라우드 전용 파일이 있어 묶음 이동을 보류합니다.".into(), + ); + } + let object_id = crate::safety::filesystem_object_id(&path).map_err(unavailable)?; + let mut options = fs::OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(0x0020_0000); // FILE_FLAG_OPEN_REPARSE_POINT + } + let file = options.open(&path).map_err(unavailable)?; + let opened = file.metadata().map_err(unavailable)?; + if !opened.is_file() + || crate::cloud::metadata_is_dataless(&opened) + || opened.len() > MAX_BYTES.saturating_sub(total) + { + return Err("로컬 내용 확인 범위를 넘어 묶음 이동을 보류합니다.".into()); + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + if opened.file_attributes() & 0x400 != 0 { + return Err(unavailable("reparse point")); + } + } + #[cfg(unix)] + if crate::safety::object_id_from_metadata(&opened).as_ref() != Some(&object_id) { + return Err(unavailable("file replaced")); + } + let modified = opened.modified().map_err(unavailable)?; + let mut bytes = Vec::new(); + (&file) + .take(MAX_BYTES.saturating_sub(total) + 1) + .read_to_end(&mut bytes) + .map_err(unavailable)?; + let after = file.metadata().map_err(unavailable)?; + if bytes.len() as u64 != opened.len() + || after.len() != opened.len() + || after.modified().map_err(unavailable)? != modified + || crate::safety::filesystem_object_id(&path).map_err(unavailable)? != object_id + { + return Err("확인 중 파일이 바뀌어 묶음 이동을 보류합니다.".into()); + } + total += bytes.len() as u64; + files.push(BundleFile { + name, + object_id, + bytes: opened.len(), + modified_ns: modified + .duration_since(UNIX_EPOCH) + .map_err(unavailable)? + .as_nanos() + .to_string(), + content_blake3: blake3::hash(&bytes).to_hex().to_string(), + }); + } + if files.is_empty() + || fs::metadata(source) + .map_err(unavailable)? + .modified() + .map_err(unavailable)? + != root.modified().map_err(unavailable)? + || crate::safety::filesystem_object_id(source).map_err(unavailable)? != root_object_id + { + return Err("비어 있거나 확인 중 구성이 바뀐 폴더는 보존합니다.".into()); + } + files.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(BundleManifest { + root_object_id, + files, + }) +} + +pub fn plan(source: &Path, target_parent: &Path) -> Result { + if !target_parent.is_absolute() || target_parent.starts_with(source) { + return Err("원본 폴더 바깥의 절대 경로를 대상으로 지정하세요.".into()); + } + let destination = target_parent.join(source.file_name().ok_or_else(|| unavailable("root"))?); + match fs::symlink_metadata(&destination) { + Ok(_) => return Err("대상 위치에 같은 이름이 있어 묶음 이동을 보류합니다.".into()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(unavailable(error)), + } + let canonical_source = fs::canonicalize(source).map_err(unavailable)?; + for ancestor in target_parent.ancestors() { + if let Ok(resolved) = fs::canonicalize(ancestor) { + if resolved.starts_with(&canonical_source) { + return Err("원본 폴더 안으로는 묶음을 옮길 수 없습니다.".into()); + } + break; + } + } + organization_boundary::validate_destination(&destination)?; + let bundle = observe(source)?; + Ok(MovePlan { + src: source.to_str().ok_or_else(|| unavailable("source"))?.into(), + dst: destination + .to_str() + .ok_or_else(|| unavailable("destination"))? + .into(), + bundle: Some(bundle), + ..Default::default() + }) +} diff --git a/src-tauri/src/organization_lineage.rs b/src-tauri/src/organization_lineage.rs index d410f86fa..89258cb74 100644 --- a/src-tauri/src/organization_lineage.rs +++ b/src-tauri/src/organization_lineage.rs @@ -159,6 +159,7 @@ mod tests { class_id: "https://disksage.app/ontology#Media".into(), source_size: Some(42), source_mtime_ms: Some(123), + bundle: None, lineage: LineageMetadata { production_time_ms: Some(456), production_time_source: Some("embedded:exiftool:MediaCreateDate".into()), diff --git a/src-tauri/src/organize.rs b/src-tauri/src/organize.rs index 6e2f1f43b..95cdf83cd 100644 --- a/src-tauri/src/organize.rs +++ b/src-tauri/src/organize.rs @@ -1,5 +1,7 @@ #[path = "organization_boundary.rs"] mod organization_boundary; +#[path = "organization_bundle.rs"] +pub mod organization_bundle; use std::path::{Component, Path, PathBuf}; @@ -30,6 +32,8 @@ pub struct MovePlan { pub source_mtime_ms: Option, #[serde(default)] pub lineage: LineageMetadata, + #[serde(default)] + pub bundle: Option, } /// Preview preserves unplanned items explicitly; omission is not a deletion recommendation. @@ -219,6 +223,7 @@ fn plan_moves_impl( source_size: lineage_probe.map(|_| f.size), source_mtime_ms: lineage_probe.map(|_| f.mtime_ms), lineage, + bundle: None, }); } plans @@ -306,6 +311,13 @@ pub fn plan_moves_with_metadata( pub fn validate_move_source(plan: &MovePlan) -> Result<(), String> { let path = Path::new(&plan.src); + if let Some(expected) = &plan.bundle { + organization_boundary::validate_destination(Path::new(&plan.dst))?; + if organization_bundle::observe(path)? != *expected { + return Err("폴더 구성이나 내용이 바뀌어 묶음 이동을 보류합니다.".into()); + } + return Ok(()); + } organization_boundary::validate_individual_move(path)?; organization_boundary::validate_destination(Path::new(&plan.dst))?; let metadata = std::fs::symlink_metadata(path) diff --git a/src-tauri/src/safety.rs b/src-tauri/src/safety.rs index 99080dbd0..1a1526de6 100644 --- a/src-tauri/src/safety.rs +++ b/src-tauri/src/safety.rs @@ -182,6 +182,8 @@ pub fn filesystem_object_id(path: &Path) -> std::io::Result { pub struct MovePaths { pub source: PathBuf, pub destination: PathBuf, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bundle: Option, } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] @@ -686,6 +688,7 @@ fn do_move( journal_path: &Path, now_ms: u64, validate: &dyn Fn() -> Result<(), SafetyError>, + bundle: Option<&crate::organize::organization_bundle::BundleManifest>, ) -> Result<(), SafetyError> { let mut entry = JournalEntry { ts_ms: now_ms, @@ -693,7 +696,7 @@ fn do_move( path: format!("{} -> {}", src.display(), dst.display()), bytes: std::fs::metadata(src).map(|m| m.len()).unwrap_or(0), outcome: "pending".into(), - move_paths: Some(MovePaths { source: src.to_path_buf(), destination: dst.to_path_buf() }), + move_paths: Some(MovePaths { source: src.to_path_buf(), destination: dst.to_path_buf(), bundle: bundle.cloned() }), }; journal_append(journal_path, &entry)?; @@ -725,13 +728,14 @@ fn do_move( pub fn move_file( src: &Path, dst: &Path, journal_path: &Path, now_ms: u64, ) -> Result<(), SafetyError> { - move_file_checked(src, dst, journal_path, now_ms, &|| Ok(())) + move_file_checked(src, dst, journal_path, now_ms, &|| Ok(()), None) } /// Validate before preparation and again inside the native move accessor. pub(crate) fn move_file_checked( src: &Path, dst: &Path, journal_path: &Path, now_ms: u64, validate: &dyn Fn() -> Result<(), SafetyError>, + bundle: Option<&crate::organize::organization_bundle::BundleManifest>, ) -> Result<(), SafetyError> { validate_move_paths(src, dst)?; validate()?; @@ -740,7 +744,10 @@ pub(crate) fn move_file_checked( } let dst_parent = dst.parent().unwrap_or(dst); std::fs::create_dir_all(dst_parent).map_err(|e| SafetyError::Trash(e.to_string()))?; - do_move(src, dst, same_volume(src, dst), journal_path, now_ms, validate) + if bundle.is_some() && !same_volume(src, dst) { + return Err(SafetyError::Validation("묶음은 같은 볼륨 안에서만 옮길 수 있습니다.".into())); + } + do_move(src, dst, same_volume(src, dst), journal_path, now_ms, validate, bundle) } fn validate_move_paths(src: &Path, dst: &Path) -> Result<(), SafetyError> { @@ -1272,7 +1279,7 @@ mod tests { let src = tmp.path().join("a.bin"); let dst = tmp.path().join("b.bin"); std::fs::write(&src, vec![7u8; 30]).unwrap(); - do_move(&src, &dst, true, &jp, 1, &|| Ok(())).unwrap(); + do_move(&src, &dst, true, &jp, 1, &|| Ok(()), None).unwrap(); assert!(!src.exists()); assert_eq!(std::fs::read(&dst).unwrap().len(), 30); } @@ -1286,7 +1293,7 @@ mod tests { let dst = tmp.path().join("b.bin"); std::fs::write(&src, b"original").unwrap(); std::fs::write(&dst, b"pre-existing").unwrap(); // TOCTOU 경합에서 먼저 생긴 것처럼 시뮬레이션 - let err = do_move(&src, &dst, true, &jp, 1, &|| Ok(())); + let err = do_move(&src, &dst, true, &jp, 1, &|| Ok(()), None); assert!(matches!(err, Err(SafetyError::Trash(_)))); assert!(src.exists(), "원본은 실패 시 보존"); assert_eq!( @@ -1305,7 +1312,7 @@ mod tests { let dst = tmp.path().join("moved-disksage-xvol-fixture.bin"); std::fs::write(&src, vec![9u8; 40]).unwrap(); // same_vol=false 강제 → 실제 같은 볼륨이어도 copy+verify+trash 경로 실행 - do_move(&src, &dst, false, &jp, 2, &|| Ok(())).unwrap(); + do_move(&src, &dst, false, &jp, 2, &|| Ok(()), None).unwrap(); assert!(!src.exists(), "원본은 휴지통으로"); assert_eq!(std::fs::read(&dst).unwrap().len(), 40); // 원본이 휴지통에 있음 확인 후 테스트 픽스처만 purge diff --git a/src/lib/Organize.svelte b/src/lib/Organize.svelte index 96eb80101..cb278d2f2 100644 --- a/src/lib/Organize.svelte +++ b/src/lib/Organize.svelte @@ -11,11 +11,19 @@ let observedFileCount = $state(0); let retained: api.OrganizationPreview["retained"] = $state([]); let busy = $state(false); + let bundleParent = $state(""); + let bundlePlan: api.MovePlan | null = $state(null); let loadError = $state(""); let results: api.CleanResult[] = $state([]); let verdicts: Record = $state({}); let exportStatus = $state(""); + $effect(() => { + scannedRoot; + bundleParent; + bundlePlan = null; + }); + async function loadVerdicts(paths: string[]) { try { const fvs = await api.fileVerdicts(paths); @@ -47,6 +55,23 @@ } } + async function loadBundlePlan() { + if (!scannedRoot) return; + busy = true; + loadError = ""; + bundlePlan = null; + const root = scannedRoot; + const parent = bundleParent; + try { + const planned = await api.planBundleOrganize(root, parent); + if (root === scannedRoot && parent === bundleParent) bundlePlan = planned; + } catch (error) { + loadError = String(error); + } finally { + busy = false; + } + } + // Group plans by class_id for display let grouped = $derived.by(() => { const g = new Map(); @@ -57,19 +82,20 @@ return Array.from(g.entries()); }); - async function executeSelected() { - if (plans.length === 0) return; + async function executeSelected(selected: api.MovePlan[]) { + if (selected.length === 0) return; const okay = await confirm( - `${plans.length}개 파일을 미리보기에 표시된 폴더로 옮깁니다.\n` + - `되돌리기 버튼으로 복원할 수 있습니다.`, + `${selected.length}개 항목을 미리보기에 표시된 폴더로 옮깁니다.\n` + + `이동 기록을 남깁니다. 변경이나 경로 충돌이 있으면 되돌리기를 보류합니다.`, { title: "DiskSage", kind: "warning" }, ); if (!okay) return; busy = true; try { - const r = await api.executeMoves(plans); + const r = await api.executeMoves(selected); results = r; plans = []; + bundlePlan = null; } catch (e) { loadError = String(e); } finally { @@ -113,6 +139,19 @@ 미리보기/실행 상태와 무관하게 항상 노출되어야 한다(그렇지 않으면 재-미리보기로 사라짐). --> +
    + 선택한 폴더를 기존 묶음 그대로 이동 +

    주제를 자동 분류하지 않고 현재 폴더 이름과 구성원을 함께 보존합니다. 현재는 하위 폴더 없이 로컬 파일 32개, 합계 512KiB 이하인 문서 묶음을 지원합니다.

    + + + {#if bundlePlan?.bundle} +

    {bundlePlan.src} → {bundlePlan.dst}

    +
      {#each bundlePlan.bundle.files as file (file.name)}
    • {file.name} · {fmtBytes(file.bytes)}
    • {/each}
    + + {/if} +
    {#if loadError}

    {loadError}

    {/if} {#if plans.length === 0 && !busy} @@ -167,7 +206,7 @@ {#if plans.length > 0}
    -
  • {/each}
diff --git a/src/lib/api.ts b/src/lib/api.ts index 39ef8d65b..9c30050af 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -285,7 +285,7 @@ export interface OrganizationPreview { whole_tree_verified: boolean; observed_file_count: number; moves: MovePlan[]; - retained: { path: string; reason: "agent_state" | "package_boundary" | "companion_bundle" | "not_planned" }[]; + retained: { path: string; reason: "agent_state" | "package_boundary" | "companion_bundle" | "project_boundary_unverified" | "not_planned" }[]; } export const planBundleOrganize = (root: string, targetParent: string) => From 61875f0f85480fe58ce2762e41b15b02b3cda5ef Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 10:39:02 +0900 Subject: [PATCH 14/17] docs(organize): record classification evidence lifecycle gaps Signed-off-by: Seongho Bae --- docs/product-technical-gap-baseline.md | 41 ++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 5ecd46866..f43201c5b 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -679,3 +679,44 @@ At each scheduled or operator loop, update this file only with new dated evidenc pipe leak that could starve the independent `ps` probe and report a false active-use timeout. The focused Rust test passed 3/3. The same patch is present on stacked PR heads `a0fa7bc` (#247) and `741ab30` (#246); hosted checks are rerunning and protected merge/review is still pending. + +## Organization evidence gap — source audit at ae41fc9e + +Status: open, not shipped. Source revision: `ae41fc9e3b37bc7c9a62a3c46e4ee421f2f360dc` (PR #346). + +The organization preview retains session, package, companion and project-boundary cases, +and deliberately reports that the whole tree is unverified. These safeguards do not establish +why a document belongs to a semantic group. The inspected move-plan record carries a class, +source metadata, production-time lineage and an optional bundle manifest. It has no explicit +record for the content-supported classification rationale, concept-scheme release, or uncertainty +of the proposed semantic relationship. Production-time confidence must not be interpreted as +classification confidence. + +Acceptance remains open: bind a classification decision to the reviewed source evidence and +concept version, expose its uncertainty before execution, and preserve that decision through +execution and undo receipts. Verify the complete command path with synthetic boundary cases +without publishing private document text. Reuse the existing plan and journal boundaries; +do not introduce an independent classification authority or infer deletion eligibility from +semantic similarity. A source fingerprint or successful safety test alone cannot close this gap. + +Research acceptance also remains open: compare existing, topic, project and hybrid structures +using independent groups and explicit retrieval tasks. Existing small content reviews support +counterexamples only; no user retrieval-time improvement has been measured. RankWeave v0.18.0 +is a possible ranked-retrieval evaluation owner, not evidence of folder-navigation effectiveness +or a deployed integration. + +### Execution and undo trace for the evidence gap + +At the same audited revision, `commands::execute_moves_inner` passes source/destination, +a source-validation callback and the optional bundle manifest into `safety::move_file_checked`. +`safety::do_move` writes pending/result entries with structured paths and the bundle, but does +not persist the plan's class or production-time lineage. `undo_last_moves_inner` reconstructs +an inverse from journal paths and bundle data. Adding a decision field only to `MovePlan` +would therefore leave both the durable receipt and inverse operation incomplete. + +The repair must carry the reviewed decision through the shared move boundary into the existing +pending/result journal entries, and link an undo to that original decision without reclassifying +it. Legacy entries must remain explicitly evidence-unavailable. Before implementation, verify +all journal constructors and public command serialization; after implementation, exercise plan +serialization, execution journal round-trip and undo preservation through the public core. +This trace is evidence of a missing contract, not a claim that the repair has been implemented. From c2101bc401460fc7eded9c348980d50fff36d048 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 12:00:40 +0900 Subject: [PATCH 15/17] feat(organize): preserve suggestion provenance through move and undo Label rule, model-picker and extension suggestions as content-unverified. Keep producer provenance in pending/result move receipts and undo while retaining legacy unknown values and existing safety checks. Add producer, legacy serialization and undo regression assertions. UI check passed; local Rust check remains running and runtime tests are not yet verified. Preserve the open semantic-evidence and multi-topic grouping gaps. Signed-off-by: Seongho Bae --- docs/product-technical-gap-baseline.md | 23 ++++++++++++++++++++ src-tauri/src/commands.rs | 29 +++++++++++++++----------- src-tauri/src/organization_lineage.rs | 1 + src-tauri/src/organize.rs | 24 +++++++++++++++++---- src-tauri/src/safety.rs | 28 +++++++++++++++++++------ src/lib/Organize.svelte | 7 +++++++ src/lib/api.ts | 1 + 7 files changed, 91 insertions(+), 22 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index f43201c5b..358de083f 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -720,3 +720,26 @@ it. Legacy entries must remain explicitly evidence-unavailable. Before implement all journal constructors and public command serialization; after implementation, exercise plan serialization, execution journal round-trip and undo preservation through the public core. This trace is evidence of a missing contract, not a claim that the repair has been implemented. + +### Multi-topic document evidence — exploratory observation, 2026-09-08 + +Two private conversation exports (21,803 bytes combined) were reviewed after native +non-shared/upload-complete/no-conflict checks. The dialogue continues a career discussion +across both exports; the second changes to statistical analysis near its end and explicitly +proposes restarting the call to separate the summary. This contradicts treating one whole +file as exactly one topic. It does not establish project ownership, transcript accuracy, +app-link independence, or a correct destination. Both originals stayed in place. Size and +modification time were unchanged after reading; native post-read observation reported both +uploaded with no conflicts. No other-device check or retrieval experiment was performed. + +Acceptance must preserve the original event/document group while allowing multiple topic +associations tied to reviewed spans. Generated summaries and speakers' hypotheses must +remain attributed assertions, not domain facts. Topic associations must not authorize +splitting or deleting the source. A synthetic regression should cover a mid-document topic +change with an unresolved app reference, keeping physical movement on hold. Private text, +personal identifiers, and private source paths must never become public test fixtures. + +The proposed producer-source patch in PR #346 is only a prerequisite: it identifies the +source of a suggestion and carries that label into move/undo receipts. It does not implement +span evidence, a released concept mapping, independent evaluation, or app-link verification. +This gap remains open regardless of that patch's test outcome. diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index d66375344..ea11c0def 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -205,7 +205,7 @@ pub fn execute_moves_inner( .map(|p| { match safety::move_file_checked( Path::new(&p.src), Path::new(&p.dst), journal_path, now_ms, - &|| organize::validate_move_source(p).map_err(safety::SafetyError::Validation), p.bundle.as_ref(), + &|| organize::validate_move_source(p).map_err(safety::SafetyError::Validation), p.bundle.as_ref(), p.classification_source.as_deref(), ) { Ok(()) => CleanResult { path: p.src.clone(), @@ -233,19 +233,18 @@ pub fn undo_last_moves_inner(limit: usize, journal_path: &Path, now_ms: u64) -> .filter(|e| e.op == "move" && e.outcome == "ok") .take(limit) .filter_map(|e| { - e.move_paths.as_ref().map(|paths| (paths.source.clone(), paths.destination.clone(), paths.bundle.clone())) - .or_else(|| parse_move_entry(&e.path).map(|(src, dst)| (src.into(), dst.into(), None))) + e.move_paths.as_ref().map(|paths| (paths.source.clone(), paths.destination.clone(), paths.bundle.clone(), paths.classification_source.clone())) + .or_else(|| parse_move_entry(&e.path).map(|(src, dst)| (src.into(), dst.into(), None, None))) }) - .map(|(src, dst, bundle)| { + .map(|(src, dst, bundle, classification_source)| { let inverse = organize::MovePlan { src: dst.to_string_lossy().into_owned(), dst: src.to_string_lossy().into_owned(), bundle, ..Default::default() }; - let moved = if inverse.bundle.is_some() { - safety::move_file_checked(&dst, &src, journal_path, now_ms, - &|| organize::validate_move_source(&inverse).map_err(safety::SafetyError::Validation), - inverse.bundle.as_ref()) - } else { - safety::move_file(&dst, &src, journal_path, now_ms) - }; + let moved = safety::move_file_checked(&dst, &src, journal_path, now_ms, + &|| if inverse.bundle.is_some() { + organize::validate_move_source(&inverse).map_err(safety::SafetyError::Validation) + } else { + Ok(()) + }, inverse.bundle.as_ref(), classification_source.as_deref()); match moved { Ok(()) => CleanResult { path: src.to_string_lossy().into_owned(), @@ -3779,7 +3778,7 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko . std::fs::write(&companion, b"metadata").unwrap(); } organize::validate_move_source(&plan).map_err(safety::SafetyError::Validation) - }, None); + }, None, None); assert_eq!(checks.get(), 2); assert!(result.is_err()); assert_eq!(std::fs::read(&source).unwrap(), b"original"); @@ -3829,6 +3828,7 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko . src: a.to_string_lossy().into(), dst: a_moved.to_string_lossy().into(), class_id: "x".into(), + classification_source: Some("user_rule".into()), ..Default::default() }]; execute_moves_inner(&plans, &jp, 5); @@ -3839,6 +3839,11 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko . assert!(undone[0].ok); assert_eq!(std::fs::read(&a).unwrap(), vec![2u8; 8]); assert!(!a_moved.exists()); + let entries = safety::journal_recent(&jp, usize::MAX); + let moves: Vec<_> = entries.iter().filter(|e| e.op == "move").collect(); + assert_eq!(moves.len(), 4); + assert!(moves.iter().all(|entry| entry.move_paths.as_ref().unwrap() + .classification_source.as_deref() == Some("user_rule"))); } #[test] diff --git a/src-tauri/src/organization_lineage.rs b/src-tauri/src/organization_lineage.rs index 89258cb74..3a93e51e4 100644 --- a/src-tauri/src/organization_lineage.rs +++ b/src-tauri/src/organization_lineage.rs @@ -157,6 +157,7 @@ mod tests { src: "/private/source/secret.mov".into(), dst: "/Users/example/Media/Media/secret.mov".into(), class_id: "https://disksage.app/ontology#Media".into(), + classification_source: None, source_size: Some(42), source_mtime_ms: Some(123), bundle: None, diff --git a/src-tauri/src/organize.rs b/src-tauri/src/organize.rs index 9786d6c9e..cb557398e 100644 --- a/src-tauri/src/organize.rs +++ b/src-tauri/src/organize.rs @@ -26,6 +26,9 @@ pub struct MovePlan { pub src: String, pub dst: String, pub class_id: String, + /// Producer provenance only; this does not attest semantic correctness. + #[serde(default)] + pub classification_source: Option, #[serde(default)] pub source_size: Option, #[serde(default)] @@ -191,12 +194,12 @@ fn plan_moves_impl( continue; } let age_days = now_ms.saturating_sub(f.mtime_ms) / 86_400_000; - let local: String = match crate::userrules::classify_by_rules(rules, &f.path, f.size, age_days) { - Some(c) => c, + let (local, classification_source) = match crate::userrules::classify_by_rules(rules, &f.path, f.size, age_days) { + Some(c) => (c, "user_rule"), None => match pick(&f.path, &candidates) { - Some(picked) => picked, + Some(picked) => (picked, "model_picker"), None => match classify(&f.path) { - Some(c) => c.to_string(), + Some(c) => (c.to_string(), "extension"), None => continue, }, }, @@ -227,6 +230,7 @@ fn plan_moves_impl( src: f.path.to_string_lossy().into_owned(), dst: dst.to_string_lossy().into_owned(), class_id: class.id.clone(), + classification_source: Some(classification_source.into()), source_size: lineage_probe.map(|_| f.size), source_mtime_ms: lineage_probe.map(|_| f.mtime_ms), lineage, @@ -404,6 +408,15 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko ; dm:targetFolder "TARGET" . parse_ttl(&ttl.replace("TARGET", target)).unwrap() } + #[test] + fn legacy_plan_has_no_invented_classification_source() { + let legacy = r#"{"src":"/source","dst":"/destination","class_id":"class"}"#; + let plan: MovePlan = serde_json::from_str(legacy).unwrap(); + assert_eq!(plan.classification_source, None); + let restored: MovePlan = serde_json::from_str(&serde_json::to_string(&plan).unwrap()).unwrap(); + assert_eq!(restored.classification_source, None); + } + #[test] fn preview_explains_preserved_relationships_without_making_move_plans() { let fixture = tempfile::tempdir().unwrap(); @@ -736,6 +749,7 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko ; dm:targetFolder "/opt/media/{ let pick = |_p: &Path, _c: &[&str]| Some("Image".to_string()); let plans = plan_moves_with(&files, &onto, home, 0, &[], &pick); assert_eq!(plans.len(), 1); + assert_eq!(plans[0].classification_source.as_deref(), Some("model_picker")); assert!(plans[0].class_id.ends_with("Image")); } @@ -749,6 +763,7 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko ; dm:targetFolder "/opt/media/{ let pick = |_p: &Path, _c: &[&str]| None; let plans = plan_moves_with(&files, &onto, home, 0, &[], &pick); assert_eq!(plans.len(), 1); + assert_eq!(plans[0].classification_source.as_deref(), Some("extension")); assert!(plans[0].class_id.ends_with("Image")); } @@ -783,6 +798,7 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko ; dm:targetFolder "/opt/media/{ let pick = |_p: &Path, _c: &[&str]| Some("Image".to_string()); // picker가 Image를 골라도 let plans = plan_moves_with(&[fixture_file(fixture.path(), "/d/pic.png", 10)], &onto, home, 0, &rules, &pick); assert_eq!(plans.len(), 1); + assert_eq!(plans[0].classification_source.as_deref(), Some("user_rule")); assert!(plans[0].class_id.ends_with("Installer")); // 규칙이 picker를 이긴다 // 규칙이 우선하므로 plan_moves_with 내부에서 pick은 호출되지 않는다(설계상 의도). // 라인 커버리지 확보를 위해 클로저 자체가 유효한 picker임을 별도로 확인. diff --git a/src-tauri/src/safety.rs b/src-tauri/src/safety.rs index 1a1526de6..388d66d1b 100644 --- a/src-tauri/src/safety.rs +++ b/src-tauri/src/safety.rs @@ -180,6 +180,9 @@ pub fn filesystem_object_id(path: &Path) -> std::io::Result { #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct MovePaths { + /// Producer provenance, not semantic verification or deletion authority. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub classification_source: Option, pub source: PathBuf, pub destination: PathBuf, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -689,6 +692,7 @@ fn do_move( now_ms: u64, validate: &dyn Fn() -> Result<(), SafetyError>, bundle: Option<&crate::organize::organization_bundle::BundleManifest>, + classification_source: Option<&str>, ) -> Result<(), SafetyError> { let mut entry = JournalEntry { ts_ms: now_ms, @@ -696,7 +700,7 @@ fn do_move( path: format!("{} -> {}", src.display(), dst.display()), bytes: std::fs::metadata(src).map(|m| m.len()).unwrap_or(0), outcome: "pending".into(), - move_paths: Some(MovePaths { source: src.to_path_buf(), destination: dst.to_path_buf(), bundle: bundle.cloned() }), + move_paths: Some(MovePaths { classification_source: classification_source.map(str::to_owned), source: src.to_path_buf(), destination: dst.to_path_buf(), bundle: bundle.cloned() }), }; journal_append(journal_path, &entry)?; @@ -728,7 +732,7 @@ fn do_move( pub fn move_file( src: &Path, dst: &Path, journal_path: &Path, now_ms: u64, ) -> Result<(), SafetyError> { - move_file_checked(src, dst, journal_path, now_ms, &|| Ok(()), None) + move_file_checked(src, dst, journal_path, now_ms, &|| Ok(()), None, None) } /// Validate before preparation and again inside the native move accessor. @@ -736,6 +740,7 @@ pub(crate) fn move_file_checked( src: &Path, dst: &Path, journal_path: &Path, now_ms: u64, validate: &dyn Fn() -> Result<(), SafetyError>, bundle: Option<&crate::organize::organization_bundle::BundleManifest>, + classification_source: Option<&str>, ) -> Result<(), SafetyError> { validate_move_paths(src, dst)?; validate()?; @@ -747,7 +752,7 @@ pub(crate) fn move_file_checked( if bundle.is_some() && !same_volume(src, dst) { return Err(SafetyError::Validation("묶음은 같은 볼륨 안에서만 옮길 수 있습니다.".into())); } - do_move(src, dst, same_volume(src, dst), journal_path, now_ms, validate, bundle) + do_move(src, dst, same_volume(src, dst), journal_path, now_ms, validate, bundle, classification_source) } fn validate_move_paths(src: &Path, dst: &Path) -> Result<(), SafetyError> { @@ -918,6 +923,17 @@ mod tests { assert!(!is_protected(Path::new("C:\\Windows.old"))); // 정당한 정리 대상 } + #[test] + fn legacy_move_journal_keeps_classification_source_unknown() { + let raw = r#"{"ts_ms":1,"op":"move","path":"legacy","bytes":1,"outcome":"ok","move_paths":{"source":"/a","destination":"/b"}}"#; + let entry: JournalEntry = serde_json::from_str(raw).unwrap(); + assert!(entry.move_paths.as_ref().unwrap().classification_source.is_none()); + let encoded = serde_json::to_string(&entry).unwrap(); + assert!(!encoded.contains("classification_source")); + let restored: JournalEntry = serde_json::from_str(&encoded).unwrap(); + assert_eq!(restored.move_paths.unwrap().source, Path::new("/a")); + } + #[test] fn journal_roundtrip_newest_first() { let tmp = tempfile::tempdir().unwrap(); @@ -1279,7 +1295,7 @@ mod tests { let src = tmp.path().join("a.bin"); let dst = tmp.path().join("b.bin"); std::fs::write(&src, vec![7u8; 30]).unwrap(); - do_move(&src, &dst, true, &jp, 1, &|| Ok(()), None).unwrap(); + do_move(&src, &dst, true, &jp, 1, &|| Ok(()), None, None).unwrap(); assert!(!src.exists()); assert_eq!(std::fs::read(&dst).unwrap().len(), 30); } @@ -1293,7 +1309,7 @@ mod tests { let dst = tmp.path().join("b.bin"); std::fs::write(&src, b"original").unwrap(); std::fs::write(&dst, b"pre-existing").unwrap(); // TOCTOU 경합에서 먼저 생긴 것처럼 시뮬레이션 - let err = do_move(&src, &dst, true, &jp, 1, &|| Ok(()), None); + let err = do_move(&src, &dst, true, &jp, 1, &|| Ok(()), None, None); assert!(matches!(err, Err(SafetyError::Trash(_)))); assert!(src.exists(), "원본은 실패 시 보존"); assert_eq!( @@ -1312,7 +1328,7 @@ mod tests { let dst = tmp.path().join("moved-disksage-xvol-fixture.bin"); std::fs::write(&src, vec![9u8; 40]).unwrap(); // same_vol=false 강제 → 실제 같은 볼륨이어도 copy+verify+trash 경로 실행 - do_move(&src, &dst, false, &jp, 2, &|| Ok(()), None).unwrap(); + do_move(&src, &dst, false, &jp, 2, &|| Ok(()), None, None).unwrap(); assert!(!src.exists(), "원본은 휴지통으로"); assert_eq!(std::fs::read(&dst).unwrap().len(), 40); // 원본이 휴지통에 있음 확인 후 테스트 픽스처만 purge diff --git a/src/lib/Organize.svelte b/src/lib/Organize.svelte index 9eeadb12f..3598d2196 100644 --- a/src/lib/Organize.svelte +++ b/src/lib/Organize.svelte @@ -188,6 +188,13 @@ {#each group as p (p.src)}
  • {p.src} + {p.classification_source === "user_rule" + ? "사용자 규칙에 따른 제안 · 내용 검증 안 됨" + : p.classification_source === "model_picker" + ? "AI 분류 제안 · 내용 검증 안 됨" + : p.classification_source === "extension" + ? "파일 형식에 따른 제안 · 내용 검증 안 됨" + : "분류 근거 확인 필요"} {#if verdicts[p.src]} {@const b = verdictBadge(verdicts[p.src])} {b.label} diff --git a/src/lib/api.ts b/src/lib/api.ts index 9c30050af..e323e29e4 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -270,6 +270,7 @@ export interface MovePlan { src: string; dst: string; class_id: string; + classification_source?: string | null; source_size?: number | null; source_mtime_ms?: number | null; lineage?: { From 336485563d89f7a85cd90009220c03693910ecbb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 12:46:57 +0900 Subject: [PATCH 16/17] feat(organize): stream bounded document bundle verification Use a fixed 64KiB buffer and incremental digests for flat bundles up to 8MiB while preserving source identity, drift, dataless and boundary checks. Reproduce the prior 826183-byte refusal and verify digest parity, move/undo, exact-limit admission and one-byte excess retention. Four bundle tests and Svelte check passed; private-folder execution and deployment remain unverified. Signed-off-by: Seongho Bae --- docs/organization-research-implementation.md | 17 ++++++++++- src-tauri/src/commands.rs | 31 ++++++++++++++++++++ src-tauri/src/organization_bundle.rs | 30 ++++++++++++------- src/lib/Organize.svelte | 2 +- 4 files changed, 68 insertions(+), 12 deletions(-) diff --git a/docs/organization-research-implementation.md b/docs/organization-research-implementation.md index 2007e87cc..a5df2dacc 100644 --- a/docs/organization-research-implementation.md +++ b/docs/organization-research-implementation.md @@ -70,6 +70,21 @@ The bounded pilot binds complete membership, raw relative names, file contents, ## Existing-folder movement pilot -The UI now previews an explicitly selected existing folder and destination parent. It does not infer a topic or ontology class. The current supported scope is a flat bundle of at most 32 local regular files totaling 512 KiB. Nested, unavailable, linked, cloud-only, recognized project, and protected package/session scopes are retained. General recursive and semantic grouping remain unfinished. +The UI now previews an explicitly selected existing folder and destination parent. It does not infer a topic or ontology class. The current supported scope is a flat bundle of at most 32 local regular files totaling 8 MiB. Nested, unavailable, linked, cloud-only, recognized project, and protected package/session scopes are retained. General recursive and semantic grouping remain unfinished. The move plan and undo receipt now bind member names, file identities, sizes, exact modification timestamps, and content digests. The same plan validator runs inside native coordination. Movement across volumes is unavailable for these bundles. Tests have verified a decomposed-Hangul folder name, complete companion movement and undo, refusal after a new member appears, and same-size/same-mtime content drift. The first completed command run passed 31 tests and failed the unchanged live cache-cleanup assertion. After adding error diagnostics and the project-boundary regression, all 33 command tests passed. The intermittent cache assertion was not reproduced in that run; its underlying cause is not established by this result. All 47 shared safety tests also passed. The latest frontend check reported zero errors and warnings. No real user folder was moved by this pilot validation. + +### Bounded content verification beyond the initial prototype + +A reviewed four-document group totaled 826,183 bytes and exceeded the initial 512 KiB +budget. A synthetic public-core regression reproduced that refusal before the change. +The proposed implementation keeps a fixed 64 KiB read buffer and incremental BLAKE3 +state, with an 8 MiB total observation budget and the existing 32-file limit. This is +an operational bound for small document groups, not a classification threshold or a +claim of support for arbitrary folder trees. The bounded reader includes one extra +byte to detect growth; observed byte count, identity, length and modification checks +still reject changed sources. Dataless, project, package, link, collision and +cross-volume restrictions remain in force. The regression covers the observed group +size, digest equivalence, execution/undo, exact-budget acceptance and one-byte excess. +Performance and iCloud behavior for this wider budget remain unverified until measured; +local test success alone is not permission to move a private group or claim deployment. diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index ea11c0def..be9a8822e 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -3725,6 +3725,37 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko . assert_eq!(std::fs::read(destination.join("new note.txt")).unwrap(), b"keep this too"); } + #[test] + fn bundle_streamed_document_group_roundtrips_and_retains_over_budget() { + let tmp = tempfile::tempdir().unwrap(); + let source = tmp.path().join("document group"); + std::fs::create_dir(&source).unwrap(); + let sizes = [258446usize, 186177, 242389, 139171]; + for (index, size) in sizes.iter().enumerate() { + std::fs::write(source.join(format!("document_{index}.bin")), vec![index as u8; *size]).unwrap(); + } + let target = tmp.path().join("organized"); + let journal = tmp.path().join("journal.jsonl"); + let plan = organize::organization_bundle::plan(&source, &target).unwrap(); + for member in &plan.bundle.as_ref().unwrap().files { + assert_eq!(member.content_blake3, blake3::hash(&std::fs::read(source.join(&member.name)).unwrap()).to_hex().to_string()); + } + assert!(execute_moves_inner(&[plan], &journal, 1)[0].ok); + assert!(undo_last_moves_inner(1, &journal, 2)[0].ok); + for (index, size) in sizes.iter().enumerate() { + assert_eq!(std::fs::read(source.join(format!("document_{index}.bin"))).unwrap(), vec![index as u8; *size]); + } + let extra = source.join("over_budget.bin"); + let remaining = 8 * 1024 * 1024 - sizes.iter().sum::() as u64; + let extra_file = std::fs::File::create(&extra).unwrap(); + extra_file.set_len(remaining).unwrap(); + assert!(organize::organization_bundle::plan(&source, &target).is_ok()); + extra_file.set_len(remaining + 1).unwrap(); + assert!(organize::organization_bundle::plan(&source, &target).is_err()); + assert_eq!(std::fs::metadata(extra).unwrap().len(), remaining + 1); + assert!(source.exists()); + } + #[test] fn bundle_preview_retains_nested_folders_and_project_ancestors() { let tmp = tempfile::tempdir().unwrap(); diff --git a/src-tauri/src/organization_bundle.rs b/src-tauri/src/organization_bundle.rs index 7e3ef9c66..9e80058cd 100644 --- a/src-tauri/src/organization_bundle.rs +++ b/src-tauri/src/organization_bundle.rs @@ -20,9 +20,9 @@ pub struct BundleFile { pub content_blake3: String, } -// ponytail: flat, 32-file/512-KiB bundles; recursive membership and streaming need separate validation. +// ponytail: flat, 32-file/8-MiB bundles; larger or recursive groups need separate I/O-budget validation. const MAX_FILES: usize = 32; -const MAX_BYTES: u64 = 512 * 1024; +const MAX_BYTES: u64 = 8 * 1024 * 1024; fn unavailable(_: impl std::fmt::Display) -> String { "폴더 구성이나 파일 상태를 확인하지 못해 현재 위치에 보존합니다.".into() @@ -108,20 +108,30 @@ pub fn observe(source: &Path) -> Result { return Err(unavailable("file replaced")); } let modified = opened.modified().map_err(unavailable)?; - let mut bytes = Vec::new(); - (&file) - .take(MAX_BYTES.saturating_sub(total) + 1) - .read_to_end(&mut bytes) - .map_err(unavailable)?; + let mut reader = (&file).take(MAX_BYTES.saturating_sub(total) + 1); + let mut buffer = [0u8; 64 * 1024]; + let mut hasher = blake3::Hasher::new(); + let mut bytes_read = 0u64; + loop { + let count = match reader.read(&mut buffer) { + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue, + result => result.map_err(unavailable)?, + }; + if count == 0 { + break; + } + hasher.update(&buffer[..count]); + bytes_read += count as u64; + } let after = file.metadata().map_err(unavailable)?; - if bytes.len() as u64 != opened.len() + if bytes_read != opened.len() || after.len() != opened.len() || after.modified().map_err(unavailable)? != modified || crate::safety::filesystem_object_id(&path).map_err(unavailable)? != object_id { return Err("확인 중 파일이 바뀌어 묶음 이동을 보류합니다.".into()); } - total += bytes.len() as u64; + total += bytes_read; files.push(BundleFile { name, object_id, @@ -131,7 +141,7 @@ pub fn observe(source: &Path) -> Result { .map_err(unavailable)? .as_nanos() .to_string(), - content_blake3: blake3::hash(&bytes).to_hex().to_string(), + content_blake3: hasher.finalize().to_hex().to_string(), }); } if files.is_empty() diff --git a/src/lib/Organize.svelte b/src/lib/Organize.svelte index 3598d2196..4afbe722e 100644 --- a/src/lib/Organize.svelte +++ b/src/lib/Organize.svelte @@ -141,7 +141,7 @@
    선택한 폴더를 기존 묶음 그대로 이동 -

    주제를 자동 분류하지 않고 현재 폴더 이름과 구성원을 함께 보존합니다. 현재는 하위 폴더 없이 로컬 파일 32개, 합계 512KiB 이하인 문서 묶음을 지원합니다.

    +

    주제를 자동 분류하지 않고 현재 폴더 이름과 구성원을 함께 보존합니다. 현재는 하위 폴더 없이 로컬 파일 32개, 합계 8MiB 이하인 문서 묶음을 지원합니다.

    From 6eca22594fbae409bb81ad19582aa5906514236b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 13:02:50 +0900 Subject: [PATCH 17/17] feat(organize): expose read-only bundle preview command Signed-off-by: Seongho Bae --- docs/organization-research-implementation.md | 13 ++++ .../src/bin/disksage-organization-plan.rs | 60 +++++++++++++++++++ src-tauri/src/lib.rs | 2 + 3 files changed, 75 insertions(+) create mode 100644 src-tauri/src/bin/disksage-organization-plan.rs diff --git a/docs/organization-research-implementation.md b/docs/organization-research-implementation.md index a5df2dacc..27f42bf6d 100644 --- a/docs/organization-research-implementation.md +++ b/docs/organization-research-implementation.md @@ -88,3 +88,16 @@ cross-volume restrictions remain in force. The regression covers the observed gr size, digest equivalence, execution/undo, exact-budget acceptance and one-byte excess. Performance and iCloud behavior for this wider budget remain unverified until measured; local test success alone is not permission to move a private group or claim deployment. + +## Read-only bundle preview + +`cargo run --manifest-path src-tauri/Cargo.toml --bin disksage-organization-plan -- ABSOLUTE_SOURCE ABSOLUTE_TARGET_PARENT` +prints the same bounded plan used by the desktop preview. It does not create the +target or move files, and has no execution option. Keep its output private: it +contains original paths, filenames and content fingerprints. Cloud-only members +are refused by the shared planner; the command does not request downloads. + +This preview proves neither the proposed grouping's meaning nor cloud synchronization. +Those observations remain separate prerequisites for an actual move. The command's +regression checks source preservation, absent destination creation, collision refusal, +and the absence of an execution option. diff --git a/src-tauri/src/bin/disksage-organization-plan.rs b/src-tauri/src/bin/disksage-organization-plan.rs new file mode 100644 index 000000000..f3aa41421 --- /dev/null +++ b/src-tauri/src/bin/disksage-organization-plan.rs @@ -0,0 +1,60 @@ +//! Read-only bundle preview. No move, download, or execution option is exposed. + +use std::{ffi::OsString, io::Write, path::Path}; + +const USAGE: &str = "usage: disksage-organization-plan ABSOLUTE_SOURCE ABSOLUTE_TARGET_PARENT\nPreview only; content meaning and cloud synchronization are not verified."; + +fn write_preview(args: &[OsString], output: &mut impl Write) -> Result<(), String> { + if args.len() == 1 && matches!(args[0].to_str(), Some("--help" | "-h")) { + return writeln!(output, "{USAGE}").map_err(|error| error.to_string()); + } + if args.len() != 2 || args.iter().any(|value| value.to_str().is_none()) { + return Err(USAGE.into()); + } + let plan = disksage_lib::plan_organization_bundle(Path::new(&args[0]), Path::new(&args[1]))?; + serde_json::to_writer_pretty(&mut *output, &plan).map_err(|error| error.to_string())?; + writeln!(output).map_err(|error| error.to_string()) +} + +fn main() { + if let Err(error) = write_preview( + &std::env::args_os().skip(1).collect::>(), + &mut std::io::stdout().lock(), + ) { + eprintln!("{error}"); + std::process::exit(2); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn preview_preserves_source_and_refuses_destination_collision() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().canonicalize().unwrap(); + let source = root.join("document_group"); + let target = root.join("reviewed_groups"); + std::fs::create_dir(&source).unwrap(); + let file_path = source.join("original draft.txt"); + std::fs::write(&file_path, b"distinct original content").unwrap(); + let args = [source.clone().into_os_string(), target.clone().into_os_string()]; + let mut output = Vec::new(); + write_preview(&args, &mut output).unwrap(); + let preview: serde_json::Value = serde_json::from_slice(&output).unwrap(); + assert_eq!(preview["bundle"]["files"][0]["name"], "original draft.txt"); + assert_eq!(preview["classification_source"], serde_json::Value::Null); + assert!(!target.exists()); + assert_eq!(std::fs::read(&file_path).unwrap(), b"distinct original content"); + std::fs::create_dir_all(target.join("document_group")).unwrap(); + output.clear(); + assert!(write_preview(&args, &mut output).is_err()); + assert!(output.is_empty()); + assert_eq!(std::fs::read(&file_path).unwrap(), b"distinct original content"); + assert!(write_preview(&[], &mut output).is_err()); + assert!(write_preview(&["--execute".into()], &mut output).is_err()); + write_preview(&["--help".into()], &mut output).unwrap(); + assert!(String::from_utf8(output).unwrap().contains("Preview only")); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fc3a6e9ee..78fb00abd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -34,6 +34,8 @@ mod ontology; mod inventory; #[cfg_attr(coverage, allow(dead_code))] mod organize; +/// Read-only, bounded preview using the same bundle planner as the desktop application. +pub use organize::organization_bundle::plan as plan_organization_bundle; #[cfg_attr(coverage, allow(dead_code))] mod llm; #[cfg_attr(coverage, allow(dead_code))]