Skip to main content

boatramp_node/
default_scope.rs

1//! Substrate-complete default-scope readiness (v0.31.0 Finding 8).
2//!
3//! The KV re-home + the serve version-guard that `boatramp_core::migrate` owns are KV-only. A
4//! default project's data also lives in BLOB (`hblob/`, `mqp/`, `mqgp/`) and LIBSQL (`{dir}/{site}.db`),
5//! which the KV version cannot observe — so a blob-only or SQL-only default project could pass the KV
6//! guard as `Ready` while its data strands (HIGH-1/2/3 of the security review). These node-level
7//! detectors make the re-home observable across those substrates so the serve guard and
8//! `boatramp migrate --finalize` can refuse/warn/sequence until ALL substrates are re-homed.
9//!
10//! The critical asymmetry: BLOB has a runtime read-fallback during the KV dual soak (a cold object is
11//! bridged), so an outstanding blob is a WARN while the soak is live. LIBSQL has **no** runtime
12//! fallback — `database("default", site)` would open the non-existent `{dir}/default/{site}.db` and
13//! auto-create an EMPTY database — so an un-re-homed default libsql database is a hard **REFUSE**.
14
15use std::collections::HashSet;
16use std::path::Path;
17use std::sync::Arc;
18
19use boatramp_core::project::DEFAULT_PROJECT;
20use boatramp_core::{Storage, StorageError};
21
22/// The guest-blob data families the default-scope re-home covers (mirrors
23/// [`crate::blobs::DEFAULT_SCOPE_BLOB_FAMILIES`]).
24const BLOB_FAMILIES: &[&str] = &["hblob/", "mqp/", "mqgp/"];
25
26/// Control-plane KV marker set by `boatramp blob rehome --finalize` once the blob substrate is
27/// verified retired (no bare default-scoped object remains), and REQUIRED by `boatramp migrate
28/// --finalize` before it clears the KV dual soak (which disengages the runtime blob read-fallback).
29/// The explicit "I retired the blob substrate" signal — set by the command that holds the blob
30/// backend (and resolves its credentials), read by the KV finalize that does not. A node with no
31/// blob data still sets it by running `blob rehome --finalize` once (a 0-object no-op).
32pub const BLOB_RETIRED_MARKER: &str = "schema/default-scope-blob-retired";
33
34/// Un-re-homed default-project **libsql** identities still at the bare (pre-`default/`) on-disk
35/// layout under `libsql_dir`. A default site's data moves from `{dir}/{site}.db` (and named DBs from
36/// `{dir}/{site}/{name}.db`, functions from `{dir}/fn/{name}.db`) to under `{dir}/default/…`; so:
37///
38/// - a top-level `*.db` FILE is a default site's default DB, un-re-homed;
39/// - a top-level SUBDIR whose name is neither the reserved `default` (already re-homed) nor a KNOWN
40///   project (`projects`, that project's own DBs) holds a default site/function's named/preview DBs,
41///   un-re-homed.
42///
43/// Empty ⇒ nothing bare (re-homed, or no libsql dir). Returned identities are human strings for the
44/// refusal message. This is why the serve guard must REFUSE (not just warn) on a non-empty result:
45/// libsql has no runtime fallback, so serving would boot a default-project SQL workload onto an
46/// empty database.
47pub fn libsql_bare_default_idents(libsql_dir: &Path, projects: &HashSet<String>) -> Vec<String> {
48    let mut out = Vec::new();
49    let Ok(entries) = std::fs::read_dir(libsql_dir) else {
50        return out; // no libsql dir ⇒ nothing bare
51    };
52    for entry in entries.flatten() {
53        let name = entry.file_name().to_string_lossy().to_string();
54        let Ok(ft) = entry.file_type() else { continue };
55        if ft.is_file() {
56            // `{site}.db` (the `-wal`/`-shm` sidecars end in `.db-wal`/`.db-shm`, not `.db`).
57            if let Some(site) = name.strip_suffix(".db")
58                && !site.is_empty()
59            {
60                out.push(format!("site `{site}` ({})", entry.path().display()));
61            }
62        } else if ft.is_dir() && name != DEFAULT_PROJECT && !projects.contains(&name) {
63            // A default site/function subdir (named/preview DBs). A known project's subdir and the
64            // re-homed `default/` subdir are left alone.
65            out.push(format!(
66                "`{name}/` named/preview databases ({})",
67                entry.path().display()
68            ));
69        }
70    }
71    out.sort();
72    out
73}
74
75/// Whether any BARE default-scoped blob object remains under `hblob/`/`mqp/`/`mqgp/` on `storage` —
76/// i.e. a key whose scope segment is neither the reserved `default` (already re-homed) nor a KNOWN
77/// project (`projects`, that project's own object). Short-circuits on the first such key. Used by
78/// `boatramp migrate --finalize` to REFUSE clearing the KV dual soak (which disengages the runtime
79/// blob read-fallback) until the blob substrate is drained — otherwise a cold default object would
80/// 404 after finalize.
81pub async fn blob_default_outstanding(
82    storage: &Arc<dyn Storage>,
83    projects: &HashSet<String>,
84) -> Result<bool, StorageError> {
85    for family in BLOB_FAMILIES {
86        for meta in storage.list(family).await? {
87            let Some(rest) = meta.key.strip_prefix(family) else {
88                continue;
89            };
90            let seg = rest.split('/').next().unwrap_or("");
91            if !seg.is_empty() && seg != DEFAULT_PROJECT && !projects.contains(seg) {
92                return Ok(true); // a bare default-scoped object remains
93            }
94        }
95    }
96    Ok(false)
97}
98
99/// Read the `projectmeta/` project-name set (including the reserved `default`) from the control-plane
100/// KV — the set both detectors use to tell a bare default scope from a named project.
101pub async fn known_projects(
102    kv: &dyn boatramp_core::kv::KvStore,
103) -> Result<HashSet<String>, boatramp_core::error::KvError> {
104    let mut set = HashSet::new();
105    for key in kv
106        .list_prefix(boatramp_core::project::POINTER_PREFIX)
107        .await?
108    {
109        if let Some(name) = key.strip_prefix(boatramp_core::project::POINTER_PREFIX)
110            && !name.is_empty()
111            && !name.contains('/')
112        {
113            set.insert(name.to_string());
114        }
115    }
116    Ok(set)
117}
118
119/// The libsql data directory for a node config's `handlers.bindings.sql` (single-node), mirroring
120/// `handlers::build_sql_backends`: `handlers.bindings.sql.dir`, else `{data_dir}/handlers-sql`. Only
121/// meaningful for a LOCAL (non-`url`) libsql binding — a remote sqld has no local directory.
122pub fn libsql_dir_for(
123    config: &crate::config::ServerConfig,
124    data_dir: &Path,
125) -> Option<std::path::PathBuf> {
126    let sql = config
127        .handlers
128        .as_ref()
129        .and_then(|h| h.bindings.sql.as_ref());
130    // A remote sqld binding has no local directory to scan.
131    if sql.and_then(|c| c.url.as_ref()).is_some() {
132        return None;
133    }
134    Some(
135        sql.and_then(|c| c.dir.clone())
136            .unwrap_or_else(|| data_dir.join("handlers-sql")),
137    )
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn libsql_detector_flags_bare_default_and_skips_rehomed_and_projects() {
146        let tmp = tempfile::tempdir().unwrap();
147        let dir = tmp.path();
148        // Bare default site default DB (+ sidecar, which must be ignored).
149        std::fs::write(dir.join("blog.db"), b"x").unwrap();
150        std::fs::write(dir.join("blog.db-wal"), b"x").unwrap();
151        // A default function's named-DB subdir (fn/ is neither default nor a project).
152        std::fs::create_dir_all(dir.join("fn")).unwrap();
153        std::fs::write(dir.join("fn").join("resize.db"), b"x").unwrap();
154        // Already re-homed (default/) — must be skipped.
155        std::fs::create_dir_all(dir.join("default")).unwrap();
156        std::fs::write(dir.join("default").join("shop.db"), b"x").unwrap();
157        // A named project's subdir — must be skipped.
158        std::fs::create_dir_all(dir.join("acme")).unwrap();
159        std::fs::write(dir.join("acme").join("blog.db"), b"x").unwrap();
160
161        let projects: HashSet<String> = ["default", "acme"].iter().map(|s| s.to_string()).collect();
162        let bare = libsql_bare_default_idents(dir, &projects);
163        // Flags the bare `blog.db` + the `fn/` subdir; skips `default/` and `acme/`.
164        assert_eq!(bare.len(), 2, "got {bare:?}");
165        assert!(bare.iter().any(|s| s.contains("site `blog`")));
166        assert!(bare.iter().any(|s| s.contains("`fn/`")));
167        assert!(!bare.iter().any(|s| s.contains("acme")));
168        assert!(!bare.iter().any(|s| s.contains("shop")));
169    }
170
171    #[test]
172    fn libsql_detector_empty_when_all_rehomed_or_absent() {
173        // Absent dir ⇒ empty.
174        assert!(
175            libsql_bare_default_idents(Path::new("/nonexistent/xyz"), &HashSet::new()).is_empty()
176        );
177        // Only a `default/` subdir ⇒ empty (fully re-homed).
178        let tmp = tempfile::tempdir().unwrap();
179        std::fs::create_dir_all(tmp.path().join("default")).unwrap();
180        std::fs::write(tmp.path().join("default").join("blog.db"), b"x").unwrap();
181        assert!(libsql_bare_default_idents(tmp.path(), &HashSet::new()).is_empty());
182    }
183
184    /// A list-only in-memory `Storage` double (only `list` is exercised by the detector).
185    #[derive(Default)]
186    struct KeyStore {
187        keys: Vec<String>,
188    }
189    #[async_trait::async_trait]
190    impl Storage for KeyStore {
191        async fn get(&self, key: &str) -> Result<boatramp_core::GetObject, StorageError> {
192            Err(StorageError::NotFound(key.into()))
193        }
194        async fn get_range(
195            &self,
196            key: &str,
197            _: u64,
198            _: Option<u64>,
199        ) -> Result<boatramp_core::GetObject, StorageError> {
200            Err(StorageError::NotFound(key.into()))
201        }
202        async fn put(
203            &self,
204            key: &str,
205            _: boatramp_core::ByteStream,
206            _: boatramp_core::PutMeta,
207        ) -> Result<boatramp_core::ObjectMeta, StorageError> {
208            Err(StorageError::unsupported(key))
209        }
210        async fn head(&self, key: &str) -> Result<boatramp_core::ObjectMeta, StorageError> {
211            Err(StorageError::NotFound(key.into()))
212        }
213        async fn delete(&self, _: &str) -> Result<(), StorageError> {
214            Ok(())
215        }
216        async fn list(&self, prefix: &str) -> Result<Vec<boatramp_core::ObjectMeta>, StorageError> {
217            Ok(self
218                .keys
219                .iter()
220                .filter(|k| k.starts_with(prefix))
221                .map(|k| boatramp_core::ObjectMeta {
222                    key: k.clone(),
223                    size: Some(1),
224                    content_type: None,
225                    etag: None,
226                })
227                .collect())
228        }
229    }
230
231    #[tokio::test]
232    async fn blob_detector_true_for_bare_default_false_when_rehomed_or_named() {
233        let projects: HashSet<String> = ["default", "acme"].iter().map(|s| s.to_string()).collect();
234
235        // A bare default-scoped object (`hblob/{site}/…`, site not a project) ⇒ outstanding.
236        let bare: Arc<dyn Storage> = Arc::new(KeyStore {
237            keys: vec![
238                "hblob/default/shop/c/ok".into(), // already re-homed
239                "hblob/acme/site/c/ok".into(),    // named project
240                "hblob/blog/c/needs".into(),      // BARE default scope — the one that matters
241            ],
242        });
243        assert!(
244            blob_default_outstanding(&bare, &projects).await.unwrap(),
245            "a bare default-scoped blob object must read as outstanding"
246        );
247
248        // Only re-homed + named-project objects (across all three families) ⇒ NOT outstanding.
249        let clean: Arc<dyn Storage> = Arc::new(KeyStore {
250            keys: vec![
251                "hblob/default/blog/c/ok".into(),
252                "mqp/default/blog/orders/17".into(),
253                "mqgp/default/blog/orders/18".into(),
254                "hblob/acme/site/c/ok".into(),
255            ],
256        });
257        assert!(
258            !blob_default_outstanding(&clean, &projects).await.unwrap(),
259            "re-homed + named-project objects only must NOT read as outstanding"
260        );
261    }
262}