max / balanced_breakfast
9 files changed,
+268 insertions,
-10 deletions
| @@ -530,6 +530,7 @@ dependencies = [ | |||
| 530 | 530 | "serde", | |
| 531 | 531 | "serde_json", | |
| 532 | 532 | "sqlx", | |
| 533 | + | "synckit-config", | |
| 533 | 534 | "thiserror 2.0.18", | |
| 534 | 535 | "tokio", | |
| 535 | 536 | "tracing", | |
| @@ -5887,6 +5888,7 @@ dependencies = [ | |||
| 5887 | 5888 | "serde", | |
| 5888 | 5889 | "serde_json", | |
| 5889 | 5890 | "sha2 0.11.0", | |
| 5891 | + | "synckit-config", | |
| 5890 | 5892 | "thiserror 2.0.18", | |
| 5891 | 5893 | "tokio", | |
| 5892 | 5894 | "tokio-stream", | |
| @@ -5899,6 +5901,14 @@ dependencies = [ | |||
| 5899 | 5901 | ] | |
| 5900 | 5902 | ||
| 5901 | 5903 | [[package]] | |
| 5904 | + | name = "synckit-config" | |
| 5905 | + | version = "0.1.2" | |
| 5906 | + | dependencies = [ | |
| 5907 | + | "rusqlite", | |
| 5908 | + | "thiserror 2.0.18", | |
| 5909 | + | ] | |
| 5910 | + | ||
| 5911 | + | [[package]] | |
| 5902 | 5912 | name = "synstructure" | |
| 5903 | 5913 | version = "0.13.2" | |
| 5904 | 5914 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -67,6 +67,7 @@ bb-pdf = { path = "crates/bb-pdf" } | |||
| 67 | 67 | # External sibling crates (shared under ~/Code/Libraries) | |
| 68 | 68 | supernote-push = { path = "../../Libraries/supernote-push" } | |
| 69 | 69 | synckit-client = { path = "../../synckit/synckit-client" } | |
| 70 | + | synckit-config = { path = "../../synckit/synckit-config" } | |
| 70 | 71 | tauri = "2.10.2" | |
| 71 | 72 | tauri-plugin-updater = "2" | |
| 72 | 73 | tagtree = { path = "../../MNW/shared/tagtree" } |
| @@ -7,6 +7,7 @@ description = "Database layer for BalancedBreakfast" | |||
| 7 | 7 | [dependencies] | |
| 8 | 8 | bb-interface.workspace = true | |
| 9 | 9 | sqlx.workspace = true | |
| 10 | + | synckit-config.workspace = true | |
| 10 | 11 | tokio.workspace = true | |
| 11 | 12 | thiserror.workspace = true | |
| 12 | 13 | tracing.workspace = true |
| @@ -0,0 +1,95 @@ | |||
| 1 | + | //! Single source of truth for `user_config` keys and their sync posture. | |
| 2 | + | //! | |
| 3 | + | //! BalancedBreakfast used to sync every `user_config` key unconditionally: the | |
| 4 | + | //! table's triggers enqueued a changelog row for any key, and the initial | |
| 5 | + | //! snapshot swept the whole table. That is the same shape audiofiles was | |
| 6 | + | //! hardened away from (fuzz-2026-07-06 #2, -07-20 #1, -07-21 #3): a config key | |
| 7 | + | //! that names device state must never cross the sync boundary, and "sync | |
| 8 | + | //! everything" cannot hold one back. | |
| 9 | + | //! | |
| 10 | + | //! [`CONFIG`] is the family's shared [`ConfigSpec`] for BB's `user_config` | |
| 11 | + | //! table, declaring each known key's [`Posture`]. It drives two filters: | |
| 12 | + | //! | |
| 13 | + | //! - the SQL export filter (the `config_key_policy` table, seeded from this spec | |
| 14 | + | //! by [`Database::seed_config_key_policy`](crate::Database::seed_config_key_policy) | |
| 15 | + | //! and joined by the `user_config` triggers and the initial snapshot), and | |
| 16 | + | //! - the Rust import filter ([`key_excluded_from_sync`]), applied when a remote | |
| 17 | + | //! pull batch is written back. | |
| 18 | + | //! | |
| 19 | + | //! Storage stays sqlx (BB's `ConfigRepository`), not `synckit_config::ConfigStore` | |
| 20 | + | //! (rusqlite): this crate takes only the pure-data half of synckit-config, the | |
| 21 | + | //! spec and posture vocabulary, so a sqlx app shares the declaration without the | |
| 22 | + | //! rusqlite store. Posture is fail-closed: an undeclared key is | |
| 23 | + | //! [`Local`](Posture::Local) and never syncs. | |
| 24 | + | //! | |
| 25 | + | //! <!-- wiki: bb-config --> | |
| 26 | + | ||
| 27 | + | use synckit_config::{ConfigSpec, Posture}; | |
| 28 | + | ||
| 29 | + | /// BB's `user_config` posture declaration. | |
| 30 | + | /// | |
| 31 | + | /// Only `theme` crosses the sync boundary. `bb-welcomed` is a per-device | |
| 32 | + | /// first-run flag (each device shows the welcome once). `bb-theme` is the legacy | |
| 33 | + | /// theme key read once to migrate the old value into `theme`; it must not sync | |
| 34 | + | /// on its own. Every other key the generic `set_config` command might write is | |
| 35 | + | /// undeclared and therefore `Local` (fail-closed). | |
| 36 | + | pub const CONFIG: ConfigSpec = ConfigSpec::new( | |
| 37 | + | "user_config", | |
| 38 | + | &[ | |
| 39 | + | // Synced: user preferences carried across the user's devices. | |
| 40 | + | ("theme", Posture::Synced), | |
| 41 | + | // Local: per-device state and the legacy alias, never synced. | |
| 42 | + | ("bb-welcomed", Posture::Local), | |
| 43 | + | ("bb-theme", Posture::Local), | |
| 44 | + | ], | |
| 45 | + | ); | |
| 46 | + | ||
| 47 | + | /// Import-side filter: true when a raw remote `user_config` key must be dropped | |
| 48 | + | /// rather than written back. A key the spec does not clear to sync is refused, | |
| 49 | + | /// which by the spec's fail-closed default also refuses any unknown key: a | |
| 50 | + | /// hostile server cannot set a device-local or unrecognized key by sending it. | |
| 51 | + | #[must_use] | |
| 52 | + | pub fn key_excluded_from_sync(key: &str) -> bool { | |
| 53 | + | !CONFIG.is_synced(key) | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | #[cfg(test)] | |
| 57 | + | mod tests { | |
| 58 | + | use super::*; | |
| 59 | + | ||
| 60 | + | #[test] | |
| 61 | + | fn only_theme_crosses_the_boundary() { | |
| 62 | + | assert!(CONFIG.is_synced("theme"), "theme is a shared preference"); | |
| 63 | + | assert!(!CONFIG.is_synced("bb-welcomed"), "welcome is per-device"); | |
| 64 | + | assert!( | |
| 65 | + | !CONFIG.is_synced("bb-theme"), | |
| 66 | + | "the legacy alias never syncs" | |
| 67 | + | ); | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | #[test] | |
| 71 | + | fn the_import_filter_refuses_local_and_unknown_keys() { | |
| 72 | + | assert!(!key_excluded_from_sync("theme"), "theme is admitted"); | |
| 73 | + | assert!( | |
| 74 | + | key_excluded_from_sync("bb-welcomed"), | |
| 75 | + | "per-device stays home" | |
| 76 | + | ); | |
| 77 | + | assert!( | |
| 78 | + | key_excluded_from_sync("totally_unknown"), | |
| 79 | + | "an unknown key fails closed", | |
| 80 | + | ); | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // The policy rows the export filter is seeded from must carry every declared | |
| 84 | + | // key, marking only `theme` replicated. If this drifted, the SQL export and | |
| 85 | + | // the Rust import would disagree. | |
| 86 | + | #[test] | |
| 87 | + | fn policy_rows_replicate_only_theme() { | |
| 88 | + | let replicated: Vec<&str> = CONFIG | |
| 89 | + | .policy_rows() | |
| 90 | + | .filter(|row| row.replicated) | |
| 91 | + | .map(|row| row.key) | |
| 92 | + | .collect(); | |
| 93 | + | assert_eq!(replicated, ["theme"]); | |
| 94 | + | } | |
| 95 | + | } |
| @@ -1,9 +1,11 @@ | |||
| 1 | 1 | //! BalancedBreakfast Database Layer | |
| 2 | 2 | ||
| 3 | + | pub mod config_key; | |
| 3 | 4 | pub mod id_types; | |
| 4 | 5 | pub mod models; | |
| 5 | 6 | pub mod repository; | |
| 6 | 7 | ||
| 8 | + | pub use config_key::{CONFIG, key_excluded_from_sync}; | |
| 7 | 9 | pub use id_types::*; | |
| 8 | 10 | pub use models::*; | |
| 9 | 11 | pub use repository::*; | |
| @@ -40,12 +42,21 @@ impl Database { | |||
| 40 | 42 | Ok(Self { pool }) | |
| 41 | 43 | } | |
| 42 | 44 | ||
| 43 | - | /// Run migrations | |
| 45 | + | /// Run migrations, then re-seed the config sync policy. | |
| 46 | + | /// | |
| 47 | + | /// The seed runs at every open (not baked into a migration) so [`CONFIG`] | |
| 48 | + | /// stays the single source of truth for which `user_config` keys sync: | |
| 49 | + | /// changing a key's posture in Rust takes effect on the next start, no | |
| 50 | + | /// migration needed. `migrate` is the one path every caller (app and tests) | |
| 51 | + | /// takes, so folding the seed in here means the export triggers always have | |
| 52 | + | /// a policy table to join. | |
| 44 | 53 | #[tracing::instrument(skip_all)] | |
| 45 | 54 | pub async fn migrate(&self) -> Result<(), sqlx::migrate::MigrateError> { | |
| 46 | 55 | sqlx::migrate!("../../migrations/sqlite") | |
| 47 | 56 | .run(&self.pool) | |
| 48 | - | .await | |
| 57 | + | .await?; | |
| 58 | + | seed_config_key_policy(&self.pool).await?; | |
| 59 | + | Ok(()) | |
| 49 | 60 | } | |
| 50 | 61 | ||
| 51 | 62 | /// Get the connection pool | |
| @@ -83,7 +94,9 @@ impl Database { | |||
| 83 | 94 | } | |
| 84 | 95 | ||
| 85 | 96 | /// Get a repository for user_config key-value pairs (theme, welcome flag, | |
| 86 | - | /// etc.). Synced via sync_changelog triggers (migration 007). | |
| 97 | + | /// etc.). Which keys sync is governed by posture: the triggers (migration | |
| 98 | + | /// 013) enqueue a changelog row only for keys the [`CONFIG`] spec marks | |
| 99 | + | /// `Synced`. See [`config_key`]. | |
| 87 | 100 | #[tracing::instrument(skip_all)] | |
| 88 | 101 | pub fn config(&self) -> ConfigRepository { | |
| 89 | 102 | ConfigRepository::new(self.pool.clone()) | |
| @@ -104,3 +117,29 @@ impl Database { | |||
| 104 | 117 | BookmarksRepository::new(self.pool.clone()) | |
| 105 | 118 | } | |
| 106 | 119 | } | |
| 120 | + | ||
| 121 | + | /// Seed `config_key_policy` from the [`CONFIG`] spec: one row per declared key, | |
| 122 | + | /// marking which may replicate. Idempotent (clear and reinsert the closed set), | |
| 123 | + | /// so a posture changed in code is reflected on the next open, and a key dropped | |
| 124 | + | /// from the spec loses its row and stops syncing. An undeclared key has no row, | |
| 125 | + | /// so the export filter cannot admit it. | |
| 126 | + | /// | |
| 127 | + | /// Public so test harnesses that stand up the schema directly can seed the same | |
| 128 | + | /// policy the app does, rather than leaving the export triggers with nothing to | |
| 129 | + | /// join. | |
| 130 | + | #[tracing::instrument(skip_all)] | |
| 131 | + | pub async fn seed_config_key_policy(pool: &SqlitePool) -> Result<(), sqlx::Error> { | |
| 132 | + | let mut tx = pool.begin().await?; | |
| 133 | + | sqlx::query("DELETE FROM config_key_policy") | |
| 134 | + | .execute(&mut *tx) | |
| 135 | + | .await?; | |
| 136 | + | for row in CONFIG.policy_rows() { | |
| 137 | + | sqlx::query("INSERT INTO config_key_policy (key, replicated) VALUES (?1, ?2)") | |
| 138 | + | .bind(row.key) | |
| 139 | + | .bind(i64::from(row.replicated)) | |
| 140 | + | .execute(&mut *tx) | |
| 141 | + | .await?; | |
| 142 | + | } | |
| 143 | + | tx.commit().await?; | |
| 144 | + | Ok(()) | |
| 145 | + | } |
| @@ -0,0 +1,49 @@ | |||
| 1 | + | -- Give user_config a sync posture. | |
| 2 | + | -- | |
| 3 | + | -- Until now every user_config key synced unconditionally: the triggers below | |
| 4 | + | -- enqueued a changelog row for any key, and the initial snapshot swept the whole | |
| 5 | + | -- table. That is the shape audiofiles was hardened away from (fuzz-2026-07-06 #2, | |
| 6 | + | -- -07-20 #1, -07-21 #3): a key naming device state must never cross the sync | |
| 7 | + | -- boundary. The policy table is an allowlist a key only matches when its row | |
| 8 | + | -- says replicated, seeded from the ConfigKey spec at every open | |
| 9 | + | -- (bb_db::config_key::CONFIG, via Database::seed_config_key_policy). An | |
| 10 | + | -- undeclared key has no row, so the filter fails closed. | |
| 11 | + | ||
| 12 | + | CREATE TABLE IF NOT EXISTS config_key_policy ( | |
| 13 | + | key TEXT PRIMARY KEY, | |
| 14 | + | replicated INTEGER NOT NULL | |
| 15 | + | ); | |
| 16 | + | ||
| 17 | + | DROP TRIGGER IF EXISTS sync_user_config_insert; | |
| 18 | + | DROP TRIGGER IF EXISTS sync_user_config_update; | |
| 19 | + | DROP TRIGGER IF EXISTS sync_user_config_delete; | |
| 20 | + | ||
| 21 | + | CREATE TRIGGER sync_user_config_insert AFTER INSERT ON user_config | |
| 22 | + | WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1' | |
| 23 | + | AND EXISTS (SELECT 1 FROM config_key_policy p WHERE p.key = NEW.key AND p.replicated = 1) | |
| 24 | + | BEGIN | |
| 25 | + | INSERT INTO sync_changelog (table_name, op, row_id, data) | |
| 26 | + | VALUES ('user_config', 'INSERT', NEW.key, json_object( | |
| 27 | + | 'key', NEW.key, | |
| 28 | + | 'value', NEW.value | |
| 29 | + | )); | |
| 30 | + | END; | |
| 31 | + | ||
| 32 | + | CREATE TRIGGER sync_user_config_update AFTER UPDATE ON user_config | |
| 33 | + | WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1' | |
| 34 | + | AND EXISTS (SELECT 1 FROM config_key_policy p WHERE p.key = NEW.key AND p.replicated = 1) | |
| 35 | + | BEGIN | |
| 36 | + | INSERT INTO sync_changelog (table_name, op, row_id, data) | |
| 37 | + | VALUES ('user_config', 'UPDATE', NEW.key, json_object( | |
| 38 | + | 'key', NEW.key, | |
| 39 | + | 'value', NEW.value | |
| 40 | + | )); | |
| 41 | + | END; | |
| 42 | + | ||
| 43 | + | CREATE TRIGGER sync_user_config_delete AFTER DELETE ON user_config | |
| 44 | + | WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1' | |
| 45 | + | AND EXISTS (SELECT 1 FROM config_key_policy p WHERE p.key = OLD.key AND p.replicated = 1) | |
| 46 | + | BEGIN | |
| 47 | + | INSERT INTO sync_changelog (table_name, op, row_id) | |
| 48 | + | VALUES ('user_config', 'DELETE', OLD.key); | |
| 49 | + | END; |
| @@ -93,6 +93,14 @@ async fn apply_changes_inner_tx( | |||
| 93 | 93 | let mut deletes: Vec<&ChangeEntry> = Vec::new(); | |
| 94 | 94 | ||
| 95 | 95 | for change in &changes { | |
| 96 | + | // Import-side posture filter. A remote peer must not set a user_config | |
| 97 | + | // key that is not cleared to sync: the same allowlist the export triggers | |
| 98 | + | // apply, enforced on the way in so a hostile server cannot smuggle a | |
| 99 | + | // device-local (or unknown) key past us. row_id is the key for | |
| 100 | + | // user_config; an excluded key is dropped rather than applied. | |
| 101 | + | if change.table == "user_config" && bb_db::key_excluded_from_sync(&change.row_id) { | |
| 102 | + | continue; | |
| 103 | + | } | |
| 96 | 104 | match change.op { | |
| 97 | 105 | ChangeOp::Insert | ChangeOp::Update => upserts.push(change), | |
| 98 | 106 | ChangeOp::Delete => deletes.push(change), | |
| @@ -530,6 +538,40 @@ mod tests { | |||
| 530 | 538 | ); | |
| 531 | 539 | } | |
| 532 | 540 | ||
| 541 | + | // The import boundary: a hostile or careless peer sends a device-local config | |
| 542 | + | // key (bb-welcomed) alongside a synced one (theme). The synced key applies; | |
| 543 | + | // the device-local key is dropped rather than written to user_config. | |
| 544 | + | #[tokio::test] | |
| 545 | + | async fn apply_remote_changes_drops_device_local_config_key() { | |
| 546 | + | let pool = setup_test_db().await; | |
| 547 | + | ||
| 548 | + | let change = |key: &str, value: &str| ChangeEntry { | |
| 549 | + | table: "user_config".to_string(), | |
| 550 | + | op: ChangeOp::Insert, | |
| 551 | + | row_id: key.to_string(), | |
| 552 | + | timestamp: chrono::Utc::now(), | |
| 553 | + | hlc: Hlc::zero(DeviceId::nil()), | |
| 554 | + | extra: serde_json::Map::default(), | |
| 555 | + | data: Some(json!({ "key": key, "value": value })), | |
| 556 | + | }; | |
| 557 | + | apply_remote_changes( | |
| 558 | + | &pool, | |
| 559 | + | vec![change("theme", "dark"), change("bb-welcomed", "1")], | |
| 560 | + | ) | |
| 561 | + | .await | |
| 562 | + | .unwrap(); | |
| 563 | + | ||
| 564 | + | let applied: Vec<String> = sqlx::query_scalar("SELECT key FROM user_config ORDER BY key") | |
| 565 | + | .fetch_all(&pool) | |
| 566 | + | .await | |
| 567 | + | .unwrap(); | |
| 568 | + | assert_eq!( | |
| 569 | + | applied, | |
| 570 | + | ["theme"], | |
| 571 | + | "a synced key is applied; a device-local key from a remote is refused", | |
| 572 | + | ); | |
| 573 | + | } | |
| 574 | + | ||
| 533 | 575 | #[tokio::test] | |
| 534 | 576 | async fn apply_remote_changes_mixed_ops() { | |
| 535 | 577 | let pool = setup_test_db().await; |
| @@ -170,6 +170,9 @@ mod tests { | |||
| 170 | 170 | .run(&pool) | |
| 171 | 171 | .await | |
| 172 | 172 | .unwrap(); | |
| 173 | + | // Seed the config sync policy the same way `Database::migrate` does, so | |
| 174 | + | // the user_config export triggers have the allowlist to join. | |
| 175 | + | bb_db::seed_config_key_policy(&pool).await.unwrap(); | |
| 173 | 176 | pool | |
| 174 | 177 | } | |
| 175 | 178 |
| @@ -79,11 +79,15 @@ pub async fn create_initial_snapshot(pool: &SqlitePool) -> Result<i64, ApiError> | |||
| 79 | 79 | .map_err(super::db_err)?; | |
| 80 | 80 | total += result.rows_affected() as i64; | |
| 81 | 81 | ||
| 82 | - | // user_config | |
| 82 | + | // user_config (only keys the posture policy clears to sync; same allowlist | |
| 83 | + | // the triggers join, so the snapshot cannot export a device-local key the | |
| 84 | + | // live path would hold back) | |
| 83 | 85 | let result = sqlx::query( | |
| 84 | 86 | "INSERT INTO sync_changelog (table_name, op, row_id, data) \ | |
| 85 | 87 | SELECT 'user_config', 'INSERT', key, \ | |
| 86 | - | json_object('key', key, 'value', value) FROM user_config", | |
| 88 | + | json_object('key', key, 'value', value) FROM user_config \ | |
| 89 | + | WHERE EXISTS (SELECT 1 FROM config_key_policy p \ | |
| 90 | + | WHERE p.key = user_config.key AND p.replicated = 1)", | |
| 87 | 91 | ) | |
| 88 | 92 | .execute(pool) | |
| 89 | 93 | .await | |
| @@ -194,10 +198,15 @@ mod tests { | |||
| 194 | 198 | .await | |
| 195 | 199 | .unwrap(); | |
| 196 | 200 | ||
| 197 | - | sqlx::query("INSERT INTO user_config (key, value) VALUES ('pref', 'yes')") | |
| 198 | - | .execute(&pool) | |
| 199 | - | .await | |
| 200 | - | .unwrap(); | |
| 201 | + | // A synced key (theme) is snapshotted; a device-local one (bb-welcomed) | |
| 202 | + | // is held back by the same posture policy the triggers use, so only one | |
| 203 | + | // user_config row is captured. | |
| 204 | + | sqlx::query( | |
| 205 | + | "INSERT INTO user_config (key, value) VALUES ('theme', 'dark'), ('bb-welcomed', '1')", | |
| 206 | + | ) | |
| 207 | + | .execute(&pool) | |
| 208 | + | .await | |
| 209 | + | .unwrap(); | |
| 201 | 210 | ||
| 202 | 211 | // Create a starred item (only starred/read items are snapshotted for feed_items) | |
| 203 | 212 | let item_id = create_test_item(&pool, &feed_id, "ext-star").await; | |
| @@ -214,7 +223,16 @@ mod tests { | |||
| 214 | 223 | .unwrap(); | |
| 215 | 224 | ||
| 216 | 225 | let total = create_initial_snapshot(&pool).await.unwrap(); | |
| 217 | - | assert_eq!(total, 4); // 1 feed + 1 feed_tag + 1 user_config + 1 starred item | |
| 226 | + | assert_eq!(total, 4); // 1 feed + 1 feed_tag + 1 user_config (theme, not bb-welcomed) + 1 starred item | |
| 227 | + | ||
| 228 | + | // The captured user_config row is the synced key, not the device-local one. | |
| 229 | + | let config_rows: Vec<String> = sqlx::query_scalar( | |
| 230 | + | "SELECT row_id FROM sync_changelog WHERE table_name = 'user_config'", | |
| 231 | + | ) | |
| 232 | + | .fetch_all(&pool) | |
| 233 | + | .await | |
| 234 | + | .unwrap(); | |
| 235 | + | assert_eq!(config_rows, ["theme"], "only the synced key is snapshotted"); | |
| 218 | 236 | } | |
| 219 | 237 | ||
| 220 | 238 | // ── Changelog retention cap ── |