From 4e8f7c453674c02f18479c72c87a74c4b8abfdfe Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 07:08:42 +0000 Subject: [PATCH 1/6] fix(tauri): reject "/" root path in workspace validation trim_end_matches('/') collapses "/" to "", which then isn't matched by the forbidden list, so a root-filesystem workspace slipped through. Keep "/" as the canonical form when the stripped value is empty. --- apps/tauri/src-tauri/src/lib.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/tauri/src-tauri/src/lib.rs b/apps/tauri/src-tauri/src/lib.rs index 8f512e9..968b37e 100644 --- a/apps/tauri/src-tauri/src/lib.rs +++ b/apps/tauri/src-tauri/src/lib.rs @@ -79,7 +79,10 @@ fn validate_workspace_path(path: &str) -> Result<(), String> { #[cfg(unix)] { let forbidden = ["/", "/etc", "/usr", "/bin", "/sbin", "/var", "/proc", "/sys", "/dev"]; + // Strip trailing slashes, but keep "/" itself — trim_end_matches would + // collapse it to "" and slip past the forbidden check. let canonical = normalized.trim_end_matches('/'); + let canonical = if canonical.is_empty() { "/" } else { canonical }; if forbidden.contains(&canonical) { return Err(format!("Cannot use system directory as workspace: {}", path)); } From 937b6c2c7d1207f42864b26c5392667bcee09202 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 07:09:49 +0000 Subject: [PATCH 2/6] refactor(storage): read dedup mtimes once instead of in sort closure sort_by may call the comparator many times, so the previous tiebreaker re-read each duplicate file's metadata on every comparison. With N duplicates that's O(N log N) stat calls, and the ordering could flip mid-sort if a file was touched concurrently. Snapshot mtime per file up front and sort on the cached values. --- crates/onyx-core/src/storage.rs | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/crates/onyx-core/src/storage.rs b/crates/onyx-core/src/storage.rs index fbf0a1b..380e757 100644 --- a/crates/onyx-core/src/storage.rs +++ b/crates/onyx-core/src/storage.rs @@ -457,26 +457,37 @@ impl Storage for FileSystemStorage { } let mut tasks = Vec::new(); - for (_id, mut entries) in by_id { - if entries.len() > 1 { - entries.sort_by(|a, b| { + for (_id, entries) in by_id { + let winner = if entries.len() > 1 { + // Read mtime once per file so sort_by doesn't hit the filesystem + // O(n log n) times and can't produce inconsistent orderings if a + // file is touched mid-sort. + let mut with_mtime: Vec<(PathBuf, Task, Option)> = entries + .into_iter() + .map(|(p, t)| { + let mtime = fs::metadata(&p).and_then(|m| m.modified()).ok(); + (p, t, mtime) + }) + .collect(); + with_mtime.sort_by(|a, b| { // Primary: highest version first let version_cmp = b.1.version.cmp(&a.1.version); if version_cmp != std::cmp::Ordering::Equal { return version_cmp; } // Tiebreaker: most recently modified file first - let mtime_a = fs::metadata(&a.0).and_then(|m| m.modified()).ok(); - let mtime_b = fs::metadata(&b.0).and_then(|m| m.modified()).ok(); - mtime_b.cmp(&mtime_a) + b.2.cmp(&a.2) }); - for (stale_path, _) in entries.drain(1..) { + for (stale_path, _, _) in with_mtime.drain(1..) { if let Err(e) = fs::remove_file(&stale_path) { eprintln!("Warning: failed to remove stale duplicate task file {:?}: {}", stale_path, e); } } - } - let (_, task) = entries.into_iter().next() + with_mtime.into_iter().next().map(|(_, t, _)| t) + } else { + entries.into_iter().next().map(|(_, t)| t) + }; + let task = winner .ok_or_else(|| Error::InvalidData("Empty dedup entries for task".to_string()))?; tasks.push(task); } From e911ac1d94d8df7b0cbd1d5e2dbc7198f15da58b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 07:11:53 +0000 Subject: [PATCH 3/6] refactor(tauri): extract credential_domain helper Three call sites reproduced the same scheme://host parsing inline. Pull it into a named helper so the domain-extraction convention lives in one place. --- apps/tauri/src-tauri/src/lib.rs | 29 +++++++++++++---------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/apps/tauri/src-tauri/src/lib.rs b/apps/tauri/src-tauri/src/lib.rs index 968b37e..4e882ef 100644 --- a/apps/tauri/src-tauri/src/lib.rs +++ b/apps/tauri/src-tauri/src/lib.rs @@ -67,6 +67,16 @@ impl AppState { } } +/// Extract the hostname from a URL (scheme://host/...), used as the credential key. +/// Returns an empty string if the URL has no scheme or host. +fn credential_domain(url: &str) -> String { + url.split("://") + .nth(1) + .and_then(|rest| rest.split('/').next()) + .unwrap_or("") + .to_string() +} + /// Validate that a workspace path is a reasonable directory and not a system path. fn validate_workspace_path(path: &str) -> Result<(), String> { let p = PathBuf::from(path); @@ -266,10 +276,7 @@ async fn rename_workspace( let base_url = webdav_url.as_deref().ok_or("No WebDAV URL configured")?; let remote_path = webdav_path.as_deref().unwrap_or(""); - let domain = base_url - .split("://").nth(1) - .and_then(|rest| rest.split('/').next()) - .unwrap_or("").to_string(); + let domain = credential_domain(base_url); let creds = app_handle.state::>(); let (username, password) = creds.load(&domain)?; @@ -761,12 +768,7 @@ fn add_webdav_workspace( s.repo = None; // Store credentials keyed by hostname - let domain = webdav_url - .split("://") - .nth(1) - .and_then(|rest| rest.split('/').next()) - .unwrap_or("") - .to_string(); + let domain = credential_domain(&webdav_url); s.save_config()?; drop(s); let creds = app_handle.state::>(); @@ -829,12 +831,7 @@ async fn sync_workspace( }; // Step 2: load credentials - let domain = webdav_url - .split("://") - .nth(1) - .and_then(|rest| rest.split('/').next()) - .unwrap_or("") - .to_string(); + let domain = credential_domain(&webdav_url); let creds = app_handle.state::>(); let (username, password) = creds.load(&domain)?; From 62cf05480d6f4473decd9f0943652dc7062dae52 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 07:12:37 +0000 Subject: [PATCH 4/6] refactor(tauri): extract join_remote_path helper Three call sites repeated the same "empty base -> child, otherwise trim_end + slash + child" pattern. Pull it into a helper to keep the join convention consistent across list_remote_folder, inspect, and create_remote_workspace. --- apps/tauri/src-tauri/src/lib.rs | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/tauri/src-tauri/src/lib.rs b/apps/tauri/src-tauri/src/lib.rs index 4e882ef..154f6d0 100644 --- a/apps/tauri/src-tauri/src/lib.rs +++ b/apps/tauri/src-tauri/src/lib.rs @@ -77,6 +77,15 @@ fn credential_domain(url: &str) -> String { .to_string() } +/// Join a remote base directory with a child path, handling empty base and trailing slashes. +fn join_remote_path(base: &str, child: &str) -> String { + if base.is_empty() { + child.to_string() + } else { + format!("{}/{}", base.trim_end_matches('/'), child) + } +} + /// Validate that a workspace path is a reasonable directory and not a system path. fn validate_workspace_path(path: &str) -> Result<(), String> { let p = PathBuf::from(path); @@ -655,10 +664,9 @@ async fn list_remote_folder( let dir_entries: Vec<_> = entries.into_iter().filter(|e| e.is_dir).collect(); // Check all subfolders for .onyx-workspace.json in parallel - let sub_paths: Vec<_> = dir_entries.iter().map(|entry| { - if path.is_empty() { entry.path.clone() } - else { format!("{}/{}", path.trim_end_matches('/'), entry.path) } - }).collect(); + let sub_paths: Vec<_> = dir_entries.iter() + .map(|entry| join_remote_path(&path, &entry.path)) + .collect(); let checks: Vec<_> = sub_paths.iter().map(|sp| { client.list_files(sp) }).collect(); @@ -690,11 +698,7 @@ async fn inspect_remote_workspace( let mut lists = Vec::new(); for entry in entries { if !entry.is_dir { continue; } - let list_path = if path.is_empty() { - entry.path.clone() - } else { - format!("{}/{}", path.trim_end_matches('/'), entry.path) - }; + let list_path = join_remote_path(&path, &entry.path); let files = client.list_files(&list_path).await.unwrap_or_else(|e| { eprintln!("Warning: failed to list remote folder '{}': {}", list_path, e); Vec::new() @@ -730,11 +734,7 @@ async fn create_remote_workspace( "list_order": [], "last_opened_list": null, }); - let file_path = if path.is_empty() { - ".onyx-workspace.json".to_string() - } else { - format!("{}/{}", path.trim_end_matches('/'), ".onyx-workspace.json") - }; + let file_path = join_remote_path(&path, ".onyx-workspace.json"); client.put_file(&file_path, serde_json::to_string_pretty(&metadata).map_err(|e| e.to_string())?.into_bytes()) .await .map_err(|e| e.to_string())?; From c9521564918e6488eeca4ff054b141dc7ba2aa65 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 07:13:29 +0000 Subject: [PATCH 5/6] refactor(date-picker): group selected-state declarations up top selectedYear/selectedMonth were declared below selectDay, which writes to them, and below isToday, which is declared nearby. Runtime worked because the assignments only run on user click (after script init), but the split made the initialization order confusing. Group all $state fields at the top of the script. --- apps/tauri/src/lib/components/DateTimePicker.svelte | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/tauri/src/lib/components/DateTimePicker.svelte b/apps/tauri/src/lib/components/DateTimePicker.svelte index a86d791..39da8ad 100644 --- a/apps/tauri/src/lib/components/DateTimePicker.svelte +++ b/apps/tauri/src/lib/components/DateTimePicker.svelte @@ -13,6 +13,8 @@ let viewYear = $state(existing ? existing.getFullYear() : now.getFullYear()); let viewMonth = $state(existing ? existing.getMonth() : now.getMonth()); let selectedDay = $state(existing ? existing.getDate() : now.getDate()); + let selectedYear = $state(existing ? existing.getFullYear() : now.getFullYear()); + let selectedMonth = $state(existing ? existing.getMonth() : now.getMonth()); let includeTime = $state(has_time); let selectedHour = $state(existing ? existing.getHours() : now.getHours()); let selectedMinute = $state(existing ? existing.getMinutes() : 0); @@ -58,9 +60,6 @@ return `${viewYear}-${viewMonth + 1}-${day}` === todayStr; } - let selectedYear = $state(existing ? existing.getFullYear() : now.getFullYear()); - let selectedMonth = $state(existing ? existing.getMonth() : now.getMonth()); - function isSelected(day: number): boolean { return selectedDay === day && selectedYear === viewYear && selectedMonth === viewMonth; } From 9a8a1a9f8ea160e6fe89443d7c6fc77b0939a938 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 07:13:47 +0000 Subject: [PATCH 6/6] style(sync): replace stray var with const in restartSyncInterval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lone var in an otherwise let/const file — promote to const since the value never gets reassigned. No behavior change. --- apps/tauri/src/lib/stores/app.svelte.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/tauri/src/lib/stores/app.svelte.ts b/apps/tauri/src/lib/stores/app.svelte.ts index c327c0d..451cbb7 100644 --- a/apps/tauri/src/lib/stores/app.svelte.ts +++ b/apps/tauri/src/lib/stores/app.svelte.ts @@ -418,7 +418,7 @@ function debouncedSync() { function restartSyncInterval() { if (_syncInterval) clearInterval(_syncInterval); - var secs = _appFocused ? syncIntervalSecs : syncIntervalUnfocusedSecs; + const secs = _appFocused ? syncIntervalSecs : syncIntervalUnfocusedSecs; _syncInterval = setInterval(triggerSync, secs * 1000); }