Skip to main content

text_mirror/
hash.rs

1//! Streaming BLAKE3 file hashing and the dedup index.
2
3use std::collections::HashMap;
4use std::fs::File;
5use std::path::Path;
6
7use crate::manifest::{ArtifactKind, Media};
8use crate::{Error, Result};
9
10/// Hashes a file with BLAKE3, streaming, and returns lowercase hex.
11pub fn hash_file(path: &Path) -> Result<String> {
12    let file = File::open(path).map_err(|e| Error::io("open", path, e))?;
13    let mut hasher = blake3::Hasher::new();
14    hasher
15        .update_reader(file)
16        .map_err(|e| Error::io("read", path, e))?;
17    Ok(hasher.finalize().to_hex().to_string())
18}
19
20/// Hashes a byte slice with BLAKE3 and returns lowercase hex.
21pub fn hash_bytes(bytes: &[u8]) -> String {
22    blake3::hash(bytes).to_hex().to_string()
23}
24
25/// The conversion outcome of the first source seen with a given hash.
26///
27/// A later source with the same hash becomes a `dedup` record that
28/// copies this artifact instead of converting again.
29#[derive(Debug, Clone)]
30pub struct CanonicalArtifact {
31    /// Source path of the canonical copy, relative to the division root.
32    pub source_path: String,
33    /// Text path of the canonical artifact, relative to the mirror root.
34    pub text_path: String,
35    /// BLAKE3 hash of the canonical artifact text.
36    pub text_hash: String,
37    /// Converter that produced the canonical artifact.
38    pub converter_id: String,
39    /// Version of that converter.
40    pub converter_version: String,
41    /// The canonical artifact's kind.
42    pub artifact_kind: Option<ArtifactKind>,
43    /// Media provenance of the canonical conversion. It describes the
44    /// bytes and the pinned engine, so a duplicate of the same bytes
45    /// inherits it.
46    pub media: Option<Media>,
47    /// Warnings from the canonical conversion. They describe the
48    /// bytes, so a duplicate of the same bytes inherits them.
49    pub warnings: Vec<String>,
50}
51
52/// Maps a source hash to the canonical artifact that converted it.
53///
54/// The first insert for a hash wins. Traversal is sorted, so the
55/// canonical copy is stable across runs of the same tree.
56#[derive(Debug, Default)]
57pub struct DedupIndex {
58    by_hash: HashMap<String, CanonicalArtifact>,
59}
60
61impl DedupIndex {
62    /// Registers a converted artifact unless the hash already has one.
63    pub fn insert(&mut self, source_hash: &str, canonical: CanonicalArtifact) {
64        self.by_hash
65            .entry(source_hash.to_string())
66            .or_insert(canonical);
67    }
68
69    /// Registers a converted artifact, replacing any seeded entry.
70    ///
71    /// An in-run conversion is fresher than anything seeded from a
72    /// prior manifest, so it wins.
73    pub fn replace(&mut self, source_hash: &str, canonical: CanonicalArtifact) {
74        self.by_hash.insert(source_hash.to_string(), canonical);
75    }
76
77    /// Looks up the canonical artifact for a source hash.
78    pub fn get(&self, source_hash: &str) -> Option<&CanonicalArtifact> {
79        self.by_hash.get(source_hash)
80    }
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86    use std::io::Write;
87
88    #[test]
89    fn hash_bytes_matches_known_vector() {
90        assert_eq!(
91            hash_bytes(b""),
92            "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262"
93        );
94    }
95
96    #[test]
97    fn hash_file_matches_hash_bytes() {
98        let mut file = tempfile::NamedTempFile::new().unwrap();
99        file.write_all(b"mirror me").unwrap();
100        file.flush().unwrap();
101        assert_eq!(hash_file(file.path()).unwrap(), hash_bytes(b"mirror me"));
102    }
103
104    #[test]
105    fn dedup_index_keeps_first_insert() {
106        let mut index = DedupIndex::default();
107        let first = CanonicalArtifact {
108            source_path: "a.txt".to_string(),
109            text_path: "a.txt.txt".to_string(),
110            text_hash: "aa".to_string(),
111            converter_id: "text-passthrough".to_string(),
112            converter_version: "1.0.0".to_string(),
113            artifact_kind: Some(ArtifactKind::Text),
114            media: None,
115            warnings: Vec::new(),
116        };
117        let mut second = first.clone();
118        second.source_path = "b.txt".to_string();
119        index.insert("h1", first);
120        index.insert("h1", second.clone());
121        assert_eq!(index.get("h1").unwrap().source_path, "a.txt");
122        assert!(index.get("h2").is_none());
123
124        index.replace("h1", second);
125        assert_eq!(index.get("h1").unwrap().source_path, "b.txt");
126    }
127}