max / audiofiles
22 files changed,
+617 insertions,
-118 deletions
| @@ -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] |