Skip to main content

gunnar_core/
arrow_log.rs

1//! Secrets as an **Arrow IPC log**: there is no database.
2//!
3//! `ArrowLogSecretStore` is the live half of this module: it stores SSH host keys
4//! and bootstrap material in `secrets.arrow`, with append durability and immediate
5//! compaction of superseded values so rotated secrets do not remain on disk.
6//!
7//! The old Arrow reference store has been retired. Reference storage now lives in
8//! the znippy ref log; this module keeps only the shared Arrow framing/durability
9//! machinery and the secret store. The reference schema constants remain because
10//! `crate::history` can still read/seal legacy Arrow reflog history.
11
12use std::collections::BTreeMap;
13use std::fs::OpenOptions;
14use std::io::{Read, Seek, SeekFrom, Write};
15use std::path::{Path, PathBuf};
16use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
17use std::sync::{Arc, Mutex, RwLock};
18use std::time::{SystemTime, UNIX_EPOCH};
19
20use arrow_array::builder::{BinaryBuilder, Int64Builder, StringBuilder};
21use arrow_array::{Array, BinaryArray, RecordBatch, StringArray};
22use arrow_ipc::reader::StreamReader;
23use arrow_ipc::writer::{
24    write_message, DictionaryTracker, IpcDataGenerator, IpcWriteContext, IpcWriteOptions,
25};
26use arrow_schema::{DataType, Field, Schema, SchemaRef};
27
28use gunnar_store::{Error, Result, SecretStore};
29
30/// The Arrow IPC stream holding this repository's reference log.
31pub const REFS_LOG: &str = "refs.arrow";
32/// The Arrow IPC stream holding this repository's secrets.
33pub const SECRETS_LOG: &str = "secrets.arrow";
34/// The `O_EXCL` lock file that refuses a second writer.
35pub const LOCK_FILE: &str = "gunnar-store.lock";
36/// Where compaction puts the reflog history it takes out of the live log.
37///
38/// One complete, self-contained Arrow IPC stream per compaction, named so that
39/// a lexical sort is chronological. Never read at open.
40pub const REFS_ARCHIVE_DIR: &str = "refs-archive";
41
42/// The actor recorded on the snapshot row a compaction writes.
43///
44/// It is not a ref move, and a reflog reader must be able to tell the two apart:
45/// a snapshot row has a null `old_oid` *and* a null `old_symref` for a ref that
46/// certainly had a previous value.
47pub const COMPACTION_ACTOR: &str = "gunnar:compaction";
48
49/// The actor recorded on a log row when none was supplied.
50const DEFAULT_ACTOR: &str = "gunnar";
51
52/// Every Arrow IPC message begins with this, followed by a 4-byte length.
53///
54/// Named here rather than imported because `arrow-ipc` keeps it private. It is a
55/// constant of the *format*, not of the crate, and the tests below read real
56/// bytes produced by `arrow-ipc` to prove the two agree.
57const CONTINUATION_MARKER: [u8; 4] = [0xff; 4];
58
59pub(crate) const COL_REFNAME: &str = "refname";
60pub(crate) const COL_OLD_OID: &str = "old_oid";
61pub(crate) const COL_NEW_OID: &str = "new_oid";
62pub(crate) const COL_OLD_SYMREF: &str = "old_symref";
63pub(crate) const COL_NEW_SYMREF: &str = "new_symref";
64pub(crate) const COL_ACTOR: &str = "actor";
65pub(crate) const COL_AT: &str = "at_unix_ms";
66const COL_KEY: &str = "key";
67const COL_VALUE: &str = "value";
68
69// ───────────────────────────── compaction policy ─────────────────────────────
70
71/// When the secret log folds itself automatically.
72///
73/// `reflog_window` remains in the public constructor for compatibility with old
74/// callers; secrets always compact to the current values only.
75#[derive(Clone, Copy, Debug, PartialEq, Eq)]
76pub struct CompactionPolicy {
77    /// Compact once the live log holds this many framed batches.
78    ///
79    /// Checked on the secret write path. `0` disables threshold-based automatic
80    /// compaction; replacements are still compacted immediately.
81    pub after_batches: usize,
82    /// Ignored by the secret store; retained for API compatibility with callers
83    /// that still construct the old reference-log policy shape.
84    pub reflog_window: usize,
85}
86
87impl CompactionPolicy {
88    /// A policy with the given trigger and window.
89    pub fn new(after_batches: usize, reflog_window: usize) -> Self {
90        CompactionPolicy {
91            after_batches,
92            reflog_window,
93        }
94    }
95
96    /// Never compact automatically: the pre-compaction behaviour, kept because
97    /// it is what the growth guard measures against.
98    pub fn never() -> Self {
99        CompactionPolicy {
100            after_batches: 0,
101            reflog_window: 0,
102        }
103    }
104
105    fn should_compact(&self, batches: usize) -> bool {
106        self.after_batches > 0 && batches >= self.after_batches
107    }
108}
109
110impl Default for CompactionPolicy {
111    /// 1024 secret writes between threshold compactions.
112    fn default() -> Self {
113        CompactionPolicy {
114            after_batches: 1024,
115            reflog_window: 256,
116        }
117    }
118}
119
120// ───────────────────────────── schemas ───────────────────────────────────────
121
122/// The reference log's schema for a repository of `hash_kind`.
123///
124/// The oid width is what pins the hash kind on disk: reopening a sha256 log as
125/// sha1 finds a `FixedSizeBinary(32)` where it wanted `FixedSizeBinary(20)` and
126/// fails with [`Error::HashKindMismatch`] rather than mixing two repositories.
127pub(crate) fn ref_schema(hash_kind: gix_hash::Kind) -> SchemaRef {
128    let width = hash_kind.len_in_bytes() as i32;
129    Arc::new(Schema::new(vec![
130        Field::new(COL_REFNAME, DataType::Utf8, false),
131        Field::new(COL_OLD_OID, DataType::FixedSizeBinary(width), true),
132        Field::new(COL_NEW_OID, DataType::FixedSizeBinary(width), true),
133        Field::new(COL_OLD_SYMREF, DataType::Utf8, true),
134        Field::new(COL_NEW_SYMREF, DataType::Utf8, true),
135        Field::new(COL_ACTOR, DataType::Utf8, false),
136        Field::new(COL_AT, DataType::Int64, false),
137    ]))
138}
139
140/// The secret log's schema. A null `value` is a delete.
141fn secret_schema() -> SchemaRef {
142    Arc::new(Schema::new(vec![
143        Field::new(COL_KEY, DataType::Utf8, false),
144        Field::new(COL_VALUE, DataType::Binary, true),
145        Field::new(COL_ACTOR, DataType::Utf8, false),
146        Field::new(COL_AT, DataType::Int64, false),
147    ]))
148}
149
150fn hash_kind_of_width(width: i32) -> Option<gix_hash::Kind> {
151    match width {
152        20 => Some(gix_hash::Kind::Sha1),
153        32 => Some(gix_hash::Kind::Sha256),
154        _ => None,
155    }
156}
157
158/// Reject a stored schema that is not the one we are about to append to.
159///
160/// A differing oid width is reported as [`Error::HashKindMismatch`] rather than
161/// as a generic schema error, because that is the case an operator can act on.
162pub(crate) fn check_schema(stored: &Schema, expected: &Schema) -> Result<()> {
163    if stored.fields() == expected.fields() {
164        return Ok(());
165    }
166    let width_of =
167        |schema: &Schema, name: &str| match schema.field_with_name(name).map(|f| f.data_type()) {
168            Ok(DataType::FixedSizeBinary(width)) => Some(*width),
169            _ => None,
170        };
171    if let (Some(found), Some(want)) = (
172        width_of(stored, COL_NEW_OID),
173        width_of(expected, COL_NEW_OID),
174    ) {
175        if found != want {
176            if let (Some(stored_kind), Some(want_kind)) =
177                (hash_kind_of_width(found), hash_kind_of_width(want))
178            {
179                return Err(Error::HashKindMismatch {
180                    stored: stored_kind,
181                    requested: want_kind,
182                });
183            }
184        }
185    }
186    Err(Error::backend(format!(
187        "the log on disk has schema {stored:?}, which is not the schema this store writes ({expected:?})"
188    )))
189}
190
191// ───────────────────────────── framing ───────────────────────────────────────
192
193/// How many bytes of `log` are complete, framed Arrow IPC messages.
194///
195/// This is the torn-write recovery point of the Arrow-IPC ref log and the only thing between
196/// a half-written push and a log that reads as empty forever. Everything from
197/// the returned offset onwards is a message that was never finished, and is
198/// **truncated away** at open, not skipped, because an append after garbage is
199/// unreachable by any reader.
200///
201/// A zero metadata length is the stream's end-of-stream marker. This backend
202/// never writes one (it would make every later append unreachable), but a
203/// compacted or externally-produced log may carry one, so the scan stops
204/// *before* it and a subsequent append overwrites it.
205pub(crate) fn valid_prefix_len(log: &[u8]) -> usize {
206    let mut pos = 0usize;
207    while let Some(total) = message_len(&log[pos..]) {
208        pos += total;
209    }
210    pos
211}
212
213/// The on-disk length of the **one** complete framed message `rest` starts
214/// with, or `None` when it does not start with one.
215///
216/// Split out of [`valid_prefix_len`] rather than copied, because
217/// [`crate::history::live_mark`] needs the length of the *first* message — the
218/// schema — and a second walk of the same framing would be a second definition
219/// of what a complete message is (one implementation). The `while let` above is the whole of
220/// `valid_prefix_len` now, so a change here changes both.
221pub(crate) fn message_len(rest: &[u8]) -> Option<usize> {
222    if rest.len() < 8 || rest[..4] != CONTINUATION_MARKER {
223        return None;
224    }
225    let meta_len = i32::from_le_bytes([rest[4], rest[5], rest[6], rest[7]]);
226    if meta_len <= 0 {
227        return None;
228    }
229    let meta_len = meta_len as usize;
230    if rest.len() < 8 + meta_len {
231        return None;
232    }
233    // Full flatbuffer verification: random bytes that happen to start with a
234    // continuation marker do not get past this.
235    let message = arrow_ipc::root_as_message(&rest[8..8 + meta_len]).ok()?;
236    let body_len = usize::try_from(message.bodyLength()).ok()?;
237    // `write_message` refuses to emit a body that is not already a multiple
238    // of the write alignment, so the recorded body length is exactly what is
239    // on disk. There is no trailing pad to account for.
240    let total = 8usize.checked_add(meta_len)?.checked_add(body_len)?;
241    if rest.len() < total {
242        return None;
243    }
244    Some(total)
245}
246
247// ───────────────────────────── backing store ─────────────────────────────────
248
249/// Where a log's bytes actually live.
250///
251/// The file variant is the normal one. The memory variant is the no-OS shape: no
252/// filesystem, no `libc`, the log is just bytes. It runs the **same**
253/// encode/frame/replay code, so the conformance run against it is evidence about
254/// this backend rather than about a second implementation.
255enum Backing {
256    File { file: std::fs::File, path: PathBuf },
257    Memory { bytes: Arc<Mutex<Vec<u8>>> },
258}
259
260fn poisoned(what: &'static str) -> Error {
261    Error::backend(format!(
262        "the {what} lock was poisoned by a panicking writer"
263    ))
264}
265
266/// The scratch file a compaction writes before the rename. Named beside the
267/// live log so the rename cannot cross a filesystem boundary and stop being
268/// atomic.
269fn compacting_path(path: &Path) -> PathBuf {
270    let name = path
271        .file_name()
272        .map(|n| n.to_string_lossy().into_owned())
273        .unwrap_or_else(|| "log".to_owned());
274    path.with_file_name(format!("{name}.compacting"))
275}
276
277/// A rename is only durable once the *directory entry* is on the platter.
278pub(crate) fn sync_parent_dir(path: &Path) -> Result<()> {
279    let parent = match path.parent() {
280        Some(parent) if !parent.as_os_str().is_empty() => parent.to_path_buf(),
281        _ => PathBuf::from("."),
282    };
283    std::fs::File::open(parent)?.sync_all()?;
284    Ok(())
285}
286
287/// How every message in this log is framed.
288///
289/// **8-byte buffer alignment, not `arrow-ipc`'s default 64.** This is the single
290/// largest lever on the size of the log, and the log's size *is* its open cost:
291/// the whole live stream is read and replayed at every open.
292///
293/// A ref transaction is a **one-row** batch of seven columns. Arrow IPC pads
294/// every buffer up to the write alignment, so those rows — 32 bytes of oid and a
295/// short name — are surrounded by padding that is an order of magnitude larger
296/// than the data. At 64 bytes the padding is paid on each of the batch's
297/// buffers plus the metadata; at 8 bytes, which is the Arrow IPC format's own
298/// stated minimum, it is paid eight times more cheaply.
299///
300/// MEASURED 2026-08-04, 4000 single-ref transactions against a
301/// never-compacting log on md1 (xfs, RAID0 NVMe), **four interleaved A/B pairs**
302/// from two binaries differing only in this function, loadavg 2.6–7.0
303/// throughout. Medians:
304///
305/// | alignment | bytes/push | live log at 4001 pushes | durable pushes/s | µs/push | reopen |
306/// |---|---|---|---|---|---|
307/// | 64 (arrow's default) | 1 536.1 | 6 145 984 B | 15 615 | 64.05 | 20.50 ms |
308/// | **8** | **680.1** | **2 721 128 B** | **17 450** | **57.31** | **16.72 ms** |
309/// | | **2.258× smaller** | 2.258× | **1.118×** | 1.118× | **1.226× faster** |
310///
311/// Every one of the four pairs favoured 8 on every metric; the byte figures are
312/// exact and load-independent.
313///
314/// **The throughput win is a consequence of the size win, not a separate
315/// effect.** `fsync`'s share of the wall clock is 0.830–0.838 *in both arms* —
316/// the sync did not get relatively cheaper, there were simply 2.26× fewer bytes
317/// for it to force through the XFS log. That also explains why the one thing
318/// tried before this — `fsync` → `fdatasync` — measured **no change at all**
319/// (16 252 → 16 449 pushes/s, inside the run-to-run spread): on XFS an append
320/// commits the size update through the log either way, so the timestamp
321/// metadata `fdatasync` skips was never what was being paid for. Recorded so it
322/// is not tried a third time.
323///
324/// # Why this is safe to change
325///
326/// 64 is a *recommendation* in the Arrow IPC specification, for the benefit of
327/// readers that want to hand a buffer straight to a SIMD kernel; 8 is the
328/// specification's requirement. Every message on disk carries its own
329/// `bodyLength`, so a stream written at 64 and appended to at 8 stays readable
330/// message by message and **no migration is needed** — which
331/// `an_existing_64_byte_aligned_log_is_still_read_after_the_alignment_changed`
332/// asserts against real bytes rather than by argument.
333///
334/// What is given up is the guarantee that a mapped body's buffers land on
335/// 64-byte boundaries. Nothing in gunnar maps this log: it is read with
336/// `StreamReader` over a `Cursor`, which copies into freshly-allocated,
337/// suitably-aligned Arrow buffers regardless. An external reader (the module
338/// docs promise DuckDB can read this file) is served by the format guarantee,
339/// not by the recommendation.
340pub(crate) fn write_options() -> IpcWriteOptions {
341    // 8 is one of the four values `IpcWriteOptions::try_new` accepts, so this
342    // cannot fail; `expect` rather than a fallback to the default, because a
343    // silent fallback to 64 would make this whole function unobservable.
344    IpcWriteOptions::try_new(8, false, arrow_ipc::MetadataVersion::V5)
345        .expect("8 is a legal Arrow IPC write alignment")
346}
347
348/// Encode `batches` as a complete, self-contained Arrow IPC stream.
349///
350/// Used both for the rewritten live log and for an archive segment. A fresh
351/// generator and dictionary tracker each time, because this is a **new** stream:
352/// carrying the old stream's tracker over would emit deltas against dictionaries
353/// the new stream never wrote.
354pub(crate) fn encode_stream(
355    schema: &SchemaRef,
356    batches: &[RecordBatch],
357    options: &IpcWriteOptions,
358) -> Result<Vec<u8>> {
359    let generator = IpcDataGenerator::default();
360    let mut tracker = DictionaryTracker::new(false);
361    let mut out = Vec::new();
362    let encoded = generator.schema_to_bytes_with_dictionary_tracker(schema, &mut tracker, options);
363    write_message(&mut out, encoded, options).map_err(arrow_err)?;
364    for batch in batches {
365        let (dictionaries, message) = generator
366            .encode(
367                batch,
368                &mut tracker,
369                options,
370                &mut IpcWriteContext::default(),
371            )
372            .map_err(arrow_err)?;
373        for dictionary in dictionaries {
374            write_message(&mut out, dictionary, options).map_err(arrow_err)?;
375        }
376        write_message(&mut out, message, options).map_err(arrow_err)?;
377    }
378    Ok(out)
379}
380
381impl Backing {
382    fn read_all(&self) -> Result<Vec<u8>> {
383        match self {
384            Backing::File { file, .. } => {
385                let mut handle = file;
386                handle.seek(SeekFrom::Start(0))?;
387                let mut out = Vec::new();
388                handle.read_to_end(&mut out)?;
389                Ok(out)
390            }
391            Backing::Memory { bytes } => {
392                Ok(bytes.lock().map_err(|_| poisoned("in-memory log"))?.clone())
393            }
394        }
395    }
396
397    fn truncate_to(&self, len: u64) -> Result<()> {
398        match self {
399            Backing::File { file, .. } => Ok(file.set_len(len)?),
400            Backing::Memory { bytes } => {
401                bytes
402                    .lock()
403                    .map_err(|_| poisoned("in-memory log"))?
404                    .truncate(len as usize);
405                Ok(())
406            }
407        }
408    }
409
410    fn append(&self, buf: &[u8]) -> Result<()> {
411        match self {
412            Backing::File { file, .. } => {
413                let mut handle = file;
414                handle.seek(SeekFrom::End(0))?;
415                handle.write_all(buf)?;
416                handle.flush()?;
417                Ok(())
418            }
419            Backing::Memory { bytes } => {
420                bytes
421                    .lock()
422                    .map_err(|_| poisoned("in-memory log"))?
423                    .extend_from_slice(buf);
424                Ok(())
425            }
426        }
427    }
428
429    /// Make everything written so far durable.
430    ///
431    /// For the memory backing there is no volatile/durable distinction, so this
432    /// is a no-op that still counts. See the note on
433    /// [`ArrowLogStore::in_memory`] about which claims that shape can support.
434    fn sync(&self) -> Result<()> {
435        match self {
436            Backing::File { file, .. } => Ok(file.sync_all()?),
437            Backing::Memory { .. } => Ok(()),
438        }
439    }
440
441    /// Replace every byte of this log with `bytes`, atomically.
442    ///
443    /// **Never mutates in place**: a new file is written beside the
444    /// live one, fsynced, and *then* renamed over it, and the rename is followed
445    /// by an fsync of the directory so the new name is durable too. A crash
446    /// before the rename leaves the old log complete and serving; a crash after
447    /// it leaves the new one complete. There is no third outcome, which is why
448    /// an interrupted compaction is simply retried.
449    fn replace_all(&mut self, bytes: &[u8], fsync: bool) -> Result<()> {
450        self.stage(bytes, fsync)?;
451        self.swap(bytes, fsync)
452    }
453
454    /// Write the replacement beside the live log and make it durable, without
455    /// touching the live log. This is the half a crash can interrupt.
456    ///
457    /// Split out from [`Backing::swap`] so the crash test can stop here and
458    /// assert what an interrupted compaction leaves behind, using the
459    /// production staging code, not a re-implementation of it.
460    fn stage(&self, bytes: &[u8], fsync: bool) -> Result<()> {
461        let Backing::File { path, .. } = self else {
462            return Ok(());
463        };
464        let temporary = compacting_path(path);
465        let mut fresh = OpenOptions::new()
466            .write(true)
467            .create(true)
468            .truncate(true)
469            .open(&temporary)?;
470        fresh.write_all(bytes)?;
471        fresh.flush()?;
472        if fsync {
473            fresh.sync_all()?;
474        }
475        Ok(())
476    }
477
478    /// Put the staged replacement in place. A rename, which is atomic on a real
479    /// filesystem, followed by an fsync of the directory entry.
480    fn swap(&mut self, bytes: &[u8], fsync: bool) -> Result<()> {
481        match self {
482            Backing::File { file, path } => {
483                std::fs::rename(compacting_path(path), &*path)?;
484                if fsync {
485                    sync_parent_dir(path)?;
486                }
487                // The old descriptor now points at an unlinked inode.
488                *file = OpenOptions::new()
489                    .read(true)
490                    .write(true)
491                    .create(true)
492                    .truncate(false)
493                    .open(&*path)?;
494                Ok(())
495            }
496            Backing::Memory { bytes: cell } => {
497                let mut held = cell.lock().map_err(|_| poisoned("in-memory log"))?;
498                held.clear();
499                held.extend_from_slice(bytes);
500                Ok(())
501            }
502        }
503    }
504}
505
506impl std::fmt::Debug for Backing {
507    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
508        match self {
509            Backing::File { path, .. } => f.debug_struct("File").field("path", path).finish(),
510            Backing::Memory { .. } => f.write_str("Memory"),
511        }
512    }
513}
514
515// ───────────────────────────── the log ───────────────────────────────────────
516
517/// What the durability tests read.
518///
519/// `written` counts every byte handed to the backing store; `durable` counts the
520/// bytes covered by a *completed* sync. A transaction that returns `Ok` with
521/// `durable < written` has acknowledged a push that a power cut would lose.
522///
523/// `written_seq` and `durable_seq` are the same two facts counted in *commits*
524/// rather than bytes, and they exist because [`SyncGate`] needs a watermark that
525/// **never goes backwards**. A byte offset does: a compaction rewrites the log
526/// shorter, so a waiter holding a pre-compaction byte target would wait for an
527/// offset the file will never reach again. A commit count only ever rises.
528#[derive(Debug, Default)]
529struct Durability {
530    written: AtomicU64,
531    durable: AtomicU64,
532    /// Commits handed to the kernel.
533    written_seq: AtomicU64,
534    /// Commits covered by a *completed* sync.
535    durable_seq: AtomicU64,
536    syncs: AtomicUsize,
537    /// Time spent inside `fsync`: the ceiling a ref transaction pays, made visible.
538    fsync_nanos: AtomicU64,
539}
540
541/// The descriptor a durability barrier is issued on, held **apart from the log's
542/// own** so that one thread can `fsync` while another appends. This is group
543/// commit, and it is the whole reason the two are separated.
544///
545/// # Why the fleet was paying N syncs for N pushes
546///
547/// A ref transaction is `lock · validate · encode · write · fsync · unlock`, and
548/// the `fsync` is 83% of it (MEASURED 2026-08-04, md1). With the sync
549/// inside the writer mutex, thirty-two concurrent pushers queue thirty-two
550/// syncs end to end and the log's total throughput does not move:
551///
552/// | concurrent writers | total durable pushes/s | aggregate lock wait |
553/// |---|---|---|
554/// | 1 | 13 038 | 0.000 s |
555/// | 8 | 14 505 | 2.224 s |
556/// | 32 | 14 907 | **9.934 s** against 0.550 s of wall clock |
557///
558/// Flat throughput and lock wait rising linearly with the writer count is the
559/// signature of a serialised barrier, not of a busy disk.
560///
561/// # What it costs now
562///
563/// **Five interleaved A/B pairs** from two binaries differing only in this
564/// change, md1, loadavg matched within each pair (11–26 across the set).
565/// Medians of total durable transactions per second:
566///
567/// | concurrent writers | before | after | |
568/// |---|---|---|---|
569/// | 1 | 14 387 | 14 836 | 1.03× — nothing to group, and nothing lost |
570/// | 2 | 15 901 | 15 964 | 1.00× |
571/// | 4 | 15 324 | 26 355 | **1.72×** |
572/// | 8 | 15 950 | 37 213 | **2.33×** |
573/// | 16 | 15 700 | 40 648 | **2.59×** |
574/// | 32 | 15 070 | 42 117 | **2.79×** |
575///
576/// Aggregate lock wait at 32 writers went **8.5–10.5 s → 0.27–0.66 s**, about
577/// 19×, which is the mechanism showing through: the pushers stopped queueing
578/// behind one another's `fsync`.
579///
580/// The single-writer row is the one that had to not move, and it did not. Group
581/// commit is a **concurrency** lever; it makes a lone pusher neither faster nor
582/// slower, and a change that had quietly taxed the serial path would show here.
583/// The 32-writer column is the widest-spread one (21 236 to 50 873 across the
584/// five runs) because the box was carrying loadavg 22–26 while it ran; the
585/// median is quoted rather than the best, and the *shape* — flat before, rising
586/// after — is what the claim rests on.
587///
588/// # What this does instead
589///
590/// The descriptor here is a `dup` of the log's — the same open file
591/// description and the same inode, so an `fsync` issued on it flushes bytes
592/// another thread's `write` put there through the other descriptor. A committer
593/// therefore:
594///
595/// 1. appends its frame under the writer mutex and takes a commit sequence;
596/// 2. **releases the writer mutex**, so the next pusher can already be writing;
597/// 3. asks this gate to make that sequence durable.
598///
599/// Step 3 is leader/follower. Whoever gets here first reads `written_seq`
600/// *before* syncing and publishes it as `durable_seq` after — so the one sync it
601/// performs is credited with every commit that had already reached the kernel
602/// when it started, which is exactly the set that `fsync` is guaranteed to
603/// cover. Everyone whose sequence is at or below that watermark returns without
604/// syncing at all. N pushers cost the number of *rounds*, not N.
605///
606/// # The one property that changed, stated plainly
607///
608/// The acknowledgement barrier is unchanged: `transaction` still does not return
609/// `Ok` until this gate has made that transaction's bytes durable, and
610/// `a_transaction_is_durable_before_it_returns_ok` still asserts it against the
611/// file's own length.
612///
613/// What did change is **reader visibility**. The in-memory ref map is published
614/// under the writer mutex, which is now released before the sync, so for the
615/// duration of one barrier a concurrent *reader* can observe a ref move whose
616/// bytes are not yet on the platter. Nobody has been promised anything — the
617/// pusher has not been acknowledged — but a fetch served inside that window and
618/// followed by a power cut would have handed out a ref the log no longer names.
619/// This is the ordinary read-uncommitted trade a group-committing log makes, and
620/// it is written down here rather than discovered later.
621struct SyncGate {
622    /// `None` for the in-memory backing, where there is no volatile/durable
623    /// distinction to enforce and the gate is a counter.
624    file: Mutex<Option<std::fs::File>>,
625}
626
627impl SyncGate {
628    fn detached() -> Self {
629        SyncGate {
630            file: Mutex::new(None),
631        }
632    }
633
634    fn on(backing: &Backing) -> Result<Self> {
635        Ok(SyncGate {
636            file: Mutex::new(match backing {
637                Backing::File { file, .. } => Some(file.try_clone()?),
638                Backing::Memory { .. } => None,
639            }),
640        })
641    }
642
643    /// Point the gate at the log's *current* descriptor.
644    ///
645    /// A compaction renames a replacement over the live log and reopens it, so
646    /// the descriptor this gate holds would otherwise keep referring to the old,
647    /// now-unlinked inode and every later `fsync` would flush a file nothing
648    /// reads. Called from [`Log::rewrite`], under the writer mutex.
649    fn rebind(&self, backing: &Backing) -> Result<()> {
650        let mut held = self.file.lock().map_err(|_| poisoned("log sync gate"))?;
651        *held = match backing {
652            Backing::File { file, .. } => Some(file.try_clone()?),
653            Backing::Memory { .. } => None,
654        };
655        Ok(())
656    }
657
658    /// Return once commit `target` is durable, doing as little work as the rest
659    /// of the fleet leaves to do.
660    fn barrier(&self, durability: &Durability, target: u64, fsync: bool) -> Result<()> {
661        if !fsync {
662            return Ok(());
663        }
664        // The common case under load: a leader already covered us and we never
665        // touch the mutex at all.
666        if durability.durable_seq.load(Ordering::SeqCst) >= target {
667            return Ok(());
668        }
669        let held = self.file.lock().map_err(|_| poisoned("log sync gate"))?;
670        // Re-checked after queueing, because the leader we queued behind was
671        // very likely syncing on our behalf. This is the line that turns N
672        // syncs into one.
673        if durability.durable_seq.load(Ordering::SeqCst) >= target {
674            return Ok(());
675        }
676        let Some(file) = held.as_ref() else {
677            return Ok(());
678        };
679        // Read BEFORE the sync, published AFTER it. An `fsync` is only
680        // guaranteed to cover writes that completed before it started, and
681        // `written`/`written_seq` are bumped *after* the bytes reach the kernel,
682        // so this watermark can never over-claim.
683        let covers_seq = durability.written_seq.load(Ordering::SeqCst);
684        let covers_bytes = durability.written.load(Ordering::SeqCst);
685        let started = std::time::Instant::now();
686        file.sync_all()?;
687        durability
688            .fsync_nanos
689            .fetch_add(started.elapsed().as_nanos() as u64, Ordering::SeqCst);
690        durability.syncs.fetch_add(1, Ordering::SeqCst);
691        durability.durable.fetch_max(covers_bytes, Ordering::SeqCst);
692        durability
693            .durable_seq
694            .fetch_max(covers_seq, Ordering::SeqCst);
695        Ok(())
696    }
697}
698
699/// One Arrow IPC stream, opened for append.
700struct Log {
701    backing: Backing,
702    schema: SchemaRef,
703    options: IpcWriteOptions,
704    generator: IpcDataGenerator,
705    tracker: DictionaryTracker,
706    durability: Arc<Durability>,
707    /// The descriptor durability barriers are issued on. Shared with the store,
708    /// which reaches it *without* the writer mutex — that separation is the
709    /// group commit; see [`SyncGate`].
710    gate: Arc<SyncGate>,
711    /// Framed record batches currently in the live stream. This is what the
712    /// compaction trigger reads, and what an open would have to replay.
713    batches: usize,
714    /// Set to `false` only by the deliberately-broken store the durability
715    /// guard is asserted against.
716    fsync: bool,
717}
718
719impl std::fmt::Debug for Log {
720    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
721        f.debug_struct("Log")
722            .field("backing", &self.backing)
723            .field("durability", &self.durability)
724            .finish()
725    }
726}
727
728impl Log {
729    /// Open `backing` as a stream of `schema`, recovering any torn tail, and
730    /// return every batch already in it.
731    fn open(backing: Backing, schema: SchemaRef, fsync: bool) -> Result<(Self, Vec<RecordBatch>)> {
732        let bytes = backing.read_all()?;
733        let valid = valid_prefix_len(&bytes);
734        if valid < bytes.len() {
735            tracing::warn!(
736                discarded = bytes.len() - valid,
737                kept = valid,
738                "the log ends in an incomplete Arrow IPC message; truncating to the last \
739                 complete one so the next append is reachable"
740            );
741            backing.truncate_to(valid as u64)?;
742            backing.sync()?;
743        }
744
745        let gate = Arc::new(if fsync {
746            SyncGate::on(&backing)?
747        } else {
748            // The deliberately-broken store the durability guard is asserted
749            // against. A gate with no descriptor cannot sync, which is the
750            // point: `the_durability_guard_catches_a_missing_fsync` must still
751            // be able to produce a log that acknowledges before the platter.
752            SyncGate::detached()
753        });
754        let mut log = Log {
755            backing,
756            schema,
757            options: write_options(),
758            generator: IpcDataGenerator::default(),
759            tracker: DictionaryTracker::new(false),
760            durability: Arc::new(Durability::default()),
761            gate,
762            batches: 0,
763            fsync,
764        };
765        log.durability.written.store(valid as u64, Ordering::SeqCst);
766        log.durability.durable.store(valid as u64, Ordering::SeqCst);
767
768        let batches = if valid == 0 {
769            log.write_schema()?;
770            Vec::new()
771        } else {
772            let expected = log.schema.clone();
773            let reader = StreamReader::try_new(std::io::Cursor::new(&bytes[..valid]), None)
774                .map_err(arrow_err)?;
775            check_schema(reader.schema().as_ref(), expected.as_ref())?;
776            reader
777                .collect::<std::result::Result<Vec<_>, _>>()
778                .map_err(arrow_err)?
779        };
780        log.batches = batches.len();
781        Ok((log, batches))
782    }
783
784    /// Replace the whole stream with `batches`, atomically.
785    ///
786    /// The old stream keeps serving until the rename lands; see
787    /// [`Backing::replace_all`].
788    fn rewrite(&mut self, batches: &[RecordBatch]) -> Result<()> {
789        let bytes = encode_stream(&self.schema, batches, &self.options)?;
790        self.backing.replace_all(&bytes, self.fsync)?;
791        // The live log is now a DIFFERENT INODE: `replace_all` renamed a
792        // replacement over it and reopened. The gate still holds a descriptor
793        // on the old, now-unlinked one, and every later barrier would flush a
794        // file nothing reads — a silent loss of durability that no test of the
795        // *contents* could see. Rebind before anything can sync again.
796        self.gate.rebind(&self.backing)?;
797        let len = bytes.len() as u64;
798        self.durability.written.store(len, Ordering::SeqCst);
799        if self.fsync {
800            self.durability.syncs.fetch_add(1, Ordering::SeqCst);
801            self.durability.durable.store(len, Ordering::SeqCst);
802            // Everything staged before this point is durable by construction:
803            // `replace_all` fsynced a complete replacement that contains it.
804            // Publishing the watermark releases any waiter whose sequence the
805            // shortened file will never reach as a byte offset again — which is
806            // exactly why the watermark counts commits and not bytes.
807            let staged = self.durability.written_seq.load(Ordering::SeqCst);
808            self.durability
809                .durable_seq
810                .fetch_max(staged, Ordering::SeqCst);
811        } else {
812            self.durability.durable.store(0, Ordering::SeqCst);
813        }
814        // A rewritten log is a NEW stream. Its schema message was emitted by a
815        // fresh generator, so the appending one has to be fresh too or the next
816        // append encodes against state this stream never wrote.
817        self.generator = IpcDataGenerator::default();
818        self.tracker = DictionaryTracker::new(false);
819        self.batches = batches.len();
820        Ok(())
821    }
822
823    fn write_schema(&mut self) -> Result<()> {
824        let encoded = self.generator.schema_to_bytes_with_dictionary_tracker(
825            &self.schema,
826            &mut self.tracker,
827            &self.options,
828        );
829        let mut framed = Vec::new();
830        write_message(&mut framed, encoded, &self.options).map_err(arrow_err)?;
831        self.commit(&framed)
832    }
833
834    /// Append **one** `RecordBatch` as **one** framed IPC message, and return
835    /// the **commit sequence** that must be made durable before the caller may
836    /// acknowledge it.
837    ///
838    /// This does not sync. The caller releases the writer mutex and then calls
839    /// [`Log::barrier`] with what this returned — which is what lets the next
840    /// pusher encode and write while this one is still on the platter. See
841    /// [`SyncGate`].
842    fn append(&mut self, batch: &RecordBatch) -> Result<u64> {
843        let (dictionaries, message) = self
844            .generator
845            .encode(
846                batch,
847                &mut self.tracker,
848                &self.options,
849                &mut IpcWriteContext::default(),
850            )
851            .map_err(arrow_err)?;
852        let mut framed = Vec::new();
853        // This schema carries no dictionary-encoded column, so this loop is
854        // empty in practice; it is here so that adding one cannot silently drop
855        // the dictionary messages the batch would then depend on.
856        for dictionary in dictionaries {
857            write_message(&mut framed, dictionary, &self.options).map_err(arrow_err)?;
858        }
859        write_message(&mut framed, message, &self.options).map_err(arrow_err)?;
860        let sequence = self.stage(&framed)?;
861        self.batches += 1;
862        Ok(sequence)
863    }
864
865    /// Write `framed` and take a commit sequence for it. **Not durable yet.**
866    ///
867    /// The two counters are bumped *after* the bytes reach the kernel, which is
868    /// what makes [`SyncGate::barrier`]'s read-before-sync watermark honest: a
869    /// sequence that is visible in `written_seq` is a sequence whose bytes an
870    /// `fsync` starting now is guaranteed to cover.
871    fn stage(&self, framed: &[u8]) -> Result<u64> {
872        self.backing.append(framed)?;
873        self.durability
874            .written
875            .fetch_add(framed.len() as u64, Ordering::SeqCst);
876        Ok(self.durability.written_seq.fetch_add(1, Ordering::SeqCst) + 1)
877    }
878
879    /// Make commit `sequence` durable. Callable **without** the writer mutex.
880    fn barrier(&self, sequence: u64) -> Result<()> {
881        self.gate.barrier(&self.durability, sequence, self.fsync)
882    }
883
884    /// Write `framed` and make it durable before returning — the whole
885    /// acknowledgement barrier in one call, for the paths that hold the writer
886    /// mutex throughout anyway (opening a fresh log, sealing a secret) and gain
887    /// nothing from group commit.
888    fn commit(&self, framed: &[u8]) -> Result<()> {
889        let sequence = self.stage(framed)?;
890        self.barrier(sequence)
891    }
892}
893
894// ───────────────────────────── the lock ──────────────────────────────────────
895
896/// The `O_EXCL` lock file that makes "one writer" true across processes.
897///
898/// `flock` would release itself when a crashed process's descriptors close; an
899/// `O_EXCL` file does not, and needs no `unsafe` and no `libc`, which this
900/// crate forbids outright.
901///
902/// # Recovery is at startup, not an operator verb
903///
904/// The trade used to be *"a stale lock after a hard kill, cleared by
905/// [`ArrowLogStore::force_unlock`]"*, and it does not survive the no-OS rule's premise: one
906/// binary as PID 1 in a distroless image, with no shell and nobody to type the
907/// verb. A power cut left every repository the server had open holding a lock
908/// and the appliance booted into a permanent refusal to serve.
909///
910/// So the lock file records **who** holds it and [`acquire`](LockFile::acquire)
911/// reads that back. The owner is `<pid> <start-time>`: the pid alone is not
912/// enough, because pids are recycled and a recycled one reads as "still
913/// running". The start time is field 22 of `/proc/<pid>/stat`, in clock ticks
914/// since boot, which changes on every process — so a pid that has been reused
915/// is detected rather than obeyed. No `unsafe`, no `libc`, no new dependency.
916///
917/// **The rule is: reclaim only what is provably dead.** Alive, or *undecidable*
918/// — no `/proc` on this platform, an unparseable owner line, a lock written by
919/// an older gunnar that recorded no start time — both mean refuse.
920/// [`ArrowLogStore::force_unlock`] stays for exactly those cases. Reclaiming a
921/// lock that is genuinely held lets two writers interleave frames and destroys
922/// the log, which is far worse than an appliance that will not start.
923#[derive(Debug)]
924struct LockFile {
925    path: PathBuf,
926}
927
928/// Who a lock file says holds it.
929#[derive(Debug, Clone, PartialEq, Eq)]
930struct LockOwner {
931    pid: u32,
932    /// `None` for a lock file written before the liveness check, which recorded
933    /// only a pid.
934    /// It is what makes pid reuse undetectable, so it is treated as
935    /// undecidable rather than as "no reuse happened".
936    started: Option<u64>,
937}
938
939impl LockOwner {
940    fn parse(text: &str) -> Option<Self> {
941        let mut fields = text.split_whitespace();
942        let pid = fields.next()?.parse().ok()?;
943        let started = fields.next().and_then(|f| f.parse().ok());
944        Some(LockOwner { pid, started })
945    }
946
947    fn line(&self) -> String {
948        match self.started {
949            Some(started) => format!("{} {}\n", self.pid, started),
950            None => format!("{}\n", self.pid),
951        }
952    }
953
954    fn this_process() -> Self {
955        let pid = std::process::id();
956        LockOwner {
957            pid,
958            started: match proc_start_time(pid) {
959                ProcStartTime::Started(ticks) => Some(ticks),
960                _ => None,
961            },
962        }
963    }
964}
965
966/// What `/proc` says about a pid.
967#[derive(Debug, Clone, Copy, PartialEq, Eq)]
968enum ProcStartTime {
969    /// The process exists and started at this tick.
970    Started(u64),
971    /// There is no such process. The one answer that permits a reclaim.
972    NoSuchProcess,
973    /// No `/proc`, or a `stat` that did not parse. **Not** the same as dead.
974    Unknown,
975}
976
977/// Read field 22 of `/proc/<pid>/stat` — the process's start time in clock
978/// ticks since boot.
979///
980/// Field 2 is `comm`, which is parenthesised and may itself contain spaces and
981/// close-parens, so the split is anchored on the **last** `)` in the line and
982/// not on whitespace. Everything after it starts at field 3, which puts field
983/// 22 at index 19.
984fn proc_start_time(pid: u32) -> ProcStartTime {
985    // If this platform has no `/proc` at all, a missing `/proc/<pid>/stat` says
986    // nothing about the process, and reading it as "dead" would reclaim every
987    // live lock on that platform.
988    if !Path::new("/proc/self/stat").exists() {
989        return ProcStartTime::Unknown;
990    }
991    let stat = match std::fs::read_to_string(format!("/proc/{pid}/stat")) {
992        Ok(stat) => stat,
993        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
994            return ProcStartTime::NoSuchProcess
995        }
996        Err(_) => return ProcStartTime::Unknown,
997    };
998    let Some((_, fields)) = stat.rsplit_once(')') else {
999        return ProcStartTime::Unknown;
1000    };
1001    // Field 3, the state. A zombie has been killed and is waiting to be
1002    // collected by its parent: it holds no descriptors and can write nothing,
1003    // so it is gone for the purpose of a lock — and its pid cannot be recycled
1004    // while it sits there, so calling it gone is safe as well as true. Without
1005    // this a crashed appliance whose reaper is slow still refuses to start.
1006    if fields.split_whitespace().next() == Some("Z") {
1007        return ProcStartTime::NoSuchProcess;
1008    }
1009    match fields
1010        .split_whitespace()
1011        .nth(19)
1012        .and_then(|ticks| ticks.parse().ok())
1013    {
1014        Some(ticks) => ProcStartTime::Started(ticks),
1015        None => ProcStartTime::Unknown,
1016    }
1017}
1018
1019/// Is the lock file's owner still running?
1020///
1021/// `false` only when the answer is **certain**. See [`LockFile`].
1022fn owner_is_gone(owner: &LockOwner) -> bool {
1023    match (proc_start_time(owner.pid), owner.started) {
1024        (ProcStartTime::NoSuchProcess, _) => true,
1025        // The pid is live but is not the process that took the lock: it was
1026        // recycled. This is the case a bare `/proc/<pid>` existence check gets
1027        // wrong, and it gets it wrong in the direction of never recovering.
1028        (ProcStartTime::Started(now), Some(then)) => now != then,
1029        // A live pid whose identity cannot be confirmed, or no `/proc`.
1030        (ProcStartTime::Started(_), None) | (ProcStartTime::Unknown, _) => false,
1031    }
1032}
1033
1034impl LockFile {
1035    fn acquire(path: PathBuf) -> Result<Self> {
1036        match Self::create(&path) {
1037            Ok(lock) => Ok(lock),
1038            Err(Error::StoreLocked { .. }) => Self::reclaim_if_stale(path),
1039            Err(err) => Err(err),
1040        }
1041    }
1042
1043    fn create(path: &Path) -> Result<Self> {
1044        match OpenOptions::new().write(true).create_new(true).open(path) {
1045            Ok(mut file) => {
1046                let owner = LockOwner::this_process();
1047                // Not best-effort any more: an owner line that failed to reach
1048                // the disk makes every later recovery undecidable, and a
1049                // silent failure here would only be discovered after a crash.
1050                if let Err(err) = write!(file, "{}", owner.line()).and_then(|()| file.sync_all()) {
1051                    tracing::warn!(
1052                        path = %path.display(),
1053                        %err,
1054                        "the store lock could not record its owner; a crash will need \
1055                         force_unlock"
1056                    );
1057                }
1058                Ok(LockFile {
1059                    path: path.to_path_buf(),
1060                })
1061            }
1062            Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
1063                Err(Error::StoreLocked {
1064                    path: path.to_path_buf(),
1065                })
1066            }
1067            Err(err) => Err(Error::Io(err)),
1068        }
1069    }
1070
1071    fn reclaim_if_stale(path: PathBuf) -> Result<Self> {
1072        let owner = std::fs::read_to_string(&path)
1073            .ok()
1074            .and_then(|text| LockOwner::parse(&text));
1075        let Some(owner) = owner else {
1076            tracing::warn!(
1077                path = %path.display(),
1078                "the store lock names no owner this gunnar can read, so it cannot be \
1079                 shown to be stale; refusing"
1080            );
1081            return Err(Error::StoreLocked { path });
1082        };
1083        if !owner_is_gone(&owner) {
1084            return Err(Error::StoreLocked { path });
1085        }
1086        tracing::warn!(
1087            path = %path.display(),
1088            pid = owner.pid,
1089            "the store lock was left by a process that is gone; reclaiming it"
1090        );
1091        match std::fs::remove_file(&path) {
1092            Ok(()) => {}
1093            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
1094            Err(err) => return Err(Error::Io(err)),
1095        }
1096        // Deliberately one attempt: if a second starter reclaimed the same
1097        // stale lock a microsecond earlier, it now holds a live lock and this
1098        // one must refuse. Retrying would be a loop that ends in two writers.
1099        Self::create(&path)
1100    }
1101}
1102
1103impl Drop for LockFile {
1104    fn drop(&mut self) {
1105        if let Err(err) = std::fs::remove_file(&self.path) {
1106            tracing::warn!(path = %self.path.display(), %err, "could not release the store lock");
1107        }
1108    }
1109}
1110
1111// ───────────────────────────── the store ─────────────────────────────────────
1112
1113/// The bytes of a store's secret log when there is no filesystem at all.
1114///
1115/// Hand the same handle back to [`ArrowLogStore::open_memory`] to simulate a
1116/// restart: the store is rebuilt by replaying these bytes, exactly as the file
1117/// backing replays a file.
1118#[derive(Clone, Debug, Default)]
1119pub struct MemoryLog {
1120    secrets: Arc<Mutex<Vec<u8>>>,
1121}
1122
1123struct Inner {
1124    hash_kind: gix_hash::Kind,
1125    actor: String,
1126    policy: CompactionPolicy,
1127    secrets_log: Mutex<Log>,
1128    secrets_state: RwLock<BTreeMap<String, Vec<u8>>>,
1129    _lock: Option<LockFile>,
1130}
1131
1132/// One repository's secrets, as an Arrow IPC log.
1133///
1134/// Cheap to clone: every clone is the same store, and the exclusive lock lives
1135/// until the last handle is dropped.
1136#[derive(Clone)]
1137pub struct ArrowLogStore {
1138    inner: Arc<Inner>,
1139}
1140
1141impl std::fmt::Debug for ArrowLogStore {
1142    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1143        f.debug_struct("ArrowLogStore")
1144            .field("hash_kind", &self.inner.hash_kind)
1145            .field("lock", &self.inner._lock)
1146            .finish()
1147    }
1148}
1149
1150impl ArrowLogStore {
1151    /// Open the logs in `dir`, creating them if absent, and take the exclusive lock.
1152    ///
1153    /// Fails with [`Error::StoreLocked`] if another writer already holds it, and
1154    /// with [`Error::HashKindMismatch`] if the log on disk belongs to a
1155    /// repository of the other hash kind.
1156    pub fn open(dir: impl AsRef<Path>, hash_kind: gix_hash::Kind) -> Result<Self> {
1157        Self::open_inner(dir.as_ref(), hash_kind, true, CompactionPolicy::default())
1158    }
1159
1160    /// [`ArrowLogStore::open`] with a chosen compaction policy.
1161    ///
1162    /// The operator lever for threshold-based secret-log compaction. Replaced or
1163    /// deleted secrets are compacted immediately regardless of this policy.
1164    pub fn open_with_policy(
1165        dir: impl AsRef<Path>,
1166        hash_kind: gix_hash::Kind,
1167        policy: CompactionPolicy,
1168    ) -> Result<Self> {
1169        Self::open_inner(dir.as_ref(), hash_kind, true, policy)
1170    }
1171
1172    fn open_inner(
1173        dir: &Path,
1174        hash_kind: gix_hash::Kind,
1175        fsync: bool,
1176        policy: CompactionPolicy,
1177    ) -> Result<Self> {
1178        std::fs::create_dir_all(dir)?;
1179        // The lock comes first: nothing may read a log it is not entitled to
1180        // append to, because the replayed state is what CAS is checked against.
1181        let lock = LockFile::acquire(dir.join(LOCK_FILE))?;
1182        let open_file = |name: &str| -> Result<Backing> {
1183            let path = dir.join(name);
1184            let file = OpenOptions::new()
1185                .read(true)
1186                .write(true)
1187                .create(true)
1188                .truncate(false)
1189                .open(&path)?;
1190            Ok(Backing::File { file, path })
1191        };
1192        Self::assemble(
1193            hash_kind,
1194            open_file(SECRETS_LOG)?,
1195            Some(lock),
1196            fsync,
1197            policy,
1198        )
1199    }
1200
1201    /// A store with no file behind it. This is the no-OS shape, and what the
1202    /// no-filesystem conformance run uses.
1203    ///
1204    /// The framing, encoding and replay code is the same as the file backing's;
1205    /// what this shape cannot speak to is durability, since there is nothing for
1206    /// an fsync to mean. The durability claim is made by the file tests only.
1207    pub fn in_memory(hash_kind: gix_hash::Kind) -> Result<(Self, MemoryLog)> {
1208        let log = MemoryLog::default();
1209        let store = Self::open_memory(&log, hash_kind)?;
1210        Ok((store, log))
1211    }
1212
1213    /// Rebuild a store by replaying `log`, the in-memory restart.
1214    pub fn open_memory(log: &MemoryLog, hash_kind: gix_hash::Kind) -> Result<Self> {
1215        Self::open_memory_with_policy(log, hash_kind, CompactionPolicy::default())
1216    }
1217
1218    /// [`ArrowLogStore::open_memory`] with a chosen compaction policy.
1219    pub fn open_memory_with_policy(
1220        log: &MemoryLog,
1221        hash_kind: gix_hash::Kind,
1222        policy: CompactionPolicy,
1223    ) -> Result<Self> {
1224        Self::assemble(
1225            hash_kind,
1226            Backing::Memory {
1227                bytes: log.secrets.clone(),
1228            },
1229            None,
1230            true,
1231            policy,
1232        )
1233    }
1234
1235    fn assemble(
1236        hash_kind: gix_hash::Kind,
1237        secrets_backing: Backing,
1238        lock: Option<LockFile>,
1239        fsync: bool,
1240        policy: CompactionPolicy,
1241    ) -> Result<Self> {
1242        let (secrets_log, secret_batches) = Log::open(secrets_backing, secret_schema(), fsync)?;
1243        let secrets_batches_on_open = secrets_log.batches;
1244        let store = ArrowLogStore {
1245            inner: Arc::new(Inner {
1246                hash_kind,
1247                actor: DEFAULT_ACTOR.to_owned(),
1248                policy,
1249                secrets_log: Mutex::new(secrets_log),
1250                secrets_state: RwLock::new(replay_secrets(&secret_batches)?),
1251                _lock: lock,
1252            }),
1253        };
1254        if policy.should_compact(secrets_batches_on_open) {
1255            if let Err(err) = store.compact_secrets_now() {
1256                tracing::warn!(%err, "could not fold an oversized secret log at open");
1257            }
1258        }
1259        Ok(store)
1260    }
1261
1262    /// Remove a lock file left behind by a process that was killed.
1263    ///
1264    /// Only ever correct when no gunnar is running against `dir`. The lock is
1265    /// what keeps Arrow stream appends single-writer; breaking one that is
1266    /// genuinely held lets two writers interleave frames and destroy the log.
1267    ///
1268    /// # This is no longer the normal recovery path
1269    ///
1270    /// [`open`](ArrowLogStore::open) reclaims a lock whose owner is **provably**
1271    /// gone, so an appliance with no shell restarts after a power cut on its
1272    /// own. What is left for this verb is the undecidable cases, where breaking
1273    /// the lock is a judgement no program should make unasked: a platform with
1274    /// no `/proc`, a lock file written by a gunnar older than the liveness
1275    /// check, or one
1276    /// whose owner line never reached the disk.
1277    pub fn force_unlock(dir: impl AsRef<Path>) -> Result<()> {
1278        match std::fs::remove_file(dir.as_ref().join(LOCK_FILE)) {
1279            Ok(()) => Ok(()),
1280            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
1281            Err(err) => Err(Error::Io(err)),
1282        }
1283    }
1284
1285    /// The hash kind this repository uses.
1286    pub fn hash_kind(&self) -> gix_hash::Kind {
1287        self.inner.hash_kind
1288    }
1289
1290    /// The compaction policy in force.
1291    pub fn compaction_policy(&self) -> CompactionPolicy {
1292        self.inner.policy
1293    }
1294
1295    /// Fold the secret log: current values only, no history, no archive.
1296    fn compact_secrets_now(&self) -> Result<()> {
1297        let mut log = self
1298            .inner
1299            .secrets_log
1300            .lock()
1301            .map_err(|_| poisoned("secret log"))?;
1302        compact_secrets(&self.inner, &mut log)
1303    }
1304
1305    /// A handle implementing [`SecretStore`].
1306    pub fn secrets(&self) -> ArrowLogSecretStore {
1307        ArrowLogSecretStore {
1308            inner: self.inner.clone(),
1309        }
1310    }
1311}
1312
1313// ───────────────────────────── shared helpers ────────────────────────────────
1314
1315fn now_unix_ms() -> i64 {
1316    SystemTime::now()
1317        .duration_since(UNIX_EPOCH)
1318        .map(|d| d.as_millis() as i64)
1319        .unwrap_or(0)
1320}
1321
1322/// An Arrow fault, as a [`gunnar_store::Error`].
1323///
1324/// `gunnar-store` used to carry `impl From<arrow_schema::ArrowError> for Error`
1325/// so a `?` did this implicitly. It does not any more: that `From` was the last
1326/// Arrow in the contract crate, and keeping one impl there would have kept the
1327/// dependency edge this module's move to `gunnar-core` exists to cut (see
1328/// `gunnar_store`'s crate header). One function, called at every site the `?`
1329/// used to cover, rather than a second opinion about what an Arrow fault is.
1330pub(crate) fn arrow_err(err: arrow_schema::ArrowError) -> Error {
1331    Error::backend(format!("arrow: {err}"))
1332}
1333
1334pub(crate) fn column<'a, T: 'static>(batch: &'a RecordBatch, name: &str) -> Result<&'a T> {
1335    batch
1336        .column_by_name(name)
1337        .and_then(|c| c.as_any().downcast_ref::<T>())
1338        .ok_or_else(|| {
1339            Error::backend(format!(
1340                "the Arrow log has no usable `{name}` column; the file is not a gunnar log"
1341            ))
1342        })
1343}
1344
1345// ───────────────────────────── compaction ────────────────────────────────────
1346
1347/// The same fold for secrets, with **no archive and no window**.
1348///
1349/// Retaining superseded secret values would make a rotated host key immortal,
1350/// which is the opposite of what rotation is for.
1351fn compact_secrets(inner: &Inner, log: &mut Log) -> Result<()> {
1352    let snapshot = {
1353        let state = inner.secrets_state.read().map_err(|_| poisoned("secret"))?;
1354        let mut keys = StringBuilder::new();
1355        let mut values = BinaryBuilder::new();
1356        let mut actors = StringBuilder::new();
1357        let mut ats = Int64Builder::new();
1358        let at = now_unix_ms();
1359        for (key, value) in state.iter() {
1360            keys.append_value(key);
1361            values.append_value(value);
1362            actors.append_value(COMPACTION_ACTOR);
1363            ats.append_value(at);
1364        }
1365        RecordBatch::try_new(
1366            log.schema.clone(),
1367            vec![
1368                Arc::new(keys.finish()),
1369                Arc::new(values.finish()),
1370                Arc::new(actors.finish()),
1371                Arc::new(ats.finish()),
1372            ],
1373        )
1374        .map_err(arrow_err)?
1375    };
1376    log.rewrite(&[snapshot])
1377}
1378
1379// ───────────────────────────── secrets ───────────────────────────────────────
1380
1381/// Secrets in an Arrow IPC log: the SSH host key and the admin bootstrap
1382/// material, on a box with no home directory.
1383#[derive(Clone)]
1384pub struct ArrowLogSecretStore {
1385    inner: Arc<Inner>,
1386}
1387
1388impl std::fmt::Debug for ArrowLogSecretStore {
1389    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1390        f.write_str("ArrowLogSecretStore")
1391    }
1392}
1393
1394fn build_secret_batch(
1395    schema: &SchemaRef,
1396    actor: &str,
1397    at: i64,
1398    key: &str,
1399    value: Option<&[u8]>,
1400) -> Result<RecordBatch> {
1401    let mut keys = StringBuilder::new();
1402    let mut values = BinaryBuilder::new();
1403    let mut actors = StringBuilder::new();
1404    let mut ats = Int64Builder::new();
1405    keys.append_value(key);
1406    match value {
1407        Some(bytes) => values.append_value(bytes),
1408        None => values.append_null(),
1409    }
1410    actors.append_value(actor);
1411    ats.append_value(at);
1412    RecordBatch::try_new(
1413        schema.clone(),
1414        vec![
1415            Arc::new(keys.finish()),
1416            Arc::new(values.finish()),
1417            Arc::new(actors.finish()),
1418            Arc::new(ats.finish()),
1419        ],
1420    )
1421    .map_err(arrow_err)
1422}
1423
1424fn replay_secrets(batches: &[RecordBatch]) -> Result<BTreeMap<String, Vec<u8>>> {
1425    let mut state = BTreeMap::new();
1426    for batch in batches {
1427        let keys = column::<StringArray>(batch, COL_KEY)?;
1428        let values = column::<BinaryArray>(batch, COL_VALUE)?;
1429        for row in 0..batch.num_rows() {
1430            let key = keys.value(row).to_owned();
1431            if values.is_null(row) {
1432                state.remove(&key);
1433            } else {
1434                state.insert(key, values.value(row).to_vec());
1435            }
1436        }
1437    }
1438    Ok(state)
1439}
1440
1441impl ArrowLogSecretStore {
1442    fn write(&self, key: &str, value: Option<&[u8]>) -> Result<()> {
1443        let mut log = self
1444            .inner
1445            .secrets_log
1446            .lock()
1447            .map_err(|_| poisoned("secret log"))?;
1448        self.write_locked(&mut log, key, value)
1449    }
1450
1451    fn write_locked(&self, log: &mut Log, key: &str, value: Option<&[u8]>) -> Result<()> {
1452        let batch = build_secret_batch(&log.schema, &self.inner.actor, now_unix_ms(), key, value)?;
1453        // Append AND barrier, both under the writer mutex — deliberately *not*
1454        // the group commit the ref log does.
1455        //
1456        // A secret is written when a host key is minted or rotated, which is
1457        // rare and never concurrent, so there is nothing to group and releasing
1458        // the lock first would buy nothing while widening the window in which a
1459        // rotated secret is still readable. `append` only stages; forgetting
1460        // this line is a secret that is acknowledged and not on the platter,
1461        // which is what `a_secret_is_durable_before_the_write_returns` exists to
1462        // catch.
1463        let sequence = log.append(&batch)?;
1464        log.barrier(sequence)?;
1465        let superseded = {
1466            let mut state = self
1467                .inner
1468                .secrets_state
1469                .write()
1470                .map_err(|_| poisoned("secret"))?;
1471            match value {
1472                Some(bytes) => state.insert(key.to_owned(), bytes.to_vec()).is_some(),
1473                None => state.remove(key).is_some(),
1474            }
1475        };
1476
1477        // A superseded secret is compacted out **now**, not when a batch count
1478        // says so.
1479        //
1480        // This module's own header says "a rotated SSH host key must actually
1481        // stop existing". It did not: the log is append-only and the only
1482        // trigger was `should_compact(log.batches)`, so the previous value sat
1483        // in `secrets.arrow` for up to `after_batches` further writes — on a
1484        // store that sees a handful of writes in its lifetime, that is
1485        // *forever*. Found by `gunnar-core`'s sealing-migration test, which sealed a
1486        // legacy plaintext host key and then found the plaintext still in the
1487        // file.
1488        //
1489        // Refs deliberately keep their history; secrets are the one log where
1490        // the old value is the defect. The fold is a rewrite of a map with a
1491        // handful of entries, so doing it on every replacement costs nothing
1492        // that a threshold was protecting.
1493        if superseded {
1494            compact_secrets(&self.inner, log).map_err(|err| {
1495                Error::backend(format!(
1496                    "the new value for the secret {key} was stored, but the PREVIOUS value \
1497                     could not be compacted out of the log and is still on disk: {err}"
1498                ))
1499            })?;
1500        } else if self.inner.policy.should_compact(log.batches) {
1501            if let Err(err) = compact_secrets(&self.inner, log) {
1502                tracing::warn!(%err, "could not compact the secret log; it keeps growing");
1503            }
1504        }
1505        Ok(())
1506    }
1507
1508    fn current(&self, key: &str) -> Result<Option<Vec<u8>>> {
1509        Ok(self
1510            .inner
1511            .secrets_state
1512            .read()
1513            .map_err(|_| poisoned("secret"))?
1514            .get(key)
1515            .cloned())
1516    }
1517}
1518
1519impl SecretStore for ArrowLogSecretStore {
1520    fn get(&self, key: &str) -> Result<Option<Vec<u8>>> {
1521        self.current(key)
1522    }
1523
1524    fn put(&self, key: &str, value: &[u8]) -> Result<()> {
1525        self.write(key, Some(value))
1526    }
1527
1528    fn delete(&self, key: &str) -> Result<()> {
1529        // A tombstone for a key that is not there would grow the log for
1530        // nothing, and "delete what is absent" is explicitly not an error.
1531        if self.current(key)?.is_none() {
1532            return Ok(());
1533        }
1534        self.write(key, None)
1535    }
1536
1537    fn get_or_create(&self, key: &str, mk: &mut dyn FnMut() -> Result<Vec<u8>>) -> Result<Vec<u8>> {
1538        // The writer mutex is taken *before* the existence check, so the loser of
1539        // a race blocks here and then finds the winner's value. `mk` runs once.
1540        let mut log = self
1541            .inner
1542            .secrets_log
1543            .lock()
1544            .map_err(|_| poisoned("secret log"))?;
1545        if let Some(existing) = self.current(key)? {
1546            return Ok(existing);
1547        }
1548        let value = mk()?;
1549        self.write_locked(&mut log, key, Some(&value))?;
1550        Ok(value)
1551    }
1552}
1553
1554// ───────────────────────────── tests ─────────────────────────────────────────
1555
1556#[cfg(test)]
1557mod tests {
1558    use super::*;
1559    use gunnar_store::SecretStoreExt;
1560
1561    const KIND: gix_hash::Kind = gix_hash::Kind::Sha256;
1562
1563    fn dir() -> tempfile::TempDir {
1564        tempfile::tempdir().expect("a temporary directory")
1565    }
1566
1567    #[test]
1568    fn the_in_memory_secret_log_replays_across_a_rebuild() {
1569        let (store, log) = ArrowLogStore::in_memory(KIND).expect("in-memory store");
1570        store.secrets().put("k", b"v").expect("put");
1571        drop(store);
1572
1573        let rebuilt = ArrowLogStore::open_memory(&log, KIND).expect("replay");
1574        assert_eq!(
1575            rebuilt.secrets().get("k").expect("get"),
1576            Some(b"v".to_vec())
1577        );
1578    }
1579
1580    #[test]
1581    fn a_secret_delete_is_a_tombstone_that_survives_a_reopen() {
1582        let dir = dir();
1583        {
1584            let store = ArrowLogStore::open(dir.path(), KIND).expect("open");
1585            store.secrets().put("k", b"v1").expect("put");
1586            store.secrets().delete("k").expect("delete");
1587        }
1588        let store = ArrowLogStore::open(dir.path(), KIND).expect("reopen");
1589        assert!(store.secrets().get("k").expect("get").is_none());
1590        store.secrets().put("k", b"v2").expect("put again");
1591        assert_eq!(store.secrets().get("k").expect("get"), Some(b"v2".to_vec()));
1592    }
1593
1594    #[test]
1595    fn a_rotated_secret_leaves_no_trace_of_the_previous_value_on_disk() {
1596        let dir = dir();
1597        let old = b"OLD-SECRET-VALUE-that-must-not-survive-rotation";
1598        let new = b"NEW-SECRET-VALUE-after-the-rotation";
1599
1600        let store = ArrowLogStore::open(dir.path(), KIND).expect("open");
1601        store.secrets().put("ssh/host_ed25519", old).expect("put");
1602
1603        let contains = |needle: &[u8]| {
1604            let bytes = std::fs::read(dir.path().join(SECRETS_LOG)).expect("read the secret log");
1605            assert!(!bytes.is_empty(), "the secret log is empty");
1606            bytes.windows(needle.len()).any(|w| w == needle)
1607        };
1608        assert!(contains(old));
1609
1610        store
1611            .secrets()
1612            .put("ssh/host_ed25519", new)
1613            .expect("rotate");
1614        assert!(!contains(old), "the previous secret survived rotation");
1615        assert!(contains(new), "the new value is not in the log");
1616        assert_eq!(
1617            store.secrets().get("ssh/host_ed25519").expect("get"),
1618            Some(new.to_vec())
1619        );
1620
1621        store.secrets().delete("ssh/host_ed25519").expect("delete");
1622        assert!(
1623            !contains(new),
1624            "a deleted secret's value is still in the log"
1625        );
1626    }
1627
1628    #[test]
1629    fn compacting_secrets_does_not_retain_superseded_values() {
1630        let dir = dir();
1631        let store = ArrowLogStore::open_with_policy(dir.path(), KIND, CompactionPolicy::new(4, 0))
1632            .expect("open");
1633        for generation in 0..12 {
1634            store
1635                .secrets()
1636                .put(
1637                    "host-key",
1638                    format!("secret-generation-{generation:02}").as_bytes(),
1639                )
1640                .expect("put");
1641        }
1642
1643        let haystack = String::from_utf8_lossy(
1644            &std::fs::read(dir.path().join(SECRETS_LOG)).expect("read the secret log"),
1645        )
1646        .into_owned();
1647        assert!(!haystack.contains("secret-generation-00"));
1648        assert!(haystack.contains("secret-generation-11"));
1649        assert!(!dir.path().join(REFS_ARCHIVE_DIR).exists());
1650        drop(store);
1651
1652        let reopened =
1653            ArrowLogStore::open_with_policy(dir.path(), KIND, CompactionPolicy::new(4, 0))
1654                .expect("reopen");
1655        assert_eq!(
1656            reopened.secrets().get("host-key").expect("get"),
1657            Some(b"secret-generation-11".to_vec())
1658        );
1659    }
1660
1661    #[test]
1662    fn get_or_create_does_not_remint_across_a_reopen() {
1663        let dir = dir();
1664        let first = {
1665            let store = ArrowLogStore::open(dir.path(), KIND).expect("open");
1666            store
1667                .secrets()
1668                .get_or_create_with(gunnar_store::SSH_HOST_KEY, || Ok(b"host-key-1".to_vec()))
1669                .expect("mint")
1670        };
1671        let store = ArrowLogStore::open(dir.path(), KIND).expect("reopen");
1672        let second = store
1673            .secrets()
1674            .get_or_create_with(gunnar_store::SSH_HOST_KEY, || {
1675                panic!("the host key was re-minted after a restart")
1676            })
1677            .expect("read back");
1678        assert_eq!(first, second);
1679    }
1680
1681    #[test]
1682    fn a_secret_is_durable_before_the_write_returns() {
1683        let dir = dir();
1684        let store = ArrowLogStore::open(dir.path(), KIND).expect("open");
1685        store
1686            .secrets()
1687            .put(gunnar_store::SSH_HOST_KEY, b"a-host-key")
1688            .expect("write a secret");
1689
1690        let on_disk = std::fs::metadata(dir.path().join(SECRETS_LOG))
1691            .expect("stat")
1692            .len();
1693        let durability = &store
1694            .inner
1695            .secrets_log
1696            .lock()
1697            .expect("the writer")
1698            .durability;
1699        let durable = durability.durable.load(Ordering::SeqCst);
1700        let written = durability.written.load(Ordering::SeqCst);
1701        assert_eq!(written, on_disk);
1702        assert_eq!(durable, written);
1703        assert!(durability.syncs.load(Ordering::SeqCst) >= 2);
1704    }
1705}