Skip to main content

vdsl_sync/domain/
fingerprint.rs

1//! FileFingerprint — ファイル同一性判定の値オブジェクト。
2//!
3//! # 画像ファイル = Entity モデル
4//!
5//! Content(ピクセルデータ)がEntityのIdentity。メタデータやファイルサイズは
6//! 副次的属性に過ぎない。メタ変更でsizeが変わっても同一Entityである。
7//!
8//! # ストレージ種別と取得可能情報
9//!
10//! - Local/SSH: byte_digest(DJB2) + content_digest + meta_digest + size
11//! - Pod/Remote: byte_digest(SHA-256) + size
12//! - Cloud (B2/S3): size + modified_at のみ(hash取得不可)
13//!
14//! # 型安全なDigest
15//!
16//! [`ByteDigest`](super::digest::ByteDigest) は `PartialEq` 未実装。
17//! 異アルゴリズム(DJB2 vs SHA-256)の `==` はコンパイルエラー。
18//! 同一location内での比較は [`matches_within_location()`](Self::matches_within_location) のみ。
19//! cross-location比較は [`CrossLocationIdentity`](super::digest::CrossLocationIdentity) を使用。
20//!
21//! # precision() と matches_within_location() の関係
22//!
23//! この2つのメソッドは**異なる軸**を扱う:
24//!
25//! - [`FileFingerprint::precision()`] — 単体が**保持する**最高精度(情報量順)。
26//!   content_digest > meta_digest > byte_digest > modified_at > size の順。
27//!
28//! - [`FileFingerprint::matches_within_location()`] — 同一location内の2者比較。
29//!   byte_digest > content_digest > meta_digest > size+mtime > size の順。
30//!   byte_digestはバイト完全一致を保証するため、比較の確実性が最も高い。
31//!   **content_digest/meta_digestはsize gateより前で判定** — メタ変更で
32//!   sizeが変わっても同一Entityとして検出する。
33
34use chrono::{DateTime, Utc};
35use serde::{Deserialize, Serialize};
36
37use super::digest::{ByteDigest, ContentDigest, MetaDigest};
38
39/// ファイル同一性判定に使用するフィンガープリント。
40///
41/// [`matches_within_location()`](Self::matches_within_location) が同一location内で
42/// 利用可能な最高精度の情報で比較を行う。
43/// cross-location比較には [`CrossLocationIdentity`](super::digest::CrossLocationIdentity) を使用。
44///
45/// # 精度の優先順位(情報量順)
46///
47/// 1. `content_digest` (Semantic) — フォーマット固有の意味的ハッシュ(ピクセルデータ等)
48/// 2. `meta_digest` (MetaLevel) — 埋め込みメタデータのハッシュ(PNG tEXt, EXIF等)
49/// 3. `byte_digest` (ByteLevel) — ファイル全体のバイト列ハッシュ(一致=バイト同一)
50/// 4. `size` + `modified_at` (Metadata) — メタデータ比較
51/// 5. `size` のみ (SizeOnly) — 最低精度
52///
53/// # matches_within_location()の比較信頼性順
54///
55/// 1. `byte_digest` — バイト完全一致(最も確実、同一アルゴリズムのみ)
56/// 2. `content_digest` — ピクセル同一性
57/// 3. `meta_digest` — メタデータ同一性
58/// 4. `size` + `modified_at`
59/// 5. `size` のみ
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct FileFingerprint {
62    /// ファイル全体のハッシュ。location固有アルゴリズム(DJB2/SHA-256)。
63    /// Cloud Storageではダウンロードなしに取得不可のためNone。
64    ///
65    /// **PartialEq未実装** — cross-location比較はコンパイルエラー。
66    #[serde(default, skip_serializing_if = "Option::is_none", rename = "file_hash")]
67    pub byte_digest: Option<ByteDigest>,
68    /// フォーマット固有セマンティックハッシュ (PNG IHDR+IDAT 等のピクセルデータ)。
69    /// location非依存。PartialEq実装済み。
70    #[serde(
71        default,
72        skip_serializing_if = "Option::is_none",
73        rename = "content_hash"
74    )]
75    pub content_digest: Option<ContentDigest>,
76    /// 埋め込みメタデータのハッシュ (PNG tEXt, EXIF等)。
77    /// content_digestと合わせて「何が変わったか」を区別する:
78    /// - content_digest一致 + meta_digest不一致 → メタデータだけ変更
79    /// - content_digest不一致 → コンテンツ自体が変更
80    #[serde(default, skip_serializing_if = "Option::is_none", rename = "meta_hash")]
81    pub meta_digest: Option<MetaDigest>,
82    /// ファイルサイズ (bytes)。
83    pub size: u64,
84    /// 最終更新日時 (ストレージ報告値)。
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub modified_at: Option<DateTime<Utc>>,
87}
88
89/// フィンガープリントが保持する情報の精度レベル。
90///
91/// 上位ほど豊かな情報を持つ。`Ord` はこの序列に基づく。
92///
93/// **注意**: `matches_within_location()` の比較優先順序とは異なる。
94/// `matches_within_location()` は信頼性順(byte_digest > content_digest > meta_digest)で比較するが、
95/// この enum は情報量順(Semantic > MetaLevel > ByteLevel)で序列化する。
96/// 詳細はモジュールドキュメントを参照。
97#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
98pub enum FingerprintPrecision {
99    /// size のみ。同一サイズ・異内容のfalse positive リスクあり。
100    SizeOnly = 0,
101    /// size + mtime。ストレージのタイムスタンプ精度に依存。
102    Metadata = 1,
103    /// byte_digest (DJB2/sha256)。バイト列完全一致。
104    ByteLevel = 2,
105    /// meta_digest (PNG tEXt, EXIF等)。埋め込みメタデータの同一性。
106    MetaLevel = 3,
107    /// content_digest (PNG IHDR+IDAT 等)。ピクセルデータの意味的同一性。
108    Semantic = 4,
109}
110
111impl FileFingerprint {
112    /// 同一location内でのファイル同一性判定。
113    ///
114    /// **cross-location比較には使用不可** — `ByteDigest` はlocation固有アルゴリズムのため、
115    /// 異location間で比較するとアルゴリズム不一致エラーになる。
116    /// cross-location比較には [`CrossLocationIdentity`](super::digest::CrossLocationIdentity) を使用。
117    ///
118    /// # 画像ファイル = Entity モデル
119    ///
120    /// Content(ピクセルデータ)がEntityのIdentity。メタデータやファイルサイズは
121    /// 副次的属性であり、メタ変更でsizeが変わっても同一Entityである。
122    /// そのため content_digest / meta_digest 比較は **size gateより前** に実行し、
123    /// hash一致時にsize不一致でも同一と判定する。
124    ///
125    /// # 信頼性フォールバック順
126    ///
127    /// 1. 双方に `byte_digest` → 同一アルゴリズム比較(最も確実)
128    /// 2. 双方に `content_digest` → ピクセル同一性(size無関係で判定)
129    /// 3. 双方に `meta_digest` → メタデータ同一性(size無関係で判定)
130    /// 4. `size` gate → 上記digestが全て比較不可能な場合のみフォールバック
131    /// 5. 双方に `modified_at` → mtime比較(size一致済み)
132    /// 6. size一致のみ → 最低精度(false positiveリスクあり)
133    pub fn matches_within_location(&self, other: &FileFingerprint) -> bool {
134        // 1. ByteLevel: byte_digest(同一アルゴリズム同士のみ比較)
135        if let (Some(a), Some(b)) = (&self.byte_digest, &other.byte_digest) {
136            // matches_same_algo は異アルゴリズムでErr。
137            // 同一location内なので通常はOk。万一Errならフォールバック。
138            match a.matches_same_algo(b) {
139                Ok(result) => return result,
140                Err(_) => { /* 異アルゴリズム — フォールバック */ }
141            }
142        }
143        // 2. Semantic: content_digest(ピクセルデータ等)
144        //    sizeが異なってもcontent_digest一致なら同一Entity
145        //    (メタデータ変更でファイルサイズが変わるケースに対応)
146        if let (Some(a), Some(b)) = (&self.content_digest, &other.content_digest) {
147            return a == b;
148        }
149        // 3. MetaLevel: meta_digest(埋め込みメタデータ)
150        //    content_digestが双方にない場合のフォールバック
151        if let (Some(a), Some(b)) = (&self.meta_digest, &other.meta_digest) {
152            return a == b;
153        }
154        // 4. Size gate — digestが全て比較不可能な場合のフォールバック
155        //    ※ content_digest/meta_digestがある場合はここに到達しない
156        if self.size != other.size {
157            return false;
158        }
159        // 5. Metadata: mtime (size は既に一致)
160        if let (Some(a), Some(b)) = (&self.modified_at, &other.modified_at) {
161            return a == b;
162        }
163        // 6. SizeOnly — size一致のみ (false positive リスクあり)
164        true
165    }
166
167    /// このフィンガープリントが保持する最高精度レベル。
168    ///
169    /// content_digest(Semantic)を最上位とする情報量の序列。
170    /// `matches_within_location()` の比較信頼性順(byte_digest最優先)とは独立した軸である。
171    pub fn precision(&self) -> FingerprintPrecision {
172        if self.content_digest.is_some() {
173            FingerprintPrecision::Semantic
174        } else if self.meta_digest.is_some() {
175            FingerprintPrecision::MetaLevel
176        } else if self.byte_digest.is_some() {
177            FingerprintPrecision::ByteLevel
178        } else if self.modified_at.is_some() {
179            FingerprintPrecision::Metadata
180        } else {
181            FingerprintPrecision::SizeOnly
182        }
183    }
184
185    /// 2つのフィンガープリントの比較で使われる実効精度。
186    ///
187    /// 双方の精度の**低い方**が実効精度となる。
188    pub fn effective_precision(&self, other: &FileFingerprint) -> FingerprintPrecision {
189        std::cmp::min(self.precision(), other.precision())
190    }
191}
192
193impl std::fmt::Display for FingerprintPrecision {
194    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
195        match self {
196            Self::SizeOnly => f.write_str("size-only"),
197            Self::Metadata => f.write_str("metadata"),
198            Self::ByteLevel => f.write_str("byte-level"),
199            Self::MetaLevel => f.write_str("meta-level"),
200            Self::Semantic => f.write_str("semantic"),
201        }
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    fn hash_fp(
210        byte_digest: ByteDigest,
211        content_digest: Option<&str>,
212        size: u64,
213    ) -> FileFingerprint {
214        FileFingerprint {
215            byte_digest: Some(byte_digest),
216            content_digest: content_digest.map(|s| ContentDigest(s.to_string())),
217            meta_digest: None,
218            size,
219            modified_at: None,
220        }
221    }
222
223    fn hash_fp_with_meta(
224        byte_digest: ByteDigest,
225        content_digest: Option<&str>,
226        meta_digest: Option<&str>,
227        size: u64,
228    ) -> FileFingerprint {
229        FileFingerprint {
230            byte_digest: Some(byte_digest),
231            content_digest: content_digest.map(|s| ContentDigest(s.to_string())),
232            meta_digest: meta_digest.map(|s| MetaDigest(s.to_string())),
233            size,
234            modified_at: None,
235        }
236    }
237
238    fn metadata_fp(size: u64, mtime: Option<DateTime<Utc>>) -> FileFingerprint {
239        FileFingerprint {
240            byte_digest: None,
241            content_digest: None,
242            meta_digest: None,
243            size,
244            modified_at: mtime,
245        }
246    }
247
248    // =========================================================================
249    // matches_within_location() — byte_digest 優先(バイト同一性が最も信頼性が高い)
250    // =========================================================================
251
252    #[test]
253    fn matches_byte_level_trumps_semantic() {
254        let a = hash_fp(ByteDigest::Djb2("h1".into()), Some("c1"), 100);
255        let b = hash_fp(ByteDigest::Djb2("h1".into()), Some("c2"), 200);
256        assert!(a.matches_within_location(&b));
257    }
258
259    #[test]
260    fn matches_byte_level_different_trumps_semantic() {
261        let a = hash_fp(ByteDigest::Djb2("h1".into()), Some("c1"), 100);
262        let b = hash_fp(ByteDigest::Djb2("h2".into()), Some("c1"), 100);
263        assert!(!a.matches_within_location(&b));
264    }
265
266    // =========================================================================
267    // matches_within_location() — content_digest フォールバック(byte_digestなし時)
268    // =========================================================================
269
270    #[test]
271    fn matches_semantic_fallback_same() {
272        let a = FileFingerprint {
273            byte_digest: None,
274            content_digest: Some(ContentDigest("c1".into())),
275            meta_digest: None,
276            size: 100,
277            modified_at: None,
278        };
279        let b = FileFingerprint {
280            byte_digest: None,
281            content_digest: Some(ContentDigest("c1".into())),
282            meta_digest: None,
283            size: 200,
284            modified_at: None,
285        };
286        assert!(a.matches_within_location(&b));
287    }
288
289    #[test]
290    fn matches_semantic_fallback_different() {
291        let a = FileFingerprint {
292            byte_digest: None,
293            content_digest: Some(ContentDigest("c1".into())),
294            meta_digest: None,
295            size: 100,
296            modified_at: None,
297        };
298        let b = FileFingerprint {
299            byte_digest: None,
300            content_digest: Some(ContentDigest("c2".into())),
301            meta_digest: None,
302            size: 100,
303            modified_at: None,
304        };
305        assert!(!a.matches_within_location(&b));
306    }
307
308    // =========================================================================
309    // matches_within_location() — metadata (size + mtime)
310    // =========================================================================
311
312    #[test]
313    fn matches_metadata_same() {
314        let t = Utc::now();
315        let a = metadata_fp(1024, Some(t));
316        let b = metadata_fp(1024, Some(t));
317        assert!(a.matches_within_location(&b));
318    }
319
320    #[test]
321    fn matches_metadata_size_differs() {
322        let t = Utc::now();
323        let a = metadata_fp(1024, Some(t));
324        let b = metadata_fp(2048, Some(t));
325        assert!(!a.matches_within_location(&b));
326    }
327
328    #[test]
329    fn matches_metadata_mtime_differs() {
330        let t1 = DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
331            .unwrap()
332            .with_timezone(&Utc);
333        let t2 = DateTime::parse_from_rfc3339("2024-06-01T00:00:00Z")
334            .unwrap()
335            .with_timezone(&Utc);
336        let a = metadata_fp(1024, Some(t1));
337        let b = metadata_fp(1024, Some(t2));
338        assert!(!a.matches_within_location(&b));
339    }
340
341    // =========================================================================
342    // matches_within_location() — size only (最低精度)
343    // =========================================================================
344
345    #[test]
346    fn matches_size_only_same() {
347        let a = metadata_fp(1024, None);
348        let b = metadata_fp(1024, None);
349        assert!(a.matches_within_location(&b));
350    }
351
352    #[test]
353    fn matches_size_only_different() {
354        let a = metadata_fp(1024, None);
355        let b = metadata_fp(2048, None);
356        assert!(!a.matches_within_location(&b));
357    }
358
359    // =========================================================================
360    // matches_within_location() — 異精度の比較
361    // =========================================================================
362
363    #[test]
364    fn matches_hash_vs_metadata_size_match() {
365        let a = hash_fp(ByteDigest::Djb2("h1".into()), None, 1024);
366        let b = metadata_fp(1024, None);
367        assert!(a.matches_within_location(&b));
368    }
369
370    #[test]
371    fn matches_hash_vs_metadata_size_differs() {
372        let a = hash_fp(ByteDigest::Djb2("h1".into()), None, 1024);
373        let b = metadata_fp(2048, None);
374        assert!(!a.matches_within_location(&b));
375    }
376
377    // =========================================================================
378    // matches_within_location() — 異アルゴリズムはフォールバック
379    // =========================================================================
380
381    #[test]
382    fn cross_algorithm_falls_back_to_size() {
383        // 同一location内では通常起きないが、万一の安全策
384        let a = hash_fp(ByteDigest::Djb2("h1".into()), None, 1024);
385        let b = hash_fp(ByteDigest::Sha256("h2".into()), None, 1024);
386        // byte_digest比較はErr → size比較にフォールバック → size一致でtrue
387        assert!(a.matches_within_location(&b));
388    }
389
390    // =========================================================================
391    // precision()
392    // =========================================================================
393
394    #[test]
395    fn precision_semantic() {
396        let fp = hash_fp(ByteDigest::Djb2("h".into()), Some("c"), 100);
397        assert_eq!(fp.precision(), FingerprintPrecision::Semantic);
398    }
399
400    #[test]
401    fn precision_byte_level() {
402        let fp = hash_fp(ByteDigest::Djb2("h".into()), None, 100);
403        assert_eq!(fp.precision(), FingerprintPrecision::ByteLevel);
404    }
405
406    #[test]
407    fn precision_metadata() {
408        let fp = metadata_fp(100, Some(Utc::now()));
409        assert_eq!(fp.precision(), FingerprintPrecision::Metadata);
410    }
411
412    #[test]
413    fn precision_size_only() {
414        let fp = metadata_fp(100, None);
415        assert_eq!(fp.precision(), FingerprintPrecision::SizeOnly);
416    }
417
418    // =========================================================================
419    // effective_precision()
420    // =========================================================================
421
422    #[test]
423    fn effective_precision_downgrades() {
424        let hash = hash_fp(ByteDigest::Djb2("h".into()), Some("c"), 100);
425        let meta = metadata_fp(100, Some(Utc::now()));
426        assert_eq!(
427            hash.effective_precision(&meta),
428            FingerprintPrecision::Metadata
429        );
430    }
431
432    #[test]
433    fn effective_precision_same_level() {
434        let a = hash_fp(ByteDigest::Djb2("h1".into()), None, 100);
435        let b = hash_fp(ByteDigest::Djb2("h2".into()), None, 200);
436        assert_eq!(a.effective_precision(&b), FingerprintPrecision::ByteLevel);
437    }
438
439    // =========================================================================
440    // Display
441    // =========================================================================
442
443    #[test]
444    fn precision_display() {
445        assert_eq!(FingerprintPrecision::Semantic.to_string(), "semantic");
446        assert_eq!(FingerprintPrecision::MetaLevel.to_string(), "meta-level");
447        assert_eq!(FingerprintPrecision::ByteLevel.to_string(), "byte-level");
448        assert_eq!(FingerprintPrecision::Metadata.to_string(), "metadata");
449        assert_eq!(FingerprintPrecision::SizeOnly.to_string(), "size-only");
450    }
451
452    // =========================================================================
453    // meta_digest — メタデータ同一性
454    // =========================================================================
455
456    #[test]
457    fn matches_meta_digest_fallback_same() {
458        let a = FileFingerprint {
459            byte_digest: None,
460            content_digest: None,
461            meta_digest: Some(MetaDigest("m1".into())),
462            size: 100,
463            modified_at: None,
464        };
465        let b = FileFingerprint {
466            byte_digest: None,
467            content_digest: None,
468            meta_digest: Some(MetaDigest("m1".into())),
469            size: 200,
470            modified_at: None,
471        };
472        assert!(a.matches_within_location(&b));
473    }
474
475    #[test]
476    fn matches_meta_digest_fallback_different() {
477        let a = FileFingerprint {
478            byte_digest: None,
479            content_digest: None,
480            meta_digest: Some(MetaDigest("m1".into())),
481            size: 100,
482            modified_at: None,
483        };
484        let b = FileFingerprint {
485            byte_digest: None,
486            content_digest: None,
487            meta_digest: Some(MetaDigest("m2".into())),
488            size: 100,
489            modified_at: None,
490        };
491        assert!(!a.matches_within_location(&b));
492    }
493
494    #[test]
495    fn content_same_meta_different_means_meta_only_change() {
496        let a = hash_fp_with_meta(ByteDigest::Djb2("h1".into()), Some("c1"), Some("m1"), 1024);
497        let b = hash_fp_with_meta(ByteDigest::Djb2("h2".into()), Some("c1"), Some("m2"), 1024);
498        // byte_digest不一致 → matches = false(バイトレベルでは別物)
499        assert!(!a.matches_within_location(&b));
500    }
501
502    #[test]
503    fn precision_meta_level() {
504        let fp = FileFingerprint {
505            byte_digest: None,
506            content_digest: None,
507            meta_digest: Some(MetaDigest("m1".into())),
508            size: 100,
509            modified_at: None,
510        };
511        assert_eq!(fp.precision(), FingerprintPrecision::MetaLevel);
512    }
513
514    #[test]
515    fn precision_semantic_trumps_meta() {
516        let fp = FileFingerprint {
517            byte_digest: None,
518            content_digest: Some(ContentDigest("c1".into())),
519            meta_digest: Some(MetaDigest("m1".into())),
520            size: 100,
521            modified_at: None,
522        };
523        assert_eq!(fp.precision(), FingerprintPrecision::Semantic);
524    }
525
526    #[test]
527    fn precision_ordering() {
528        assert!(FingerprintPrecision::SizeOnly < FingerprintPrecision::Metadata);
529        assert!(FingerprintPrecision::Metadata < FingerprintPrecision::ByteLevel);
530        assert!(FingerprintPrecision::ByteLevel < FingerprintPrecision::MetaLevel);
531        assert!(FingerprintPrecision::MetaLevel < FingerprintPrecision::Semantic);
532    }
533
534    // =========================================================================
535    // Entity model — Content = Identity
536    // =========================================================================
537
538    #[test]
539    fn entity_model_meta_change_does_not_break_identity() {
540        let before = FileFingerprint {
541            byte_digest: None,
542            content_digest: Some(ContentDigest("pixel_hash_abc".into())),
543            meta_digest: Some(MetaDigest("meta_v1".into())),
544            size: 10240,
545            modified_at: None,
546        };
547        let after = FileFingerprint {
548            byte_digest: None,
549            content_digest: Some(ContentDigest("pixel_hash_abc".into())),
550            meta_digest: Some(MetaDigest("meta_v2".into())),
551            size: 10300,
552            modified_at: None,
553        };
554        assert!(before.matches_within_location(&after));
555    }
556
557    #[test]
558    fn entity_model_content_change_is_detected() {
559        let v1 = FileFingerprint {
560            byte_digest: None,
561            content_digest: Some(ContentDigest("pixel_v1".into())),
562            meta_digest: Some(MetaDigest("meta_v1".into())),
563            size: 10240,
564            modified_at: None,
565        };
566        let v2 = FileFingerprint {
567            byte_digest: None,
568            content_digest: Some(ContentDigest("pixel_v2".into())),
569            meta_digest: Some(MetaDigest("meta_v1".into())),
570            size: 10240,
571            modified_at: None,
572        };
573        assert!(!v1.matches_within_location(&v2));
574    }
575
576    #[test]
577    fn entity_model_reexport_with_ts_in_meta() {
578        let original = hash_fp_with_meta(
579            ByteDigest::Djb2("file_h1".into()),
580            Some("pixel_abc"),
581            Some("meta_ts1"),
582            10240,
583        );
584        let reexport = hash_fp_with_meta(
585            ByteDigest::Djb2("file_h2".into()),
586            Some("pixel_abc"),
587            Some("meta_ts2"),
588            10300,
589        );
590        assert!(!original.matches_within_location(&reexport));
591        assert_eq!(
592            original.content_digest.as_ref().map(|cd| cd.as_str()),
593            reexport.content_digest.as_ref().map(|cd| cd.as_str()),
594        );
595    }
596}