fix(prefs): replace the desktop preference store atomically
Upstream shared_preferences_windows and _linux write the whole preference document with a bare `writeAsStringSync`. That opens with the default `FileMode.write`, which truncates the live file before writing it, so every single preference write has a window in which the only copy on disk is empty or half-written. A crash, power loss, forced reboot or antivirus interception inside that window leaves a document that fails to parse on every subsequent launch — and the store holds the credential-vault key, so the loss is not recoverable by rewriting it. This is the corruption class behind #1732; the recovery path already landed is a band-aid over it. Vendor both packages under packages/ — the convention saf_util and wakelock_plus already follow — and stage, flush, then rename over the target. The flush has to precede the rename or it could publish contents that were never committed, the same corruption by another route. Staging uses one fixed sibling name rather than a stamped one, because the file is a plaintext copy of the vault key, tracker refresh tokens and Seerr cookies; it is created in the target's own directory so rename stays on one volume and the mode matches what the canonical file would have had, and a stale one is swept once the canonical document has been read cleanly. Both deltas are marked in-source and in provenance.json with the refresh contract. Atomicity is proven, not asserted. A hard link to the store observes the old document after a write, which only holds when the directory entry was replaced — truncate-in-place would have rewritten the shared inode, and that test does fail against unpatched upstream. Upstream's own suites still pass unchanged in both packages and now run in CI, so the patch keeps the contract it inherited. Windows `MoveFileExW` replacement semantics cannot be proven on a POSIX runner or a memory file system, so they get their own test on the existing windows-latest job, including replacement while a reader holds the file open — antivirus and Search Indexer both do.
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
// Copyright 2013 The Flutter Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
|
||||
import 'package:path_provider_windows/path_provider_windows.dart';
|
||||
|
||||
/// Fake implementation of PathProviderWindows that returns hard-coded paths,
|
||||
/// allowing tests to run on any platform.
|
||||
///
|
||||
/// Note that this should only be used with an in-memory filesystem, as the
|
||||
/// path it returns is a root path that does not actually exist on Windows.
|
||||
class FakePathProviderWindows extends PathProviderPlatform
|
||||
implements PathProviderWindows {
|
||||
@override
|
||||
late VersionInfoQuerier versionInfoQuerier;
|
||||
|
||||
@override
|
||||
Future<String?> getApplicationSupportPath() async => r'C:\appsupport';
|
||||
|
||||
@override
|
||||
Future<String?> getTemporaryPath() async => null;
|
||||
|
||||
@override
|
||||
Future<String?> getLibraryPath() async => null;
|
||||
|
||||
@override
|
||||
Future<String?> getApplicationDocumentsPath() async => null;
|
||||
|
||||
@override
|
||||
Future<String?> getDownloadsPath() async => null;
|
||||
|
||||
@override
|
||||
Future<String> getPath(String folderID) async => '';
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
// Copyright 2013 The Flutter Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:file/memory.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:path_provider_windows/path_provider_windows.dart';
|
||||
import 'package:shared_preferences_platform_interface/shared_preferences_platform_interface.dart';
|
||||
import 'package:shared_preferences_platform_interface/types.dart';
|
||||
import 'package:shared_preferences_windows/shared_preferences_windows.dart';
|
||||
|
||||
import 'fake_path_provider_windows.dart';
|
||||
|
||||
void main() {
|
||||
late MemoryFileSystem fs;
|
||||
late PathProviderWindows pathProvider;
|
||||
|
||||
SharedPreferencesWindows.registerWith();
|
||||
|
||||
const Map<String, Object> flutterTestValues = <String, Object>{
|
||||
'flutter.String': 'hello world',
|
||||
'flutter.Bool': true,
|
||||
'flutter.Int': 42,
|
||||
'flutter.Double': 3.14159,
|
||||
'flutter.StringList': <String>['foo', 'bar'],
|
||||
};
|
||||
|
||||
const Map<String, Object> prefixTestValues = <String, Object>{
|
||||
'prefix.String': 'hello world',
|
||||
'prefix.Bool': true,
|
||||
'prefix.Int': 42,
|
||||
'prefix.Double': 3.14159,
|
||||
'prefix.StringList': <String>['foo', 'bar'],
|
||||
};
|
||||
|
||||
const Map<String, Object> nonPrefixTestValues = <String, Object>{
|
||||
'String': 'hello world',
|
||||
'Bool': true,
|
||||
'Int': 42,
|
||||
'Double': 3.14159,
|
||||
'StringList': <String>['foo', 'bar'],
|
||||
};
|
||||
|
||||
final Map<String, Object> allTestValues = <String, Object>{};
|
||||
|
||||
allTestValues.addAll(flutterTestValues);
|
||||
allTestValues.addAll(prefixTestValues);
|
||||
allTestValues.addAll(nonPrefixTestValues);
|
||||
|
||||
setUp(() {
|
||||
fs = MemoryFileSystem.test();
|
||||
pathProvider = FakePathProviderWindows();
|
||||
});
|
||||
|
||||
Future<String> getFilePath() async {
|
||||
final String? directory = await pathProvider.getApplicationSupportPath();
|
||||
return path.join(directory!, 'shared_preferences.json');
|
||||
}
|
||||
|
||||
Future<void> writeTestFile(String value) async {
|
||||
fs.file(await getFilePath())
|
||||
..createSync(recursive: true)
|
||||
..writeAsStringSync(value);
|
||||
}
|
||||
|
||||
Future<String> readTestFile() async {
|
||||
return fs.file(await getFilePath()).readAsStringSync();
|
||||
}
|
||||
|
||||
SharedPreferencesWindows getPreferences() {
|
||||
final SharedPreferencesWindows prefs = SharedPreferencesWindows();
|
||||
prefs.fs = fs;
|
||||
prefs.pathProvider = pathProvider;
|
||||
return prefs;
|
||||
}
|
||||
|
||||
test('registered instance', () async {
|
||||
SharedPreferencesWindows.registerWith();
|
||||
expect(SharedPreferencesStorePlatform.instance,
|
||||
isA<SharedPreferencesWindows>());
|
||||
});
|
||||
|
||||
test('getAll', () async {
|
||||
await writeTestFile(json.encode(allTestValues));
|
||||
final SharedPreferencesWindows prefs = getPreferences();
|
||||
|
||||
final Map<String, Object> values = await prefs.getAll();
|
||||
expect(values, hasLength(5));
|
||||
expect(values, flutterTestValues);
|
||||
});
|
||||
|
||||
test('getAllWithPrefix', () async {
|
||||
await writeTestFile(json.encode(allTestValues));
|
||||
final SharedPreferencesWindows prefs = getPreferences();
|
||||
|
||||
final Map<String, Object> values = await prefs.getAllWithPrefix('prefix.');
|
||||
expect(values, hasLength(5));
|
||||
expect(values, prefixTestValues);
|
||||
});
|
||||
|
||||
test('getAllWithParameters with Prefix', () async {
|
||||
await writeTestFile(json.encode(allTestValues));
|
||||
final SharedPreferencesWindows prefs = getPreferences();
|
||||
|
||||
final Map<String, Object> values = await prefs.getAllWithParameters(
|
||||
GetAllParameters(
|
||||
filter: PreferencesFilter(prefix: 'prefix.'),
|
||||
),
|
||||
);
|
||||
expect(values, hasLength(5));
|
||||
expect(values, prefixTestValues);
|
||||
});
|
||||
|
||||
test('getAllWithParameters with Prefix with allow list', () async {
|
||||
await writeTestFile(json.encode(allTestValues));
|
||||
final SharedPreferencesWindows prefs = getPreferences();
|
||||
|
||||
final Map<String?, Object?> all = await prefs.getAllWithParameters(
|
||||
GetAllParameters(
|
||||
filter: PreferencesFilter(
|
||||
prefix: 'prefix.',
|
||||
allowList: <String>{'prefix.Bool'},
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(all.length, 1);
|
||||
expect(all['prefix.Bool'], prefixTestValues['prefix.Bool']);
|
||||
});
|
||||
|
||||
test('remove', () async {
|
||||
await writeTestFile('{"key1":"one","key2":2}');
|
||||
final SharedPreferencesWindows prefs = getPreferences();
|
||||
|
||||
await prefs.remove('key2');
|
||||
|
||||
expect(await readTestFile(), '{"key1":"one"}');
|
||||
});
|
||||
|
||||
test('setValue', () async {
|
||||
await writeTestFile('{}');
|
||||
final SharedPreferencesWindows prefs = getPreferences();
|
||||
|
||||
await prefs.setValue('', 'key1', 'one');
|
||||
await prefs.setValue('', 'key2', 2);
|
||||
|
||||
expect(await readTestFile(), '{"key1":"one","key2":2}');
|
||||
});
|
||||
|
||||
test('clear', () async {
|
||||
await writeTestFile(json.encode(flutterTestValues));
|
||||
final SharedPreferencesWindows prefs = getPreferences();
|
||||
|
||||
expect(await readTestFile(), json.encode(flutterTestValues));
|
||||
await prefs.clear();
|
||||
expect(await readTestFile(), '{}');
|
||||
});
|
||||
|
||||
test('clearWithPrefix', () async {
|
||||
await writeTestFile(json.encode(flutterTestValues));
|
||||
final SharedPreferencesWindows prefs = getPreferences();
|
||||
await prefs.clearWithPrefix('prefix.');
|
||||
final Map<String, Object> noValues =
|
||||
await prefs.getAllWithPrefix('prefix.');
|
||||
expect(noValues, hasLength(0));
|
||||
|
||||
final Map<String, Object> values = await prefs.getAll();
|
||||
expect(values, hasLength(5));
|
||||
expect(values, flutterTestValues);
|
||||
});
|
||||
|
||||
test('getAllWithNoPrefix', () async {
|
||||
await writeTestFile(json.encode(allTestValues));
|
||||
final SharedPreferencesWindows prefs = getPreferences();
|
||||
|
||||
final Map<String, Object> values = await prefs.getAllWithPrefix('');
|
||||
expect(values, hasLength(15));
|
||||
expect(values, allTestValues);
|
||||
});
|
||||
|
||||
test('clearWithNoPrefix', () async {
|
||||
await writeTestFile(json.encode(flutterTestValues));
|
||||
final SharedPreferencesWindows prefs = getPreferences();
|
||||
await prefs.clearWithPrefix('');
|
||||
final Map<String, Object> noValues = await prefs.getAllWithPrefix('');
|
||||
expect(noValues, hasLength(0));
|
||||
});
|
||||
|
||||
test('clearWithParameters with Prefix', () async {
|
||||
await writeTestFile(json.encode(flutterTestValues));
|
||||
final SharedPreferencesWindows prefs = getPreferences();
|
||||
await prefs.clearWithParameters(
|
||||
ClearParameters(
|
||||
filter: PreferencesFilter(prefix: 'prefix.'),
|
||||
),
|
||||
);
|
||||
final Map<String, Object> noValues = await prefs.getAllWithParameters(
|
||||
GetAllParameters(
|
||||
filter: PreferencesFilter(prefix: 'prefix.'),
|
||||
),
|
||||
);
|
||||
expect(noValues, hasLength(0));
|
||||
|
||||
final Map<String, Object> values = await prefs.getAll();
|
||||
expect(values, hasLength(5));
|
||||
expect(values, flutterTestValues);
|
||||
});
|
||||
|
||||
test('clearWithParameters with allow list', () async {
|
||||
await writeTestFile(json.encode(prefixTestValues));
|
||||
final SharedPreferencesWindows prefs = getPreferences();
|
||||
await prefs.clearWithParameters(
|
||||
ClearParameters(
|
||||
filter: PreferencesFilter(
|
||||
prefix: 'prefix.',
|
||||
allowList: <String>{'prefix.StringList'},
|
||||
),
|
||||
),
|
||||
);
|
||||
final Map<String, Object> noValues = await prefs.getAllWithParameters(
|
||||
GetAllParameters(
|
||||
filter: PreferencesFilter(prefix: 'prefix.'),
|
||||
),
|
||||
);
|
||||
expect(noValues, hasLength(4));
|
||||
});
|
||||
|
||||
test('getAllWithNoPrefix', () async {
|
||||
await writeTestFile(json.encode(allTestValues));
|
||||
final SharedPreferencesWindows prefs = getPreferences();
|
||||
|
||||
final Map<String, Object> values = await prefs.getAllWithParameters(
|
||||
GetAllParameters(
|
||||
filter: PreferencesFilter(prefix: ''),
|
||||
),
|
||||
);
|
||||
expect(values, hasLength(15));
|
||||
expect(values, allTestValues);
|
||||
});
|
||||
|
||||
test('clearWithNoPrefix', () async {
|
||||
await writeTestFile(json.encode(flutterTestValues));
|
||||
final SharedPreferencesWindows prefs = getPreferences();
|
||||
await prefs.clearWithParameters(
|
||||
ClearParameters(
|
||||
filter: PreferencesFilter(prefix: ''),
|
||||
),
|
||||
);
|
||||
final Map<String, Object> noValues = await prefs.getAllWithParameters(
|
||||
GetAllParameters(
|
||||
filter: PreferencesFilter(prefix: ''),
|
||||
),
|
||||
);
|
||||
expect(noValues, hasLength(0));
|
||||
});
|
||||
}
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
// Copyright 2013 The Flutter Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'package:file/memory.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:path_provider_windows/path_provider_windows.dart';
|
||||
import 'package:shared_preferences_platform_interface/types.dart';
|
||||
import 'package:shared_preferences_windows/shared_preferences_windows.dart';
|
||||
|
||||
import 'fake_path_provider_windows.dart';
|
||||
|
||||
void main() {
|
||||
late MemoryFileSystem fs;
|
||||
late PathProviderWindows pathProvider;
|
||||
|
||||
SharedPreferencesAsyncWindows.registerWith();
|
||||
|
||||
const String stringKey = 'testString';
|
||||
const String boolKey = 'testBool';
|
||||
const String intKey = 'testInt';
|
||||
const String doubleKey = 'testDouble';
|
||||
const String listKey = 'testList';
|
||||
|
||||
const String testString = 'hello world';
|
||||
const bool testBool = true;
|
||||
const int testInt = 42;
|
||||
const double testDouble = 3.14159;
|
||||
const List<String> testList = <String>['foo', 'bar'];
|
||||
|
||||
const SharedPreferencesWindowsOptions emptyOptions =
|
||||
SharedPreferencesWindowsOptions();
|
||||
|
||||
setUp(() {
|
||||
fs = MemoryFileSystem.test();
|
||||
pathProvider = FakePathProviderWindows();
|
||||
});
|
||||
|
||||
SharedPreferencesAsyncWindows getPreferences() {
|
||||
final SharedPreferencesAsyncWindows prefs = SharedPreferencesAsyncWindows();
|
||||
prefs.fs = fs;
|
||||
prefs.pathProvider = pathProvider;
|
||||
return prefs;
|
||||
}
|
||||
|
||||
test('set and get String', () async {
|
||||
final SharedPreferencesAsyncWindows preferences = getPreferences();
|
||||
|
||||
await preferences.setString(stringKey, testString, emptyOptions);
|
||||
expect(await preferences.getString(stringKey, emptyOptions), testString);
|
||||
});
|
||||
|
||||
test('set and get bool', () async {
|
||||
final SharedPreferencesAsyncWindows preferences = getPreferences();
|
||||
|
||||
await preferences.setBool(boolKey, testBool, emptyOptions);
|
||||
expect(await preferences.getBool(boolKey, emptyOptions), testBool);
|
||||
});
|
||||
|
||||
test('set and get int', () async {
|
||||
final SharedPreferencesAsyncWindows preferences = getPreferences();
|
||||
|
||||
await preferences.setInt(intKey, testInt, emptyOptions);
|
||||
expect(await preferences.getInt(intKey, emptyOptions), testInt);
|
||||
});
|
||||
|
||||
test('set and get double', () async {
|
||||
final SharedPreferencesAsyncWindows preferences = getPreferences();
|
||||
|
||||
await preferences.setDouble(doubleKey, testDouble, emptyOptions);
|
||||
expect(await preferences.getDouble(doubleKey, emptyOptions), testDouble);
|
||||
});
|
||||
|
||||
test('set and get StringList', () async {
|
||||
final SharedPreferencesAsyncWindows preferences = getPreferences();
|
||||
|
||||
await preferences.setStringList(listKey, testList, emptyOptions);
|
||||
expect(await preferences.getStringList(listKey, emptyOptions), testList);
|
||||
});
|
||||
|
||||
test('getPreferences', () async {
|
||||
final SharedPreferencesAsyncWindows preferences = getPreferences();
|
||||
await preferences.setString(stringKey, testString, emptyOptions);
|
||||
await preferences.setBool(boolKey, testBool, emptyOptions);
|
||||
await preferences.setInt(intKey, testInt, emptyOptions);
|
||||
await preferences.setDouble(doubleKey, testDouble, emptyOptions);
|
||||
await preferences.setStringList(listKey, testList, emptyOptions);
|
||||
|
||||
final Map<String, Object?> gotAll = await preferences.getPreferences(
|
||||
const GetPreferencesParameters(filter: PreferencesFilters()),
|
||||
emptyOptions);
|
||||
|
||||
expect(gotAll.length, 5);
|
||||
expect(gotAll[stringKey], testString);
|
||||
expect(gotAll[boolKey], testBool);
|
||||
expect(gotAll[intKey], testInt);
|
||||
expect(gotAll[doubleKey], testDouble);
|
||||
expect(gotAll[listKey], testList);
|
||||
});
|
||||
|
||||
test('getPreferences with filter', () async {
|
||||
final SharedPreferencesAsyncWindows preferences = getPreferences();
|
||||
await preferences.setString(stringKey, testString, emptyOptions);
|
||||
await preferences.setBool(boolKey, testBool, emptyOptions);
|
||||
await preferences.setInt(intKey, testInt, emptyOptions);
|
||||
await preferences.setDouble(doubleKey, testDouble, emptyOptions);
|
||||
await preferences.setStringList(listKey, testList, emptyOptions);
|
||||
|
||||
final Map<String, Object?> gotAll = await preferences.getPreferences(
|
||||
const GetPreferencesParameters(
|
||||
filter:
|
||||
PreferencesFilters(allowList: <String>{stringKey, boolKey})),
|
||||
emptyOptions);
|
||||
|
||||
expect(gotAll.length, 2);
|
||||
expect(gotAll[stringKey], testString);
|
||||
expect(gotAll[boolKey], testBool);
|
||||
});
|
||||
|
||||
test('getKeys', () async {
|
||||
final SharedPreferencesAsyncWindows preferences = getPreferences();
|
||||
await preferences.setString(stringKey, testString, emptyOptions);
|
||||
await preferences.setBool(boolKey, testBool, emptyOptions);
|
||||
await preferences.setInt(intKey, testInt, emptyOptions);
|
||||
await preferences.setDouble(doubleKey, testDouble, emptyOptions);
|
||||
await preferences.setStringList(listKey, testList, emptyOptions);
|
||||
|
||||
final Set<String> keys = await preferences.getKeys(
|
||||
const GetPreferencesParameters(filter: PreferencesFilters()),
|
||||
emptyOptions,
|
||||
);
|
||||
|
||||
expect(keys.length, 5);
|
||||
expect(keys, contains(stringKey));
|
||||
expect(keys, contains(boolKey));
|
||||
expect(keys, contains(intKey));
|
||||
expect(keys, contains(doubleKey));
|
||||
expect(keys, contains(listKey));
|
||||
});
|
||||
|
||||
test('getKeys with filter', () async {
|
||||
final SharedPreferencesAsyncWindows preferences = getPreferences();
|
||||
await preferences.setString(stringKey, testString, emptyOptions);
|
||||
await preferences.setBool(boolKey, testBool, emptyOptions);
|
||||
await preferences.setInt(intKey, testInt, emptyOptions);
|
||||
await preferences.setDouble(doubleKey, testDouble, emptyOptions);
|
||||
await preferences.setStringList(listKey, testList, emptyOptions);
|
||||
|
||||
final Set<String> keys = await preferences.getKeys(
|
||||
const GetPreferencesParameters(
|
||||
filter: PreferencesFilters(allowList: <String>{stringKey, boolKey}),
|
||||
),
|
||||
emptyOptions,
|
||||
);
|
||||
|
||||
expect(keys.length, 2);
|
||||
expect(keys, contains(stringKey));
|
||||
expect(keys, contains(boolKey));
|
||||
});
|
||||
|
||||
test('clear', () async {
|
||||
final SharedPreferencesAsyncWindows preferences = getPreferences();
|
||||
await preferences.setString(stringKey, testString, emptyOptions);
|
||||
await preferences.setBool(boolKey, testBool, emptyOptions);
|
||||
await preferences.setInt(intKey, testInt, emptyOptions);
|
||||
await preferences.setDouble(doubleKey, testDouble, emptyOptions);
|
||||
await preferences.setStringList(listKey, testList, emptyOptions);
|
||||
await preferences.clear(
|
||||
const ClearPreferencesParameters(filter: PreferencesFilters()),
|
||||
emptyOptions);
|
||||
expect(await preferences.getString(stringKey, emptyOptions), null);
|
||||
expect(await preferences.getBool(boolKey, emptyOptions), null);
|
||||
expect(await preferences.getInt(intKey, emptyOptions), null);
|
||||
expect(await preferences.getDouble(doubleKey, emptyOptions), null);
|
||||
expect(await preferences.getStringList(listKey, emptyOptions), null);
|
||||
});
|
||||
|
||||
test('clear with filter', () async {
|
||||
final SharedPreferencesAsyncWindows preferences = getPreferences();
|
||||
await preferences.setString(stringKey, testString, emptyOptions);
|
||||
await preferences.setBool(boolKey, testBool, emptyOptions);
|
||||
await preferences.setInt(intKey, testInt, emptyOptions);
|
||||
await preferences.setDouble(doubleKey, testDouble, emptyOptions);
|
||||
await preferences.setStringList(listKey, testList, emptyOptions);
|
||||
await preferences.clear(
|
||||
const ClearPreferencesParameters(
|
||||
filter: PreferencesFilters(allowList: <String>{stringKey, boolKey}),
|
||||
),
|
||||
emptyOptions,
|
||||
);
|
||||
expect(await preferences.getString(stringKey, emptyOptions), null);
|
||||
expect(await preferences.getBool(boolKey, emptyOptions), null);
|
||||
expect(await preferences.getInt(intKey, emptyOptions), testInt);
|
||||
expect(await preferences.getDouble(doubleKey, emptyOptions), testDouble);
|
||||
expect(await preferences.getStringList(listKey, emptyOptions), testList);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user