feat(dedup): move files to target directory after merge (bookshelf-c15a.3) #606
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "bd-bookshelf-c15a.3"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Test plan
Closes bead bookshelf-c15a.3 on merge.
365d7bec31cfcc1957b5CODE REVIEW: APPROVED
Reviewed diff for
bd-bookshelf-c15a.3(Find Duplicates v3: Move files / Merge All / Dismiss / Delete).Phase 0: DEMO Verification
No standalone DEMO block — changes are verified via the e2e Ordered journey. The move-files checkbox e2e test (
e2e/browser/journey_duplicates_test.go:70) covers the interactive toggle. Accepted as the verification mechanism per review scope.Phase 1: Spec Compliance
All four v3 features present:
mergeAllinfind_duplicates_controller.js:443iterates all.dup-groupelements sequentially via_mergeGroup, shows a confirm dialog, posts a summary toast.dismissmethod removes the group element; button wired indedup_results.html.onCheckboxChange/deleteCheckedpresent (pre-existing).MoveFilesAfterMergeservice, guarded by DISK_TYPE=NETWORK (ErrNetworkDisk → 409), checkbox disabled in template whenNetworkDisk=true.Rebase coherence:
dedup.gohas both main'sScanCriteria/PresetCriteriaand branch'sErrNetworkDisk/MoveFiles.handler.gohas both main'sKeepStrategyflow and branch'sdiskType/NetworkDisk/moveFiles. No dropped fields.Multi-user: scan scoped to
userLibraryIDs; merge callscheckOwnershipfor all book IDs beforemoveFilesfires; move SQL queries operate only on pre-verified IDs.CSP: no
style=,on*=, or inline<script>in template diff.Phase 2: Code Quality
Path-traversal (adversarial):
moveOneFile(move_files_service.go:491) guards source withHasPrefix(srcAbs+sep, srcRoot+sep). Null-byte check fires beforefilepath.Clean. A traversal viaFileSubPath="../../../etc/cron.d/evil"producessrcAbs="/etc/cron.d/evil", failing theHasPrefixcheck — logged and skipped beforerenameFileis called. Destination uses the sameFileSubPath, so destination traversal is implicitly blocked by the source guard. Destination-exists guard (os.Stat) prevents overwrites. NETWORK guard fires before any FS mutation. Path-traversal protection is sound.Double-call design (handler.go:308–332): Pre-check call
moveFiles(ctx, MergeRequest{MoveFiles:true})passes emptySourceBookIDs, short-circuiting after the NETWORK check — cheap no-op on LOCAL. Post-merge call at line 326 is unconditional butMoveFilesAfterMergeshort-circuits onreq.MoveFiles=false. No wasted DB queries when feature is off.[MINOR]
e2e/browser/journey_duplicates_test.go:70— fourExpectcalls in oneItblock (pre-click check, post-click check, plus twoerrchecks). Project convention is oneExpectperIt. Reasonable exception for an Ordered browser toggle test where splitting would duplicate the click interaction, but is a documented deviation.[MINOR]
internal/dedup/move_files_service_test.go:231-234— "happy path" test assertsrenamedSrc == renamedDst == "/library/books/dune.epub"(src equals dst). The stubrenameFileis called because/librarydoesn't exist on the test host (os.Statreturns error → dst-exists guard doesn't fire). This exercises the "file absent from disk" path rather than a genuine cross-directory move. Not a bug — the service logic is covered — but a comment clarifying the test intent would reduce confusion for future readers.REVIEW VERDICT: 0 blocker, 0 major, 2 minor
Security Review — bookshelf-c15a.3 (Find Duplicates v3: move files after merge)
Scope reviewed
Path traversal / arbitrary file write, DISK_TYPE=NETWORK guard, ownership scoping, partial-data-loss consistency, SQL injection, secrets/PII in logs.
Findings
[MINOR] internal/dedup/move_files_service.go:118 — destination path lacks explicit containment guard
The source path is guarded:
srcAbsis checked to start withsrcRootbefore the move proceeds. The destinationdstAbs = filepath.Clean(filepath.Join(targetRoot, f.FileSubPath))is not checked to start withtargetRoot. This is currently not exploitable — the source containment check is sufficient: anyFileSubPaththat does not escapesrcRootalso cannot escape anytargetRoot. But the asymmetry is a missing defence-in-depth layer. A future refactor that relaxes the source check could silently open a traversal on the destination side. Addingstrings.HasPrefix(dstAbs+string(filepath.Separator), filepath.Clean(targetRoot)+string(filepath.Separator))after line 118 is cheap and makes the invariant explicit.[MINOR] internal/dedup/move_files_service_test.go:231 — happy-path test uses identical src and dst
The default
BeforeEachsetsLibraryRoot = "/library"andgetTargetRootreturns"/library", sorenamedSrc == renamedDst. This does not exercise the cross-directory move (src library != target library). A variant with different roots would catch path-construction bugs the current test misses.Items cleared (no finding)
srcAbsguarded bystrings.HasPrefix(srcAbs+sep, srcRoot+sep). AbsoluteFileSubPathvalues causefilepath.Jointo discard the root; guard fires. Null-byte check present.FileSubPathkeepssrcAbswithinsrcRoot, the same relative components cannot escape anytargetRoot. Absolute paths caught by source guard first.SourceBookIDs) triggers it beforemerge()touches the DB. Correct ordering confirmed.merge()checks ownership of all book IDs against session-deriveduserLibraryIDs.moveFilescalled post-merge with same in-processreq— no ID substitution possible.GetTargetBookLibraryRootqueries only an already-owned book.ListSourceBookFilesbuildsIN (...)with?placeholders and typedint64args. No user-controlled string interpolation.os.Rename). Noos.Remove/os.RemoveAllin diff.DiskTypevalidated at startup to beLOCALorNETWORK; servicestrings.ToUpperis redundant but harmless.REVIEW VERDICT: 0 blocker, 0 major, 2 minor
53cfe0435a9d8dc1a085