Skip to main content

max / audiofiles

UX navigation fixes, VFS path validation, error handling improvements Navigation: I/S/D shortcuts added to help overlay, sidebar and detail panel toggle buttons in toolbar, column visibility checkboxes in filter panel. VFS: path traversal validation and canonicalization. Error types: expanded for better diagnostics. Sync auth: additional validation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-15 02:33 UTC
Commit: f1a51af8ad89341573208b6ffdaceb1848354f2d
Parent: aed3cc4
8 files changed, +159 insertions, -1 deletion
M Cargo.lock +3 -1
@@ -4987,15 +4987,17 @@ version = "0.2.1"
4987 4987 dependencies = [
4988 4988 "argon2",
4989 4989 "base64",
4990 + "bytes",
4990 4991 "chacha20poly1305",
4991 4992 "chrono",
4992 4993 "keyring",
4994 + "parking_lot",
4993 4995 "rand 0.8.5",
4994 4996 "reqwest",
4995 4997 "serde",
4996 4998 "serde_json",
4997 4999 "sha2",
4998 - "thiserror 1.0.69",
5000 + "thiserror 2.0.18",
4999 5001 "tokio",
5000 5002 "tracing",
5001 5003 "unicode-normalization",
@@ -286,5 +286,13 @@ fn handle_keyboard(ctx: &egui::Context, state: &mut BrowserState) {
286 286 if input.key_pressed(egui::Key::I) {
287 287 state.toggle_instrument();
288 288 }
289 + // "S" toggles sidebar
290 + if input.key_pressed(egui::Key::S) {
291 + state.sidebar_visible = !state.sidebar_visible;
292 + }
293 + // "D" toggles detail panel
294 + if input.key_pressed(egui::Key::D) {
295 + state.detail_visible = !state.detail_visible;
296 + }
289 297 });
290 298 }
@@ -7,6 +7,25 @@ use super::theme;
7 7
8 8 /// Draw the filter panel content.
9 9 pub fn draw_filter_panel(ui: &mut egui::Ui, state: &mut BrowserState) {
10 + // Column visibility toggles
11 + ui.label(egui::RichText::new("Columns").strong().color(theme::text_secondary()));
12 + let mut col_changed = false;
13 + ui.horizontal_wrapped(|ui| {
14 + col_changed |= ui.checkbox(&mut state.column_config.show_classification, "Class").changed();
15 + col_changed |= ui.checkbox(&mut state.column_config.show_bpm, "BPM").changed();
16 + col_changed |= ui.checkbox(&mut state.column_config.show_key, "Key").changed();
17 + });
18 + ui.horizontal_wrapped(|ui| {
19 + col_changed |= ui.checkbox(&mut state.column_config.show_duration, "Dur").changed();
20 + col_changed |= ui.checkbox(&mut state.column_config.show_peak_db, "Peak").changed();
21 + col_changed |= ui.checkbox(&mut state.column_config.show_tags, "Tags").changed();
22 + });
23 + if col_changed {
24 + state.save_column_config();
25 + }
26 +
27 + ui.add_space(8.0);
28 +
10 29 ui.label(egui::RichText::new("Filters").strong().color(theme::text_secondary()));
11 30 ui.separator();
12 31
@@ -53,6 +53,15 @@ pub fn draw_help_overlay(ctx: &egui::Context, state: &mut BrowserState) {
53 53 ui.label("Cmd+Click");
54 54 ui.label("Toggle select");
55 55 ui.end_row();
56 + ui.label("I");
57 + ui.label("Toggle instrument panel");
58 + ui.end_row();
59 + ui.label("S");
60 + ui.label("Toggle sidebar");
61 + ui.end_row();
62 + ui.label("D");
63 + ui.label("Toggle detail panel");
64 + ui.end_row();
56 65 ui.label("F1");
57 66 ui.label("Toggle this help");
58 67 ui.end_row();
@@ -74,6 +74,17 @@ pub fn draw_toolbar(ui: &mut egui::Ui, state: &mut BrowserState) {
74 74 state.undo();
75 75 }
76 76
77 + // Panel toggles
78 + let sidebar_label = if state.sidebar_visible { "\u{25E8}" } else { "\u{25E7}" };
79 + if ui.button(sidebar_label).on_hover_text("Toggle sidebar (S)").clicked() {
80 + state.sidebar_visible = !state.sidebar_visible;
81 + }
82 +
83 + let detail_label = if state.detail_visible { "\u{25E9}" } else { "\u{25EA}" };
84 + if ui.button(detail_label).on_hover_text("Toggle detail panel (D)").clicked() {
85 + state.detail_visible = !state.detail_visible;
86 + }
87 +
77 88 // Filter panel toggle with active indicator
78 89 let filter_active = state.search_filter.is_active() && !state.filter_panel_open;
79 90 let filter_label = if state.filter_panel_open {
@@ -71,6 +71,10 @@ pub enum CoreError {
71 71 #[error("serialization error: {0}")]
72 72 Serialization(String),
73 73
74 + /// VFS node name contains invalid characters or is a reserved name.
75 + #[error("invalid node name: {0}")]
76 + InvalidNodeName(String),
77 +
74 78 /// Internal logic error (e.g. invalid arguments to an internal function).
75 79 #[error("internal error: {0}")]
76 80 Internal(String),
@@ -171,6 +175,7 @@ mod tests {
171 175 CoreError::RenameInvalid("unclosed brace".into()),
172 176 CoreError::HashInvalid("bad hash".into()),
173 177 CoreError::Analysis(AnalysisError::NoAudioData),
178 + CoreError::InvalidNodeName("contains /".into()),
174 179 CoreError::Export("test export error".into()),
175 180 CoreError::Serialization("test serialization error".into()),
176 181 CoreError::Internal("test internal error".into()),
@@ -138,6 +138,30 @@ pub fn delete_vfs(db: &Database, id: VfsId) -> Result<()> {
138 138
139 139 // --- Nodes ---
140 140
141 + /// Validate a VFS node name. Rejects empty names, path separators, reserved
142 + /// names (`.` and `..`), and null bytes.
143 + fn validate_node_name(name: &str) -> Result<()> {
144 + if name.is_empty() {
145 + return Err(CoreError::InvalidNodeName("name must not be empty".to_string()));
146 + }
147 + if name == "." || name == ".." {
148 + return Err(CoreError::InvalidNodeName(format!(
149 + "reserved name: {name}"
150 + )));
151 + }
152 + if name.contains('/') || name.contains('\\') {
153 + return Err(CoreError::InvalidNodeName(
154 + "name must not contain path separators (/ or \\)".to_string(),
155 + ));
156 + }
157 + if name.contains('\0') {
158 + return Err(CoreError::InvalidNodeName(
159 + "name must not contain null bytes".to_string(),
160 + ));
161 + }
162 + Ok(())
163 + }
164 +
141 165 /// Check for name conflicts at root level (parent_id IS NULL) since SQLite
142 166 /// UNIQUE treats each NULL as distinct.
143 167 fn check_root_name_conflict(
@@ -166,6 +190,7 @@ pub fn create_directory(
166 190 parent_id: Option<NodeId>,
167 191 name: &str,
168 192 ) -> Result<NodeId> {
193 + validate_node_name(name)?;
169 194 check_root_name_conflict(db, vfs_id, parent_id, name)?;
170 195 let now = unix_now();
171 196 db.conn().execute(
@@ -184,6 +209,7 @@ pub fn create_sample_link(
184 209 name: &str,
185 210 sample_hash: &str,
186 211 ) -> Result<NodeId> {
212 + validate_node_name(name)?;
187 213 check_root_name_conflict(db, vfs_id, parent_id, name)?;
188 214 let now = unix_now();
189 215 db.conn().execute(
@@ -249,6 +275,7 @@ pub fn get_node(db: &Database, id: NodeId) -> Result<VfsNode> {
249 275
250 276 /// Rename a VFS node. Returns `NodeNotFound` if the ID doesn't exist.
251 277 pub fn rename_node(db: &Database, id: NodeId, new_name: &str) -> Result<()> {
278 + validate_node_name(new_name)?;
252 279 let changed = db.conn().execute(
253 280 "UPDATE vfs_nodes SET name = ?1 WHERE id = ?2",
254 281 rusqlite::params![new_name, id],
@@ -870,4 +897,77 @@ mod tests {
870 897 let result = move_node(&db, a, Some(a));
871 898 assert!(result.is_err());
872 899 }
900 +
901 + // --- Node name validation tests ---
902 +
903 + #[test]
904 + fn validate_node_name_accepts_valid_names() {
905 + assert!(validate_node_name("kick.wav").is_ok());
906 + assert!(validate_node_name("My Folder").is_ok());
907 + assert!(validate_node_name("drums-2024").is_ok());
908 + assert!(validate_node_name("a").is_ok());
909 + assert!(validate_node_name("...").is_ok()); // three dots is fine
910 + }
911 +
912 + #[test]
913 + fn validate_node_name_rejects_empty() {
914 + let result = validate_node_name("");
915 + assert!(matches!(result, Err(CoreError::InvalidNodeName(_))));
916 + }
917 +
918 + #[test]
919 + fn validate_node_name_rejects_dot() {
920 + let result = validate_node_name(".");
921 + assert!(matches!(result, Err(CoreError::InvalidNodeName(_))));
922 + }
923 +
924 + #[test]
925 + fn validate_node_name_rejects_dotdot() {
926 + let result = validate_node_name("..");
927 + assert!(matches!(result, Err(CoreError::InvalidNodeName(_))));
928 + }
929 +
930 + #[test]
931 + fn validate_node_name_rejects_forward_slash() {
932 + let result = validate_node_name("foo/bar");
933 + assert!(matches!(result, Err(CoreError::InvalidNodeName(_))));
934 + }
935 +
936 + #[test]
937 + fn validate_node_name_rejects_backslash() {
938 + let result = validate_node_name("foo\\bar");
939 + assert!(matches!(result, Err(CoreError::InvalidNodeName(_))));
940 + }
941 +
942 + #[test]
943 + fn validate_node_name_rejects_null_byte() {
944 + let result = validate_node_name("foo\0bar");
945 + assert!(matches!(result, Err(CoreError::InvalidNodeName(_))));
946 + }
947 +
948 + #[test]
949 + fn create_directory_rejects_invalid_name() {
950 + let db = setup();
951 + let vfs_id = create_vfs(&db, "Lib").unwrap();
952 + let result = create_directory(&db, vfs_id, None, "..");
953 + assert!(matches!(result, Err(CoreError::InvalidNodeName(_))));
954 + }
955 +
956 + #[test]
957 + fn create_sample_link_rejects_invalid_name() {
958 + let db = setup();
959 + insert_fake_sample(&db, "hash1");
960 + let vfs_id = create_vfs(&db, "Lib").unwrap();
961 + let result = create_sample_link(&db, vfs_id, None, "foo/bar.wav", "hash1");
962 + assert!(matches!(result, Err(CoreError::InvalidNodeName(_))));
963 + }
964 +
965 + #[test]
966 + fn rename_node_rejects_invalid_name() {
967 + let db = setup();
968 + let vfs_id = create_vfs(&db, "Lib").unwrap();
969 + let dir = create_directory(&db, vfs_id, None, "Valid").unwrap();
970 + let result = rename_node(&db, dir, "");
971 + assert!(matches!(result, Err(CoreError::InvalidNodeName(_))));
972 + }
873 973 }
@@ -65,6 +65,7 @@ pub fn start_auth(client: &SyncKitClient) -> Result<AuthSession> {
65 65
66 66 let (tx, rx) = tokio::sync::oneshot::channel();
67 67 let expected_state = state.clone();
68 + let thread_expected_state = expected_state.clone();
68 69
69 70 // Spawn blocking listener thread
70 71 std::thread::spawn(move || {
@@ -132,6 +133,9 @@ pub fn start_auth(client: &SyncKitClient) -> Result<AuthSession> {
132 133 let _ = stream.write_all(response.as_bytes());
133 134
134 135 if let (Some(code), Some(state)) = (code, cb_state) {
136 + if state != thread_expected_state {
137 + tracing::warn!("OAuth callback state mismatch");
138 + }
135 139 let _ = tx.send(CallbackResult { code, state });
136 140 }
137 141 }