use std::{
collections::HashMap,
fmt::Write as _,
fs,
path::{Path, PathBuf},
};
use blake2::{Blake2s256, Digest};
use crate::error::{Error, Result};
#[derive(Debug, Clone)]
#[allow(clippy::struct_field_names)]
pub struct EntryFingerprint {
pub enc_hash: String,
pub target_hash: String,
pub source_hash: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WhichSide {
Unknown,
Neither,
RepoOnly,
ActualOnly,
Both,
}
pub struct FingerprintStore {
records: HashMap<String, EntryFingerprint>,
path: PathBuf,
dirty: bool,
}
impl FingerprintStore {
pub fn load(path: PathBuf) -> Self {
let records = if path.exists() {
fs::read_to_string(&path)
.ok()
.map(|s| parse_store(&s))
.unwrap_or_default()
} else {
HashMap::new()
};
Self {
records,
path,
dirty: false,
}
}
pub fn has_record(&self, source: &str) -> bool {
self.records.contains_key(source)
}
pub fn record(&mut self, source: &str, enc_path: &Path, target_path: &Path) -> Result<()> {
let enc_hash = hash_path(enc_path)?;
let target_hash = hash_path(target_path)?;
self.records.insert(
source.to_string(),
EntryFingerprint {
enc_hash,
target_hash,
source_hash: String::new(),
},
);
self.dirty = true;
Ok(())
}
pub fn record_plain(
&mut self,
source: &str,
source_path: &Path,
target_path: &Path,
) -> Result<()> {
let source_hash = hash_path(source_path)?;
let target_hash = hash_path(target_path)?;
self.records.insert(
source.to_string(),
EntryFingerprint {
enc_hash: String::new(),
target_hash,
source_hash,
},
);
self.dirty = true;
Ok(())
}
pub fn is_in_sync(&self, source: &str, enc_path: &Path, target_path: &Path) -> Option<bool> {
let stored = self.records.get(source)?;
let enc_ok = hash_path(enc_path).is_ok_and(|h| h == stored.enc_hash);
let target_ok = hash_path(target_path).is_ok_and(|h| h == stored.target_hash);
Some(enc_ok && target_ok)
}
pub fn who_changed(&self, source: &str, source_path: &Path, target_path: &Path) -> WhichSide {
let Some(stored) = self.records.get(source) else {
return WhichSide::Unknown;
};
let source_same = hash_path(source_path).is_ok_and(|h| h == stored.source_hash);
let target_same = hash_path(target_path).is_ok_and(|h| h == stored.target_hash);
match (source_same, target_same) {
(true, true) => WhichSide::Neither,
(false, true) => WhichSide::RepoOnly,
(true, false) => WhichSide::ActualOnly,
(false, false) => WhichSide::Both,
}
}
pub fn save(&self) -> Result<()> {
if !self.dirty {
return Ok(());
}
let content = serialize_store(&self.records);
crate::fs::atomic_write(&self.path, content.as_bytes())
}
}
pub fn hash_path(path: &Path) -> Result<String> {
if path.is_dir() {
let mut files = crate::fs::walk_dir(path, false)?;
files.sort();
let mut hasher = Blake2s256::new();
for file in files {
let rel_path = file.strip_prefix(path).unwrap_or(&file);
hasher.update(rel_path.to_string_lossy().as_bytes());
let content =
fs::read(&file).map_err(|e| Error::io(&file, "read for fingerprint", e))?;
hasher.update(&content);
}
let digest = hasher.finalize();
Ok(hex_encode(&digest))
} else {
hash_file(path)
}
}
pub fn hash_file(path: &Path) -> Result<String> {
let bytes = fs::read(path).map_err(|e| Error::io(path, "read for fingerprint", e))?;
let mut hasher = Blake2s256::new();
hasher.update(&bytes);
let digest = hasher.finalize();
Ok(hex_encode(&digest))
}
fn hex_encode(data: &[u8]) -> String {
let mut out = String::with_capacity(data.len() * 2);
for b in data {
let _ = write!(out, "{b:02x}");
}
out
}
fn serialize_store(records: &HashMap<String, EntryFingerprint>) -> String {
let mut out = String::from("# dotling sync fingerprints — managed by dotling\n\n");
let mut entries: Vec<_> = records.iter().collect();
entries.sort_by_key(|(k, _)| k.as_str());
for (source, fp) in entries {
let _ = writeln!(out, "[[entries]]");
let _ = writeln!(out, "source = \"{}\"", escape(source));
if !fp.enc_hash.is_empty() {
let _ = writeln!(out, "enc_hash = \"{}\"", fp.enc_hash);
}
if !fp.source_hash.is_empty() {
let _ = writeln!(out, "source_hash = \"{}\"", fp.source_hash);
}
let _ = writeln!(out, "target_hash = \"{}\"", fp.target_hash);
let _ = writeln!(out);
}
out
}
fn escape(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
fn parse_store(input: &str) -> HashMap<String, EntryFingerprint> {
let mut map = HashMap::new();
let mut source: Option<String> = None;
let mut enc_hash: Option<String> = None;
let mut source_hash: Option<String> = None;
let mut target_hash: Option<String> = None;
for raw in input.lines() {
let line = raw.split('#').next().unwrap_or("").trim();
if line == "[[entries]]" {
if let (Some(s), Some(t)) = (source.take(), target_hash.take()) {
map.insert(
s,
EntryFingerprint {
enc_hash: enc_hash.take().unwrap_or_default(),
source_hash: source_hash.take().unwrap_or_default(),
target_hash: t,
},
);
}
enc_hash = None;
source_hash = None;
continue;
}
if let Some((key, val)) = line.split_once('=') {
let key = key.trim();
let val = val.trim().trim_matches('"');
match key {
"source" => source = Some(val.to_string()),
"enc_hash" => enc_hash = Some(val.to_string()),
"source_hash" => source_hash = Some(val.to_string()),
"target_hash" => target_hash = Some(val.to_string()),
_ => {}
}
}
}
if let (Some(s), Some(t)) = (source, target_hash) {
map.insert(
s,
EntryFingerprint {
enc_hash: enc_hash.unwrap_or_default(),
source_hash: source_hash.unwrap_or_default(),
target_hash: t,
},
);
}
map
}
#[cfg(test)]
mod tests {
use std::io::Write as _;
use tempfile::NamedTempFile;
use super::*;
#[test]
fn hash_file_is_deterministic() {
let mut f = NamedTempFile::new().unwrap();
f.write_all(b"hello dotling").unwrap();
let h1 = hash_file(f.path()).unwrap();
let h2 = hash_file(f.path()).unwrap();
assert_eq!(h1, h2);
assert_eq!(h1.len(), 64); }
#[test]
fn hash_changes_with_content() {
let mut f = NamedTempFile::new().unwrap();
f.write_all(b"version one").unwrap();
let h1 = hash_file(f.path()).unwrap();
f.as_file_mut().set_len(0).unwrap();
f.write_all(b"version two").unwrap();
let h2 = hash_file(f.path()).unwrap();
assert_ne!(h1, h2);
}
#[test]
fn store_roundtrip() {
let store_file = NamedTempFile::new().unwrap();
let enc_file = NamedTempFile::new().unwrap();
let tgt_file = NamedTempFile::new().unwrap();
{
let mut ef = enc_file.reopen().unwrap();
ef.write_all(b"encrypted bytes").unwrap();
}
{
let mut tf = tgt_file.reopen().unwrap();
tf.write_all(b"plaintext bytes").unwrap();
}
let store_path = store_file.path().to_path_buf();
{
let mut store = FingerprintStore::load(store_path.clone());
store
.record("secrets/key", enc_file.path(), tgt_file.path())
.unwrap();
store.save().unwrap();
}
let store2 = FingerprintStore::load(store_path);
assert_eq!(
store2.is_in_sync("secrets/key", enc_file.path(), tgt_file.path()),
Some(true)
);
assert_eq!(
store2.is_in_sync("secrets/other", enc_file.path(), tgt_file.path()),
None
);
}
#[test]
fn detects_changed_target() {
use std::io::Write;
let store_file = NamedTempFile::new().unwrap();
let enc_file = NamedTempFile::new().unwrap();
let tgt_file = NamedTempFile::new().unwrap();
enc_file.reopen().unwrap().write_all(b"enc").unwrap();
tgt_file.reopen().unwrap().write_all(b"target v1").unwrap();
let store_path = store_file.path().to_path_buf();
let mut store = FingerprintStore::load(store_path.clone());
store
.record("a/b", enc_file.path(), tgt_file.path())
.unwrap();
store.save().unwrap();
std::fs::write(tgt_file.path(), b"target v2").unwrap();
let store2 = FingerprintStore::load(store_path);
assert_eq!(
store2.is_in_sync("a/b", enc_file.path(), tgt_file.path()),
Some(false)
);
}
#[test]
fn who_changed_unknown() {
let store = FingerprintStore::load(NamedTempFile::new().unwrap().path().to_path_buf());
let src = NamedTempFile::new().unwrap();
let tgt = NamedTempFile::new().unwrap();
assert_eq!(
store.who_changed("missing", src.path(), tgt.path()),
WhichSide::Unknown
);
}
#[test]
fn who_changed_neither() {
let store_file = NamedTempFile::new().unwrap();
let src = NamedTempFile::new().unwrap();
let tgt = NamedTempFile::new().unwrap();
std::fs::write(src.path(), "content").unwrap();
std::fs::write(tgt.path(), "content").unwrap();
let mut store = FingerprintStore::load(store_file.path().to_path_buf());
store.record_plain("entry", src.path(), tgt.path()).unwrap();
store.save().unwrap();
let store2 = FingerprintStore::load(store_file.path().to_path_buf());
assert_eq!(
store2.who_changed("entry", src.path(), tgt.path()),
WhichSide::Neither
);
}
#[test]
fn who_changed_repo_only() {
let store_file = NamedTempFile::new().unwrap();
let src = NamedTempFile::new().unwrap();
let tgt = NamedTempFile::new().unwrap();
std::fs::write(src.path(), "original").unwrap();
std::fs::write(tgt.path(), "original").unwrap();
let mut store = FingerprintStore::load(store_file.path().to_path_buf());
store.record_plain("entry", src.path(), tgt.path()).unwrap();
store.save().unwrap();
std::fs::write(src.path(), "modified source").unwrap();
let store2 = FingerprintStore::load(store_file.path().to_path_buf());
assert_eq!(
store2.who_changed("entry", src.path(), tgt.path()),
WhichSide::RepoOnly
);
}
#[test]
fn who_changed_actual_only() {
let store_file = NamedTempFile::new().unwrap();
let src = NamedTempFile::new().unwrap();
let tgt = NamedTempFile::new().unwrap();
std::fs::write(src.path(), "original").unwrap();
std::fs::write(tgt.path(), "original").unwrap();
let mut store = FingerprintStore::load(store_file.path().to_path_buf());
store.record_plain("entry", src.path(), tgt.path()).unwrap();
store.save().unwrap();
std::fs::write(tgt.path(), "modified target").unwrap();
let store2 = FingerprintStore::load(store_file.path().to_path_buf());
assert_eq!(
store2.who_changed("entry", src.path(), tgt.path()),
WhichSide::ActualOnly
);
}
#[test]
fn who_changed_both() {
let store_file = NamedTempFile::new().unwrap();
let src = NamedTempFile::new().unwrap();
let tgt = NamedTempFile::new().unwrap();
std::fs::write(src.path(), "original").unwrap();
std::fs::write(tgt.path(), "original").unwrap();
let mut store = FingerprintStore::load(store_file.path().to_path_buf());
store.record_plain("entry", src.path(), tgt.path()).unwrap();
store.save().unwrap();
std::fs::write(src.path(), "new source").unwrap();
std::fs::write(tgt.path(), "new target").unwrap();
let store2 = FingerprintStore::load(store_file.path().to_path_buf());
assert_eq!(
store2.who_changed("entry", src.path(), tgt.path()),
WhichSide::Both
);
}
#[test]
fn record_plain_roundtrip() {
let store_file = NamedTempFile::new().unwrap();
let src = NamedTempFile::new().unwrap();
let tgt = NamedTempFile::new().unwrap();
std::fs::write(src.path(), "repo content").unwrap();
std::fs::write(tgt.path(), "target content").unwrap();
let mut store = FingerprintStore::load(store_file.path().to_path_buf());
store
.record_plain("copy/entry", src.path(), tgt.path())
.unwrap();
store.save().unwrap();
let store2 = FingerprintStore::load(store_file.path().to_path_buf());
assert_eq!(
store2.who_changed("copy/entry", src.path(), tgt.path()),
WhichSide::Neither
);
}
#[test]
fn is_in_sync_after_record() {
let store_file = NamedTempFile::new().unwrap();
let enc = NamedTempFile::new().unwrap();
let tgt = NamedTempFile::new().unwrap();
std::fs::write(enc.path(), "encrypted").unwrap();
std::fs::write(tgt.path(), "plaintext").unwrap();
let mut store = FingerprintStore::load(store_file.path().to_path_buf());
store.record("entry", enc.path(), tgt.path()).unwrap();
assert_eq!(
store.is_in_sync("entry", enc.path(), tgt.path()),
Some(true)
);
}
#[test]
fn is_in_sync_unknown_source() {
let store = FingerprintStore::load(NamedTempFile::new().unwrap().path().to_path_buf());
let enc = NamedTempFile::new().unwrap();
let tgt = NamedTempFile::new().unwrap();
assert_eq!(store.is_in_sync("missing", enc.path(), tgt.path()), None);
}
#[test]
fn hash_path_directory() {
let temp = tempfile::tempdir().unwrap();
let dir = temp.path().join("content");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("a.txt"), "aaa").unwrap();
fs::write(dir.join("b.txt"), "bbb").unwrap();
let h = hash_path(&dir).unwrap();
assert_eq!(h.len(), 64);
assert_eq!(hash_path(&dir).unwrap(), h);
}
#[test]
fn hash_path_nonexistent() {
let temp = tempfile::tempdir().unwrap();
let result = hash_path(&temp.path().join("nonexistent"));
assert!(result.is_err());
}
#[test]
fn serialize_parse_roundtrip() {
let mut records = HashMap::new();
records.insert(
"a/b".to_string(),
EntryFingerprint {
enc_hash: "abc123".to_string(),
target_hash: "def456".to_string(),
source_hash: String::new(),
},
);
records.insert(
"c/d".to_string(),
EntryFingerprint {
enc_hash: String::new(),
target_hash: "789abc".to_string(),
source_hash: "012def".to_string(),
},
);
let serialized = serialize_store(&records);
let parsed = parse_store(&serialized);
assert_eq!(parsed.len(), 2);
assert_eq!(parsed["a/b"].enc_hash, "abc123");
assert_eq!(parsed["a/b"].target_hash, "def456");
assert_eq!(parsed["c/d"].source_hash, "012def");
}
#[test]
fn parse_empty_input() {
let parsed = parse_store("");
assert!(parsed.is_empty());
}
#[test]
fn parse_with_comments() {
let input = "# comment\n[[entries]]\nsource = \"x\"\ntarget_hash = \"abc\"\n";
let parsed = parse_store(input);
assert_eq!(parsed.len(), 1);
assert_eq!(parsed["x"].target_hash, "abc");
}
#[test]
fn parse_with_missing_optional_fields() {
let input = "[[entries]]\nsource = \"x\"\ntarget_hash = \"abc\"\n";
let parsed = parse_store(input);
assert_eq!(parsed["x"].enc_hash, "");
assert_eq!(parsed["x"].source_hash, "");
}
}