Skip to main content

mongreldb_core/
catalog.rs

1//! DB-wide catalog checkpoint (spec ยง5.1).
2//!
3//! The catalog records every table's id, name, schema, and live/dropped state
4//! plus the DB-wide monotonic counters (`db_epoch`, `next_table_id`,
5//! `open_generation` (sidecar), `next_segment_no`). CATALOG is rewritten on
6//! DDL and persisted to `<root>/CATALOG` with:
7//!
8//! - a fixed magic + SHA-256 integrity tag for plaintext, or
9//! - AES-256-GCM (`meta_dek`-derived via [`Kek::derive_meta_key`]) which both
10//!   encrypts and authenticates, plus
11//! - a directory `sync_all` after the atomic rename (review fix #19), so a crash
12//!   never leaves a half-linked catalog entry.
13
14use crate::error::{MongrelError, Result};
15use crate::external_table::ExternalTableEntry;
16use crate::procedure::ProcedureEntry;
17use crate::schema::Schema;
18use crate::trigger::TriggerEntry;
19use serde::{Deserialize, Serialize};
20use sha2::{Digest, Sha256};
21use std::io::Write;
22use std::path::Path;
23
24pub const CATALOG_FILENAME: &str = "CATALOG";
25const MAGIC: &[u8; 8] = b"MONGRCAT";
26/// 32-byte meta DEK length (matches [`crate::encryption::DEK_LEN`]).
27pub const META_DEK_LEN: usize = 32;
28
29/// Lifecycle state of a catalog table entry.
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
31pub enum TableState {
32    /// Live and queryable.
33    Live,
34    /// Logically dropped at `at_epoch`; the physical subdir is reaped once no
35    /// reader pins a snapshot older than `at_epoch`.
36    Dropped { at_epoch: u64 },
37}
38
39/// One row of the catalog.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct CatalogEntry {
42    pub table_id: u64,
43    pub name: String,
44    pub schema: Schema,
45    pub state: TableState,
46    pub created_epoch: u64,
47}
48
49/// The full in-memory catalog, mirrored on disk by [`write_atomic`].
50///
51/// Note: `open_generation` is intentionally **not** stored here โ€” it bumps on
52/// every open, and keeping it in CATALOG would dirty the working tree even for
53/// a bare read. It lives in a separate sidecar file (`_meta/generation`) that
54/// callers can `.gitignore` for content-addressed storage workflows.
55#[derive(Debug, Clone, Serialize, Deserialize, Default)]
56pub struct Catalog {
57    /// Highest epoch ever assigned by this DB's commit sequencer.
58    pub db_epoch: u64,
59    /// Next table id to allocate.
60    pub next_table_id: u64,
61    /// Next shared-WAL segment number to allocate.
62    pub next_segment_no: u64,
63    pub tables: Vec<CatalogEntry>,
64    #[serde(default)]
65    pub procedures: Vec<ProcedureEntry>,
66    #[serde(default)]
67    pub triggers: Vec<TriggerEntry>,
68    #[serde(default)]
69    pub external_tables: Vec<ExternalTableEntry>,
70    /// Catalog-level user accounts (Argon2id-hashed credentials).
71    #[serde(default)]
72    pub users: Vec<crate::auth::UserEntry>,
73    /// Catalog-level role definitions.
74    #[serde(default)]
75    pub roles: Vec<crate::auth::RoleEntry>,
76    /// Next monotonic user id to allocate.
77    #[serde(default)]
78    pub next_user_id: u64,
79    /// When true, every Database/Table/Transaction/MongrelSession operation
80    /// requires an authenticated `Principal` with sufficient permission.
81    /// Defaults to false โ†’ existing credentialless databases open unchanged.
82    /// See `docs/15-credential-enforcement.md`.
83    #[serde(default)]
84    pub require_auth: bool,
85}
86
87/// The `open_generation` counter, stored in `_meta/generation` (NOT in CATALOG).
88/// Bumped on every open to scope `txn_id` across reopens so ids never alias.
89///
90/// Kept as a sidecar so that CATALOG is stable across bare opens โ€” the only
91/// volatile bytes in the database directory during a read-only session are
92/// this 8-byte file + `.lock` + caches, all of which can be `.gitignore`-d.
93pub const GENERATION_FILENAME: &str = "generation";
94
95/// Read `open_generation` from `_meta/generation`. Returns 0 if the file is
96/// missing (first open or after a fresh `create`). On legacy databases that
97/// stored `open_generation` inside CATALOG, the migration path is: if the
98/// sidecar doesn't exist, default to 0 (the counter only needs to be
99/// monotonic within a process's lifetime for txn-id uniqueness).
100pub fn read_generation(meta_dir: &Path) -> u64 {
101    let p = meta_dir.join(GENERATION_FILENAME);
102    match std::fs::read(&p) {
103        Ok(bytes) => {
104            if bytes.len() >= 8 {
105                let mut arr = [0u8; 8];
106                arr.copy_from_slice(&bytes[..8]);
107                u64::from_le_bytes(arr)
108            } else {
109                0
110            }
111        }
112        Err(_) => 0,
113    }
114}
115
116/// Write `open_generation` to `_meta/generation` atomically (temp + rename +
117/// fsync). This is intentionally a separate file from CATALOG so that CATALOG
118/// stays byte-stable across bare opens (the generation counter is the only
119/// field that changes on every open).
120pub fn write_generation(meta_dir: &Path, generation: u64) -> Result<()> {
121    std::fs::create_dir_all(meta_dir)?;
122    let tmp = meta_dir.join(format!(".{GENERATION_FILENAME}.tmp"));
123    {
124        let mut f = std::fs::File::create(&tmp)?;
125        f.write_all(&generation.to_le_bytes())?;
126        f.sync_all()?;
127    }
128    let dest = meta_dir.join(GENERATION_FILENAME);
129    std::fs::rename(&tmp, &dest)?;
130    if let Ok(d) = std::fs::File::open(meta_dir) {
131        let _ = d.sync_all();
132    }
133    Ok(())
134}
135
136impl Catalog {
137    /// An empty catalog for a freshly created DB.
138    pub fn empty() -> Self {
139        Catalog::default()
140    }
141
142    /// Look up an entry by name (live only).
143    pub fn live(&self, name: &str) -> Option<&CatalogEntry> {
144        self.tables
145            .iter()
146            .find(|t| t.name == name && matches!(t.state, TableState::Live))
147    }
148}
149
150#[cfg(feature = "encryption")]
151fn seal(body: &[u8], meta_dek: Option<&[u8; META_DEK_LEN]>) -> Result<Vec<u8>> {
152    match meta_dek {
153        Some(dek) => crate::encryption::encrypt_blob(dek, body),
154        None => Ok(plaintext_frame(body)),
155    }
156}
157
158#[cfg(not(feature = "encryption"))]
159fn seal(body: &[u8], _meta_dek: Option<&[u8; META_DEK_LEN]>) -> Result<Vec<u8>> {
160    Ok(plaintext_frame(body))
161}
162
163fn plaintext_frame(body: &[u8]) -> Vec<u8> {
164    let hash = Sha256::digest(body);
165    let mut out = Vec::with_capacity(body.len() + 8 + 32);
166    out.extend_from_slice(MAGIC);
167    out.extend_from_slice(&hash);
168    out.extend_from_slice(body);
169    out
170}
171
172/// Atomically write the catalog to `<dir>/CATALOG` (review fix #19: dir-fsync).
173///
174/// If `meta_dek` is `Some`, the body is AES-256-GCM sealed (confidential +
175/// authenticated); otherwise the body carries a SHA-256 tag (integrity only).
176pub fn write_atomic(
177    dir: &Path,
178    cat: &Catalog,
179    meta_dek: Option<&[u8; META_DEK_LEN]>,
180) -> Result<()> {
181    let body = serde_json::to_vec(cat)
182        .map_err(|e| MongrelError::Other(format!("catalog serialize: {e}")))?;
183    let payload = seal(&body, meta_dek)?;
184
185    let tmp = dir.join(format!(".{CATALOG_FILENAME}.tmp"));
186    {
187        let mut f = std::fs::File::create(&tmp)?;
188        f.write_all(&payload)?;
189        f.sync_all()?;
190    }
191    let dest = dir.join(CATALOG_FILENAME);
192    std::fs::rename(&tmp, &dest)?;
193    // Directory fsync so the rename is durable across a crash.
194    if let Ok(d) = std::fs::File::open(dir) {
195        let _ = d.sync_all();
196    }
197    Ok(())
198}
199
200#[cfg(feature = "encryption")]
201fn open_payload(bytes: &[u8], meta_dek: Option<&[u8; META_DEK_LEN]>) -> Result<Option<Catalog>> {
202    match meta_dek {
203        Some(dek) => match crate::encryption::decrypt_blob(dek, bytes) {
204            Ok(body) => deserialize(&body),
205            Err(_) => Ok(None),
206        },
207        None => parse_plaintext(bytes),
208    }
209}
210
211#[cfg(not(feature = "encryption"))]
212fn open_payload(bytes: &[u8], _meta_dek: Option<&[u8; META_DEK_LEN]>) -> Result<Option<Catalog>> {
213    parse_plaintext(bytes)
214}
215
216fn deserialize(body: &[u8]) -> Result<Option<Catalog>> {
217    serde_json::from_slice(body)
218        .map(Some)
219        .map_err(|e| MongrelError::Other(format!("catalog deserialize: {e}")))
220}
221
222/// Read the catalog from `<dir>/CATALOG`. Returns `Ok(None)` if no catalog is
223/// present, or if authentication fails (tampered / wrong key).
224pub fn read(dir: &Path, meta_dek: Option<&[u8; META_DEK_LEN]>) -> Result<Option<Catalog>> {
225    let p = dir.join(CATALOG_FILENAME);
226    let bytes = match std::fs::read(&p) {
227        Ok(b) => b,
228        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
229        Err(e) => return Err(e.into()),
230    };
231    open_payload(&bytes, meta_dek)
232}
233
234fn parse_plaintext(bytes: &[u8]) -> Result<Option<Catalog>> {
235    if bytes.len() < 8 + 32 || &bytes[..8] != MAGIC {
236        return Ok(None);
237    }
238    let (tag, body) = bytes[8..].split_at(32);
239    let calc = Sha256::digest(body);
240    if tag != calc.as_slice() {
241        // tampered
242        return Ok(None);
243    }
244    deserialize(body)
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    #[test]
252    fn empty_catalog_default() {
253        let c = Catalog::empty();
254        assert_eq!(c.db_epoch, 0);
255        assert_eq!(c.next_table_id, 0);
256        assert!(c.tables.is_empty());
257    }
258}