Skip to content

Commit 6b295ec

Browse files
committed
Validate paths in copy/move and mkdir dialogs
- Add validateDirectoryPath() to filename-validation.ts for full path validation (empty, absolute, null bytes, path/component length) - Wire it into TransferDialog (structural errors before logical checks) - Replace NewFolderDialog's inline char check with shared validators, gaining name length and path length validation - Add isDir param to validators for context-aware error messages ("Folder name" vs "Filename") - Expose path limits from backend via get_path_limits command so frontend uses platform-correct values (1024 macOS, 4096 Linux)
1 parent 5056bb6 commit 6b295ec

12 files changed

Lines changed: 319 additions & 22 deletions

File tree

apps/desktop/src-tauri/src/commands/file_system.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,25 @@ use tokio::time::Duration;
3232
use super::util::{
3333
IpcError, TimedOut, blocking_result_with_timeout, blocking_with_timeout, blocking_with_timeout_flag,
3434
};
35+
use crate::file_system::validation::{MAX_NAME_BYTES, MAX_PATH_BYTES};
3536

3637
const PATH_EXISTS_TIMEOUT: Duration = Duration::from_secs(2);
3738

39+
#[derive(serde::Serialize)]
40+
#[serde(rename_all = "camelCase")]
41+
pub struct PathLimits {
42+
pub max_name_bytes: usize,
43+
pub max_path_bytes: usize,
44+
}
45+
46+
#[tauri::command]
47+
pub fn get_path_limits() -> PathLimits {
48+
PathLimits {
49+
max_name_bytes: MAX_NAME_BYTES,
50+
max_path_bytes: MAX_PATH_BYTES,
51+
}
52+
}
53+
3854
#[tauri::command]
3955
pub async fn path_exists(volume_id: Option<String>, path: String) -> bool {
4056
let volume_id = volume_id.unwrap_or_else(|| "root".to_string());

apps/desktop/src-tauri/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,7 @@ pub fn run() {
484484
commands::file_system::get_max_filename_width,
485485
commands::file_system::find_file_index,
486486
commands::file_system::resort_listing,
487+
commands::file_system::get_path_limits,
487488
commands::file_system::path_exists,
488489
commands::file_system::create_directory,
489490
commands::file_system::benchmark_log,

apps/desktop/src/lib/file-operations/CLAUDE.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ Provides unified UI for file operations triggered by F5 (copy), F6 (move), F7 (n
1414

1515
1. **TransferDialog** (destination picker + dry-run scan)
1616
- Pre-fills destination from opposite pane
17+
- Validates path structure via `validateDirectoryPath()` from `$lib/utils/filename-validation` (empty, absolute,
18+
null bytes, length limits), then checks logical constraints (subfolder, same location)
1719
- Optional dry-run scan to detect conflicts upfront
1820
- Shows sampled conflicts (max 200) with streaming progress
1921
- User makes conflict decisions before operation starts
@@ -50,9 +52,11 @@ Provides unified UI for file operations triggered by F5 (copy), F6 (move), F7 (n
5052

5153
### New folder (`mkdir/`)
5254

53-
- **mkdir/NewFolderDialog.svelte**: F7 opens dialog pre-filled with cursor item name (sans extension for files). If
54-
`createDirectory` times out (slow volume), shows a warning banner with "Refresh listing" and "Dismiss" actions instead
55-
of a generic error. Warning uses `--color-warning` / `--color-warning-bg` to distinguish from permanent errors.
55+
- **mkdir/NewFolderDialog.svelte**: F7 opens dialog pre-filled with cursor item name (sans extension for files). Uses
56+
shared validators from `$lib/utils/filename-validation` (`validateDisallowedChars`, `validateNameLength`,
57+
`validatePathLength`) for sync checks, then runs async `findFileIndex()` for conflict detection. If `createDirectory`
58+
times out (slow volume), shows a warning banner with "Refresh listing" and "Dismiss" actions instead of a generic
59+
error. Warning uses `--color-warning` / `--color-warning-bg` to distinguish from permanent errors.
5660
- **mkdir/new-folder-operations.ts**: `getInitialFolderName()` extracts from cursor, `moveCursorToNewFolder()`
5761
subscribes to file watcher to track newly created folder
5862
- **mkdir/new-folder-utils.ts**: Pure utility helpers for deriving the initial folder name from the cursor entry

apps/desktop/src/lib/file-operations/mkdir/NewFolderDialog.svelte

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
refreshListing,
1212
type UnlistenFn,
1313
} from '$lib/tauri-commands'
14+
import { validateDisallowedChars, validateNameLength, validatePathLength } from '$lib/utils/filename-validation'
1415
import type { DirectoryDiff } from '$lib/file-explorer/types'
1516
import ModalDialog from '$lib/ui/ModalDialog.svelte'
1617
import Button from '$lib/ui/Button.svelte'
@@ -56,10 +57,26 @@
5657
errorMessage = ''
5758
return
5859
}
59-
if (trimmed.includes('/') || trimmed.includes('\0')) {
60-
errorMessage = 'Folder name contains invalid characters.'
60+
61+
// Sync validators: chars, name length, full path length
62+
const charCheck = validateDisallowedChars(trimmed, true)
63+
if (charCheck.severity === 'error') {
64+
errorMessage = charCheck.message
65+
return
66+
}
67+
const nameLenCheck = validateNameLength(trimmed, true)
68+
if (nameLenCheck.severity === 'error') {
69+
errorMessage = nameLenCheck.message
6170
return
6271
}
72+
const pathLenCheck = validatePathLength(currentPath, trimmed)
73+
if (pathLenCheck.severity === 'error') {
74+
errorMessage = pathLenCheck.message
75+
return
76+
}
77+
78+
// Sync checks passed — clear any previous error, then run async conflict check
79+
errorMessage = ''
6380
6481
isChecking = true
6582
try {

apps/desktop/src/lib/file-operations/transfer/TransferDialog.svelte

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
TransferOperationType,
2424
} from '$lib/file-explorer/types'
2525
import { getSetting } from '$lib/settings'
26+
import { validateDirectoryPath } from '$lib/utils/filename-validation'
2627
import DirectionIndicator from './DirectionIndicator.svelte'
2728
import ModalDialog from '$lib/ui/ModalDialog.svelte'
2829
import Button from '$lib/ui/Button.svelte'
@@ -140,7 +141,11 @@
140141
return null
141142
}
142143
143-
const pathError = $derived(getPathValidationError(sourcePaths, editedPath))
144+
const pathError = $derived.by(() => {
145+
const structural = validateDirectoryPath(editedPath)
146+
if (structural.severity === 'error') return structural.message
147+
return getPathValidationError(sourcePaths, editedPath)
148+
})
144149
145150
// Format space info for display
146151
function formatSpaceInfo(space: VolumeSpaceInfo | null): string {

apps/desktop/src/lib/tauri-commands/file-listing.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,16 @@ export async function clearSelfDragOverlay(): Promise<void> {
211211
await invoke('clear_self_drag_overlay')
212212
}
213213

214+
export interface PathLimits {
215+
maxNameBytes: number
216+
maxPathBytes: number
217+
}
218+
219+
/** Returns platform-specific filesystem path limits from the backend. */
220+
export async function getPathLimits(): Promise<PathLimits> {
221+
return invoke<PathLimits>('get_path_limits')
222+
}
223+
214224
/**
215225
* Checks if a path exists.
216226
* @param path - Path to check.

apps/desktop/src/lib/tauri-commands/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export {
1717
startSelectionDrag,
1818
prepareSelfDragOverlay,
1919
clearSelfDragOverlay,
20+
getPathLimits,
2021
pathExists,
2122
createDirectory,
2223
getSyncStatus,

apps/desktop/src/lib/utils/CLAUDE.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ Small stateless utility functions. Pure, no Svelte state, safe to import from pl
1414

1515
## filename-validation.ts
1616

17-
`validateFilename()` is the main orchestrator. It runs checks in priority order: errors first, then warnings. Returns
18-
the first non-ok result, or `{ severity: 'ok', message: '' }`.
17+
`validateFilename()` is the main orchestrator for single-file renames. It runs checks in priority order: errors first,
18+
then warnings. Returns the first non-ok result, or `{ severity: 'ok', message: '' }`.
1919

2020
```
2121
validateFilename()
@@ -27,6 +27,18 @@ validateFilename()
2727
└── validateConflict() — warning if a sibling already has that name (case-insensitive)
2828
```
2929

30+
`validateDirectoryPath()` validates full directory paths (not filenames). Used by TransferDialog and composable with
31+
individual validators in NewFolderDialog.
32+
33+
```
34+
validateDirectoryPath(path)
35+
├── empty check — error if blank after trim
36+
├── absolute check — error if doesn't start with /
37+
├── null byte check — error if contains \0
38+
├── total path length — error if >= 1024 bytes (UTF-8)
39+
└── per-component length — error if any segment >= 255 bytes (splits on /, filters empty)
40+
```
41+
3042
Key types:
3143

3244
```ts

apps/desktop/src/lib/utils/filename-validation.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
validateNotEmpty,
55
validateNameLength,
66
validatePathLength,
7+
validateDirectoryPath,
78
validateExtensionChange,
89
validateConflict,
910
validateFilename,
@@ -28,6 +29,11 @@ describe('validateDisallowedChars', () => {
2829
it('allows dots, spaces, dashes, underscores', () => {
2930
expect(validateDisallowedChars('my file-name_v2.0.txt').severity).toBe('ok')
3031
})
32+
33+
it('uses folder label when isDir is true', () => {
34+
const result = validateDisallowedChars('foo/bar', true)
35+
expect(result.message).toContain('Folder name')
36+
})
3137
})
3238

3339
describe('validateNotEmpty', () => {
@@ -81,6 +87,77 @@ describe('validatePathLength', () => {
8187
})
8288
})
8389

90+
describe('validateDirectoryPath', () => {
91+
it('rejects empty string', () => {
92+
const result = validateDirectoryPath('')
93+
expect(result.severity).toBe('error')
94+
expect(result.message).toBe("Path can't be empty")
95+
})
96+
97+
it('rejects whitespace-only string', () => {
98+
const result = validateDirectoryPath(' ')
99+
expect(result.severity).toBe('error')
100+
expect(result.message).toBe("Path can't be empty")
101+
})
102+
103+
it('rejects relative path', () => {
104+
const result = validateDirectoryPath('Documents/folder')
105+
expect(result.severity).toBe('error')
106+
expect(result.message).toBe('Path must be absolute (start with /)')
107+
})
108+
109+
it('rejects path with null byte', () => {
110+
const result = validateDirectoryPath('/Users/test\0/folder')
111+
expect(result.severity).toBe('error')
112+
expect(result.message).toBe('Path contains a null character')
113+
})
114+
115+
it('rejects path at 1024 bytes', () => {
116+
const longPath = '/' + 'a'.repeat(1023)
117+
const result = validateDirectoryPath(longPath)
118+
expect(result.severity).toBe('error')
119+
expect(result.message).toMatch(/Path is too long/)
120+
})
121+
122+
it('rejects path with a component at 255 bytes', () => {
123+
const longComponent = 'a'.repeat(255)
124+
const result = validateDirectoryPath(`/Users/${longComponent}/folder`)
125+
expect(result.severity).toBe('error')
126+
expect(result.message).toMatch(/A folder name in the path is too long/)
127+
})
128+
129+
it('allows valid absolute path', () => {
130+
expect(validateDirectoryPath('/Users/test/Documents').severity).toBe('ok')
131+
})
132+
133+
it('allows root path', () => {
134+
expect(validateDirectoryPath('/').severity).toBe('ok')
135+
})
136+
137+
it('handles trailing slashes', () => {
138+
expect(validateDirectoryPath('/Users/test/').severity).toBe('ok')
139+
})
140+
141+
it('handles double slashes', () => {
142+
expect(validateDirectoryPath('/Users//test').severity).toBe('ok')
143+
})
144+
145+
it('counts multi-byte characters correctly', () => {
146+
// Each emoji is 4 bytes
147+
const emojiDir = '\u{1F600}'.repeat(64) // 256 bytes
148+
const result = validateDirectoryPath(`/Users/${emojiDir}`)
149+
expect(result.severity).toBe('error')
150+
expect(result.message).toMatch(/A folder name in the path is too long/)
151+
})
152+
153+
it('allows path just under 1024 bytes', () => {
154+
// Build a long path from many short segments to avoid per-component limit
155+
const segment = 'a'.repeat(100)
156+
const path = ('/' + segment).repeat(10) + '/' + 'b'.repeat(12) // 10 * 101 + 13 = 1023 bytes
157+
expect(validateDirectoryPath(path).severity).toBe('ok')
158+
})
159+
})
160+
84161
describe('getExtension', () => {
85162
it('returns extension with dot', () => {
86163
expect(getExtension('file.txt')).toBe('.txt')

apps/desktop/src/lib/utils/filename-validation.ts

Lines changed: 66 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
11
/** Client-side filename validation for instant keystroke feedback. */
22

3-
const MAX_NAME_BYTES = 255
4-
const MAX_PATH_BYTES = 1024
3+
/** Defaults match macOS. Call initPathLimits() at startup to get platform-specific values from the backend. */
4+
let maxNameBytes = 255
5+
let maxPathBytes = 1024
6+
7+
/** Fetches platform-specific limits from the backend. Call once at app startup. */
8+
export async function initPathLimits(): Promise<void> {
9+
const { getPathLimits } = await import('$lib/tauri-commands')
10+
const limits = await getPathLimits()
11+
maxNameBytes = limits.maxNameBytes
12+
maxPathBytes = limits.maxPathBytes
13+
}
514

615
/** Characters disallowed on macOS (APFS/HFS+). TODO: Per-OS logic for future platforms. */
716
const DISALLOWED_CHARS_REGEX = /[/\0]/
@@ -15,29 +24,33 @@ export interface ValidationResult {
1524

1625
const OK_RESULT: ValidationResult = { severity: 'ok', message: '' }
1726

18-
/** Validates a filename for disallowed characters. Operates on trimmed value. */
19-
export function validateDisallowedChars(name: string): ValidationResult {
27+
function nameLabel(isDir: boolean): string {
28+
return isDir ? 'Folder name' : 'Filename'
29+
}
30+
31+
/** Validates a file or dir name for disallowed characters. Operates on trimmed value. */
32+
export function validateDisallowedChars(name: string, isDir = false): ValidationResult {
2033
if (DISALLOWED_CHARS_REGEX.test(name)) {
21-
return { severity: 'error', message: 'Filenames can\'t contain "/" or null characters' }
34+
return { severity: 'error', message: `${nameLabel(isDir)} can't contain "/" or null characters` }
2235
}
2336
return OK_RESULT
2437
}
2538

26-
/** Validates that a filename is not empty after trimming. */
27-
export function validateNotEmpty(name: string): ValidationResult {
39+
/** Validates that a file or dir name is not empty after trimming. */
40+
export function validateNotEmpty(name: string, isDir = false): ValidationResult {
2841
if (name.trim().length === 0) {
29-
return { severity: 'error', message: "A filename can't be empty" }
42+
return { severity: 'error', message: `${nameLabel(isDir)} can't be empty` }
3043
}
3144
return OK_RESULT
3245
}
3346

34-
/** Validates filename byte length (max 255 bytes). */
35-
export function validateNameLength(name: string): ValidationResult {
47+
/** Validates name byte length (max 255 bytes). */
48+
export function validateNameLength(name: string, isDir = false): ValidationResult {
3649
const byteLength = new TextEncoder().encode(name.trim()).length
37-
if (byteLength >= MAX_NAME_BYTES) {
50+
if (byteLength >= maxNameBytes) {
3851
return {
3952
severity: 'error',
40-
message: `Filename is too long (${String(byteLength)}/${String(MAX_NAME_BYTES)} bytes)`,
53+
message: `${nameLabel(isDir)} is too long (${String(byteLength)}/${String(maxNameBytes)} bytes)`,
4154
}
4255
}
4356
return OK_RESULT
@@ -47,10 +60,10 @@ export function validateNameLength(name: string): ValidationResult {
4760
export function validatePathLength(parentPath: string, name: string): ValidationResult {
4861
const fullPath = parentPath.endsWith('/') ? parentPath + name.trim() : parentPath + '/' + name.trim()
4962
const byteLength = new TextEncoder().encode(fullPath).length
50-
if (byteLength >= MAX_PATH_BYTES) {
63+
if (byteLength >= maxPathBytes) {
5164
return {
5265
severity: 'error',
53-
message: `Full path is too long (${String(byteLength)}/${String(MAX_PATH_BYTES)} bytes)`,
66+
message: `Full path is too long (${String(byteLength)}/${String(maxPathBytes)} bytes)`,
5467
}
5568
}
5669
return OK_RESULT
@@ -102,6 +115,45 @@ export function validateConflict(newName: string, siblingNames: string[], origin
102115
return OK_RESULT
103116
}
104117

118+
/** Validates a full directory path (not a filename). Checks structure only, not existence. */
119+
export function validateDirectoryPath(path: string): ValidationResult {
120+
const trimmed = path.trim()
121+
122+
if (trimmed.length === 0) {
123+
return { severity: 'error', message: "Path can't be empty" }
124+
}
125+
126+
if (!trimmed.startsWith('/')) {
127+
return { severity: 'error', message: 'Path must be absolute (start with /)' }
128+
}
129+
130+
if (trimmed.includes('\0')) {
131+
return { severity: 'error', message: 'Path contains a null character' }
132+
}
133+
134+
const encoder = new TextEncoder()
135+
const totalBytes = encoder.encode(trimmed).length
136+
if (totalBytes >= maxPathBytes) {
137+
return {
138+
severity: 'error',
139+
message: `Path is too long (${String(totalBytes)}/${String(maxPathBytes)} bytes)`,
140+
}
141+
}
142+
143+
const segments = trimmed.split('/').filter((s) => s.length > 0)
144+
for (const segment of segments) {
145+
const segmentBytes = encoder.encode(segment).length
146+
if (segmentBytes >= maxNameBytes) {
147+
return {
148+
severity: 'error',
149+
message: `A folder name in the path is too long (${String(segmentBytes)}/${String(maxNameBytes)} bytes)`,
150+
}
151+
}
152+
}
153+
154+
return OK_RESULT
155+
}
156+
105157
/** Runs all validation checks and returns the first error, then the first warning, or ok. */
106158
export function validateFilename(
107159
newName: string,

0 commit comments

Comments
 (0)