diff --git a/lib/services/network_library_scanner.dart b/lib/services/network_library_scanner.dart index 92481b72..20f24971 100644 --- a/lib/services/network_library_scanner.dart +++ b/lib/services/network_library_scanner.dart @@ -1,7 +1,9 @@ import 'dart:convert'; import 'dart:io'; +import 'dart:isolate'; import 'package:crypto/crypto.dart'; +import 'package:flutter/services.dart'; import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/services/network_metadata_service.dart'; import 'package:spotiflac_android/services/network_storage_service.dart'; @@ -119,28 +121,28 @@ class NetworkLibraryScanner { await File(cover).exists()) { // Identical embedded artwork shares an immutable file, including // across albums/sources. Cleanup retains all DB cover references. - File? target; - File? staged; try { - final digest = await sha256.bind(File(cover).openRead()).first; - target = File('${covers.path}/network_cover_$digest.image'); - staged = File( - '${target.path}.${NetworkStorageService.newId()}.tmp', - ); - if (!await target.exists() || await target.length() == 0) { - await File(cover).copy(staged.path); - await staged.rename(target.path); + try { + if (!PlatformBridge.supportsCoreBackend) { + final directory = covers.path; + coverPath = await Isolate.run( + () => _promoteCover(cover, directory), + ); + } else { + final promoted = await PlatformBridge.runNativeDataJob({ + 'operation': 'promote_network_cover', + 'path': cover, + 'directory': covers.path, + }); + coverPath = promoted['cover_path'] as String; + } + } on MissingPluginException { + final directory = covers.path; + coverPath = await Isolate.run( + () => _promoteCover(cover, directory), + ); } - coverPath = target.path; - } catch (_) { - if (target != null && await target.exists()) { - coverPath = target.path; - } - } finally { - if (staged != null && await staged.exists()) { - await staged.delete(); - } - } + } catch (_) {} } await checkpoint(); final row = networkLibraryRow(trackSource, entry, tags) @@ -173,6 +175,21 @@ class NetworkLibraryScanner { } } +Future _promoteCover(String cover, String directory) async { + final digest = await sha256.bind(File(cover).openRead()).first; + final target = File('$directory/network_cover_$digest.image'); + final staged = File('${target.path}.${NetworkStorageService.newId()}.tmp'); + try { + if (!await target.exists() || await target.length() == 0) { + await File(cover).copy(staged.path); + await staged.rename(target.path); + } + return target.path; + } finally { + if (await staged.exists()) await staged.delete(); + } +} + Map networkLibraryRow( String source, NetworkEntry entry, diff --git a/lib/services/network_metadata_service.dart b/lib/services/network_metadata_service.dart index 55b90eac..d8397850 100644 --- a/lib/services/network_metadata_service.dart +++ b/lib/services/network_metadata_service.dart @@ -3,6 +3,8 @@ import 'dart:convert'; import 'dart:io'; import 'package:crypto/crypto.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/services/network_storage_service.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; @@ -18,6 +20,7 @@ class NetworkMetadataService { NetworkTagReader? reader, Future Function()? cacheDirectory, }) : _storage = storage ?? NetworkStorageService.instance, + _useNativeReader = reader == null, _reader = reader ?? ((url, name) => @@ -27,6 +30,7 @@ class NetworkMetadataService { static final instance = NetworkMetadataService(); final NetworkStorageService _storage; final NetworkTagReader _reader; + final bool _useNativeReader; final Future Function() _directory; final _cache = value})>{}; final _pending = >>{}; @@ -72,44 +76,41 @@ class NetworkMetadataService { name = '${name.substring(0, name.length - 5)}.m4a'; } final url = await _storage.resolve(source); - final metadata = Map.from(await _reader(url, name)); + Map? loaded; + if (_useNativeReader) { + try { + loaded = await PlatformBridge.runNativeDataJob({ + 'operation': 'network_metadata', + 'url': url, + 'display_name': name, + 'cache_directory': '${(await _directory()).path}/network_metadata', + }); + } on MissingPluginException { + // Test/unsupported hosts retain the same reader on an isolate. + } + } + if (loaded == null) { + final tags = await _reader(url, name); + String directory = ''; + if (tags['cover_base64'] is String && + (tags['cover_base64'] as String).isNotEmpty) { + try { + directory = '${(await _directory()).path}/network_metadata'; + } catch (_) {} + } + loaded = await compute(_cacheNetworkMetadata, { + 'source': source, + 'directory': directory, + 'metadata': tags, + }); + } + final result = loaded ?? (throw StateError('Network metadata is empty')); + final metadata = Map.from(result['metadata'] as Map); if (metadata['error']?.toString().isNotEmpty == true) { throw StateError('Network metadata could not be read'); } - final cover = metadata.remove('cover_base64'); - if (cover is String && - cover.isNotEmpty && - cover.length <= 6 * 1024 * 1024) { - // A cover/cache failure must not discard successfully read tags or lyrics. - try { - final directory = Directory( - '${(await _directory()).path}/network_metadata', - ); - await directory.create(recursive: true); - final file = File( - '${directory.path}/${sha256.convert(utf8.encode(source))}.image', - ); - await file.writeAsBytes(base64Decode(cover), flush: true); - metadata['cover_path'] = file.path; - final files = await directory - .list() - .where((entry) => entry is File) - .cast() - .toList(); - final dated = <({File file, DateTime time})>[]; - for (final entry in files) { - dated.add((file: entry, time: (await entry.stat()).modified)); - } - dated.sort((a, b) => b.time.compareTo(a.time)); - for (final old in dated.skip(32)) { - await old.file.delete(); - } - } catch (_) {} - } final value = Map.unmodifiable(metadata); - // Oversized tag blocks can still be displayed for the current song, but - // must not accumulate across a large NAS collection in the memory cache. - if (utf8.encode(jsonEncode(value)).length > 128 * 1024) return value; + if (result['cacheable'] != true) return value; _cache.remove(source); while (_cache.length >= 32) { _cache.remove(_cache.keys.first); @@ -118,3 +119,44 @@ class NetworkMetadataService { return value; } } + +/// Compatibility path keeps cover decoding/hash/directory sorting off the UI +/// isolate too. Production receives only native file paths and small tags. +Future> _cacheNetworkMetadata( + Map job, +) async { + final metadata = Map.from(job['metadata'] as Map); + final cover = metadata.remove('cover_base64'); + if (cover is String && + cover.isNotEmpty && + (job['directory'] as String).isNotEmpty && + cover.length <= 6 * 1024 * 1024) { + // A cover/cache failure must not discard successfully read tags or lyrics. + try { + final directory = Directory(job['directory'] as String); + await directory.create(recursive: true); + final file = File( + '${directory.path}/${sha256.convert(utf8.encode(job['source'] as String))}.image', + ); + await file.writeAsBytes(base64Decode(cover), flush: true); + metadata['cover_path'] = file.path; + final files = await directory + .list() + .where((entry) => entry is File) + .cast() + .toList(); + final dated = <({File file, DateTime time})>[]; + for (final entry in files) { + dated.add((file: entry, time: (await entry.stat()).modified)); + } + dated.sort((a, b) => b.time.compareTo(a.time)); + for (final old in dated.skip(32)) { + await old.file.delete(); + } + } catch (_) {} + } + return { + 'metadata': metadata, + 'cacheable': utf8.encode(jsonEncode(metadata)).length <= 128 * 1024, + }; +} diff --git a/lib/services/network_storage_service.dart b/lib/services/network_storage_service.dart index c478c491..34639649 100644 --- a/lib/services/network_storage_service.dart +++ b/lib/services/network_storage_service.dart @@ -5,14 +5,44 @@ import 'dart:math'; import 'dart:typed_data'; import 'package:crypto/crypto.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:html/parser.dart' as html; import 'package:spotiflac_android/services/network_certificate.dart'; import 'package:spotiflac_android/services/network_smb_client.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:xml/xml.dart'; part 'network_storage_upload.dart'; +List _parseNetworkListingInBackground( + Map request, +) { + final connection = NetworkConnection( + id: 'listing', + name: 'listing', + protocol: NetworkProtocol.values.byName(request['protocol'] as String), + address: request['address'] as String, + ); + return NetworkStorageService.parseListing( + connection, + request['path'] as String, + utf8.decode(request['bytes'] as Uint8List, allowMalformed: true), + ); +} + +List _hydrateNetworkEntriesInBackground(List entries) => + [ + for (final raw in entries) + NetworkEntry( + (raw as Map)['path'] as String, + raw['name'] as String, + directory: raw['directory'] as bool, + size: (raw['size'] as num?)?.toInt(), + ), + ]; + enum NetworkProtocol { smb, webdav, http } class NetworkConnection { @@ -470,11 +500,7 @@ class NetworkStorageService { } bytes.add(chunk); } - return parseListing( - c, - path, - utf8.decode(bytes.takeBytes(), allowMalformed: true), - ); + return await parseListingInBackground(c, path, bytes.takeBytes()); } finally { client.close(force: true); } @@ -570,6 +596,41 @@ class NetworkStorageService { return _sorted(found.values.toList()); } + /// Keep DOM parsing and sorting away from the Flutter frame while retaining + /// the synchronous parser for unsupported native hosts and contract tests. + static Future> parseListingInBackground( + NetworkConnection connection, + String path, + Uint8List bytes, + ) async { + List entries; + try { + final result = await PlatformBridge.runNativeDataJob({ + 'operation': 'parse_network_listing', + 'protocol': connection.protocol.name, + 'base_url': networkTarget(connection, path).toString(), + 'path': path, + }, bytes: bytes); + entries = result['entries'] as List; + } on MissingPluginException { + return compute(_parseNetworkListingInBackground, { + 'protocol': connection.protocol.name, + 'address': connection.address, + 'path': path, + 'bytes': bytes, + }); + } on PlatformException catch (error) { + if (error.message?.contains('XML nesting exceeds') != true) rethrow; + return compute(_parseNetworkListingInBackground, { + 'protocol': connection.protocol.name, + 'address': connection.address, + 'path': path, + 'bytes': bytes, + }); + } + return compute(_hydrateNetworkEntriesInBackground, entries); + } + Future _serve(HttpRequest request) async { HttpClient? client; NetworkSmbClient? smb; diff --git a/lib/services/network_storage_upload.dart b/lib/services/network_storage_upload.dart index ece36704..ddeaf9c9 100644 --- a/lib/services/network_storage_upload.dart +++ b/lib/services/network_storage_upload.dart @@ -121,11 +121,74 @@ extension NetworkStorageUpload on NetworkStorageService { File file, int length, void Function() checkpoint, + ) async { + final url = await resolve(source); + checkpoint(); + final requestId = 'upload_hash_${NetworkStorageService.newId()}'; + Object? cancellationError; + StackTrace? cancellationStack; + final guard = Timer.periodic(const Duration(milliseconds: 50), (timer) { + try { + checkpoint(); + } catch (error, stack) { + cancellationError = error; + cancellationStack = stack; + timer.cancel(); + unawaited(_cancelUploadHash(requestId)); + } + }); + void checkCancelled() { + final error = cancellationError; + if (error != null) { + Error.throwWithStackTrace(error, cancellationStack!); + } + } + + try { + final result = await PlatformBridge.runNativeDataJob({ + 'operation': 'matches_network_upload', + 'url': url, + 'path': file.path, + 'length': length, + 'idle_timeout_ms': NetworkStorageService.timeout.inMilliseconds, + }, requestId: requestId); + checkCancelled(); + checkpoint(); + final matches = result['matches']; + if (matches is! bool) { + throw const FormatException('Invalid native upload verification'); + } + return matches; + } on MissingPluginException { + guard.cancel(); + checkCancelled(); + return _matchesUploadInDart(url, file, length, checkpoint); + } catch (_) { + checkCancelled(); + rethrow; + } finally { + guard.cancel(); + } + } + + Future _cancelUploadHash(String requestId) async { + try { + await PlatformBridge.cancelNativeDataJob(requestId); + } catch (_) { + // The worker is still awaited before the caller can release its file. + } + } + + Future _matchesUploadInDart( + String url, + File file, + int length, + void Function() checkpoint, ) async { final client = _client(); try { final request = await client - .getUrl(Uri.parse(await resolve(source))) + .getUrl(Uri.parse(url)) .timeout(NetworkStorageService.timeout); final response = await request.close().timeout( NetworkStorageService.timeout, diff --git a/rust_backend/crates/mobile/src/data_jobs.rs b/rust_backend/crates/mobile/src/data_jobs.rs index 6f0498d2..262bd974 100644 --- a/rust_backend/crates/mobile/src/data_jobs.rs +++ b/rust_backend/crates/mobile/src/data_jobs.rs @@ -53,13 +53,33 @@ fn run( fn execute( request: &Value, - _bytes: &[u8], + bytes: &[u8], check: &(dyn Fn() -> Result<(), String> + Sync), _manager: Option<&ExtensionManager>, ) -> Result { let operation = string(request, "operation")?; match operation { "hash_file" => Ok(json!({"sha256": hash_file(string(request, "path")?, check)?})), + "matches_network_upload" => crate::network_tags::matches_upload(request, check), + "network_metadata" => { + let metadata = crate::network_tags::read_to_cache( + string(request, "url")?, + string(request, "display_name")?, + string(request, "cache_directory")?, + check, + )?; + let cacheable = serde_json::to_vec(&metadata) + .map_err(|error| error.to_string())? + .len() + <= 128 * 1024; + Ok(json!({"metadata": metadata, "cacheable": cacheable})) + } + "promote_network_cover" => crate::network_tags::promote_cover( + string(request, "path")?, + string(request, "directory")?, + check, + ), + "parse_network_listing" => crate::native_listing::execute(request, Some(bytes)), _ => Err(format!("Unknown native data operation: {operation}")), } } diff --git a/rust_backend/crates/mobile/src/lib.rs b/rust_backend/crates/mobile/src/lib.rs index 7052f49f..b54c3e4d 100644 --- a/rust_backend/crates/mobile/src/lib.rs +++ b/rust_backend/crates/mobile/src/lib.rs @@ -11,6 +11,8 @@ mod logging; mod lyrics; mod manager; mod metadata; +mod native_listing; +mod native_xml; mod network_tags; mod progress; mod repository; diff --git a/rust_backend/crates/mobile/src/native_listing.rs b/rust_backend/crates/mobile/src/native_listing.rs new file mode 100644 index 00000000..70e3e976 --- /dev/null +++ b/rust_backend/crates/mobile/src/native_listing.rs @@ -0,0 +1,235 @@ +//! Pure directory-listing parser. Transport and credentials remain owned by +//! the platform; only a bounded response body enters this worker operation. +use scraper::{Html, Selector}; +use serde_json::{Value, json}; +use std::collections::HashMap; +use url::Url; + +pub(crate) fn execute(request: &Value, bytes: Option<&[u8]>) -> Result { + if bytes.map_or_else( + || request["body"].as_str().unwrap_or_default().len(), + <[u8]>::len, + ) > 4 * 1024 * 1024 + { + return Err("Directory listing is too large".into()); + } + let body = match bytes { + Some(bytes) => String::from_utf8_lossy(bytes).into_owned(), + None => request["body"].as_str().unwrap_or_default().to_owned(), + }; + let target = Url::parse(required(request, "base_url")?).map_err(|e| e.to_string())?; + let path = required(request, "path")?; + let mut base = target; + if !base.path().ends_with('/') { + base.set_path(&format!("{}/", base.path())); + } + let mut entries = Vec::::new(); + let mut positions = HashMap::::new(); + let mut add = |href: &str, directory: bool, size: Option| -> Result<(), String> { + let uri = base.join(&dart_href(href)).map_err(|e| e.to_string())?; + if uri.origin() != base.origin() + || uri.query().is_some() + || uri.fragment().is_some() + || !uri.username().is_empty() + || uri.password().is_some() + || !uri.path().starts_with(base.path()) + { + return Ok(()); + } + let tail = decode_component(&uri.path()[base.path().len()..])?; + let name = tail.strip_suffix('/').unwrap_or(&tail); + if name.is_empty() + || name.contains('/') + || matches!(name, "." | "..") + || name.contains('\\') + { + return Ok(()); + } + let relative = format!("{path}{name}{}", if directory { "/" } else { "" }); + let entry = json!({"path": relative, "name": name, "directory": directory, "size": size}); + if let Some(index) = positions.get(&relative) { + entries[*index] = entry; + } else { + positions.insert(relative, entries.len()); + entries.push(entry); + } + Ok(()) + }; + if request["protocol"] == "webdav" { + crate::native_xml::check_depth(&body)?; + let document = roxmltree::Document::parse(&body).map_err(|e| e.to_string())?; + for response in document + .descendants() + .filter(|node| named(*node, "response")) + { + let href = response.descendants().find(|node| named(*node, "href")); + let props: Vec<_> = response + .descendants() + .filter(|node| { + named(*node, "propstat") + && node.children().any(|child| { + named(child, "status") && inner_text(child).contains(" 200 ") + }) + }) + .collect(); + if let Some(href) = href.filter(|_| !props.is_empty()) { + let values: Vec<_> = props + .iter() + .flat_map(|node| node.descendants().skip(1)) + .collect(); + let directory = values.iter().any(|node| named(*node, "collection")); + let size = values + .iter() + .find(|node| named(**node, "getcontentlength")) + .and_then(|node| inner_text(*node).trim().parse::().ok()); + add(&inner_text(href), directory, size)?; + } + } + } else { + let document = Html::parse_document(&body); + let selector = Selector::parse("a[href]").map_err(|e| e.to_string())?; + for anchor in document.select(&selector) { + let href = anchor.value().attr("href").unwrap_or_default(); + let path = href.split(['?', '#']).next().unwrap_or_default(); + let directory = path.ends_with('/') || path.ends_with('\\'); + add(href, directory, None)?; + } + } + entries.sort_by(|a, b| { + b["directory"] + .as_bool() + .cmp(&a["directory"].as_bool()) + .then_with(|| { + // Dart String.compareTo orders UTF-16 code units, rather than UTF-8. + dart_lower(a["name"].as_str().unwrap_or_default()) + .encode_utf16() + .cmp(dart_lower(b["name"].as_str().unwrap_or_default()).encode_utf16()) + }) + }); + Ok(json!({"entries": entries})) +} + +fn dart_lower(value: &str) -> String { + // String.toLowerCase uses simple character mappings (İ -> i), without + // Rust's multi-character expansions or contextual final sigma. + value + .chars() + .map(|ch| ch.to_lowercase().next().unwrap_or(ch)) + .collect() +} + +fn required<'a>(request: &'a Value, key: &str) -> Result<&'a str, String> { + request[key] + .as_str() + .ok_or_else(|| format!("Missing listing {key}")) +} + +fn dart_href(href: &str) -> String { + // Uri.resolve preserves edge spaces/controls and quotes invalid '%' as + // a literal. WHATWG URL otherwise trims controls and keeps broken escapes. + let mut result = String::with_capacity(href.len()); + let bytes = href.as_bytes(); + for (index, character) in href.char_indices() { + if character == '%' + && !(bytes.get(index + 1).is_some_and(u8::is_ascii_hexdigit) + && bytes.get(index + 2).is_some_and(u8::is_ascii_hexdigit)) + { + result.push_str("%25"); + } else if character <= ' ' || character == '\u{007f}' { + result.push_str(&format!("%{:02X}", character as u32)); + } else { + result.push(character); + } + } + result +} + +fn named(node: roxmltree::Node<'_, '_>, name: &str) -> bool { + node.is_element() && node.tag_name().name() == name +} + +fn inner_text(node: roxmltree::Node<'_, '_>) -> String { + node.descendants() + .filter(|node| node.is_text()) + .filter_map(|node| node.text()) + .collect() +} + +fn decode_component(value: &str) -> Result { + let mut decoded = Vec::with_capacity(value.len()); + let mut bytes = value.bytes(); + while let Some(byte) = bytes.next() { + if byte == b'%' { + let first = bytes.next().and_then(hex).ok_or("Invalid percent escape")?; + let second = bytes.next().and_then(hex).ok_or("Invalid percent escape")?; + decoded.push(first * 16 + second); + } else { + decoded.push(byte); + } + } + String::from_utf8(decoded).map_err(|e| e.to_string()) +} + +fn hex(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'A'..=b'F' => Some(byte - b'A' + 10), + b'a'..=b'f' => Some(byte - b'a' + 10), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shared_dart_listing_fixtures_match_native_output() { + let cases: Value = serde_json::from_str(include_str!( + "../../../../test/fixtures/native_metadata_parsers.json" + )) + .unwrap(); + for case in cases["listings"].as_array().unwrap() { + let result = execute(case, None).unwrap(); + assert_eq!(result["entries"], case["entries"]); + } + } + + #[test] + fn html_children_entities_unicode_and_origin_are_preserved() { + let result = execute(&json!({"base_url":"https://nas.test/music/", "path":"", "protocol":"http", "body": + "parentfolderaudiomusicnestedotherqueryduplicate"}), None).unwrap(); + assert_eq!(result["entries"].as_array().unwrap().len(), 3); + assert_eq!(result["entries"][0]["path"], "Folder/"); + assert_eq!(result["entries"][1]["name"], "A +#%.flac"); + assert_eq!(result["entries"][2]["name"], "音.flac"); + } + + #[test] + fn dav_only_accepts_successful_properties_and_immediate_children() { + let result = execute(&json!({"base_url":"https://nas.test/dav/Album/", "path":"Album/", "protocol":"webdav", "body": + "/dav/Album/A%20B.flac123HTTP/1.1 200 OK/dav/Album/Missing.flacHTTP/1.1 404 Not Found"}), None).unwrap(); + assert_eq!( + result["entries"], + json!([{"path":"Album/A B.flac", "name":"A B.flac", "directory":false, "size":123}]) + ); + } + + #[test] + fn encoded_slash_and_credentials_do_not_escape_the_root() { + let result = execute(&json!({"base_url":"https://nas.test/music/", "path":"", "protocol":"http", "body": + "nestedcredentialsfragment"}), None).unwrap(); + assert_eq!(result["entries"], json!([])); + } + + #[test] + fn deeply_nested_dav_response_is_rejected_before_xml_tokenization() { + let body = format!( + "{}ignored{}", + "".repeat(1_000), + "".repeat(1_000), + ); + let error = execute(&json!({"base_url":"https://nas.test/dav/", "path":"", "protocol":"webdav", "body":body}), None).unwrap_err(); + assert_eq!(error, "XML nesting exceeds 16 elements"); + } +} diff --git a/rust_backend/crates/mobile/src/native_xml.rs b/rust_backend/crates/mobile/src/native_xml.rs new file mode 100644 index 00000000..f98078e2 --- /dev/null +++ b/rust_backend/crates/mobile/src/native_xml.rs @@ -0,0 +1,109 @@ +//! Bounds for the recursive tokenizer used by our native XML readers. +pub(crate) fn check_depth(input: &str) -> Result<(), String> { + // roxmltree's tokenizer recurses through nested elements. Guard its input + // before parsing so pathological metadata returns an error, not a native + // stack overflow. Ordinary TTML is only a handful of elements deep. + const MAX_DEPTH: usize = 16; + let bytes = input.as_bytes(); + let (mut index, mut depth) = (0, 0_usize); + while index < bytes.len() { + if bytes[index] != b'<' { + index += 1; + continue; + } + let tail = &input[index..]; + let ending = if tail.starts_with("") + } else if tail.starts_with("") + } else if tail.starts_with("") + } else { + None + }; + if let Some(ending) = ending { + let Some(end) = tail.find(ending) else { + return Ok(()); // The XML parser reports unterminated markup. + }; + index += end + ending.len(); + continue; + } + let Some(end) = markup_end(tail, tail.starts_with(" MAX_DEPTH { + return Err(format!("XML nesting exceeds {MAX_DEPTH} elements")); + } + } + index += end; + } + Ok(()) +} + +pub(crate) fn markup_end(input: &str, doctype: bool) -> Option { + let bytes = input.as_bytes(); + let (mut quote, mut brackets, mut position) = (None, 0_usize, 0); + while position < bytes.len() { + let byte = bytes[position]; + if let Some(current) = quote { + if byte == current { + quote = None; + } + } else { + // A DOCTYPE's internal subset may contain comments or processing + // instructions whose brackets and tag-like text have no meaning. + let ending = if doctype && bytes[position..].starts_with(b"".as_slice()) + } else if doctype && bytes[position..].starts_with(b"".as_slice()) + } else { + None + }; + if let Some(ending) = ending { + let end = bytes[position..] + .windows(ending.len()) + .position(|window| window == ending)?; + position += end + ending.len(); + continue; + } + match byte { + b'\'' | b'"' => quote = Some(byte), + b'[' if doctype => brackets += 1, + b']' if doctype => brackets = brackets.saturating_sub(1), + b'>' if brackets == 0 => return Some(position + 1), + _ => {} + } + } + position += 1; + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bounds_actual_element_nesting_and_ignores_self_closing_nodes() { + let maximum = format!("{}{}", "".repeat(16), "".repeat(16)); + check_depth(&maximum).unwrap(); + assert!(check_depth(&format!("{maximum}")).is_err()); + check_depth(&format!("{}", "".repeat(10_000))).unwrap(); + } + + #[test] + fn quoted_attributes_and_opaque_sections_are_not_element_depth() { + let markup = "".repeat(1_000); + check_depth(&format!( + " {markup} --> ?> ]>\"/>" + )).unwrap(); + } +} diff --git a/rust_backend/crates/mobile/src/network_tags.rs b/rust_backend/crates/mobile/src/network_tags.rs index 67b62e5f..62558e32 100644 --- a/rust_backend/crates/mobile/src/network_tags.rs +++ b/rust_backend/crates/mobile/src/network_tags.rs @@ -1,14 +1,17 @@ //! Bounded, seekable metadata reads through the app's authenticated loopback //! proxy. The native reader never receives NAS credentials or external URLs. use base64::Engine; -use serde_json::Value; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; use spotiflac_core::tags; use spotiflac_network::{ HttpRequest, NetworkService, NetworkSession, policy::NetworkPermissions, url::UrlParts, }; use std::{ collections::{BTreeMap, VecDeque}, - io::{self, Read, Seek, SeekFrom}, + fs::{self, File}, + io::{self, Read, Seek, SeekFrom, Write}, + path::Path, sync::{Arc, OnceLock}, time::{Duration, Instant}, }; @@ -18,10 +21,142 @@ const MAX_BYTES: usize = 16 * 1024 * 1024; const MAX_REQUESTS: usize = 80; const DEADLINE: Duration = Duration::from_secs(20); +fn proxy_session(url: &str, timeout: Duration) -> Result { + let parsed = UrlParts::parse(url).ok_or("invalid network proxy URL")?; + let token = parsed.path.strip_prefix(b"/").unwrap_or_default(); + if parsed.scheme != "http" + || parsed.hostname != "127.0.0.1" + || parsed.port.is_none() + || parsed.has_credentials + || !parsed.raw_query.is_empty() + || !parsed.fragment.is_empty() + || token.len() != 48 + || !token.iter().all(u8::is_ascii_hexdigit) + { + return Err("URL must reference the local network proxy".into()); + } + static NETWORK: OnceLock, String>> = OnceLock::new(); + let service = NETWORK + .get_or_init(|| { + let service = NetworkService::new().map_err(|e| e.to_string())?; + service.set_allow_private_network(true); + Ok(service) + }) + .as_ref() + .map_err(Clone::clone)?; + Ok(service + .session( + NetworkPermissions { + domains: vec!["127.0.0.1".into()], + allow_http: true, + }, + timeout, + ) + .direct_media()) +} + +/// Reconcile a lost NAS rename response with streamed bytes. Credentials and +/// pins remain inside the app's existing proxy; neither file crosses Flutter. +pub(crate) fn matches_upload( + request: &Value, + check: &dyn Fn() -> Result<(), String>, +) -> Result { + check()?; + let url = crate::data_jobs::string(request, "url")?; + let path = crate::data_jobs::string(request, "path")?; + let length = request["length"].as_u64().ok_or("Missing upload length")?; + let idle = request["idle_timeout_ms"] + .as_u64() + .filter(|ms| *ms > 0 && *ms <= 300_000) + .ok_or("Invalid upload idle timeout")?; + let metadata = fs::metadata(path).map_err(|error| error.to_string())?; + if !metadata.is_file() || metadata.len() != length { + return Ok(json!({"matches": false})); + } + let idle = Duration::from_millis(idle); + let session = proxy_session(url, idle)?; + let mut stream = session.open_stream( + HttpRequest { + url: url.into(), + method: "GET".into(), + body: String::new(), + headers: BTreeMap::from([("Accept-Encoding".into(), "identity".into())]), + default_json: false, + user_agent: "SpotiFLAC/NetworkUploadRecovery".into(), + }, + Duration::from_secs(u32::MAX.into()), + idle, + check, + )?; + let header = |name: &str| { + stream + .response + .headers + .iter() + .find(|(key, _)| key.eq_ignore_ascii_case(name)) + .and_then(|(_, values)| values.first()) + .map(String::as_str) + }; + if stream.response.status != 200 + || header("content-length") + .and_then(|value| value.parse::().ok()) + .is_some_and(|value| value != length) + || header("content-encoding").is_some_and(|value| !value.eq_ignore_ascii_case("identity")) + { + return Ok(json!({"matches": false})); + } + let mut hash = Sha256::new(); + let mut buffer = [0; 64 * 1024]; + let mut received = 0_u64; + loop { + check()?; + let count = stream.read(&mut buffer, check)?; + if count == 0 { + break; + } + received = received + .checked_add(count as u64) + .ok_or("Upload length overflow")?; + if received > length { + return Err("Network destination changed".into()); + } + hash.update(&buffer[..count]); + } + if received != length { + return Ok(json!({"matches": false})); + } + let remote: String = hash + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + let local = crate::data_jobs::hash_file(path, check)?; + check()?; + Ok(json!({"matches": remote == local})) +} + pub(crate) fn read( url: &str, hint: &str, check: &dyn Fn() -> Result<(), String>, +) -> Result { + read_internal(url, hint, None, check) +} + +pub(crate) fn read_to_cache( + url: &str, + hint: &str, + cache_directory: &str, + check: &dyn Fn() -> Result<(), String>, +) -> Result { + read_internal(url, hint, Some(Path::new(cache_directory)), check) +} + +fn read_internal( + url: &str, + hint: &str, + cache_directory: Option<&Path>, + check: &dyn Fn() -> Result<(), String>, ) -> Result { let mut reader = RangeReader::new(url, check)?; let mut metadata = tags::read_file_metadata(&mut reader, hint, hint, check)?; @@ -30,13 +165,150 @@ pub(crate) fn read( && !cover.data.is_empty() && cover.data.len() <= 4 * 1024 * 1024 { - metadata["cover_base64"] = base64::engine::general_purpose::STANDARD - .encode(cover.data) - .into(); + if let Some(directory) = cache_directory { + // Artwork I/O failures preserve successfully parsed tags/lyrics. + if let Ok((path, digest)) = cache_cover(&cover.data, directory, check) { + metadata["cover_path"] = path.into(); + metadata["cover_sha256"] = digest.into(); + } + } else { + metadata["cover_base64"] = base64::engine::general_purpose::STANDARD + .encode(cover.data) + .into(); + } } + check()?; Ok(metadata) } +fn cache_cover( + bytes: &[u8], + directory: &Path, + check: &dyn Fn() -> Result<(), String>, +) -> Result<(String, String), String> { + check()?; + if !directory.is_absolute() { + return Err("Artwork cache must be absolute".into()); + } + fs::create_dir_all(directory).map_err(|error| error.to_string())?; + let digest: String = Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + let target = directory.join(format!("network_cover_{digest}.image")); + let mut staged = + tempfile::NamedTempFile::new_in(directory).map_err(|error| error.to_string())?; + staged.write_all(bytes).map_err(|error| error.to_string())?; + staged + .as_file() + .sync_all() + .map_err(|error| error.to_string())?; + check()?; + staged.persist(&target).map_err(|error| error.to_string())?; + let mut files = fs::read_dir(directory) + .map_err(|error| error.to_string())? + .filter_map(Result::ok) + .filter_map(|entry| { + let name = entry.file_name(); + let name = name.to_string_lossy(); + let legacy = name.strip_suffix(".image").is_some_and(|digest| { + digest.len() == 64 && digest.bytes().all(|byte| byte.is_ascii_hexdigit()) + }); + if cover_digest(&name).is_none() && !legacy { + return None; + } + let metadata = entry.metadata().ok()?; + if !metadata.is_file() { + return None; + } + Some((entry.path(), metadata.modified().ok()?)) + }) + .collect::>(); + files.sort_by_key(|(_, time)| std::cmp::Reverse(*time)); + for (old, _) in files.into_iter().skip(32) { + if old != target { + let _ = fs::remove_file(old); + } + } + Ok((target.to_string_lossy().into_owned(), digest)) +} + +fn cover_digest(name: &str) -> Option<&str> { + let digest = name + .strip_prefix("network_cover_")? + .strip_suffix(".image")?; + (digest.len() == 64 && digest.bytes().all(|byte| byte.is_ascii_hexdigit())).then_some(digest) +} + +pub(crate) fn promote_cover( + path: &str, + directory: &str, + check: &dyn Fn() -> Result<(), String>, +) -> Result { + check()?; + let source = Path::new(path); + let destination = Path::new(directory); + if !source.is_absolute() || !destination.is_absolute() { + return Err("Artwork paths must be absolute".into()); + } + if !source + .metadata() + .is_ok_and(|metadata| metadata.is_file() && metadata.len() > 0) + { + return Err("Artwork input must be a nonempty regular file".into()); + } + // Only our immutable content-addressed cache skips a second hash. Older + // cache filenames use the same streamed hash as the previous Dart caller. + let digest = source + .parent() + .filter(|parent| { + parent + .file_name() + .is_some_and(|name| name == "network_metadata") + }) + .and_then(|_| source.file_name()) + .and_then(|name| name.to_str()) + .and_then(cover_digest) + .map(str::to_owned) + .map(Ok) + .unwrap_or_else(|| crate::data_jobs::hash_file(path, check))?; + fs::create_dir_all(destination).map_err(|error| error.to_string())?; + let target = destination.join(format!("network_cover_{digest}.image")); + if !target + .metadata() + .is_ok_and(|metadata| metadata.is_file() && metadata.len() > 0) + { + let mut input = File::open(source).map_err(|error| error.to_string())?; + if !input + .metadata() + .map_err(|error| error.to_string())? + .is_file() + { + return Err("Artwork input must be a regular file".into()); + } + let mut staged = + tempfile::NamedTempFile::new_in(destination).map_err(|error| error.to_string())?; + let mut buffer = [0; 64 * 1024]; + loop { + check()?; + let count = input.read(&mut buffer).map_err(|error| error.to_string())?; + if count == 0 { + break; + } + staged + .write_all(&buffer[..count]) + .map_err(|error| error.to_string())?; + } + staged + .as_file() + .sync_all() + .map_err(|error| error.to_string())?; + check()?; + staged.persist(&target).map_err(|error| error.to_string())?; + } + Ok(json!({"cover_path": target.to_string_lossy(), "sha256": digest})) +} + struct RangeReader<'a> { url: String, session: NetworkSession, @@ -51,37 +323,7 @@ struct RangeReader<'a> { impl<'a> RangeReader<'a> { fn new(url: &str, check: &'a dyn Fn() -> Result<(), String>) -> Result { - let parsed = UrlParts::parse(url).ok_or("invalid metadata URL")?; - let token = parsed.path.strip_prefix(b"/").unwrap_or_default(); - if parsed.scheme != "http" - || parsed.hostname != "127.0.0.1" - || parsed.port.is_none() - || parsed.has_credentials - || !parsed.raw_query.is_empty() - || !parsed.fragment.is_empty() - || token.len() != 48 - || !token.iter().all(u8::is_ascii_hexdigit) - { - return Err("metadata URL must reference the local network proxy".into()); - } - static NETWORK: OnceLock, String>> = OnceLock::new(); - let service = NETWORK - .get_or_init(|| { - let service = NetworkService::new().map_err(|e| e.to_string())?; - service.set_allow_private_network(true); - Ok(service) - }) - .as_ref() - .map_err(Clone::clone)?; - let session = service - .session( - NetworkPermissions { - domains: vec!["127.0.0.1".into()], - allow_http: true, - }, - DEADLINE, - ) - .direct_media(); + let session = proxy_session(url, DEADLINE)?; let mut reader = Self { url: url.into(), session, @@ -279,9 +521,14 @@ mod tests { let mut reader = BufReader::new(socket.try_clone().unwrap()); let mut line = String::new(); let mut range = (0, data.len() - 1); + let mut headers_complete = false; loop { line.clear(); - if reader.read_line(&mut line).unwrap_or(0) == 0 || line == "\r\n" { + if reader.read_line(&mut line).unwrap_or(0) == 0 { + break; + } + if line == "\r\n" { + headers_complete = true; break; } if let Some(value) = line.to_lowercase().strip_prefix("range: bytes=") { @@ -292,6 +539,12 @@ mod tests { ); } } + // A connector may abandon an idle socket before sending a + // request. Sending a response after EOF/timeout produces an + // unsolicited HTTP message and a spurious client failure. + if !headers_complete { + continue; + } if !ranges { range = (0, data.len() - 1); } @@ -339,6 +592,84 @@ mod tests { .concat() } #[test] + fn fixture_does_not_respond_to_abandoned_connections() { + let fixture = Fixture::new(b"abc".to_vec(), false); + let address = fixture + .url + .strip_prefix("http://") + .unwrap() + .split('/') + .next() + .unwrap(); + let mut socket = std::net::TcpStream::connect(address).unwrap(); + socket + .set_read_timeout(Some(Duration::from_secs(3))) + .unwrap(); + assert_eq!(socket.read(&mut [0; 64]).unwrap(), 0); + assert_eq!(fixture.bytes.load(Ordering::Relaxed), 0); + let mut local = tempfile::NamedTempFile::new().unwrap(); + local.write_all(b"abc").unwrap(); + assert_eq!( + matches_upload( + &json!({"url": fixture.url, + "path": local.path(), "length": 3, "idle_timeout_ms": 20000}), + &|| Ok(()) + ) + .unwrap()["matches"], + true + ); + } + #[test] + fn cache_artwork_is_atomic_content_addressed_and_bounded() { + let root = tempfile::tempdir().unwrap(); + let cache = root.path().join("network_metadata"); + fs::create_dir(&cache).unwrap(); + // Legacy source-addressed entries participate in the same 32-file cap. + for value in 0..35 { + fs::write(cache.join(format!("{value:064x}.image")), [value]).unwrap(); + } + fs::write(cache.join("unrelated.txt"), b"keep").unwrap(); + let (path, digest) = cache_cover(b"cover bytes", &cache, &|| Ok(())).unwrap(); + assert_eq!(fs::read(&path).unwrap(), b"cover bytes"); + assert_eq!( + digest, + crate::data_jobs::hash_file(&path, &|| Ok(())).unwrap() + ); + let again = cache_cover(b"cover bytes", &cache, &|| Ok(())).unwrap(); + assert_eq!(again.0, path); + assert_eq!(fs::read_dir(&cache).unwrap().count(), 33); + let covers = root.path().join("library_covers"); + let promoted = promote_cover(&path, covers.to_str().unwrap(), &|| Ok(())).unwrap(); + let target = promoted["cover_path"].as_str().unwrap(); + assert_eq!(fs::read(target).unwrap(), b"cover bytes"); + fs::remove_file(&path).unwrap(); + assert_eq!(fs::read(target).unwrap(), b"cover bytes"); + assert!(cache_cover(b"cancelled", &cache, &|| Err("cancelled".into())).is_err()); + assert_eq!(fs::read_dir(&covers).unwrap().count(), 1); + } + + #[test] + fn streamed_upload_recovery_compares_content_and_checks_cancellation() { + let fixture = Fixture::new(b"abc".to_vec(), false); + let mut local = tempfile::NamedTempFile::new().unwrap(); + local.write_all(b"abc").unwrap(); + let request = + json!({"url":fixture.url,"path":local.path(),"length":3,"idle_timeout_ms":20000}); + assert_eq!( + matches_upload(&request, &|| Ok(())).unwrap()["matches"], + true + ); + fs::write(local.path(), b"abd").unwrap(); + assert_eq!( + matches_upload(&request, &|| Ok(())).unwrap()["matches"], + false + ); + assert!(matches_upload(&request, &|| Err("cancelled".into())).is_err()); + let mut invalid = request.clone(); + invalid["url"] = "https://example.test/private".into(); + assert!(matches_upload(&invalid, &|| Ok(())).is_err()); + } + #[test] fn reads_flac_tags_lyrics_cover_without_audio_download() { let input = [ b"fLaC".as_slice(), diff --git a/test/fixtures/native_metadata_parsers.json b/test/fixtures/native_metadata_parsers.json new file mode 100644 index 00000000..a0816491 --- /dev/null +++ b/test/fixtures/native_metadata_parsers.json @@ -0,0 +1,87 @@ +{ + "listings": [ + { + "protocol": "http", + "address": "https://nas.test/music/", + "base_url": "https://nas.test/music/", + "path": "", + "body": "parentfolderaudiomusicnestedotherqueryduplicate", + "entries": [ + {"path": "Folder/", "name": "Folder", "directory": true, "size": null}, + {"path": "A +#%.flac", "name": "A +#%.flac", "directory": false, "size": null}, + {"path": "音.flac", "name": "音.flac", "directory": false, "size": null} + ] + }, + { + "protocol": "http", + "address": "https://nas.test/music/", + "base_url": "https://nas.test/music/", + "path": "", + "body": "idotted capital iprivate usesupplementary plane", + "entries": [ + {"path": "İA.flac", "name": "İA.flac", "directory": false, "size": null}, + {"path": "ib.flac", "name": "ib.flac", "directory": false, "size": null}, + {"path": "𐀀.flac", "name": "𐀀.flac", "directory": false, "size": null}, + {"path": ".flac", "name": ".flac", "directory": false, "size": null} + ] + }, + { + "protocol": "http", + "address": "https://nas.test/music/", + "base_url": "https://nas.test/music/", + "path": "", + "body": "directoryedge spacesliteral percentnormalized childoutside", + "entries": [ + {"path": "Folder/", "name": "Folder", "directory": true, "size": null}, + {"path": " song.flac ", "name": " song.flac ", "directory": false, "size": null}, + {"path": "bad%zz.flac", "name": "bad%zz.flac", "directory": false, "size": null}, + {"path": "child.flac", "name": "child.flac", "directory": false, "size": null} + ] + }, + { + "protocol": "webdav", + "address": "https://nas.test/dav/", + "base_url": "https://nas.test/dav/Album/", + "path": "Album/", + "body": "/dav/Album/A%20B.flac123HTTP/1.1 200 OK/dav/Album/Missing.flacHTTP/1.1 404 Not Found", + "entries": [ + {"path": "Album/A B.flac", "name": "A B.flac", "directory": false, "size": 123} + ] + }, + { + "protocol": "http", + "address": "https://nas.test/music/", + "base_url": "https://nas.test/music/", + "path": "", + "body": "nestedcredentialsfragment", + "entries": [] + } + ], + "playlists": [ + { + "format": "csv", "id_seed": 42, + "body": "title,artist\nA,B\n\nC,D", + "tracks": [ + {"id": "csv_42_1", "name": "A", "artistName": "B", "albumName": "Unknown Album", "duration": 0, "isrc": null, "coverUrl": null}, + {"id": "csv_42_3", "name": "C", "artistName": "D", "albumName": "Unknown Album", "duration": 0, "isrc": null, "coverUrl": null} + ] + }, + { + "format": "csv", "id_seed": 10, + "body": "\r\nTrack Name,Artist Name(s),Album Name,Track URI,ISRC\r\n\"Song, \"\"One\"\"\",Artist,Album,spotify:track:abc,AAABC1200001\r\nSecond,,Album,,\r\n", + "tracks": [ + {"id": "abc", "name": "Song, \"One\"", "artistName": "Artist", "albumName": "Album", "duration": 0, "isrc": "AAABC1200001", "coverUrl": null}, + {"id": "", "name": "Second", "artistName": "", "albumName": "Album", "duration": 0, "isrc": "", "coverUrl": null} + ] + }, + { + "format": "m3u", "id_seed": 100, + "body": "#EXTM3U\r#EXTINF:180.5,Artist - Song - Mix\r#comment\r\nfolder/a.flac\nMusic/02 Demo.flac\n#EXTINF:-1,\nx/.hidden", + "tracks": [ + {"id": "m3u_100_0", "name": "Song - Mix", "artistName": "Artist", "albumName": "Unknown Album", "duration": 181, "isrc": null, "coverUrl": null}, + {"id": "m3u_100_1", "name": "02 Demo", "artistName": "Unknown Artist", "albumName": "Unknown Album", "duration": 0, "isrc": null, "coverUrl": null}, + {"id": "m3u_100_2", "name": ".hidden", "artistName": "Unknown Artist", "albumName": "Unknown Album", "duration": 0, "isrc": null, "coverUrl": null} + ] + } + ] +} diff --git a/test/native_metadata_parsers_test.dart b/test/native_metadata_parsers_test.dart new file mode 100644 index 00000000..0804f918 --- /dev/null +++ b/test/native_metadata_parsers_test.dart @@ -0,0 +1,130 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/services/network_storage_service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final fixtures = jsonDecode( + File('test/fixtures/native_metadata_parsers.json').readAsStringSync(), + ) as Map; + const channel = MethodChannel('com.zarz.spotiflac/backend'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + for (final (index, raw) in (fixtures['listings'] as List).indexed) { + final fixture = Map.from(raw as Map); + test('listing worker retains synchronous parser contract $index', () async { + final connection = NetworkConnection( + id: 'fixture', + name: 'fixture', + protocol: NetworkProtocol.values.byName(fixture['protocol'] as String), + address: fixture['address'] as String, + ); + final body = fixture['body'] as String; + final path = fixture['path'] as String; + final legacy = NetworkStorageService.parseListing(connection, path, body); + final worker = await NetworkStorageService.parseListingInBackground( + connection, + path, + Uint8List.fromList(utf8.encode(body)), + ); + List> rows(List entries) => entries + .map( + (entry) => { + 'path': entry.path, + 'name': entry.name, + 'directory': entry.directory, + 'size': entry.size, + }, + ) + .toList(growable: false); + expect(rows(legacy), fixture['entries']); + expect(rows(worker), fixture['entries']); + messenger.setMockMethodCallHandler(channel, (call) async { + expect(call.method, 'runNativeDataJob'); + final arguments = call.arguments as Map; + final request = jsonDecode(arguments['request_json'] as String) as Map; + expect(request, { + 'operation': 'parse_network_listing', + 'protocol': fixture['protocol'], + 'base_url': fixture['base_url'], + 'path': path, + }); + expect(arguments['bytes'] as Uint8List, utf8.encode(body)); + return jsonEncode({'entries': fixture['entries']}); + }); + try { + final nativeRows = await NetworkStorageService.parseListingInBackground( + connection, + path, + Uint8List.fromList(utf8.encode(body)), + ); + expect(rows(nativeRows), fixture['entries']); + } finally { + messenger.setMockMethodCallHandler(channel, null); + } + }); + } + + test('deep valid WebDAV uses the isolated compatibility parser', () async { + final connection = NetworkConnection( + id: 'deep-dav', + name: 'deep-dav', + protocol: NetworkProtocol.webdav, + address: 'https://nas.test/dav/', + ); + final body = + '${List.filled(18, '').join()}' + '/dav/Album/Song.flac' + '123' + 'HTTP/1.1 200 OK' + '${List.filled(18, '').join()}'; + final bytes = Uint8List.fromList(utf8.encode(body)); + final legacy = NetworkStorageService.parseListing( + connection, + 'Album/', + body, + ); + expect(legacy.single.name, 'Song.flac'); + var calls = 0; + messenger.setMockMethodCallHandler(channel, (call) async { + expect(call.method, 'runNativeDataJob'); + calls++; + throw PlatformException( + code: 'data_job_error', + message: 'XML nesting exceeds 16 elements', + ); + }); + try { + final actual = await NetworkStorageService.parseListingInBackground( + connection, + 'Album/', + bytes, + ); + expect(actual.single.path, legacy.single.path); + expect(actual.single.name, legacy.single.name); + expect(actual.single.directory, legacy.single.directory); + expect(actual.single.size, legacy.single.size); + expect(calls, 1); + messenger.setMockMethodCallHandler(channel, (_) async { + throw PlatformException( + code: 'data_job_error', + message: 'Invalid listing document', + ); + }); + await expectLater( + NetworkStorageService.parseListingInBackground( + connection, + 'Album/', + bytes, + ), + throwsA(isA()), + ); + } finally { + messenger.setMockMethodCallHandler(channel, null); + } + }); +} diff --git a/test/network_metadata_service_test.dart b/test/network_metadata_service_test.dart index 54c5195b..a7144efa 100644 --- a/test/network_metadata_service_test.dart +++ b/test/network_metadata_service_test.dart @@ -2,11 +2,13 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:spotiflac_android/services/network_storage_service.dart'; import 'package:spotiflac_android/services/network_metadata_service.dart'; void main() { + TestWidgetsFlutterBinding.ensureInitialized(); late NetworkStorageService storage; late Directory directory; setUp(() async { @@ -25,10 +27,55 @@ void main() { directory = await Directory.systemTemp.createTemp('network-tag-test-'); }); tearDown(() async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('com.zarz.spotiflac/backend'), + null, + ); await storage.dispose(); await directory.delete(recursive: true); }); + test( + 'native cache returns a file path and excludes oversized tags from memory', + () async { + var calls = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('com.zarz.spotiflac/backend'), + (call) async { + expect(call.method, 'runNativeDataJob'); + final args = call.arguments as Map; + final request = jsonDecode(args['request_json'] as String) as Map; + expect(request['operation'], 'network_metadata'); + expect(request['url'], startsWith('http://127.0.0.1:')); + expect( + request['cache_directory'], + '${directory.path}/network_metadata', + ); + expect(args['request_id'], isA()); + calls++; + return jsonEncode({ + 'metadata': { + 'title': 'Native tags', + 'cover_path': '/native/cover.image', + }, + 'cacheable': calls != 1, + }); + }, + ); + final reader = NetworkMetadataService( + storage: storage, + cacheDirectory: () async => directory, + ); + final source = NetworkStorageService.source('nas', 'song.flac'); + expect((await reader.read(source))['cover_path'], '/native/cover.image'); + await reader.read(source); + await reader.read(source); + expect(calls, 2); + }, + ); + test( 'playback and Lyrics share one read and embedded cover is cached as a file', () async { diff --git a/test/network_storage_service_test.dart b/test/network_storage_service_test.dart index cd034637..df582157 100644 --- a/test/network_storage_service_test.dart +++ b/test/network_storage_service_test.dart @@ -29,6 +29,11 @@ NetworkConnection connection( ); void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + // Listing now crosses a method channel before the isolated host fallback. + // Keep the in-process transport fixtures on real HTTP clients. + HttpOverrides.global = null; + test('paths retain literal percent, spaces, Unicode, plus and hash', () { final c = connection('https://example.test/Music%20Library/'); const path = '日本語/100% + #1.flac'; diff --git a/test/network_upload_test.dart b/test/network_upload_test.dart index cc4160a9..bc706583 100644 --- a/test/network_upload_test.dart +++ b/test/network_upload_test.dart @@ -1,6 +1,8 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:spotiflac_android/services/network_download_staging.dart'; import 'package:spotiflac_android/services/network_storage_service.dart'; @@ -97,6 +99,10 @@ class _Dav { } void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + // These integration tests use an authenticated in-process WebDAV server. + HttpOverrides.global = null; + const channel = MethodChannel('com.zarz.spotiflac/backend'); late _Dav dav; late NetworkStorageService storage; late NetworkDownloadStaging staging; @@ -126,6 +132,8 @@ void main() { ); }); tearDown(() async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); await storage.dispose(); await dav.server.close(force: true); await temp.delete(recursive: true); @@ -327,6 +335,96 @@ void main() { }, ); + test( + 'native recovery receives only the authenticated proxy and local path', + () async { + final file = await prepare(dir: ''); + dav.loseMoveResponse = true; + await expectLater(publish(), throwsA(anything)); + var nativeHashes = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + final args = Map.from(call.arguments as Map); + final request = jsonDecode(args['request_json'] as String) as Map; + if (request['operation'] != 'matches_network_upload') { + throw MissingPluginException(); + } + final uri = Uri.parse(request['url'] as String); + expect(uri.scheme, 'http'); + expect(uri.host, '127.0.0.1'); + expect(uri.hasPort, true); + expect(uri.path, matches(r'^/[a-f0-9]{48}$')); + expect(jsonEncode(request), isNot(contains('secret'))); + expect(request['length'], await file.length()); + expect( + await File(request['path'] as String).readAsBytes(), + await file.readAsBytes(), + ); + expect(request['idle_timeout_ms'], 20000); + nativeHashes++; + return {'matches': true}; + }); + await publish(); + expect(nativeHashes, 1); + expect(dav.puts, 1); + }, + ); + + test( + 'native recovery cancellation keeps staging until the worker completes', + () async { + final file = await prepare(dir: ''); + dav.loseMoveResponse = true; + await expectLater(publish(), throwsA(anything)); + final started = Completer(); + final cancelled = Completer(); + final worker = Completer>(); + String? requestId; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + final args = Map.from(call.arguments as Map); + if (call.method == 'cancelNativeDataJob') { + expect(args['request_id'], requestId); + cancelled.complete(); + return null; + } + final request = jsonDecode(args['request_json'] as String) as Map; + if (request['operation'] != 'matches_network_upload') { + throw MissingPluginException(); + } + requestId = args['request_id'] as String; + started.complete(); + return worker.future; + }); + var stop = false; + var completed = false; + final originalError = StateError('user cancelled verification'); + final result = publish( + checkpoint: () { + if (stop) throw originalError; + }, + ); + final expectation = expectLater(result, throwsA(same(originalError))); + unawaited( + result.then( + (_) => completed = true, + onError: (Object _) { + completed = true; + }, + ), + ); + await started.future; + stop = true; + await cancelled.future; + expect(completed, false); + expect(await file.exists(), true); + worker.completeError(PlatformException(code: 'cancelled')); + await expectation; + expect(dav.puts, 1); + expect(await file.exists(), true); + }, + ); + test( 'same-length conflicting file is never overwritten or adopted', () async { diff --git a/test/network_webdav_tls_test.dart b/test/network_webdav_tls_test.dart index fc802a0f..7a0d3889 100644 --- a/test/network_webdav_tls_test.dart +++ b/test/network_webdav_tls_test.dart @@ -7,6 +7,10 @@ import 'package:spotiflac_android/services/network_storage_error.dart'; import 'package:spotiflac_android/services/network_storage_service.dart'; void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + // The native listing fallback needs a binding while TLS must use real I/O. + HttpOverrides.global = null; + test( 'self-signed WebDAV requires explicit trust for listing and streaming', () async {