Skip to main content

max / synckit

8.2 KB · 204 lines History Blame Raw
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 }
204