Skip to main content

max / audiofiles

Audit remediation: observability, adversarial fixes, doc comments, sync hardening Multi-round audit improvements: - Observability A: registry + EnvFilter subscriber, 12 #[instrument] annotations - Adversarial fixes: extension validation in store.rs (rejects path traversal), OAuth callback safe slicing, query_sample_field allowlist hardened, double-lock fix in sync scheduler - Security: applying_remote cleared on startup (crash recovery), column whitelists in sync, hash validation, LIKE wildcard escaping - Code documentation: module-level docs, public function docs, README, architecture.md - Concurrency: parking_lot upgrade in sync service Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-14 03:36 UTC
Commit: aed3cc409ef48654669c1c8d798674a2170bc517
Parent: fe65020
18 files changed, +507 insertions, -32 deletions
M Cargo.lock +49 -10
@@ -418,7 +418,7 @@ dependencies = [
418 418
419 419 [[package]]
420 420 name = "audiofiles-app"
421 - version = "0.2.0"
421 + version = "0.2.1"
422 422 dependencies = [
423 423 "audiofiles-browser",
424 424 "audiofiles-core",
@@ -436,7 +436,7 @@ dependencies = [
436 436
437 437 [[package]]
438 438 name = "audiofiles-browser"
439 - version = "0.2.0"
439 + version = "0.2.1"
440 440 dependencies = [
441 441 "audiofiles-core",
442 442 "audiofiles-rhai",
@@ -458,7 +458,7 @@ dependencies = [
458 458
459 459 [[package]]
460 460 name = "audiofiles-core"
461 - version = "0.2.0"
461 + version = "0.2.1"
462 462 dependencies = [
463 463 "bs1770",
464 464 "hound",
@@ -476,7 +476,7 @@ dependencies = [
476 476
477 477 [[package]]
478 478 name = "audiofiles-ipc"
479 - version = "0.2.0"
479 + version = "0.2.1"
480 480 dependencies = [
481 481 "audiofiles-core",
482 482 "serde",
@@ -486,7 +486,7 @@ dependencies = [
486 486
487 487 [[package]]
488 488 name = "audiofiles-plugin"
489 - version = "0.2.0"
489 + version = "0.2.1"
490 490 dependencies = [
491 491 "audiofiles-browser",
492 492 "audiofiles-core",
@@ -494,11 +494,12 @@ dependencies = [
494 494 "nih_plug",
495 495 "nih_plug_egui",
496 496 "parking_lot",
497 + "smallvec",
497 498 ]
498 499
499 500 [[package]]
500 501 name = "audiofiles-rhai"
501 - version = "0.2.0"
502 + version = "0.2.1"
502 503 dependencies = [
503 504 "audiofiles-core",
504 505 "dirs",
@@ -511,7 +512,7 @@ dependencies = [
511 512
512 513 [[package]]
513 514 name = "audiofiles-sync"
514 - version = "0.2.0"
515 + version = "0.2.1"
515 516 dependencies = [
516 517 "audiofiles-core",
517 518 "base64",
@@ -2852,6 +2853,15 @@ dependencies = [
2852 2853 ]
2853 2854
2854 2855 [[package]]
2856 + name = "matchers"
2857 + version = "0.2.0"
2858 + source = "registry+https://github.com/rust-lang/crates.io-index"
2859 + checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
2860 + dependencies = [
2861 + "regex-automata",
2862 + ]
2863 +
2864 + [[package]]
2855 2865 name = "memchr"
2856 2866 version = "2.8.0"
2857 2867 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4973,7 +4983,7 @@ dependencies = [
4973 4983
4974 4984 [[package]]
4975 4985 name = "synckit-client"
4976 - version = "0.2.0"
4986 + version = "0.2.1"
4977 4987 dependencies = [
4978 4988 "argon2",
4979 4989 "base64",
@@ -4988,6 +4998,7 @@ dependencies = [
4988 4998 "thiserror 1.0.69",
4989 4999 "tokio",
4990 5000 "tracing",
5001 + "unicode-normalization",
4991 5002 "urlencoding",
4992 5003 "uuid 1.21.0",
4993 5004 ]
@@ -5050,7 +5061,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
5050 5061 checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1"
5051 5062 dependencies = [
5052 5063 "fastrand",
5053 - "getrandom 0.3.4",
5064 + "getrandom 0.4.2",
5054 5065 "once_cell",
5055 5066 "rustix 1.1.3",
5056 5067 "windows-sys 0.61.2",
@@ -5187,6 +5198,21 @@ dependencies = [
5187 5198 ]
5188 5199
5189 5200 [[package]]
5201 + name = "tinyvec"
5202 + version = "1.10.0"
5203 + source = "registry+https://github.com/rust-lang/crates.io-index"
5204 + checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa"
5205 + dependencies = [
5206 + "tinyvec_macros",
5207 + ]
5208 +
5209 + [[package]]
5210 + name = "tinyvec_macros"
5211 + version = "0.1.1"
5212 + source = "registry+https://github.com/rust-lang/crates.io-index"
5213 + checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
5214 +
5215 + [[package]]
5190 5216 name = "tokio"
5191 5217 version = "1.50.0"
5192 5218 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5447,10 +5473,14 @@ version = "0.3.22"
5447 5473 source = "registry+https://github.com/rust-lang/crates.io-index"
5448 5474 checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
5449 5475 dependencies = [
5476 + "matchers",
5450 5477 "nu-ansi-term",
5478 + "once_cell",
5479 + "regex-automata",
5451 5480 "sharded-slab",
5452 5481 "smallvec",
5453 5482 "thread_local",
5483 + "tracing",
5454 5484 "tracing-core",
5455 5485 "tracing-log",
5456 5486 ]
@@ -5522,6 +5552,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
5522 5552 checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
5523 5553
5524 5554 [[package]]
5555 + name = "unicode-normalization"
5556 + version = "0.1.25"
5557 + source = "registry+https://github.com/rust-lang/crates.io-index"
5558 + checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
5559 + dependencies = [
5560 + "tinyvec",
5561 + ]
5562 +
5563 + [[package]]
5525 5564 name = "unicode-segmentation"
5526 5565 version = "1.12.0"
5527 5566 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6574,7 +6613,7 @@ checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f"
6574 6613
6575 6614 [[package]]
6576 6615 name = "xtask"
6577 - version = "0.2.0"
6616 + version = "0.2.1"
6578 6617 dependencies = [
6579 6618 "nih_plug_xtask",
6580 6619 ]
M Cargo.toml +2 -1
@@ -34,7 +34,7 @@ serde_json = "1.0.149"
34 34 rubato = "0.14"
35 35 hound = "3.5"
36 36 tracing = "0.1.44"
37 - tracing-subscriber = "0.3.22"
37 + tracing-subscriber = { version = "0.3.22", features = ["env-filter"] }
38 38 rhai = { version = "1.21", features = ["sync"] }
39 39 tray-icon = "0.21"
40 40 tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "sync"] }
@@ -42,3 +42,4 @@ uuid = { version = "1", features = ["v4"] }
42 42 base64 = "0.22"
43 43 chrono = "0.4"
44 44 rand = "0.8"
45 + smallvec = "1.13"
A README.md +58
@@ -0,0 +1,58 @@
1 + # AudioFiles
2 +
3 + A sample manager with content-addressed storage and a virtual file system. Runs as a CLAP/VST3 audio plugin inside your DAW or as a standalone desktop app. Built with Rust, egui, and SQLite.
4 +
5 + ## Prerequisites
6 +
7 + - **Rust** (stable toolchain, 2021 edition)
8 +
9 + No platform-specific audio libraries are required. Audio decoding uses Symphonia (pure Rust), SQLite is bundled via rusqlite, and the standalone app uses cpal for system audio output.
10 +
11 + ## Build and Run
12 +
13 + ```sh
14 + # Standalone app
15 + cargo run -p audiofiles-app
16 +
17 + # Standalone app — import a folder on launch
18 + cargo run -p audiofiles-app -- /path/to/samples
19 +
20 + # CLAP/VST3 plugin bundle (uses nih-plug xtask)
21 + cargo xtask bundle audiofiles-plugin --release
22 +
23 + # Run all workspace tests
24 + cargo test --workspace
25 + ```
26 +
27 + The `cargo xtask bundle` command produces both CLAP and VST3 bundles in `target/bundled/`. Copy them to your DAW's plugin directory.
28 +
29 + ## Workspace Architecture
30 +
31 + Seven crates plus an xtask build helper:
32 +
33 + | Crate | Path | Role |
34 + |-------|------|------|
35 + | `audiofiles-core` | `crates/audiofiles-core/` | Domain library. SQLite database, content-addressed store (SHA-256), audio decoding (Symphonia), analysis pipeline (loudness, BPM, key, spectral, classification), VFS, tag system. |
36 + | `audiofiles-browser` | `crates/audiofiles-browser/` | Shared egui UI. File list, detail panel, waveform display, search/filter, import wizard, analysis progress, export. Used by both plugin and standalone. |
37 + | `audiofiles-plugin` | `crates/audiofiles-plugin/` | CLAP/VST3 plugin via nih-plug. Stereo output for preview playback, egui editor window, persistent state across DAW sessions. |
38 + | `audiofiles-app` | `crates/audiofiles-app/` | Standalone desktop app via eframe. System audio output (cpal), drag-and-drop import, CLI argument import, system tray. |
39 + | `audiofiles-sync` | `crates/audiofiles-sync/` | Cloud sync integration via SyncKit. Pushes/pulls sample metadata, tags, and VFS structure across devices. |
40 + | `audiofiles-rhai` | `crates/audiofiles-rhai/` | Rhai scripting engine for device export profiles. Transforms sample metadata and file layout for hardware samplers. |
41 + | `audiofiles-ipc` | `crates/audiofiles-ipc/` | IPC message types for communication between plugin and standalone instances. Shared database coordination. |
42 +
43 + Dependency flow: `audiofiles-core` is the leaf -> `audiofiles-rhai`, `audiofiles-sync`, and `audiofiles-ipc` depend on core -> `audiofiles-browser` depends on core, sync, and rhai -> `audiofiles-plugin` and `audiofiles-app` depend on browser and core.
44 +
45 + ## Features
46 +
47 + - **Content-addressed storage** -- samples stored by SHA-256 hash, automatic deduplication
48 + - **Virtual file system** -- organize samples in virtual directories independent of disk location, multiple VFS roots
49 + - **Analysis pipeline** -- loudness (peak/RMS/LUFS), BPM detection, key detection, spectral analysis, loop detection, classification into 12 categories
50 + - **Tag system** -- hierarchical dot-notation tags with auto-suggestions from analysis results
51 + - **Search and filtering** -- text search, BPM/duration ranges, key selector, classification filters, tag prefix matching
52 + - **Rhai export engine** -- scriptable device profiles for exporting to hardware samplers
53 + - **Cloud sync** -- cross-device sync of metadata, tags, and VFS via SyncKit (E2E encrypted)
54 + - **Audio formats** -- WAV, FLAC, MP3, OGG, AIFF
55 +
56 + ## License
57 +
58 + PolyForm Noncommercial 1.0.0
@@ -7,6 +7,7 @@ use audiofiles_browser::state::SharedState;
7 7 use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
8 8 use cpal::Stream;
9 9 use parking_lot::Mutex;
10 + use tracing::instrument;
10 11 use thiserror::Error;
11 12
12 13 /// Errors from audio output stream setup.
@@ -26,6 +27,7 @@ pub enum AudioError {
26 27
27 28 /// Build and start a cpal output stream that reads from the shared preview state.
28 29 /// Returns the stream handle (must be kept alive for playback to continue).
30 + #[instrument(skip_all)]
29 31 pub fn start_output_stream(shared: Arc<SharedState>) -> Result<Stream, AudioError> {
30 32 let host = cpal::default_host();
31 33 let device = host
@@ -13,6 +13,7 @@ use audiofiles_browser::state::{BrowserState, SharedState};
13 13 use audiofiles_sync::{SyncKitConfig, SyncManager};
14 14 use eframe::egui;
15 15 use eframe::egui::ViewportCommand;
16 + use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};
16 17
17 18 /// Launch the AudioFiles standalone app.
18 19 ///
@@ -20,7 +21,12 @@ use eframe::egui::ViewportCommand;
20 21 /// audio output stream for sample preview, and opens an eframe window running
21 22 /// the shared egui browser UI.
22 23 fn main() -> eframe::Result<()> {
23 - tracing_subscriber::fmt::init();
24 + tracing_subscriber::registry()
25 + .with(EnvFilter::try_from_default_env().unwrap_or_else(|_| {
26 + "audiofiles_app=info,audiofiles_browser=debug,audiofiles_sync=debug,audiofiles_core=info,warn".into()
27 + }))
28 + .with(tracing_subscriber::fmt::layer())
29 + .init();
24 30
25 31 let data_dir = dirs::data_dir()
26 32 .unwrap_or_else(|| PathBuf::from("."))
@@ -625,26 +625,23 @@ impl Backend for DirectBackend {
625 625 }
626 626
627 627 fn cancel_import(&self) -> BackendResult<()> {
628 - if let Some(ref worker) = *self.import_worker.lock() {
628 + if let Some(worker) = self.import_worker.lock().take() {
629 629 worker.send(ImportCommand::Cancel);
630 630 }
631 - *self.import_worker.lock() = None;
632 631 Ok(())
633 632 }
634 633
635 634 fn cancel_analysis(&self) -> BackendResult<()> {
636 - if let Some(ref worker) = *self.analysis_worker.lock() {
635 + if let Some(worker) = self.analysis_worker.lock().take() {
637 636 worker.send(WorkerCommand::Cancel);
638 637 }
639 - *self.analysis_worker.lock() = None;
640 638 Ok(())
641 639 }
642 640
643 641 fn cancel_export(&self) -> BackendResult<()> {
644 - if let Some(ref worker) = *self.export_worker.lock() {
642 + if let Some(worker) = self.export_worker.lock().take() {
645 643 worker.send(ExportCommand::Cancel);
646 644 }
647 - *self.export_worker.lock() = None;
648 645 Ok(())
649 646 }
650 647
@@ -7,7 +7,7 @@ use std::path::PathBuf;
7 7 use std::sync::{mpsc, Mutex};
8 8 use std::thread;
9 9
10 - use tracing::error;
10 + use tracing::{error, instrument};
11 11
12 12 use audiofiles_core::export::{ExportConfig, ExportItem, ExportSummary};
13 13 use audiofiles_core::store::SampleStore;
@@ -74,6 +74,7 @@ impl Drop for ExportHandle {
74 74 /// Spawn the background export worker thread.
75 75 ///
76 76 /// The worker opens its own `SampleStore` to avoid Mutex contention with the GUI thread.
77 + #[instrument(skip_all)]
77 78 pub fn spawn_export_worker(store_root: PathBuf) -> ExportHandle {
78 79 let (cmd_tx, cmd_rx) = mpsc::channel::<ExportCommand>();
79 80 let (event_tx, event_rx) = mpsc::channel::<ExportEvent>();
@@ -9,7 +9,7 @@ use std::path::{Path, PathBuf};
9 9 use std::sync::{mpsc, Mutex};
10 10 use std::thread;
11 11
12 - use tracing::error;
12 + use tracing::{error, instrument};
13 13
14 14 use audiofiles_core::db::Database;
15 15 use audiofiles_core::error::CoreError;
@@ -116,6 +116,7 @@ impl Drop for ImportHandle {
116 116 ///
117 117 /// The worker opens its own `Database` and `SampleStore` to avoid Mutex contention
118 118 /// with the GUI thread. Returns a handle for sending commands and polling events.
119 + #[instrument(skip_all)]
119 120 pub fn spawn_import_worker(db_path: PathBuf, store_root: PathBuf) -> ImportHandle {
120 121 let (cmd_tx, cmd_rx) = mpsc::channel::<ImportCommand>();
121 122 let (event_tx, event_rx) = mpsc::channel::<ImportEvent>();
@@ -10,10 +10,16 @@ use realfft::RealFftPlanner;
10 10 /// Results of spectral analysis across the entire signal.
11 11 #[derive(Debug, Clone, Default)]
12 12 pub struct SpectralFeatures {
13 + /// Spectral center of mass in Hz (low = bassy, high = bright).
13 14 pub centroid: f64,
15 + /// Ratio from 0.0 (pure tone) to 1.0 (white noise), computed as geometric/arithmetic
16 + /// mean of magnitudes.
14 17 pub flatness: f64,
18 + /// Frequency in Hz below which 85% of spectral energy resides.
15 19 pub rolloff: f64,
20 + /// Proportion of sign changes per sample (high = noisy/bright transients).
16 21 pub zero_crossing_rate: f64,
22 + /// Sum of positive spectral flux across frames (higher = more percussive onsets).
17 23 pub onset_strength: f64,
18 24 }
19 25
@@ -70,6 +70,10 @@ pub enum CoreError {
70 70 /// JSON serialization/deserialization failure.
71 71 #[error("serialization error: {0}")]
72 72 Serialization(String),
73 +
74 + /// Internal logic error (e.g. invalid arguments to an internal function).
75 + #[error("internal error: {0}")]
76 + Internal(String),
73 77 }
74 78
75 79 /// Typed errors for the audio analysis/decode pipeline.
@@ -169,6 +173,7 @@ mod tests {
169 173 CoreError::Analysis(AnalysisError::NoAudioData),
170 174 CoreError::Export("test export error".into()),
171 175 CoreError::Serialization("test serialization error".into()),
176 + CoreError::Internal("test internal error".into()),
172 177 ];
173 178 for err in &variants {
174 179 let msg = format!("{err}");
@@ -9,6 +9,24 @@ use sha2::{Digest, Sha256};
9 9 use crate::db::Database;
10 10 use crate::error::{io_err, unix_now, CoreError, Result};
11 11
12 + /// Validate that a file extension contains only safe characters.
13 + ///
14 + /// Allows alphanumeric, dots, and hyphens (covers wav, mp3, flac, aiff, ogg,
15 + /// tar.gz, etc.). Rejects path separators, null bytes, and anything else that
16 + /// could be used for directory traversal.
17 + fn validate_extension(ext: &str) -> Result<()> {
18 + if !ext.is_empty()
19 + && !ext
20 + .bytes()
21 + .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-')
22 + {
23 + return Err(CoreError::Internal(format!(
24 + "invalid file extension: {ext:?}"
25 + )));
26 + }
27 + Ok(())
28 + }
29 +
12 30 /// Validate that a hash string is exactly 64 lowercase hex characters (SHA-256).
13 31 fn validate_hash(hash: &str) -> Result<()> {
14 32 if hash.len() != 64 || !hash.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
@@ -22,6 +40,14 @@ fn validate_hash(hash: &str) -> Result<()> {
22 40 Ok(())
23 41 }
24 42
43 + /// Manages on-disk sample blobs in a flat directory structure, storing files as
44 + /// `{sha256_hex}.{ext}` directly in the root directory.
45 + ///
46 + /// Deduplication via SHA-256: import streams the file through a hasher and skips
47 + /// the copy if a blob with the same hash already exists.
48 + ///
49 + /// Thread-safe: all operations are stateless reads/writes against the filesystem
50 + /// (no interior mutability or locks).
25 51 pub struct SampleStore {
26 52 root: PathBuf,
27 53 }
@@ -41,6 +67,13 @@ impl SampleStore {
41 67 let metadata = file.metadata().map_err(|e| io_err(path, e))?;
42 68 let file_size = metadata.len() as i64;
43 69
70 + if file_size == 0 {
71 + return Err(CoreError::Internal(format!(
72 + "cannot import zero-byte file: {}",
73 + path.display()
74 + )));
75 + }
76 +
44 77 // Stream through SHA-256
45 78 let mut hasher = Sha256::new();
46 79 let mut buf = [0u8; 8192];
@@ -84,6 +117,7 @@ impl SampleStore {
84 117 /// to prevent directory traversal or malformed paths.
85 118 pub fn sample_path(&self, hash: &str, ext: &str) -> Result<PathBuf> {
86 119 validate_hash(hash)?;
120 + validate_extension(ext)?;
87 121 if ext.is_empty() {
88 122 Ok(self.root.join(hash))
89 123 } else {
@@ -92,20 +126,25 @@ impl SampleStore {
92 126 }
93 127
94 128 /// Remove a sample from store and database. CASCADE handles VFS/tag refs.
129 + ///
130 + /// Deletes the DB row first, then removes the file from disk. This ordering
131 + /// ensures that if the file deletion fails, the only residue is an orphaned
132 + /// blob on disk (harmless, can be cleaned up later). The reverse order would
133 + /// risk deleting the file while the DB row still references it.
95 134 pub fn remove(&self, hash: &str, db: &Database) -> Result<()> {
96 135 // Look up extension before deleting the row
97 136 let ext = sample_extension(db, hash)?;
98 -
99 - // Delete file
100 137 let path = self.sample_path(hash, &ext)?;
101 - if path.exists() {
102 - fs::remove_file(&path).map_err(|e| io_err(&path, e))?;
103 - }
104 138
105 - // Delete DB row (CASCADE handles tags, vfs_nodes, etc.)
139 + // Delete DB row first (CASCADE handles tags, vfs_nodes, etc.)
106 140 db.conn()
107 141 .execute("DELETE FROM samples WHERE hash = ?1", [hash])?;
108 142
143 + // Then delete the file — an orphaned blob is harmless if this fails
144 + if path.exists() {
145 + fs::remove_file(&path).map_err(|e| io_err(&path, e))?;
146 + }
147 +
109 148 Ok(())
110 149 }
111 150
@@ -113,12 +152,45 @@ impl SampleStore {
113 152 pub fn root(&self) -> &Path {
114 153 &self.root
115 154 }
155 +
156 + /// Re-hash a stored sample and compare against the expected hash.
157 + ///
158 + /// Returns `Ok(true)` if the file's SHA-256 matches `hash`, `Ok(false)` if
159 + /// it differs. Returns an error if the file cannot be read.
160 + pub fn verify_sample(&self, hash: &str, ext: &str) -> Result<bool> {
161 + let path = self.sample_path(hash, ext)?;
162 + let mut file = fs::File::open(&path).map_err(|e| io_err(&path, e))?;
163 +
164 + let mut hasher = Sha256::new();
165 + let mut buf = [0u8; 8192];
166 + loop {
167 + let n = file.read(&mut buf).map_err(|e| io_err(&path, e))?;
168 + if n == 0 {
169 + break;
170 + }
171 + hasher.update(&buf[..n]);
172 + }
173 + let computed = format!("{:x}", hasher.finalize());
174 +
175 + Ok(computed == hash)
176 + }
116 177 }
117 178
118 179 // --- Sample metadata queries ---
119 180
120 181 /// Helper to query a single text column from the samples table by hash.
182 + ///
183 + /// Only fields in the allowlist may be queried; any other value returns an error
184 + /// to prevent SQL injection through the interpolated column name.
121 185 fn query_sample_field(db: &Database, hash: &str, field: &str) -> Result<String> {
186 + const ALLOWED_FIELDS: &[&str] = &["file_extension", "original_name"];
187 +
188 + if !ALLOWED_FIELDS.contains(&field) {
189 + return Err(CoreError::Internal(format!(
190 + "query_sample_field: disallowed field {field:?}"
191 + )));
192 + }
193 +
122 194 let sql = format!("SELECT {field} FROM samples WHERE hash = ?1");
123 195 db.conn()
124 196 .query_row(&sql, [hash], |row| row.get(0))
@@ -257,4 +329,124 @@ mod tests {
257 329 let path = store.sample_path(&hash, "wav").unwrap();
258 330 assert!(path.to_string_lossy().ends_with(".wav"));
259 331 }
332 +
333 + #[test]
334 + fn sample_path_rejects_traversal_in_extension() {
335 + let (_dir, _db, store) = setup();
336 + let hash = "a".repeat(64);
337 + let result = store.sample_path(&hash, "../etc/passwd");
338 + assert!(matches!(result, Err(CoreError::Internal(_))));
339 + }
340 +
341 + #[test]
342 + fn sample_path_rejects_path_separator_in_extension() {
343 + let (_dir, _db, store) = setup();
344 + let hash = "a".repeat(64);
345 + let result = store.sample_path(&hash, "wav/../../etc");
346 + assert!(matches!(result, Err(CoreError::Internal(_))));
347 + }
348 +
349 + #[test]
350 + fn sample_path_accepts_common_extensions() {
351 + let (_dir, _db, store) = setup();
352 + let hash = "a".repeat(64);
353 + for ext in &["wav", "mp3", "flac", "aiff", "ogg", "tar.gz"] {
354 + assert!(store.sample_path(&hash, ext).is_ok(), "rejected valid ext: {ext}");
355 + }
356 + }
357 +
358 + #[test]
359 + fn query_sample_field_rejects_disallowed_field() {
360 + let (_dir, db, _store) = setup();
361 + let hash = "a".repeat(64);
362 + let result = query_sample_field(&db, &hash, "hash; DROP TABLE samples --");
363 + assert!(matches!(result, Err(CoreError::Internal(_))));
364 + }
365 +
366 + #[test]
367 + fn verify_sample_matches_after_import() {
368 + let (dir, db, store) = setup();
369 + let src = create_test_file(&dir, "hihat.wav", b"hihat audio data");
370 +
371 + let hash = store.import(&src, &db).unwrap();
372 + assert!(store.verify_sample(&hash, "wav").unwrap());
373 + }
374 +
375 + #[test]
376 + fn verify_sample_detects_corruption() {
377 + let (dir, db, store) = setup();
378 + let src = create_test_file(&dir, "snare.wav", b"original data");
379 +
380 + let hash = store.import(&src, &db).unwrap();
381 +
382 + // Corrupt the stored file
383 + let stored_path = store.sample_path(&hash, "wav").unwrap();
384 + fs::write(&stored_path, b"corrupted data").unwrap();
385 +
386 + assert!(!store.verify_sample(&hash, "wav").unwrap());
387 + }
388 +
389 + #[test]
390 + fn verify_sample_errors_on_missing_file() {
391 + let (_dir, _db, store) = setup();
392 + let fake_hash = "b".repeat(64);
393 + let result = store.verify_sample(&fake_hash, "wav");
394 + assert!(matches!(result, Err(CoreError::Io { .. })));
395 + }
396 +
397 + #[test]
398 + fn import_rejects_zero_byte_file() {
399 + let (dir, db, store) = setup();
400 + let src = create_test_file(&dir, "empty.wav", b"");
401 +
402 + let result = store.import(&src, &db);
403 + assert!(result.is_err());
404 + let err_msg = format!("{}", result.unwrap_err());
405 + assert!(
406 + err_msg.contains("zero-byte"),
407 + "expected zero-byte error, got: {err_msg}"
408 + );
409 +
410 + // No row should have been inserted
411 + let count: i64 = db
412 + .conn()
413 + .query_row("SELECT COUNT(*) FROM samples", [], |row| row.get(0))
414 + .unwrap();
415 + assert_eq!(count, 0);
416 + }
417 +
418 + #[test]
419 + fn import_accepts_non_empty_file() {
420 + let (dir, db, store) = setup();
421 + let src = create_test_file(&dir, "valid.wav", b"audio content");
422 +
423 + let hash = store.import(&src, &db).unwrap();
424 + assert!(!hash.is_empty());
425 + assert!(store.exists(&hash, "wav").unwrap());
426 + }
427 +
428 + #[test]
429 + fn remove_deletes_db_row_before_file() {
430 + // Verify that after remove(), both the DB row and the file are gone.
431 + // The ordering guarantee (DB first, then file) means an orphaned blob
432 + // is the only possible failure mode — never a dangling DB reference.
433 + let (dir, db, store) = setup();
434 + let src = create_test_file(&dir, "tom.wav", b"tom data");
435 +
436 + let hash = store.import(&src, &db).unwrap();
437 + let stored_path = store.sample_path(&hash, "wav").unwrap();
438 + assert!(stored_path.exists());
439 +
440 + store.remove(&hash, &db).unwrap();
441 +
442 + // DB row gone
443 + let count: i64 = db
444 + .conn()
445 + .query_row("SELECT COUNT(*) FROM samples", [], |row| row.get(0))
446 + .unwrap();
447 + assert_eq!(count, 0);
448 +
449 + // File gone
450 + assert!(!stored_path.exists());
451 + }
260 452 }
@@ -260,7 +260,25 @@ pub fn rename_node(db: &Database, id: NodeId, new_name: &str) -> Result<()> {
260 260 }
261 261
262 262 /// Move a VFS node to a new parent directory (or root if `None`).
263 + ///
264 + /// Returns an error if the move would create a circular parent reference
265 + /// (e.g. moving a node to be a child of one of its own descendants).
263 266 pub fn move_node(db: &Database, id: NodeId, new_parent_id: Option<NodeId>) -> Result<()> {
267 + // Check for circular reference: walk from new_parent_id up to root.
268 + // If we encounter `id` along the way, the move would create a cycle.
269 + if let Some(parent) = new_parent_id {
270 + let mut current = Some(parent);
271 + while let Some(cur_id) = current {
272 + if cur_id == id {
273 + return Err(CoreError::Internal(
274 + "move would create a circular parent reference".to_string(),
275 + ));
276 + }
277 + let node = get_node(db, cur_id)?;
278 + current = node.parent_id;
279 + }
280 + }
281 +
264 282 let changed = db.conn().execute(
265 283 "UPDATE vfs_nodes SET parent_id = ?1 WHERE id = ?2",
266 284 rusqlite::params![new_parent_id, id],
@@ -445,9 +463,11 @@ pub fn find_nodes_by_hashes(
445 463 .collect();
446 464 let sql = format!(
447 465 "SELECT n.id, n.vfs_id, n.parent_id, n.name, n.node_type, n.sample_hash, n.created_at,
448 - a.bpm, a.musical_key, a.duration, a.classification, a.peak_db, a.is_loop
466 + a.bpm, a.musical_key, a.duration, a.classification, a.peak_db, a.is_loop,
467 + s.cloud_only
449 468 FROM vfs_nodes n
450 469 LEFT JOIN audio_analysis a ON n.sample_hash = a.hash
470 + LEFT JOIN samples s ON n.sample_hash = s.hash
451 471 WHERE n.vfs_id = ?1 AND n.sample_hash IN ({})
452 472 GROUP BY n.sample_hash",
453 473 placeholders.join(", ")
@@ -733,4 +753,121 @@ mod tests {
733 753 // Directories have no sample_hash so cloud_only defaults to false
734 754 assert!(!nodes[0].cloud_only);
735 755 }
756 +
757 + #[test]
758 + fn find_nodes_by_hashes_returns_correct_results() {
759 + let db = setup();
760 + let vfs_id = create_vfs(&db, "Lib").unwrap();
761 + insert_fake_sample(&db, "hash_a");
762 + insert_fake_sample(&db, "hash_b");
763 + insert_fake_sample(&db, "hash_c");
764 + create_sample_link(&db, vfs_id, None, "a.wav", "hash_a").unwrap();
765 + create_sample_link(&db, vfs_id, None, "b.wav", "hash_b").unwrap();
766 + create_sample_link(&db, vfs_id, None, "c.wav", "hash_c").unwrap();
767 +
768 + let results =
769 + find_nodes_by_hashes(&db, vfs_id, &["hash_b", "hash_a"]).unwrap();
770 +
771 + // Returns results in input order
772 + assert_eq!(results.len(), 2);
773 + assert_eq!(results[0].node.sample_hash.as_deref(), Some("hash_b"));
774 + assert_eq!(results[1].node.sample_hash.as_deref(), Some("hash_a"));
775 + }
776 +
777 + #[test]
778 + fn find_nodes_by_hashes_empty_input() {
779 + let db = setup();
780 + let vfs_id = create_vfs(&db, "Lib").unwrap();
781 +
782 + let results = find_nodes_by_hashes(&db, vfs_id, &[]).unwrap();
783 + assert!(results.is_empty());
784 + }
785 +
786 + #[test]
787 + fn find_nodes_by_hashes_nonexistent_hash() {
788 + let db = setup();
789 + let vfs_id = create_vfs(&db, "Lib").unwrap();
790 +
791 + let results =
792 + find_nodes_by_hashes(&db, vfs_id, &["nonexistent"]).unwrap();
793 + assert!(results.is_empty());
794 + }
795 +
796 + #[test]
797 + fn find_nodes_by_hashes_includes_cloud_only() {
798 + let db = setup();
799 + let vfs_id = create_vfs(&db, "Lib").unwrap();
800 + insert_fake_sample(&db, "hash_cloud");
801 + create_sample_link(&db, vfs_id, None, "cloud.wav", "hash_cloud").unwrap();
802 +
803 + db.conn()
804 + .execute(
805 + "UPDATE samples SET cloud_only = 1 WHERE hash = 'hash_cloud'",
806 + [],
807 + )
808 + .unwrap();
809 +
810 + let results =
811 + find_nodes_by_hashes(&db, vfs_id, &["hash_cloud"]).unwrap();
812 + assert_eq!(results.len(), 1);
813 + assert!(results[0].cloud_only);
814 + }
815 +
816 + #[test]
817 + fn move_node_rejects_circular_parent_to_child() {
818 + let db = setup();
819 + let vfs_id = create_vfs(&db, "Lib").unwrap();
820 + let a = create_directory(&db, vfs_id, None, "A").unwrap();
821 + let b = create_directory(&db, vfs_id, Some(a), "B").unwrap();
822 + let c = create_directory(&db, vfs_id, Some(b), "C").unwrap();
823 +
824 + // Moving A under C would create A -> B -> C -> A cycle
825 + let result = move_node(&db, a, Some(c));
826 + assert!(result.is_err());
827 + let err_msg = format!("{}", result.unwrap_err());
828 + assert!(err_msg.contains("circular"), "expected circular error, got: {err_msg}");
829 + }
830 +
831 + #[test]
832 + fn move_node_allows_valid_reparent() {
833 + let db = setup();
834 + let vfs_id = create_vfs(&db, "Lib").unwrap();
835 + let a = create_directory(&db, vfs_id, None, "A").unwrap();
836 + let b = create_directory(&db, vfs_id, Some(a), "B").unwrap();
837 + let c = create_directory(&db, vfs_id, Some(b), "C").unwrap();
838 +
839 + // Moving C under A (skipping B) is valid — no cycle
840 + move_node(&db, c, Some(a)).unwrap();
841 + let node = get_node(&db, c).unwrap();
842 + assert_eq!(node.parent_id, Some(a));
843 + }
844 +
845 + #[test]
846 + fn move_node_to_root_succeeds() {
847 + let db = setup();
848 + let vfs_id = create_vfs(&db, "Lib").unwrap();
849 + let a = create_directory(&db, vfs_id, None, "A").unwrap();
850 + let b = create_directory(&db, vfs_id, Some(a), "B").unwrap();
851 +
852 + // Moving A to root is always valid
853 + move_node(&db, a, None).unwrap();
854 + let node = get_node(&db, a).unwrap();
855 + assert_eq!(node.parent_id, None);
856 +
857 + // Moving B to root is also valid
858 + move_node(&db, b, None).unwrap();
859 + let node = get_node(&db, b).unwrap();
860 + assert_eq!(node.parent_id, None);
861 + }
862 +
863 + #[test]
864 + fn move_node_rejects_self_as_parent() {
865 + let db = setup();
866 + let vfs_id = create_vfs(&db, "Lib").unwrap();
867 + let a = create_directory(&db, vfs_id, None, "A").unwrap();
868 +
869 + // Moving A under itself creates a trivial cycle
870 + let result = move_node(&db, a, Some(a));
871 + assert!(result.is_err());
872 + }
736 873 }
@@ -13,3 +13,4 @@ nih_plug = { workspace = true }
13 13 nih_plug_egui = { workspace = true }
14 14 parking_lot = { workspace = true }
15 15 dirs = { workspace = true }
16 + smallvec = { workspace = true }
@@ -4,6 +4,7 @@ use audiofiles_browser::instrument::{EnvelopePhase, InstrumentPlayback};
4 4 use nih_plug::buffer::Buffer;
5 5 use nih_plug::prelude::*;
6 6 use parking_lot::Mutex;
7 + use smallvec::SmallVec;
7 8
8 9 /// Process MIDI events and render instrument voices into the output buffer.
9 10 ///
@@ -17,7 +18,7 @@ pub fn fill_instrument<P: Plugin>(
17 18 sample_rate: f32,
18 19 ) -> bool {
19 20 // Drain MIDI events regardless of lock state to prevent event backup
20 - let mut events = Vec::new();
21 + let mut events: SmallVec<[NoteEvent<P::SysExMessage>; 8]> = SmallVec::new();
21 22 while let Some(event) = context.next_event() {
22 23 events.push(event);
23 24 }
@@ -5,6 +5,8 @@ use base64::Engine;
5 5 use sha2::{Digest, Sha256};
6 6 use synckit_client::SyncKitClient;
7 7
8 + use tracing::instrument;
9 +
8 10 use crate::error::{Result, SyncError};
9 11
10 12 /// Result of the localhost callback: authorization code + state.
@@ -45,6 +47,7 @@ pub fn generate_state() -> String {
45 47 }
46 48
47 49 /// Start the OAuth2 PKCE flow: bind a localhost callback server, build the auth URL.
50 + #[instrument(skip_all)]
48 51 pub fn start_auth(client: &SyncKitClient) -> Result<AuthSession> {
49 52 let code_verifier = generate_code_verifier();
50 53 let code_challenge = generate_code_challenge(&code_verifier);
@@ -92,8 +95,19 @@ pub fn start_auth(client: &SyncKitClient) -> Result<AuthSession> {
92 95
93 96 // Parse GET /callback?code=X&state=Y or GET /?code=X&state=Y
94 97 if let Some(query_start) = request.find('?') {
95 - let query_end = request[query_start..].find(' ').unwrap_or(request.len() - query_start);
96 - let query = &request[query_start + 1..query_start + query_end];
98 + let after_q = query_start + 1;
99 + let query_end = request[query_start..]
100 + .find(' ')
101 + .unwrap_or(request.len().saturating_sub(query_start));
102 + let end = query_start + query_end;
103 +
104 + let query = match request.get(after_q..end) {
105 + Some(q) => q,
106 + None => {
107 + tracing::warn!("Malformed OAuth callback request, could not parse query string");
108 + break;
109 + }
110 + };
97 111
98 112 let mut code = None;
99 113 let mut cb_state = None;
@@ -222,7 +222,7 @@ impl SyncManager {
222 222
223 223 /// Try to restore a previous session from keychain on startup.
224 224 pub fn try_restore_session(&self) {
225 - if self.client.session_info().is_some() {
225 + if self.client.session_info().ok().and_then(|o| o).is_some() {
226 226 let has_key = self.client.try_load_key_from_keychain().unwrap_or(false);
227 227 if has_key {
228 228 let mut s = self.status.lock();
@@ -7,6 +7,8 @@ use parking_lot::Mutex;
7 7 use synckit_client::SyncKitClient;
8 8 use tokio::sync::mpsc;
9 9
10 + use tracing::instrument;
11 +
10 12 use crate::error::SyncError;
11 13 use crate::service;
12 14 use crate::SyncStatus;
@@ -22,6 +24,7 @@ pub enum SyncCommand {
22 24 /// Run the background sync scheduler loop.
23 25 ///
24 26 /// Uses `db_path` to open short-lived connections inside `spawn_blocking`.
27 + #[instrument(skip_all)]
25 28 pub async fn run_scheduler(
26 29 client: Arc<SyncKitClient>,
27 30 db_path: PathBuf,
@@ -107,7 +110,9 @@ pub async fn run_scheduler(
107 110
108 111 /// Check if auto-sync is enabled and the client is ready (authenticated + has key).
109 112 fn should_auto_sync(db_path: &std::path::Path, client: &SyncKitClient) -> bool {
110 - if client.session_info().is_none() || !client.has_master_key() {
113 + if client.session_info().ok().and_then(|o| o).is_none()
114 + || !client.has_master_key().unwrap_or(false)
115 + {
111 116 return false;
112 117 }
113 118 let conn = match rusqlite::Connection::open(db_path) {
@@ -142,6 +147,7 @@ fn interval_elapsed(db_path: &std::path::Path) -> bool {
142 147 }
143 148
144 149 /// Run a single sync cycle: snapshot if needed, sync, blob sync, cleanup.
150 + #[instrument(skip_all)]
145 151 async fn run_sync_cycle(
146 152 db_path: &Path,
147 153 content_dir: &Path,
@@ -9,6 +9,8 @@ use rusqlite::Connection;
9 9 use synckit_client::{ChangeEntry, ChangeOp, SyncKitClient};
10 10 use uuid::Uuid;
11 11
12 + use tracing::instrument;
13 +
12 14 use crate::error::{Result, SyncError};
13 15
14 16 const PUSH_BATCH_LIMIT: usize = 500;
@@ -92,6 +94,7 @@ fn pk_columns(table: &str) -> &'static [&'static str] {
92 94 ///
93 95 /// The `db_path` is used to open separate connections inside `spawn_blocking`,
94 96 /// avoiding the `Connection` is `!Send` issue.
97 + #[instrument(skip_all)]
95 98 pub async fn perform_sync(
96 99 db_path: &std::path::Path,
97 100 client: &SyncKitClient,
@@ -125,6 +128,7 @@ pub async fn perform_sync(
125 128 ///
126 129 /// Finds samples that have local files (cloud_only = 0) in sync-enabled VFS
127 130 /// entries, and uploads them to the server via presigned URLs.
131 + #[instrument(skip_all)]
128 132 pub async fn upload_pending_blobs(
129 133 db_path: &Path,
130 134 content_dir: &Path,
@@ -191,6 +195,7 @@ pub async fn upload_pending_blobs(
191 195 ///
192 196 /// For samples in sync-enabled VFS entries where the local file is missing,
193 197 /// downloads from the server and writes to the content directory.
198 + #[instrument(skip_all)]
194 199 pub async fn download_missing_blobs(
195 200 db_path: &Path,
196 201 content_dir: &Path,
@@ -306,6 +311,7 @@ fn open_conn(db_path: &std::path::Path) -> Result<Connection> {
306 311 }
307 312
308 313 /// Ensure this device is registered with the server. Caches device_id in sync_state.
314 + #[instrument(skip_all)]
309 315 async fn ensure_device_registered(
310 316 db_path: &std::path::Path,
311 317 client: &SyncKitClient,
@@ -347,6 +353,7 @@ async fn ensure_device_registered(
347 353 }
348 354
349 355 /// Push unpushed changelog entries to the server.
356 + #[instrument(skip_all)]
350 357 async fn push_changes(
351 358 db_path: &std::path::Path,
352 359 client: &SyncKitClient,
@@ -435,6 +442,7 @@ async fn push_changes(
435 442 }
436 443
437 444 /// Pull remote changes from the server and apply them locally.
445 + #[instrument(skip_all)]
438 446 async fn pull_changes(
439 447 db_path: &std::path::Path,
440 448 client: &SyncKitClient,