Skip to main content

max / makenotwork

synckit-client: the config sync adapter, step 2 of portable config Turns a synckit-config ConfigSpec into a SyncTable, so an app folds config into its SyncSchema and its ConfigStore writes replicate transparently. The consumer never learns whether sync is wired: it does get/set; if the triggers are installed, a Synced key enqueues, and if not, nothing does. The whole adapter is a key/value SyncTable plus a posture exclude_where — the engine already generates the triggers and substitutes {row} into the filter, so this is a recipe over it, not new machinery. The filter is an allowlist: a key syncs only if its config_key_policy row says replicated. A key with no row cannot be admitted, which is the fail-closed rule audiofiles reached the hard way (a NOT LIKE denylist that never covered mirror_path let a hostile server steer a local write root across the boundary, fuzz-2026-07-21 #3). The policy is seeded from the spec, whose default posture is Local, so the SQL inherits synckit-config's fail-closed default. install_policy re-seeds each open, delete-then-insert, so a key dropped from the spec loses its row and stops syncing rather than lingering with a stale posture. Wrapped in a transaction. Gated on the store feature (needs rusqlite); no-default-features still builds for the transport-only consumers. Depends on synckit-config by path. The security-critical behaviour is tested end to end: a Synced key captures, a Local path never enters the changelog, an undeclared key is refused.
Author: Max Johnson <me@maxj.phd> · 2026-07-24 21:13 UTC
Commit: aec45f402ee6024613a726be69c89a34906747d3
Parent: 0eb3a5f
4 files changed, +220 insertions, -1 deletion
@@ -2193,6 +2193,7 @@ dependencies = [
2193 2193 "serde_json",
2194 2194 "sha2 0.11.0",
2195 2195 "synckit-client",
2196 + "synckit-config",
2196 2197 "thiserror",
2197 2198 "tokio",
2198 2199 "tokio-stream",
@@ -2206,6 +2207,14 @@ dependencies = [
2206 2207 ]
2207 2208
2208 2209 [[package]]
2210 + name = "synckit-config"
2211 + version = "0.1.0"
2212 + dependencies = [
2213 + "rusqlite",
2214 + "thiserror",
2215 + ]
2216 +
2217 + [[package]]
2209 2218 name = "synstructure"
2210 2219 version = "0.13.2"
2211 2220 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -12,7 +12,7 @@ keychain = ["dep:keyring"]
12 12 # Default-on so app consumers get it for free; a consumer that only needs the
13 13 # transport/crypto SDK (e.g. mnw-cli, which sets `default-features = false`) skips
14 14 # it and avoids compiling bundled SQLite.
15 - store = ["dep:rusqlite"]
15 + store = ["dep:rusqlite", "dep:synckit-config"]
16 16 # Exposes test-only constructors (`set_master_key_raw`, `with_http_client`) that
17 17 # bypass key derivation. Enabled automatically for this crate's own test builds
18 18 # via the self dev-dependency below; never part of `default`, so a real consumer
@@ -70,6 +70,10 @@ tracing = "0.1"
70 70 # SyncStore engine (feature = "store"). Owns the app's SQLite connection. Bundled
71 71 # so no system libsqlite is needed; `functions` to register the `hash_row_id` UDF.
72 72 rusqlite = { version = "0.39", features = ["bundled", "functions"], optional = true }
73 + # The local config store (posture types + KV store). The config sync adapter
74 + # (src/store/config.rs) turns a ConfigSpec into a SyncTable. Path dep: sibling in
75 + # the MNW tree, versioned together.
76 + synckit-config = { path = "../synckit-config", optional = true }
73 77
74 78 [dev-dependencies]
75 79 wiremock = "0.6"
@@ -0,0 +1,203 @@
1 + //! The sync half of portable config: a [`ConfigSpec`] as a [`SyncTable`].
2 + //!
3 + //! [`synckit-config`] holds the local store an app reads and writes and the
4 + //! posture declaration; this turns that declaration into the sync engine's
5 + //! terms. An app calls [`config_sync_table`] to fold config into its
6 + //! [`SyncSchema`] and [`install_policy`] once at open to seed the filter the
7 + //! generated trigger reads. After that the app's `ConfigStore` writes replicate
8 + //! transparently — the consumer never learns whether sync is wired.
9 + //!
10 + //! ## The posture filter, and why it is an allowlist
11 + //!
12 + //! A config key/value table syncs every key unless told otherwise. The
13 + //! [`exclude_where`](SyncTable::exclude_where) predicate here admits a key only
14 + //! if a `config_key_policy` row marks it replicated:
15 + //!
16 + //! ```sql
17 + //! EXISTS (SELECT 1 FROM config_key_policy p WHERE p.key = {row}.key AND p.replicated = 1)
18 + //! ```
19 + //!
20 + //! An **allowlist**, not a denylist, and that is the security property. A key
21 + //! with no policy row does not match, so it never enters the changelog — an
22 + //! unclassified key stays on the device. audiofiles reached the same predicate
23 + //! the hard way: its earlier `key != 'loose_files'` denylist never covered
24 + //! `mirror_path`/`mirror_enabled`, letting a hostile server steer a local write
25 + //! root across the boundary (fuzz-2026-07-21 #3). The policy table is seeded from
26 + //! the spec, whose default posture is `Local`, so the allowlist is
27 + //! [`ConfigSpec`]'s fail-closed rule carried into SQL.
28 +
29 + use rusqlite::Connection;
30 + use synckit_config::ConfigSpec;
31 +
32 + use super::schema::SyncTable;
33 + use crate::error::Result;
34 +
35 + /// The policy table the trigger predicate joins. One row per declared key.
36 + ///
37 + /// Keyed by `key` alone: one config table per app, so keys do not collide across
38 + /// tables. A second config table would need a `table` column here; no app has
39 + /// one, and adding it is cheap when one does.
40 + const POLICY_TABLE: &str = "config_key_policy";
41 +
42 + /// The `exclude_where` predicate: admit a key only if its policy row replicates.
43 + ///
44 + /// Fixed SQL, so a `&'static str`. `{row}` is substituted with the trigger's
45 + /// `NEW`/`OLD` alias by the migration layer, symmetrically on export and import.
46 + const POLICY_FILTER: &str = "EXISTS (SELECT 1 FROM config_key_policy p \
47 + WHERE p.key = {row}.key AND p.replicated = 1)";
48 +
49 + /// The [`SyncTable`] for a config: a key/value table filtered by posture.
50 + ///
51 + /// Fold the result into the app's [`SyncSchema`](super::schema::SyncSchema). The
52 + /// generated triggers capture a change only for a key the policy admits, so a
53 + /// `Local` key is written to the store and never enqueued. Pair with
54 + /// [`install_policy`], which seeds the table the filter reads.
55 + pub fn config_sync_table(spec: &ConfigSpec) -> SyncTable {
56 + SyncTable::full(spec.table(), &["key", "value"])
57 + .pk(&["key"])
58 + .exclude_where(POLICY_FILTER)
59 + }
60 +
61 + /// Create and seed the policy table the [`config_sync_table`] filter reads.
62 + ///
63 + /// Idempotent, and safe to call at every open: it recreates the table's rows
64 + /// from the spec each time, so a key whose posture changed in code takes effect
65 + /// on the next start, and a key removed from the spec loses its row and stops
66 + /// replicating. The seed is the spec's [`policy_rows`](ConfigSpec::policy_rows),
67 + /// so an undeclared key has no row and the filter cannot admit it.
68 + ///
69 + /// Wrapped in a transaction so a crash mid-seed cannot leave the policy table
70 + /// describing a set of keys that never all existed at once.
71 + pub fn install_policy(conn: &mut Connection, spec: &ConfigSpec) -> Result<()> {
72 + let tx = conn.transaction()?;
73 + tx.execute_batch(&format!(
74 + "CREATE TABLE IF NOT EXISTS {POLICY_TABLE} (\
75 + key TEXT PRIMARY KEY,\
76 + replicated INTEGER NOT NULL\
77 + ) WITHOUT ROWID;"
78 + ))?;
79 + // Rebuild the rows from the spec each open. Delete-then-insert rather than
80 + // upsert so a key dropped from the spec loses its row (and stops syncing)
81 + // rather than lingering with a stale posture.
82 + tx.execute(&format!("DELETE FROM {POLICY_TABLE}"), [])?;
83 + {
84 + let mut stmt = tx.prepare(&format!(
85 + "INSERT INTO {POLICY_TABLE} (key, replicated) VALUES (?1, ?2)"
86 + ))?;
87 + for row in spec.policy_rows() {
88 + stmt.execute(rusqlite::params![row.key, i64::from(row.replicated)])?;
89 + }
90 + }
91 + tx.commit()?;
92 + Ok(())
93 + }
94 +
95 + #[cfg(test)]
96 + mod tests {
97 + use super::*;
98 + use crate::store::schema::SyncSchema;
99 + use synckit_config::{ConfigStore, Posture};
100 +
101 + const SPEC: ConfigSpec = ConfigSpec::new(
102 + "app_config",
103 + &[
104 + ("theme", Posture::Synced),
105 + ("sidebar_visible", Posture::Synced),
106 + ("mirror_path", Posture::Local),
107 + ],
108 + );
109 +
110 + /// The changelog row-ids captured so far, in order.
111 + fn captured(conn: &Connection) -> Vec<String> {
112 + let mut stmt = conn
113 + .prepare("SELECT row_id FROM sync_changelog ORDER BY id")
114 + .unwrap();
115 + stmt.query_map([], |r| r.get::<_, String>(0))
116 + .unwrap()
117 + .map(std::result::Result::unwrap)
118 + .collect()
119 + }
120 +
121 + /// A DB with the config table, its store, its policy, and its sync triggers.
122 + ///
123 + /// The order matters and is the order an app opens in: the store creates the
124 + /// config table, `install_policy` seeds the filter, then the schema's
125 + /// migration installs the triggers that read it.
126 + fn wired() -> (Connection, ConfigStore) {
127 + let mut conn = Connection::open_in_memory().unwrap();
128 + let store = ConfigStore::open(&conn, &SPEC).unwrap();
129 + install_policy(&mut conn, &SPEC).unwrap();
130 + let sql = SyncSchema::new(vec![config_sync_table(&SPEC)]).migration_sql();
131 + conn.execute_batch(&sql).unwrap();
132 + (conn, store)
133 + }
134 +
135 + // The point of the whole layer: a Synced key is captured, a Local one is
136 + // not, and neither the store nor the caller said anything about sync.
137 + #[test]
138 + fn a_synced_key_is_captured_and_a_local_key_is_not() {
139 + let (conn, store) = wired();
140 + store.set(&conn, "theme", "akari-night").unwrap();
141 + store.set(&conn, "mirror_path", "/mnt/samples").unwrap();
142 +
143 + assert_eq!(
144 + captured(&conn),
145 + ["theme"],
146 + "the preference syncs; the local path never enters the changelog",
147 + );
148 + }
149 +
150 + // The allowlist's reason for being: a key nobody classified must not sync,
151 + // even though the table syncs by default. This is the fuzz-hardened rule in
152 + // SQL rather than in Rust.
153 + #[test]
154 + fn an_undeclared_key_is_never_captured() {
155 + let (conn, store) = wired();
156 + store
157 + .set(&conn, "some_new_local_path", "/etc/secret")
158 + .unwrap();
159 + assert!(
160 + captured(&conn).is_empty(),
161 + "an unclassified key has no policy row and cannot be admitted",
162 + );
163 + }
164 +
165 + #[test]
166 + fn every_synced_key_captures() {
167 + let (conn, store) = wired();
168 + store.set(&conn, "theme", "flatwhite").unwrap();
169 + store.set(&conn, "sidebar_visible", "true").unwrap();
170 + assert_eq!(captured(&conn), ["theme", "sidebar_visible"]);
171 + }
172 +
173 + // Re-seeding drops a key removed from the spec, so it stops syncing rather
174 + // than lingering with a stale posture.
175 + #[test]
176 + fn reseeding_a_narrower_spec_stops_the_dropped_key_syncing() {
177 + let (mut conn, store) = wired();
178 + store.set(&conn, "theme", "flatwhite").unwrap();
179 + assert_eq!(captured(&conn), ["theme"]);
180 +
181 + const NARROWER: ConfigSpec =
182 + ConfigSpec::new("app_config", &[("mirror_path", Posture::Local)]);
183 + install_policy(&mut conn, &NARROWER).unwrap();
184 +
185 + conn.execute("DELETE FROM sync_changelog", []).unwrap();
186 + store.set(&conn, "theme", "akari-night").unwrap();
187 + assert!(
188 + captured(&conn).is_empty(),
189 + "theme is no longer in the policy, so it no longer syncs",
190 + );
191 + }
192 +
193 + #[test]
194 + fn install_policy_is_idempotent() {
195 + let (mut conn, _store) = wired();
196 + // Every open re-seeds; a second call must not error or duplicate rows.
197 + install_policy(&mut conn, &SPEC).unwrap();
198 + let count: i64 = conn
199 + .query_row("SELECT COUNT(*) FROM config_key_policy", [], |r| r.get(0))
200 + .unwrap();
201 + assert_eq!(count, 3, "one row per declared key, not doubled");
202 + }
203 + }
@@ -18,6 +18,8 @@
18 18
19 19 pub mod apply;
20 20 pub mod blob;
21 + #[cfg(feature = "store")]
22 + pub mod config;
21 23 pub mod db;
22 24 pub mod facade;
23 25 pub mod hlc;
@@ -31,6 +33,7 @@ pub use blob::{
31 33 BlobOutcome, BlobPolicy, BlobRef, BlobTransport, download_blobs, download_one, sync_blobs,
32 34 upload_blobs,
33 35 };
36 + pub use config::{config_sync_table, install_policy};
34 37 pub use db::{
35 38 DbSource, clear_applying_remote, device_id, get_scope_cursor, get_sync_state,
36 39 get_sync_state_or, set_device_id, set_scope_cursor, set_sync_state, with_applying_remote,