Skip to main content

max / audiofiles

8.9 KB · 211 lines History Blame Raw
1 //! Single source of truth for `user_config` keys and their sync posture.
2 //!
3 //! Every persisted `user_config` key is declared exactly once, here, together
4 //! with whether it may cross the sync boundary. One [`ConfigSpec`] is derived
5 //! from this registry ([`CONFIG`]) and drives everything downstream:
6 //!
7 //! - the Rust import filter ([`key_excluded_from_sync`]), used when applying a
8 //! remote pull batch, and
9 //! - the SQL export filter (the `config_key_policy` table seeded from
10 //! [`CONFIG`] by [`crate::db::Database`] and joined by the `user_config` sync
11 //! triggers).
12 //!
13 //! The two hand-maintained denylists this replaced, a SQL trigger predicate in
14 //! `db.rs` and a Rust predicate in `audiofiles-sync`, had drifted apart and
15 //! twice failed to cover device-local keys (`mirror_path`, `mirror_enabled`,
16 //! `import_preflight_disabled`), letting a hostile server steer a local
17 //! filesystem write root across the sync boundary (fuzz-2026-07-06 #2,
18 //! fuzz-2026-07-20 #1, fuzz-2026-07-21 #3 CHRONIC).
19 //!
20 //! Posture is [`synckit_config::Posture`], the family's shared vocabulary
21 //! ([`Synced`]/[`Local`], the generalization of this crate's old
22 //! `Replicated`/`DeviceLocal`). Storage and the posture policy come from the
23 //! shared config store rather than being audiofiles' own; what stays local is
24 //! the typed key façade, because the compile-time guarantee is worth keeping:
25 //! keys are declared through the [`config_keys!`] macro, so a new key cannot be
26 //! added without stating its posture, and a caller cannot name a key that is
27 //! not in the registry. [`ConfigKey`] is the type the config accessors take, so
28 //! an unclassified key does not compile, a stronger promise than the spec's
29 //! runtime fail-closed default, kept on top of it.
30 //!
31 //! <!-- wiki: af-config -->
32
33 use synckit_config::{ConfigSpec, Posture};
34
35 /// Declare the full set of `user_config` keys in one place. Each line binds an
36 /// enum variant, its on-disk key string, and its [`Posture`]; the macro derives
37 /// the variant list, the string mapping (both directions), the posture lookup,
38 /// and the [`ConfigSpec`] so they can never disagree.
39 macro_rules! config_keys {
40 ($( $(#[$m:meta])* $variant:ident => $key:literal : $posture:ident ),+ $(,)?) => {
41 /// A known `user_config` key. The config accessors take this rather than
42 /// a `&str`, so adding a new key means adding a variant here, which in
43 /// turn forces declaring its [`Posture`].
44 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
45 pub enum ConfigKey {
46 $( $(#[$m])* $variant ),+
47 }
48
49 impl ConfigKey {
50 /// Every declared key, in declaration order.
51 pub const ALL: &'static [ConfigKey] = &[ $( ConfigKey::$variant ),+ ];
52
53 /// The on-disk `user_config.key` string.
54 #[must_use]
55 pub const fn as_str(self) -> &'static str {
56 match self { $( ConfigKey::$variant => $key ),+ }
57 }
58
59 /// This key's sync posture.
60 #[must_use]
61 pub const fn posture(self) -> Posture {
62 match self { $( ConfigKey::$variant => Posture::$posture ),+ }
63 }
64
65 /// Resolve a raw key string (e.g. from a remote pull batch) to a
66 /// known key. Unknown strings return `None`.
67 #[must_use]
68 pub fn from_key(key: &str) -> Option<ConfigKey> {
69 match key {
70 $( $key => Some(ConfigKey::$variant), )+
71 _ => None,
72 }
73 }
74 }
75
76 /// The config declaration the shared store reads: table name plus every
77 /// key's posture. Derived from the same macro rows as [`ConfigKey`], so
78 /// the typed façade and the spec cannot describe different key sets.
79 ///
80 /// The table is `user_config`, audiofiles' existing settings table; the
81 /// store attaches to it rather than creating its own (see
82 /// [`ConfigStore::attached`](synckit_config::ConfigStore::attached)).
83 pub const CONFIG: ConfigSpec =
84 ConfigSpec::new("user_config", &[ $( ($key, Posture::$posture) ),+ ]);
85 };
86 }
87
88 config_keys! {
89 // --- Synced: user preferences shared across the user's devices. ---
90 Theme => "theme": Synced,
91 PreviewLoop => "preview_loop": Synced,
92 PreviewAutoplay => "preview_autoplay": Synced,
93 EditResultMode => "edit_result_mode": Synced,
94 ForgeAutoTrimOvershoot => "forge.auto_trim_overshoot": Synced,
95 CurrentVfsId => "current_vfs_id": Synced,
96 SidebarVisible => "sidebar_visible": Synced,
97 DetailVisible => "detail_visible": Synced,
98 FilterPanelOpen => "filter_panel_open": Synced,
99 VfsExplained => "vfs_explained": Synced,
100 HintsDismissed => "hints_dismissed": Synced,
101 SyncIntroDismissed => "sync_intro_dismissed": Synced,
102 RowHeight => "row_height": Synced,
103 SuggestionsDismissed => "suggestions.dismissed": Synced,
104 ColumnConfig => "column_config": Synced,
105 SampleTombstoneRetainDays => "sample_tombstone_retain_days": Synced,
106
107 // --- Local: local paths and safety gates. Never sync these. ---
108 /// Reference-in-place vault mode. Local safety gate.
109 LooseFiles => "loose_files": Local,
110 /// Legacy alias of `loose_files`, read once during migration.
111 UnsafeMode => "unsafe_mode": Local,
112 /// Whether the on-disk mirror is enabled. Local.
113 MirrorEnabled => "mirror_enabled": Local,
114 /// Absolute local path the sample library is mirrored to. Local.
115 MirrorPath => "mirror_path": Local,
116 /// Suppresses the local import safety preflight. Local safety gate.
117 ImportPreflightDisabled => "import_preflight_disabled": Local,
118 }
119
120 impl ConfigKey {
121 /// True when this key must not cross the sync boundary.
122 #[must_use]
123 pub const fn is_excluded_from_sync(self) -> bool {
124 !self.posture().replicated()
125 }
126 }
127
128 /// Import-side filter: true when a raw remote key must be dropped rather than
129 /// applied to `user_config`. Device-local keys are refused; unknown keys fail
130 /// closed (a hostile server cannot smuggle a key past us by misspelling it, and
131 /// an older client ignores keys a newer one added).
132 #[must_use]
133 pub fn key_excluded_from_sync(key: &str) -> bool {
134 match ConfigKey::from_key(key) {
135 Some(k) => k.is_excluded_from_sync(),
136 None => true,
137 }
138 }
139
140 #[cfg(test)]
141 mod tests {
142 use super::*;
143
144 #[test]
145 fn as_str_round_trips_through_from_key() {
146 for &k in ConfigKey::ALL {
147 assert_eq!(ConfigKey::from_key(k.as_str()), Some(k), "{k:?}");
148 }
149 }
150
151 #[test]
152 fn key_strings_are_unique() {
153 let mut seen = std::collections::HashSet::new();
154 for &k in ConfigKey::ALL {
155 assert!(
156 seen.insert(k.as_str()),
157 "duplicate key string {:?}",
158 k.as_str()
159 );
160 }
161 }
162
163 #[test]
164 fn device_local_keys_are_excluded_replicated_are_not() {
165 assert!(key_excluded_from_sync("loose_files"));
166 assert!(key_excluded_from_sync("mirror_path"));
167 assert!(key_excluded_from_sync("mirror_enabled"));
168 assert!(key_excluded_from_sync("import_preflight_disabled"));
169 assert!(key_excluded_from_sync("unsafe_mode"));
170
171 assert!(!key_excluded_from_sync("theme"));
172 assert!(!key_excluded_from_sync("sample_tombstone_retain_days"));
173 }
174
175 #[test]
176 fn unknown_keys_fail_closed() {
177 assert!(key_excluded_from_sync("sync_cursor"));
178 assert!(key_excluded_from_sync("totally_unknown_key"));
179 }
180
181 // The derived spec and the typed registry must describe the same key set
182 // with the same postures. If they drifted, the SQL export filter (seeded
183 // from the spec) and the Rust import filter (driven off the registry) would
184 // disagree, which is the exact class of bug the single registry exists to
185 // rule out.
186 #[test]
187 fn the_spec_matches_the_registry() {
188 let spec_keys: Vec<&str> = CONFIG.keys().map(|(k, _)| k).collect();
189 let registry_keys: Vec<&str> = ConfigKey::ALL.iter().map(|k| k.as_str()).collect();
190 assert_eq!(spec_keys, registry_keys, "same keys, same order");
191
192 for &key in ConfigKey::ALL {
193 assert_eq!(
194 CONFIG.is_synced(key.as_str()),
195 !key.is_excluded_from_sync(),
196 "posture disagreement for {key:?}",
197 );
198 }
199 }
200
201 // Posture is synckit's shared vocabulary now; the mapping from the old names
202 // is the security-load-bearing half, so pin it: a sample of each kind.
203 #[test]
204 fn postures_map_to_the_shared_vocabulary() {
205 assert_eq!(ConfigKey::Theme.posture(), Posture::Synced);
206 assert_eq!(ConfigKey::MirrorPath.posture(), Posture::Local);
207 assert!(ConfigKey::MirrorPath.is_excluded_from_sync());
208 assert!(!ConfigKey::Theme.is_excluded_from_sync());
209 }
210 }
211