app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit 659b192545075967dae606c899e2e7bf7d66d06f
parent e5761180533e6479015c3c91d70bc525a9f3aab2
Author: triesap <tyson@radroots.org>
Date:   Tue, 11 Aug 2026 04:42:49 +0000

package: isolate packaged health-check data

- require a fresh absolute health data root before native open
- guard cleanup with a versioned create-new ownership marker
- preserve caller-owned roots and retain data when shutdown fails
- test invalid roots, redaction, cleanup, and packaged execution

Diffstat:
Mapp/desktop/src/main/kotlin/org/harvestcircle/desktop/Main.kt | 183+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mapp/desktop/src/test/kotlin/org/harvestcircle/desktop/MainTest.kt | 160+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbuild-logic/plugins/src/functionalTest/kotlin/org/harvestcircle/buildlogic/plugins/ConventionPluginSmokeTest.kt | 14+++++++++++++-
Mbuild-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/tasks/PackagingTasks.kt | 7++++++-
4 files changed, 340 insertions(+), 24 deletions(-)

diff --git a/app/desktop/src/main/kotlin/org/harvestcircle/desktop/Main.kt b/app/desktop/src/main/kotlin/org/harvestcircle/desktop/Main.kt @@ -20,6 +20,14 @@ import org.harvestcircle.ffi.compatibilityDescriptor import org.harvestcircle.identities.ui.StartupFailureScreen import java.awt.Dimension import java.awt.Taskbar +import java.nio.file.FileVisitResult +import java.nio.file.Files +import java.nio.file.LinkOption +import java.nio.file.Path +import java.nio.file.SimpleFileVisitor +import java.nio.file.StandardOpenOption +import java.nio.file.attribute.BasicFileAttributes +import java.security.SecureRandom import java.util.concurrent.Executors import java.util.concurrent.TimeUnit import java.util.concurrent.TimeoutException @@ -34,8 +42,12 @@ internal const val MINIMUM_WINDOW_HEIGHT = 720 internal const val HEALTH_CHECK_ARGUMENT = "--health-check" internal const val HEALTH_READY_EVIDENCE = "HARVESTCIRCLE_HEALTH_READY" internal const val HEALTH_CLOSED_EVIDENCE = "HARVESTCIRCLE_HEALTH_CLOSED" +internal const val HEALTH_DATA_ENVIRONMENT = "HARVESTCIRCLE_DEVELOPMENT_DATA_DIR" private const val HEALTH_FAILURE_EVIDENCE = "HARVESTCIRCLE_HEALTH_FAILED" private const val HEALTH_TIMEOUT_MILLIS = 90_000L +private const val HEALTH_OWNER_MARKER = ".harvestcircle-health-owner-v1" +private const val HEALTH_OWNER_TOKEN_BYTES = 16 +private val healthOwnershipRandom = SecureRandom() private val isMacOs: Boolean = System @@ -90,9 +102,15 @@ internal fun isHealthCheck(args: Array<String>): Boolean = args.size == 1 && arg private fun runHealthCheck(): Int { val executor = Executors.newSingleThreadExecutor() return try { - executor - .submit<Int> { runBlocking { executeHealthCheck() } } - .get(HEALTH_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS) + val healthCheck = + executor.submit<Int> { + runBlocking { + withTimeout(HEALTH_TIMEOUT_MILLIS) { + executeHealthCheck(System.getenv(HEALTH_DATA_ENVIRONMENT)) + } + } + } + healthCheck.get(HEALTH_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS) } catch (_: TimeoutException) { System.err.println("$HEALTH_FAILURE_EVIDENCE:TIMEOUT") 1 @@ -101,40 +119,161 @@ private fun runHealthCheck(): Int { } } -private suspend fun executeHealthCheck(): Int { - var runtime: NativeHarvestCircleRuntime? = null +internal interface PackagedHealthRuntime { + suspend fun bootstrapLifecycle(): ApplicationLifecycle + + suspend fun shutdownClosed(): Boolean +} + +internal fun interface PackagedHealthRuntimeOpener { + fun open(dataRoot: Path): PackagedHealthRuntime +} + +private class NativePackagedHealthRuntime( + private val runtime: NativeHarvestCircleRuntime, +) : PackagedHealthRuntime { + override suspend fun bootstrapLifecycle(): ApplicationLifecycle = runtime.bootstrap().lifecycle + + override suspend fun shutdownClosed(): Boolean = runtime.shutdown().closed +} + +internal suspend fun executeHealthCheck( + developmentDataDirectory: String?, + timeoutMillis: Long = HEALTH_TIMEOUT_MILLIS, + runtimeOpener: PackagedHealthRuntimeOpener = + PackagedHealthRuntimeOpener { + NativePackagedHealthRuntime(NativeHarvestCircleRuntime.open(developmentMode = true)) + }, + standardOutput: (String) -> Unit = ::println, + errorOutput: (String) -> Unit = System.err::println, +): Int { + var ownedRoot: OwnedHealthDataRoot? = null + var runtime: PackagedHealthRuntime? = null var closed = false - var stage = "OPEN" + var stage = "ROOT" + var failureEvidence: String? = null try { - withTimeout(HEALTH_TIMEOUT_MILLIS) { + withTimeout(timeoutMillis) { + ownedRoot = claimHealthDataRoot(developmentDataDirectory) stage = "COMPATIBILITY" verifyNativeCompatibility(compatibilityDescriptor()) stage = "OPEN" - runtime = NativeHarvestCircleRuntime.open(developmentMode = true) + runtime = runtimeOpener.open(requireNotNull(ownedRoot).path) stage = "BOOTSTRAP" - val snapshot = requireNotNull(runtime).bootstrap() + val lifecycle = requireNotNull(runtime).bootstrapLifecycle() stage = "READY" - require(snapshot.lifecycle in setOf(ApplicationLifecycle.Ready, ApplicationLifecycle.Degraded)) - println(HEALTH_READY_EVIDENCE) + require(lifecycle in setOf(ApplicationLifecycle.Ready, ApplicationLifecycle.Degraded)) + standardOutput(HEALTH_READY_EVIDENCE) stage = "SHUTDOWN" - val receipt = requireNotNull(runtime).shutdown() - require(receipt.closed) + require(requireNotNull(runtime).shutdownClosed()) closed = true - println(HEALTH_CLOSED_EVIDENCE) + standardOutput(HEALTH_CLOSED_EVIDENCE) } - return 0 + } catch (_: kotlinx.coroutines.TimeoutCancellationException) { + failureEvidence = "$HEALTH_FAILURE_EVIDENCE:TIMEOUT" } catch (error: HarvestCircleException.Failure) { - System.err.println("$HEALTH_FAILURE_EVIDENCE:$stage:${error.code}:${error.safeMessage}") - return 1 + failureEvidence = "$HEALTH_FAILURE_EVIDENCE:$stage:${error.code}:${error.safeMessage}" } catch (error: Exception) { - System.err.println("$HEALTH_FAILURE_EVIDENCE:$stage:${error.javaClass.simpleName}") - return 1 + failureEvidence = "$HEALTH_FAILURE_EVIDENCE:$stage:${error.javaClass.simpleName}" } finally { - if (!closed) { - runCatching { - withTimeout(HEALTH_TIMEOUT_MILLIS) { runtime?.shutdown() } + var safeToCleanup = runtime == null || closed + if (!closed && runtime != null) { + val shutdown = runCatching { withTimeout(timeoutMillis) { requireNotNull(runtime).shutdownClosed() } } + if (shutdown.getOrDefault(false)) { + closed = true + safeToCleanup = true + } else if (failureEvidence == null) { + failureEvidence = "$HEALTH_FAILURE_EVIDENCE:SHUTDOWN:IllegalStateException" } } + ownedRoot?.takeIf { safeToCleanup }?.let { root -> + runCatching { root.cleanup() } + .onFailure { + if (failureEvidence == null) { + failureEvidence = "$HEALTH_FAILURE_EVIDENCE:CLEANUP:${it.javaClass.simpleName}" + } + } + } + } + failureEvidence?.let(errorOutput) + return if (failureEvidence == null) 0 else 1 +} + +internal class OwnedHealthDataRoot internal constructor( + val path: Path, + private val marker: Path, + private val ownershipToken: String, + private val createdByHealthCheck: Boolean, +) { + fun cleanup() { + require(Files.exists(path, LinkOption.NOFOLLOW_LINKS) && Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) + requireNoSymbolicLinks(path) + require(path.toRealPath() == path) + require(Files.isRegularFile(marker, LinkOption.NOFOLLOW_LINKS) && !Files.isSymbolicLink(marker)) + require(Files.readString(marker) == ownershipToken) + + Files.walkFileTree( + path, + object : SimpleFileVisitor<Path>() { + override fun visitFile( + file: Path, + attributes: BasicFileAttributes, + ): FileVisitResult { + Files.delete(file) + return FileVisitResult.CONTINUE + } + + override fun postVisitDirectory( + directory: Path, + error: java.io.IOException?, + ): FileVisitResult { + if (error != null) throw error + if (directory != path || createdByHealthCheck) Files.delete(directory) + return FileVisitResult.CONTINUE + } + }, + ) + } +} + +internal fun claimHealthDataRoot(rawPath: String?): OwnedHealthDataRoot { + require(!rawPath.isNullOrBlank()) { "A dedicated health data root is required" } + val requested = Path.of(rawPath).normalize() + require(requested.isAbsolute) { "The health data root must be absolute" } + val parent = requireNotNull(requested.parent) { "The health data root must have a parent" } + requireNoSymbolicLinks(parent) + require(Files.isDirectory(parent, LinkOption.NOFOLLOW_LINKS)) { "The health data parent must exist" } + + val created = !Files.exists(requested, LinkOption.NOFOLLOW_LINKS) + if (created) { + Files.createDirectory(requested) + } else { + require(!Files.isSymbolicLink(requested)) { "The health data root cannot be a symbolic link" } + require(Files.isDirectory(requested, LinkOption.NOFOLLOW_LINKS)) { "The health data root must be a directory" } + Files.list(requested).use { entries -> require(entries.findAny().isEmpty) { "The health data root must be empty" } } + } + + return try { + requireNoSymbolicLinks(requested) + val canonical = requested.toRealPath() + val token = + ByteArray(HEALTH_OWNER_TOKEN_BYTES) + .also(healthOwnershipRandom::nextBytes) + .joinToString(separator = "") { byte -> "%02x".format(byte.toInt() and 0xff) } + val marker = canonical.resolve(HEALTH_OWNER_MARKER) + Files.writeString(marker, token, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE) + OwnedHealthDataRoot(canonical, marker, token, created) + } catch (error: Exception) { + if (created) runCatching { Files.deleteIfExists(requested) } + throw error + } +} + +private fun requireNoSymbolicLinks(path: Path) { + var current = requireNotNull(path.root) { "The health data root must be absolute" } + path.forEach { segment -> + current = current.resolve(segment) + require(!Files.isSymbolicLink(current)) { "The health data root cannot traverse a symbolic link" } } } diff --git a/app/desktop/src/test/kotlin/org/harvestcircle/desktop/MainTest.kt b/app/desktop/src/test/kotlin/org/harvestcircle/desktop/MainTest.kt @@ -1,8 +1,13 @@ package org.harvestcircle.desktop +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.harvestcircle.application.ApplicationLifecycle import java.io.ByteArrayInputStream +import java.nio.file.Files import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue @@ -28,4 +33,159 @@ class MainTest { assertFalse(isHealthCheck(emptyArray())) assertFalse(isHealthCheck(arrayOf(HEALTH_CHECK_ARGUMENT, "unexpected"))) } + + @Test + fun invalidHealthRootsFailBeforeTheRuntimeOpener() = + runBlocking { + val parent = Files.createTempDirectory("harvestcircle-health-invalid-").toRealPath() + val nonempty = Files.createDirectory(parent.resolve("nonempty")) + Files.writeString(nonempty.resolve("foreign"), "owned elsewhere") + val file = Files.writeString(parent.resolve("file"), "not a directory") + val target = Files.createDirectory(parent.resolve("target")) + var openCalls = 0 + val opener = + PackagedHealthRuntimeOpener { + openCalls += 1 + successfulRuntime() + } + + val invalidRoots = + mutableListOf<String?>( + null, + "", + "relative/path", + parent.resolve("missing-parent/root").toString(), + nonempty.toString(), + file.toString(), + ) + runCatching { Files.createSymbolicLink(parent.resolve("link"), target) } + .getOrNull() + ?.let { invalidRoots += it.toString() } + + invalidRoots.forEach { invalid -> + assertEquals(1, executeHealthCheck(invalid, runtimeOpener = opener, errorOutput = {})) + } + assertEquals(0, openCalls) + } + + @Test + fun healthCheckOwnsBootstrapsShutsDownAndCleansCreatedAndProvidedRoots() = + runBlocking { + val parent = Files.createTempDirectory("harvestcircle-health-valid-").toRealPath() + val createdRoot = parent.resolve("created") + val providedRoot = Files.createDirectory(parent.resolve("provided")) + val output = mutableListOf<String>() + var openCalls = 0 + + for (root in listOf(createdRoot, providedRoot)) { + val result = + executeHealthCheck( + root.toString(), + runtimeOpener = + PackagedHealthRuntimeOpener { ownedRoot -> + openCalls += 1 + Files.writeString(ownedRoot.resolve("runtime.sqlite3"), "runtime data") + successfulRuntime() + }, + standardOutput = output::add, + errorOutput = output::add, + ) + assertEquals(0, result) + } + + assertEquals(2, openCalls) + assertFalse(Files.exists(createdRoot)) + assertTrue(Files.isDirectory(providedRoot)) + Files.list(providedRoot).use { entries -> assertTrue(entries.findAny().isEmpty) } + assertEquals( + listOf(HEALTH_READY_EVIDENCE, HEALTH_CLOSED_EVIDENCE, HEALTH_READY_EVIDENCE, HEALTH_CLOSED_EVIDENCE), + output, + ) + } + + @Test + fun cleanupRefusesMissingOrChangedOwnershipMarkersWithoutDeletingData() { + val parent = Files.createTempDirectory("harvestcircle-health-marker-").toRealPath() + listOf("missing", "changed").forEach { caseName -> + val root = Files.createDirectory(parent.resolve(caseName)) + val ownership = claimHealthDataRoot(root.toString()) + val marker = Files.list(root).use { entries -> entries.findFirst().orElseThrow() } + val retained = Files.writeString(root.resolve("retained"), "caller data") + if (caseName == "missing") Files.delete(marker) else Files.writeString(marker, "different owner") + + assertFailsWith<IllegalArgumentException> { ownership.cleanup() } + assertTrue(Files.exists(retained)) + } + } + + @Test + fun timeoutAndFailureEvidenceDoNotExposeExceptionSecrets() = + runBlocking { + val parent = Files.createTempDirectory("harvestcircle-health-redaction-").toRealPath() + val secret = "nsec1must-not-escape" + val evidence = mutableListOf<String>() + val timeoutRuntime = + object : PackagedHealthRuntime { + override suspend fun bootstrapLifecycle(): ApplicationLifecycle { + delay(50) + return ApplicationLifecycle.Ready + } + + override suspend fun shutdownClosed(): Boolean = true + } + + assertEquals( + 1, + executeHealthCheck( + parent.resolve("timeout").toString(), + timeoutMillis = 1, + runtimeOpener = PackagedHealthRuntimeOpener { timeoutRuntime }, + errorOutput = evidence::add, + ), + ) + assertEquals( + 1, + executeHealthCheck( + parent.resolve("failure").toString(), + runtimeOpener = PackagedHealthRuntimeOpener { throw IllegalStateException(secret) }, + errorOutput = evidence::add, + ), + ) + assertTrue(evidence.any { it == "HARVESTCIRCLE_HEALTH_FAILED:TIMEOUT" }) + assertTrue(evidence.any { it.contains(":OPEN:IllegalStateException") }) + assertFalse(evidence.joinToString().contains(secret)) + assertFalse(Files.exists(parent.resolve("timeout"))) + assertFalse(Files.exists(parent.resolve("failure"))) + } + + @Test + fun failedShutdownRetainsTheOwnedRootInsteadOfDeletingLiveRuntimeData() = + runBlocking { + val parent = Files.createTempDirectory("harvestcircle-health-shutdown-").toRealPath() + val root = parent.resolve("retained") + val runtime = + object : PackagedHealthRuntime { + override suspend fun bootstrapLifecycle(): ApplicationLifecycle = ApplicationLifecycle.Ready + + override suspend fun shutdownClosed(): Boolean = false + } + + assertEquals( + 1, + executeHealthCheck( + root.toString(), + runtimeOpener = PackagedHealthRuntimeOpener { runtime }, + errorOutput = {}, + ), + ) + assertTrue(Files.isDirectory(root)) + Files.list(root).use { entries -> assertTrue(entries.findAny().isPresent) } + } } + +private fun successfulRuntime(): PackagedHealthRuntime = + object : PackagedHealthRuntime { + override suspend fun bootstrapLifecycle(): ApplicationLifecycle = ApplicationLifecycle.Ready + + override suspend fun shutdownClosed(): Boolean = true + } 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 @@ -296,7 +296,13 @@ class ConventionPluginSmokeTest { @Test fun packagingPluginLaunchesAndClosesThePackagedHealthEntry() { val fixture = createTempDirectory("harvestcircle-package-health-") - preparePackagingBuild(fixture, "printf 'HARVESTCIRCLE_HEALTH_READY\\nHARVESTCIRCLE_HEALTH_CLOSED\\n'") + preparePackagingBuild( + fixture, + "test -n \"\${HARVESTCIRCLE_DEVELOPMENT_DATA_DIR:-}\" && " + + "test -d \"\$HARVESTCIRCLE_DEVELOPMENT_DATA_DIR\" && " + + "test -z \"\$(find \"\$HARVESTCIRCLE_DEVELOPMENT_DATA_DIR\" -mindepth 1 -print -quit)\" && " + + "printf 'HARVESTCIRCLE_HEALTH_READY\\nHARVESTCIRCLE_HEALTH_CLOSED\\n'", + ) val result = GradleRunner.create() @@ -323,6 +329,12 @@ class ConventionPluginSmokeTest { "printf 'nsec1aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa HARVESTCIRCLE_HEALTH_READY HARVESTCIRCLE_HEALTH_CLOSED\\n'", "emitted secret material", ), + Triple( + "residue", + "touch \"\$HARVESTCIRCLE_DEVELOPMENT_DATA_DIR/leftover\"; " + + "printf 'HARVESTCIRCLE_HEALTH_READY\\nHARVESTCIRCLE_HEALTH_CLOSED\\n'", + "did not clean its isolated health data root", + ), ).forEach { (caseName, scriptBody, expected) -> val fixture = createTempDirectory("harvestcircle-package-$caseName-") preparePackagingBuild(fixture, scriptBody, timeoutSeconds = if (caseName == "timeout") 1L else 10L) diff --git a/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/tasks/PackagingTasks.kt b/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/tasks/PackagingTasks.kt @@ -16,6 +16,7 @@ import org.gradle.process.ExecOperations import org.gradle.work.DisableCachingByDefault import java.io.ByteArrayOutputStream import java.io.File +import java.nio.file.Files import java.util.concurrent.TimeUnit import java.util.jar.JarFile import javax.inject.Inject @@ -233,7 +234,7 @@ public abstract class VerifyPackagedApplicationHealth : DefaultTask() { @TaskAction public fun verify() { - val dataRoot = temporaryDir.resolve("isolated-data").apply { mkdirs() } + val dataRoot = Files.createTempDirectory(temporaryDir.toPath(), "isolated-data-").toFile() val process = ProcessBuilder(executable.get().asFile.absolutePath, "--health-check") .redirectErrorStream(true) @@ -250,6 +251,10 @@ public abstract class VerifyPackagedApplicationHealth : DefaultTask() { require(process.exitValue() == 0) { "Packaged application health-check failed" } require(output.contains(readyEvidence.get())) { "Packaged application did not report ready health evidence" } require(output.contains(closedEvidence.get())) { "Packaged application did not report closed health evidence" } + require(!dataRoot.exists() || dataRoot.list()?.isEmpty() == true) { + "Packaged application did not clean its isolated health data root" + } + if (dataRoot.exists()) require(dataRoot.delete()) { "Isolated health data root could not be removed" } } }