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    /// ローカルファイルのハッシュ結果から `FileFingerprint` を構築するファクトリ関数。
193    ///
194    /// `watcher` 等の外部クレートが `ByteDigest` / `ContentDigest` を直接構築せずに
195    /// `FileFingerprint` を生成するためのパブリック API。
196    ///
197    /// # Parameters
198    ///
199    /// - `file_hash`: DJB2 ハッシュ文字列 (16 文字 hex)
200    /// - `content_hash`: PNG 等のセマンティックハッシュ (Optional)
201    /// - `size`: ファイルサイズ (bytes)
202    /// - `modified_at`: 最終更新日時 (Optional)
203    pub fn from_local_hash(
204        file_hash: String,
205        content_hash: Option<String>,
206        size: u64,
207        modified_at: Option<DateTime<Utc>>,
208    ) -> Self {
209        Self {
210            byte_digest: Some(ByteDigest::Djb2(file_hash)),
211            content_digest: content_hash.map(ContentDigest),
212            meta_digest: None,
213            size,
214            modified_at,
215        }
216    }
217}
218
219impl std::fmt::Display for FingerprintPrecision {
220    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
221        match self {
222            Self::SizeOnly => f.write_str("size-only"),
223            Self::Metadata => f.write_str("metadata"),
224            Self::ByteLevel => f.write_str("byte-level"),
225            Self::MetaLevel => f.write_str("meta-level"),
226            Self::Semantic => f.write_str("semantic"),
227        }
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    fn hash_fp(
236        byte_digest: ByteDigest,
237        content_digest: Option<&str>,
238        size: u64,
239    ) -> FileFingerprint {
240        FileFingerprint {
241            byte_digest: Some(byte_digest),
242            content_digest: content_digest.map(|s| ContentDigest(s.to_string())),
243            meta_digest: None,
244            size,
245            modified_at: None,
246        }
247    }
248
249    fn hash_fp_with_meta(
250        byte_digest: ByteDigest,
251        content_digest: Option<&str>,
252        meta_digest: Option<&str>,
253        size: u64,
254    ) -> FileFingerprint {
255        FileFingerprint {
256            byte_digest: Some(byte_digest),
257            content_digest: content_digest.map(|s| ContentDigest(s.to_string())),
258            meta_digest: meta_digest.map(|s| MetaDigest(s.to_string())),
259            size,
260            modified_at: None,
261        }
262    }
263
264    fn metadata_fp(size: u64, mtime: Option<DateTime<Utc>>) -> FileFingerprint {
265        FileFingerprint {
266            byte_digest: None,
267            content_digest: None,
268            meta_digest: None,
269            size,
270            modified_at: mtime,
271        }
272    }
273
274    // =========================================================================
275    // matches_within_location() — byte_digest 優先(バイト同一性が最も信頼性が高い)
276    // =========================================================================
277
278    #[test]
279    fn matches_byte_level_trumps_semantic() {
280        let a = hash_fp(ByteDigest::Djb2("h1".into()), Some("c1"), 100);
281        let b = hash_fp(ByteDigest::Djb2("h1".into()), Some("c2"), 200);
282        assert!(a.matches_within_location(&b));
283    }
284
285    #[test]
286    fn matches_byte_level_different_trumps_semantic() {
287        let a = hash_fp(ByteDigest::Djb2("h1".into()), Some("c1"), 100);
288        let b = hash_fp(ByteDigest::Djb2("h2".into()), Some("c1"), 100);
289        assert!(!a.matches_within_location(&b));
290    }
291
292    // =========================================================================
293    // matches_within_location() — content_digest フォールバック(byte_digestなし時)
294    // =========================================================================
295
296    #[test]
297    fn matches_semantic_fallback_same() {
298        let a = FileFingerprint {
299            byte_digest: None,
300            content_digest: Some(ContentDigest("c1".into())),
301            meta_digest: None,
302            size: 100,
303            modified_at: None,
304        };
305        let b = FileFingerprint {
306            byte_digest: None,
307            content_digest: Some(ContentDigest("c1".into())),
308            meta_digest: None,
309            size: 200,
310            modified_at: None,
311        };
312        assert!(a.matches_within_location(&b));
313    }
314
315    #[test]
316    fn matches_semantic_fallback_different() {
317        let a = FileFingerprint {
318            byte_digest: None,
319            content_digest: Some(ContentDigest("c1".into())),
320            meta_digest: None,
321            size: 100,
322            modified_at: None,
323        };
324        let b = FileFingerprint {
325            byte_digest: None,
326            content_digest: Some(ContentDigest("c2".into())),
327            meta_digest: None,
328            size: 100,
329            modified_at: None,
330        };
331        assert!(!a.matches_within_location(&b));
332    }
333
334    // =========================================================================
335    // matches_within_location() — metadata (size + mtime)
336    // =========================================================================
337
338    #[test]
339    fn matches_metadata_same() {
340        let t = Utc::now();
341        let a = metadata_fp(1024, Some(t));
342        let b = metadata_fp(1024, Some(t));
343        assert!(a.matches_within_location(&b));
344    }
345
346    #[test]
347    fn matches_metadata_size_differs() {
348        let t = Utc::now();
349        let a = metadata_fp(1024, Some(t));
350        let b = metadata_fp(2048, Some(t));
351        assert!(!a.matches_within_location(&b));
352    }
353
354    #[test]
355    fn matches_metadata_mtime_differs() {
356        let t1 = DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
357            .unwrap()
358            .with_timezone(&Utc);
359        let t2 = DateTime::parse_from_rfc3339("2024-06-01T00:00:00Z")
360            .unwrap()
361            .with_timezone(&Utc);
362        let a = metadata_fp(1024, Some(t1));
363        let b = metadata_fp(1024, Some(t2));
364        assert!(!a.matches_within_location(&b));
365    }
366
367    // =========================================================================
368    // matches_within_location() — size only (最低精度)
369    // =========================================================================
370
371    #[test]
372    fn matches_size_only_same() {
373        let a = metadata_fp(1024, None);
374        let b = metadata_fp(1024, None);
375        assert!(a.matches_within_location(&b));
376    }
377
378    #[test]
379    fn matches_size_only_different() {
380        let a = metadata_fp(1024, None);
381        let b = metadata_fp(2048, None);
382        assert!(!a.matches_within_location(&b));
383    }
384
385    // =========================================================================
386    // matches_within_location() — 異精度の比較
387    // =========================================================================
388
389    #[test]
390    fn matches_hash_vs_metadata_size_match() {
391        let a = hash_fp(ByteDigest::Djb2("h1".into()), None, 1024);
392        let b = metadata_fp(1024, None);
393        assert!(a.matches_within_location(&b));
394    }
395
396    #[test]
397    fn matches_hash_vs_metadata_size_differs() {
398        let a = hash_fp(ByteDigest::Djb2("h1".into()), None, 1024);
399        let b = metadata_fp(2048, None);
400        assert!(!a.matches_within_location(&b));
401    }
402
403    // =========================================================================
404    // matches_within_location() — 異アルゴリズムはフォールバック
405    // =========================================================================
406
407    #[test]
408    fn cross_algorithm_falls_back_to_size() {
409        // 同一location内では通常起きないが、万一の安全策
410        let a = hash_fp(ByteDigest::Djb2("h1".into()), None, 1024);
411        let b = hash_fp(ByteDigest::Sha256("h2".into()), None, 1024);
412        // byte_digest比較はErr → size比較にフォールバック → size一致でtrue
413        assert!(a.matches_within_location(&b));
414    }
415
416    // =========================================================================
417    // precision()
418    // =========================================================================
419
420    #[test]
421    fn precision_semantic() {
422        let fp = hash_fp(ByteDigest::Djb2("h".into()), Some("c"), 100);
423        assert_eq!(fp.precision(), FingerprintPrecision::Semantic);
424    }
425
426    #[test]
427    fn precision_byte_level() {
428        let fp = hash_fp(ByteDigest::Djb2("h".into()), None, 100);
429        assert_eq!(fp.precision(), FingerprintPrecision::ByteLevel);
430    }
431
432    #[test]
433    fn precision_metadata() {
434        let fp = metadata_fp(100, Some(Utc::now()));
435        assert_eq!(fp.precision(), FingerprintPrecision::Metadata);
436    }
437
438    #[test]
439    fn precision_size_only() {
440        let fp = metadata_fp(100, None);
441        assert_eq!(fp.precision(), FingerprintPrecision::SizeOnly);
442    }
443
444    // =========================================================================
445    // effective_precision()
446    // =========================================================================
447
448    #[test]
449    fn effective_precision_downgrades() {
450        let hash = hash_fp(ByteDigest::Djb2("h".into()), Some("c"), 100);
451        let meta = metadata_fp(100, Some(Utc::now()));
452        assert_eq!(
453            hash.effective_precision(&meta),
454            FingerprintPrecision::Metadata
455        );
456    }
457
458    #[test]
459    fn effective_precision_same_level() {
460        let a = hash_fp(ByteDigest::Djb2("h1".into()), None, 100);
461        let b = hash_fp(ByteDigest::Djb2("h2".into()), None, 200);
462        assert_eq!(a.effective_precision(&b), FingerprintPrecision::ByteLevel);
463    }
464
465    // =========================================================================
466    // Display
467    // =========================================================================
468
469    #[test]
470    fn precision_display() {
471        assert_eq!(FingerprintPrecision::Semantic.to_string(), "semantic");
472        assert_eq!(FingerprintPrecision::MetaLevel.to_string(), "meta-level");
473        assert_eq!(FingerprintPrecision::ByteLevel.to_string(), "byte-level");
474        assert_eq!(FingerprintPrecision::Metadata.to_string(), "metadata");
475        assert_eq!(FingerprintPrecision::SizeOnly.to_string(), "size-only");
476    }
477
478    // =========================================================================
479    // meta_digest — メタデータ同一性
480    // =========================================================================
481
482    #[test]
483    fn matches_meta_digest_fallback_same() {
484        let a = FileFingerprint {
485            byte_digest: None,
486            content_digest: None,
487            meta_digest: Some(MetaDigest("m1".into())),
488            size: 100,
489            modified_at: None,
490        };
491        let b = FileFingerprint {
492            byte_digest: None,
493            content_digest: None,
494            meta_digest: Some(MetaDigest("m1".into())),
495            size: 200,
496            modified_at: None,
497        };
498        assert!(a.matches_within_location(&b));
499    }
500
501    #[test]
502    fn matches_meta_digest_fallback_different() {
503        let a = FileFingerprint {
504            byte_digest: None,
505            content_digest: None,
506            meta_digest: Some(MetaDigest("m1".into())),
507            size: 100,
508            modified_at: None,
509        };
510        let b = FileFingerprint {
511            byte_digest: None,
512            content_digest: None,
513            meta_digest: Some(MetaDigest("m2".into())),
514            size: 100,
515            modified_at: None,
516        };
517        assert!(!a.matches_within_location(&b));
518    }
519
520    #[test]
521    fn content_same_meta_different_means_meta_only_change() {
522        let a = hash_fp_with_meta(ByteDigest::Djb2("h1".into()), Some("c1"), Some("m1"), 1024);
523        let b = hash_fp_with_meta(ByteDigest::Djb2("h2".into()), Some("c1"), Some("m2"), 1024);
524        // byte_digest不一致 → matches = false(バイトレベルでは別物)
525        assert!(!a.matches_within_location(&b));
526    }
527
528    #[test]
529    fn precision_meta_level() {
530        let fp = FileFingerprint {
531            byte_digest: None,
532            content_digest: None,
533            meta_digest: Some(MetaDigest("m1".into())),
534            size: 100,
535            modified_at: None,
536        };
537        assert_eq!(fp.precision(), FingerprintPrecision::MetaLevel);
538    }
539
540    #[test]
541    fn precision_semantic_trumps_meta() {
542        let fp = FileFingerprint {
543            byte_digest: None,
544            content_digest: Some(ContentDigest("c1".into())),
545            meta_digest: Some(MetaDigest("m1".into())),
546            size: 100,
547            modified_at: None,
548        };
549        assert_eq!(fp.precision(), FingerprintPrecision::Semantic);
550    }
551
552    #[test]
553    fn precision_ordering() {
554        assert!(FingerprintPrecision::SizeOnly < FingerprintPrecision::Metadata);
555        assert!(FingerprintPrecision::Metadata < FingerprintPrecision::ByteLevel);
556        assert!(FingerprintPrecision::ByteLevel < FingerprintPrecision::MetaLevel);
557        assert!(FingerprintPrecision::MetaLevel < FingerprintPrecision::Semantic);
558    }
559
560    // =========================================================================
561    // Entity model — Content = Identity
562    // =========================================================================
563
564    #[test]
565    fn entity_model_meta_change_does_not_break_identity() {
566        let before = FileFingerprint {
567            byte_digest: None,
568            content_digest: Some(ContentDigest("pixel_hash_abc".into())),
569            meta_digest: Some(MetaDigest("meta_v1".into())),
570            size: 10240,
571            modified_at: None,
572        };
573        let after = FileFingerprint {
574            byte_digest: None,
575            content_digest: Some(ContentDigest("pixel_hash_abc".into())),
576            meta_digest: Some(MetaDigest("meta_v2".into())),
577            size: 10300,
578            modified_at: None,
579        };
580        assert!(before.matches_within_location(&after));
581    }
582
583    #[test]
584    fn entity_model_content_change_is_detected() {
585        let v1 = FileFingerprint {
586            byte_digest: None,
587            content_digest: Some(ContentDigest("pixel_v1".into())),
588            meta_digest: Some(MetaDigest("meta_v1".into())),
589            size: 10240,
590            modified_at: None,
591        };
592        let v2 = FileFingerprint {
593            byte_digest: None,
594            content_digest: Some(ContentDigest("pixel_v2".into())),
595            meta_digest: Some(MetaDigest("meta_v1".into())),
596            size: 10240,
597            modified_at: None,
598        };
599        assert!(!v1.matches_within_location(&v2));
600    }
601
602    #[test]
603    fn entity_model_reexport_with_ts_in_meta() {
604        let original = hash_fp_with_meta(
605            ByteDigest::Djb2("file_h1".into()),
606            Some("pixel_abc"),
607            Some("meta_ts1"),
608            10240,
609        );
610        let reexport = hash_fp_with_meta(
611            ByteDigest::Djb2("file_h2".into()),
612            Some("pixel_abc"),
613            Some("meta_ts2"),
614            10300,
615        );
616        assert!(!original.matches_within_location(&reexport));
617        assert_eq!(
618            original.content_digest.as_ref().map(|cd| cd.as_str()),
619            reexport.content_digest.as_ref().map(|cd| cd.as_str()),
620        );
621    }
622}