mirror of
https://github.com/nimbold/Firelink.git
synced 2026-09-01 13:38:01 +00:00
fix(downloads): allow staged replacements to remove their own target
- Treat the persisted staged row as its own legacy ownership claim - Keep cross-download and ambiguous ownership conflicts fail-closed - Cover persisted self-claim cleanup and changed-target protection
This commit is contained in:
+81
-14
@@ -6346,6 +6346,7 @@ async fn remove_download(
|
|||||||
remove_unowned_replacement_target(
|
remove_unowned_replacement_target(
|
||||||
&replacement.target,
|
&replacement.target,
|
||||||
&replacement.fingerprint,
|
&replacement.fingerprint,
|
||||||
|
&replacement.download_id,
|
||||||
permanent_asset_removal,
|
permanent_asset_removal,
|
||||||
&app_handle,
|
&app_handle,
|
||||||
)
|
)
|
||||||
@@ -6407,6 +6408,7 @@ fn should_skip_unowned_asset_cleanup(
|
|||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
struct UnownedReplacementTarget {
|
struct UnownedReplacementTarget {
|
||||||
|
download_id: String,
|
||||||
target: std::path::PathBuf,
|
target: std::path::PathBuf,
|
||||||
fingerprint: String,
|
fingerprint: String,
|
||||||
}
|
}
|
||||||
@@ -6416,6 +6418,7 @@ fn validate_unowned_replacement_target<R: tauri::Runtime>(
|
|||||||
target: &std::path::Path,
|
target: &std::path::Path,
|
||||||
expected_fingerprint: &str,
|
expected_fingerprint: &str,
|
||||||
metadata: &std::fs::Metadata,
|
metadata: &std::fs::Metadata,
|
||||||
|
allowed_legacy_owner_id: &str,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
if metadata_is_link_or_reparse(metadata) || path_has_symlink_component(target) {
|
if metadata_is_link_or_reparse(metadata) || path_has_symlink_component(target) {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
@@ -6436,9 +6439,16 @@ fn validate_unowned_replacement_target<R: tauri::Runtime>(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
if let Some(owner) = crate::download_ownership::owner_for_path(app_handle, target)? {
|
if let Some(owner) = crate::download_ownership::owner_for_path(app_handle, target)? {
|
||||||
return Err(format!(
|
// owner_for_path also considers the persisted download row for
|
||||||
"Cannot remove replacement target because it is owned by Firelink download {owner}"
|
// compatibility with databases created before the native ownership
|
||||||
));
|
// registry. A staged replacement is expected to be its own legacy
|
||||||
|
// owner until native admission establishes the real ownership record;
|
||||||
|
// every other owner remains a hard conflict.
|
||||||
|
if owner != allowed_legacy_owner_id {
|
||||||
|
return Err(format!(
|
||||||
|
"Cannot remove replacement target because it is owned by Firelink download {owner}"
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if target_fingerprint(target, metadata) != expected_fingerprint {
|
if target_fingerprint(target, metadata) != expected_fingerprint {
|
||||||
return Err(
|
return Err(
|
||||||
@@ -6492,17 +6502,26 @@ fn inspect_unowned_replacement_target<R: tauri::Runtime>(
|
|||||||
// an ownership record for that exact path still blocks the
|
// an ownership record for that exact path still blocks the
|
||||||
// unowned replacement exception.
|
// unowned replacement exception.
|
||||||
if let Some(owner) = crate::download_ownership::owner_for_path(app_handle, &target)? {
|
if let Some(owner) = crate::download_ownership::owner_for_path(app_handle, &target)? {
|
||||||
return Err(format!(
|
if owner != persisted.id {
|
||||||
"Cannot remove replacement target because it is owned by Firelink download {owner}"
|
return Err(format!(
|
||||||
));
|
"Cannot remove replacement target because it is owned by Firelink download {owner}"
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
Err(error) => return Err(format!("Could not inspect replacement target: {error}")),
|
Err(error) => return Err(format!("Could not inspect replacement target: {error}")),
|
||||||
};
|
};
|
||||||
|
|
||||||
validate_unowned_replacement_target(app_handle, &target, expected_fingerprint, &metadata)?;
|
validate_unowned_replacement_target(
|
||||||
|
app_handle,
|
||||||
|
&target,
|
||||||
|
expected_fingerprint,
|
||||||
|
&metadata,
|
||||||
|
&persisted.id,
|
||||||
|
)?;
|
||||||
Ok(Some(UnownedReplacementTarget {
|
Ok(Some(UnownedReplacementTarget {
|
||||||
|
download_id: persisted.id.clone(),
|
||||||
target,
|
target,
|
||||||
fingerprint: expected_fingerprint.to_string(),
|
fingerprint: expected_fingerprint.to_string(),
|
||||||
}))
|
}))
|
||||||
@@ -6511,6 +6530,7 @@ fn inspect_unowned_replacement_target<R: tauri::Runtime>(
|
|||||||
async fn remove_unowned_replacement_target<R: tauri::Runtime>(
|
async fn remove_unowned_replacement_target<R: tauri::Runtime>(
|
||||||
target: &std::path::Path,
|
target: &std::path::Path,
|
||||||
expected_fingerprint: &str,
|
expected_fingerprint: &str,
|
||||||
|
allowed_legacy_owner_id: &str,
|
||||||
permanent: bool,
|
permanent: bool,
|
||||||
app_handle: &tauri::AppHandle<R>,
|
app_handle: &tauri::AppHandle<R>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
@@ -6525,7 +6545,13 @@ async fn remove_unowned_replacement_target<R: tauri::Runtime>(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
validate_unowned_replacement_target(app_handle, target, expected_fingerprint, &metadata)?;
|
validate_unowned_replacement_target(
|
||||||
|
app_handle,
|
||||||
|
target,
|
||||||
|
expected_fingerprint,
|
||||||
|
&metadata,
|
||||||
|
allowed_legacy_owner_id,
|
||||||
|
)?;
|
||||||
|
|
||||||
if permanent {
|
if permanent {
|
||||||
match tokio::fs::remove_file(target).await {
|
match tokio::fs::remove_file(target).await {
|
||||||
@@ -6562,6 +6588,7 @@ async fn remove_unowned_replacement_target<R: tauri::Runtime>(
|
|||||||
target,
|
target,
|
||||||
expected_fingerprint,
|
expected_fingerprint,
|
||||||
¤t_metadata,
|
¤t_metadata,
|
||||||
|
allowed_legacy_owner_id,
|
||||||
)?;
|
)?;
|
||||||
match std::fs::remove_file(target) {
|
match std::fs::remove_file(target) {
|
||||||
Ok(()) => Ok(()),
|
Ok(()) => Ok(()),
|
||||||
@@ -13804,8 +13831,8 @@ mod tests {
|
|||||||
.unwrap());
|
.unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn unowned_replacement_authorization_requires_the_original_target() {
|
async fn unowned_replacement_authorization_requires_the_original_target() {
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
|
|
||||||
let app = tauri::test::mock_builder()
|
let app = tauri::test::mock_builder()
|
||||||
@@ -13857,6 +13884,34 @@ mod tests {
|
|||||||
assert!(error.contains("owned by Firelink download other-owner"));
|
assert!(error.contains("owned by Firelink download other-owner"));
|
||||||
crate::download_ownership::remove(app.handle(), "other-owner").unwrap();
|
crate::download_ownership::remove(app.handle(), "other-owner").unwrap();
|
||||||
|
|
||||||
|
// Persisted pre-admission rows are included by the legacy ownership
|
||||||
|
// fallback. That self-claim must not block cleanup of the row's own
|
||||||
|
// exact replacement target.
|
||||||
|
let database = app.handle().state::<crate::db::DbState>();
|
||||||
|
let mut connection = database.lock().unwrap();
|
||||||
|
crate::db::replace_downloads(
|
||||||
|
&mut connection,
|
||||||
|
&serde_json::to_string(&[item.clone()]).unwrap(),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
drop(connection);
|
||||||
|
assert!(
|
||||||
|
inspect_unowned_replacement_target(app.handle(), &item, false)
|
||||||
|
.unwrap()
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
|
remove_unowned_replacement_target(
|
||||||
|
&target,
|
||||||
|
&fingerprint,
|
||||||
|
&item.id,
|
||||||
|
true,
|
||||||
|
app.handle(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("a staged row may remove its own legacy replacement target");
|
||||||
|
assert!(!target.exists());
|
||||||
|
|
||||||
std::fs::write(&target, b"changed target").unwrap();
|
std::fs::write(&target, b"changed target").unwrap();
|
||||||
let error = inspect_unowned_replacement_target(app.handle(), &item, false)
|
let error = inspect_unowned_replacement_target(app.handle(), &item, false)
|
||||||
.expect_err("a changed target must not inherit replacement authorization");
|
.expect_err("a changed target must not inherit replacement authorization");
|
||||||
@@ -13885,15 +13940,27 @@ mod tests {
|
|||||||
std::fs::write(&target, b"original").unwrap();
|
std::fs::write(&target, b"original").unwrap();
|
||||||
let fingerprint = target_fingerprint(&target, &std::fs::symlink_metadata(&target).unwrap());
|
let fingerprint = target_fingerprint(&target, &std::fs::symlink_metadata(&target).unwrap());
|
||||||
|
|
||||||
remove_unowned_replacement_target(&target, &fingerprint, true, app.handle())
|
remove_unowned_replacement_target(
|
||||||
|
&target,
|
||||||
|
&fingerprint,
|
||||||
|
"staged-replacement",
|
||||||
|
true,
|
||||||
|
app.handle(),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.expect("matching replacement target should be removable");
|
.expect("matching replacement target should be removable");
|
||||||
assert!(!target.exists());
|
assert!(!target.exists());
|
||||||
|
|
||||||
std::fs::write(&target, b"new file").unwrap();
|
std::fs::write(&target, b"new file").unwrap();
|
||||||
let error = remove_unowned_replacement_target(&target, &fingerprint, true, app.handle())
|
let error = remove_unowned_replacement_target(
|
||||||
.await
|
&target,
|
||||||
.expect_err("a replacement target with a new fingerprint must be preserved");
|
&fingerprint,
|
||||||
|
"staged-replacement",
|
||||||
|
true,
|
||||||
|
app.handle(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect_err("a replacement target with a new fingerprint must be preserved");
|
||||||
assert!(error.contains("changed after duplicate resolution"));
|
assert!(error.contains("changed after duplicate resolution"));
|
||||||
assert_eq!(std::fs::read(&target).unwrap(), b"new file");
|
assert_eq!(std::fs::read(&target).unwrap(), b"new file");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user