1use std::io::{Read as _, Write as _};
20use std::path::{Path, PathBuf};
21
22use clawft_types::project::cert::ProjectCert;
23use serde::{Deserialize, Serialize};
24
25use super::{IdentityError, write_private_atomic};
26
27pub const JOURNAL_FILE: &str = "identity.journal.jsonl";
29const LOCK_FILE: &str = "identity.lock";
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)]
34pub enum JournalRecord {
35 Register {
37 cert: ProjectCert,
39 },
40 Rekey {
42 old_key_id: String,
44 cert: ProjectCert,
46 },
47 Init {},
49 Revoke {
51 project_id: String,
53 key_id: String,
55 },
56}
57
58fn corrupt(path: &Path, why: impl Into<String>) -> IdentityError {
59 IdentityError::JournalCorrupt {
60 path: path.display().to_string(),
61 why: why.into(),
62 }
63}
64
65#[derive(Debug, Clone)]
67pub struct IdentityJournal {
68 dir: PathBuf,
69}
70
71#[derive(Debug)]
75pub struct JournalLock {
76 _file: std::fs::File,
77}
78
79#[derive(Debug)]
81pub struct SharedLock {
82 _file: Option<std::fs::File>,
83}
84
85impl IdentityJournal {
86 pub fn new(manifests_dir: &Path) -> Self {
88 Self {
89 dir: manifests_dir.to_path_buf(),
90 }
91 }
92
93 pub fn path(&self) -> PathBuf {
95 self.dir.join(JOURNAL_FILE)
96 }
97
98 pub fn exists(&self) -> bool {
100 self.path().exists()
101 }
102
103 fn lock_path(&self) -> PathBuf {
104 self.dir.join(LOCK_FILE)
105 }
106
107 pub fn lock(&self) -> Result<JournalLock, IdentityError> {
111 let fresh = !self.lock_path().exists() && !self.path().exists();
112 if fresh {
113 match write_private_atomic(&self.path(), &init_line(), true) {
117 Ok(()) | Err(IdentityError::Exists(_)) => {}
118 Err(e) => return Err(e),
119 }
120 }
121 self.lock_inner(false, false)?
122 .map(|f| JournalLock { _file: f })
123 .ok_or_else(|| IdentityError::Io(std::io::ErrorKind::WouldBlock.into()))
124 }
125
126 pub fn try_lock(&self) -> Result<Option<JournalLock>, IdentityError> {
128 Ok(self.lock_inner(true, false)?.map(|f| JournalLock { _file: f }))
129 }
130
131 pub fn lock_shared(&self) -> Result<SharedLock, IdentityError> {
134 Ok(SharedLock { _file: self.lock_inner(false, true)? })
135 }
136
137 fn lock_inner(&self, nonblocking: bool, shared: bool) -> Result<Option<std::fs::File>, IdentityError> {
138 let mut opts = std::fs::OpenOptions::new();
139 if shared {
140 opts.read(true);
141 match opts.open(self.lock_path()) {
142 Ok(f) => return Self::flock(f, nonblocking, true),
143 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
144 Err(e) => return Err(e.into()),
145 }
146 }
147 std::fs::create_dir_all(&self.dir)?;
148 opts.create(true).truncate(false).write(true);
149 #[cfg(unix)]
150 {
151 use std::os::unix::fs::OpenOptionsExt;
152 opts.mode(0o600);
153 }
154 Self::flock(opts.open(self.lock_path())?, nonblocking, false)
155 }
156
157 #[allow(unused_variables)]
158 fn flock(file: std::fs::File, nonblocking: bool, shared: bool) -> Result<Option<std::fs::File>, IdentityError> {
159 #[cfg(unix)]
160 {
161 use std::os::unix::io::AsRawFd;
162 let mut flags = if shared { libc::LOCK_SH } else { libc::LOCK_EX };
163 if nonblocking {
164 flags |= libc::LOCK_NB;
165 }
166 loop {
167 if unsafe { libc::flock(file.as_raw_fd(), flags) } == 0 {
169 break;
170 }
171 let err = std::io::Error::last_os_error();
172 match err.kind() {
173 std::io::ErrorKind::Interrupted => {}
174 std::io::ErrorKind::WouldBlock if nonblocking => return Ok(None),
175 _ => return Err(err.into()),
176 }
177 }
178 }
179 Ok(Some(file))
180 }
181
182 fn read_bytes(&self) -> Result<Option<Vec<u8>>, IdentityError> {
184 let path = self.path();
185 let mut opts = std::fs::OpenOptions::new();
186 opts.read(true);
187 #[cfg(unix)]
188 {
189 use std::os::unix::fs::OpenOptionsExt;
190 opts.custom_flags(libc::O_NOFOLLOW);
191 }
192 let mut f = match opts.open(&path) {
193 Ok(f) => f,
194 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
195 Err(e) => return Err(corrupt(&path, format!("unreadable: {e}"))),
196 };
197 let mut b = Vec::new();
198 f.read_to_end(&mut b).map_err(|e| corrupt(&path, format!("unreadable: {e}")))?;
199 Ok(Some(b))
200 }
201
202 pub fn read(&self, other_evidence: bool) -> Result<Vec<JournalRecord>, IdentityError> {
207 let _g = self.lock_shared()?;
208 self.parse(other_evidence)
209 }
210
211 pub fn read_locked(&self, _lock: &JournalLock, other_evidence: bool) -> Result<Vec<JournalRecord>, IdentityError> {
213 self.parse(other_evidence)
214 }
215
216 fn parse(&self, other_evidence: bool) -> Result<Vec<JournalRecord>, IdentityError> {
217 let path = self.path();
218 let Some(bytes) = self.read_bytes()? else {
219 if other_evidence || self.lock_path().exists() {
220 return Err(corrupt(&path, "is missing although this install has used it"));
221 }
222 return Ok(Vec::new());
223 };
224 if bytes.is_empty() {
225 return Err(corrupt(&path, "exists but is empty"));
226 }
227 let text = std::str::from_utf8(&bytes).map_err(|_| corrupt(&path, "is not UTF-8"))?;
228 let body = text.strip_suffix('\n').unwrap_or(text);
229 let recs: Result<Vec<JournalRecord>, _> = body
230 .split('\n')
231 .enumerate()
232 .map(|(i, line)| {
233 serde_json::from_str(line).map_err(|e| corrupt(&path, format!("line {}: {e}", i + 1)))
234 })
235 .collect();
236 Ok(recs?.into_iter().filter(|r| !matches!(r, JournalRecord::Init {})).collect())
237 }
238
239 pub fn append(&self, lock: &JournalLock, rec: &JournalRecord) -> Result<(), IdentityError> {
243 let path = self.path();
244 self.read_locked(lock, false)?;
245 let mut line = serde_json::to_vec(rec).map_err(|e| corrupt(&path, e.to_string()))?;
246 line.push(b'\n');
247 match self.read_bytes()? {
248 None => return write_private_atomic(&path, &line, true),
249 Some(b) if b.last() != Some(&b'\n') => {
250 return Err(corrupt(&path, "does not end in a newline (torn write)"));
251 }
252 Some(_) => {}
253 }
254 let mut opts = std::fs::OpenOptions::new();
255 opts.append(true);
256 #[cfg(unix)]
257 {
258 use std::os::unix::fs::OpenOptionsExt;
259 opts.custom_flags(libc::O_NOFOLLOW);
260 }
261 let mut f = opts.open(&path)?;
262 f.write_all(&line)?;
263 f.sync_all()?;
264 Ok(())
265 }
266
267 pub fn salvage(&self, _lock: &JournalLock) -> Vec<JournalRecord> {
271 let Ok(Some(bytes)) = self.read_bytes() else { return Vec::new() };
272 String::from_utf8_lossy(&bytes)
273 .lines()
274 .filter_map(|l| serde_json::from_str::<JournalRecord>(l).ok())
275 .filter(|r| !matches!(r, JournalRecord::Init {}))
276 .collect()
277 }
278
279 pub fn replace(&self, _lock: &JournalLock, records: &[JournalRecord]) -> Result<Option<PathBuf>, IdentityError> {
283 let path = self.path();
284 let mut moved = None;
285 if path.exists() {
286 let secs = std::time::SystemTime::now()
287 .duration_since(std::time::UNIX_EPOCH)
288 .map_or(0, |d| d.as_secs());
289 let to = self.dir.join(format!("{JOURNAL_FILE}.corrupt-{secs}"));
290 std::fs::rename(&path, &to)?;
291 moved = Some(to);
292 }
293 let mut bytes = init_line();
294 for r in records {
295 bytes.extend(serde_json::to_vec(r).map_err(|e| corrupt(&path, e.to_string()))?);
296 bytes.push(b'\n');
297 }
298 write_private_atomic(&path, &bytes, true)?;
299 Ok(moved)
300 }
301}
302
303fn init_line() -> Vec<u8> {
304 let mut v = serde_json::to_vec(&JournalRecord::Init {}).unwrap_or_default();
305 v.push(b'\n');
306 v
307}