Skip to main content

mkit_git_bridge/
map.rs

1//! blake3↔sha1 mapping cache and per-remote export state
2//! (SPEC-GIT-BRIDGE §12.3).
3//!
4//! Everything here is a **disposable cache**: translation is
5//! deterministic, so a missing or corrupt file means "rebuild", never
6//! an error. The map file is append-only text (`<64hex> <40hex>\n`);
7//! [`load_map`] skips lines that do not parse (so a partially-written
8//! file still loads), and [`map_is_intact`] reports ANY malformed or
9//! blank line so the import driver can trigger the full rebuild —
10//! surviving lines of a damaged file are not evidence the rest
11//! exists. Ref state is rewritten whole via temp-file + rename.
12
13use crate::error::BridgeError;
14use crate::gitobj::{Sha1Id, sha1_from_hex, sha1_hex};
15use mkit_core::Hash;
16use mkit_core::hash::{from_hex, to_hex};
17use mkit_core::layout::RepoLayout;
18use std::collections::HashMap;
19use std::io::Write as _;
20use std::path::{Path, PathBuf};
21
22/// `<common dir>/git/<remote>/` — the per-remote bridge state
23/// directory (shared across worktrees, see `mkit_core::layout`).
24/// Remote names are restricted to the mkit ref-segment charset, minus
25/// `.`, so the directory name is always safe: this matches
26/// `mkit-cli`'s `validate_remote_name` (dot-free, on top of
27/// `refs::validate_ref_name`) rather than the ref grammar alone, so a
28/// name accepted here can never collide with the dot-leading temp
29/// directory `remote rename` uses (`remote.rs::rename_state_dir`) and
30/// the two bridge-state entry points (`remote add`/`rename` and `mkit
31/// git import/export --remote-name`) agree on what a valid state-dir
32/// name looks like.
33pub fn state_dir(layout: &RepoLayout, remote: &str) -> Result<PathBuf, BridgeError> {
34    if remote.is_empty()
35        || !remote
36            .bytes()
37            .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
38    {
39        return Err(BridgeError::Source(format!(
40            "remote name {remote:?} is not a valid dot-free bridge state name"
41        )));
42    }
43    Ok(layout.git_state_dir().join(remote))
44}
45
46const MAP_FILE: &str = "map";
47const REFS_FILE: &str = "refs";
48const IMPORT_REFS_FILE: &str = "refs-import";
49
50/// Recorded direction of a state dir (SPEC-GIT-IMPORT §6): one dir
51/// serves one direction; `fork` couples an import source with
52/// passthrough export. Immutable once stamped.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum Direction {
55    Import,
56    Export,
57    Fork,
58}
59
60impl Direction {
61    /// Stable on-disk / display token for this direction.
62    #[must_use]
63    pub fn as_str(self) -> &'static str {
64        match self {
65            Self::Import => "import",
66            Self::Export => "export",
67            Self::Fork => "fork",
68        }
69    }
70
71    fn parse(s: &str) -> Option<Self> {
72        Some(match s {
73            "import" => Self::Import,
74            "export" => Self::Export,
75            "fork" => Self::Fork,
76            _ => return None,
77        })
78    }
79}
80
81fn read_stamp(dir: &Path, name: &str) -> Result<Option<String>, BridgeError> {
82    match std::fs::read_to_string(dir.join(name)) {
83        Ok(v) => Ok(Some(v.trim().to_owned())),
84        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
85        Err(e) => Err(e.into()),
86    }
87}
88
89fn write_stamp(dir: &Path, name: &str, value: &str) -> Result<(), BridgeError> {
90    std::fs::create_dir_all(dir)?;
91    // Temp + content-fsync + rename + dir-fsync: stamps are bindings
92    // (direction, signer, source, …) — a torn or vanished stamp after
93    // power loss either wedges the state dir or silently unbinds it.
94    let tmp = dir.join(format!(".{name}.tmp"));
95    {
96        let mut f = std::fs::File::create(&tmp)?;
97        f.write_all(format!("{value}\n").as_bytes())?;
98        f.sync_all()?;
99    }
100    std::fs::rename(&tmp, dir.join(name))?;
101    if let Ok(d) = std::fs::File::open(dir) {
102        let _ = d.sync_all();
103    }
104    Ok(())
105}
106
107/// Durable write of a named binding file (`source`, `dest`) — same
108/// guarantees as the internal stamps.
109pub fn write_binding(dir: &Path, name: &str, value: &str) -> Result<(), BridgeError> {
110    write_stamp(dir, name, value)
111}
112
113/// Read the recorded direction, if stamped.
114pub fn read_direction(dir: &Path) -> Result<Option<Direction>, BridgeError> {
115    match read_stamp(dir, "direction")? {
116        None => Ok(None),
117        Some(v) => Direction::parse(&v).map(Some).ok_or_else(|| {
118            // A present-but-unparsable stamp must NOT read as absent:
119            // bind_direction would silently rebind a state dir whose
120            // direction the spec pins as immutable (§6).
121            BridgeError::Source(format!(
122                "direction stamp is corrupt ({v:?}); refusing to guess — \
123                 restore or remove the state dir"
124            ))
125        }),
126    }
127}
128
129/// Stamp the direction, or verify it matches an existing stamp.
130/// `Export → Fork` upgrades are refused like any other mismatch (the
131/// map semantics differ); `Import → Fork` is the supported upgrade
132/// (fork = import + passthrough export over the same source).
133pub fn bind_direction(dir: &Path, want: Direction) -> Result<(), BridgeError> {
134    match read_direction(dir)? {
135        None => write_stamp(dir, "direction", want.as_str()),
136        Some(have) if have == want => Ok(()),
137        Some(Direction::Import) if want == Direction::Fork => {
138            write_stamp(dir, "direction", want.as_str())
139        }
140        Some(have) => Err(BridgeError::Source(format!(
141            "state dir is bound to direction '{}'; '{}' is not allowed here \
142             (one direction per state dir — use a different --remote-name)",
143            have.as_str(),
144            want.as_str()
145        ))),
146    }
147}
148
149/// Read the pinned importer pubkey (64 lowercase hex), if stamped.
150pub fn read_signer(dir: &Path) -> Result<Option<[u8; 32]>, BridgeError> {
151    match read_stamp(dir, "signer")? {
152        None => Ok(None),
153        Some(v) => crate::gitobj::bytes_from_hex(&v, 32)
154            .map(|b| {
155                let mut k = [0u8; 32];
156                k.copy_from_slice(&b);
157                Some(k)
158            })
159            .ok_or_else(|| {
160                // Same rule as the direction stamp: corruption must
161                // not silently unpin the importer key (§4).
162                BridgeError::Source(
163                    "signer stamp is corrupt; refusing to re-pin — restore or \
164                     remove the state dir"
165                        .into(),
166                )
167            }),
168    }
169}
170
171/// Pin the importer key, or refuse a mismatch (SPEC-GIT-IMPORT §4).
172pub fn bind_signer(dir: &Path, key: &[u8; 32]) -> Result<(), BridgeError> {
173    match read_signer(dir)? {
174        None => write_stamp(dir, "signer", &crate::gitobj::bytes_hex(key)),
175        Some(have) if have == *key => Ok(()),
176        Some(have) => Err(BridgeError::Source(format!(
177            "this import is pinned to importer key {}…; the available key is {}…. \
178             Designated-importer model: pull this history over mkit transport from \
179             the importer, or install the pinned key (SPEC-GIT-IMPORT §4)",
180            &crate::gitobj::bytes_hex(&have)[..16],
181            &crate::gitobj::bytes_hex(key)[..16]
182        ))),
183    }
184}
185
186/// Record that this state dir's imported history contains
187/// historic-mode-normalized trees (SPEC-GIT-IMPORT §3.3). Sticky: a
188/// normalized tree cannot reproduce its original sha1, so a later
189/// import→fork upgrade must refuse (SPEC-GIT-BRIDGE §14.3 fork audit
190/// would otherwise report false tampering forever).
191pub fn mark_normalized(dir: &Path) -> Result<(), BridgeError> {
192    write_stamp(dir, "normalized", "1")
193}
194
195/// Whether [`mark_normalized`] was ever stamped.
196pub fn read_normalized(dir: &Path) -> Result<bool, BridgeError> {
197    Ok(read_stamp(dir, "normalized")?.is_some())
198}
199
200/// Read / pin the import-spec version (SPEC-GIT-IMPORT §1.2).
201pub fn bind_import_spec(dir: &Path, version: u32) -> Result<(), BridgeError> {
202    match read_stamp(dir, "import-spec")? {
203        None => write_stamp(dir, "import-spec", &version.to_string()),
204        Some(v) if v == version.to_string() => Ok(()),
205        Some(v) => Err(BridgeError::Source(format!(
206            "state recorded import-spec {v}, this build implements {version}; \
207             incremental pulls across mapping versions are refused — re-import \
208             under a new --remote-name (SPEC-GIT-IMPORT §1.2)"
209        ))),
210    }
211}
212
213/// Whether every non-empty line of the map file parses. A missing
214/// file is intact (nothing to distrust). Any malformed line —
215/// torn tail or mid-file corruption — means the cache may be
216/// MISSING entries that recorded refs rely on, so callers must
217/// rebuild rather than trust the surviving lines alone (§12.3).
218pub fn map_is_intact(dir: &Path) -> Result<bool, BridgeError> {
219    let path = dir.join(MAP_FILE);
220    let data = match std::fs::read(&path) {
221        Ok(d) => d,
222        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(true),
223        Err(e) => return Err(e.into()),
224    };
225    let Ok(text) = std::str::from_utf8(&data) else {
226        return Ok(false);
227    };
228    for line in text.lines() {
229        if line.is_empty() {
230            // The format has no blank-line record: an internal blank
231            // is a dropped mapping, not noise.
232            return Ok(false);
233        }
234        let Some((b3, s1)) = line.split_once(' ') else {
235            return Ok(false);
236        };
237        if from_hex(b3).is_err() || sha1_from_hex(s1).is_none() {
238            return Ok(false);
239        }
240    }
241    Ok(true)
242}
243
244/// Load the map inverted (sha1 → blake3) for the import direction.
245/// Parsed directly from the file lines: translation is many-to-one
246/// (two historic-mode spellings of a tree normalize to ONE mkit
247/// tree), so inverting the blake3-keyed [`load_map`] would drop a
248/// sha1 and force a pointless re-translation every fetch.
249pub fn load_map_inverse(dir: &Path) -> Result<HashMap<Sha1Id, Hash>, BridgeError> {
250    let path = dir.join(MAP_FILE);
251    let data = match std::fs::read(&path) {
252        Ok(d) => String::from_utf8_lossy(&d).into_owned(),
253        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(HashMap::new()),
254        Err(e) => return Err(e.into()),
255    };
256    let mut map = HashMap::new();
257    for line in data.lines() {
258        let Some((b3, s1)) = line.split_once(' ') else {
259            continue;
260        };
261        let (Ok(h), Some(id)) = (from_hex(b3), sha1_from_hex(s1)) else {
262            continue;
263        };
264        map.insert(id, h);
265    }
266    Ok(map)
267}
268
269/// Append pairs given in import orientation (sha1, blake3) — the file
270/// format stays blake3-first either way.
271pub fn append_map_import(dir: &Path, pairs: &[(Sha1Id, Hash)]) -> Result<(), BridgeError> {
272    let flipped: Vec<(Hash, Sha1Id)> = pairs.iter().map(|(s, b)| (*b, *s)).collect();
273    append_map(dir, &flipped)
274}
275
276/// Load the blake3→sha1 map. Missing file = empty map. Lines that do
277/// not parse (torn tail from a crash) are ignored.
278pub fn load_map(dir: &Path) -> Result<HashMap<Hash, Sha1Id>, BridgeError> {
279    let path = dir.join(MAP_FILE);
280    // §12.3: corruption (including undecodable bytes) means "rebuild",
281    // never an error — lossy decoding turns garbage into skipped lines.
282    let data = match std::fs::read(&path) {
283        Ok(d) => String::from_utf8_lossy(&d).into_owned(),
284        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(HashMap::new()),
285        Err(e) => return Err(e.into()),
286    };
287    let mut map = HashMap::new();
288    for line in data.lines() {
289        let Some((b3, s1)) = line.split_once(' ') else {
290            continue;
291        };
292        let (Ok(h), Some(id)) = (from_hex(b3), sha1_from_hex(s1)) else {
293            continue;
294        };
295        map.insert(h, id);
296    }
297    Ok(map)
298}
299
300/// Append newly translated pairs. Append-only by design: entries for
301/// rewritten-away commits stay valid forever (determinism), so no
302/// compaction or invalidation exists (§12.2).
303pub fn append_map(dir: &Path, pairs: &[(Hash, Sha1Id)]) -> Result<(), BridgeError> {
304    if pairs.is_empty() {
305        return Ok(());
306    }
307    std::fs::create_dir_all(dir)?;
308    let mut out = String::new();
309    for (h, id) in pairs {
310        out.push_str(&to_hex(h));
311        out.push(' ');
312        out.push_str(&sha1_hex(id));
313        out.push('\n');
314    }
315    let mut f = std::fs::OpenOptions::new()
316        .create(true)
317        .append(true)
318        .open(dir.join(MAP_FILE))?;
319    f.write_all(out.as_bytes())?;
320    f.sync_all()?;
321    // Dir fsync so the FIRST append's file creation is as durable as
322    // the stamps' (later appends find it a no-op-cost write).
323    if let Ok(d) = std::fs::File::open(dir) {
324        let _ = d.sync_all();
325    }
326    Ok(())
327}
328
329/// Last-exported state for one ref: what the bridge last pushed.
330/// Used as the `--force-with-lease` expectation (§12.2).
331#[derive(Debug, Clone, PartialEq, Eq)]
332pub struct RefState {
333    pub ref_name: String,
334    pub mkit_hash: Hash,
335    pub git_id: Sha1Id,
336}
337
338/// Load per-ref EXPORT state (push leases). Missing file = empty.
339pub fn load_ref_state(dir: &Path) -> Result<Vec<RefState>, BridgeError> {
340    load_ref_state_file(dir, REFS_FILE)
341}
342
343/// Load per-ref IMPORT state (last-seen upstream tips). Kept separate
344/// from the export leases: in a fork-mode state dir both directions
345/// track the same ref names against different remotes.
346pub fn load_import_ref_state(dir: &Path) -> Result<Vec<RefState>, BridgeError> {
347    load_ref_state_file(dir, IMPORT_REFS_FILE)
348}
349
350fn load_ref_state_file(dir: &Path, file: &str) -> Result<Vec<RefState>, BridgeError> {
351    let path = dir.join(file);
352    let data = match std::fs::read(&path) {
353        Ok(d) => String::from_utf8_lossy(&d).into_owned(),
354        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
355        Err(e) => return Err(e.into()),
356    };
357    let mut out = Vec::new();
358    for line in data.lines() {
359        let mut parts = line.splitn(3, ' ');
360        let (Some(name), Some(b3), Some(s1)) = (parts.next(), parts.next(), parts.next()) else {
361            continue;
362        };
363        let (Ok(h), Some(id)) = (from_hex(b3), sha1_from_hex(s1)) else {
364            continue;
365        };
366        out.push(RefState {
367            ref_name: name.to_owned(),
368            mkit_hash: h,
369            git_id: id,
370        });
371    }
372    Ok(out)
373}
374
375/// Rewrite the whole export ref-state file atomically (temp + rename).
376pub fn store_ref_state(dir: &Path, states: &[RefState]) -> Result<(), BridgeError> {
377    store_ref_state_file(dir, REFS_FILE, states)
378}
379
380/// Rewrite the import ref-state file (see [`load_import_ref_state`]).
381pub fn store_import_ref_state(dir: &Path, states: &[RefState]) -> Result<(), BridgeError> {
382    store_ref_state_file(dir, IMPORT_REFS_FILE, states)
383}
384
385fn store_ref_state_file(dir: &Path, file: &str, states: &[RefState]) -> Result<(), BridgeError> {
386    std::fs::create_dir_all(dir)?;
387    let mut out = String::new();
388    for s in states {
389        out.push_str(&s.ref_name);
390        out.push(' ');
391        out.push_str(&to_hex(&s.mkit_hash));
392        out.push(' ');
393        out.push_str(&sha1_hex(&s.git_id));
394        out.push('\n');
395    }
396    // Per-target temp name: `refs` and `refs-import` rewrites must
397    // not race each other onto one temp path (fetch + export can run
398    // concurrently against a fork state dir).
399    let tmp = dir.join(format!(".{file}.tmp"));
400    {
401        use std::io::Write as _;
402        let mut f = std::fs::File::create(&tmp)?;
403        f.write_all(out.as_bytes())?;
404        // Content fsync before rename: this file is the lease /
405        // tracking source of truth, and a durable name over torn
406        // pages would be worse than the old file.
407        f.sync_all()?;
408    }
409    std::fs::rename(&tmp, dir.join(file))?;
410    if let Ok(d) = std::fs::File::open(dir) {
411        let _ = d.sync_all();
412    }
413    Ok(())
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419
420    #[test]
421    fn map_round_trips_and_tolerates_torn_tail() {
422        let dir = tempfile::tempdir().unwrap();
423        let pairs = vec![([1u8; 32], [2u8; 20]), ([3u8; 32], [4u8; 20])];
424        append_map(dir.path(), &pairs).unwrap();
425        // Simulate a torn append.
426        let mut f = std::fs::OpenOptions::new()
427            .append(true)
428            .open(dir.path().join(MAP_FILE))
429            .unwrap();
430        f.write_all(b"deadbeef").unwrap();
431        drop(f);
432        let map = load_map(dir.path()).unwrap();
433        assert_eq!(map.len(), 2);
434        assert_eq!(map[&[1u8; 32]], [2u8; 20]);
435    }
436
437    #[test]
438    fn map_intact_detection() {
439        let dir = tempfile::tempdir().unwrap();
440        // Missing file: intact (nothing to distrust).
441        assert!(map_is_intact(dir.path()).unwrap());
442        let pairs = vec![([1u8; 32], [2u8; 20]), ([3u8; 32], [4u8; 20])];
443        append_map(dir.path(), &pairs).unwrap();
444        assert!(map_is_intact(dir.path()).unwrap());
445        // Malformed line.
446        let good = std::fs::read_to_string(dir.path().join("map")).unwrap();
447        std::fs::write(dir.path().join("map"), format!("{good}GARBAGE\n")).unwrap();
448        assert!(!map_is_intact(dir.path()).unwrap());
449        // Internal blank line (a dropped record, not noise).
450        let lines: Vec<&str> = good.lines().collect();
451        std::fs::write(
452            dir.path().join("map"),
453            format!("{}\n\n{}\n", lines[0], lines[1]),
454        )
455        .unwrap();
456        assert!(!map_is_intact(dir.path()).unwrap());
457    }
458
459    #[test]
460    fn ref_state_round_trips() {
461        let dir = tempfile::tempdir().unwrap();
462        let states = vec![RefState {
463            ref_name: "refs/heads/main".into(),
464            mkit_hash: [7; 32],
465            git_id: [9; 20],
466        }];
467        store_ref_state(dir.path(), &states).unwrap();
468        assert_eq!(load_ref_state(dir.path()).unwrap(), states);
469    }
470
471    #[test]
472    fn state_dir_rejects_traversal() {
473        let layout = RepoLayout::single("/tmp");
474        let mkit = &layout;
475        assert!(state_dir(mkit, "origin").is_ok());
476        assert!(state_dir(mkit, "..").is_err());
477        assert!(state_dir(mkit, "a/b").is_err());
478        assert!(state_dir(mkit, "").is_err());
479    }
480
481    #[test]
482    fn state_dir_rejects_dots() {
483        // Aligned with `mkit-cli`'s `validate_remote_name`, which is
484        // dot-free on top of the mkit ref grammar: a dot anywhere in a
485        // bridge state name — not just the traversal shapes `.`/`..` —
486        // is rejected, so this entry point can't create a state dir
487        // that a `remote rename`-crash temp dir (`.rename.tmp.<pid>.0`)
488        // could ever collide with.
489        let layout = RepoLayout::single("/tmp");
490        let mkit = &layout;
491        assert!(state_dir(mkit, ".hidden").is_err());
492        assert!(state_dir(mkit, "a.b").is_err());
493        assert!(state_dir(mkit, "trailing.").is_err());
494    }
495}