//! Single source of truth for `user_config` keys and their sync posture. //! //! Every persisted `user_config` key is declared exactly once, here, together //! with whether it may cross the sync boundary. One [`ConfigSpec`] is derived //! from this registry ([`CONFIG`]) and drives everything downstream: //! //! - the Rust import filter ([`key_excluded_from_sync`]), used when applying a //! remote pull batch, and //! - the SQL export filter (the `config_key_policy` table seeded from //! [`CONFIG`] by [`crate::db::Database`] and joined by the `user_config` sync //! triggers). //! //! The two hand-maintained denylists this replaced, a SQL trigger predicate in //! `db.rs` and a Rust predicate in `audiofiles-sync`, had drifted apart and //! twice failed to cover device-local keys (`mirror_path`, `mirror_enabled`, //! `import_preflight_disabled`), letting a hostile server steer a local //! filesystem write root across the sync boundary (fuzz-2026-07-06 #2, //! fuzz-2026-07-20 #1, fuzz-2026-07-21 #3 CHRONIC). //! //! Posture is [`synckit_config::Posture`], the family's shared vocabulary //! ([`Synced`]/[`Local`], the generalization of this crate's old //! `Replicated`/`DeviceLocal`). Storage and the posture policy come from the //! shared config store rather than being audiofiles' own; what stays local is //! the typed key façade, because the compile-time guarantee is worth keeping: //! keys are declared through the [`config_keys!`] macro, so a new key cannot be //! added without stating its posture, and a caller cannot name a key that is //! not in the registry. [`ConfigKey`] is the type the config accessors take, so //! an unclassified key does not compile, a stronger promise than the spec's //! runtime fail-closed default, kept on top of it. //! //! use synckit_config::{ConfigSpec, Posture}; /// Declare the full set of `user_config` keys in one place. Each line binds an /// enum variant, its on-disk key string, and its [`Posture`]; the macro derives /// the variant list, the string mapping (both directions), the posture lookup, /// and the [`ConfigSpec`] so they can never disagree. macro_rules! config_keys { ($( $(#[$m:meta])* $variant:ident => $key:literal : $posture:ident ),+ $(,)?) => { /// A known `user_config` key. The config accessors take this rather than /// a `&str`, so adding a new key means adding a variant here, which in /// turn forces declaring its [`Posture`]. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum ConfigKey { $( $(#[$m])* $variant ),+ } impl ConfigKey { /// Every declared key, in declaration order. pub const ALL: &'static [ConfigKey] = &[ $( ConfigKey::$variant ),+ ]; /// The on-disk `user_config.key` string. #[must_use] pub const fn as_str(self) -> &'static str { match self { $( ConfigKey::$variant => $key ),+ } } /// This key's sync posture. #[must_use] pub const fn posture(self) -> Posture { match self { $( ConfigKey::$variant => Posture::$posture ),+ } } /// Resolve a raw key string (e.g. from a remote pull batch) to a /// known key. Unknown strings return `None`. #[must_use] pub fn from_key(key: &str) -> Option { match key { $( $key => Some(ConfigKey::$variant), )+ _ => None, } } } /// The config declaration the shared store reads: table name plus every /// key's posture. Derived from the same macro rows as [`ConfigKey`], so /// the typed façade and the spec cannot describe different key sets. /// /// The table is `user_config`, audiofiles' existing settings table; the /// store attaches to it rather than creating its own (see /// [`ConfigStore::attached`](synckit_config::ConfigStore::attached)). pub const CONFIG: ConfigSpec = ConfigSpec::new("user_config", &[ $( ($key, Posture::$posture) ),+ ]); }; } config_keys! { // --- Synced: user preferences shared across the user's devices. --- Theme => "theme": Synced, PreviewLoop => "preview_loop": Synced, PreviewAutoplay => "preview_autoplay": Synced, EditResultMode => "edit_result_mode": Synced, ForgeAutoTrimOvershoot => "forge.auto_trim_overshoot": Synced, CurrentVfsId => "current_vfs_id": Synced, SidebarVisible => "sidebar_visible": Synced, DetailVisible => "detail_visible": Synced, FilterPanelOpen => "filter_panel_open": Synced, VfsExplained => "vfs_explained": Synced, HintsDismissed => "hints_dismissed": Synced, SyncIntroDismissed => "sync_intro_dismissed": Synced, RowHeight => "row_height": Synced, SuggestionsDismissed => "suggestions.dismissed": Synced, ColumnConfig => "column_config": Synced, SampleTombstoneRetainDays => "sample_tombstone_retain_days": Synced, // --- Local: local paths and safety gates. Never sync these. --- /// Reference-in-place vault mode. Local safety gate. LooseFiles => "loose_files": Local, /// Legacy alias of `loose_files`, read once during migration. UnsafeMode => "unsafe_mode": Local, /// Whether the on-disk mirror is enabled. Local. MirrorEnabled => "mirror_enabled": Local, /// Absolute local path the sample library is mirrored to. Local. MirrorPath => "mirror_path": Local, /// Suppresses the local import safety preflight. Local safety gate. ImportPreflightDisabled => "import_preflight_disabled": Local, } impl ConfigKey { /// True when this key must not cross the sync boundary. #[must_use] pub const fn is_excluded_from_sync(self) -> bool { !self.posture().replicated() } } /// Import-side filter: true when a raw remote key must be dropped rather than /// applied to `user_config`. Device-local keys are refused; unknown keys fail /// closed (a hostile server cannot smuggle a key past us by misspelling it, and /// an older client ignores keys a newer one added). #[must_use] pub fn key_excluded_from_sync(key: &str) -> bool { match ConfigKey::from_key(key) { Some(k) => k.is_excluded_from_sync(), None => true, } } #[cfg(test)] mod tests { use super::*; #[test] fn as_str_round_trips_through_from_key() { for &k in ConfigKey::ALL { assert_eq!(ConfigKey::from_key(k.as_str()), Some(k), "{k:?}"); } } #[test] fn key_strings_are_unique() { let mut seen = std::collections::HashSet::new(); for &k in ConfigKey::ALL { assert!( seen.insert(k.as_str()), "duplicate key string {:?}", k.as_str() ); } } #[test] fn device_local_keys_are_excluded_replicated_are_not() { assert!(key_excluded_from_sync("loose_files")); assert!(key_excluded_from_sync("mirror_path")); assert!(key_excluded_from_sync("mirror_enabled")); assert!(key_excluded_from_sync("import_preflight_disabled")); assert!(key_excluded_from_sync("unsafe_mode")); assert!(!key_excluded_from_sync("theme")); assert!(!key_excluded_from_sync("sample_tombstone_retain_days")); } #[test] fn unknown_keys_fail_closed() { assert!(key_excluded_from_sync("sync_cursor")); assert!(key_excluded_from_sync("totally_unknown_key")); } // The derived spec and the typed registry must describe the same key set // with the same postures. If they drifted, the SQL export filter (seeded // from the spec) and the Rust import filter (driven off the registry) would // disagree, which is the exact class of bug the single registry exists to // rule out. #[test] fn the_spec_matches_the_registry() { let spec_keys: Vec<&str> = CONFIG.keys().map(|(k, _)| k).collect(); let registry_keys: Vec<&str> = ConfigKey::ALL.iter().map(|k| k.as_str()).collect(); assert_eq!(spec_keys, registry_keys, "same keys, same order"); for &key in ConfigKey::ALL { assert_eq!( CONFIG.is_synced(key.as_str()), !key.is_excluded_from_sync(), "posture disagreement for {key:?}", ); } } // Posture is synckit's shared vocabulary now; the mapping from the old names // is the security-load-bearing half, so pin it: a sample of each kind. #[test] fn postures_map_to_the_shared_vocabulary() { assert_eq!(ConfigKey::Theme.posture(), Posture::Synced); assert_eq!(ConfigKey::MirrorPath.posture(), Posture::Local); assert!(ConfigKey::MirrorPath.is_excluded_from_sync()); assert!(!ConfigKey::Theme.is_excluded_from_sync()); } }