1use 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
22pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum Direction {
55 Import,
56 Export,
57 Fork,
58}
59
60impl Direction {
61 #[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 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
107pub fn write_binding(dir: &Path, name: &str, value: &str) -> Result<(), BridgeError> {
110 write_stamp(dir, name, value)
111}
112
113pub 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 BridgeError::Source(format!(
122 "direction stamp is corrupt ({v:?}); refusing to guess — \
123 restore or remove the state dir"
124 ))
125 }),
126 }
127}
128
129pub 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
149pub 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 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
171pub 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
186pub fn mark_normalized(dir: &Path) -> Result<(), BridgeError> {
192 write_stamp(dir, "normalized", "1")
193}
194
195pub fn read_normalized(dir: &Path) -> Result<bool, BridgeError> {
197 Ok(read_stamp(dir, "normalized")?.is_some())
198}
199
200pub 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
213pub 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 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
244pub 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
269pub 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
276pub fn load_map(dir: &Path) -> Result<HashMap<Hash, Sha1Id>, BridgeError> {
279 let path = dir.join(MAP_FILE);
280 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
300pub 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 if let Ok(d) = std::fs::File::open(dir) {
324 let _ = d.sync_all();
325 }
326 Ok(())
327}
328
329#[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
338pub fn load_ref_state(dir: &Path) -> Result<Vec<RefState>, BridgeError> {
340 load_ref_state_file(dir, REFS_FILE)
341}
342
343pub 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
375pub fn store_ref_state(dir: &Path, states: &[RefState]) -> Result<(), BridgeError> {
377 store_ref_state_file(dir, REFS_FILE, states)
378}
379
380pub 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 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 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 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 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 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 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 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}