mirror of
https://github.com/nimbold/Firelink.git
synced 2026-09-01 13:38:01 +00:00
fix(persistence): harden cross-layer ownership
- Validate and sanitize renderer event payloads before UI projection - Fence stale enqueue cleanup by native lifecycle generation - Canonicalize empty startup hydration and harden SQLite backup durability - Add real-postcondition IPC, restart, storage, and queue regressions
This commit is contained in:
+155
-12
@@ -66,7 +66,28 @@ fn init_at_path_internal(
|
||||
fs::create_dir_all(app_data_dir)
|
||||
.map_err(|error| format!("failed to create app data directory: {error}"))?;
|
||||
let database_path = app_data_dir.join(DATABASE_NAME);
|
||||
let existed = database_path.exists();
|
||||
let existed = match fs::symlink_metadata(&database_path) {
|
||||
Ok(metadata) if metadata.file_type().is_symlink() => {
|
||||
return Err(format!(
|
||||
"persistence database is a symbolic link: '{}'",
|
||||
database_path.display()
|
||||
));
|
||||
}
|
||||
Ok(metadata) if !metadata.is_file() => {
|
||||
return Err(format!(
|
||||
"persistence database is not a regular file: '{}'",
|
||||
database_path.display()
|
||||
));
|
||||
}
|
||||
Ok(_) => true,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
"failed to inspect persistence database '{}': {error}",
|
||||
database_path.display()
|
||||
));
|
||||
}
|
||||
};
|
||||
let mut connection = Connection::open(&database_path)
|
||||
.map_err(|error| format!("failed to open database: {error}"))?;
|
||||
|
||||
@@ -289,6 +310,27 @@ fn import_legacy_data(
|
||||
}
|
||||
|
||||
fn sanitize_legacy_source(path: &Path, remove_pairing_token: bool) -> Result<(), String> {
|
||||
match fs::symlink_metadata(path) {
|
||||
Ok(metadata) if metadata.file_type().is_symlink() => {
|
||||
return Err(format!(
|
||||
"legacy persistence source is a symbolic link: '{}'",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
Ok(metadata) if !metadata.is_file() => {
|
||||
return Err(format!(
|
||||
"legacy persistence source is not a regular file: '{}'",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
"failed to inspect legacy persistence source '{}': {error}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
if is_database_path(path) {
|
||||
let mut connection = Connection::open(path).map_err(|error| {
|
||||
format!(
|
||||
@@ -389,12 +431,27 @@ fn write_sanitized_legacy_store(
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
temporary.as_file().sync_all().map_err(|error| {
|
||||
format!(
|
||||
"failed to synchronize temporary sanitized legacy store beside '{}': {error}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
temporary.persist(path).map_err(|error| {
|
||||
format!(
|
||||
"failed to replace legacy store '{}' without losing the original: {}",
|
||||
path.display(), error.error
|
||||
)
|
||||
})?;
|
||||
#[cfg(unix)]
|
||||
fs::File::open(parent)
|
||||
.and_then(|directory| directory.sync_all())
|
||||
.map_err(|error| {
|
||||
format!(
|
||||
"failed to synchronize legacy store directory '{}': {error}",
|
||||
parent.display()
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -591,6 +648,18 @@ fn query_string_column(connection: &Connection, query: &str) -> Result<Vec<Strin
|
||||
}
|
||||
|
||||
fn backup_file(path: &Path, reason: &str) -> Result<PathBuf, String> {
|
||||
let source_metadata = fs::symlink_metadata(path).map_err(|error| {
|
||||
format!(
|
||||
"failed to inspect persistence file '{}': {error}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
if source_metadata.file_type().is_symlink() || !source_metadata.is_file() {
|
||||
return Err(format!(
|
||||
"persistence backup source is not a regular file: '{}'",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
let timestamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ");
|
||||
let file_name = path
|
||||
.file_name()
|
||||
@@ -599,6 +668,7 @@ fn backup_file(path: &Path, reason: &str) -> Result<PathBuf, String> {
|
||||
let backup_prefix = format!("{file_name}.backup-{reason}-");
|
||||
if let Some(existing) = path.parent().and_then(|parent| {
|
||||
fs::read_dir(parent).ok()?.flatten().find_map(|entry| {
|
||||
entry.file_type().ok().filter(|kind| kind.is_file())?;
|
||||
entry
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
@@ -608,17 +678,50 @@ fn backup_file(path: &Path, reason: &str) -> Result<PathBuf, String> {
|
||||
}) {
|
||||
return Ok(existing);
|
||||
}
|
||||
let backup_path = path.with_file_name(format!("{file_name}.backup-{reason}-{timestamp}"));
|
||||
if backup_path.exists() {
|
||||
return Ok(backup_path);
|
||||
let backup_path = path.with_file_name(format!(
|
||||
"{file_name}.backup-{reason}-{timestamp}-{}",
|
||||
uuid::Uuid::new_v4().simple()
|
||||
));
|
||||
let result = (|| {
|
||||
use std::io::{copy, BufReader};
|
||||
|
||||
let source = fs::File::open(path).map_err(|error| {
|
||||
format!(
|
||||
"failed to open persistence file '{}' for backup: {error}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
let mut source = BufReader::new(source);
|
||||
let destination = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&backup_path)
|
||||
.map_err(|error| {
|
||||
format!(
|
||||
"failed to create persistence backup '{}': {error}",
|
||||
backup_path.display()
|
||||
)
|
||||
})?;
|
||||
let mut destination = destination;
|
||||
copy(&mut source, &mut destination).map_err(|error| {
|
||||
format!(
|
||||
"failed to back up persistence file '{}' to '{}': {error}",
|
||||
path.display(),
|
||||
backup_path.display()
|
||||
)
|
||||
})?;
|
||||
destination.sync_all().map_err(|error| {
|
||||
format!(
|
||||
"failed to synchronize persistence backup '{}': {error}",
|
||||
backup_path.display()
|
||||
)
|
||||
})?;
|
||||
Ok::<(), String>(())
|
||||
})();
|
||||
if let Err(error) = result {
|
||||
let _ = fs::remove_file(&backup_path);
|
||||
return Err(error);
|
||||
}
|
||||
fs::copy(path, &backup_path).map_err(|error| {
|
||||
format!(
|
||||
"failed to back up persistence file '{}' to '{}': {error}",
|
||||
path.display(),
|
||||
backup_path.display()
|
||||
)
|
||||
})?;
|
||||
Ok(backup_path)
|
||||
}
|
||||
|
||||
@@ -628,7 +731,10 @@ fn backup_database(connection: &Connection, path: &Path, reason: &str) -> Result
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.ok_or_else(|| format!("invalid database path '{}'", path.display()))?;
|
||||
let backup_path = path.with_file_name(format!("{file_name}.backup-{reason}-{timestamp}"));
|
||||
let backup_path = path.with_file_name(format!(
|
||||
"{file_name}.backup-{reason}-{timestamp}-{}",
|
||||
uuid::Uuid::new_v4().simple()
|
||||
));
|
||||
connection
|
||||
.execute("VACUUM INTO ?1", params![backup_path.to_string_lossy()])
|
||||
.map_err(|error| {
|
||||
@@ -2349,6 +2455,43 @@ mod tests {
|
||||
}));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn refuses_to_open_a_database_symlink() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let temp = TempDir::new().unwrap();
|
||||
let target = temp.path().join("outside.sqlite");
|
||||
symlink(&target, temp.path().join(DATABASE_NAME)).unwrap();
|
||||
|
||||
let result = init_at_path(temp.path());
|
||||
let error = result.err().expect("database symlink must fail closed");
|
||||
assert!(error.contains("symbolic link"));
|
||||
assert!(!target.exists());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn ignores_symlinked_legacy_backup_candidates() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let temp = TempDir::new().unwrap();
|
||||
let source = temp.path().join(LEGACY_STORE_NAME);
|
||||
let outside = temp.path().join("outside-store.bin");
|
||||
let linked_backup = temp
|
||||
.path()
|
||||
.join("store.bin.backup-legacy-import-attacker");
|
||||
fs::write(&source, b"trusted-source").unwrap();
|
||||
fs::write(&outside, b"outside-content").unwrap();
|
||||
symlink(&outside, &linked_backup).unwrap();
|
||||
|
||||
let backup = backup_file(&source, "legacy-import").unwrap();
|
||||
assert_ne!(backup, linked_backup);
|
||||
assert!(fs::symlink_metadata(&backup).unwrap().is_file());
|
||||
assert_eq!(fs::read(&backup).unwrap(), b"trusted-source");
|
||||
assert_eq!(fs::read(&outside).unwrap(), b"outside-content");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portable_migration_does_not_create_raw_schema_backup() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
|
||||
+50
-3
@@ -5911,6 +5911,16 @@ async fn resume_download(
|
||||
}
|
||||
}
|
||||
|
||||
fn stale_lifecycle_cleanup_is_noop(
|
||||
expected_generation: Option<u64>,
|
||||
current_generation: Option<u64>,
|
||||
) -> bool {
|
||||
matches!(
|
||||
(expected_generation, current_generation),
|
||||
(Some(expected), Some(current)) if expected != current
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn remove_download(
|
||||
caller: tauri::WebviewWindow,
|
||||
@@ -5919,10 +5929,18 @@ async fn remove_download(
|
||||
id: String,
|
||||
delete_assets: bool,
|
||||
preserve_resumable: Option<bool>,
|
||||
expected_lifecycle_generation: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
properties_window::ensure_main_window(&caller)?;
|
||||
log::info!("remove_download called for id: {}", id);
|
||||
let preserve_resumable = preserve_resumable.unwrap_or(false);
|
||||
let expected_lifecycle_generation = expected_lifecycle_generation
|
||||
.map(|generation| {
|
||||
generation
|
||||
.parse::<u64>()
|
||||
.map_err(|_| "Invalid expected download lifecycle generation".to_string())
|
||||
})
|
||||
.transpose()?;
|
||||
let control_guard = state.queue_manager.acquire_aria2_control(&id).await;
|
||||
|
||||
let active_kind = state.queue_manager.active_kind(&id).await;
|
||||
@@ -5930,8 +5948,28 @@ async fn remove_download(
|
||||
.queue_manager
|
||||
.registered_lifecycle_generation(&id)
|
||||
.await;
|
||||
let media_lifecycle_generation = registered_lifecycle_generation.unwrap_or_default();
|
||||
if let Some(generation) = registered_lifecycle_generation {
|
||||
if stale_lifecycle_cleanup_is_noop(
|
||||
expected_lifecycle_generation,
|
||||
registered_lifecycle_generation,
|
||||
) {
|
||||
// A renderer cleanup worker may be delayed until after the old native
|
||||
// lifecycle has already completed and a newer generation has claimed
|
||||
// the same download id. It must never remove that newer owner.
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(expected_generation) = expected_lifecycle_generation {
|
||||
// Registration normally exists before a pending task is committed.
|
||||
// If a delayed cleanup observes a transient registry/pending gap,
|
||||
// remove only the exact expected task and do not perform a broad
|
||||
// download removal against an unowned lifecycle.
|
||||
state
|
||||
.queue_manager
|
||||
.remove_from_pending_for_generation(&id, expected_generation)
|
||||
.await;
|
||||
if registered_lifecycle_generation.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
} else if let Some(generation) = registered_lifecycle_generation {
|
||||
state
|
||||
.queue_manager
|
||||
.remove_from_pending_for_generation(&id, generation)
|
||||
@@ -5939,6 +5977,7 @@ async fn remove_download(
|
||||
} else {
|
||||
state.queue_manager.remove_from_pending(&id).await;
|
||||
}
|
||||
let media_lifecycle_generation = registered_lifecycle_generation.unwrap_or_default();
|
||||
|
||||
let gid = state.queue_manager.aria2_gid_for_download(&id);
|
||||
if let Some(gid) = gid.as_deref() {
|
||||
@@ -11778,7 +11817,7 @@ mod tests {
|
||||
retained_torrent_id_from_persisted_record,
|
||||
retained_torrent_info_hash_from_persisted_record,
|
||||
merge_durable_torrent_telemetry, torrent_identity_magnet, torrent_move_path_pair,
|
||||
Aria2DaemonGuard,
|
||||
Aria2DaemonGuard, stale_lifecycle_cleanup_is_noop,
|
||||
};
|
||||
#[cfg(target_os = "macos")]
|
||||
use super::should_apply_dock_badge_update;
|
||||
@@ -11803,6 +11842,14 @@ mod tests {
|
||||
assert!(!aria2_gid_not_found("aria2 error code 3: Resource not found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_lifecycle_cleanup_only_targets_the_expected_native_owner() {
|
||||
assert!(!stale_lifecycle_cleanup_is_noop(Some(7), Some(7)));
|
||||
assert!(stale_lifecycle_cleanup_is_noop(Some(7), Some(8)));
|
||||
assert!(!stale_lifecycle_cleanup_is_noop(Some(7), None));
|
||||
assert!(!stale_lifecycle_cleanup_is_noop(None, Some(8)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_aria2_status_preserves_exact_progress_snapshot() {
|
||||
let snapshot = aria2_download_state_progress(Some(&json!({
|
||||
|
||||
@@ -460,6 +460,31 @@ async fn accepted_generation_cannot_be_replayed_after_registry_release() {
|
||||
.expect("only a newer lifecycle may reuse the id");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn generation_fenced_pending_removal_handles_a_registry_gap() {
|
||||
let (mgr, _spawner) = make_manager(2);
|
||||
mgr.push_with_generation(sample_task("pending-gap"), 7)
|
||||
.await
|
||||
.expect("the pending lifecycle should be accepted");
|
||||
assert_eq!(
|
||||
mgr.registered_lifecycle_generation("pending-gap").await,
|
||||
Some(7)
|
||||
);
|
||||
|
||||
// Model a delayed cleanup observing the pending task after its registry
|
||||
// marker has already been released. Only the exact lifecycle may be
|
||||
// removed; a newer generation must remain untouched.
|
||||
mgr.release_registered_id("pending-gap").await;
|
||||
assert!(!mgr
|
||||
.remove_from_pending_for_generation("pending-gap", 8)
|
||||
.await);
|
||||
assert_eq!(mgr.pending_order(None).await, vec!["pending-gap".to_string()]);
|
||||
assert!(mgr
|
||||
.remove_from_pending_for_generation("pending-gap", 7)
|
||||
.await);
|
||||
assert!(mgr.pending_order(None).await.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn release_permit_is_idempotent() {
|
||||
let (mgr, _spawner) = make_manager(2);
|
||||
|
||||
Reference in New Issue
Block a user