diff --git a/lib/import/import_container.dart b/lib/import/import_container.dart index 8075847b4..234564fdc 100644 --- a/lib/import/import_container.dart +++ b/lib/import/import_container.dart @@ -32,6 +32,7 @@ import 'dart:typed_data'; import 'package:archive/archive.dart'; import 'package:path/path.dart' as p; +import 'package:sqflite/sqflite.dart'; /// What the first bytes of the picked file say it is. enum ImportContainer { @@ -147,6 +148,68 @@ bool noopCsvFirstRecordMatches(String text, {bool truncated = false}) { return false; } +/// True when [path] is a database THIS app wrote: an export, an auto-backup, +/// or a quarantined copy — judged by CONTENT when the name does not say so. +/// +/// The router used to go by name alone (`.db`, `.db.gz`, `.db.unopenable-`). +/// A backup that came back off Drive, a mail attachment or a file manager +/// under any other name is still a SQLite file, so it fell through to +/// [isNoopExport], which claims every SQLite file, and the user restoring +/// their own history was told "That database is not a NOOP backup" (#606). +/// `LocalDb.importFromDbFile` already reads by magic bytes for the same +/// reason; this makes the router agree with it. +/// +/// A name that says it is ours is still believed without opening the file, so +/// a damaged backup reaches the backup importer and gets ITS message. +/// +/// Null means the inspection itself failed (the file would not open or +/// query), which is not the same as "not ours": the caller must not hand it to +/// [isNoopExport], which claims every SQLite file. +Future isEdgeBackup(String path) async { + final lower = path.toLowerCase(); + if (lower.endsWith('.db') || + lower.endsWith('.db.gz') || + lower.contains('.db.unopenable-')) { + return true; + } + // Never throws: this runs while a decrypted backup may be sitting in the + // temp directory, before the caller's cleanup is armed. An unreadable file + // is reported as null, and the caller shows the generic unreadable-file path. + ResolvedNoopDatabase? resolved; + try { + final kind = await sniffFile(path); + if (kind != ImportContainer.sqlite && kind != ImportContainer.gzip) { + return false; + } + // Unwraps the gzip; null when it does not hold a database. + resolved = await resolveNoopDatabase(path); + if (resolved == null) return false; + final src = await openDatabase(resolved.path, + readOnly: true, singleInstance: false); + try { + final tables = { + for (final r in await src + .rawQuery("SELECT name FROM sqlite_master WHERE type = 'table'")) + r['name'], + }; + // `hrSample` is NOOP's spine. Ours is the raw ledger, the baselines and + // a per-day result table TOGETHER: one generic name such as `samples` + // in somebody else's database must not get its rows merged into the + // user's history. + return !tables.contains('hrSample') && + tables.contains('samples') && + tables.contains('baselines') && + (tables.contains('day_result') || tables.contains('derived_day')); + } finally { + await src.close(); + } + } catch (_) { + return null; + } finally { + await resolved?.dispose(); + } +} + /// True when [path] is a NOOP export — judged by CONTENT, not by name. /// /// The onboarding router used to switch on the extension: `.noopbak`/`.zip` diff --git a/lib/ui2/onboarding/welcome.dart b/lib/ui2/onboarding/welcome.dart index e290466d8..c37a90ed7 100644 --- a/lib/ui2/onboarding/welcome.dart +++ b/lib/ui2/onboarding/welcome.dart @@ -411,21 +411,33 @@ Future runImport( // multi-select and this used to return inside the winning branch, so a // backup selected alongside a vendor CSV imported the backup and threw the // CSV away without a word. - final db = [...plain.where(_isDbBackup), ...decrypted]; + final ownDb = {}; // Raw-vs-vendor is decided by what the file HOLDS, not by what it is called. // See [isNoopExport]: routing on the extension sent NOOP's raw-sensor `.csv` // to the vendor importer and WHOOP's `.zip` to the NOOP one — both files // fine, both refused, both with advice for the other file. final raw = []; final csv = []; - for (final p in plain) { - if (_isDbBackup(p)) continue; - (await isNoopExport(p) ? raw : csv).add(p); - } + final db = []; if (decrypted.isNotEmpty) sources.add('Encrypted backup'); - if (plain.any(_isDbBackup)) sources.add('OpenStrap backup'); try { + // Inside the cleanup boundary: a throw while sniffing must not leave a + // decrypted backup in the temp directory. + for (final p in plain) { + final own = await isEdgeBackup(p); + if (own == true) { + ownDb.add(p); + } else if (own == null) { + // Inspection failed: not claimable as ours, and not a NOOP export + // either, so the vendor path's generic message is the honest one. + csv.add(p); + } else { + (await isNoopExport(p) ? raw : csv).add(p); + } + } + if (ownDb.isNotEmpty) sources.add('OpenStrap backup'); + db.addAll([...plain.where(ownDb.contains), ...decrypted]); for (final p in db) { days += await app.importEdgeBackup(p); // The rows are in and the rollup rebuild threw. AppState's own note: @@ -470,7 +482,13 @@ Future runImport( // comes back as `FileSystemException: Failed to decode data using encoding // 'utf-8'`, which no catch below was going to turn into advice. The vendor // path unwraps archives (and gzip) properly, so hand them straight over. - if (await sniffFile(p) != ImportContainer.text) { + // An unreadable file is the vendor path's to report (it is caught below + // and named); throwing here would lose the counts already landed. + ImportContainer? kind; + try { + kind = await sniffFile(p); + } catch (_) {} + if (kind != ImportContainer.text) { vendor.add(p); continue; } @@ -624,19 +642,6 @@ Future decryptToTemp(String path, String passphrase) async { return dest; } -bool _isDbBackup(String path) { - final p = path.toLowerCase(); - // `.db.unopenable-` is what a corrupt-database rebuild quarantines the - // old file as, and the rebuilt card points the user straight at it. Matching - // only the `.db` suffix handed that SQLite file to the vendor-CSV importer. - // `.db.gz` is what the app's own automatic backup writes (auto_backup.dart's - // kBackupExtension). Leaving it out sent a user restoring their own backup - // down the vendor-CSV path, which is the one import that has to work. - return p.endsWith('.db') || - p.endsWith('.db.gz') || - p.contains('.db.unopenable-'); -} - class WelcomeView extends StatelessWidget { final bool busy; final ImportOutcome? outcome; diff --git a/test/import_routing_test.dart b/test/import_routing_test.dart index 69194a107..fae092f97 100644 --- a/test/import_routing_test.dart +++ b/test/import_routing_test.dart @@ -17,6 +17,8 @@ import 'dart:io'; import 'package:archive/archive.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:openstrap_edge/import/import_container.dart'; import 'package:openstrap_edge/state/app_state.dart'; import 'package:openstrap_edge/ui2/onboarding/welcome.dart'; @@ -28,6 +30,13 @@ class _RoutingSpy extends AppState { final noop = []; final vendor = []; + final backup = []; + + @override + Future importEdgeBackup(String path) async { + backup.add(path); + return 1; + } @override Future importNoopCsv(String path, @@ -146,6 +155,112 @@ void main() { expect(app.vendor, isEmpty); }); + // #606: the app's own backup, renamed on its way through Drive or a file + // manager, was a SQLite file the NOOP path claimed: "That database is not a + // NOOP backup — it has no `hrSample` table". + group('own backup by content', () { + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + Future sqlite(String name, List tables) async { + final path = '${tmp.path}/$name'; + final db = await openDatabase(path, singleInstance: false); + for (final t in tables) { + await db.execute('CREATE TABLE $t (id INTEGER PRIMARY KEY)'); + } + await db.close(); + return path; + } + + const ours = ['samples', 'baselines', 'day_result']; + + test('a renamed backup goes to the backup importer', () async { + final app = _RoutingSpy(); + final path = await sqlite('openstrap-backup (1)', ours); + + final out = await runImport(app, [path]); + + expect(app.backup, [path]); + expect(app.noop, isEmpty, reason: 'this is the #606 refusal'); + expect(out.source, contains('OpenStrap backup')); + }); + + test('a renamed gzipped auto-backup goes to the backup importer', + () async { + final app = _RoutingSpy(); + final plain = await sqlite('auto', ['samples', 'baselines', 'derived_day']); + final path = await write( + 'backup-2026-10-18.gz', gzip.encode(File(plain).readAsBytesSync())); + + await runImport(app, [path]); + + expect(app.backup, [path]); + expect(app.noop, isEmpty); + expect(app.vendor, isEmpty); + }); + + test('a bare NOOP database still goes to the NOOP importer', () async { + final app = _RoutingSpy(); + final path = await sqlite('noop-backup.sqlite', ['hrSample', ...ours]); + + await runImport(app, [path]); + + expect(app.noop, [path]); + expect(app.backup, isEmpty); + }); + + test('one generic table name does not make a database ours', () async { + final app = _RoutingSpy(); + final path = await sqlite('someone-elses.sqlite', ['samples']); + + await runImport(app, [path]); + + expect(app.backup, isEmpty, + reason: 'its rows would be merged into the user\'s history'); + }); + + test('a file that vanished reports a failed inspection, not a throw', + () async { + expect(await isEdgeBackup('${tmp.path}/gone.sqlite'), isNull); + }); + + test('a database that will not open is not sent to the NOOP importer', + () async { + final app = _RoutingSpy(); + final path = await write('broken.sqlite', + [...utf8.encode('SQLite format 3\x00'), ...List.filled(200, 7)]); + + await runImport(app, [path]); + + expect(app.noop, isEmpty, + reason: 'the NOOP check claims every SQLite file'); + expect(app.vendor, [path]); + }); + + test('a vanished file after a backup does not lose the backup counts', + () async { + final app = _RoutingSpy(); + final good = await write('mine.db', utf8.encode('x')); + + final out = await runImport(app, [good, '${tmp.path}/gone.bin']); + + expect(app.backup, [good]); + expect(out.days, greaterThan(0)); + }); + + test('a file named .db is still ours without being opened', () async { + final app = _RoutingSpy(); + final path = await write('damaged.db', utf8.encode('not a database')); + + await runImport(app, [path]); + + expect(app.backup, [path], + reason: 'the backup importer owns the message for a damaged backup'); + }); + }); + test('a journal export re-saved as cp1252 is named, not handed to the ' 'vendor importer', () async { final app = _RoutingSpy();