Skip to main content

max / audiofiles

Fix Sync/Sec risk axis: blob upload fault-tolerance + ConfigKey registry Remediates the open findings from fuzz-2026-07-21 (risk lens) on the Sync/Sec axis, plus the data-integrity minors. SERIOUS #1 — blob upload head-of-line block + silent failure. upload_pending_blobs now continues past a failing blob (per-blob error is counted, not propagated with `?`), so one bad sample no longer blocks the rest of the queue. The scheduler surfaces a non-zero failure count through SyncStatus::last_error, mirroring the retention path, so a partial backup is no longer reported as a clean cycle. Confirm size is single-sourced from the uploaded bytes rather than the DB's file_size. SERIOUS #3 CHRONIC — device-local user_config keys crossing the sync boundary. Replaces three hand-maintained denylists (a Rust predicate, the SQL trigger predicate, and the initial-snapshot query) with one ConfigKey registry in audiofiles-core. Each key declares its SyncPosture once; the import filter and the SQL config_key_policy table (joined by the triggers and the snapshot) are both generated from it. The config accessors take ConfigKey, so adding a key without a posture no longer compiles, and mirror_path / mirror_enabled / import_preflight_disabled can no longer leak. Export- and import-side tests lock all three paths. MINOR — checked u64::try_from at the three SQLite aggregate cast sites (a corrupt negative surfaces as an error, not a ~1.8e19 wrap); documented the rusqlite 0.39 pin rationale. Gate: cargo test --workspace green (1219), clippy --workspace --all-targets clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-23 21:45 UTC
Commit: 53af4ebfb8f76cb2c07bdce2a855db4c0d5bb4be
Parent: ed2fe88
22 files changed, +617 insertions, -118 deletions
M Cargo.toml +4
@@ -16,6 +16,10 @@ egui = { version = "0.35", default-features = false, features = ["default_fonts"
16 16 egui_extras = { version = "0.35", default-features = false }
17 17 eframe = { version = "0.35", default-features = false, features = ["default_fonts", "glow"] }
18 18 cpal = "0.18"
19 + # Pinned to 0.39 deliberately: 0.40 bumps libsqlite3-sys to 0.37, which then
20 + # co-links with the sqlx-sqlite 0.9 that synckit-client's SyncStore pulls in and
21 + # fails the build with two libsqlite3-sys versions. Hold at 0.39 until sqlx and
22 + # rusqlite agree on a libsqlite3-sys major. (0.39 still provides u64: FromSql.)
19 23 rusqlite = { version = "0.39", features = ["bundled", "functions"] }
20 24 thiserror = "2.0.18"
21 25 sha2 = "0.11.0"
@@ -457,12 +457,22 @@ impl AudioFilesApp {
457 457 // `unsafe_mode` row is deleted via `delete_config`. Safe to
458 458 // remove this block once every active vault has been opened at
459 459 // least once after this release.
460 - let loose = match browser.backend.get_config("loose_files") {
460 + let loose = match browser
461 + .backend
462 + .get_config(audiofiles_browser::backend::ConfigKey::LooseFiles)
463 + {
461 464 Ok(Some(v)) => Some(v),
462 - _ => match browser.backend.get_config("unsafe_mode") {
465 + _ => match browser
466 + .backend
467 + .get_config(audiofiles_browser::backend::ConfigKey::UnsafeMode)
468 + {
463 469 Ok(Some(v)) => {
464 - let _ = browser.backend.set_config("loose_files", &v);
465 - let _ = browser.backend.delete_config("unsafe_mode");
470 + let _ = browser
471 + .backend
472 + .set_config(audiofiles_browser::backend::ConfigKey::LooseFiles, &v);
473 + let _ = browser
474 + .backend
475 + .delete_config(audiofiles_browser::backend::ConfigKey::UnsafeMode);
466 476 Some(v)
467 477 }
468 478 _ => None,
@@ -684,7 +694,10 @@ impl AudioFilesApp {
684 694 if self.with_vault_registry(|reg| vault::create_vault(reg, &name, &path)) {
685 695 self.switch_vault(switch_path);
686 696 if loose_files && let Some(ref mut browser) = self.browser {
687 - let _ = browser.backend.set_config("loose_files", "1");
697 + let _ = browser.backend.set_config(
698 + audiofiles_browser::backend::ConfigKey::LooseFiles,
699 + "1",
700 + );
688 701 browser.settings.is_loose_files = true;
689 702 }
690 703 return;
@@ -2,6 +2,7 @@
2 2 //! feature backfill queries, and rule application over one or many samples.
3 3
4 4 use super::*;
5 + use crate::backend::ConfigKey;
5 6
6 7 impl AnalysisBackend for DirectBackend {
7 8 // --- Analysis ---
@@ -95,13 +96,13 @@ impl RulesBackend for DirectBackend {
95 96 impl ConfigBackend for DirectBackend {
96 97 // --- Config ---
97 98
98 - fn get_config(&self, key: &str) -> BackendResult<Option<String>> {
99 + fn get_config(&self, key: ConfigKey) -> BackendResult<Option<String>> {
99 100 let db = self.db.lock();
100 101 let result = db
101 102 .conn()
102 103 .query_row(
103 104 "SELECT value FROM user_config WHERE key = ?1",
104 - [key],
105 + [key.as_str()],
105 106 |row| row.get::<_, String>(0),
106 107 )
107 108 .ok();
@@ -109,21 +110,21 @@ impl ConfigBackend for DirectBackend {
109 110 }
110 111
111 112 #[instrument(skip(self, value))]
112 - fn set_config(&self, key: &str, value: &str) -> BackendResult<()> {
113 + fn set_config(&self, key: ConfigKey, value: &str) -> BackendResult<()> {
113 114 let db = self.db.lock();
114 115 db.conn()
115 116 .execute(
116 117 "INSERT OR REPLACE INTO user_config (key, value) VALUES (?1, ?2)",
117 - rusqlite::params![key, value],
118 + rusqlite::params![key.as_str(), value],
118 119 )
119 120 .map_err(audiofiles_core::error::CoreError::Db)?;
120 121 Ok(())
121 122 }
122 123
123 - fn delete_config(&self, key: &str) -> BackendResult<()> {
124 + fn delete_config(&self, key: ConfigKey) -> BackendResult<()> {
124 125 let db = self.db.lock();
125 126 db.conn()
126 - .execute("DELETE FROM user_config WHERE key = ?1", [key])
127 + .execute("DELETE FROM user_config WHERE key = ?1", [key.as_str()])
127 128 .map_err(audiofiles_core::error::CoreError::Db)?;
128 129 Ok(())
129 130 }
@@ -75,13 +75,34 @@ fn tags_crud() {
75 75 #[test]
76 76 fn config_crud() {
77 77 let backend = setup();
78 - assert!(backend.get_config("theme").unwrap().is_none());
78 + assert!(
79 + backend
80 + .get_config(crate::backend::ConfigKey::Theme)
81 + .unwrap()
82 + .is_none()
83 + );
79 84
80 - backend.set_config("theme", "dark").unwrap();
81 - assert_eq!(backend.get_config("theme").unwrap().unwrap(), "dark");
85 + backend
86 + .set_config(crate::backend::ConfigKey::Theme, "dark")
87 + .unwrap();
88 + assert_eq!(
89 + backend
90 + .get_config(crate::backend::ConfigKey::Theme)
91 + .unwrap()
92 + .unwrap(),
93 + "dark"
94 + );
82 95
83 - backend.set_config("theme", "light").unwrap();
84 - assert_eq!(backend.get_config("theme").unwrap().unwrap(), "light");
96 + backend
97 + .set_config(crate::backend::ConfigKey::Theme, "light")
98 + .unwrap();
99 + assert_eq!(
100 + backend
101 + .get_config(crate::backend::ConfigKey::Theme)
102 + .unwrap()
103 + .unwrap(),
104 + "light"
105 + );
85 106 }
86 107
87 108 #[test]
@@ -14,6 +14,8 @@ pub mod search_worker;
14 14
15 15 use std::path::{Path, PathBuf};
16 16
17 + pub use audiofiles_core::config_key::ConfigKey;
18 +
17 19 use audiofiles_core::analysis::AnalysisResult;
18 20 use audiofiles_core::analysis::config::AnalysisConfig;
19 21 use audiofiles_core::analysis::suggest::TagSuggestion;
@@ -239,7 +241,7 @@ pub struct StorageStats {
239 241 /// scale at an integer target is trimmed to the ceiling (the gentlest reversible
240 242 /// fix); otherwise (default) the signal is left untouched and the overshoot is
241 243 /// only reported, leaving the encoder's clamp as the disclosed last resort.
242 - pub const FORGE_AUTO_TRIM_OVERSHOOT_KEY: &str = "forge.auto_trim_overshoot";
244 + pub const FORGE_AUTO_TRIM_OVERSHOOT_KEY: &str = ConfigKey::ForgeAutoTrimOvershoot.as_str();
243 245
244 246 /// Lightweight summary of a persisted trained head for the Settings UI (the model's
245 247 /// weights stay in core).
@@ -836,13 +838,13 @@ pub trait ConfigBackend {
836 838 // --- Config ---
837 839
838 840 /// Get a user config value by key.
839 - fn get_config(&self, key: &str) -> BackendResult<Option<String>>;
841 + fn get_config(&self, key: ConfigKey) -> BackendResult<Option<String>>;
840 842
841 843 /// Set a user config value.
842 - fn set_config(&self, key: &str, value: &str) -> BackendResult<()>;
844 + fn set_config(&self, key: ConfigKey, value: &str) -> BackendResult<()>;
843 845
844 846 /// Delete a user config value by key. No-op if the key does not exist.
845 - fn delete_config(&self, key: &str) -> BackendResult<()>;
847 + fn delete_config(&self, key: ConfigKey) -> BackendResult<()>;
846 848
847 849 /// Set whether a VFS should sync audio file blobs to cloud.
848 850 fn set_vfs_sync_files(&self, id: VfsId, enabled: bool) -> BackendResult<()>;
@@ -58,8 +58,12 @@ pub(super) fn build_sample_info(
58 58 .query_row(
59 59 "SELECT file_size FROM samples WHERE hash = ?1 AND deleted_at IS NULL",
60 60 [&item.hash],
61 - // rusqlite 0.40 dropped u64: FromSql; file_size is non-negative.
62 - |row| row.get::<_, i64>(0).map(|v| v as u64),
61 + // file_size is non-negative in practice; a corrupt negative surfaces
62 + // as an error rather than wrapping silently to a nonsense u64.
63 + |row| {
64 + let v = row.get::<_, i64>(0)?;
65 + u64::try_from(v).map_err(|_| rusqlite::Error::IntegralValueOutOfRange(0, v))
66 + },
63 67 )
64 68 .unwrap_or_else(|_| {
65 69 // Fallback: read from store
@@ -31,7 +31,7 @@ impl BrowserState {
31 31 // Cache result mode from user_config
32 32 self.edit.result_mode = match self
33 33 .backend
34 - .get_config("edit_result_mode")
34 + .get_config(crate::backend::ConfigKey::EditResultMode)
35 35 .ok()
36 36 .flatten()
37 37 .as_deref()
@@ -296,7 +296,8 @@ impl BrowserState {
296 296 };
297 297 super::log_backend_err(
298 298 "set_config edit_result_mode",
299 - self.backend.set_config("edit_result_mode", mode_str),
299 + self.backend
300 + .set_config(crate::backend::ConfigKey::EditResultMode, mode_str),
300 301 );
301 302 self.edit.result_mode = Some(mode);
302 303 }
@@ -190,7 +190,7 @@ impl BrowserState {
190 190 super::log_backend_err(
191 191 "set_config forge_auto_trim_overshoot",
192 192 self.backend.set_config(
193 - crate::backend::FORGE_AUTO_TRIM_OVERSHOOT_KEY,
193 + crate::backend::ConfigKey::ForgeAutoTrimOvershoot,
194 194 if self.forge_auto_trim_overshoot {
195 195 "true"
196 196 } else {
@@ -160,7 +160,8 @@ impl BrowserState {
160 160 self.onboarding.show_first_launch_hint = false;
161 161 super::log_backend_err(
162 162 "set_config hints_dismissed",
163 - self.backend.set_config("hints_dismissed", "1"),
163 + self.backend
164 + .set_config(crate::backend::ConfigKey::HintsDismissed, "1"),
164 165 );
165 166 }
166 167
@@ -170,7 +171,8 @@ impl BrowserState {
170 171 self.onboarding.show_first_launch_hint = true;
171 172 super::log_backend_err(
172 173 "set_config hints_dismissed",
173 - self.backend.set_config("hints_dismissed", "0"),
174 + self.backend
175 + .set_config(crate::backend::ConfigKey::HintsDismissed, "0"),
174 176 );
175 177 }
176 178
@@ -181,7 +183,8 @@ impl BrowserState {
181 183 self.onboarding.show_sync_intro = false;
182 184 super::log_backend_err(
183 185 "set_config sync_intro_dismissed",
184 - self.backend.set_config("sync_intro_dismissed", "1"),
186 + self.backend
187 + .set_config(crate::backend::ConfigKey::SyncIntroDismissed, "1"),
185 188 );
186 189 }
187 190
@@ -200,7 +203,8 @@ impl BrowserState {
200 203 self.onboarding.show_vfs_banner = true;
201 204 super::log_backend_err(
202 205 "set_config vfs_explained",
203 - self.backend.set_config("vfs_explained", "0"),
206 + self.backend
207 + .set_config(crate::backend::ConfigKey::VfsExplained, "0"),
204 208 );
205 209 }
206 210
@@ -389,7 +393,9 @@ impl BrowserState {
389 393
390 394 /// Load column config from the user_config table.
391 395 pub fn load_column_config(&mut self) {
392 - if let Ok(Some(json)) = self.backend.get_config("column_config")
396 + if let Ok(Some(json)) = self
397 + .backend
398 + .get_config(crate::backend::ConfigKey::ColumnConfig)
393 399 && let Ok(parsed) = serde_json::from_str::<ColumnConfig>(&json)
394 400 {
395 401 self.column_config = parsed;
@@ -400,7 +406,8 @@ impl BrowserState {
400 406 pub fn save_theme_preference(&self) {
401 407 super::log_backend_err(
402 408 "set_config theme",
403 - self.backend.set_config("theme", &self.current_theme_id),
409 + self.backend
410 + .set_config(crate::backend::ConfigKey::Theme, &self.current_theme_id),
404 411 );
405 412 }
406 413
@@ -416,7 +423,8 @@ impl BrowserState {
416 423 self.save_column_config();
417 424 super::log_backend_err(
418 425 "set_config row_height",
419 - self.backend.set_config("row_height", "24"),
426 + self.backend
427 + .set_config(crate::backend::ConfigKey::RowHeight, "24"),
420 428 );
421 429 self.status = "Columns reset to defaults".to_string();
422 430 }
@@ -453,7 +461,8 @@ impl BrowserState {
453 461 };
454 462 super::log_backend_err(
455 463 "set_config suggestions.dismissed",
456 - self.backend.set_config("suggestions.dismissed", &json),
464 + self.backend
465 + .set_config(crate::backend::ConfigKey::SuggestionsDismissed, &json),
457 466 );
458 467 }
459 468
@@ -523,7 +532,8 @@ impl BrowserState {
523 532 };
524 533 super::log_backend_err(
525 534 "set_config column_config",
526 - self.backend.set_config("column_config", &json),
535 + self.backend
536 + .set_config(crate::backend::ConfigKey::ColumnConfig, &json),
527 537 );
528 538 }
529 539
@@ -559,9 +569,10 @@ impl BrowserState {
559 569 /// Enable or disable the VFS mirror. Persists the setting.
560 570 pub fn set_mirror_enabled(&mut self, enabled: bool) {
561 571 self.mirror.mirror_enabled = enabled;
562 - let _ = self
563 - .backend
564 - .set_config("mirror_enabled", if enabled { "1" } else { "0" });
572 + let _ = self.backend.set_config(
573 + crate::backend::ConfigKey::MirrorEnabled,
574 + if enabled { "1" } else { "0" },
575 + );
565 576
566 577 if enabled {
567 578 // Run initial sync immediately.
@@ -580,9 +591,10 @@ impl BrowserState {
580 591 let _ = audiofiles_core::vfs_mirror::remove_mirror(&self.mirror.mirror_path);
581 592 }
582 593 self.mirror.mirror_path = path;
583 - let _ = self
584 - .backend
585 - .set_config("mirror_path", &self.mirror.mirror_path.to_string_lossy());
594 + let _ = self.backend.set_config(
595 + crate::backend::ConfigKey::MirrorPath,
596 + &self.mirror.mirror_path.to_string_lossy(),
597 + );
586 598 if self.mirror.mirror_enabled {
587 599 self.mirror.mirror_dirty = true;
588 600 }
@@ -318,7 +318,7 @@ impl BrowserState {
318 318 // Restore the last-selected vault by id (indices shift as vaults are
319 319 // added/removed), so the initial contents below load for the right vault.
320 320 let current_vfs_idx = backend
321 - .get_config("current_vfs_id")
321 + .get_config(crate::backend::ConfigKey::CurrentVfsId)
322 322 .ok()
323 323 .flatten()
324 324 .and_then(|s| s.parse::<i64>().ok())
@@ -342,23 +342,27 @@ impl BrowserState {
342 342
343 343 // Load saved theme preference
344 344 let theme_id = backend
345 - .get_config("theme")
345 + .get_config(crate::backend::ConfigKey::Theme)
346 346 .ok()
347 347 .flatten()
348 348 .unwrap_or_else(|| "audiofiles".to_string());
349 349 crate::ui::theme::init(Some(&theme_id));
350 350
351 351 // Load preview settings
352 - let loop_enabled =
353 - backend.get_config("preview_loop").ok().flatten().as_deref() == Some("1");
352 + let loop_enabled = backend
353 + .get_config(crate::backend::ConfigKey::PreviewLoop)
354 + .ok()
355 + .flatten()
356 + .as_deref()
357 + == Some("1");
354 358 let autoplay = backend
355 - .get_config("preview_autoplay")
359 + .get_config(crate::backend::ConfigKey::PreviewAutoplay)
356 360 .ok()
357 361 .flatten()
358 362 .as_deref()
359 363 == Some("1");
360 364 let forge_auto_trim_overshoot = backend
361 - .get_config(crate::backend::FORGE_AUTO_TRIM_OVERSHOOT_KEY)
365 + .get_config(crate::backend::ConfigKey::ForgeAutoTrimOvershoot)
362 366 .ok()
363 367 .flatten()
364 368 .as_deref()
@@ -366,26 +370,26 @@ impl BrowserState {
366 370
367 371 // First-run VFS banner
368 372 let vfs_explained = backend
369 - .get_config("vfs_explained")
373 + .get_config(crate::backend::ConfigKey::VfsExplained)
370 374 .ok()
371 375 .flatten()
372 376 .as_deref()
373 377 == Some("1");
374 378 let hints_dismissed = backend
375 - .get_config("hints_dismissed")
379 + .get_config(crate::backend::ConfigKey::HintsDismissed)
376 380 .ok()
377 381 .flatten()
378 382 .as_deref()
379 383 == Some("1");
380 384 let sync_intro_dismissed = backend
381 - .get_config("sync_intro_dismissed")
385 + .get_config(crate::backend::ConfigKey::SyncIntroDismissed)
382 386 .ok()
383 387 .flatten()
384 388 .as_deref()
385 389 == Some("1");
386 390 // M-9: load persistent preflight dismissal.
387 391 let import_preflight_disabled = backend
388 - .get_config("import_preflight_disabled")
392 + .get_config(crate::backend::ConfigKey::ImportPreflightDisabled)
389 393 .ok()
390 394 .flatten()
391 395 .as_deref()
@@ -393,7 +397,7 @@ impl BrowserState {
393 397
394 398 // Load display density
395 399 let row_height = backend
396 - .get_config("row_height")
400 + .get_config(crate::backend::ConfigKey::RowHeight)
397 401 .ok()
398 402 .flatten()
399 403 .and_then(|s| s.parse::<f32>().ok())
@@ -402,7 +406,7 @@ impl BrowserState {
402 406
403 407 // Load dismissed tag suggestions
404 408 let dismissed_suggestions: std::collections::HashMap<String, Vec<String>> = backend
405 - .get_config("suggestions.dismissed")
409 + .get_config(crate::backend::ConfigKey::SuggestionsDismissed)
406 410 .ok()
407 411 .flatten()
408 412 .and_then(|s| serde_json::from_str(&s).ok())
@@ -410,13 +414,13 @@ impl BrowserState {
410 414
411 415 // Load mirror settings
412 416 let mirror_enabled = backend
413 - .get_config("mirror_enabled")
417 + .get_config(crate::backend::ConfigKey::MirrorEnabled)
414 418 .ok()
415 419 .flatten()
416 420 .as_deref()
417 421 == Some("1");
418 422 let mirror_path = backend
419 - .get_config("mirror_path")
423 + .get_config(crate::backend::ConfigKey::MirrorPath)
420 424 .ok()
421 425 .flatten()
422 426 .map(PathBuf::from)
@@ -429,19 +433,19 @@ impl BrowserState {
429 433 // Restore shell layout state (persisted on toggle). Sidebar/detail
430 434 // default to shown, filter panel to closed.
431 435 let sidebar_visible = backend
432 - .get_config("sidebar_visible")
436 + .get_config(crate::backend::ConfigKey::SidebarVisible)
433 437 .ok()
434 438 .flatten()
435 439 .as_deref()
436 440 != Some("0");
437 441 let detail_visible = backend
438 - .get_config("detail_visible")
442 + .get_config(crate::backend::ConfigKey::DetailVisible)
439 443 .ok()
440 444 .flatten()
441 445 .as_deref()
442 446 != Some("0");
443 447 let filter_panel_open = backend
444 - .get_config("filter_panel_open")
448 + .get_config(crate::backend::ConfigKey::FilterPanelOpen)
445 449 .ok()
446 450 .flatten()
447 451 .as_deref()
@@ -331,7 +331,7 @@ impl BrowserState {
331 331 super::log_backend_err(
332 332 "set_config current_vfs_id",
333 333 self.backend.set_config(
334 - "current_vfs_id",
334 + crate::backend::ConfigKey::CurrentVfsId,
335 335 &self.nav.vfs_list[self.nav.current_vfs_idx]
336 336 .id
337 337 .as_i64()
@@ -353,7 +353,7 @@ impl BrowserState {
353 353 super::log_backend_err(
354 354 "set_config sidebar_visible",
355 355 self.backend.set_config(
356 - "sidebar_visible",
356 + crate::backend::ConfigKey::SidebarVisible,
357 357 if self.sidebar_visible { "1" } else { "0" },
358 358 ),
359 359 );
@@ -371,8 +371,10 @@ impl BrowserState {
371 371 self.detail_visible = visible;
372 372 super::log_backend_err(
373 373 "set_config detail_visible",
374 - self.backend
375 - .set_config("detail_visible", if visible { "1" } else { "0" }),
374 + self.backend.set_config(
375 + crate::backend::ConfigKey::DetailVisible,
376 + if visible { "1" } else { "0" },
377 + ),
376 378 );
377 379 }
378 380 }
@@ -383,7 +385,7 @@ impl BrowserState {
383 385 super::log_backend_err(
384 386 "set_config filter_panel_open",
385 387 self.backend.set_config(
386 - "filter_panel_open",
388 + crate::backend::ConfigKey::FilterPanelOpen,
387 389 if self.search.filter_panel_open {
388 390 "1"
389 391 } else {
@@ -104,8 +104,10 @@ impl BrowserState {
104 104 self.loop_enabled = !self.loop_enabled;
105 105 super::log_backend_err(
106 106 "set_config preview_loop",
107 - self.backend
108 - .set_config("preview_loop", if self.loop_enabled { "1" } else { "0" }),
107 + self.backend.set_config(
108 + crate::backend::ConfigKey::PreviewLoop,
109 + if self.loop_enabled { "1" } else { "0" },
110 + ),
109 111 );
110 112 // Sync to the live playback state
111 113 self.shared.preview.lock().loop_enabled = self.loop_enabled;
@@ -116,8 +118,10 @@ impl BrowserState {
116 118 self.autoplay = !self.autoplay;
117 119 super::log_backend_err(
118 120 "set_config preview_autoplay",
119 - self.backend
120 - .set_config("preview_autoplay", if self.autoplay { "1" } else { "0" }),
121 + self.backend.set_config(
122 + crate::backend::ConfigKey::PreviewAutoplay,
123 + if self.autoplay { "1" } else { "0" },
124 + ),
121 125 );
122 126 }
123 127
@@ -432,7 +432,10 @@ pub fn draw_import_preflight(ctx: &egui::Context, state: &mut BrowserState) {
432 432 // mirror update so the next quick_import_folder bypass-check sees
433 433 // the new value without a reload.
434 434 if state.import_wf.preflight_dont_ask {
435 - if let Err(e) = state.backend.set_config("import_preflight_disabled", "1") {
435 + if let Err(e) = state
436 + .backend
437 + .set_config(crate::backend::ConfigKey::ImportPreflightDisabled, "1")
438 + {
436 439 tracing::warn!("Failed to persist preflight dismissal: {e}");
437 440 }
438 441 state.import_wf.import_preflight_disabled = true;
@@ -690,9 +690,10 @@ fn draw_display_section(ui: &mut egui::Ui, state: &mut BrowserState) {
690 690 .changed()
691 691 {
692 692 state.row_height = row_height;
693 - let _ = state
694 - .backend
695 - .set_config("row_height", &format!("{row_height}"));
693 + let _ = state.backend.set_config(
694 + crate::backend::ConfigKey::RowHeight,
695 + &format!("{row_height}"),
696 + );
696 697 }
697 698 });
698 699
@@ -289,7 +289,9 @@ pub fn draw_sidebar(ui: &mut egui::Ui, state: &mut BrowserState) {
289 289 );
290 290 if ui.small_button("Got it").clicked() {
291 291 state.onboarding.show_vfs_banner = false;
292 - let _ = state.backend.set_config("vfs_explained", "1");
292 + let _ = state
293 + .backend
294 + .set_config(crate::backend::ConfigKey::VfsExplained, "1");
293 295 }
294 296 ui.add_space(theme::space::SM);
295 297 }
@@ -0,0 +1,169 @@
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. Two filters are generated from
5 + //! this one registry:
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 by
10 + //! [`crate::db::Database`] and joined by the `user_config` sync triggers).
11 + //!
12 + //! The two hand-maintained denylists this replaces — a SQL trigger predicate in
13 + //! `db.rs` and a Rust predicate in `audiofiles-sync` — had drifted apart and
14 + //! twice failed to cover device-local keys (`mirror_path`, `mirror_enabled`,
15 + //! `import_preflight_disabled`), letting a hostile server steer a local
16 + //! filesystem write root across the sync boundary (fuzz-2026-07-06 #2,
17 + //! fuzz-2026-07-20 #1, fuzz-2026-07-21 #3 CHRONIC).
18 + //!
19 + //! Because keys are declared through the [`config_keys!`] macro, a new key
20 + //! cannot be added without stating its posture, and a caller cannot name a key
21 + //! that is not in the registry: [`ConfigKey`] is the type the config accessors
22 + //! take, so an unclassified key does not compile.
23 +
24 + /// Whether a `user_config` key may be replicated to other devices.
25 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
26 + pub enum SyncPosture {
27 + /// Safe to sync — a user preference shared across the user's devices.
28 + Replicated,
29 + /// Must never leave the device — names a local filesystem path or gates a
30 + /// local safety check. A remote peer must not be able to set it.
31 + DeviceLocal,
32 + }
33 +
34 + /// Declare the full set of `user_config` keys in one place. Each line binds an
35 + /// enum variant, its on-disk key string, and its [`SyncPosture`]; the macro
36 + /// derives the variant list, the string mapping (both directions), and the
37 + /// posture lookup so the three can never disagree.
38 + macro_rules! config_keys {
39 + ($( $(#[$m:meta])* $variant:ident => $key:literal : $posture:ident ),+ $(,)?) => {
40 + /// A known `user_config` key. The config accessors take this rather than
41 + /// a `&str`, so adding a new key means adding a variant here — which in
42 + /// turn forces declaring its [`SyncPosture`].
43 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
44 + pub enum ConfigKey {
45 + $( $(#[$m])* $variant ),+
46 + }
47 +
48 + impl ConfigKey {
49 + /// Every declared key, for seeding the SQL policy table.
50 + pub const ALL: &'static [ConfigKey] = &[ $( ConfigKey::$variant ),+ ];
51 +
52 + /// The on-disk `user_config.key` string.
53 + #[must_use]
54 + pub const fn as_str(self) -> &'static str {
55 + match self { $( ConfigKey::$variant => $key ),+ }
56 + }
57 +
58 + /// This key's sync posture.
59 + #[must_use]
60 + pub const fn posture(self) -> SyncPosture {
61 + match self { $( ConfigKey::$variant => SyncPosture::$posture ),+ }
62 + }
63 +
64 + /// Resolve a raw key string (e.g. from a remote pull batch) to a
65 + /// known key. Unknown strings return `None`.
66 + #[must_use]
67 + pub fn from_key(key: &str) -> Option<ConfigKey> {
68 + match key {
69 + $( $key => Some(ConfigKey::$variant), )+
70 + _ => None,
71 + }
72 + }
73 + }
74 + };
75 + }
76 +
77 + config_keys! {
78 + // --- Replicated: user preferences shared across the user's devices. ---
79 + Theme => "theme": Replicated,
80 + PreviewLoop => "preview_loop": Replicated,
81 + PreviewAutoplay => "preview_autoplay": Replicated,
82 + EditResultMode => "edit_result_mode": Replicated,
83 + ForgeAutoTrimOvershoot => "forge.auto_trim_overshoot": Replicated,
84 + CurrentVfsId => "current_vfs_id": Replicated,
85 + SidebarVisible => "sidebar_visible": Replicated,
86 + DetailVisible => "detail_visible": Replicated,
87 + FilterPanelOpen => "filter_panel_open": Replicated,
88 + VfsExplained => "vfs_explained": Replicated,
89 + HintsDismissed => "hints_dismissed": Replicated,
90 + SyncIntroDismissed => "sync_intro_dismissed": Replicated,
91 + RowHeight => "row_height": Replicated,
92 + SuggestionsDismissed => "suggestions.dismissed": Replicated,
93 + ColumnConfig => "column_config": Replicated,
94 + SampleTombstoneRetainDays => "sample_tombstone_retain_days": Replicated,
95 +
96 + // --- DeviceLocal: local paths and safety gates. Never sync these. ---
97 + /// Reference-in-place vault mode. Local safety gate.
98 + LooseFiles => "loose_files": DeviceLocal,
99 + /// Legacy alias of `loose_files`, read once during migration.
100 + UnsafeMode => "unsafe_mode": DeviceLocal,
101 + /// Whether the on-disk mirror is enabled. Local.
102 + MirrorEnabled => "mirror_enabled": DeviceLocal,
103 + /// Absolute local path the sample library is mirrored to. Local.
104 + MirrorPath => "mirror_path": DeviceLocal,
105 + /// Suppresses the local import safety preflight. Local safety gate.
106 + ImportPreflightDisabled => "import_preflight_disabled": DeviceLocal,
107 + }
108 +
109 + impl ConfigKey {
110 + /// True when this key must not cross the sync boundary.
111 + #[must_use]
112 + pub const fn is_excluded_from_sync(self) -> bool {
113 + matches!(self.posture(), SyncPosture::DeviceLocal)
114 + }
115 + }
116 +
117 + /// Import-side filter: true when a raw remote key must be dropped rather than
118 + /// applied to `user_config`. Device-local keys are refused; unknown keys fail
119 + /// closed (a hostile server cannot smuggle a key past us by misspelling it, and
120 + /// an older client simply ignores keys a newer one added).
121 + #[must_use]
122 + pub fn key_excluded_from_sync(key: &str) -> bool {
123 + match ConfigKey::from_key(key) {
124 + Some(k) => k.is_excluded_from_sync(),
125 + None => true,
126 + }
127 + }
128 +
129 + #[cfg(test)]
130 + mod tests {
131 + use super::*;
132 +
133 + #[test]
134 + fn as_str_round_trips_through_from_key() {
135 + for &k in ConfigKey::ALL {
136 + assert_eq!(ConfigKey::from_key(k.as_str()), Some(k), "{k:?}");
137 + }
138 + }
139 +
140 + #[test]
141 + fn key_strings_are_unique() {
142 + let mut seen = std::collections::HashSet::new();
143 + for &k in ConfigKey::ALL {
144 + assert!(
145 + seen.insert(k.as_str()),
146 + "duplicate key string {:?}",
147 + k.as_str()
148 + );
149 + }
150 + }
151 +
152 + #[test]
153 + fn device_local_keys_are_excluded_replicated_are_not() {
154 + assert!(key_excluded_from_sync("loose_files"));
155 + assert!(key_excluded_from_sync("mirror_path"));
156 + assert!(key_excluded_from_sync("mirror_enabled"));
157 + assert!(key_excluded_from_sync("import_preflight_disabled"));
158 + assert!(key_excluded_from_sync("unsafe_mode"));
159 +
160 + assert!(!key_excluded_from_sync("theme"));
161 + assert!(!key_excluded_from_sync("sample_tombstone_retain_days"));
162 + }
163 +
164 + #[test]
165 + fn unknown_keys_fail_closed() {
166 + assert!(key_excluded_from_sync("sync_cursor"));
167 + assert!(key_excluded_from_sync("totally_unknown_key"));
168 + }
169 + }
@@ -1554,6 +1554,53 @@ BEGIN
1554 1554 END;
1555 1555 "#;
1556 1556
1557 + const MIGRATION_033: &str = r#"
1558 + -- Generate the user_config export filter from the ConfigKey registry instead of
1559 + -- a hand-maintained trigger predicate. `config_key_policy` is seeded at every
1560 + -- open from `audiofiles_core::config_key::ConfigKey::ALL` (see
1561 + -- Database::seed_config_key_policy); the triggers below enqueue a changelog row
1562 + -- only for keys the registry marks replicated. This closes the CHRONIC where the
1563 + -- old `NEW.key != 'loose_files'` denylist never covered mirror_path /
1564 + -- mirror_enabled / import_preflight_disabled, letting a hostile server steer a
1565 + -- local filesystem write root across the sync boundary (fuzz-2026-07-21 #3).
1566 + -- Unknown keys are absent from the policy table and therefore never exported —
1567 + -- the filter fails closed.
1568 + CREATE TABLE IF NOT EXISTS config_key_policy (
1569 + key TEXT PRIMARY KEY,
1570 + replicated INTEGER NOT NULL
1571 + );
1572 +
1573 + DROP TRIGGER IF EXISTS sync_user_config_insert;
1574 + DROP TRIGGER IF EXISTS sync_user_config_update;
1575 + DROP TRIGGER IF EXISTS sync_user_config_delete;
1576 +
1577 + CREATE TRIGGER sync_user_config_insert AFTER INSERT ON user_config
1578 + WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1'
1579 + AND EXISTS (SELECT 1 FROM config_key_policy p WHERE p.key = NEW.key AND p.replicated = 1)
1580 + BEGIN
1581 + INSERT INTO sync_changelog (table_name, op, row_id, data)
1582 + VALUES ('user_config', 'INSERT', NEW.key,
1583 + json_object('key', NEW.key, 'value', NEW.value));
1584 + END;
1585 +
1586 + CREATE TRIGGER sync_user_config_update AFTER UPDATE ON user_config
1587 + WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1'
1588 + AND EXISTS (SELECT 1 FROM config_key_policy p WHERE p.key = NEW.key AND p.replicated = 1)
1589 + BEGIN
1590 + INSERT INTO sync_changelog (table_name, op, row_id, data)
1591 + VALUES ('user_config', 'UPDATE', NEW.key,
1592 + json_object('key', NEW.key, 'value', NEW.value));
1593 + END;
1594 +
1595 + CREATE TRIGGER sync_user_config_delete AFTER DELETE ON user_config
1596 + WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1'
1597 + AND EXISTS (SELECT 1 FROM config_key_policy p WHERE p.key = OLD.key AND p.replicated = 1)
1598 + BEGIN
1599 + INSERT INTO sync_changelog (table_name, op, row_id, data)
1600 + VALUES ('user_config', 'DELETE', OLD.key, json_object('key', OLD.key));
1601 + END;
1602 + "#;
1603 +
1557 1604 /// Register `hash_row_id(salt, key) -> TEXT` as a deterministic SQLite
1558 1605 /// function on the given connection. Used by the M018 sync triggers so the
1559 1606 /// `sync_changelog.row_id` field never carries cleartext content (tag strings,
@@ -1623,6 +1670,7 @@ impl Database {
1623 1670 register_hash_row_id(&conn)?;
1624 1671 let mut db = Self { conn };
1625 1672 db.migrate()?;
1673 + db.seed_config_key_policy()?;
1626 1674 Ok(db)
1627 1675 }
1628 1676
@@ -1643,9 +1691,28 @@ impl Database {
1643 1691 register_hash_row_id(&conn)?;
1644 1692 let mut db = Self { conn };
1645 1693 db.migrate()?;
1694 + db.seed_config_key_policy()?;
1646 1695 Ok(db)
1647 1696 }
1648 1697
1698 + /// Seed `config_key_policy` from the [`ConfigKey`](crate::config_key::ConfigKey)
1699 + /// registry — the single source of truth for which `user_config` keys may
1700 + /// sync. Run at every open so the SQL export triggers always reflect the
1701 + /// current registry: adding a key in Rust is enough, no migration needed.
1702 + /// Idempotent (clear + reinsert the closed set).
1703 + fn seed_config_key_policy(&self) -> Result<(), DbError> {
1704 + use crate::config_key::{ConfigKey, SyncPosture};
1705 + self.conn.execute("DELETE FROM config_key_policy", [])?;
1706 + let mut stmt = self
1707 + .conn
1708 + .prepare("INSERT INTO config_key_policy (key, replicated) VALUES (?1, ?2)")?;
1709 + for &key in ConfigKey::ALL {
1710 + let replicated = i64::from(matches!(key.posture(), SyncPosture::Replicated));
1711 + stmt.execute(rusqlite::params![key.as_str(), replicated])?;
1712 + }
1713 + Ok(())
1714 + }
1715 +
1649 1716 /// Apply pending migrations using PRAGMA user_version as the version tracker.
1650 1717 ///
1651 1718 /// Each migration step runs inside a transaction so the schema change and
@@ -1690,6 +1757,7 @@ impl Database {
1690 1757 MIGRATION_030,
1691 1758 MIGRATION_031,
1692 1759 MIGRATION_032,
1760 + MIGRATION_033,
1693 1761 ];
1694 1762
1695 1763 for (i, sql) in MIGRATIONS.iter().enumerate() {
@@ -1835,9 +1903,19 @@ impl Database {
1835 1903 let (count, total): (u64, u64) = self.conn.query_row(
1836 1904 "SELECT COUNT(*), COALESCE(SUM(file_size), 0) FROM live_samples",
1837 1905 [],
1838 - // rusqlite 0.40 dropped u64: FromSql (SQLite integers are i64);
1839 - // COUNT/SUM are non-negative, so read i64 and widen.
1840 - |row| Ok((row.get::<_, i64>(0)? as u64, row.get::<_, i64>(1)? as u64)),
1906 + // SQLite integers are i64. COUNT/SUM should be non-negative, but a
1907 + // single corrupt negative file_size must surface as an error, not
1908 + // wrap silently to ~1.8e19 (workspace denies unwrap for this class).
1909 + |row| {
1910 + let count = row.get::<_, i64>(0)?;
1911 + let total = row.get::<_, i64>(1)?;
1912 + Ok((
1913 + u64::try_from(count)
1914 + .map_err(|_| rusqlite::Error::IntegralValueOutOfRange(0, count))?,
1915 + u64::try_from(total)
1916 + .map_err(|_| rusqlite::Error::IntegralValueOutOfRange(1, total))?,
1917 + ))
1918 + },
1841 1919 )?;
1842 1920 Ok((count, total))
1843 1921 }
@@ -1857,8 +1935,18 @@ impl Database {
1857 1935 WHERE vfs_id = ? AND sample_hash IS NOT NULL\
1858 1936 )",
1859 1937 [vfs_id],
1860 - // rusqlite 0.40 dropped u64: FromSql; COUNT/SUM are non-negative.
1861 - |row| Ok((row.get::<_, i64>(0)? as u64, row.get::<_, i64>(1)? as u64)),
1938 + // Non-negative in practice; a corrupt negative surfaces as an error
1939 + // rather than wrapping silently to a nonsense u64.
1940 + |row| {
1941 + let count = row.get::<_, i64>(0)?;
1942 + let total = row.get::<_, i64>(1)?;
1943 + Ok((
1944 + u64::try_from(count)
1945 + .map_err(|_| rusqlite::Error::IntegralValueOutOfRange(0, count))?,
1946 + u64::try_from(total)
1947 + .map_err(|_| rusqlite::Error::IntegralValueOutOfRange(1, total))?,
1948 + ))
1949 + },
1862 1950 )?;
1863 1951 Ok((count, total))
1864 1952 }
@@ -1915,6 +2003,7 @@ mod tests {
1915 2003 "classifier_layers",
1916 2004 "collection_members",
1917 2005 "collections",
2006 + "config_key_policy",
1918 2007 "edit_history",
1919 2008 "fingerprints",
1920 2009 "hlc_ledger",
@@ -1942,7 +2031,7 @@ mod tests {
1942 2031 .conn()
1943 2032 .query_row("PRAGMA user_version", [], |row| row.get(0))
1944 2033 .unwrap();
1945 - assert_eq!(version, 32);
2034 + assert_eq!(version, 33);
1946 2035 }
1947 2036
1948 2037 #[test]
@@ -1953,7 +2042,7 @@ mod tests {
1953 2042 .conn()
1954 2043 .query_row("PRAGMA user_version", [], |row| row.get(0))
1955 2044 .unwrap();
1956 - assert_eq!(version, 32);
2045 + assert_eq!(version, 33);
1957 2046 }
1958 2047
1959 2048 #[test]
@@ -1985,6 +2074,46 @@ mod tests {
1985 2074 }
1986 2075 }
1987 2076
2077 + #[test]
2078 + fn user_config_export_trigger_excludes_device_local_keys() {
2079 + // Export side of the CHRONIC fix (fuzz-2026-07-21 #3): the sync triggers
2080 + // are generated from the ConfigKey registry via config_key_policy, so a
2081 + // device-local key never enqueues a changelog row — while a replicated
2082 + // key still does. Symmetric with the import-side test in audiofiles-sync.
2083 + let db = Database::open_in_memory().unwrap();
2084 + let conn = db.conn();
2085 + let changelog_rows = |key: &str| -> i64 {
2086 + conn.query_row(
2087 + "SELECT COUNT(*) FROM sync_changelog WHERE table_name = 'user_config' AND row_id = ?1",
2088 + [key],
2089 + |r| r.get(0),
2090 + )
2091 + .unwrap()
2092 + };
2093 +
2094 + for key in [
2095 + "mirror_path",
2096 + "mirror_enabled",
2097 + "import_preflight_disabled",
2098 + "loose_files",
2099 + ] {
2100 + conn.execute(
2101 + "INSERT OR REPLACE INTO user_config (key, value) VALUES (?1, '1')",
2102 + [key],
2103 + )
2104 + .unwrap();
2105 + assert_eq!(changelog_rows(key), 0, "{key} must not be exported");
2106 + }
2107 +
2108 + // A replicated key still enqueues a changelog row.
2109 + conn.execute(
2110 + "INSERT OR REPLACE INTO user_config (key, value) VALUES ('theme', 'dark')",
2111 + [],
2112 + )
2113 + .unwrap();
2114 + assert_eq!(changelog_rows("theme"), 1, "theme must be exported");
2115 + }
2116 +
1988 2117 /// Open a fresh file-backed DB, close, reopen. The second open re-enters
1989 2118 /// `migrate()`; with `user_version=17` no migration body runs, but the
1990 2119 /// shape verifies our open/close cycle is clean (no locks, no WAL leak).
@@ -2001,7 +2130,7 @@ mod tests {
2001 2130 .conn()
2002 2131 .query_row("PRAGMA user_version", [], |row| row.get(0))
2003 2132 .unwrap();
2004 - assert_eq!(version, 32);
2133 + assert_eq!(version, 33);
2005 2134 }
2006 2135
2007 2136 /// Simulates the worst-case recovery path: a prior partial migration left
@@ -2045,7 +2174,7 @@ mod tests {
2045 2174 .conn()
2046 2175 .query_row("PRAGMA user_version", [], |row| row.get(0))
2047 2176 .unwrap();
2048 - assert_eq!(version, 32);
2177 + assert_eq!(version, 33);
2049 2178 }
2050 2179
2051 2180 /// M018 contract: the `sync_changelog.row_id` for sensitive tables must
@@ -2273,7 +2402,7 @@ mod tests {
2273 2402 let initial_version: i32 = conn
2274 2403 .query_row("PRAGMA user_version", [], |row| row.get(0))
2275 2404 .unwrap();
2276 - assert_eq!(initial_version, 32);
2405 + assert_eq!(initial_version, 33);
2277 2406
2278 2407 let batch = format!("BEGIN;\n{}\nPRAGMA user_version = 999;\nCOMMIT;", bad_sql);
2279 2408 let first_err = conn.execute_batch(&batch).unwrap_err();
@@ -2338,7 +2467,7 @@ mod tests {
2338 2467 .conn()
2339 2468 .query_row("PRAGMA user_version", [], |row| row.get(0))
2340 2469 .unwrap();
2341 - assert_eq!(version, 32);
2470 + assert_eq!(version, 33);
2342 2471 }
2343 2472
2344 2473 #[test]
@@ -47,6 +47,7 @@
47 47
48 48 pub mod analysis;
49 49 pub mod collections;
50 + pub mod config_key;
50 51 pub mod db;
51 52 pub mod edit;
52 53 pub mod error;
@@ -265,8 +265,26 @@ async fn run_sync_cycle(
265 265 }
266 266
267 267 // Blob sync: upload pending, then download missing
268 - if let Err(e) = service::upload_pending_blobs(db_path, content_dir, client).await {
269 - tracing::warn!("Blob upload failed (non-fatal): {e}");
268 + match service::upload_pending_blobs(db_path, content_dir, client).await {
269 + Ok(summary) if summary.failed > 0 => {
270 + // A per-blob failure no longer wedges the queue, but the user must
271 + // still be told: an unbounded suffix of the library would otherwise
272 + // never leave the device while sync reports a clean cycle. Mirror the
273 + // retention path below, which surfaces its data-loss risk the same way.
274 + let detail = summary
275 + .first_error
276 + .as_deref()
277 + .unwrap_or("see logs for details");
278 + status.lock().last_error = Some(format!(
279 + "{} sample(s) could not be uploaded and are not backed up yet: {detail}",
280 + summary.failed
281 + ));
282 + }
283 + Ok(_) => {}
284 + Err(e) => {
285 + tracing::warn!("Blob upload failed (non-fatal): {e}");
286 + status.lock().last_error = Some(format!("Blob upload failed: {e}"));
287 + }
270 288 }
271 289 if let Err(e) = service::download_missing_blobs(db_path, content_dir, client).await {
272 290 tracing::warn!("Blob download failed (non-fatal): {e}");
@@ -256,15 +256,18 @@ fn is_constraint_violation(e: &SyncError) -> bool {
256 256 )
257 257 }
258 258
259 - /// A `user_config` key the sync boundary must never carry — the exact set the
260 - /// *export* side refuses to emit (`db.rs` triggers + `state.rs` snapshot query:
261 - /// `key NOT LIKE 'sync_%' AND key != 'loose_files'`). The import path must be
262 - /// symmetric: a compromised or malicious server can put any `{key, value}` into
263 - /// a pull batch, and `loose_files` flips the vault into reference-in-place mode
264 - /// while the `sync_*` keys are sync-internal bookkeeping (cursors, tokens). If
265 - /// export won't send them, import won't accept them. See fuzz-2026-07-06 #2.
259 + /// A `user_config` key the sync boundary must never carry, on import. This is
260 + /// the symmetric partner of the *export* filter: both are generated from the one
261 + /// [`ConfigKey`](audiofiles_core::config_key::ConfigKey) registry
262 + /// (`key_excluded_from_sync` here; the `config_key_policy` table the db.rs
263 + /// triggers join against on export), so the two can no longer drift the way the
264 + /// two hand-maintained denylists did across fuzz-2026-07-06 / -07-20 / -07-21.
265 + ///
266 + /// A compromised or malicious server can put any `{key, value}` into a pull
267 + /// batch; device-local keys (`mirror_path`, `mirror_enabled`,
268 + /// `import_preflight_disabled`, `loose_files`) and unknown keys are refused.
266 269 pub(crate) fn config_key_excluded_from_sync(key: &str) -> bool {
267 - key.starts_with("sync_") || key == "loose_files"
270 + audiofiles_core::config_key::key_excluded_from_sync(key)
268 271 }
269 272
270 273 /// Apply an upsert (INSERT OR REPLACE) for a single row.
@@ -646,17 +649,21 @@ mod tests {
646 649 }
647 650
648 651 #[test]
649 - fn config_key_exclusion_matches_export_denylist() {
652 + fn config_key_exclusion_covers_device_local_and_unknown_keys() {
653 + // Device-local keys the CHRONIC finding named — all must be refused on
654 + // import, including the three the old hand-list never covered.
650 655 assert!(config_key_excluded_from_sync("loose_files"));
656 + assert!(config_key_excluded_from_sync("mirror_path"));
657 + assert!(config_key_excluded_from_sync("mirror_enabled"));
658 + assert!(config_key_excluded_from_sync("import_preflight_disabled"));
659 + // Unknown keys fail closed (a hostile server cannot smuggle one past).
651 660 assert!(config_key_excluded_from_sync("sync_cursor"));
652 - assert!(config_key_excluded_from_sync("sync_"));
661 + assert!(config_key_excluded_from_sync("totally_unknown"));
662 + // Replicated keys still apply.
653 663 assert!(!config_key_excluded_from_sync(
654 664 "sample_tombstone_retain_days"
655 665 ));
656 666 assert!(!config_key_excluded_from_sync("theme"));
657 - // Not a prefix match beyond the literal — a benign key that merely
658 - // contains "sync" mid-string is allowed.
659 - assert!(!config_key_excluded_from_sync("autosync_hint"));
660 667 }
661 668
662 669 #[test]