fix: reject duplicate completed upload names

A user could select another local file with the same name as one that already
exists in completed storage. The upload would be allowed to start and only hit
an existing-file conflict late in the flow, which made the UI look like the
file was uploadable.

Reject duplicate sanitized names during upload creation so no staging record or
chunk transfer starts for a file that cannot be completed. Keep the completion
path non-replacing as a second guard by promoting through a no-overwrite file
creation path, with a hard-link fast path and copy fallback for custom temp
locations.

The browser now treats the server's duplicate-name conflict as a terminal row:
it disables the action, marks the item visually, and tells the user to rename
the file if they want to upload that copy.

Test Plan:
- just check

Refs: none
This commit is contained in:
2026-05-30 18:42:55 +02:00
parent 1923ff2a6f
commit 60663a461c
7 changed files with 184 additions and 21 deletions
+40
View File
@@ -171,6 +171,46 @@ async fn rejects_incomplete_upload() -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}
#[tokio::test]
async fn rejects_completion_that_would_replace_file() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = TempDir::new()?;
let app = test_app(temp_dir.path());
let upload = create_upload(&app, "clash.bin", 8).await?;
tokio::fs::write(
temp_dir.path().join("complete").join("clash.bin"),
b"original",
)
.await?;
let response = app
.clone()
.oneshot(chunk_request(&upload.upload_id, 0, b"incoming".to_vec())?)
.await?;
assert_eq!(response.status(), StatusCode::NO_CONTENT);
let response = app
.oneshot(empty_request(
Method::POST,
&format!("/api/uploads/{}/complete", upload.upload_id),
)?)
.await?;
assert_eq!(response.status(), StatusCode::CONFLICT);
assert_eq!(
tokio::fs::read(temp_dir.path().join("complete").join("clash.bin")).await?,
b"original"
);
assert!(
temp_dir
.path()
.join("staging")
.join(&upload.upload_id)
.exists()
);
Ok(())
}
#[tokio::test]
async fn rejects_tampered_temp_upload_file() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = TempDir::new()?;
+35
View File
@@ -77,6 +77,41 @@ async fn rejects_empty_upload_name() -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}
#[tokio::test]
async fn rejects_upload_name_that_already_exists() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = TempDir::new()?;
let app = test_app(temp_dir.path());
let complete_dir = temp_dir.path().join("complete");
tokio::fs::create_dir_all(&complete_dir).await?;
tokio::fs::write(complete_dir.join("xyz.foo"), b"original").await?;
let response = app
.oneshot(json_request(
"/api/uploads",
&json!({
"name": "xyz.foo",
"size": 10,
"last_modified": 1_760_000_000_000_i64
}),
)?)
.await?;
assert_eq!(response.status(), StatusCode::CONFLICT);
let body = response.into_body().collect().await?.to_bytes();
let body: serde_json::Value = serde_json::from_slice(&body)?;
assert_eq!(body["error"], "file already exists");
assert_eq!(
tokio::fs::read(complete_dir.join("xyz.foo")).await?,
b"original"
);
let mut staging_entries = tokio::fs::read_dir(temp_dir.path().join("staging")).await?;
assert!(staging_entries.next_entry().await?.is_none());
Ok(())
}
fn test_app(data_dir: &Path) -> axum::Router {
build_router(&AppConfig::new(
SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),