bynk_driver/schema_lock.rs
1//! Reading and atomically writing `bynk.schema.lock` — the driver's side of
2//! `CompileOptions::schema_registry` (#1078, R2.3/T0.7 residue).
3//!
4//! Ported verbatim from what used to be `bynk-emit/src/project/schema_registry.rs`'s
5//! `read`/`write` — same crash-safety discipline (temp file + `sync_all` +
6//! rename + best-effort directory fsync), same unchanged-content no-op. Only
7//! the location moved: `bynk-emit` now only ever sees pre-read content (via
8//! `SchemaLock::On`) and hands back serialized content to write (via
9//! `ProjectOutput::schema_lock`) — it touches no disk for this file, same as
10//! it no longer does for `.bynk` sources on the real CLI path (#1077/#1081).
11//!
12//! `bynk-emit`'s own `schema_registry::parse`/`serialize` are the pure
13//! counterparts this module's [`read`]/[`write()`] wrap disk I/O around.
14
15use std::io::{self, Write};
16use std::path::{Path, PathBuf};
17use std::sync::atomic::{AtomicU64, Ordering};
18
19const LOCK_FILE: &str = "bynk.schema.lock";
20
21/// Where the registry lives for a given project root — exposed so callers
22/// reporting a write failure can name the actual file, not just the root.
23pub fn lock_path(project_root: &Path) -> PathBuf {
24 project_root.join(LOCK_FILE)
25}
26
27/// Read `bynk.schema.lock`'s raw content from `project_root`, if it exists.
28/// `Ok(None)` means **verified absent** — no such file — which is the only
29/// thing `CompileOptions`' `SchemaLock::On { existing: None }` is allowed to
30/// mean (see that type's own doc). A present-but-unreadable file is a real
31/// `io::Error`, not folded into `None` — silently treating a permission
32/// error, a non-traversable ancestor, or any other stat/read failure as
33/// "fresh project" would risk re-baselining a real registry's history
34/// exactly as wrongly as never reading it at all.
35///
36/// #1085 review: a prior version checked `path.exists()` first and only then
37/// read — `exists()` maps *every* metadata error to `false`, not just
38/// "not found," so a non-`ENOENT` stat failure (or a dangling symlink, or a
39/// race with another process between the two calls) would have silently
40/// produced `Ok(None)` here. Reading directly and matching on
41/// `ErrorKind::NotFound` is both narrower (only the one error kind degrades)
42/// and race-free (one syscall, not two).
43pub fn read(project_root: &Path) -> io::Result<Option<String>> {
44 match std::fs::read_to_string(lock_path(project_root)) {
45 Ok(content) => Ok(Some(content)),
46 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
47 Err(e) => Err(e),
48 }
49}
50
51static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
52
53/// Write `bynk.schema.lock` to `project_root`, atomically (temp file +
54/// `sync_all` + rename + best-effort directory fsync, so a crash mid-write
55/// can only leave the intact old file or the intact new one — `ledger.rs`'s
56/// own discipline). A no-op when `content` is byte-identical to what is
57/// already on disk, so a clean rebuild never touches the file's mtime or
58/// produces a spurious `git diff`.
59pub fn write(project_root: &Path, content: &str) -> io::Result<()> {
60 let path = lock_path(project_root);
61 if let Ok(existing) = std::fs::read_to_string(&path)
62 && existing == content
63 {
64 return Ok(());
65 }
66
67 let dir = project_root;
68 let (tmp, mut file) = loop {
69 let n = TMP_COUNTER.fetch_add(1, Ordering::Relaxed);
70 let candidate = dir.join(format!(".{LOCK_FILE}.{}.{n}.tmp", std::process::id()));
71 match std::fs::OpenOptions::new()
72 .write(true)
73 .create_new(true)
74 .open(&candidate)
75 {
76 Ok(f) => break (candidate, f),
77 Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
78 Err(e) => return Err(e),
79 }
80 };
81
82 let write_then_sync = file
83 .write_all(content.as_bytes())
84 .and_then(|()| file.sync_all());
85 if let Err(e) = write_then_sync {
86 let _ = std::fs::remove_file(&tmp);
87 return Err(e);
88 }
89 drop(file);
90 if let Err(e) = std::fs::rename(&tmp, &path) {
91 let _ = std::fs::remove_file(&tmp);
92 return Err(e);
93 }
94 if let Ok(dir_handle) = std::fs::File::open(dir) {
95 let _ = dir_handle.sync_all();
96 }
97 Ok(())
98}
99
100#[cfg(test)]
101mod tests {
102 use super::*;
103
104 /// A throwaway on-disk directory, removed on drop (including on panic) —
105 /// mirrors this crate's other test modules' own `Scratch`.
106 struct Scratch(PathBuf);
107 impl Drop for Scratch {
108 fn drop(&mut self) {
109 let _ = std::fs::remove_dir_all(&self.0);
110 }
111 }
112
113 fn scratch_dir(tag: &str) -> Scratch {
114 let dir = std::env::temp_dir().join(format!(
115 "bynk_1078_schema_lock_{tag}_{}_{:?}",
116 std::process::id(),
117 std::thread::current().id()
118 ));
119 let _ = std::fs::remove_dir_all(&dir);
120 std::fs::create_dir_all(&dir).unwrap();
121 Scratch(dir)
122 }
123
124 #[test]
125 fn read_of_an_absent_file_is_verified_absent() {
126 let dir = scratch_dir("absent");
127 assert_eq!(read(&dir.0).unwrap(), None);
128 }
129
130 /// #1085 review: the property `SchemaLock`'s `On { existing }` exists to
131 /// protect — a present-but-unreadable lock file must be a real error, not
132 /// silently degrade to "verified absent" (which would re-baseline a real
133 /// registry's history). Unix-only: `chmod`-based permission denial has no
134 /// portable Windows equivalent, and this crate's other permission-style
135 /// tests are unix-gated the same way.
136 #[cfg(unix)]
137 #[test]
138 fn read_of_an_unreadable_present_file_is_a_real_error_not_absent() {
139 use std::os::unix::fs::PermissionsExt;
140
141 let dir = scratch_dir("unreadable");
142 std::fs::write(lock_path(&dir.0), "version = 1\n").unwrap();
143 std::fs::set_permissions(lock_path(&dir.0), std::fs::Permissions::from_mode(0o000))
144 .unwrap();
145
146 // Skip if the test runs as root (or another privileged context) where
147 // permission bits don't actually block the read.
148 if std::fs::read_to_string(lock_path(&dir.0)).is_ok() {
149 return;
150 }
151 assert!(
152 read(&dir.0).is_err(),
153 "an unreadable-but-present file must not be reported as absent"
154 );
155 }
156
157 #[test]
158 fn write_then_read_round_trips() {
159 let dir = scratch_dir("roundtrip");
160 let content = "version = 1\n";
161 write(&dir.0, content).unwrap();
162 assert_eq!(read(&dir.0).unwrap().as_deref(), Some(content));
163 }
164
165 #[test]
166 fn write_is_a_no_op_when_content_is_unchanged() {
167 let dir = scratch_dir("noop-write");
168 let content = "version = 1\n";
169 write(&dir.0, content).unwrap();
170 let mtime_before = std::fs::metadata(lock_path(&dir.0))
171 .unwrap()
172 .modified()
173 .unwrap();
174 std::thread::sleep(std::time::Duration::from_millis(10));
175 write(&dir.0, content).unwrap();
176 let mtime_after = std::fs::metadata(lock_path(&dir.0))
177 .unwrap()
178 .modified()
179 .unwrap();
180 assert_eq!(
181 mtime_before, mtime_after,
182 "unchanged content must not rewrite the file"
183 );
184 }
185}