commit a921d18102dbc35780d447e0c4e242fc433c1118 parent 659b192545075967dae606c899e2e7bf7d66d06f Author: triesap <tyson@radroots.org> Date: Tue, 11 Aug 2026 15:41:25 +0000 runtime: pass explicit storage through native open - introduce the typed FFI runtime-open input contract - validate canonical development data directories in Rust - pass packaged-health ownership paths through the desktop adapter - advance and verify the desktop FFI contract to version 4.2 Diffstat:
13 files changed, 169 insertions(+), 33 deletions(-)
diff --git a/app/desktop/src/main/kotlin/org/harvestcircle/application/HarvestCircleApplication.kt b/app/desktop/src/main/kotlin/org/harvestcircle/application/HarvestCircleApplication.kt @@ -174,7 +174,7 @@ internal class ApplicationLifecycleResources( internal fun createHarvestCirclePresenter(scope: CoroutineScope): HarvestCirclePresenter { val developmentMode = java.lang.Boolean.getBoolean("harvestcircle.development") return HarvestCirclePresenter( - runtime = NativeHarvestCircleRuntime.open(developmentMode), + runtime = NativeHarvestCircleRuntime.open(desktopRuntimeOpenConfiguration(developmentMode)), scope = scope, clock = DesktopApplicationClock, operationIds = DesktopOperationIdSource, diff --git a/app/desktop/src/main/kotlin/org/harvestcircle/application/NativeHarvestCircleRuntime.kt b/app/desktop/src/main/kotlin/org/harvestcircle/application/NativeHarvestCircleRuntime.kt @@ -22,6 +22,7 @@ import org.harvestcircle.ffi.RelayDestinationDto import org.harvestcircle.ffi.RelayEndpointDto import org.harvestcircle.ffi.RemovalRequest import org.harvestcircle.ffi.RequestContextDto +import org.harvestcircle.ffi.RuntimeOpenInputDto import org.harvestcircle.ffi.ShutdownReceiptDto import org.harvestcircle.ffi.SnapshotChangeDto import org.harvestcircle.ffi.compatibilityDescriptor @@ -281,21 +282,50 @@ class NativeHarvestCircleRuntime internal constructor( } companion object { - fun open( - developmentMode: Boolean, - relayInput: RelayBootstrapInputDto = desktopRelayBootstrapInput(developmentMode), - ): NativeHarvestCircleRuntime { + internal fun open(configuration: DesktopRuntimeOpenConfiguration): NativeHarvestCircleRuntime { val expectation = verifyNativeCompatibility(compatibilityDescriptor()) return NativeHarvestCircleRuntime( - UniFfiNativeCorePort(HarvestCircleAppCore.openCompatible(expectation, developmentMode, relayInput)), + UniFfiNativeCorePort(HarvestCircleAppCore.openCompatible(expectation, configuration.toNative())), ) } } } +internal const val HARVESTCIRCLE_DEVELOPMENT_DATA_DIR_ENVIRONMENT = "HARVESTCIRCLE_DEVELOPMENT_DATA_DIR" internal const val HARVESTCIRCLE_NOSTR_RELAYS_ENVIRONMENT = "HARVESTCIRCLE_NOSTR_RELAYS" internal const val HARVESTCIRCLE_LOCAL_DEVELOPMENT_RELAY = "local|ws://localhost:8080" +internal data class DesktopRuntimeOpenConfiguration( + val developmentMode: Boolean, + val explicitDataDirectory: String?, + val relayInput: RelayBootstrapInputDto, +) { + init { + require(developmentMode || explicitDataDirectory == null) { + "An explicit data directory is available only in development mode" + } + } + + fun toNative(): RuntimeOpenInputDto = + RuntimeOpenInputDto( + developmentMode = developmentMode, + explicitDataDirectory = explicitDataDirectory, + relayInput = relayInput, + ) +} + +internal fun desktopRuntimeOpenConfiguration( + developmentMode: Boolean, + explicitDataDirectory: String? = + if (developmentMode) System.getenv(HARVESTCIRCLE_DEVELOPMENT_DATA_DIR_ENVIRONMENT) else null, + configuredRelays: String? = System.getenv(HARVESTCIRCLE_NOSTR_RELAYS_ENVIRONMENT), +): DesktopRuntimeOpenConfiguration = + DesktopRuntimeOpenConfiguration( + developmentMode = developmentMode, + explicitDataDirectory = explicitDataDirectory, + relayInput = desktopRelayBootstrapInput(developmentMode, configuredRelays), + ) + internal fun desktopRelayBootstrapInput( developmentMode: Boolean, configuredValue: String? = System.getenv(HARVESTCIRCLE_NOSTR_RELAYS_ENVIRONMENT), diff --git a/app/desktop/src/main/kotlin/org/harvestcircle/desktop/Main.kt b/app/desktop/src/main/kotlin/org/harvestcircle/desktop/Main.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.withTimeout import org.harvestcircle.application.ApplicationLifecycle import org.harvestcircle.application.HarvestCircleApplication import org.harvestcircle.application.NativeHarvestCircleRuntime +import org.harvestcircle.application.desktopRuntimeOpenConfiguration import org.harvestcircle.application.verifyNativeCompatibility import org.harvestcircle.ffi.HarvestCircleException import org.harvestcircle.ffi.compatibilityDescriptor @@ -142,7 +143,14 @@ internal suspend fun executeHealthCheck( timeoutMillis: Long = HEALTH_TIMEOUT_MILLIS, runtimeOpener: PackagedHealthRuntimeOpener = PackagedHealthRuntimeOpener { - NativePackagedHealthRuntime(NativeHarvestCircleRuntime.open(developmentMode = true)) + NativePackagedHealthRuntime( + NativeHarvestCircleRuntime.open( + desktopRuntimeOpenConfiguration( + developmentMode = true, + explicitDataDirectory = it.toString(), + ), + ), + ) }, standardOutput: (String) -> Unit = ::println, errorOutput: (String) -> Unit = System.err::println, diff --git a/app/desktop/src/test/kotlin/org/harvestcircle/application/NativeGeneratedRecoveryTest.kt b/app/desktop/src/test/kotlin/org/harvestcircle/application/NativeGeneratedRecoveryTest.kt @@ -28,7 +28,14 @@ class NativeGeneratedRecoveryTest { @Test fun generatedRecoveryCrossesTheNativeBoundaryAndCancelsWithoutPersistence() = runTest { - val runtime = NativeHarvestCircleRuntime.open(developmentMode = true) + val runtime = + NativeHarvestCircleRuntime.open( + desktopRuntimeOpenConfiguration( + developmentMode = true, + explicitDataDirectory = dataDirectory.toRealPath().toString(), + configuredRelays = "", + ), + ) try { runtime.bootstrap() val recovery = runtime.prepareLocalIdentity() diff --git a/app/desktop/src/test/kotlin/org/harvestcircle/desktop/MainTest.kt b/app/desktop/src/test/kotlin/org/harvestcircle/desktop/MainTest.kt @@ -76,6 +76,8 @@ class MainTest { val providedRoot = Files.createDirectory(parent.resolve("provided")) val output = mutableListOf<String>() var openCalls = 0 + val openedRoots = mutableListOf<java.nio.file.Path>() + val expectedRoots = listOf(createdRoot.toAbsolutePath().normalize(), providedRoot.toRealPath()) for (root in listOf(createdRoot, providedRoot)) { val result = @@ -84,6 +86,7 @@ class MainTest { runtimeOpener = PackagedHealthRuntimeOpener { ownedRoot -> openCalls += 1 + openedRoots.add(ownedRoot) Files.writeString(ownedRoot.resolve("runtime.sqlite3"), "runtime data") successfulRuntime() }, @@ -94,6 +97,7 @@ class MainTest { } assertEquals(2, openCalls) + assertEquals(expectedRoots, openedRoots) assertFalse(Files.exists(createdRoot)) assertTrue(Files.isDirectory(providedRoot)) Files.list(providedRoot).use { entries -> assertTrue(entries.findAny().isEmpty) } diff --git a/build-logic/contracts/src/main/kotlin/org/harvestcircle/buildlogic/contracts/FfiCompatibilityBaseline.kt b/build-logic/contracts/src/main/kotlin/org/harvestcircle/buildlogic/contracts/FfiCompatibilityBaseline.kt @@ -47,7 +47,7 @@ public class FfiCompatibilityBaseline private constructor( require(values.getValue("schema") == "harvestcircle.ffi.v4") require(values.getValue("contract.id") == "harvestcircle-desktop-ffi-v4") require(values.getValue("contract.major") == "4") - require(values.getValue("contract.minor") == "1") + require(values.getValue("contract.minor") == "2") require(values.getValue("snapshot.schema") == "1") require(values.getValue("storage.schema.minimum") == "5") require(values.getValue("storage.schema.current") == "10") diff --git a/build-logic/contracts/src/test/kotlin/org/harvestcircle/buildlogic/contracts/BuildContractsTest.kt b/build-logic/contracts/src/test/kotlin/org/harvestcircle/buildlogic/contracts/BuildContractsTest.kt @@ -77,7 +77,7 @@ class BuildContractsTest { assertFails { FfiCompatibilityBaseline.parse(ffiBaseline.replace("package.version=1.0.0", "package.version=invalid")) } assertFails { FfiCompatibilityBaseline.parse(ffiBaseline.replace("schema=harvestcircle.ffi.v4", "schema=harvestcircle.ffi.v3")) } assertFails { FfiCompatibilityBaseline.parse(ffiBaseline.replace("contract.major=4", "contract.major=3")) } - assertFails { FfiCompatibilityBaseline.parse(ffiBaseline.replace("contract.minor=1", "contract.minor=2")) } + assertFails { FfiCompatibilityBaseline.parse(ffiBaseline.replace("contract.minor=2", "contract.minor=1")) } } @Test @@ -189,7 +189,7 @@ class BuildContractsTest { schema=harvestcircle.ffi.v4 contract.id=harvestcircle-desktop-ffi-v4 contract.major=4 - contract.minor=1 + contract.minor=2 contract.hash=${"a".repeat(64)} product.coordinate_digest=${"b".repeat(64)} snapshot.schema=1 diff --git a/build-logic/plugins/src/functionalTest/kotlin/org/harvestcircle/buildlogic/plugins/ConventionPluginSmokeTest.kt b/build-logic/plugins/src/functionalTest/kotlin/org/harvestcircle/buildlogic/plugins/ConventionPluginSmokeTest.kt @@ -506,8 +506,8 @@ class ConventionPluginSmokeTest { schema=harvestcircle.ffi.v4 contract.id=harvestcircle-desktop-ffi-v4 contract.major=4 - contract.minor=1 - contract.hash=c7a84960e53cd9df35d676bab28294eb048a8b86c766d81cded2635b64a7f3d6 + contract.minor=2 + contract.hash=b32b9a47d12e445e93866ae0ab668b18de503ba6c999e3a053f26dc9509ddaf9 product.coordinate_digest=93bf10e334e989b20ba5fb8ed05e5d55b83f4502efba5f893aef4dc1a66c8223 snapshot.schema=1 storage.schema.minimum=5 diff --git a/core/compatibility/harvestcircle-ffi-v4.properties b/core/compatibility/harvestcircle-ffi-v4.properties @@ -1,8 +1,8 @@ schema=harvestcircle.ffi.v4 contract.id=harvestcircle-desktop-ffi-v4 contract.major=4 -contract.minor=1 -contract.hash=c7a84960e53cd9df35d676bab28294eb048a8b86c766d81cded2635b64a7f3d6 +contract.minor=2 +contract.hash=b32b9a47d12e445e93866ae0ab668b18de503ba6c999e3a053f26dc9509ddaf9 product.coordinate_digest=93bf10e334e989b20ba5fb8ed05e5d55b83f4502efba5f893aef4dc1a66c8223 snapshot.schema=1 storage.schema.minimum=5 diff --git a/core/crates/harvestcircle_ffi/build.rs b/core/crates/harvestcircle_ffi/build.rs @@ -223,7 +223,7 @@ fn validate_baseline_inputs(baseline: &BTreeMap<String, String>) { "harvestcircle-desktop-ffi-v4" ); assert_eq!(required(baseline, "contract.major"), "4"); - assert_eq!(required(baseline, "contract.minor"), "1"); + assert_eq!(required(baseline, "contract.minor"), "2"); assert_eq!(required(baseline, "snapshot.schema"), "1"); assert_eq!(required(baseline, "storage.schema.minimum"), "5"); assert_eq!(required(baseline, "storage.schema.current"), "10"); diff --git a/core/crates/harvestcircle_ffi/src/commands.rs b/core/crates/harvestcircle_ffi/src/commands.rs @@ -17,7 +17,6 @@ use harvestcircle_domain::{ use harvestcircle_nostr::SdkNostrClient; use harvestcircle_product::{ DATABASE_APPLICATION, DATABASE_FILENAME, DATABASE_ORGANIZATION, DATABASE_QUALIFIER, - DEVELOPMENT_DATA_DIR_ENVIRONMENT, }; use harvestcircle_runtime::{ RuntimeActorHandle, RuntimeDependencies, UuidInstallationIdentitySource, @@ -57,6 +56,14 @@ pub struct RelayBootstrapInputDto { #[derive(Clone, Debug, Eq, PartialEq)] #[cfg_attr(not(coverage_nightly), derive(uniffi::Record))] +pub struct RuntimeOpenInputDto { + pub development_mode: bool, + pub explicit_data_directory: Option<String>, + pub relay_input: RelayBootstrapInputDto, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +#[cfg_attr(not(coverage_nightly), derive(uniffi::Record))] pub struct IdentityCommandReceiptDto { pub request_id: String, pub committed_revision: u64, @@ -312,11 +319,10 @@ impl HarvestCircleAppCore { #[allow(clippy::needless_pass_by_value)] pub fn open_compatible( expectation: CompatibilityExpectation, - development_mode: bool, - relay_input: RelayBootstrapInputDto, + input: RuntimeOpenInputDto, ) -> Result<Arc<Self>, HarvestCircleError> { - let path = application_database_path(development_mode)?; - Self::open_path_compatible(&path, &expectation, relay_input) + let path = application_database_path(&input)?; + Self::open_path_compatible(&path, &expectation, input.relay_input) } /// Restores durable public application state. @@ -670,12 +676,28 @@ impl Clock for SystemClock { } } -// ProjectDirs and the process environment are host integration boundaries. +// ProjectDirs is the production host integration boundary. Development paths +// are supplied explicitly by the desktop host and never inferred from Rust +// process environment. #[cfg_attr(coverage_nightly, coverage(off))] -fn application_database_path(development_mode: bool) -> Result<PathBuf, HarvestCircleError> { - if development_mode && let Some(directory) = std::env::var_os(DEVELOPMENT_DATA_DIR_ENVIRONMENT) - { - return Ok(PathBuf::from(directory).join(DATABASE_FILENAME)); +fn application_database_path(input: &RuntimeOpenInputDto) -> Result<PathBuf, HarvestCircleError> { + if let Some(raw_directory) = input.explicit_data_directory.as_deref() { + if !input.development_mode || raw_directory.is_empty() { + return Err(path_unavailable()); + } + let directory = PathBuf::from(raw_directory); + if !directory.is_absolute() { + return Err(path_unavailable()); + } + let metadata = std::fs::symlink_metadata(&directory).map_err(|_| path_unavailable())?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(path_unavailable()); + } + let canonical = std::fs::canonicalize(&directory).map_err(|_| path_unavailable())?; + if canonical != directory { + return Err(path_unavailable()); + } + return Ok(canonical.join(DATABASE_FILENAME)); } ProjectDirs::from( DATABASE_QUALIFIER, @@ -814,10 +836,11 @@ mod tests { DATABASE_ORGANIZATION, DATABASE_QUALIFIER, FFI_CONTRACT_HASH, FFI_CONTRACT_ID, FFI_CONTRACT_MAJOR, FFI_CONTRACT_MINOR, HarvestCircleAppCore, HarvestCircleError, PRODUCT_COORDINATE_DIGEST, ProjectDirs, RelayBootstrapInputDto, RequestContextDto, - RuntimeCore, SNAPSHOT_SCHEMA_VERSION, SystemClock, WireErrorCategory, WireErrorCode, - WireRecoveryAction, actor_mailbox_capacity, compatibility_descriptor, confirmation_expired, - generated_commit_failed, local_first_relay_configuration, path_unavailable, runtime, - runtime_unavailable, verify_compatibility, + RuntimeCore, RuntimeOpenInputDto, SNAPSHOT_SCHEMA_VERSION, SystemClock, WireErrorCategory, + WireErrorCode, WireRecoveryAction, actor_mailbox_capacity, application_database_path, + compatibility_descriptor, confirmation_expired, generated_commit_failed, + local_first_relay_configuration, path_unavailable, runtime, runtime_unavailable, + verify_compatibility, }; async fn in_memory_core() -> Arc<HarvestCircleAppCore> { @@ -1193,6 +1216,70 @@ mod tests { } #[test] + fn explicit_development_data_directory_is_exact_and_fail_closed() { + let temporary = tempfile::tempdir().expect("directory"); + let canonical = temporary + .path() + .canonicalize() + .expect("canonical directory"); + let relay_input = RelayBootstrapInputDto { + endpoints: Vec::new(), + }; + let explicit = RuntimeOpenInputDto { + development_mode: true, + explicit_data_directory: Some(canonical.to_string_lossy().into_owned()), + relay_input: relay_input.clone(), + }; + assert_eq!( + application_database_path(&explicit).expect("explicit path"), + canonical.join(DATABASE_FILENAME), + ); + + for rejected in [ + RuntimeOpenInputDto { + development_mode: false, + explicit_data_directory: explicit.explicit_data_directory.clone(), + relay_input: relay_input.clone(), + }, + RuntimeOpenInputDto { + development_mode: true, + explicit_data_directory: Some("relative/data".to_owned()), + relay_input: relay_input.clone(), + }, + RuntimeOpenInputDto { + development_mode: true, + explicit_data_directory: Some( + canonical.join("missing").to_string_lossy().into_owned(), + ), + relay_input, + }, + ] { + assert!(application_database_path(&rejected).is_err()); + } + } + + #[cfg(unix)] + #[test] + fn explicit_development_data_directory_rejects_symbolic_links() { + use std::os::unix::fs::symlink; + + let temporary = tempfile::tempdir().expect("directory"); + let target = temporary.path().join("target"); + std::fs::create_dir(&target).expect("target"); + let link = temporary.path().join("link"); + symlink(&target, &link).expect("link"); + let input = RuntimeOpenInputDto { + development_mode: true, + explicit_data_directory: Some(link.to_string_lossy().into_owned()), + relay_input: RelayBootstrapInputDto { + endpoints: Vec::new(), + }, + }; + + assert!(application_database_path(&input).is_err()); + } + + #[test] fn superseded_v1_ffi_commands_are_absent() { let commands = include_str!("commands.rs"); let observer = include_str!("observer.rs"); diff --git a/core/crates/harvestcircle_ffi/src/contract.rs b/core/crates/harvestcircle_ffi/src/contract.rs @@ -2,7 +2,7 @@ pub const FFI_CONTRACT_ID: &str = env!("HARVESTCIRCLE_FFI_CONTRACT_ID"); pub const PRODUCT_VERSION: &str = env!("HARVESTCIRCLE_PRODUCT_VERSION"); pub const DISTRIBUTION_PACKAGE_VERSION: &str = env!("HARVESTCIRCLE_PACKAGE_VERSION"); pub const FFI_CONTRACT_MAJOR: u16 = 4; -pub const FFI_CONTRACT_MINOR: u16 = 1; +pub const FFI_CONTRACT_MINOR: u16 = 2; pub const PRODUCT_COORDINATE_DIGEST: &str = env!("HARVESTCIRCLE_PRODUCT_COORDINATE_DIGEST"); pub const SNAPSHOT_SCHEMA_VERSION: u32 = 1; pub const MINIMUM_SCHEMA_VERSION: u32 = 5; diff --git a/core/crates/harvestcircle_ffi/src/lib.rs b/core/crates/harvestcircle_ffi/src/lib.rs @@ -9,7 +9,7 @@ mod observer; pub use commands::{ BuildInfoDto, GeneratedRecoveryRequest, HarvestCircleAppCore, HarvestCircleError, IdentityCommandReceiptDto, RelayBootstrapInputDto, RemovalRequest, RequestContextDto, - build_info, + RuntimeOpenInputDto, build_info, }; pub use contract::{ DISTRIBUTION_PACKAGE_VERSION, FFI_CONTRACT_HASH, FFI_CONTRACT_ID, FFI_CONTRACT_MAJOR, @@ -52,7 +52,7 @@ mod tests { assert_eq!(env!("CARGO_PKG_VERSION"), "0.1.0-alpha"); assert_eq!(super::FFI_CONTRACT_ID, "harvestcircle-desktop-ffi-v4"); assert_eq!(super::FFI_CONTRACT_MAJOR, 4); - assert_eq!(super::FFI_CONTRACT_MINOR, 1); + assert_eq!(super::FFI_CONTRACT_MINOR, 2); assert_eq!(super::SNAPSHOT_SCHEMA_VERSION, 1); assert_eq!( super::PRODUCT_COORDINATE_DIGEST,