feat(storage): A.2 — persist TS3 identity across app restarts
A fresh `Identity::create()` was generated on every connect, which meant the server saw a different client UID each time. Long-lived features (bookmarks, server-side bans, group membership) depend on a stable UID — restoring that now via a minimal directory-backed identity file. * `chanora_storage::IdentityFileStore` reads / writes a single `identity.tskey` file under a caller-supplied directory. On Unix the file is created with `O_CREAT | O_TRUNC | mode 0600`; on non-Unix targets the platform sandbox does the access control. Writes are atomic (temp file + `fsync` + `rename`) so a crash mid-write cannot leave a half-written identity on disk. Empty files are treated as "no identity" rather than as an error. * `chanora_protocol::ProtocolClient::generate_identity()` exposes the `counterVbase64key` serialisation used by tsclientlib's `Identity::new_from_str`, so the core layer can mint an identity and store it before dialling. * `chanora_core::ChanoraSession::init_storage(dir)` wires the store. `connect()` then resolves the identity in this order: (1) `cfg.identity` if explicitly supplied; (2) persisted value if any; (3) generate-and-persist a fresh one. * `chanora_bridge::api::init_storage(dir: String)` is the Flutter-facing entrypoint; the matching Dart side resolves `path_provider`'s `getApplicationSupportDirectory()` and calls it once on app start. * `BridgeError` now maps `CoreError::Storage`. Beta caveat (RISK-PoC-002 / SS-RISK-FALLBACK): the identity is not encrypted at rest. The v0.4 storage rework lands proper Secret Service + Android Keystore + iOS Keychain backends. Documented under `IdentityFileStore`'s doc comment. Live-verified on Moto G Stylus 5G: first connect generated + persisted the identity (visible in the redacted diagnostic export as "generated + persisted fresh identity"); disconnect + reconnect in the same session logged "reusing persisted identity" and dialled with the same UID.
This commit is contained in:
@@ -13,6 +13,7 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
import 'l10n/generated/app_localizations.dart';
|
import 'l10n/generated/app_localizations.dart';
|
||||||
import 'src/rust/api.dart' as rust;
|
import 'src/rust/api.dart' as rust;
|
||||||
@@ -21,14 +22,27 @@ import 'src/rust/frb_generated.dart';
|
|||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
await RustLib.init();
|
await RustLib.init();
|
||||||
// Push the OS-reported connectivity state into the core supervisor.
|
// A.2 — wire identity persistence to the platform's app-private
|
||||||
// The supervisor uses this to short-circuit reconnect backoff when
|
// directory so the same TS3 UID is presented on every restart.
|
||||||
// the network comes back, and to pre-charge the loss watchdog when
|
// Beta: stored as a plain 0600 file (RISK-PoC-002).
|
||||||
// the OS already knows we're offline (A.6.1).
|
unawaited(_wireStorage());
|
||||||
|
// A.6.1 — push the OS-reported connectivity state into the core
|
||||||
|
// supervisor so reconnects redial promptly when the network
|
||||||
|
// returns.
|
||||||
unawaited(_wireConnectivity());
|
unawaited(_wireConnectivity());
|
||||||
runApp(const ChanoraApp());
|
runApp(const ChanoraApp());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _wireStorage() async {
|
||||||
|
try {
|
||||||
|
final dir = await getApplicationSupportDirectory();
|
||||||
|
await rust.initStorage(dir: dir.path);
|
||||||
|
} catch (_) {
|
||||||
|
// Storage is best-effort — on failure the app still works but
|
||||||
|
// each session gets a fresh ephemeral identity.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Map `connectivity_plus`' list-of-results to our coarse tri-state.
|
/// Map `connectivity_plus`' list-of-results to our coarse tri-state.
|
||||||
/// We consider the device "Online" if any of the reported transports
|
/// We consider the device "Online" if any of the reported transports
|
||||||
/// is non-`none`. This is intentionally permissive — the supervisor's
|
/// is non-`none`. This is intentionally permissive — the supervisor's
|
||||||
|
|||||||
@@ -37,6 +37,21 @@ Future<void> startAudio() => RustLib.instance.api.crateApiStartAudio();
|
|||||||
Future<void> setPtt({required bool active}) =>
|
Future<void> setPtt({required bool active}) =>
|
||||||
RustLib.instance.api.crateApiSetPtt(active: active);
|
RustLib.instance.api.crateApiSetPtt(active: active);
|
||||||
|
|
||||||
|
/// Wire the identity persistence store to a platform-private
|
||||||
|
/// directory. Should be called once on app start after Flutter has
|
||||||
|
/// resolved `getApplicationSupportDirectory()` (or equivalent).
|
||||||
|
///
|
||||||
|
/// Subsequent [`connect`] calls will reuse the persisted identity,
|
||||||
|
/// or generate-and-persist a fresh one on first use. This keeps the
|
||||||
|
/// server-visible UID stable across app restarts.
|
||||||
|
///
|
||||||
|
/// Beta caveat: the identity is stored as a plain file (mode 0600
|
||||||
|
/// on Unix). It is *not* encrypted at rest. RISK-PoC-002 documents
|
||||||
|
/// this gap; the v0.4 storage rework lands the proper Secret
|
||||||
|
/// Service + Android Keystore + iOS Keychain backends.
|
||||||
|
Future<void> initStorage({required String dir}) =>
|
||||||
|
RustLib.instance.api.crateApiInitStorage(dir: dir);
|
||||||
|
|
||||||
/// Notify the core of the latest OS-reported connectivity state.
|
/// Notify the core of the latest OS-reported connectivity state.
|
||||||
/// Called by the Flutter side from `connectivity_plus` callbacks.
|
/// Called by the Flutter side from `connectivity_plus` callbacks.
|
||||||
/// The core's supervisor uses this to (a) pre-charge the watchdog
|
/// The core's supervisor uses this to (a) pre-charge the watchdog
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ class RustLib extends BaseEntrypoint<RustLibApi, RustLibApiImpl, RustLibWire> {
|
|||||||
String get codegenVersion => '2.12.0';
|
String get codegenVersion => '2.12.0';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get rustContentHash => -1212711005;
|
int get rustContentHash => 1702138901;
|
||||||
|
|
||||||
static const kDefaultExternalLibraryLoaderConfig =
|
static const kDefaultExternalLibraryLoaderConfig =
|
||||||
ExternalLibraryLoaderConfig(
|
ExternalLibraryLoaderConfig(
|
||||||
@@ -92,6 +92,8 @@ abstract class RustLibApi extends BaseApi {
|
|||||||
|
|
||||||
Stream<BridgeEvent> crateApiEventsStream();
|
Stream<BridgeEvent> crateApiEventsStream();
|
||||||
|
|
||||||
|
Future<void> crateApiInitStorage({required String dir});
|
||||||
|
|
||||||
Future<bool> crateApiIsConnected();
|
Future<bool> crateApiIsConnected();
|
||||||
|
|
||||||
void crateApiSetNetworkState({required BridgeNetworkState state});
|
void crateApiSetNetworkState({required BridgeNetworkState state});
|
||||||
@@ -256,6 +258,34 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
TaskConstMeta get kCrateApiEventsStreamConstMeta =>
|
TaskConstMeta get kCrateApiEventsStreamConstMeta =>
|
||||||
const TaskConstMeta(debugName: "events_stream", argNames: ["sink"]);
|
const TaskConstMeta(debugName: "events_stream", argNames: ["sink"]);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> crateApiInitStorage({required String dir}) {
|
||||||
|
return handler.executeNormal(
|
||||||
|
NormalTask(
|
||||||
|
callFfi: (port_) {
|
||||||
|
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||||
|
sse_encode_String(dir, serializer);
|
||||||
|
pdeCallFfi(
|
||||||
|
generalizedFrbRustBinding,
|
||||||
|
serializer,
|
||||||
|
funcId: 6,
|
||||||
|
port: port_,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
codec: SseCodec(
|
||||||
|
decodeSuccessData: sse_decode_unit,
|
||||||
|
decodeErrorData: sse_decode_bridge_error,
|
||||||
|
),
|
||||||
|
constMeta: kCrateApiInitStorageConstMeta,
|
||||||
|
argValues: [dir],
|
||||||
|
apiImpl: this,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
TaskConstMeta get kCrateApiInitStorageConstMeta =>
|
||||||
|
const TaskConstMeta(debugName: "init_storage", argNames: ["dir"]);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<bool> crateApiIsConnected() {
|
Future<bool> crateApiIsConnected() {
|
||||||
return handler.executeNormal(
|
return handler.executeNormal(
|
||||||
@@ -265,7 +295,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 6,
|
funcId: 7,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -290,7 +320,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
callFfi: () {
|
callFfi: () {
|
||||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||||
sse_encode_bridge_network_state(state, serializer);
|
sse_encode_bridge_network_state(state, serializer);
|
||||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 7)!;
|
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 8)!;
|
||||||
},
|
},
|
||||||
codec: SseCodec(
|
codec: SseCodec(
|
||||||
decodeSuccessData: sse_decode_unit,
|
decodeSuccessData: sse_decode_unit,
|
||||||
@@ -316,7 +346,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 8,
|
funcId: 9,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -343,7 +373,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 9,
|
funcId: 10,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -370,7 +400,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 10,
|
funcId: 11,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
|||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
jni
|
||||||
)
|
)
|
||||||
|
|
||||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||||
|
|||||||
@@ -121,6 +121,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.2"
|
version: "1.1.2"
|
||||||
|
code_assets:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: code_assets
|
||||||
|
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.0"
|
||||||
collection:
|
collection:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -285,6 +293,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.2"
|
version: "2.3.2"
|
||||||
|
hooks:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: hooks
|
||||||
|
sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.3"
|
||||||
http_multi_server:
|
http_multi_server:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -317,6 +333,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.5"
|
version: "1.0.5"
|
||||||
|
jni:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: jni
|
||||||
|
sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.0"
|
||||||
|
jni_flutter:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: jni_flutter
|
||||||
|
sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.1"
|
||||||
json_annotation:
|
json_annotation:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -397,6 +429,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
version: "2.0.0"
|
||||||
|
native_toolchain_c:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: native_toolchain_c
|
||||||
|
sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.17.6"
|
||||||
nm:
|
nm:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -405,6 +445,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.5.0"
|
version: "0.5.0"
|
||||||
|
objective_c:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: objective_c
|
||||||
|
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "9.3.0"
|
||||||
package_config:
|
package_config:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -421,6 +469,54 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.9.1"
|
version: "1.9.1"
|
||||||
|
path_provider:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: path_provider
|
||||||
|
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.5"
|
||||||
|
path_provider_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_android
|
||||||
|
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.3.1"
|
||||||
|
path_provider_foundation:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_foundation
|
||||||
|
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.6.0"
|
||||||
|
path_provider_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_linux
|
||||||
|
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.1"
|
||||||
|
path_provider_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_platform_interface
|
||||||
|
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.2"
|
||||||
|
path_provider_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_windows
|
||||||
|
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.3.0"
|
||||||
petitparser:
|
petitparser:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -429,6 +525,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.2"
|
version: "7.0.2"
|
||||||
|
platform:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: platform
|
||||||
|
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.6"
|
||||||
plugin_platform_interface:
|
plugin_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -461,6 +565,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.5.0"
|
version: "1.5.0"
|
||||||
|
record_use:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: record_use
|
||||||
|
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.6.0"
|
||||||
shelf:
|
shelf:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -602,6 +714,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.3"
|
version: "3.0.3"
|
||||||
|
xdg_directories:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: xdg_directories
|
||||||
|
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.0"
|
||||||
xml:
|
xml:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -620,4 +740,4 @@ packages:
|
|||||||
version: "3.1.3"
|
version: "3.1.3"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.11.5 <4.0.0"
|
dart: ">=3.11.5 <4.0.0"
|
||||||
flutter: ">=3.18.0-18.0.pre.54"
|
flutter: ">=3.38.4"
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ dependencies:
|
|||||||
flutter_rust_bridge: 2.12.0
|
flutter_rust_bridge: 2.12.0
|
||||||
freezed_annotation: ^3.1.0
|
freezed_annotation: ^3.1.0
|
||||||
connectivity_plus: ^6.1.0
|
connectivity_plus: ^6.1.0
|
||||||
|
path_provider: ^2.1.4
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ pub use chanora_audio::{AudioEngine, AudioEngineConfig};
|
|||||||
pub use chanora_protocol::{
|
pub use chanora_protocol::{
|
||||||
ChannelInfo, ClientInfo, ConnectConfig, DisconnectReason, ProtocolError, ServerSnapshot,
|
ChannelInfo, ClientInfo, ConnectConfig, DisconnectReason, ProtocolError, ServerSnapshot,
|
||||||
};
|
};
|
||||||
|
pub use chanora_storage::IdentityFileStore;
|
||||||
|
|
||||||
/// Errors that can arise during top-level orchestration.
|
/// Errors that can arise during top-level orchestration.
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
@@ -179,6 +180,11 @@ pub struct ChanoraSession {
|
|||||||
/// `connectivity_plus` callbacks. Supervisor observes via
|
/// `connectivity_plus` callbacks. Supervisor observes via
|
||||||
/// [`watch::Receiver`].
|
/// [`watch::Receiver`].
|
||||||
network_tx: watch::Sender<NetworkState>,
|
network_tx: watch::Sender<NetworkState>,
|
||||||
|
/// Beta identity persistence. Optional — when `None`, the
|
||||||
|
/// per-connect identity is whatever `ConnectConfig::identity`
|
||||||
|
/// carries (or a fresh ephemeral one if that is also `None`).
|
||||||
|
/// Wired by [`Self::init_storage`].
|
||||||
|
identity_store: Arc<Mutex<Option<IdentityFileStore>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChanoraSession {
|
impl ChanoraSession {
|
||||||
@@ -190,9 +196,25 @@ impl ChanoraSession {
|
|||||||
inner: Arc::new(Mutex::new(None)),
|
inner: Arc::new(Mutex::new(None)),
|
||||||
events_tx,
|
events_tx,
|
||||||
network_tx,
|
network_tx,
|
||||||
|
identity_store: Arc::new(Mutex::new(None)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wire a directory-backed identity store. Called by the bridge
|
||||||
|
/// during `bridge_init` once Flutter has resolved the platform
|
||||||
|
/// app-private storage directory. Subsequent [`Self::connect`]
|
||||||
|
/// calls will reuse the stored identity, or generate-and-store
|
||||||
|
/// one if none exists yet.
|
||||||
|
///
|
||||||
|
/// Beta caveat: the file is *not* encrypted at rest — see
|
||||||
|
/// `chanora_storage::IdentityFileStore` for the full gap notice.
|
||||||
|
pub async fn init_storage(&self, dir: impl AsRef<std::path::Path>) -> Result<(), CoreError> {
|
||||||
|
let store = IdentityFileStore::new(dir)?;
|
||||||
|
info!(target: "chanora_core", path = ?store.path(), "identity store initialised");
|
||||||
|
*self.identity_store.lock().await = Some(store);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Push an OS connectivity update. Called by the bridge when
|
/// Push an OS connectivity update. Called by the bridge when
|
||||||
/// `connectivity_plus` fires. Safe to call from any thread.
|
/// `connectivity_plus` fires. Safe to call from any thread.
|
||||||
pub fn set_network_state(&self, state: NetworkState) {
|
pub fn set_network_state(&self, state: NetworkState) {
|
||||||
@@ -220,11 +242,39 @@ impl ChanoraSession {
|
|||||||
/// Connect to a server. Fails with [`CoreError::AlreadyConnected`]
|
/// Connect to a server. Fails with [`CoreError::AlreadyConnected`]
|
||||||
/// if a connection is already active (DEC-006). Audio is not
|
/// if a connection is already active (DEC-006). Audio is not
|
||||||
/// started automatically; call [`Self::start_audio`] after.
|
/// started automatically; call [`Self::start_audio`] after.
|
||||||
pub async fn connect(&self, cfg: ConnectConfig) -> Result<ServerSnapshot, CoreError> {
|
///
|
||||||
|
/// Identity policy (A.2): if `cfg.identity` is `Some(_)`, use it
|
||||||
|
/// verbatim. Otherwise, if [`Self::init_storage`] has been
|
||||||
|
/// called and the store already holds an identity, load and
|
||||||
|
/// reuse it. Otherwise generate a fresh identity and — if a
|
||||||
|
/// store is wired — persist it before dialling so the *same*
|
||||||
|
/// UID is presented on every subsequent connect from this
|
||||||
|
/// install.
|
||||||
|
pub async fn connect(&self, mut cfg: ConnectConfig) -> Result<ServerSnapshot, CoreError> {
|
||||||
let mut guard = self.inner.lock().await;
|
let mut guard = self.inner.lock().await;
|
||||||
if guard.is_some() {
|
if guard.is_some() {
|
||||||
return Err(CoreError::AlreadyConnected);
|
return Err(CoreError::AlreadyConnected);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve identity from the store before dialling.
|
||||||
|
if cfg.identity.is_none() {
|
||||||
|
let store_guard = self.identity_store.lock().await;
|
||||||
|
if let Some(store) = store_guard.as_ref() {
|
||||||
|
match store.load()? {
|
||||||
|
Some(saved) => {
|
||||||
|
info!(target: "chanora_core", "reusing persisted identity");
|
||||||
|
cfg.identity = Some(saved);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let fresh = chanora_protocol::ProtocolClient::generate_identity();
|
||||||
|
store.save(&fresh)?;
|
||||||
|
info!(target: "chanora_core", "generated + persisted fresh identity");
|
||||||
|
cfg.identity = Some(fresh);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let client = chanora_protocol::ProtocolClient::connect(cfg.clone()).await?;
|
let client = chanora_protocol::ProtocolClient::connect(cfg.clone()).await?;
|
||||||
let snap = client.snapshot().await?;
|
let snap = client.snapshot().await?;
|
||||||
|
|
||||||
|
|||||||
@@ -233,6 +233,28 @@ pub struct BridgeAudioStats {
|
|||||||
pub ptt_active: bool,
|
pub ptt_active: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Storage (A.2) ----------
|
||||||
|
|
||||||
|
/// Wire the identity persistence store to a platform-private
|
||||||
|
/// directory. Should be called once on app start after Flutter has
|
||||||
|
/// resolved `getApplicationSupportDirectory()` (or equivalent).
|
||||||
|
///
|
||||||
|
/// Subsequent [`connect`] calls will reuse the persisted identity,
|
||||||
|
/// or generate-and-persist a fresh one on first use. This keeps the
|
||||||
|
/// server-visible UID stable across app restarts.
|
||||||
|
///
|
||||||
|
/// Beta caveat: the identity is stored as a plain file (mode 0600
|
||||||
|
/// on Unix). It is *not* encrypted at rest. RISK-PoC-002 documents
|
||||||
|
/// this gap; the v0.4 storage rework lands the proper Secret
|
||||||
|
/// Service + Android Keystore + iOS Keychain backends.
|
||||||
|
pub async fn init_storage(dir: String) -> Result<(), BridgeError> {
|
||||||
|
runtime()
|
||||||
|
.spawn(async move { session().init_storage(&dir).await })
|
||||||
|
.await
|
||||||
|
.map_err(|e| BridgeError::Unmapped(format!("join: {e}")))??;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Connectivity (A.6.1) ----------
|
// ---------- Connectivity (A.6.1) ----------
|
||||||
|
|
||||||
/// Coarse OS-reported network state. Mirrors
|
/// Coarse OS-reported network state. Mirrors
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ flutter_rust_bridge::frb_generated_boilerplate!(
|
|||||||
default_rust_auto_opaque = RustAutoOpaqueMoi,
|
default_rust_auto_opaque = RustAutoOpaqueMoi,
|
||||||
);
|
);
|
||||||
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0";
|
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0";
|
||||||
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -1212711005;
|
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 1702138901;
|
||||||
|
|
||||||
// Section: executor
|
// Section: executor
|
||||||
|
|
||||||
@@ -223,6 +223,42 @@ fn wire__crate__api__events_stream_impl(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
fn wire__crate__api__init_storage_impl(
|
||||||
|
port_: flutter_rust_bridge::for_generated::MessagePort,
|
||||||
|
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
||||||
|
rust_vec_len_: i32,
|
||||||
|
data_len_: i32,
|
||||||
|
) {
|
||||||
|
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec, _, _, _>(
|
||||||
|
flutter_rust_bridge::for_generated::TaskInfo {
|
||||||
|
debug_name: "init_storage",
|
||||||
|
port: Some(port_),
|
||||||
|
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
|
||||||
|
},
|
||||||
|
move || {
|
||||||
|
let message = unsafe {
|
||||||
|
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
|
||||||
|
ptr_,
|
||||||
|
rust_vec_len_,
|
||||||
|
data_len_,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let mut deserializer =
|
||||||
|
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
|
||||||
|
let api_dir = <String>::sse_decode(&mut deserializer);
|
||||||
|
deserializer.end();
|
||||||
|
move |context| async move {
|
||||||
|
transform_result_sse::<_, crate::BridgeError>(
|
||||||
|
(move || async move {
|
||||||
|
let output_ok = crate::api::init_storage(api_dir).await?;
|
||||||
|
Ok(output_ok)
|
||||||
|
})()
|
||||||
|
.await,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
fn wire__crate__api__is_connected_impl(
|
fn wire__crate__api__is_connected_impl(
|
||||||
port_: flutter_rust_bridge::for_generated::MessagePort,
|
port_: flutter_rust_bridge::for_generated::MessagePort,
|
||||||
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
||||||
@@ -677,10 +713,11 @@ fn pde_ffi_dispatcher_primary_impl(
|
|||||||
3 => wire__crate__api__connect_impl(port, ptr, rust_vec_len, data_len),
|
3 => wire__crate__api__connect_impl(port, ptr, rust_vec_len, data_len),
|
||||||
4 => wire__crate__api__disconnect_impl(port, ptr, rust_vec_len, data_len),
|
4 => wire__crate__api__disconnect_impl(port, ptr, rust_vec_len, data_len),
|
||||||
5 => wire__crate__api__events_stream_impl(port, ptr, rust_vec_len, data_len),
|
5 => wire__crate__api__events_stream_impl(port, ptr, rust_vec_len, data_len),
|
||||||
6 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len),
|
6 => wire__crate__api__init_storage_impl(port, ptr, rust_vec_len, data_len),
|
||||||
8 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len),
|
7 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len),
|
||||||
9 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len),
|
9 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len),
|
||||||
10 => wire__crate__api__start_audio_impl(port, ptr, rust_vec_len, data_len),
|
10 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len),
|
||||||
|
11 => wire__crate__api__start_audio_impl(port, ptr, rust_vec_len, data_len),
|
||||||
_ => unreachable!(),
|
_ => unreachable!(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -693,7 +730,7 @@ fn pde_ffi_dispatcher_sync_impl(
|
|||||||
) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse {
|
) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse {
|
||||||
// Codec=Pde (Serialization + dispatch), see doc to use other codecs
|
// Codec=Pde (Serialization + dispatch), see doc to use other codecs
|
||||||
match func_id {
|
match func_id {
|
||||||
7 => wire__crate__api__set_network_state_impl(ptr, rust_vec_len, data_len),
|
8 => wire__crate__api__set_network_state_impl(ptr, rust_vec_len, data_len),
|
||||||
_ => unreachable!(),
|
_ => unreachable!(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,6 +87,9 @@ impl From<chanora_core::CoreError> for BridgeError {
|
|||||||
}) => BridgeError::DnsFailed { host, reason },
|
}) => BridgeError::DnsFailed { host, reason },
|
||||||
chanora_core::CoreError::Protocol(p) => BridgeError::Connection(format!("{p}")),
|
chanora_core::CoreError::Protocol(p) => BridgeError::Connection(format!("{p}")),
|
||||||
chanora_core::CoreError::Audio(a) => BridgeError::Connection(format!("audio: {a}")),
|
chanora_core::CoreError::Audio(a) => BridgeError::Connection(format!("audio: {a}")),
|
||||||
|
chanora_core::CoreError::Storage(s) => {
|
||||||
|
BridgeError::Connection(format!("storage: {s}"))
|
||||||
|
}
|
||||||
other => BridgeError::Unmapped(format!("{other}")),
|
other => BridgeError::Unmapped(format!("{other}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,6 +130,17 @@ impl SnapshotProbe {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ProtocolClient {
|
impl ProtocolClient {
|
||||||
|
/// Generate a fresh, persistable TS3 identity string. The
|
||||||
|
/// returned value is the canonical `counter`V`base64key` form
|
||||||
|
/// accepted by [`ConnectConfig::identity`] and by tsclientlib's
|
||||||
|
/// `Identity::new_from_str`. Callers should persist it via
|
||||||
|
/// `chanora_storage` so subsequent connects reuse the same
|
||||||
|
/// identity and the server sees the same client UID.
|
||||||
|
pub fn generate_identity() -> String {
|
||||||
|
let id = Identity::create();
|
||||||
|
format!("{}V{}", id.counter(), id.key().to_ts())
|
||||||
|
}
|
||||||
|
|
||||||
/// Dial the server and wait for the initial state snapshot. The
|
/// Dial the server and wait for the initial state snapshot. The
|
||||||
/// returned client is ready for [`Self::snapshot`] and
|
/// returned client is ready for [`Self::snapshot`] and
|
||||||
/// [`Self::disconnect`] calls.
|
/// [`Self::disconnect`] calls.
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
//!
|
//!
|
||||||
//! * [`LocalDatabaseRepository`] — non-secret state (bookmarks,
|
//! * [`LocalDatabaseRepository`] — non-secret state (bookmarks,
|
||||||
//! settings, identity *references*) via SQLite. Crate choice:
|
//! settings, identity *references*) via SQLite. Crate choice:
|
||||||
//! `rusqlite` bundled (DEC-013.1).
|
//! `rusqlite` bundled (DEC-013.1). **Not yet implemented** —
|
||||||
|
//! `poc/sqlite-storage-spike` lands in v0.4.
|
||||||
//! * [`SecretStorageRepository`] — secret material (identity private
|
//! * [`SecretStorageRepository`] — secret material (identity private
|
||||||
//! keys, server passwords) via platform secure storage. Linux
|
//! keys, server passwords) via platform secure storage. Linux
|
||||||
//! policy: Secret Service preferred, kernel keyutils fallback
|
//! policy: Secret Service preferred, kernel keyutils fallback
|
||||||
@@ -14,15 +15,37 @@
|
|||||||
//! bookmarks store only an `identity_ref` lookup name into the
|
//! bookmarks store only an `identity_ref` lookup name into the
|
||||||
//! secure store.
|
//! secure store.
|
||||||
//!
|
//!
|
||||||
//! ## Status
|
//! ## Beta status (A.2)
|
||||||
//!
|
//!
|
||||||
//! Scaffold only. `poc/secure-storage-spike` and
|
//! Ships a single concrete identity-only store, [`IdentityFileStore`],
|
||||||
//! `poc/sqlite-storage-spike` will be promoted here.
|
//! that persists a single identity to a file at
|
||||||
|
//! `<storage_dir>/identity.tskey` with restrictive POSIX permissions
|
||||||
|
//! (0600) on Unix. This is the **best-effort fallback** the secure
|
||||||
|
//! storage policy permits when no platform keyring is available
|
||||||
|
//! (RISK-PoC-002 / SS-RISK-FALLBACK). The full Secret Service +
|
||||||
|
//! keyutils backend, plus Android Keystore / iOS Keychain, will
|
||||||
|
//! replace this in v0.4. Until then:
|
||||||
|
//!
|
||||||
|
//! * Linux desktop: file with 0600 mode in `$XDG_DATA_HOME/chanora`
|
||||||
|
//! (or the path provided by the bridge caller).
|
||||||
|
//! * Android: file in app-private storage. App-private means it's
|
||||||
|
//! not world-readable, but it is **not** encrypted at rest. This
|
||||||
|
//! is the documented Beta gap.
|
||||||
|
//! * iOS / Windows / macOS: same — caller chooses the directory.
|
||||||
|
//!
|
||||||
|
//! The store is intentionally limited to one identity per
|
||||||
|
//! installation in Beta; bookmark / multi-identity support arrives
|
||||||
|
//! with the SQLite repository.
|
||||||
|
|
||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
#![warn(missing_docs)]
|
#![warn(missing_docs)]
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
/// Errors raised by either storage repository.
|
/// Errors raised by either storage repository.
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
@@ -39,6 +62,9 @@ pub enum StorageError {
|
|||||||
/// Platform secure-storage backend rejected an operation.
|
/// Platform secure-storage backend rejected an operation.
|
||||||
#[error("secure-store: {0}")]
|
#[error("secure-store: {0}")]
|
||||||
SecureStore(String),
|
SecureStore(String),
|
||||||
|
/// Filesystem I/O error (Beta file-fallback store).
|
||||||
|
#[error("io: {0}")]
|
||||||
|
Io(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Marker trait for the non-secret database side. Concrete impl will
|
/// Marker trait for the non-secret database side. Concrete impl will
|
||||||
@@ -51,8 +77,161 @@ pub trait LocalDatabaseRepository: Send + Sync {}
|
|||||||
/// promoted from the secure-storage PoC.
|
/// promoted from the secure-storage PoC.
|
||||||
pub trait SecretStorageRepository: Send + Sync {}
|
pub trait SecretStorageRepository: Send + Sync {}
|
||||||
|
|
||||||
|
/// Beta identity store: a single file containing the base64 TS3
|
||||||
|
/// identity string. The directory is created on first use; on Unix
|
||||||
|
/// the file is written with mode 0600 so other local users can't
|
||||||
|
/// read it. **Not** encrypted at rest — that is the v0.4 task.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct IdentityFileStore {
|
||||||
|
path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IdentityFileStore {
|
||||||
|
/// Construct a store rooted at `dir`. The directory is created
|
||||||
|
/// recursively if it does not already exist.
|
||||||
|
pub fn new(dir: impl AsRef<Path>) -> Result<Self, StorageError> {
|
||||||
|
let dir = dir.as_ref();
|
||||||
|
fs::create_dir_all(dir).map_err(|e| StorageError::Io(format!("mkdir {dir:?}: {e}")))?;
|
||||||
|
Ok(Self {
|
||||||
|
path: dir.join("identity.tskey"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Path to the underlying file. Exposed for diagnostics.
|
||||||
|
pub fn path(&self) -> &Path {
|
||||||
|
&self.path
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the persisted identity, if any. Returns `Ok(None)` when
|
||||||
|
/// no identity has been saved yet — that is not an error.
|
||||||
|
pub fn load(&self) -> Result<Option<String>, StorageError> {
|
||||||
|
match fs::File::open(&self.path) {
|
||||||
|
Ok(mut f) => {
|
||||||
|
let mut buf = String::new();
|
||||||
|
f.read_to_string(&mut buf)
|
||||||
|
.map_err(|e| StorageError::Io(format!("read {:?}: {e}", self.path)))?;
|
||||||
|
let trimmed = buf.trim().to_string();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
Ok(None)
|
||||||
|
} else {
|
||||||
|
Ok(Some(trimmed))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||||
|
Err(e) => Err(StorageError::Io(format!("open {:?}: {e}", self.path))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist `identity` to disk, replacing any previous content.
|
||||||
|
/// On Unix the file is written with mode 0600.
|
||||||
|
pub fn save(&self, identity: &str) -> Result<(), StorageError> {
|
||||||
|
// Write atomically: temp file + rename. Avoids leaving a
|
||||||
|
// half-written identity on the device after a crash or
|
||||||
|
// power loss.
|
||||||
|
let tmp = self.path.with_extension("tskey.tmp");
|
||||||
|
{
|
||||||
|
let mut f = open_private(&tmp)?;
|
||||||
|
f.write_all(identity.trim().as_bytes())
|
||||||
|
.map_err(|e| StorageError::Io(format!("write {tmp:?}: {e}")))?;
|
||||||
|
f.write_all(b"\n")
|
||||||
|
.map_err(|e| StorageError::Io(format!("write nl: {e}")))?;
|
||||||
|
f.sync_all()
|
||||||
|
.map_err(|e| StorageError::Io(format!("sync {tmp:?}: {e}")))?;
|
||||||
|
}
|
||||||
|
fs::rename(&tmp, &self.path)
|
||||||
|
.map_err(|e| StorageError::Io(format!("rename {tmp:?} -> {:?}: {e}", self.path)))?;
|
||||||
|
info!(target: "chanora_storage", path = ?self.path, "identity persisted");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove any persisted identity. No-op if none exists.
|
||||||
|
pub fn clear(&self) -> Result<(), StorageError> {
|
||||||
|
match fs::remove_file(&self.path) {
|
||||||
|
Ok(()) => {
|
||||||
|
info!(target: "chanora_storage", path = ?self.path, "identity cleared");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||||
|
Err(e) => Err(StorageError::Io(format!(
|
||||||
|
"remove {:?}: {e}",
|
||||||
|
self.path
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn open_private(p: &Path) -> Result<fs::File, StorageError> {
|
||||||
|
use std::os::unix::fs::OpenOptionsExt;
|
||||||
|
fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.write(true)
|
||||||
|
.truncate(true)
|
||||||
|
.mode(0o600)
|
||||||
|
.open(p)
|
||||||
|
.map_err(|e| StorageError::Io(format!("open {p:?}: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
fn open_private(p: &Path) -> Result<fs::File, StorageError> {
|
||||||
|
// On non-Unix targets we can't set POSIX mode bits; the file
|
||||||
|
// sits in app-private storage where the platform sandbox does
|
||||||
|
// the access control. Document the gap rather than failing.
|
||||||
|
warn!(target: "chanora_storage", "non-unix: file permissions not restricted");
|
||||||
|
fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.write(true)
|
||||||
|
.truncate(true)
|
||||||
|
.open(p)
|
||||||
|
.map_err(|e| StorageError::Io(format!("open {p:?}: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn it_compiles() {}
|
fn round_trip() {
|
||||||
|
let tmp = tempdir();
|
||||||
|
let store = IdentityFileStore::new(&tmp).unwrap();
|
||||||
|
assert!(store.load().unwrap().is_none());
|
||||||
|
store.save("abc123").unwrap();
|
||||||
|
assert_eq!(store.load().unwrap().as_deref(), Some("abc123"));
|
||||||
|
store.clear().unwrap();
|
||||||
|
assert!(store.load().unwrap().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_file_is_none() {
|
||||||
|
let tmp = tempdir();
|
||||||
|
let store = IdentityFileStore::new(&tmp).unwrap();
|
||||||
|
fs::write(store.path(), " \n").unwrap();
|
||||||
|
assert!(store.load().unwrap().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn unix_mode_is_0600() {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let tmp = tempdir();
|
||||||
|
let store = IdentityFileStore::new(&tmp).unwrap();
|
||||||
|
store.save("xyz").unwrap();
|
||||||
|
let mode = fs::metadata(store.path()).unwrap().permissions().mode() & 0o777;
|
||||||
|
assert_eq!(mode, 0o600, "expected 0600, got {mode:o}");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tempdir() -> PathBuf {
|
||||||
|
let p = std::env::temp_dir()
|
||||||
|
.join("chanora_storage_test")
|
||||||
|
.join(format!("{}", std::process::id()))
|
||||||
|
.join(format!(
|
||||||
|
"{}",
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos()
|
||||||
|
));
|
||||||
|
fs::create_dir_all(&p).unwrap();
|
||||||
|
p
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user