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            ipc_guard(&bytes[..valid])?;
774            let reader = StreamReader::try_new(std::io::Cursor::new(&bytes[..valid]), None)
775                .map_err(arrow_err)?;
776            check_schema(reader.schema().as_ref(), expected.as_ref())?;
777            reader
778                .collect::<std::result::Result<Vec<_>, _>>()
779                .map_err(arrow_err)?
780        };
781        log.batches = batches.len();
782        Ok((log, batches))
783    }
784
785    /// Replace the whole stream with `batches`, atomically.
786    ///
787    /// The old stream keeps serving until the rename lands; see
788    /// [`Backing::replace_all`].
789    fn rewrite(&mut self, batches: &[RecordBatch]) -> Result<()> {
790        let bytes = encode_stream(&self.schema, batches, &self.options)?;
791        self.backing.replace_all(&bytes, self.fsync)?;
792        // The live log is now a DIFFERENT INODE: `replace_all` renamed a
793        // replacement over it and reopened. The gate still holds a descriptor
794        // on the old, now-unlinked one, and every later barrier would flush a
795        // file nothing reads — a silent loss of durability that no test of the
796        // *contents* could see. Rebind before anything can sync again.
797        self.gate.rebind(&self.backing)?;
798        let len = bytes.len() as u64;
799        self.durability.written.store(len, Ordering::SeqCst);
800        if self.fsync {
801            self.durability.syncs.fetch_add(1, Ordering::SeqCst);
802            self.durability.durable.store(len, Ordering::SeqCst);
803            // Everything staged before this point is durable by construction:
804            // `replace_all` fsynced a complete replacement that contains it.
805            // Publishing the watermark releases any waiter whose sequence the
806            // shortened file will never reach as a byte offset again — which is
807            // exactly why the watermark counts commits and not bytes.
808            let staged = self.durability.written_seq.load(Ordering::SeqCst);
809            self.durability
810                .durable_seq
811                .fetch_max(staged, Ordering::SeqCst);
812        } else {
813            self.durability.durable.store(0, Ordering::SeqCst);
814        }
815        // A rewritten log is a NEW stream. Its schema message was emitted by a
816        // fresh generator, so the appending one has to be fresh too or the next
817        // append encodes against state this stream never wrote.
818        self.generator = IpcDataGenerator::default();
819        self.tracker = DictionaryTracker::new(false);
820        self.batches = batches.len();
821        Ok(())
822    }
823
824    fn write_schema(&mut self) -> Result<()> {
825        let encoded = self.generator.schema_to_bytes_with_dictionary_tracker(
826            &self.schema,
827            &mut self.tracker,
828            &self.options,
829        );
830        let mut framed = Vec::new();
831        write_message(&mut framed, encoded, &self.options).map_err(arrow_err)?;
832        self.commit(&framed)
833    }
834
835    /// Append **one** `RecordBatch` as **one** framed IPC message, and return
836    /// the **commit sequence** that must be made durable before the caller may
837    /// acknowledge it.
838    ///
839    /// This does not sync. The caller releases the writer mutex and then calls
840    /// [`Log::barrier`] with what this returned — which is what lets the next
841    /// pusher encode and write while this one is still on the platter. See
842    /// [`SyncGate`].
843    fn append(&mut self, batch: &RecordBatch) -> Result<u64> {
844        let (dictionaries, message) = self
845            .generator
846            .encode(
847                batch,
848                &mut self.tracker,
849                &self.options,
850                &mut IpcWriteContext::default(),
851            )
852            .map_err(arrow_err)?;
853        let mut framed = Vec::new();
854        // This schema carries no dictionary-encoded column, so this loop is
855        // empty in practice; it is here so that adding one cannot silently drop
856        // the dictionary messages the batch would then depend on.
857        for dictionary in dictionaries {
858            write_message(&mut framed, dictionary, &self.options).map_err(arrow_err)?;
859        }
860        write_message(&mut framed, message, &self.options).map_err(arrow_err)?;
861        let sequence = self.stage(&framed)?;
862        self.batches += 1;
863        Ok(sequence)
864    }
865
866    /// Write `framed` and take a commit sequence for it. **Not durable yet.**
867    ///
868    /// The two counters are bumped *after* the bytes reach the kernel, which is
869    /// what makes [`SyncGate::barrier`]'s read-before-sync watermark honest: a
870    /// sequence that is visible in `written_seq` is a sequence whose bytes an
871    /// `fsync` starting now is guaranteed to cover.
872    fn stage(&self, framed: &[u8]) -> Result<u64> {
873        self.backing.append(framed)?;
874        self.durability
875            .written
876            .fetch_add(framed.len() as u64, Ordering::SeqCst);
877        Ok(self.durability.written_seq.fetch_add(1, Ordering::SeqCst) + 1)
878    }
879
880    /// Make commit `sequence` durable. Callable **without** the writer mutex.
881    fn barrier(&self, sequence: u64) -> Result<()> {
882        self.gate.barrier(&self.durability, sequence, self.fsync)
883    }
884
885    /// Write `framed` and make it durable before returning — the whole
886    /// acknowledgement barrier in one call, for the paths that hold the writer
887    /// mutex throughout anyway (opening a fresh log, sealing a secret) and gain
888    /// nothing from group commit.
889    fn commit(&self, framed: &[u8]) -> Result<()> {
890        let sequence = self.stage(framed)?;
891        self.barrier(sequence)
892    }
893}
894
895// ───────────────────────────── the lock ──────────────────────────────────────
896
897/// The `O_EXCL` lock file that makes "one writer" true across processes.
898///
899/// `flock` would release itself when a crashed process's descriptors close; an
900/// `O_EXCL` file does not, and needs no `unsafe` and no `libc`, which this
901/// crate forbids outright.
902///
903/// # Recovery is at startup, not an operator verb
904///
905/// The trade used to be *"a stale lock after a hard kill, cleared by
906/// [`ArrowLogStore::force_unlock`]"*, and it does not survive the no-OS rule's premise: one
907/// binary as PID 1 in a distroless image, with no shell and nobody to type the
908/// verb. A power cut left every repository the server had open holding a lock
909/// and the appliance booted into a permanent refusal to serve.
910///
911/// So the lock file records **who** holds it and [`acquire`](LockFile::acquire)
912/// reads that back. The owner is `<pid> <start-time>`: the pid alone is not
913/// enough, because pids are recycled and a recycled one reads as "still
914/// running". The start time is field 22 of `/proc/<pid>/stat`, in clock ticks
915/// since boot, which changes on every process — so a pid that has been reused
916/// is detected rather than obeyed. No `unsafe`, no `libc`, no new dependency.
917///
918/// **The rule is: reclaim only what is provably dead.** Alive, or *undecidable*
919/// — no `/proc` on this platform, an unparseable owner line, a lock written by
920/// an older gunnar that recorded no start time — both mean refuse.
921/// [`ArrowLogStore::force_unlock`] stays for exactly those cases. Reclaiming a
922/// lock that is genuinely held lets two writers interleave frames and destroys
923/// the log, which is far worse than an appliance that will not start.
924#[derive(Debug)]
925struct LockFile {
926    path: PathBuf,
927}
928
929/// Who a lock file says holds it.
930#[derive(Debug, Clone, PartialEq, Eq)]
931struct LockOwner {
932    pid: u32,
933    /// `None` for a lock file written before the liveness check, which recorded
934    /// only a pid.
935    /// It is what makes pid reuse undetectable, so it is treated as
936    /// undecidable rather than as "no reuse happened".
937    started: Option<u64>,
938}
939
940impl LockOwner {
941    fn parse(text: &str) -> Option<Self> {
942        let mut fields = text.split_whitespace();
943        let pid = fields.next()?.parse().ok()?;
944        let started = fields.next().and_then(|f| f.parse().ok());
945        Some(LockOwner { pid, started })
946    }
947
948    fn line(&self) -> String {
949        match self.started {
950            Some(started) => format!("{} {}\n", self.pid, started),
951            None => format!("{}\n", self.pid),
952        }
953    }
954
955    fn this_process() -> Self {
956        let pid = std::process::id();
957        LockOwner {
958            pid,
959            started: match proc_start_time(pid) {
960                ProcStartTime::Started(ticks) => Some(ticks),
961                _ => None,
962            },
963        }
964    }
965}
966
967/// What `/proc` says about a pid.
968#[derive(Debug, Clone, Copy, PartialEq, Eq)]
969enum ProcStartTime {
970    /// The process exists and started at this tick.
971    Started(u64),
972    /// There is no such process. The one answer that permits a reclaim.
973    NoSuchProcess,
974    /// No `/proc`, or a `stat` that did not parse. **Not** the same as dead.
975    Unknown,
976}
977
978/// Read field 22 of `/proc/<pid>/stat` — the process's start time in clock
979/// ticks since boot.
980///
981/// Field 2 is `comm`, which is parenthesised and may itself contain spaces and
982/// close-parens, so the split is anchored on the **last** `)` in the line and
983/// not on whitespace. Everything after it starts at field 3, which puts field
984/// 22 at index 19.
985fn proc_start_time(pid: u32) -> ProcStartTime {
986    // If this platform has no `/proc` at all, a missing `/proc/<pid>/stat` says
987    // nothing about the process, and reading it as "dead" would reclaim every
988    // live lock on that platform.
989    if !Path::new("/proc/self/stat").exists() {
990        return ProcStartTime::Unknown;
991    }
992    let stat = match std::fs::read_to_string(format!("/proc/{pid}/stat")) {
993        Ok(stat) => stat,
994        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
995            return ProcStartTime::NoSuchProcess
996        }
997        Err(_) => return ProcStartTime::Unknown,
998    };
999    let Some((_, fields)) = stat.rsplit_once(')') else {
1000        return ProcStartTime::Unknown;
1001    };
1002    // Field 3, the state. A zombie has been killed and is waiting to be
1003    // collected by its parent: it holds no descriptors and can write nothing,
1004    // so it is gone for the purpose of a lock — and its pid cannot be recycled
1005    // while it sits there, so calling it gone is safe as well as true. Without
1006    // this a crashed appliance whose reaper is slow still refuses to start.
1007    if fields.split_whitespace().next() == Some("Z") {
1008        return ProcStartTime::NoSuchProcess;
1009    }
1010    match fields
1011        .split_whitespace()
1012        .nth(19)
1013        .and_then(|ticks| ticks.parse().ok())
1014    {
1015        Some(ticks) => ProcStartTime::Started(ticks),
1016        None => ProcStartTime::Unknown,
1017    }
1018}
1019
1020/// Is the lock file's owner still running?
1021///
1022/// `false` only when the answer is **certain**. See [`LockFile`].
1023fn owner_is_gone(owner: &LockOwner) -> bool {
1024    match (proc_start_time(owner.pid), owner.started) {
1025        (ProcStartTime::NoSuchProcess, _) => true,
1026        // The pid is live but is not the process that took the lock: it was
1027        // recycled. This is the case a bare `/proc/<pid>` existence check gets
1028        // wrong, and it gets it wrong in the direction of never recovering.
1029        (ProcStartTime::Started(now), Some(then)) => now != then,
1030        // A live pid whose identity cannot be confirmed, or no `/proc`.
1031        (ProcStartTime::Started(_), None) | (ProcStartTime::Unknown, _) => false,
1032    }
1033}
1034
1035impl LockFile {
1036    fn acquire(path: PathBuf) -> Result<Self> {
1037        match Self::create(&path) {
1038            Ok(lock) => Ok(lock),
1039            Err(Error::StoreLocked { .. }) => Self::reclaim_if_stale(path),
1040            Err(err) => Err(err),
1041        }
1042    }
1043
1044    fn create(path: &Path) -> Result<Self> {
1045        match OpenOptions::new().write(true).create_new(true).open(path) {
1046            Ok(mut file) => {
1047                let owner = LockOwner::this_process();
1048                // Not best-effort any more: an owner line that failed to reach
1049                // the disk makes every later recovery undecidable, and a
1050                // silent failure here would only be discovered after a crash.
1051                if let Err(err) = write!(file, "{}", owner.line()).and_then(|()| file.sync_all()) {
1052                    tracing::warn!(
1053                        path = %path.display(),
1054                        %err,
1055                        "the store lock could not record its owner; a crash will need \
1056                         force_unlock"
1057                    );
1058                }
1059                Ok(LockFile {
1060                    path: path.to_path_buf(),
1061                })
1062            }
1063            Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
1064                Err(Error::StoreLocked {
1065                    path: path.to_path_buf(),
1066                })
1067            }
1068            Err(err) => Err(Error::Io(err)),
1069        }
1070    }
1071
1072    fn reclaim_if_stale(path: PathBuf) -> Result<Self> {
1073        let owner = std::fs::read_to_string(&path)
1074            .ok()
1075            .and_then(|text| LockOwner::parse(&text));
1076        let Some(owner) = owner else {
1077            tracing::warn!(
1078                path = %path.display(),
1079                "the store lock names no owner this gunnar can read, so it cannot be \
1080                 shown to be stale; refusing"
1081            );
1082            return Err(Error::StoreLocked { path });
1083        };
1084        if !owner_is_gone(&owner) {
1085            return Err(Error::StoreLocked { path });
1086        }
1087        tracing::warn!(
1088            path = %path.display(),
1089            pid = owner.pid,
1090            "the store lock was left by a process that is gone; reclaiming it"
1091        );
1092        match std::fs::remove_file(&path) {
1093            Ok(()) => {}
1094            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
1095            Err(err) => return Err(Error::Io(err)),
1096        }
1097        // Deliberately one attempt: if a second starter reclaimed the same
1098        // stale lock a microsecond earlier, it now holds a live lock and this
1099        // one must refuse. Retrying would be a loop that ends in two writers.
1100        Self::create(&path)
1101    }
1102}
1103
1104impl Drop for LockFile {
1105    fn drop(&mut self) {
1106        if let Err(err) = std::fs::remove_file(&self.path) {
1107            tracing::warn!(path = %self.path.display(), %err, "could not release the store lock");
1108        }
1109    }
1110}
1111
1112// ───────────────────────────── the store ─────────────────────────────────────
1113
1114/// The bytes of a store's secret log when there is no filesystem at all.
1115///
1116/// Hand the same handle back to [`ArrowLogStore::open_memory`] to simulate a
1117/// restart: the store is rebuilt by replaying these bytes, exactly as the file
1118/// backing replays a file.
1119#[derive(Clone, Debug, Default)]
1120pub struct MemoryLog {
1121    secrets: Arc<Mutex<Vec<u8>>>,
1122}
1123
1124struct Inner {
1125    hash_kind: gix_hash::Kind,
1126    actor: String,
1127    policy: CompactionPolicy,
1128    secrets_log: Mutex<Log>,
1129    secrets_state: RwLock<BTreeMap<String, Vec<u8>>>,
1130    _lock: Option<LockFile>,
1131}
1132
1133/// One repository's secrets, as an Arrow IPC log.
1134///
1135/// Cheap to clone: every clone is the same store, and the exclusive lock lives
1136/// until the last handle is dropped.
1137#[derive(Clone)]
1138pub struct ArrowLogStore {
1139    inner: Arc<Inner>,
1140}
1141
1142impl std::fmt::Debug for ArrowLogStore {
1143    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1144        f.debug_struct("ArrowLogStore")
1145            .field("hash_kind", &self.inner.hash_kind)
1146            .field("lock", &self.inner._lock)
1147            .finish()
1148    }
1149}
1150
1151impl ArrowLogStore {
1152    /// Open the logs in `dir`, creating them if absent, and take the exclusive lock.
1153    ///
1154    /// Fails with [`Error::StoreLocked`] if another writer already holds it, and
1155    /// with [`Error::HashKindMismatch`] if the log on disk belongs to a
1156    /// repository of the other hash kind.
1157    pub fn open(dir: impl AsRef<Path>, hash_kind: gix_hash::Kind) -> Result<Self> {
1158        Self::open_inner(dir.as_ref(), hash_kind, true, CompactionPolicy::default())
1159    }
1160
1161    /// [`ArrowLogStore::open`] with a chosen compaction policy.
1162    ///
1163    /// The operator lever for threshold-based secret-log compaction. Replaced or
1164    /// deleted secrets are compacted immediately regardless of this policy.
1165    pub fn open_with_policy(
1166        dir: impl AsRef<Path>,
1167        hash_kind: gix_hash::Kind,
1168        policy: CompactionPolicy,
1169    ) -> Result<Self> {
1170        Self::open_inner(dir.as_ref(), hash_kind, true, policy)
1171    }
1172
1173    fn open_inner(
1174        dir: &Path,
1175        hash_kind: gix_hash::Kind,
1176        fsync: bool,
1177        policy: CompactionPolicy,
1178    ) -> Result<Self> {
1179        std::fs::create_dir_all(dir)?;
1180        // The lock comes first: nothing may read a log it is not entitled to
1181        // append to, because the replayed state is what CAS is checked against.
1182        let lock = LockFile::acquire(dir.join(LOCK_FILE))?;
1183        let open_file = |name: &str| -> Result<Backing> {
1184            let path = dir.join(name);
1185            let file = OpenOptions::new()
1186                .read(true)
1187                .write(true)
1188                .create(true)
1189                .truncate(false)
1190                .open(&path)?;
1191            Ok(Backing::File { file, path })
1192        };
1193        Self::assemble(
1194            hash_kind,
1195            open_file(SECRETS_LOG)?,
1196            Some(lock),
1197            fsync,
1198            policy,
1199        )
1200    }
1201
1202    /// A store with no file behind it. This is the no-OS shape, and what the
1203    /// no-filesystem conformance run uses.
1204    ///
1205    /// The framing, encoding and replay code is the same as the file backing's;
1206    /// what this shape cannot speak to is durability, since there is nothing for
1207    /// an fsync to mean. The durability claim is made by the file tests only.
1208    pub fn in_memory(hash_kind: gix_hash::Kind) -> Result<(Self, MemoryLog)> {
1209        let log = MemoryLog::default();
1210        let store = Self::open_memory(&log, hash_kind)?;
1211        Ok((store, log))
1212    }
1213
1214    /// Rebuild a store by replaying `log`, the in-memory restart.
1215    pub fn open_memory(log: &MemoryLog, hash_kind: gix_hash::Kind) -> Result<Self> {
1216        Self::open_memory_with_policy(log, hash_kind, CompactionPolicy::default())
1217    }
1218
1219    /// [`ArrowLogStore::open_memory`] with a chosen compaction policy.
1220    pub fn open_memory_with_policy(
1221        log: &MemoryLog,
1222        hash_kind: gix_hash::Kind,
1223        policy: CompactionPolicy,
1224    ) -> Result<Self> {
1225        Self::assemble(
1226            hash_kind,
1227            Backing::Memory {
1228                bytes: log.secrets.clone(),
1229            },
1230            None,
1231            true,
1232            policy,
1233        )
1234    }
1235
1236    fn assemble(
1237        hash_kind: gix_hash::Kind,
1238        secrets_backing: Backing,
1239        lock: Option<LockFile>,
1240        fsync: bool,
1241        policy: CompactionPolicy,
1242    ) -> Result<Self> {
1243        let (secrets_log, secret_batches) = Log::open(secrets_backing, secret_schema(), fsync)?;
1244        let secrets_batches_on_open = secrets_log.batches;
1245        let store = ArrowLogStore {
1246            inner: Arc::new(Inner {
1247                hash_kind,
1248                actor: DEFAULT_ACTOR.to_owned(),
1249                policy,
1250                secrets_log: Mutex::new(secrets_log),
1251                secrets_state: RwLock::new(replay_secrets(&secret_batches)?),
1252                _lock: lock,
1253            }),
1254        };
1255        if policy.should_compact(secrets_batches_on_open) {
1256            if let Err(err) = store.compact_secrets_now() {
1257                tracing::warn!(%err, "could not fold an oversized secret log at open");
1258            }
1259        }
1260        Ok(store)
1261    }
1262
1263    /// Remove a lock file left behind by a process that was killed.
1264    ///
1265    /// Only ever correct when no gunnar is running against `dir`. The lock is
1266    /// what keeps Arrow stream appends single-writer; breaking one that is
1267    /// genuinely held lets two writers interleave frames and destroy the log.
1268    ///
1269    /// # This is no longer the normal recovery path
1270    ///
1271    /// [`open`](ArrowLogStore::open) reclaims a lock whose owner is **provably**
1272    /// gone, so an appliance with no shell restarts after a power cut on its
1273    /// own. What is left for this verb is the undecidable cases, where breaking
1274    /// the lock is a judgement no program should make unasked: a platform with
1275    /// no `/proc`, a lock file written by a gunnar older than the liveness
1276    /// check, or one
1277    /// whose owner line never reached the disk.
1278    pub fn force_unlock(dir: impl AsRef<Path>) -> Result<()> {
1279        match std::fs::remove_file(dir.as_ref().join(LOCK_FILE)) {
1280            Ok(()) => Ok(()),
1281            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
1282            Err(err) => Err(Error::Io(err)),
1283        }
1284    }
1285
1286    /// The hash kind this repository uses.
1287    pub fn hash_kind(&self) -> gix_hash::Kind {
1288        self.inner.hash_kind
1289    }
1290
1291    /// The compaction policy in force.
1292    pub fn compaction_policy(&self) -> CompactionPolicy {
1293        self.inner.policy
1294    }
1295
1296    /// Fold the secret log: current values only, no history, no archive.
1297    fn compact_secrets_now(&self) -> Result<()> {
1298        let mut log = self
1299            .inner
1300            .secrets_log
1301            .lock()
1302            .map_err(|_| poisoned("secret log"))?;
1303        compact_secrets(&self.inner, &mut log)
1304    }
1305
1306    /// A handle implementing [`SecretStore`].
1307    pub fn secrets(&self) -> ArrowLogSecretStore {
1308        ArrowLogSecretStore {
1309            inner: self.inner.clone(),
1310        }
1311    }
1312}
1313
1314// ───────────────────────────── shared helpers ────────────────────────────────
1315
1316fn now_unix_ms() -> i64 {
1317    SystemTime::now()
1318        .duration_since(UNIX_EPOCH)
1319        .map(|d| d.as_millis() as i64)
1320        .unwrap_or(0)
1321}
1322
1323/// An Arrow fault, as a [`gunnar_store::Error`].
1324///
1325/// `gunnar-store` used to carry `impl From<arrow_schema::ArrowError> for Error`
1326/// so a `?` did this implicitly. It does not any more: that `From` was the last
1327/// Arrow in the contract crate, and keeping one impl there would have kept the
1328/// dependency edge this module's move to `gunnar-core` exists to cut (see
1329/// `gunnar_store`'s crate header). One function, called at every site the `?`
1330/// used to cover, rather than a second opinion about what an Arrow fault is.
1331pub(crate) fn arrow_err(err: arrow_schema::ArrowError) -> Error {
1332    Error::backend(format!("arrow: {err}"))
1333}
1334
1335/// The one door between bytes on disk and arrow-ipc's `StreamReader`: znippy's
1336/// IPC guard. arrow-ipc trusts the lengths a message declares, so a hostile
1337/// file does not give an `Err` there, it panics or aborts the process (a 1 TiB
1338/// `bodyLength` is allocated before a byte of it is read). The guard checks
1339/// every length arrow is about to trust against the bytes that are there; it
1340/// reads the metadata only and copies nothing. `stream` is a framed prefix
1341/// ([`valid_prefix_len`]), so a torn tail never reaches it: what it refuses is a
1342/// well-framed message that lies, which is an error and not a truncation.
1343pub(crate) fn ipc_guard(stream: &[u8]) -> Result<()> {
1344    znippy_common::ipc_guard::check_stream(stream)
1345        .map_err(|e| Error::backend(format!("arrow: refused by the IPC guard: {e}")))
1346}
1347
1348pub(crate) fn column<'a, T: 'static>(batch: &'a RecordBatch, name: &str) -> Result<&'a T> {
1349    batch
1350        .column_by_name(name)
1351        .and_then(|c| c.as_any().downcast_ref::<T>())
1352        .ok_or_else(|| {
1353            Error::backend(format!(
1354                "the Arrow log has no usable `{name}` column; the file is not a gunnar log"
1355            ))
1356        })
1357}
1358
1359// ───────────────────────────── compaction ────────────────────────────────────
1360
1361/// The same fold for secrets, with **no archive and no window**.
1362///
1363/// Retaining superseded secret values would make a rotated host key immortal,
1364/// which is the opposite of what rotation is for.
1365fn compact_secrets(inner: &Inner, log: &mut Log) -> Result<()> {
1366    let snapshot = {
1367        let state = inner.secrets_state.read().map_err(|_| poisoned("secret"))?;
1368        let mut keys = StringBuilder::new();
1369        let mut values = BinaryBuilder::new();
1370        let mut actors = StringBuilder::new();
1371        let mut ats = Int64Builder::new();
1372        let at = now_unix_ms();
1373        for (key, value) in state.iter() {
1374            keys.append_value(key);
1375            values.append_value(value);
1376            actors.append_value(COMPACTION_ACTOR);
1377            ats.append_value(at);
1378        }
1379        RecordBatch::try_new(
1380            log.schema.clone(),
1381            vec![
1382                Arc::new(keys.finish()),
1383                Arc::new(values.finish()),
1384                Arc::new(actors.finish()),
1385                Arc::new(ats.finish()),
1386            ],
1387        )
1388        .map_err(arrow_err)?
1389    };
1390    log.rewrite(&[snapshot])
1391}
1392
1393// ───────────────────────────── secrets ───────────────────────────────────────
1394
1395/// Secrets in an Arrow IPC log: the SSH host key and the admin bootstrap
1396/// material, on a box with no home directory.
1397#[derive(Clone)]
1398pub struct ArrowLogSecretStore {
1399    inner: Arc<Inner>,
1400}
1401
1402impl std::fmt::Debug for ArrowLogSecretStore {
1403    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1404        f.write_str("ArrowLogSecretStore")
1405    }
1406}
1407
1408fn build_secret_batch(
1409    schema: &SchemaRef,
1410    actor: &str,
1411    at: i64,
1412    key: &str,
1413    value: Option<&[u8]>,
1414) -> Result<RecordBatch> {
1415    let mut keys = StringBuilder::new();
1416    let mut values = BinaryBuilder::new();
1417    let mut actors = StringBuilder::new();
1418    let mut ats = Int64Builder::new();
1419    keys.append_value(key);
1420    match value {
1421        Some(bytes) => values.append_value(bytes),
1422        None => values.append_null(),
1423    }
1424    actors.append_value(actor);
1425    ats.append_value(at);
1426    RecordBatch::try_new(
1427        schema.clone(),
1428        vec![
1429            Arc::new(keys.finish()),
1430            Arc::new(values.finish()),
1431            Arc::new(actors.finish()),
1432            Arc::new(ats.finish()),
1433        ],
1434    )
1435    .map_err(arrow_err)
1436}
1437
1438fn replay_secrets(batches: &[RecordBatch]) -> Result<BTreeMap<String, Vec<u8>>> {
1439    let mut state = BTreeMap::new();
1440    for batch in batches {
1441        let keys = column::<StringArray>(batch, COL_KEY)?;
1442        let values = column::<BinaryArray>(batch, COL_VALUE)?;
1443        for row in 0..batch.num_rows() {
1444            let key = keys.value(row).to_owned();
1445            if values.is_null(row) {
1446                state.remove(&key);
1447            } else {
1448                state.insert(key, values.value(row).to_vec());
1449            }
1450        }
1451    }
1452    Ok(state)
1453}
1454
1455impl ArrowLogSecretStore {
1456    fn write(&self, key: &str, value: Option<&[u8]>) -> Result<()> {
1457        let mut log = self
1458            .inner
1459            .secrets_log
1460            .lock()
1461            .map_err(|_| poisoned("secret log"))?;
1462        self.write_locked(&mut log, key, value)
1463    }
1464
1465    fn write_locked(&self, log: &mut Log, key: &str, value: Option<&[u8]>) -> Result<()> {
1466        let batch = build_secret_batch(&log.schema, &self.inner.actor, now_unix_ms(), key, value)?;
1467        // Append AND barrier, both under the writer mutex — deliberately *not*
1468        // the group commit the ref log does.
1469        //
1470        // A secret is written when a host key is minted or rotated, which is
1471        // rare and never concurrent, so there is nothing to group and releasing
1472        // the lock first would buy nothing while widening the window in which a
1473        // rotated secret is still readable. `append` only stages; forgetting
1474        // this line is a secret that is acknowledged and not on the platter,
1475        // which is what `a_secret_is_durable_before_the_write_returns` exists to
1476        // catch.
1477        let sequence = log.append(&batch)?;
1478        log.barrier(sequence)?;
1479        let superseded = {
1480            let mut state = self
1481                .inner
1482                .secrets_state
1483                .write()
1484                .map_err(|_| poisoned("secret"))?;
1485            match value {
1486                Some(bytes) => state.insert(key.to_owned(), bytes.to_vec()).is_some(),
1487                None => state.remove(key).is_some(),
1488            }
1489        };
1490
1491        // A superseded secret is compacted out **now**, not when a batch count
1492        // says so.
1493        //
1494        // This module's own header says "a rotated SSH host key must actually
1495        // stop existing". It did not: the log is append-only and the only
1496        // trigger was `should_compact(log.batches)`, so the previous value sat
1497        // in `secrets.arrow` for up to `after_batches` further writes — on a
1498        // store that sees a handful of writes in its lifetime, that is
1499        // *forever*. Found by `gunnar-core`'s sealing-migration test, which sealed a
1500        // legacy plaintext host key and then found the plaintext still in the
1501        // file.
1502        //
1503        // Refs deliberately keep their history; secrets are the one log where
1504        // the old value is the defect. The fold is a rewrite of a map with a
1505        // handful of entries, so doing it on every replacement costs nothing
1506        // that a threshold was protecting.
1507        if superseded {
1508            compact_secrets(&self.inner, log).map_err(|err| {
1509                Error::backend(format!(
1510                    "the new value for the secret {key} was stored, but the PREVIOUS value \
1511                     could not be compacted out of the log and is still on disk: {err}"
1512                ))
1513            })?;
1514        } else if self.inner.policy.should_compact(log.batches) {
1515            if let Err(err) = compact_secrets(&self.inner, log) {
1516                tracing::warn!(%err, "could not compact the secret log; it keeps growing");
1517            }
1518        }
1519        Ok(())
1520    }
1521
1522    fn current(&self, key: &str) -> Result<Option<Vec<u8>>> {
1523        Ok(self
1524            .inner
1525            .secrets_state
1526            .read()
1527            .map_err(|_| poisoned("secret"))?
1528            .get(key)
1529            .cloned())
1530    }
1531}
1532
1533impl SecretStore for ArrowLogSecretStore {
1534    fn get(&self, key: &str) -> Result<Option<Vec<u8>>> {
1535        self.current(key)
1536    }
1537
1538    fn put(&self, key: &str, value: &[u8]) -> Result<()> {
1539        self.write(key, Some(value))
1540    }
1541
1542    fn delete(&self, key: &str) -> Result<()> {
1543        // A tombstone for a key that is not there would grow the log for
1544        // nothing, and "delete what is absent" is explicitly not an error.
1545        if self.current(key)?.is_none() {
1546            return Ok(());
1547        }
1548        self.write(key, None)
1549    }
1550
1551    fn get_or_create(&self, key: &str, mk: &mut dyn FnMut() -> Result<Vec<u8>>) -> Result<Vec<u8>> {
1552        // The writer mutex is taken *before* the existence check, so the loser of
1553        // a race blocks here and then finds the winner's value. `mk` runs once.
1554        let mut log = self
1555            .inner
1556            .secrets_log
1557            .lock()
1558            .map_err(|_| poisoned("secret log"))?;
1559        if let Some(existing) = self.current(key)? {
1560            return Ok(existing);
1561        }
1562        let value = mk()?;
1563        self.write_locked(&mut log, key, Some(&value))?;
1564        Ok(value)
1565    }
1566}
1567
1568// ───────────────────────────── tests ─────────────────────────────────────────
1569
1570#[cfg(test)]
1571mod tests {
1572    use super::*;
1573    use gunnar_store::SecretStoreExt;
1574
1575    const KIND: gix_hash::Kind = gix_hash::Kind::Sha256;
1576
1577    fn dir() -> tempfile::TempDir {
1578        tempfile::tempdir().expect("a temporary directory")
1579    }
1580
1581    #[test]
1582    fn the_in_memory_secret_log_replays_across_a_rebuild() {
1583        let (store, log) = ArrowLogStore::in_memory(KIND).expect("in-memory store");
1584        store.secrets().put("k", b"v").expect("put");
1585        drop(store);
1586
1587        let rebuilt = ArrowLogStore::open_memory(&log, KIND).expect("replay");
1588        assert_eq!(
1589            rebuilt.secrets().get("k").expect("get"),
1590            Some(b"v".to_vec())
1591        );
1592    }
1593
1594    #[test]
1595    fn a_secret_delete_is_a_tombstone_that_survives_a_reopen() {
1596        let dir = dir();
1597        {
1598            let store = ArrowLogStore::open(dir.path(), KIND).expect("open");
1599            store.secrets().put("k", b"v1").expect("put");
1600            store.secrets().delete("k").expect("delete");
1601        }
1602        let store = ArrowLogStore::open(dir.path(), KIND).expect("reopen");
1603        assert!(store.secrets().get("k").expect("get").is_none());
1604        store.secrets().put("k", b"v2").expect("put again");
1605        assert_eq!(store.secrets().get("k").expect("get"), Some(b"v2".to_vec()));
1606    }
1607
1608    #[test]
1609    fn a_rotated_secret_leaves_no_trace_of_the_previous_value_on_disk() {
1610        let dir = dir();
1611        let old = b"OLD-SECRET-VALUE-that-must-not-survive-rotation";
1612        let new = b"NEW-SECRET-VALUE-after-the-rotation";
1613
1614        let store = ArrowLogStore::open(dir.path(), KIND).expect("open");
1615        store.secrets().put("ssh/host_ed25519", old).expect("put");
1616
1617        let contains = |needle: &[u8]| {
1618            let bytes = std::fs::read(dir.path().join(SECRETS_LOG)).expect("read the secret log");
1619            assert!(!bytes.is_empty(), "the secret log is empty");
1620            bytes.windows(needle.len()).any(|w| w == needle)
1621        };
1622        assert!(contains(old));
1623
1624        store
1625            .secrets()
1626            .put("ssh/host_ed25519", new)
1627            .expect("rotate");
1628        assert!(!contains(old), "the previous secret survived rotation");
1629        assert!(contains(new), "the new value is not in the log");
1630        assert_eq!(
1631            store.secrets().get("ssh/host_ed25519").expect("get"),
1632            Some(new.to_vec())
1633        );
1634
1635        store.secrets().delete("ssh/host_ed25519").expect("delete");
1636        assert!(
1637            !contains(new),
1638            "a deleted secret's value is still in the log"
1639        );
1640    }
1641
1642    #[test]
1643    fn compacting_secrets_does_not_retain_superseded_values() {
1644        let dir = dir();
1645        let store = ArrowLogStore::open_with_policy(dir.path(), KIND, CompactionPolicy::new(4, 0))
1646            .expect("open");
1647        for generation in 0..12 {
1648            store
1649                .secrets()
1650                .put(
1651                    "host-key",
1652                    format!("secret-generation-{generation:02}").as_bytes(),
1653                )
1654                .expect("put");
1655        }
1656
1657        let haystack = String::from_utf8_lossy(
1658            &std::fs::read(dir.path().join(SECRETS_LOG)).expect("read the secret log"),
1659        )
1660        .into_owned();
1661        assert!(!haystack.contains("secret-generation-00"));
1662        assert!(haystack.contains("secret-generation-11"));
1663        assert!(!dir.path().join(REFS_ARCHIVE_DIR).exists());
1664        drop(store);
1665
1666        let reopened =
1667            ArrowLogStore::open_with_policy(dir.path(), KIND, CompactionPolicy::new(4, 0))
1668                .expect("reopen");
1669        assert_eq!(
1670            reopened.secrets().get("host-key").expect("get"),
1671            Some(b"secret-generation-11".to_vec())
1672        );
1673    }
1674
1675    #[test]
1676    fn get_or_create_does_not_remint_across_a_reopen() {
1677        let dir = dir();
1678        let first = {
1679            let store = ArrowLogStore::open(dir.path(), KIND).expect("open");
1680            store
1681                .secrets()
1682                .get_or_create_with(gunnar_store::SSH_HOST_KEY, || Ok(b"host-key-1".to_vec()))
1683                .expect("mint")
1684        };
1685        let store = ArrowLogStore::open(dir.path(), KIND).expect("reopen");
1686        let second = store
1687            .secrets()
1688            .get_or_create_with(gunnar_store::SSH_HOST_KEY, || {
1689                panic!("the host key was re-minted after a restart")
1690            })
1691            .expect("read back");
1692        assert_eq!(first, second);
1693    }
1694
1695    #[test]
1696    fn a_secret_is_durable_before_the_write_returns() {
1697        let dir = dir();
1698        let store = ArrowLogStore::open(dir.path(), KIND).expect("open");
1699        store
1700            .secrets()
1701            .put(gunnar_store::SSH_HOST_KEY, b"a-host-key")
1702            .expect("write a secret");
1703
1704        let on_disk = std::fs::metadata(dir.path().join(SECRETS_LOG))
1705            .expect("stat")
1706            .len();
1707        let durability = &store
1708            .inner
1709            .secrets_log
1710            .lock()
1711            .expect("the writer")
1712            .durability;
1713        let durable = durability.durable.load(Ordering::SeqCst);
1714        let written = durability.written.load(Ordering::SeqCst);
1715        assert_eq!(written, on_disk);
1716        assert_eq!(durable, written);
1717        assert!(durability.syncs.load(Ordering::SeqCst) >= 2);
1718    }
1719}