perf(nas): process listings, artwork caches, and upload recovery natively

Move WebDAV/HTML parsing, content-addressed artwork caching, cover promotion, and streamed upload verification to native workers. Preserve proxy authentication, cancellation, cache limits, and deep XML fallback behavior.
This commit is contained in:
zarzet committed 2026-10-06 23:27:07 +07:00
1 parent 867371cb67
commit 927fd033cd
15 files changed
+1349 -98

No files matched your search

+37 -20
View File
@@ -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<String?> _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<String, dynamic> networkLibraryRow(
String source,
NetworkEntry entry,
+76 -34
View File
@@ -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<Directory> 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<Directory> Function() _directory;
final _cache = <String, ({DateTime time, Map<String, dynamic> value})>{};
final _pending = <String, Future<Map<String, dynamic>>>{};
@@ -72,44 +76,41 @@ class NetworkMetadataService {
name = '${name.substring(0, name.length - 5)}.m4a';
}
final url = await _storage.resolve(source);
final metadata = Map<String, dynamic>.from(await _reader(url, name));
Map<String, dynamic>? 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<String, dynamic>.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<File>()
.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<String, dynamic>.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<Map<String, dynamic>> _cacheNetworkMetadata(
Map<String, dynamic> job,
) async {
final metadata = Map<String, dynamic>.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<File>()
.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,
};
}
+66 -5
View File
@@ -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<NetworkEntry> _parseNetworkListingInBackground(
Map<String, dynamic> 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<NetworkEntry> _hydrateNetworkEntriesInBackground(List<dynamic> 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<List<NetworkEntry>> parseListingInBackground(
NetworkConnection connection,
String path,
Uint8List bytes,
) async {
List<dynamic> 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<void> _serve(HttpRequest request) async {
HttpClient? client;
NetworkSmbClient? smb;
+64 -1
View File
@@ -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<void> _cancelUploadHash(String requestId) async {
try {
await PlatformBridge.cancelNativeDataJob(requestId);
} catch (_) {
// The worker is still awaited before the caller can release its file.
}
}
Future<bool> _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,
+21 -1
View File
@@ -53,13 +53,33 @@ fn run(
fn execute(
request: &Value,
_bytes: &[u8],
bytes: &[u8],
check: &(dyn Fn() -> Result<(), String> + Sync),
_manager: Option<&ExtensionManager>,
) -> Result<Value, String> {
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}")),
}
}
+2
View File
@@ -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;
@@ -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<Value, String> {
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::<Value>::new();
let mut positions = HashMap::<String, usize>::new();
let mut add = |href: &str, directory: bool, size: Option<i64>| -> 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::<i64>().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<String, String> {
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<u8> {
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":
"<a href='../'>parent</a><a href='Folder/'>folder</a><a href='A%20%2B%23%25.flac'>audio</a><a href='音.flac'>music</a><a href='nested/song.flac'>nested</a><a href='https://other.test/music/b.flac'>other</a><a href='bad.flac?q=1'>query</a><a href='A%20%2B%23%25.flac'>duplicate</a>"}), 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":
"<d:multistatus xmlns:d='DAV:'><d:response><d:href>/dav/Album/A%20B.flac</d:href><d:propstat><d:prop><d:getcontentlength>123</d:getcontentlength></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response><d:response><d:href>/dav/Album/Missing.flac</d:href><d:propstat><d:prop/><d:status>HTTP/1.1 404 Not Found</d:status></d:propstat></d:response></d:multistatus>"}), 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":
"<a href='a%2fb.flac'>nested</a><a href='https://user@nas.test/music/private.flac'>credentials</a><a href='ok.flac#part'>fragment</a>"}), None).unwrap();
assert_eq!(result["entries"], json!([]));
}
#[test]
fn deeply_nested_dav_response_is_rejected_before_xml_tokenization() {
let body = format!(
"<multistatus>{}ignored{}</multistatus>",
"<wrapper>".repeat(1_000),
"</wrapper>".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");
}
}
@@ -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("<!--") {
Some("-->")
} else if tail.starts_with("<![CDATA[") {
Some("]]>")
} else if tail.starts_with("<?") {
Some("?>")
} 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("<!DOCTYPE")) else {
return Ok(());
};
if tail.starts_with("</") {
depth = depth.saturating_sub(1);
} else if !tail.starts_with("<!")
&& !tail[..end - 1]
.trim_end_matches([' ', '\t', '\r', '\n'])
.ends_with('/')
{
depth += 1;
if depth > MAX_DEPTH {
return Err(format!("XML nesting exceeds {MAX_DEPTH} elements"));
}
}
index += end;
}
Ok(())
}
pub(crate) fn markup_end(input: &str, doctype: bool) -> Option<usize> {
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"<!--") {
Some(b"-->".as_slice())
} else if doctype && bytes[position..].starts_with(b"<?") {
Some(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!("{}{}", "<a>".repeat(16), "</a>".repeat(16));
check_depth(&maximum).unwrap();
assert!(check_depth(&format!("<a>{maximum}</a>")).is_err());
check_depth(&format!("<a>{}</a>", "<b/>".repeat(10_000))).unwrap();
}
#[test]
fn quoted_attributes_and_opaque_sections_are_not_element_depth() {
let markup = "<span>".repeat(1_000);
check_depth(&format!(
"<?xml version='1.0'?><!DOCTYPE a [<!-- ]> {markup} --> <?note ]>?> <!ENTITY b '{markup}'>]><a value='>'><!--{markup}--><![CDATA[{markup}]]><b value=\">\"/></a>"
)).unwrap();
}
}
+368 -37
View File
@@ -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<NetworkSession, String> {
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<Result<Arc<NetworkService>, 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<Value, String> {
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::<u64>().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<Value, String> {
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<Value, String> {
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<Value, String> {
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::<Vec<_>>();
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<Value, String> {
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<Self, String> {
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<Result<Arc<NetworkService>, 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(),
+87
View File
@@ -0,0 +1,87 @@
{
"listings": [
{
"protocol": "http",
"address": "https://nas.test/music/",
"base_url": "https://nas.test/music/",
"path": "",
"body": "<a href='../'>parent</a><a href='Folder/'>folder</a><a href='A%20%2B%23%25.flac'>audio</a><a href='音.flac'>music</a><a href='nested/song.flac'>nested</a><a href='https://other.test/music/b.flac'>other</a><a href='bad.flac?q=1'>query</a><a href='A%20%2B%23%25.flac'>duplicate</a>",
"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": "<a href='ib.flac'>i</a><a href='İA.flac'>dotted capital i</a><a href='%EE%80%80.flac'>private use</a><a href='%F0%90%80%80.flac'>supplementary plane</a>",
"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": "<a href='Folder\\'>directory</a><a href=' song.flac '>edge spaces</a><a href='bad%zz.flac'>literal percent</a><a href='nested/%2E%2E/child.flac'>normalized child</a><a href='%2E%2E/outside.flac'>outside</a>",
"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": "<d:multistatus xmlns:d='DAV:'><d:response><d:href>/dav/Album/A%20B.flac</d:href><d:propstat><d:prop><d:getcontentlength>123</d:getcontentlength></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response><d:response><d:href>/dav/Album/Missing.flac</d:href><d:propstat><d:prop/><d:status>HTTP/1.1 404 Not Found</d:status></d:propstat></d:response></d:multistatus>",
"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": "<a href='a%2fb.flac'>nested</a><a href='https://user@nas.test/music/private.flac'>credentials</a><a href='ok.flac#part'>fragment</a>",
"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}
]
}
]
}
+130
View File
@@ -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<String, dynamic>;
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<String, dynamic>.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<Map<String, dynamic>> rows(List<NetworkEntry> 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 =
'<d:multistatus xmlns:d="DAV:">${List.filled(18, '<d:wrapper>').join()}'
'<d:response><d:href>/dav/Album/Song.flac</d:href>'
'<d:propstat><d:prop><d:getcontentlength>123</d:getcontentlength>'
'</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat>'
'</d:response>${List.filled(18, '</d:wrapper>').join()}</d:multistatus>';
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<PlatformException>()),
);
} finally {
messenger.setMockMethodCallHandler(channel, null);
}
});
}
+47
View File
@@ -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<String>());
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 {
+5
View File
@@ -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';
+98
View File
@@ -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<String, dynamic>.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<void>();
final cancelled = Completer<void>();
final worker = Completer<Map<String, dynamic>>();
String? requestId;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
final args = Map<String, dynamic>.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 {
+4
View File
@@ -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 {