Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
220494a
feat: add evidence-bound photo duplicate audit
seonghobae Aug 29, 2026
2dab86f
feat: group exact decoded PNG pixels
seonghobae Aug 29, 2026
0811ca6
fix: bind photo evidence to one stable snapshot
seonghobae Aug 29, 2026
fbbe2b1
test(photo): reject hard-link duplicate inflation
seonghobae Aug 29, 2026
e637738
fix(photo): deduplicate filesystem identities
seonghobae Aug 29, 2026
e6fbcab
test(photo): bound decoded PNG allocation
seonghobae Aug 29, 2026
2d84088
fix(photo): bound decoded PNG memory
seonghobae Aug 29, 2026
e21f6fc
test(photo): reject unsupported exact-audit inputs
seonghobae Aug 29, 2026
09d97f8
fix(photo): reject unsupported exact evidence
seonghobae Aug 29, 2026
2281eb6
fix: keep photo audit portable and read-only
seonghobae Aug 29, 2026
bcaa609
test: fail closed without photo handle identity
seonghobae Aug 30, 2026
ceecac6
fix: bind photo hashing to opened file identity
seonghobae Aug 30, 2026
95eb36d
test: exercise photo identity on Windows
seonghobae Aug 30, 2026
04ae214
docs: align photo audit active-use contract
seonghobae Aug 30, 2026
d94057a
fix(ci): preserve main path filters and parse photo regression safely
seonghobae Sep 4, 2026
f03e6cf
test(photo): make decode-bound fixtures exceed the budget
seonghobae Sep 4, 2026
cf20a1d
test(photo): align APNG rejection contract
seonghobae Sep 4, 2026
993d4fe
merge(main): adopt protected CI path filters for photo audit
seonghobae Sep 4, 2026
d761f75
fix(photo): fail closed when audit evidence is incomplete
seonghobae Sep 4, 2026
1e6918a
test(photo): require nonzero exit for incomplete audit evidence
seonghobae Sep 4, 2026
69c2a2c
fix(photo): bind bounded evidence to managed-path and raster contracts
seonghobae Sep 4, 2026
55aaf73
docs(adr): keep photo audit decision proposed until integration
seonghobae Sep 4, 2026
c4c6369
docs(adr): mark photo audit decision proposed
seonghobae Sep 4, 2026
9190c87
fix(changelog): preserve protected-main release history
seonghobae Sep 4, 2026
461331f
fix(photo): align PNG metadata evidence with 0.18 API
seonghobae Sep 4, 2026
59b4fe7
docs(photo): delegate product baseline to canonical owner
seonghobae Sep 4, 2026
0d5ae6a
chore(stack): adopt canonical release prerequisite
seonghobae Sep 4, 2026
85575d4
chore(stack): adopt exact release test repair
seonghobae Sep 4, 2026
2448e06
chore: restack photo-audit owner on current release foundation
seonghobae Sep 4, 2026
94a07ca
chore(stack): restack photo-audit owner on current release foundation
seonghobae Sep 5, 2026
1676e1c
chore(stack): adopt current release Test foundation
seonghobae Sep 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ jobs:

windows-home-resolution:
runs-on: windows-latest
timeout-minutes: 10
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
Expand All @@ -87,6 +87,9 @@ jobs:
New-Item -ItemType Directory -Force target | Out-Null
rustc --edition=2021 --test src-tauri/tests/home_resolution_contract.rs -o target/home-resolution-contract.exe
& .\target\home-resolution-contract.exe
- name: Windows photo open-handle identity regression
run: |
cargo test --manifest-path src-tauri/Cargo.toml photo_duplicate::tests:: --lib

llm-engine-build:
runs-on: ubuntu-latest
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and

### Changed

- Add a bounded, read-only exact-photo duplicate audit that records byte identity with BLAKE3 and
groups local PNGs only when raster dimensions and normalized decoded RGBA16 pixels share the
version-two domain-separated digest. Provider-managed paths, Photos libraries, dataless
placeholders, symlinks, oversized encoded inputs, and replacement races fail closed; incomplete
audits return a non-zero CLI exit after emitting structured rejection evidence. Cleanup and
permanent deletion remain unavailable, and active-use evidence is reserved for a fresh future
execution preflight.
- Keep coverage builds compile-safe by applying the same `not(coverage)` boundary to native-copy
identity cleanup and dependent eviction helpers; the focused authority contract remains green.
- Add durable private failure records in a separate journal directory and a receipt-bound
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# ADR 0012: Separate photo-duplicate and keeper evidence without composite scoring

- Status: Proposed
- Date: 2026-08-29

## Context

Pixel dimensions, bit depth, codec losslessness, edit lineage, metadata completeness, perceptual
similarity, and perceptual quality answer different questions. Combining them with undocumented
weights would turn descriptive evidence into deletion authority. Pixel count is also not a
validated substitute for spatial-frequency response or perceptual quality. A perceptual hash
distance requires a descriptor-specific, population-calibrated decision threshold; BRISQUE
requires the trained model used by the published method.

Photos libraries and File Provider trees have additional ownership and materialization semantics.
Reading package internals or a dataless placeholder can trigger provider activity and cannot
authorize cleanup. Encoded files and aggregate audit batches also need explicit work budgets before
any content allocation so hostile inputs cannot convert an audit into an availability failure.

## Decision

DiskSage records byte identity with BLAKE3 and groups currently materialized PNG files only when
raster dimensions and normalized decoded RGBA16 pixels have the same domain-separated digest.
The decoder expands palette/low-depth samples and `tRNS` transparency before normalization, while
source bit depth remains separate keeper evidence. This permits semantically identical lossless
encodings with different compression or ancillary metadata to share a group without a
perceptual-distance threshold, while preventing different raster shapes or transparency semantics
from collapsing into one exact identity.

Before content allocation, the audit rejects symlinks, provider-managed paths, Photos library
packages, dataless objects, unsupported codecs, and encoded inputs above the fixed per-file budget.
The already-open handle is read through the same byte ceiling and filesystem-object identity,
length, and modification evidence are rechecked around hashing. Aggregate input count and declared
bytes are also bounded. Active-use evidence is collected fail-closed only by a fresh execution
preflight; read-only audit does not turn platform-specific process inspection into a discovery
prerequisite.

The public wire contract is `disksage.photo-duplicate-audit.v2`. Version 2 makes the evidence-state
serialization and keeper metadata semantics explicit and changes exact grouping to
`decoded-pixel-rgba16-raster-exact-v2`; consumers must not interpret it as the earlier draft v1
shape.

The audit reports dimensions, source bit depth, losslessness, metadata-field count, lineage
availability, no-reference IQA availability, and perceptual-descriptor availability as separate
evidence. It does not calculate a composite score. Perceptual grouping remains unavailable until a
versioned descriptor and dataset-calibrated threshold artifact include provenance and a
cryptographic checksum. BRISQUE remains unavailable until its exact trained model artifact has
equivalent provenance and checksum.

Keeper evidence uses Pareto dominance only: losslessness, source bit depth, metadata completeness,
and original/edit lineage must all be no worse and at least one must be better for exactly one
member. File size, modification time, and filename have no quality authority. Ties and incomparable
members require customer selection; byte-identical members therefore never receive an arbitrary
automatic keeper. The audit may display a unique Pareto keeper but does not mutate files. Cleanup
execution and permanent deletion remain unavailable. A later execution decision must bind an exact
group identity, exact unique keeper identity, fresh approval, current inactive/materialized
evidence, reversible Trash or quarantine, and a durable journal with undo.

This ADR remains Proposed until the exact PR head passes the applicable Test/Release/Security/SAST
checks and all valid review findings are resolved. Merge is the acceptance boundary.

## Consequences

Customers can establish bounded exact-duplicate evidence without intentionally hydrating Photos or
cloud-provider data. They are told why near-duplicate grouping and cleanup are unavailable and what
evidence must be installed next. This initial capability deliberately recovers no bytes by itself.
Automation receives a non-zero CLI exit when the supplied audit is incomplete while retaining the
structured JSON rejection evidence.

## Rejected alternatives

- A hand-tuned weighted “quality score”: rejected because its weights and construct validity are
unsupported.
- A fixed perceptual-hash distance copied from examples: rejected because it is not calibrated to
the operating image population.
- Selecting the largest image automatically: rejected because dimensions alone do not establish
fidelity, originality, or perceptual quality.
- Substring matching for cloud-provider brands: rejected because it both misses canonical managed
roots and rejects ordinary local names; DiskSage reuses the filesystem/platform classifiers
owned by the cloud bounded context.
- Unbounded `read_to_end`: rejected because decode limits do not protect the preceding encoded-file
allocation.
- Direct Photos library mutation or permanent deletion: rejected because it bypasses provider and
reversible-recovery contracts.

## References

Camera & Imaging Products Association. (2026). *Exchangeable image file format for digital still
camera: Exif Version 3.1 (CIPA DC-008-Translation-2026)*.
https://www.cipa.jp/e/std/std-sec.html

International Organization for Standardization. (2024). *Photography—Electronic still picture
imaging—Resolution and spatial frequency responses (ISO 12233:2024)*.
https://www.iso.org/standard/88626.html

Mittal, A., Moorthy, A. K., & Bovik, A. C. (2012). No-reference image quality assessment in the
spatial domain. *IEEE Transactions on Image Processing, 21*(12), 4695–4708.
https://doi.org/10.1109/TIP.2012.2214050

Zauner, C. (2010). *Implementation and benchmarking of perceptual image hash functions* [Master's
thesis, University of Applied Sciences Upper Austria]. https://www.phash.org/docs/
1 change: 1 addition & 0 deletions docs/architecture/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ new numbered record rather than rewriting history.
| [0009](0009-path-free-lineage-relation-graph.md) | Export a path-free lineage relation graph | Accepted |
| [0010](0010-rooted-organize-destinations.md) | Require rooted, process-independent organize destinations | Accepted |
| [0011](0011-cloud-transfer-failure-and-materialization.md) | Durable failed-copy evidence and placeholder-safe adoption | Accepted |
| [0012](0012-photo-duplicate-evidence-without-composite-scoring.md) | Separate photo-duplicate and keeper evidence without composite scoring | Proposed |

New records must state context, decision, consequences, rejected alternatives, and the evidence or
standard that led to the decision. A record never grants cloud-write or source-eviction authority;
Expand Down
4 changes: 4 additions & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,10 @@ path = "src/bin/disksage-cache-cleanup.rs"
name = "disksage-dev-artifacts"
path = "src/bin/disksage-dev-artifacts.rs"

[[bin]]
name = "disksage-photo-duplicate-audit"
path = "src/bin/disksage-photo-duplicate-audit.rs"

[[bin]]
name = "disksage-provider-client-runtime"
path = "src/bin/disksage-provider-client-runtime.rs"
Expand Down
18 changes: 18 additions & 0 deletions src-tauri/src/bin/disksage-photo-duplicate-audit.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
use std::path::PathBuf;

fn main() {
let paths: Vec<PathBuf> = std::env::args_os().skip(1).map(PathBuf::from).collect();
if paths.is_empty() {
eprintln!("다음 단계: 내보낸 로컬 PNG 파일 경로를 하나 이상 지정하세요. Photos 보관함과 클라우드 전용 파일은 열지 않습니다.");
std::process::exit(2);
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|value| value.as_millis() as u64)
.unwrap_or_default();
let audit = disksage_lib::photo_duplicate::audit_photos(&paths, now);
println!("{}", serde_json::to_string_pretty(&audit).unwrap());
Comment thread
seonghobae marked this conversation as resolved.
if !audit.evidence_complete || audit.inspected_input_count == 0 {
std::process::exit(3);
}
}
4 changes: 2 additions & 2 deletions src-tauri/src/cloud.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4154,7 +4154,7 @@ fn source_blocked_reason(

/// File Provider's private storage and download staging trees are owned by macOS. Their files
/// are implementation state, not user payloads; only a provider-aware operation may reclaim them.
fn path_inside_managed_file_provider_storage(path: &Path) -> bool {
pub(crate) fn path_inside_managed_file_provider_storage(path: &Path) -> bool {
let mut previous = String::new();
path.components().any(|component| {
let name = normalized_account_text(&component.as_os_str().to_string_lossy());
Expand All @@ -4170,7 +4170,7 @@ fn path_inside_managed_file_provider_storage(path: &Path) -> bool {
/// Photos databases are individually archive-shaped but are owned by the Photos package.
/// Moving one member would corrupt the library; only a future package-aware operation may handle
/// the bundle as a whole.
fn path_inside_managed_photo_library(path: &Path) -> bool {
pub(crate) fn path_inside_managed_photo_library(path: &Path) -> bool {
path.components().any(|component| {
let name = normalized_account_text(&component.as_os_str().to_string_lossy());
name.ends_with(".photoslibrary") || name.ends_with(".photolibrary")
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ pub mod cloud_review;
pub mod cloud_transfer;
pub mod content_digest;
pub mod duplicate_audit;
pub mod photo_duplicate;
pub mod icloud_sync_health;
pub mod judge_calibration;
pub mod incomplete_download;
Expand Down
Loading
Loading