mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-21 06:52:18 +02:00
Match decoded path components once in Dart and Rust while rejecting encoded separators and paths outside the selected folder. Add folder scan diagnostics, shared parser fixtures, a synthetic 1806-track traversal and native Android regression coverage.
513 lines
18 KiB
Dart
513 lines
18 KiB
Dart
// Host-only native library injection into the SMB worker isolates.
|
|
// ignore_for_file: invalid_use_of_internal_member
|
|
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'dart:typed_data';
|
|
|
|
// Host tests load the pinned native library used by the mobile plugin.
|
|
// ignore: implementation_imports
|
|
import 'package:dart_smb2/src/ffi/native_lib.dart';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:spotiflac_android/services/network_storage_service.dart';
|
|
import 'package:spotiflac_android/services/network_storage_error.dart';
|
|
|
|
NetworkConnection connection(
|
|
String url, {
|
|
NetworkProtocol protocol = NetworkProtocol.http,
|
|
String id = 'server',
|
|
String user = '',
|
|
String password = '',
|
|
}) => NetworkConnection(
|
|
id: id,
|
|
name: 'Music server',
|
|
protocol: protocol,
|
|
address: url,
|
|
username: user,
|
|
password: password,
|
|
);
|
|
|
|
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';
|
|
final uri = networkTarget(c, path);
|
|
expect(uri.pathSegments, ['Music Library', '日本語', '100% + #1.flac']);
|
|
expect(
|
|
Uri.parse(NetworkStorageService.source('server', path)).pathSegments,
|
|
['日本語', '100% + #1.flac'],
|
|
);
|
|
for (final invalid in [
|
|
'../secret',
|
|
'/secret',
|
|
'a/../b',
|
|
'a\\b',
|
|
'a\u0000b',
|
|
]) {
|
|
expect(() => networkTarget(c, invalid), throwsFormatException);
|
|
}
|
|
});
|
|
|
|
test(
|
|
'single HTTP byte ranges include suffix and reject malformed ranges',
|
|
() {
|
|
expect(networkByteRange(null, 10), (start: 0, end: 9));
|
|
expect(networkByteRange('bytes=3-', 10), (start: 3, end: 9));
|
|
expect(networkByteRange('bytes=-4', 10), (start: 6, end: 9));
|
|
expect(networkByteRange('bytes=5-20', 10), (start: 5, end: 9));
|
|
for (final range in [
|
|
'bytes=10-',
|
|
'bytes=-0',
|
|
'bytes=5-2',
|
|
'bytes=0-1,3-4',
|
|
'bytes=-',
|
|
]) {
|
|
expect(() => networkByteRange(range, 10), throwsFormatException);
|
|
}
|
|
},
|
|
);
|
|
|
|
test(
|
|
'HTML indexes are confined to immediate children of the configured root',
|
|
() {
|
|
final result = NetworkStorageService.parseListing(
|
|
connection('https://example.test/music/'),
|
|
'',
|
|
'''
|
|
<a href="../">Parent</a><a href="album/">Album</a>
|
|
<a href="song%20%23%25.flac">Song</a><a href="song%20%23%25.flac">Duplicate</a>
|
|
<a href="https://other.test/music/a.mp3">Other server</a>
|
|
<a href="/outside/a.mp3">Outside root</a><a href="%2e%2e/secret.mp3">Traversal</a>
|
|
<a href="a/b.mp3">Nested</a><a href="?sort=name">Sort</a>
|
|
''',
|
|
);
|
|
expect(result.map((e) => e.path), ['album/', 'song #%.flac']);
|
|
expect(result.first.directory, true);
|
|
},
|
|
);
|
|
|
|
test('WebDAV reads successful properties and encoded child paths only', () {
|
|
final result = NetworkStorageService.parseListing(
|
|
connection('https://example.test/dav/', protocol: NetworkProtocol.webdav),
|
|
'Album/',
|
|
'''
|
|
<d:multistatus xmlns:d="DAV:">
|
|
<d:response><d:href>/dav/Album/</d:href><d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>
|
|
<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>''',
|
|
);
|
|
expect(result.single.path, 'Album/A B.flac');
|
|
expect(result.single.size, 123);
|
|
});
|
|
|
|
test('WebDAV accepts equivalent encoding of parent folder names', () {
|
|
final server = connection(
|
|
'https://example.test/dav/',
|
|
protocol: NetworkProtocol.webdav,
|
|
);
|
|
for (final folder in [
|
|
'Rock & Roll',
|
|
'Artist + Band',
|
|
'Album [Deluxe]',
|
|
"Artist's Album",
|
|
'हिन्दी 音楽',
|
|
]) {
|
|
final href = '/dav/${Uri.encodeComponent(folder)}/A%20%26%20B.flac';
|
|
final entries = NetworkStorageService.parseListing(
|
|
server,
|
|
'$folder/',
|
|
'<d:multistatus xmlns:d="DAV:"><d:response><d:href>$href</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:multistatus>',
|
|
);
|
|
expect(
|
|
entries.map((entry) => entry.path),
|
|
['$folder/A & B.flac'],
|
|
reason:
|
|
'Requested ${networkTarget(server, '$folder/')} but href was $href',
|
|
);
|
|
}
|
|
});
|
|
|
|
test(
|
|
'concurrent saves persist both connections and retain credentials privately',
|
|
() async {
|
|
String? stored;
|
|
final service = NetworkStorageService(
|
|
read: () async => stored,
|
|
write: (s) async {
|
|
stored = s;
|
|
},
|
|
);
|
|
final empty = await service.connections();
|
|
await Future.wait([
|
|
service.save(
|
|
connection(
|
|
'https://example.test/',
|
|
id: 'one',
|
|
user: 'user',
|
|
password: 'secret',
|
|
),
|
|
),
|
|
service.save(
|
|
connection(
|
|
'smb://server/music/',
|
|
id: 'two',
|
|
protocol: NetworkProtocol.smb,
|
|
),
|
|
),
|
|
]);
|
|
final saved = await service.connections();
|
|
expect(empty, isEmpty);
|
|
expect(saved.length, 2);
|
|
expect(await service.connections(), same(saved));
|
|
expect(() => saved.clear(), throwsUnsupportedError);
|
|
final restored = NetworkStorageService(
|
|
read: () async => stored,
|
|
write: (_) async {},
|
|
);
|
|
final loaded = await restored.connections();
|
|
expect(await restored.connections(), same(loaded));
|
|
expect(() => loaded.clear(), throwsUnsupportedError);
|
|
expect((await restored.connection('one')).password, 'secret');
|
|
final source = NetworkStorageService.source('one', 'test.flac');
|
|
expect(source, 'network://one/test.flac');
|
|
await service.remove('one');
|
|
final removed = await service.connections();
|
|
expect(removed.single.id, 'two');
|
|
expect(await service.connections(), same(removed));
|
|
expect(saved.length, 2);
|
|
expect(() => removed.clear(), throwsUnsupportedError);
|
|
},
|
|
);
|
|
|
|
group('real HTTP transport', () {
|
|
late HttpServer server;
|
|
late NetworkStorageService service;
|
|
late String origin;
|
|
final requests = <String>[];
|
|
List<List<int>>? listingChunks;
|
|
setUp(() async {
|
|
requests.clear();
|
|
listingChunks = null;
|
|
server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
|
origin = 'http://127.0.0.1:${server.port}';
|
|
service = NetworkStorageService(
|
|
read: () async => null,
|
|
write: (_) async {},
|
|
);
|
|
server.listen((r) async {
|
|
requests.add('${r.method} ${r.uri.path}');
|
|
if (r.headers.value(HttpHeaders.authorizationHeader) !=
|
|
'Basic ${base64Encode(utf8.encode('test:secret'))}') {
|
|
r.response.statusCode = 401;
|
|
} else if (r.uri.path == '/music') {
|
|
r.response.statusCode = 301;
|
|
r.response.headers.set('location', '/music/');
|
|
} else if (r.uri.path == '/external') {
|
|
r.response.statusCode = 302;
|
|
r.response.headers.set('location', 'https://elsewhere.invalid/');
|
|
} else if (r.uri.path == '/chunks/') {
|
|
try {
|
|
for (final chunk in listingChunks!) {
|
|
r.response.add(chunk);
|
|
await r.response.flush();
|
|
}
|
|
await r.response.close();
|
|
} on SocketException {
|
|
// The client closes the response when it exceeds the size limit.
|
|
} on HttpException {
|
|
// A rejected response can also fail an in-flight flush.
|
|
}
|
|
return;
|
|
} else if (r.method == 'PROPFIND') {
|
|
expect(r.headers.value('Depth'), '1');
|
|
await r.drain<void>();
|
|
r.response.statusCode = 207;
|
|
r.response.write(
|
|
'<d:multistatus xmlns:d="DAV:"><d:response><d:href>/music/A%20B.flac</d:href><d:propstat><d:prop><d:getcontentlength>10</d:getcontentlength></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response></d:multistatus>',
|
|
);
|
|
} else if (r.uri.path.endsWith('/')) {
|
|
r.response.write('<a href="A%20B.flac">Audio</a>');
|
|
} else {
|
|
final part = networkByteRange(r.headers.value('range'), 10);
|
|
r.response.statusCode = r.headers.value('range') == null ? 200 : 206;
|
|
r.response.headers.set('content-type', 'audio/flac');
|
|
r.response.headers.set('accept-ranges', 'bytes');
|
|
r.response.headers.set(
|
|
'content-range',
|
|
'bytes ${part.start}-${part.end}/10',
|
|
);
|
|
r.response.contentLength = part.end - part.start + 1;
|
|
if (r.method != 'HEAD') {
|
|
r.response.add(
|
|
List.generate(10, (i) => i).sublist(part.start, part.end + 1),
|
|
);
|
|
}
|
|
}
|
|
await r.response.close();
|
|
});
|
|
});
|
|
tearDown(() async {
|
|
await service.dispose();
|
|
await server.close(force: true);
|
|
});
|
|
|
|
test('chunked listings preserve Unicode and malformed UTF-8', () async {
|
|
final body = [
|
|
...utf8.encode('<a href="日本語.flac">'),
|
|
0xff,
|
|
...utf8.encode('</a><a href="Album/">Album</a>'),
|
|
];
|
|
// One-byte writes split both three-byte code points and HTML tokens.
|
|
listingChunks = [
|
|
for (final byte in body) [byte],
|
|
];
|
|
final c = connection('$origin/chunks/', user: 'test', password: 'secret');
|
|
final entries = await service.list(c);
|
|
expect(entries.map((entry) => entry.path), ['Album/', '日本語.flac']);
|
|
expect(entries.last.name, '日本語.flac');
|
|
expect(entries.first.directory, true);
|
|
});
|
|
|
|
test(
|
|
'listing size limit accepts 4 MiB and rejects the next byte',
|
|
() async {
|
|
const limit = 4 * 1024 * 1024;
|
|
final body = Uint8List(limit + 1)..fillRange(0, limit + 1, 32);
|
|
final prefix = utf8.encode('<a href="Song.flac">Song</a>');
|
|
body.setRange(0, prefix.length, prefix);
|
|
listingChunks = [
|
|
for (var offset = 0; offset < limit; offset += 64 * 1024)
|
|
Uint8List.sublistView(body, offset, offset + 64 * 1024),
|
|
];
|
|
final c = connection(
|
|
'$origin/chunks/',
|
|
user: 'test',
|
|
password: 'secret',
|
|
);
|
|
expect((await service.list(c)).single.name, 'Song.flac');
|
|
listingChunks = [...listingChunks!, Uint8List.sublistView(body, limit)];
|
|
await expectLater(
|
|
service.list(c),
|
|
throwsA(
|
|
isA<HttpException>().having(
|
|
(error) => error.message,
|
|
'message',
|
|
'Directory listing is too large',
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'authenticated listing, proxy range seek, HEAD, and revocation',
|
|
() async {
|
|
final c = connection(
|
|
'$origin/music/',
|
|
user: 'test',
|
|
password: 'secret',
|
|
);
|
|
await service.save(c);
|
|
expect((await service.list(c)).single.name, 'A B.flac');
|
|
final url = await service.resolve(
|
|
NetworkStorageService.source(c.id, 'A B.flac'),
|
|
);
|
|
expect(url, isNot(contains('secret')));
|
|
final client = HttpClient();
|
|
addTearDown(() => client.close(force: true));
|
|
final request = await client.getUrl(Uri.parse(url));
|
|
request.headers.set('range', 'bytes=4-7');
|
|
final response = await request.close();
|
|
expect(response.statusCode, 206);
|
|
expect(response.headers.value('content-range'), 'bytes 4-7/10');
|
|
expect(await response.expand((e) => e).toList(), [4, 5, 6, 7]);
|
|
final head = await (await client.openUrl(
|
|
'HEAD',
|
|
Uri.parse(url),
|
|
)).close();
|
|
expect(head.contentLength, 10);
|
|
await head.drain<void>();
|
|
await service.remove(c.id);
|
|
final removed = await (await client.getUrl(Uri.parse(url))).close();
|
|
expect(removed.statusCode, 404);
|
|
await removed.drain<void>();
|
|
},
|
|
);
|
|
|
|
test('WebDAV and direct HTTP file validation use real requests', () async {
|
|
final dav = connection(
|
|
'$origin/music/',
|
|
protocol: NetworkProtocol.webdav,
|
|
user: 'test',
|
|
password: 'secret',
|
|
);
|
|
expect((await service.list(dav)).single.size, 10);
|
|
final direct = connection(
|
|
'$origin/song.mp3',
|
|
user: 'test',
|
|
password: 'secret',
|
|
);
|
|
expect((await service.list(direct)).single.path, '');
|
|
expect(requests, contains('GET /song.mp3'));
|
|
await expectLater(
|
|
service.list(connection('$origin/music/')),
|
|
throwsA(isA<HttpException>()),
|
|
);
|
|
});
|
|
|
|
test(
|
|
'same origin redirects work; cross origin redirects cannot leak credentials',
|
|
() async {
|
|
expect(
|
|
(await service.list(
|
|
connection('$origin/music', user: 'test', password: 'secret'),
|
|
)).single.audio,
|
|
true,
|
|
);
|
|
await expectLater(
|
|
service.list(
|
|
connection('$origin/external', user: 'test', password: 'secret'),
|
|
),
|
|
throwsA(isA<HttpException>()),
|
|
);
|
|
},
|
|
);
|
|
});
|
|
|
|
test('SMB server browsing and byte-range streaming', () async {
|
|
debugLibSmb2PathOverride = Platform.environment['TEST_SMB_LIBRARY'];
|
|
final port = Platform.environment['TEST_SMB_PORT'] ?? '1445';
|
|
addTearDown(() => debugLibSmb2PathOverride = null);
|
|
final service = NetworkStorageService(
|
|
read: () async => null,
|
|
write: (_) async {},
|
|
);
|
|
addTearDown(service.dispose);
|
|
final c = connection(
|
|
'smb://127.0.0.1:$port/MUSIC/',
|
|
protocol: NetworkProtocol.smb,
|
|
user: 'test',
|
|
password: 'secret',
|
|
);
|
|
await service.save(c);
|
|
final shares = await service.list(
|
|
connection(
|
|
'smb://127.0.0.1:$port/',
|
|
protocol: NetworkProtocol.smb,
|
|
user: 'test',
|
|
password: 'secret',
|
|
),
|
|
);
|
|
expect(
|
|
shares.any((entry) => entry.name == 'MUSIC' && entry.directory),
|
|
true,
|
|
);
|
|
final entries = await service.list(c);
|
|
expect(entries.any((e) => e.name == 'A B.wav'), true);
|
|
final domainConnection = NetworkConnection(
|
|
id: c.id,
|
|
name: c.name,
|
|
protocol: c.protocol,
|
|
address: c.address,
|
|
username: c.username,
|
|
password: c.password,
|
|
domain: 'WORKGROUP',
|
|
);
|
|
expect(
|
|
(await service.list(domainConnection)).any((e) => e.name == 'A B.wav'),
|
|
true,
|
|
);
|
|
final withoutSlash = connection(
|
|
'smb://127.0.0.1:$port/MUSIC',
|
|
protocol: NetworkProtocol.smb,
|
|
user: 'test',
|
|
password: 'secret',
|
|
);
|
|
expect(
|
|
(await service.list(withoutSlash)).any((e) => e.name == 'A B.wav'),
|
|
true,
|
|
);
|
|
await expectLater(
|
|
service.list(
|
|
connection(
|
|
'smb://127.0.0.1:$port/DOES_NOT_EXIST',
|
|
protocol: NetworkProtocol.smb,
|
|
user: 'test',
|
|
password: 'secret',
|
|
),
|
|
),
|
|
throwsA(
|
|
predicate<Object>(
|
|
(error) =>
|
|
classifyNetworkStorageError(error) ==
|
|
NetworkStorageError.missingShare,
|
|
),
|
|
),
|
|
);
|
|
final url = await service.resolve(
|
|
NetworkStorageService.source(c.id, 'A B.wav'),
|
|
);
|
|
final client = HttpClient();
|
|
addTearDown(() => client.close(force: true));
|
|
final request = await client.getUrl(Uri.parse(url));
|
|
request.headers.set('range', 'bytes=4-7');
|
|
final response = await request.close();
|
|
expect(response.statusCode, 206);
|
|
expect(await response.expand((e) => e).toList(), [4, 5, 6, 7]);
|
|
final head = await client.headUrl(Uri.parse(url));
|
|
final headResponse = await head.close();
|
|
expect(headResponse.contentLength, 256);
|
|
expect(await headResponse.expand((e) => e).toList(), isEmpty);
|
|
final suffix = await client.getUrl(Uri.parse(url));
|
|
suffix.headers.set('range', 'bytes=-3');
|
|
final suffixResponse = await suffix.close();
|
|
expect(await suffixResponse.expand((e) => e).toList(), [253, 254, 255]);
|
|
await expectLater(
|
|
service.list(
|
|
connection(
|
|
'smb://127.0.0.1:$port/MUSIC/',
|
|
protocol: NetworkProtocol.smb,
|
|
user: 'test',
|
|
password: 'incorrect',
|
|
),
|
|
),
|
|
throwsA(
|
|
predicate<Object>(
|
|
(error) =>
|
|
classifyNetworkStorageError(error) ==
|
|
NetworkStorageError.authentication,
|
|
),
|
|
),
|
|
);
|
|
expect((await service.list(c)).any((e) => e.name == 'A B.wav'), true);
|
|
}, skip: Platform.environment['TEST_SMB'] != '1');
|
|
test('SMB guest works when credentials are explicitly blank', () async {
|
|
debugLibSmb2PathOverride = Platform.environment['TEST_SMB_LIBRARY'];
|
|
final port = Platform.environment['TEST_SMB_GUEST_PORT']!;
|
|
final service = NetworkStorageService(
|
|
read: () async => null,
|
|
write: (_) async {},
|
|
);
|
|
try {
|
|
final guest = connection(
|
|
'smb://127.0.0.1:$port/MUSIC/',
|
|
protocol: NetworkProtocol.smb,
|
|
);
|
|
expect((await service.list(guest)).any((e) => e.name == 'A B.wav'), true);
|
|
} finally {
|
|
debugLibSmb2PathOverride = null;
|
|
await service.dispose();
|
|
}
|
|
}, skip: Platform.environment['TEST_SMB_GUEST_PORT'] == null);
|
|
}
|