mirror of
https://github.com/nimbold/Firelink.git
synced 2026-09-02 14:07:57 +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)
|
fs::create_dir_all(app_data_dir)
|
||||||
.map_err(|error| format!("failed to create app data directory: {error}"))?;
|
.map_err(|error| format!("failed to create app data directory: {error}"))?;
|
||||||
let database_path = app_data_dir.join(DATABASE_NAME);
|
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)
|
let mut connection = Connection::open(&database_path)
|
||||||
.map_err(|error| format!("failed to open database: {error}"))?;
|
.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> {
|
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) {
|
if is_database_path(path) {
|
||||||
let mut connection = Connection::open(path).map_err(|error| {
|
let mut connection = Connection::open(path).map_err(|error| {
|
||||||
format!(
|
format!(
|
||||||
@@ -389,12 +431,27 @@ fn write_sanitized_legacy_store(
|
|||||||
path.display()
|
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| {
|
temporary.persist(path).map_err(|error| {
|
||||||
format!(
|
format!(
|
||||||
"failed to replace legacy store '{}' without losing the original: {}",
|
"failed to replace legacy store '{}' without losing the original: {}",
|
||||||
path.display(), error.error
|
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(())
|
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> {
|
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 timestamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ");
|
||||||
let file_name = path
|
let file_name = path
|
||||||
.file_name()
|
.file_name()
|
||||||
@@ -599,6 +668,7 @@ fn backup_file(path: &Path, reason: &str) -> Result<PathBuf, String> {
|
|||||||
let backup_prefix = format!("{file_name}.backup-{reason}-");
|
let backup_prefix = format!("{file_name}.backup-{reason}-");
|
||||||
if let Some(existing) = path.parent().and_then(|parent| {
|
if let Some(existing) = path.parent().and_then(|parent| {
|
||||||
fs::read_dir(parent).ok()?.flatten().find_map(|entry| {
|
fs::read_dir(parent).ok()?.flatten().find_map(|entry| {
|
||||||
|
entry.file_type().ok().filter(|kind| kind.is_file())?;
|
||||||
entry
|
entry
|
||||||
.file_name()
|
.file_name()
|
||||||
.to_string_lossy()
|
.to_string_lossy()
|
||||||
@@ -608,17 +678,50 @@ fn backup_file(path: &Path, reason: &str) -> Result<PathBuf, String> {
|
|||||||
}) {
|
}) {
|
||||||
return Ok(existing);
|
return Ok(existing);
|
||||||
}
|
}
|
||||||
let backup_path = path.with_file_name(format!("{file_name}.backup-{reason}-{timestamp}"));
|
let backup_path = path.with_file_name(format!(
|
||||||
if backup_path.exists() {
|
"{file_name}.backup-{reason}-{timestamp}-{}",
|
||||||
return Ok(backup_path);
|
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)
|
Ok(backup_path)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -628,7 +731,10 @@ fn backup_database(connection: &Connection, path: &Path, reason: &str) -> Result
|
|||||||
.file_name()
|
.file_name()
|
||||||
.and_then(|name| name.to_str())
|
.and_then(|name| name.to_str())
|
||||||
.ok_or_else(|| format!("invalid database path '{}'", path.display()))?;
|
.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
|
connection
|
||||||
.execute("VACUUM INTO ?1", params![backup_path.to_string_lossy()])
|
.execute("VACUUM INTO ?1", params![backup_path.to_string_lossy()])
|
||||||
.map_err(|error| {
|
.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]
|
#[test]
|
||||||
fn portable_migration_does_not_create_raw_schema_backup() {
|
fn portable_migration_does_not_create_raw_schema_backup() {
|
||||||
let temp = TempDir::new().unwrap();
|
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]
|
#[tauri::command]
|
||||||
async fn remove_download(
|
async fn remove_download(
|
||||||
caller: tauri::WebviewWindow,
|
caller: tauri::WebviewWindow,
|
||||||
@@ -5919,10 +5929,18 @@ async fn remove_download(
|
|||||||
id: String,
|
id: String,
|
||||||
delete_assets: bool,
|
delete_assets: bool,
|
||||||
preserve_resumable: Option<bool>,
|
preserve_resumable: Option<bool>,
|
||||||
|
expected_lifecycle_generation: Option<String>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
properties_window::ensure_main_window(&caller)?;
|
properties_window::ensure_main_window(&caller)?;
|
||||||
log::info!("remove_download called for id: {}", id);
|
log::info!("remove_download called for id: {}", id);
|
||||||
let preserve_resumable = preserve_resumable.unwrap_or(false);
|
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 control_guard = state.queue_manager.acquire_aria2_control(&id).await;
|
||||||
|
|
||||||
let active_kind = state.queue_manager.active_kind(&id).await;
|
let active_kind = state.queue_manager.active_kind(&id).await;
|
||||||
@@ -5930,8 +5948,28 @@ async fn remove_download(
|
|||||||
.queue_manager
|
.queue_manager
|
||||||
.registered_lifecycle_generation(&id)
|
.registered_lifecycle_generation(&id)
|
||||||
.await;
|
.await;
|
||||||
let media_lifecycle_generation = registered_lifecycle_generation.unwrap_or_default();
|
if stale_lifecycle_cleanup_is_noop(
|
||||||
if let Some(generation) = registered_lifecycle_generation {
|
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
|
state
|
||||||
.queue_manager
|
.queue_manager
|
||||||
.remove_from_pending_for_generation(&id, generation)
|
.remove_from_pending_for_generation(&id, generation)
|
||||||
@@ -5939,6 +5977,7 @@ async fn remove_download(
|
|||||||
} else {
|
} else {
|
||||||
state.queue_manager.remove_from_pending(&id).await;
|
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);
|
let gid = state.queue_manager.aria2_gid_for_download(&id);
|
||||||
if let Some(gid) = gid.as_deref() {
|
if let Some(gid) = gid.as_deref() {
|
||||||
@@ -11778,7 +11817,7 @@ mod tests {
|
|||||||
retained_torrent_id_from_persisted_record,
|
retained_torrent_id_from_persisted_record,
|
||||||
retained_torrent_info_hash_from_persisted_record,
|
retained_torrent_info_hash_from_persisted_record,
|
||||||
merge_durable_torrent_telemetry, torrent_identity_magnet, torrent_move_path_pair,
|
merge_durable_torrent_telemetry, torrent_identity_magnet, torrent_move_path_pair,
|
||||||
Aria2DaemonGuard,
|
Aria2DaemonGuard, stale_lifecycle_cleanup_is_noop,
|
||||||
};
|
};
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
use super::should_apply_dock_badge_update;
|
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"));
|
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]
|
#[test]
|
||||||
fn terminal_aria2_status_preserves_exact_progress_snapshot() {
|
fn terminal_aria2_status_preserves_exact_progress_snapshot() {
|
||||||
let snapshot = aria2_download_state_progress(Some(&json!({
|
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");
|
.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]
|
#[tokio::test]
|
||||||
async fn release_permit_is_idempotent() {
|
async fn release_permit_is_idempotent() {
|
||||||
let (mgr, _spawner) = make_manager(2);
|
let (mgr, _spawner) = make_manager(2);
|
||||||
|
|||||||
+9
-1
@@ -70,7 +70,15 @@ type CommandMap = {
|
|||||||
open_downloaded_file: { args: { path: string }; result: void };
|
open_downloaded_file: { args: { path: string }; result: void };
|
||||||
pause_download: { args: { id: string }; result: void };
|
pause_download: { args: { id: string }; result: void };
|
||||||
resume_download: { args: { id: string; queueId: string }; result: boolean };
|
resume_download: { args: { id: string; queueId: string }; result: boolean };
|
||||||
remove_download: { args: { id: string; deleteAssets: boolean; preserveResumable?: boolean }; result: void };
|
remove_download: {
|
||||||
|
args: {
|
||||||
|
id: string;
|
||||||
|
deleteAssets: boolean;
|
||||||
|
preserveResumable?: boolean;
|
||||||
|
expectedLifecycleGeneration?: string;
|
||||||
|
};
|
||||||
|
result: void;
|
||||||
|
};
|
||||||
get_download_primary_path: { args: { id: string }; result: string | null };
|
get_download_primary_path: { args: { id: string }; result: string | null };
|
||||||
detach_download_for_reconfigure: { args: { id: string }; result: void };
|
detach_download_for_reconfigure: { args: { id: string }; result: void };
|
||||||
clear_torrent_removal_paths: { args: { id: string }; result: void };
|
clear_torrent_removal_paths: { args: { id: string }; result: void };
|
||||||
|
|||||||
@@ -92,6 +92,38 @@ describe('useDownloadProgressStore', () => {
|
|||||||
release();
|
release();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('ignores malformed state events instead of projecting an unknown status', async () => {
|
||||||
|
const handlers: Record<string, (event: any) => void> = {};
|
||||||
|
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
|
||||||
|
handlers[event] = handler as (event: any) => void;
|
||||||
|
return Promise.resolve(vi.fn());
|
||||||
|
});
|
||||||
|
useDownloadStore.setState({
|
||||||
|
downloads: [{
|
||||||
|
id: 'malformed-state',
|
||||||
|
url: 'https://example.com/file',
|
||||||
|
fileName: 'file.bin',
|
||||||
|
status: 'downloading',
|
||||||
|
category: 'Other',
|
||||||
|
dateAdded: ''
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
const release = await initDownloadListener();
|
||||||
|
handlers['download-state']({ payload: {
|
||||||
|
id: 'malformed-state',
|
||||||
|
status: 'not-a-download-status',
|
||||||
|
error: { secret: 'must not enter the store' }
|
||||||
|
} });
|
||||||
|
|
||||||
|
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
||||||
|
id: 'malformed-state',
|
||||||
|
status: 'downloading'
|
||||||
|
});
|
||||||
|
expect(useDownloadStore.getState().downloads[0]).not.toHaveProperty('lastError');
|
||||||
|
release();
|
||||||
|
});
|
||||||
|
|
||||||
it('removes a row from backend pending order when its lifecycle becomes active or retrying', async () => {
|
it('removes a row from backend pending order when its lifecycle becomes active or retrying', async () => {
|
||||||
const handlers: Record<string, (event: any) => void> = {};
|
const handlers: Record<string, (event: any) => void> = {};
|
||||||
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
|
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
|
||||||
@@ -167,6 +199,37 @@ describe('useDownloadProgressStore', () => {
|
|||||||
release();
|
release();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('accepts a progress frame that omits the optional size value', async () => {
|
||||||
|
const handlers: Record<string, (event: any) => void> = {};
|
||||||
|
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
|
||||||
|
handlers[event] = handler as (event: any) => void;
|
||||||
|
return Promise.resolve(vi.fn());
|
||||||
|
});
|
||||||
|
useDownloadStore.setState({
|
||||||
|
downloads: [{
|
||||||
|
id: 'omitted-size',
|
||||||
|
url: 'https://example.com/file.bin',
|
||||||
|
fileName: 'file.bin',
|
||||||
|
status: 'downloading',
|
||||||
|
category: 'Other',
|
||||||
|
dateAdded: ''
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
const release = await initDownloadListener();
|
||||||
|
handlers['download-progress']({ payload: {
|
||||||
|
id: 'omitted-size',
|
||||||
|
fraction: 0.25,
|
||||||
|
speed: '1 MB/s',
|
||||||
|
eta: '1s',
|
||||||
|
size_is_final: false
|
||||||
|
} });
|
||||||
|
|
||||||
|
expect(useDownloadProgressStore.getState().progressMap['omitted-size'])
|
||||||
|
.toMatchObject({ id: 'omitted-size', fraction: 0.25, size: null });
|
||||||
|
release();
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps the last valid live frame when a malformed fraction arrives', async () => {
|
it('keeps the last valid live frame when a malformed fraction arrives', async () => {
|
||||||
const handlers: Record<string, (event: any) => void> = {};
|
const handlers: Record<string, (event: any) => void> = {};
|
||||||
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
|
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
|
||||||
@@ -231,6 +294,13 @@ describe('useDownloadProgressStore', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const release = await initDownloadListener();
|
const release = await initDownloadListener();
|
||||||
|
handlers['download-allocation']({ payload: {
|
||||||
|
id: 'native-allocation',
|
||||||
|
pending: true,
|
||||||
|
lifecycleGeneration: 'not-a-generation'
|
||||||
|
} });
|
||||||
|
expect(useDownloadStore.getState().allocationPendingIds.has('native-allocation')).toBe(false);
|
||||||
|
|
||||||
handlers['download-allocation']({ payload: {
|
handlers['download-allocation']({ payload: {
|
||||||
id: 'native-allocation',
|
id: 'native-allocation',
|
||||||
pending: true,
|
pending: true,
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import type { UnlistenFn } from '@tauri-apps/api/event';
|
import type { UnlistenFn } from '@tauri-apps/api/event';
|
||||||
import type { DownloadStatus } from '../bindings/DownloadStatus';
|
import type { DownloadStatus } from '../bindings/DownloadStatus';
|
||||||
|
import type { DownloadStateEvent } from '../bindings/DownloadStateEvent';
|
||||||
|
import type { DownloadErrorKind } from '../bindings/DownloadErrorKind';
|
||||||
import { listenEvent as listen } from '../ipc';
|
import { listenEvent as listen } from '../ipc';
|
||||||
import type { DownloadItem } from '../bindings/DownloadItem';
|
import type { DownloadItem } from '../bindings/DownloadItem';
|
||||||
import type { DownloadProgressEvent } from '../bindings/DownloadProgressEvent';
|
import type { DownloadProgressEvent } from '../bindings/DownloadProgressEvent';
|
||||||
import { categoryForDownload } from '../utils/downloads';
|
import { categoryForDownload, isDownloadStatus } from '../utils/downloads';
|
||||||
import { useDownloadProgressStore } from './downloadProgressStore';
|
import { useDownloadProgressStore } from './downloadProgressStore';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -35,9 +37,53 @@ type ProgressFields = {
|
|||||||
const finiteNonNegative = (value: unknown): value is number =>
|
const finiteNonNegative = (value: unknown): value is number =>
|
||||||
typeof value === 'number' && Number.isFinite(value) && value >= 0;
|
typeof value === 'number' && Number.isFinite(value) && value >= 0;
|
||||||
|
|
||||||
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
|
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
|
||||||
|
const isDownloadErrorKind = (value: unknown): value is DownloadErrorKind =>
|
||||||
|
value === 'nameResolution' || value === 'destinationAccess';
|
||||||
|
|
||||||
|
const isLifecycleGeneration = (value: unknown): value is string =>
|
||||||
|
typeof value === 'string' && /^\d+$/.test(value);
|
||||||
|
|
||||||
|
type SanitizedDownloadStateEvent = Omit<DownloadStateEvent, 'status'> & {
|
||||||
|
status: DownloadStatus;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sanitizeStatePayload = (value: unknown): SanitizedDownloadStateEvent | null => {
|
||||||
|
if (!isRecord(value) || typeof value.id !== 'string' || !isDownloadStatus(value.status)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: value.id,
|
||||||
|
status: value.status,
|
||||||
|
error: typeof value.error === 'string' ? value.error : null,
|
||||||
|
...(isDownloadErrorKind(value.errorKind) ? { errorKind: value.errorKind } : {}),
|
||||||
|
...(typeof value.resolverFallback === 'boolean'
|
||||||
|
? { resolverFallback: value.resolverFallback }
|
||||||
|
: {}),
|
||||||
|
...(typeof value.fileName === 'string' ? { fileName: value.fileName } : {}),
|
||||||
|
...(typeof value.destination === 'string' ? { destination: value.destination } : {}),
|
||||||
|
...(finiteNonNegative(value.torrentSeedRemaining)
|
||||||
|
? { torrentSeedRemaining: value.torrentSeedRemaining }
|
||||||
|
: {}),
|
||||||
|
...(value.progress !== undefined ? { progress: value.progress as DownloadStateEvent['progress'] } : {})
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const sanitizeProgressPayload = (
|
const sanitizeProgressPayload = (
|
||||||
payload: DownloadProgressEvent,
|
value: unknown,
|
||||||
): DownloadProgressEvent | null => {
|
): DownloadProgressEvent | null => {
|
||||||
|
if (!isRecord(value)
|
||||||
|
|| typeof value.id !== 'string'
|
||||||
|
|| typeof value.speed !== 'string'
|
||||||
|
|| typeof value.eta !== 'string'
|
||||||
|
|| (value.size !== undefined && value.size !== null && typeof value.size !== 'string')
|
||||||
|
|| typeof value.size_is_final !== 'boolean') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const payload = { ...value, size: value.size ?? null } as unknown as DownloadProgressEvent;
|
||||||
if (typeof payload.fraction !== 'number'
|
if (typeof payload.fraction !== 'number'
|
||||||
|| !Number.isFinite(payload.fraction)
|
|| !Number.isFinite(payload.fraction)
|
||||||
|| payload.fraction < 0
|
|| payload.fraction < 0
|
||||||
@@ -65,6 +111,9 @@ const sanitizeProgressPayload = (
|
|||||||
&& typeof sanitized.total_is_estimate !== 'boolean') {
|
&& typeof sanitized.total_is_estimate !== 'boolean') {
|
||||||
delete sanitized.total_is_estimate;
|
delete sanitized.total_is_estimate;
|
||||||
}
|
}
|
||||||
|
if (sanitized.upload_speed !== undefined && typeof sanitized.upload_speed !== 'string') {
|
||||||
|
delete sanitized.upload_speed;
|
||||||
|
}
|
||||||
return sanitized;
|
return sanitized;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -151,7 +200,8 @@ const disposeDownloadListeners = () => {
|
|||||||
const startDownloadListeners = async () => {
|
const startDownloadListeners = async () => {
|
||||||
const registrations = await Promise.allSettled([
|
const registrations = await Promise.allSettled([
|
||||||
listen('download-progress', (event) => {
|
listen('download-progress', (event) => {
|
||||||
const payload = event.payload;
|
const payload = sanitizeProgressPayload(event.payload);
|
||||||
|
if (!payload) return;
|
||||||
const mainStore = useDownloadStore.getState();
|
const mainStore = useDownloadStore.getState();
|
||||||
const current = mainStore.downloads.find(d => d.id === payload.id);
|
const current = mainStore.downloads.find(d => d.id === payload.id);
|
||||||
if (!current) {
|
if (!current) {
|
||||||
@@ -167,10 +217,7 @@ const startDownloadListeners = async () => {
|
|||||||
useDownloadProgressStore.getState().clearDownloadProgress(payload.id);
|
useDownloadProgressStore.getState().clearDownloadProgress(payload.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const sanitizedPayload = sanitizeProgressPayload(payload);
|
const sanitizedPayload = payload;
|
||||||
if (!sanitizedPayload) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
useDownloadProgressStore.getState().updateDownloadProgress(payload.id, sanitizedPayload);
|
useDownloadProgressStore.getState().updateDownloadProgress(payload.id, sanitizedPayload);
|
||||||
const shouldUpdateSize = Boolean(sanitizedPayload.size && (!current.isMedia || sanitizedPayload.size_is_final));
|
const shouldUpdateSize = Boolean(sanitizedPayload.size && (!current.isMedia || sanitizedPayload.size_is_final));
|
||||||
const updates: Partial<DownloadItem> = {};
|
const updates: Partial<DownloadItem> = {};
|
||||||
@@ -232,6 +279,12 @@ const startDownloadListeners = async () => {
|
|||||||
}),
|
}),
|
||||||
listen('download-allocation', (event) => {
|
listen('download-allocation', (event) => {
|
||||||
const payload = event.payload;
|
const payload = event.payload;
|
||||||
|
if (!isRecord(payload)
|
||||||
|
|| typeof payload.id !== 'string'
|
||||||
|
|| typeof payload.pending !== 'boolean'
|
||||||
|
|| !isLifecycleGeneration(payload.lifecycleGeneration)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const mainStore = useDownloadStore.getState();
|
const mainStore = useDownloadStore.getState();
|
||||||
const current = mainStore.downloads.find(download => download.id === payload.id);
|
const current = mainStore.downloads.find(download => download.id === payload.id);
|
||||||
if (!current) {
|
if (!current) {
|
||||||
@@ -258,14 +311,15 @@ const startDownloadListeners = async () => {
|
|||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
listen('download-state', async (event) => {
|
listen('download-state', async (event) => {
|
||||||
const payload = event.payload;
|
const payload = sanitizeStatePayload(event.payload);
|
||||||
|
if (!payload) return;
|
||||||
const mainStore = useDownloadStore.getState();
|
const mainStore = useDownloadStore.getState();
|
||||||
const current = mainStore.downloads.find(d => d.id === payload.id);
|
const current = mainStore.downloads.find(d => d.id === payload.id);
|
||||||
if (!current) {
|
if (!current) {
|
||||||
useDownloadProgressStore.getState().resetDownloadProgress(payload.id);
|
useDownloadProgressStore.getState().resetDownloadProgress(payload.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const status = payload.status as DownloadStatus;
|
const status = payload.status;
|
||||||
// A move terminal event carries its authoritative destination. Older
|
// A move terminal event carries its authoritative destination. Older
|
||||||
// lifecycle events do not, so they must not overwrite an active move or
|
// lifecycle events do not, so they must not overwrite an active move or
|
||||||
// clear its progress while the native relocation still owns the row.
|
// clear its progress while the native relocation still owns the row.
|
||||||
@@ -462,6 +516,7 @@ const startDownloadListeners = async () => {
|
|||||||
}),
|
}),
|
||||||
listen('torrent-move-progress', (event) => {
|
listen('torrent-move-progress', (event) => {
|
||||||
const payload = event.payload;
|
const payload = event.payload;
|
||||||
|
if (!isRecord(payload) || typeof payload.id !== 'string') return;
|
||||||
const current = useDownloadStore.getState().downloads.find(d => d.id === payload.id);
|
const current = useDownloadStore.getState().downloads.find(d => d.id === payload.id);
|
||||||
if (!current || current.status !== 'moving') {
|
if (!current || current.status !== 'moving') {
|
||||||
useDownloadProgressStore.getState().clearMoveProgress(payload.id);
|
useDownloadProgressStore.getState().clearMoveProgress(payload.id);
|
||||||
|
|||||||
@@ -772,6 +772,28 @@ describe('useDownloadStore', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('replaces stale in-memory downloads when startup loads an empty persisted snapshot', async () => {
|
||||||
|
useDownloadStore.setState({
|
||||||
|
downloads: [{
|
||||||
|
id: 'stale-memory-row',
|
||||||
|
url: 'https://example.com/stale.bin',
|
||||||
|
fileName: 'stale.bin',
|
||||||
|
status: 'completed',
|
||||||
|
category: 'Other',
|
||||||
|
dateAdded: ''
|
||||||
|
}] as any[]
|
||||||
|
});
|
||||||
|
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
||||||
|
if (cmd === 'db_get_all_queues') return [];
|
||||||
|
if (cmd === 'db_get_all_downloads') return [];
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
await useDownloadStore.getState().initDB();
|
||||||
|
|
||||||
|
expect(useDownloadStore.getState().downloads).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it('remaps persisted downloads when queue records are malformed or missing', async () => {
|
it('remaps persisted downloads when queue records are malformed or missing', async () => {
|
||||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
||||||
if (cmd === 'db_get_all_queues') {
|
if (cmd === 'db_get_all_queues') {
|
||||||
@@ -1336,6 +1358,12 @@ describe('useDownloadStore', () => {
|
|||||||
expect(
|
expect(
|
||||||
vi.mocked(ipc.invokeCommand).mock.calls.filter(([command]) => command === 'remove_download')
|
vi.mocked(ipc.invokeCommand).mock.calls.filter(([command]) => command === 'remove_download')
|
||||||
).toHaveLength(2);
|
).toHaveLength(2);
|
||||||
|
expect(
|
||||||
|
vi.mocked(ipc.invokeCommand).mock.calls.some(([command, args]) =>
|
||||||
|
command === 'remove_download'
|
||||||
|
&& (args as { expectedLifecycleGeneration?: string })?.expectedLifecycleGeneration === '0'
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not expose allocation while admission is merely blocked', async () => {
|
it('does not expose allocation while admission is merely blocked', async () => {
|
||||||
|
|||||||
@@ -256,9 +256,13 @@ const isCurrentDownloadLifecycle = (id: string, generation: bigint): boolean =>
|
|||||||
currentDownloadLifecycle(id) === generation &&
|
currentDownloadLifecycle(id) === generation &&
|
||||||
useDownloadStore.getState().downloads.some(download => download.id === id);
|
useDownloadStore.getState().downloads.some(download => download.id === id);
|
||||||
|
|
||||||
const removeStaleBackendDispatch = async (id: string): Promise<void> => {
|
const removeStaleBackendDispatch = async (id: string, lifecycleGeneration: bigint): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
await invoke('remove_download', { id, deleteAssets: false });
|
await invoke('remove_download', {
|
||||||
|
id,
|
||||||
|
deleteAssets: false,
|
||||||
|
expectedLifecycleGeneration: lifecycleGeneration.toString()
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// The original remove request may already have won this race. Either way,
|
// The original remove request may already have won this race. Either way,
|
||||||
// never allow a stale enqueue to make the deleted row live again.
|
// never allow a stale enqueue to make the deleted row live again.
|
||||||
@@ -452,7 +456,7 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
|
|||||||
const accepted = await invoke('enqueue_download', { item: enqueueItem });
|
const accepted = await invoke('enqueue_download', { item: enqueueItem });
|
||||||
backendAccepted = true;
|
backendAccepted = true;
|
||||||
if (!isCurrentDownloadLifecycle(id, lifecycleGeneration)) {
|
if (!isCurrentDownloadLifecycle(id, lifecycleGeneration)) {
|
||||||
await removeStaleBackendDispatch(id);
|
await removeStaleBackendDispatch(id, lifecycleGeneration);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,7 +473,7 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
|
|||||||
}
|
}
|
||||||
const order = await invoke('get_pending_order', { queueId: item.queueId || MAIN_QUEUE_ID });
|
const order = await invoke('get_pending_order', { queueId: item.queueId || MAIN_QUEUE_ID });
|
||||||
if (!isCurrentDownloadLifecycle(id, lifecycleGeneration)) {
|
if (!isCurrentDownloadLifecycle(id, lifecycleGeneration)) {
|
||||||
await removeStaleBackendDispatch(id);
|
await removeStaleBackendDispatch(id, lifecycleGeneration);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -483,7 +487,7 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`Failed to dispatch ${id}:`, e);
|
console.error(`Failed to dispatch ${id}:`, e);
|
||||||
if (backendAccepted && lifecycleGeneration !== null) {
|
if (backendAccepted && lifecycleGeneration !== null) {
|
||||||
await removeStaleBackendDispatch(id);
|
await removeStaleBackendDispatch(id, lifecycleGeneration);
|
||||||
}
|
}
|
||||||
if (lifecycleGeneration !== null && isCurrentDownloadLifecycle(id, lifecycleGeneration)) {
|
if (lifecycleGeneration !== null && isCurrentDownloadLifecycle(id, lifecycleGeneration)) {
|
||||||
const proxyBlocked = isSystemProxyConfigurationError(e);
|
const proxyBlocked = isSystemProxyConfigurationError(e);
|
||||||
@@ -2940,11 +2944,9 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
|||||||
return normalizePersistedDownloadProgress({ ...download, queueId });
|
return normalizePersistedDownloadProgress({ ...download, queueId });
|
||||||
});
|
});
|
||||||
|
|
||||||
set(state => ({
|
set(() => ({
|
||||||
queues,
|
queues,
|
||||||
downloads: downloads.length > 0
|
downloads: normalizeQueuePositions(downloads)
|
||||||
? normalizeQueuePositions(downloads)
|
|
||||||
: state.downloads
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// A process can die after Aria2 has removed the unselected files but
|
// A process can die after Aria2 has removed the unselected files but
|
||||||
|
|||||||
@@ -38,6 +38,26 @@ const ACTIVE_DOWNLOAD_STATUSES: ReadonlySet<DownloadStatus> = new Set([
|
|||||||
'moving',
|
'moving',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const DOWNLOAD_STATUSES: ReadonlySet<string> = new Set([
|
||||||
|
'ready',
|
||||||
|
'staged',
|
||||||
|
'downloading',
|
||||||
|
'processing',
|
||||||
|
'seeding',
|
||||||
|
'waitingToSeed',
|
||||||
|
'paused',
|
||||||
|
'completed',
|
||||||
|
'failed',
|
||||||
|
'queued',
|
||||||
|
'retrying',
|
||||||
|
'verifying',
|
||||||
|
'moving',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Runtime guard for values arriving from the untyped Tauri event channel. */
|
||||||
|
export const isDownloadStatus = (status: unknown): status is DownloadStatus =>
|
||||||
|
typeof status === 'string' && DOWNLOAD_STATUSES.has(status);
|
||||||
|
|
||||||
export const isActiveDownloadStatus = (status: DownloadStatus): boolean =>
|
export const isActiveDownloadStatus = (status: DownloadStatus): boolean =>
|
||||||
ACTIVE_DOWNLOAD_STATUSES.has(status);
|
ACTIVE_DOWNLOAD_STATUSES.has(status);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user