|
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 |
+ |
}
|