feat: content downloader with sha256 verify and atomic cache
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,97 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
import 'manifest.dart';
|
||||||
|
|
||||||
|
/// Pulls the remote season manifest and downloads new/updated packs into
|
||||||
|
/// [cacheDir] with SHA256 verification and atomic (temp + rename) writes.
|
||||||
|
/// All failures are swallowed into a `false` return — remote content is an
|
||||||
|
/// enhancement, never a crash source.
|
||||||
|
class ContentDownloader {
|
||||||
|
ContentDownloader({
|
||||||
|
required this.baseUrl,
|
||||||
|
required this.cacheDir,
|
||||||
|
http.Client? client,
|
||||||
|
}) : _client = client ?? http.Client() {
|
||||||
|
_loadIndex();
|
||||||
|
}
|
||||||
|
|
||||||
|
final String baseUrl;
|
||||||
|
final Directory cacheDir;
|
||||||
|
final http.Client _client;
|
||||||
|
final Map<String, int> _index = {};
|
||||||
|
|
||||||
|
File get _indexFile => File('${cacheDir.path}/cache_index.json');
|
||||||
|
|
||||||
|
void _loadIndex() {
|
||||||
|
try {
|
||||||
|
if (_indexFile.existsSync()) {
|
||||||
|
final json =
|
||||||
|
jsonDecode(_indexFile.readAsStringSync()) as Map<String, dynamic>;
|
||||||
|
json.forEach((k, v) => _index[k] = v as int);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Corrupt index: treat as empty; packs will re-download.
|
||||||
|
_index.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveIndex() async {
|
||||||
|
await cacheDir.create(recursive: true);
|
||||||
|
await _indexFile.writeAsString(jsonEncode(_index));
|
||||||
|
}
|
||||||
|
|
||||||
|
int? cachedVersion(String seasonId) => _index[seasonId];
|
||||||
|
|
||||||
|
/// Checks the manifest and downloads anything new. Returns true when at
|
||||||
|
/// least one pack was added or updated.
|
||||||
|
Future<bool> sync() async {
|
||||||
|
final RemoteManifest manifest;
|
||||||
|
try {
|
||||||
|
final res = await _client
|
||||||
|
.get(Uri.parse('$baseUrl/manifest.json'))
|
||||||
|
.timeout(const Duration(seconds: 10));
|
||||||
|
if (res.statusCode != 200) return false;
|
||||||
|
manifest = RemoteManifest.fromJson(
|
||||||
|
jsonDecode(res.body) as Map<String, dynamic>);
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var changed = false;
|
||||||
|
for (final season in manifest.seasons) {
|
||||||
|
if (_index[season.seasonId] == season.version) continue;
|
||||||
|
if (await _downloadPack(season)) changed = true;
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _downloadPack(ManifestSeason season) async {
|
||||||
|
try {
|
||||||
|
final res = await _client
|
||||||
|
.get(Uri.parse('$baseUrl/${season.packUrl}'))
|
||||||
|
.timeout(const Duration(seconds: 30));
|
||||||
|
if (res.statusCode != 200) return false;
|
||||||
|
|
||||||
|
// Verify in memory before any file is touched, so a bad pack can
|
||||||
|
// never leave artifacts on disk.
|
||||||
|
final digest = sha256.convert(res.bodyBytes).toString();
|
||||||
|
if (digest != season.sha256) return false;
|
||||||
|
|
||||||
|
final dir = Directory('${cacheDir.path}/seasons/${season.seasonId}');
|
||||||
|
await dir.create(recursive: true);
|
||||||
|
final tmp = File('${dir.path}/pack.json.tmp');
|
||||||
|
await tmp.writeAsBytes(res.bodyBytes, flush: true);
|
||||||
|
await tmp.rename('${dir.path}/pack.json');
|
||||||
|
|
||||||
|
_index[season.seasonId] = season.version;
|
||||||
|
await _saveIndex();
|
||||||
|
return true;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:block_seasons/data/remote/content_downloader.dart';
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:http/testing.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late Directory tmp;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
tmp = await Directory.systemTemp.createTemp('bs_cache_');
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
if (await tmp.exists()) await tmp.delete(recursive: true);
|
||||||
|
});
|
||||||
|
|
||||||
|
String shaOf(String body) => sha256.convert(utf8.encode(body)).toString();
|
||||||
|
|
||||||
|
// Minimal pack body (repository parses later; downloader only verifies
|
||||||
|
// bytes).
|
||||||
|
const packBody = '{"schemaVersion":1,"seasonId":"season_002"}';
|
||||||
|
|
||||||
|
Map<String, dynamic> manifestJson({String? sha, int version = 1}) => {
|
||||||
|
'schemaVersion': 1,
|
||||||
|
'minAppBuild': 1,
|
||||||
|
'current': 'season_002',
|
||||||
|
'seasons': [
|
||||||
|
{
|
||||||
|
'seasonId': 'season_002',
|
||||||
|
'version': version,
|
||||||
|
'packUrl': 'seasons/season_002/pack.json',
|
||||||
|
'sha256': sha ?? shaOf(packBody),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
MockClient okClient({String? sha, int version = 1}) =>
|
||||||
|
MockClient((request) async {
|
||||||
|
if (request.url.path.endsWith('manifest.json')) {
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode(manifestJson(sha: sha, version: version)),
|
||||||
|
200,
|
||||||
|
headers: {'content-type': 'application/json'},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (request.url.path.endsWith('pack.json')) {
|
||||||
|
return http.Response(packBody, 200);
|
||||||
|
}
|
||||||
|
return http.Response('not found', 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('downloads, verifies, and caches a new season', () async {
|
||||||
|
final downloader = ContentDownloader(
|
||||||
|
baseUrl: 'https://example.com/content',
|
||||||
|
cacheDir: tmp,
|
||||||
|
client: okClient(),
|
||||||
|
);
|
||||||
|
final updated = await downloader.sync();
|
||||||
|
expect(updated, isTrue);
|
||||||
|
final cached = File('${tmp.path}/seasons/season_002/pack.json');
|
||||||
|
expect(await cached.readAsString(), packBody);
|
||||||
|
expect(downloader.cachedVersion('season_002'), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('second sync with same version is a no-op', () async {
|
||||||
|
final downloader = ContentDownloader(
|
||||||
|
baseUrl: 'https://example.com/content',
|
||||||
|
cacheDir: tmp,
|
||||||
|
client: okClient(),
|
||||||
|
);
|
||||||
|
await downloader.sync();
|
||||||
|
expect(await downloader.sync(), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('checksum mismatch rejects the pack and keeps cache clean', () async {
|
||||||
|
final downloader = ContentDownloader(
|
||||||
|
baseUrl: 'https://example.com/content',
|
||||||
|
cacheDir: tmp,
|
||||||
|
client: okClient(sha: 'deadbeef'),
|
||||||
|
);
|
||||||
|
final updated = await downloader.sync();
|
||||||
|
expect(updated, isFalse);
|
||||||
|
expect(
|
||||||
|
File('${tmp.path}/seasons/season_002/pack.json').existsSync(),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
// No stray temp files left behind.
|
||||||
|
final leftovers = tmp
|
||||||
|
.listSync(recursive: true)
|
||||||
|
.whereType<File>()
|
||||||
|
.where((f) => f.path.endsWith('.tmp'));
|
||||||
|
expect(leftovers, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('network failure leaves existing cache untouched', () async {
|
||||||
|
final good = ContentDownloader(
|
||||||
|
baseUrl: 'https://example.com/content',
|
||||||
|
cacheDir: tmp,
|
||||||
|
client: okClient(),
|
||||||
|
);
|
||||||
|
await good.sync();
|
||||||
|
|
||||||
|
final offline = ContentDownloader(
|
||||||
|
baseUrl: 'https://example.com/content',
|
||||||
|
cacheDir: tmp,
|
||||||
|
client: MockClient((_) async => throw const SocketException('off')),
|
||||||
|
);
|
||||||
|
expect(await offline.sync(), isFalse);
|
||||||
|
expect(
|
||||||
|
File('${tmp.path}/seasons/season_002/pack.json').existsSync(),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('version bump re-downloads', () async {
|
||||||
|
await ContentDownloader(
|
||||||
|
baseUrl: 'https://example.com/content',
|
||||||
|
cacheDir: tmp,
|
||||||
|
client: okClient(version: 1),
|
||||||
|
).sync();
|
||||||
|
final updated = await ContentDownloader(
|
||||||
|
baseUrl: 'https://example.com/content',
|
||||||
|
cacheDir: tmp,
|
||||||
|
client: okClient(version: 2),
|
||||||
|
).sync();
|
||||||
|
expect(updated, isTrue);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user