Skip to main content

clawft_kernel/
project_identity_journal.rs

1//! The identity journal: an append-only, fsynced record of every
2//! certification, rekey and revocation, beside the manifests
3//! (`<manifests_dir>/identity.journal.jsonl`, mode 0600).
4//!
5//! The user chain is saved only on clean shutdown, so after a crash the
6//! chain can be missing events the daemon already acted on (a registered
7//! project would look unbound and a second key could be certified). Every
8//! operation therefore appends here first, then to the chain. Fail closed:
9//! a journal that exists but is unreadable, empty, or has a line that does
10//! not parse is an error, never "no records", and so is a journal that is
11//! missing while anything shows it should exist (the lock file, a
12//! certificate file, a `user.projects` chain event). Nothing is issued or
13//! verified until the owner runs the repair RPC (`project.identity.repair`),
14//! and [`IdentityJournal::append`] never touches a journal it could not
15//! parse. Readers take a shared `flock`, writers an exclusive one. Certificates in records are re-verified
16//! against the user key when the view is built, so forging a `register`
17//! line needs the user key; a forged `revoke` can only deny service.
18
19use 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
27/// Journal file name.
28pub const JOURNAL_FILE: &str = "identity.journal.jsonl";
29const LOCK_FILE: &str = "identity.lock";
30
31/// One journal line.
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)]
34pub enum JournalRecord {
35    /// A key was certified.
36    Register {
37        /// The certificate issued.
38        cert: ProjectCert,
39    },
40    /// A key was replaced: `old_key_id` revoked, `cert` is the new key's.
41    Rekey {
42        /// Revoked key id.
43        old_key_id: String,
44        /// The new certificate.
45        cert: ProjectCert,
46    },
47    /// Written once when the journal is created; carries no state.
48    Init {},
49    /// A key was revoked.
50    Revoke {
51        /// Project the key belonged to.
52        project_id: String,
53        /// Revoked key id.
54        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/// The journal in a manifests directory.
66#[derive(Debug, Clone)]
67pub struct IdentityJournal {
68    dir: PathBuf,
69}
70
71/// Exclusive lock over the journal for a read-modify-write. Held until
72/// dropped. `flock` locks the open file, so two threads of one process
73/// exclude each other as well as two processes. Appending needs one.
74#[derive(Debug)]
75pub struct JournalLock {
76    _file: std::fs::File,
77}
78
79/// A shared (read) lock; held while a view is built.
80#[derive(Debug)]
81pub struct SharedLock {
82    _file: Option<std::fs::File>,
83}
84
85impl IdentityJournal {
86    /// The journal under `manifests_dir`.
87    pub fn new(manifests_dir: &Path) -> Self {
88        Self {
89            dir: manifests_dir.to_path_buf(),
90        }
91    }
92
93    /// Journal file path.
94    pub fn path(&self) -> PathBuf {
95        self.dir.join(JOURNAL_FILE)
96    }
97
98    /// Does the journal file exist?
99    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    /// Take the exclusive lock (blocking). On a brand-new install (no lock
108    /// file and no journal yet) this also creates the journal with an
109    /// `init` record, so from then on a missing journal is an error.
110    pub fn lock(&self) -> Result<JournalLock, IdentityError> {
111        let fresh = !self.lock_path().exists() && !self.path().exists();
112        if fresh {
113            // Journal first, lock file second: a crash between the two
114            // leaves a journal without a lock file (harmless), never a lock
115            // file without a journal.
116            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    /// Take the exclusive lock if free; `None` when someone holds it.
127    pub fn try_lock(&self) -> Result<Option<JournalLock>, IdentityError> {
128        Ok(self.lock_inner(true, false)?.map(|f| JournalLock { _file: f }))
129    }
130
131    /// Take a shared lock for reading. Never creates the lock file (a
132    /// reader must not make an unused install look initialised).
133    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                // SAFETY: flock on a valid fd owned by `file`.
168                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    /// Raw bytes, `None` when the file does not exist. Opened `O_NOFOLLOW`.
183    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    /// Every record, oldest first (the `init` marker is not returned),
203    /// under a shared lock. `other_evidence` says whether anything else
204    /// proves the journal should exist (a certificate file or a
205    /// `user.projects` chain event). See [`Self::read_locked`].
206    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    /// [`Self::read`] for a caller already holding the exclusive lock.
212    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    /// Append `rec`, fsynced. Parses the existing journal first and refuses
240    /// (leaving it untouched) if it does not parse or does not end in a
241    /// newline.
242    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    /// Every record of the journal that parses, line by line, ignoring any
268    /// line that does not (a torn tail must not hide earlier records).
269    /// Unverified: the caller rebuilds a view, which re-verifies certificates.
270    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    /// Repair: move the current journal (if any) aside as
280    /// `identity.journal.jsonl.corrupt-<unix secs>` and write a new one from
281    /// `records`. Returns the path of the moved file.
282    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}