Skip to content

Commit 25ce82f

Browse files
committed
Bugfix: Stop Volume::create_file from clobbering existing files
The IPC New File path expects `Volume::create_file` to fail with `AlreadyExists` when the destination already exists, so the dialog can surface a friendly "already exists" error. Pre-fix, every backend silently truncated: - `LocalPosixVolume` used `std::fs::write`. Now uses `OpenOptions::create_new(true)`; the resulting `EEXIST` round-trips to `VolumeError::AlreadyExists`. - `SmbVolume` delegated to `tree.write_file` (`FileOverwriteIf` disposition). smb2 0.11's public API has no exclusive-create writer, so this lands a `tree.stat` pre-check followed by the existing write. The pre-check leaves a microsecond-scale TOCTOU window; the truly atomic fix needs an upstream smb2 release exposing `FileCreate` for files. - `InMemoryVolume` now rejects collisions so the test backend can't mask the bug in unit tests. Regression tests pinned for local (unit), in-memory (unit), and SMB (Docker integration, `#[ignore]`).
1 parent 7698e49 commit 25ce82f

5 files changed

Lines changed: 128 additions & 2 deletions

File tree

apps/desktop/src-tauri/src/file_system/volume/backends/in_memory.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,10 @@ impl Volume for InMemoryVolume {
287287

288288
let normalized = self.normalize(path);
289289

290+
if entries.contains_key(&normalized) {
291+
return Err(VolumeError::AlreadyExists(normalized.display().to_string()));
292+
}
293+
290294
let name = normalized
291295
.file_name()
292296
.map(|s| s.to_string_lossy().to_string())

apps/desktop/src-tauri/src/file_system/volume/backends/in_memory_test.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,29 @@ async fn test_create_file_then_exists() {
135135
assert!(!metadata.is_directory);
136136
}
137137

138+
#[tokio::test]
139+
async fn test_create_file_does_not_clobber_existing() {
140+
// Regression for the high-severity audit finding: `create_file` is a
141+
// no-overwrite contract. The InMemoryVolume must reject collisions so
142+
// tests that stand in for real backends don't mask the bug.
143+
let volume = InMemoryVolume::new("Test");
144+
145+
volume
146+
.create_file(Path::new("/notes.txt"), b"important user data")
147+
.await
148+
.unwrap();
149+
150+
let result = volume.create_file(Path::new("/notes.txt"), b"").await;
151+
152+
assert!(
153+
matches!(result, Err(VolumeError::AlreadyExists(_))),
154+
"expected AlreadyExists, got {:?}",
155+
result
156+
);
157+
let metadata = volume.get_metadata(Path::new("/notes.txt")).await.unwrap();
158+
assert_eq!(metadata.size, Some(19), "original file bytes must survive");
159+
}
160+
138161
#[tokio::test]
139162
async fn test_create_directory_then_exists() {
140163
let volume = InMemoryVolume::new("Test");

apps/desktop/src-tauri/src/file_system/volume/backends/local_posix.rs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -287,8 +287,18 @@ impl Volume for LocalPosixVolume {
287287
}
288288
let content = content.to_vec();
289289
Box::pin(async move {
290-
spawn_blocking(move || {
291-
std::fs::write(&abs_path, content)?;
290+
spawn_blocking(move || -> Result<(), VolumeError> {
291+
use std::io::Write;
292+
// `create_new(true)` is the no-clobber contract the IPC layer
293+
// and frontend assume: an `AlreadyExists` errno surfaces as
294+
// `VolumeError::AlreadyExists`, which the New File command
295+
// maps to a friendly "already exists" error. A plain
296+
// `std::fs::write` would silently truncate the user's file.
297+
let mut file = std::fs::OpenOptions::new()
298+
.write(true)
299+
.create_new(true)
300+
.open(&abs_path)?;
301+
file.write_all(&content)?;
292302
Ok(())
293303
})
294304
.await

apps/desktop/src-tauri/src/file_system/volume/backends/local_posix_test.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,39 @@ async fn test_rename_force_overwrites() {
203203
let _ = fs::remove_dir_all(&test_dir);
204204
}
205205

206+
#[tokio::test]
207+
async fn test_create_file_does_not_clobber_existing() {
208+
// Regression for the high-severity audit finding: `create_file` is a
209+
// no-overwrite contract; the IPC layer maps `AlreadyExists` to a friendly
210+
// "file already exists" error. Switching to a truncating write here loses
211+
// the user's data when the listing-cache pre-check race fails.
212+
use std::fs;
213+
214+
let test_dir = std::env::temp_dir().join("cmdr_create_file_no_clobber_test");
215+
let _ = fs::remove_dir_all(&test_dir);
216+
fs::create_dir_all(&test_dir).unwrap();
217+
218+
let volume = LocalPosixVolume::new("Test", &test_dir);
219+
let target = test_dir.join("notes.txt");
220+
221+
fs::write(&target, "important user data").unwrap();
222+
223+
let result = volume.create_file(Path::new("notes.txt"), b"").await;
224+
225+
assert!(
226+
matches!(result, Err(VolumeError::AlreadyExists(_))),
227+
"expected AlreadyExists, got {:?}",
228+
result
229+
);
230+
assert_eq!(
231+
fs::read_to_string(&target).unwrap(),
232+
"important user data",
233+
"original file contents must survive a colliding create_file"
234+
);
235+
236+
let _ = fs::remove_dir_all(&test_dir);
237+
}
238+
206239
// ============================================================================
207240
// Symlink edge case tests
208241
// ============================================================================

apps/desktop/src-tauri/src/file_system/volume/backends/smb.rs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1314,6 +1314,27 @@ impl Volume for SmbVolume {
13141314

13151315
{
13161316
let (tree, mut conn) = self.clone_session().await?;
1317+
// No-clobber contract. The smb2 0.11 public API only exposes
1318+
// file writers backed by `FileOverwriteIf` disposition (no
1319+
// exclusive `FileCreate` for files), so we do a pre-flight
1320+
// stat. There's a brief TOCTOU window between stat and write
1321+
// where a third party can create the same path; we accept it
1322+
// because the alternative (silently truncating the user's
1323+
// file when the listing-cache pre-check races) is worse, and
1324+
// because the realistic attack window is microseconds on the
1325+
// same SMB session. The truly atomic fix needs an smb2 crate
1326+
// release that exposes an exclusive-create writer.
1327+
match tree.stat(&mut conn, &smb_path).await {
1328+
Ok(_) => {
1329+
return Err(VolumeError::AlreadyExists(path.display().to_string()));
1330+
}
1331+
Err(e) if e.kind() == smb2::ErrorKind::NotFound => {
1332+
// Expected: file doesn't exist, fall through to write.
1333+
}
1334+
Err(e) => {
1335+
return Err(map_smb_error(e));
1336+
}
1337+
}
13171338
let result = tree.write_file(&mut conn, &smb_path, &data).await;
13181339
self.handle_smb_result("create_file", result)?;
13191340
}
@@ -2832,6 +2853,41 @@ mod tests {
28322853
vol.delete(Path::new(&dir)).await.unwrap();
28332854
}
28342855

2856+
/// Regression for the high-severity audit finding: `create_file` is a
2857+
/// no-overwrite contract. Pre-fix, SMB delegated to `tree.write_file`
2858+
/// which uses `FileOverwriteIf` disposition and silently truncated.
2859+
#[tokio::test]
2860+
#[ignore = "Requires Docker SMB containers (./apps/desktop/test/smb-servers/start.sh)"]
2861+
async fn smb_integration_create_file_does_not_clobber_existing() {
2862+
let vol = make_docker_volume().await;
2863+
let dir = test_dir_name();
2864+
ensure_clean(&vol, &dir).await;
2865+
2866+
vol.create_directory(Path::new(&dir)).await.unwrap();
2867+
let file_path = format!("{}/notes.txt", dir);
2868+
let original = b"important user data";
2869+
vol.create_file(Path::new(&file_path), original).await.unwrap();
2870+
2871+
// Second create on the same path must fail with AlreadyExists;
2872+
// bytes on the wire must be unchanged.
2873+
let result = vol.create_file(Path::new(&file_path), b"junk").await;
2874+
assert!(
2875+
matches!(result, Err(VolumeError::AlreadyExists(_))),
2876+
"expected AlreadyExists, got {:?}",
2877+
result
2878+
);
2879+
2880+
let mut readback = vol.open_read_stream(Path::new(&file_path)).await.unwrap();
2881+
let mut bytes = Vec::new();
2882+
while let Some(Ok(chunk)) = readback.next_chunk().await {
2883+
bytes.extend_from_slice(&chunk);
2884+
}
2885+
assert_eq!(bytes, original, "original bytes must survive a colliding create_file");
2886+
2887+
vol.delete(Path::new(&file_path)).await.unwrap();
2888+
vol.delete(Path::new(&dir)).await.unwrap();
2889+
}
2890+
28352891
/// Regression test for a unit-mismatch bug where SMB returned `modified_at` in
28362892
/// milliseconds while the rest of cmdr (and the frontend formatter) expects seconds.
28372893
/// That caused displayed years like 58247 on real shares. Asserts the mtime of a

0 commit comments

Comments
 (0)