app

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

commit ff21938c14afb3ffad4dabb429486a311ccc7380
parent 7a6cd5b7a5fb1d7c5075a9ff2d129d2332fe0812
Author: triesap <tyson@radroots.org>
Date:   Mon, 10 Aug 2026 22:24:59 +0000

tools: move repository audits to xtask

- add four explicit Rust audit commands with Git and archive inventories
- enforce path, symlink, secret, namespace, Git-source, and provenance policy
- remove broad Gradle and buildSrc repository scans and source-fragment checks
- route foundation qualification and xtask formatting, linting, and tests through Make

Diffstat:
MMakefile | 8+++++++-
Dapp/desktop/src/test/kotlin/org/harvestcircle/architecture/ProductCoordinateConsumerTest.kt | 49-------------------------------------------------
Mbuild-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleDesktopAppPlugin.kt | 1-
Mbuild-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCirclePackagingPlugin.kt | 2--
Mbuild-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleRootPlugin.kt | 55-------------------------------------------------------
Dbuild-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/tasks/FoundationBoundaryAudit.kt | 312-------------------------------------------------------------------------------
Dbuild-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/tasks/GitSourcePolicy.kt | 102-------------------------------------------------------------------------------
Dbuild-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/tasks/ProductCoordinateConsumers.kt | 104-------------------------------------------------------------------------------
DbuildSrc/src/main/kotlin/org/harvestcircle/gradle/FoundationBoundaryAudit.kt | 311-------------------------------------------------------------------------------
DbuildSrc/src/main/kotlin/org/harvestcircle/gradle/GitSourcePolicy.kt | 102-------------------------------------------------------------------------------
DbuildSrc/src/main/kotlin/org/harvestcircle/gradle/ProductCoordinateConsumers.kt | 84-------------------------------------------------------------------------------
Atools/xtask/Cargo.lock | 7+++++++
Atools/xtask/Cargo.toml | 17+++++++++++++++++
Atools/xtask/src/lib.rs | 782+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atools/xtask/src/main.rs | 43+++++++++++++++++++++++++++++++++++++++++++
15 files changed, 856 insertions(+), 1123 deletions(-)

diff --git a/Makefile b/Makefile @@ -3,6 +3,7 @@ GRADLE ?= ./gradlew CARGO ?= cargo CARGO_MANIFEST := core/Cargo.toml +XTASK_MANIFEST := tools/xtask/Cargo.toml EXTBUILD ?= $(if $(shell cargo extbuild --version 2>/dev/null),cargo extbuild run --) .PHONY: help doctor lock metadata build-logic-check format format-fix lint test check build bindings dev run audit licenses foundation-check package host-package-check governed-package-check source-check package-check signing-check notarization-check release-check clean @@ -18,6 +19,7 @@ doctor: lock: doctor $(EXTBUILD) $(CARGO) generate-lockfile --manifest-path $(CARGO_MANIFEST) + $(EXTBUILD) $(CARGO) generate-lockfile --manifest-path $(XTASK_MANIFEST) metadata: doctor $(EXTBUILD) $(CARGO) metadata --manifest-path $(CARGO_MANIFEST) --locked --format-version 1 --no-deps @@ -27,18 +29,22 @@ build-logic-check: doctor format: doctor $(EXTBUILD) $(CARGO) fmt --manifest-path $(CARGO_MANIFEST) --all -- --check + $(EXTBUILD) $(CARGO) fmt --manifest-path $(XTASK_MANIFEST) --all -- --check $(EXTBUILD) $(GRADLE) --no-daemon :app:shared:ktlintCheck :app:desktop:ktlintCheck format-fix: doctor $(EXTBUILD) $(CARGO) fmt --manifest-path $(CARGO_MANIFEST) --all + $(EXTBUILD) $(CARGO) fmt --manifest-path $(XTASK_MANIFEST) --all $(EXTBUILD) $(GRADLE) --no-daemon :app:shared:ktlintFormat :app:desktop:ktlintFormat lint: doctor $(EXTBUILD) $(CARGO) clippy --manifest-path $(CARGO_MANIFEST) --workspace --all-targets --locked -- -D warnings + $(EXTBUILD) $(CARGO) clippy --manifest-path $(XTASK_MANIFEST) --all-targets --locked -- -D warnings $(EXTBUILD) $(GRADLE) --no-daemon :app:shared:detektCommonMainSourceSet :app:shared:detektCommonTestSourceSet :app:desktop:detekt test: doctor $(EXTBUILD) $(CARGO) test --manifest-path $(CARGO_MANIFEST) --workspace --locked + $(EXTBUILD) $(CARGO) test --manifest-path $(XTASK_MANIFEST) --locked $(EXTBUILD) $(GRADLE) --no-daemon :app:shared:desktopTest :app:desktop:test check: format lint test foundation-check @@ -67,7 +73,7 @@ licenses: doctor $(EXTBUILD) $(GRADLE) --no-daemon --no-parallel --no-configuration-cache :app:desktop:checkLicense foundation-check: doctor - $(EXTBUILD) $(GRADLE) --no-daemon :verifyFoundationBoundaries :verifyFoundationArchive + $(EXTBUILD) $(CARGO) run --manifest-path $(XTASK_MANIFEST) --locked -- qualification-report package: check $(EXTBUILD) $(GRADLE) --no-daemon :app:desktop:verifyHostPackage diff --git a/app/desktop/src/test/kotlin/org/harvestcircle/architecture/ProductCoordinateConsumerTest.kt b/app/desktop/src/test/kotlin/org/harvestcircle/architecture/ProductCoordinateConsumerTest.kt @@ -1,49 +0,0 @@ -package org.harvestcircle.architecture - -import java.nio.file.Files -import java.nio.file.Path -import java.util.Properties -import kotlin.io.path.inputStream -import kotlin.io.path.readText -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -class ProductCoordinateConsumerTest { - @Test - fun finalManifestDrivesBuildNativeStorageAndKeyringCoordinates() { - val root = findRepositoryRoot() - val coordinates = - Properties().apply { - root.resolve("config/product/harvestcircle-v1.properties").inputStream().use(::load) - } - - assertEquals("org.harvestcircle", coordinates.getProperty("kotlin.root_namespace")) - assertEquals("org.harvestcircle.desktop", coordinates.getProperty("desktop.application_id")) - assertEquals("org.harvestcircle.desktop", coordinates.getProperty("desktop.bundle_id")) - assertEquals("org.harvestcircle.desktop.MainKt", coordinates.getProperty("desktop.main_class")) - assertEquals("org.harvestcircle.ffi", coordinates.getProperty("ffi.kotlin_package")) - assertEquals("harvestcircle", coordinates.getProperty("database.organization")) - assertEquals("desktop", coordinates.getProperty("database.application")) - assertEquals("harvestcircle.sqlite3", coordinates.getProperty("database.filename")) - assertEquals("org.harvestcircle.desktop.nostr", coordinates.getProperty("keyring.service")) - - val build = - listOf( - "app/desktop/build.gradle.kts", - "build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleDesktopAppPlugin.kt", - "build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleRustFfiPlugin.kt", - "build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCirclePackagingPlugin.kt", - ).joinToString("\n") { relativePath -> root.resolve(relativePath).readText() } - assertTrue(build.contains("ProductCoordinates.load")) - assertTrue(build.contains("application.mainClass = mainClass")) - assertTrue(build.contains("mac.bundleID = bundleId")) - assertTrue(build.contains("expectedPackage.set(productCoordinates[\"ffi.kotlin_package\"])")) - assertFalse(Files.exists(root.resolve("core/compatibility/v5-baseline.properties"))) - } -} - -private fun findRepositoryRoot(): Path = - generateSequence(Path.of("").toAbsolutePath()) { it.parent } - .first { Files.isRegularFile(it.resolve("config/product/harvestcircle-v1.properties")) } diff --git a/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleDesktopAppPlugin.kt b/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleDesktopAppPlugin.kt @@ -208,7 +208,6 @@ public class HarvestCircleDesktopAppPlugin : Plugin<Project> { } target.tasks.named("check") { task -> task.dependsOn(target.rootProject.tasks.named("verifyProductCoordinates")) - task.dependsOn(target.rootProject.tasks.named("verifyProductCoordinateConsumers")) task.dependsOn(verify) } } diff --git a/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCirclePackagingPlugin.kt b/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCirclePackagingPlugin.kt @@ -236,8 +236,6 @@ public class HarvestCirclePackagingPlugin : Plugin<Project> { task.dependsOn( ":verifyProductCoordinates", ":verifyVerificationLanes", - ":verifyFoundationBoundaries", - ":verifyFoundationArchive", ":app:shared:check", "check", "verifyUniFfiBindings", diff --git a/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleRootPlugin.kt b/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleRootPlugin.kt @@ -2,9 +2,6 @@ package org.harvestcircle.buildlogic.plugins import org.gradle.api.Plugin import org.gradle.api.Project -import org.harvestcircle.buildlogic.plugins.tasks.VerifyFoundationBoundaries -import org.harvestcircle.buildlogic.plugins.tasks.VerifyGitSourcePolicy -import org.harvestcircle.buildlogic.plugins.tasks.VerifyProductCoordinateConsumers import org.harvestcircle.buildlogic.plugins.tasks.VerifyProductCoordinates import org.harvestcircle.buildlogic.plugins.tasks.VerifyVerificationLanes @@ -47,31 +44,6 @@ public class HarvestCircleRootPlugin : Plugin<Project> { task.description = "Validates canonical source provenance and its governed digest." task.dependsOn(verifyProductCoordinates) } - target.tasks.register("verifyProductCoordinateConsumers", VerifyProductCoordinateConsumers::class.java) { task -> - task.group = "verification" - task.description = "Validates that build and runtime identities consume the product manifest." - task.manifestFile.set(productCoordinatesFile) - task.desktopBuildFile.set(target.layout.projectDirectory.file("app/desktop/build.gradle.kts")) - task.desktopPluginFile.set( - target.layout.projectDirectory.file( - "build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleDesktopAppPlugin.kt", - ), - ) - task.rustPluginFile.set( - target.layout.projectDirectory.file( - "build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleRustFfiPlugin.kt", - ), - ) - task.packagingPluginFile.set( - target.layout.projectDirectory.file( - "build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCirclePackagingPlugin.kt", - ), - ) - task.uniFfiConfigFile.set(target.layout.projectDirectory.file("core/crates/harvestcircle_ffi/uniffi.toml")) - task.productBuildFile.set(target.layout.projectDirectory.file("core/crates/harvestcircle_product/build.rs")) - task.ffiConsumerFile.set(target.layout.projectDirectory.file("core/crates/harvestcircle_ffi/src/commands.rs")) - task.keyringConsumerFile.set(target.layout.projectDirectory.file("core/crates/harvestcircle_storage/src/os_keyring.rs")) - } target.tasks.register("verifyVerificationLanes", VerifyVerificationLanes::class.java) { task -> task.group = "verification" task.description = "Validates forge-agnostic verification lanes and least-privilege policy." @@ -79,33 +51,6 @@ public class HarvestCircleRootPlugin : Plugin<Project> { task.productManifestFile.set(productCoordinatesFile) task.repositoryRoot.set(target.layout.projectDirectory) } - val verifyGitSourcePolicy = - target.tasks.register("verifyGitSourcePolicy", VerifyGitSourcePolicy::class.java) { task -> - task.group = "verification" - task.description = "Validates immutable and allowlisted Cargo Git dependency sources." - task.denyConfigFile.set(target.layout.projectDirectory.file("core/deny.toml")) - task.cargoLockFile.set(target.layout.projectDirectory.file("core/Cargo.lock")) - task.cargoManifestFiles.from( - target.fileTree("core") { tree -> - tree.include("Cargo.toml", "crates/*/Cargo.toml") - }, - ) - } - target.tasks.register("verifyFoundationBoundaries", VerifyFoundationBoundaries::class.java) { task -> - task.group = "verification" - task.description = "Audits tracked sources against the HarvestCircle foundation boundaries." - task.repositoryRoot.set(target.layout.projectDirectory) - task.gitAware.set(true) - task.dependsOn(verifyGitSourcePolicy) - } - target.tasks.register("verifyFoundationArchive", VerifyFoundationBoundaries::class.java) { task -> - task.group = "verification" - task.description = "Audits a source-archive inventory without Git metadata." - task.repositoryRoot.set(target.layout.projectDirectory) - task.gitAware.set(false) - task.dependsOn(verifyGitSourcePolicy) - } - target.providers.environmentVariable("EXT_BUILD_GRADLE_BUILD_DIR").orNull?.let { outputRoot -> target.layout.buildDirectory.set(target.file(outputRoot).resolve("root")) } diff --git a/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/tasks/FoundationBoundaryAudit.kt b/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/tasks/FoundationBoundaryAudit.kt @@ -1,312 +0,0 @@ -package org.harvestcircle.buildlogic.plugins.tasks - -import org.harvestcircle.buildlogic.contracts.ProductCoordinates -import org.gradle.api.DefaultTask -import org.gradle.api.file.DirectoryProperty -import org.gradle.api.provider.Property -import org.gradle.api.tasks.Input -import org.gradle.api.tasks.Internal -import org.gradle.api.tasks.TaskAction -import java.nio.charset.StandardCharsets -import java.nio.file.Files -import java.nio.file.Path -import kotlin.io.path.extension -import kotlin.io.path.name -import kotlin.io.path.readText - -abstract class VerifyFoundationBoundaries : DefaultTask() { - @get:Internal - abstract val repositoryRoot: DirectoryProperty - - @get:Input - abstract val gitAware: Property<Boolean> - - @TaskAction - fun verify() { - val root = repositoryRoot.get().asFile.toPath() - val useGitInventory = gitAware.get() && Files.exists(root.resolve(".git")) - val paths = if (useGitInventory) trackedPaths(root) else archivePaths(root) - FoundationBoundaryAudit(root, paths).verify() - if (!gitAware.get()) { - verifyNegativeFixtures(root, paths) - } - } - - private fun trackedPaths(root: Path): List<String> { - val process = - ProcessBuilder("git", "-C", root.toString(), "ls-files", "-z") - .redirectErrorStream(true) - .start() - val output = process.inputStream.readAllBytes() - require(process.waitFor() == 0) { - "Unable to enumerate tracked HarvestCircle sources: ${output.toString(StandardCharsets.UTF_8)}" - } - return output - .toString(StandardCharsets.UTF_8) - .split('\u0000') - .filter(String::isNotEmpty) - .filter { Files.exists(root.resolve(it)) } - .sorted() - } - - private fun archivePaths(root: Path): List<String> = - Files.walk(root).use { paths -> - paths - .filter { path -> - path != root && - shouldInspect(root.relativize(path).toString().replace('\\', '/')) - }.map { root.relativize(it).toString().replace('\\', '/') } - .sorted() - .toList() - } - - private fun shouldInspect(relative: String): Boolean { - val segments = relative.split('/') - return segments.none { it in setOf(".git", ".gradle", ".kotlin", ".idea", "build", "target", "out") } - } - - private fun verifyNegativeFixtures( - root: Path, - paths: List<String>, - ) { - val fixtures = - listOf( - ".github/ISSUE_TEMPLATE/bug.md" to "# Bug report", - "app/shared/src/commonMain/kotlin/org/harvestcircle/application/Leak.kt" to - ("import org.harvestcircle." + "ffi.BuildInfoDto"), - "app/desktop/src/main/kotlin/org/harvestcircle/desktop/Blocking.kt" to - ("fun bad() = run" + "Blocking {}"), - "app/desktop/src/main/kotlin/org/harvestcircle/desktop/Counter.kt" to - ("val bad = Atomic" + "Long(0)"), - "core/target/generated/native.bin" to "generated", - "config/credentials/release.key" to "not-a-real-key", - ) - fixtures.forEach { (path, source) -> - check( - runCatching { - FoundationBoundaryAudit(root, paths + path, mapOf(path to source)).verify() - }.isFailure, - ) { "Foundation audit accepted negative fixture $path" } - } - val symlinkPath = "docs/escape.md" - check( - runCatching { - FoundationBoundaryAudit( - root, - paths + symlinkPath, - overrides = mapOf(symlinkPath to "outside"), - symbolicLinks = setOf(symlinkPath), - ).verify() - }.isFailure, - ) { "Foundation audit accepted symlink fixture $symlinkPath" } - val provenancePath = "core/provenance/" + "stu" + "dio-import-v1.toml" - val altered = root.resolve(provenancePath).readText().replace("09065a610d95e57acdc895a14c07580fa099e7c3", "0".repeat(40)) - check( - runCatching { - FoundationBoundaryAudit(root, paths, mapOf(provenancePath to altered)).verify() - }.isFailure, - ) { "Foundation audit accepted altered source provenance" } - } -} - -private class FoundationBoundaryAudit( - private val root: Path, - paths: List<String>, - private val overrides: Map<String, String> = emptyMap(), - private val symbolicLinks: Set<String> = emptySet(), -) { - private val inventory = paths.distinct().sorted() - private val legacyProduct = "stu" + "dio" - private val provenancePath = "core/provenance/$legacyProduct-import-v1.toml" - private val legacyRepository = "https://github.com/radrootslabs/${legacyProduct}_app" - private val temporaryNamespace = listOf("org", "radroots", "harvestcircle").joinToString(".") - private val textExtensions = - setOf("gradle", "json", "kt", "kts", "lock", "md", "properties", "rs", "sql", "toml", "txt", "xml", "yaml", "yml") - private val textNames = - setOf(".gitattributes", ".gitignore", "AGENTS.md", "LICENSE", "Makefile", "NOTICE", "gradlew", "gradlew.bat") - - fun verify() { - val findings = mutableListOf<String>() - inventory.forEach { relative -> - verifyPath(relative, findings) - if (isText(relative)) { - val source = overrides[relative] ?: readText(relative) - verifyText(relative, source, findings) - } - } - verifyExactContracts(findings) - check(findings.isEmpty()) { findings.sorted().joinToString("\n") } - } - - private fun verifyPath( - relative: String, - findings: MutableList<String>, - ) { - val normalized = relative.lowercase() - if (normalized.startsWith("docs/") || normalized.startsWith("spec/") || - normalized.startsWith(".github/") || normalized.startsWith(".act/")) { - findings += "$relative: forbidden repository root" - } - if (relative in symbolicLinks || Files.isSymbolicLink(root.resolve(relative))) { - findings += "$relative: symbolic links are not allowed in public sources" - } - if (normalized.startsWith("core/target/") || normalized.contains("/build/") || - normalized.contains("/generated/") || - normalized.contains("generated/uniffi") || normalized.endsWith(".dylib") || - normalized.endsWith(".so") || normalized.endsWith(".dll") || normalized.endsWith(".class") - ) { - findings += "$relative: generated build output must not be source controlled" - } - if (normalized.endsWith(".pem") || normalized.endsWith(".key") || normalized.endsWith(".p12") || - normalized.endsWith(".pfx") || normalized.endsWith(".jks") || normalized.endsWith(".keystore") || - normalized.endsWith(".env") || normalized.contains("/credentials/") - ) { - findings += "$relative: credential or secret-shaped source path" - } - if (relative != provenancePath && normalized.contains(legacyProduct)) { - findings += "$relative: legacy product name in source path" - } - val kotlinMarker = "/kotlin/" - if (normalized.startsWith("app/") && normalized.contains(kotlinMarker) && normalized.endsWith(".kt")) { - val packagePath = normalized.substringAfter(kotlinMarker) - if (!packagePath.startsWith("org/harvestcircle/")) { - findings += "$relative: Kotlin source is outside the final namespace" - } - } - } - - private fun verifyText( - relative: String, - source: String, - findings: MutableList<String>, - ) { - if (relative != provenancePath) { - var inspected = if (relative == "core/Cargo.toml") source.replace(legacyRepository, "") else source - if (relative == "NOTICE") { - val legacyDisplayName = legacyProduct.replaceFirstChar { it.uppercase() } - inspected = - inspected - .replace("Radroots $legacyDisplayName application work", "") - .replace("core/provenance/$legacyProduct-import-v1.toml", "") - } - if (inspected.lowercase().contains(legacyProduct)) { - findings += "$relative: legacy product name outside the exact provenance allowlist" - } - } - if (source.contains(temporaryNamespace) || source.contains(temporaryNamespace.replace('.', '/'))) { - findings += "$relative: temporary product namespace" - } - val productionKotlin = - relative.startsWith("app/") && - relative.endsWith(".kt") && - (relative.contains("/src/main/") || relative.contains("/src/commonMain/") || relative.contains("/src/desktopMain/")) - val boundedDesktopHealthBridge = - relative == "app/desktop/src/main/kotlin/org/harvestcircle/desktop/Main.kt" && - source.contains("HEALTH_CHECK_ARGUMENT") && - source.contains("withTimeout(HEALTH_TIMEOUT_MILLIS)") - if (productionKotlin && source.contains("run" + "Blocking") && !boundedDesktopHealthBridge) { - findings += "$relative: blocking coroutine bridge in application source" - } - if (productionKotlin && (source.contains("Atomic" + "Long") || source.contains("desktop" + "-operation:"))) { - findings += "$relative: process-local operation counter" - } - if (relative.startsWith("app/shared/src/commonMain/") && - listOf("org.harvestcircle." + "ffi", "com.sun." + "jna", "java.", "javax.").any(source::contains) - ) { - findings += "$relative: platform dependency in shared common source" - } - inheritedPreferenceTokens().filter(source.lowercase()::contains).forEach { token -> - findings += "$relative: inherited non-product preference $token" - } - val secretMarkers = - listOf( - "-----BEGIN " + "PRIVATE KEY-----", - "AWS_" + "SECRET_ACCESS_KEY=", - "gh" + "p_", - "sk_" + "live_", - ) - if (secretMarkers.any(source::contains)) { - findings += "$relative: credential or private-key material in source text" - } - if (productionKotlin && source.lowercase().contains("nsec1")) { - findings += "$relative: secret key literal in production Kotlin" - } - } - - private fun verifyExactContracts(findings: MutableList<String>) { - val requiredPublicFiles = - setOf( - "README.md", - "NOTICE", - "CONTRIBUTING.md", - "SECURITY.md", - "LICENSE", - "LICENSES/GPL-3.0-only.txt", - ) - (requiredPublicFiles - inventory.toSet()).sorted().forEach { relative -> - findings += "$relative: required public repository file is missing" - } - val cargo = text("core/Cargo.toml") - if (cargo.lineSequence().count { it.trim() == "repository = \"$legacyRepository\"" } != 1) { - findings += "core/Cargo.toml: legacy repository allowlist must be exact" - } - val provenance = text(provenancePath) - if (!provenance.contains("source_repository = \"$legacyRepository\"") || - !provenance.contains("canonical_radroots_revision = \"09065a610d95e57acdc895a14c07580fa099e7c3\"") || - !provenance.contains("foundation_baseline = \"a2038b3e25b9e34f0b8fd001f26a8ed10b5772cb\"") - ) { - findings += "$provenancePath: exact source provenance changed" - } - val productCoordinates = - runCatching { - ProductCoordinates.parse(text("config/product/harvestcircle-v1.properties")) - }.getOrElse { error -> - findings += "config/product/harvestcircle-v1.properties: ${error.message}" - null - } - val uniFfi = text("core/crates/harvestcircle_ffi/uniffi.toml") - if (!uniFfi.contains("[crates.harvestcircle_ffi.bindings.kotlin]") || - productCoordinates == null || - !uniFfi.contains("package_name = \"${productCoordinates["ffi.kotlin_package"]}\"") || - !uniFfi.contains("cdylib_name = \"${productCoordinates["ffi.cdylib_name"]}\"") - ) { - findings += "core/crates/harvestcircle_ffi/uniffi.toml: final FFI identity changed" - } - val baseline = text("core/compatibility/harvestcircle-ffi-v4.properties") - if (!baseline.contains("contract.id=harvestcircle-desktop-ffi-v4") || !baseline.contains("contract.major=4")) { - findings += "core/compatibility/harvestcircle-ffi-v4.properties: FFI v4 identity changed" - } - val sharedBuild = text("app/shared/build.gradle.kts") - if (!sharedBuild.contains("id(\"org.harvestcircle.build.kmp-shared\")") || - listOf("androidTarget", "iosArm", "iosX", "js(", "wasm").any(sharedBuild::contains) - ) { - findings += "app/shared/build.gradle.kts: shared KMP target boundary changed" - } - } - - private fun inheritedPreferenceTokens(): List<String> { - val separator = "_" - return listOf( - listOf("use", "radroots", "dns").joinToString(separator), - listOf("use", "radroots", "subnets").joinToString(separator), - listOf("vpn", "on", "demand", "enabled").joinToString(separator), - listOf("run", "as", "exit", "node").joinToString(separator), - listOf("automatically", "check", "for", "updates").joinToString(separator), - listOf("update", "channel").joinToString(separator), - listOf("last", "update", "check", "summary").joinToString(separator), - listOf("alternate", "server", "url").joinToString(separator), - ) - } - - private fun isText(relative: String): Boolean { - val path = Path.of(relative) - return path.extension in textExtensions || path.name in textNames - } - - private fun text(relative: String): String = overrides[relative] ?: readText(relative) - - private fun readText(relative: String): String { - val path = root.resolve(relative) - return if (Files.isRegularFile(path)) path.readText() else "" - } -} diff --git a/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/tasks/GitSourcePolicy.kt b/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/tasks/GitSourcePolicy.kt @@ -1,102 +0,0 @@ -package org.harvestcircle.buildlogic.plugins.tasks - -import org.gradle.api.DefaultTask -import org.gradle.api.file.ConfigurableFileCollection -import org.gradle.api.file.RegularFileProperty -import org.gradle.api.tasks.InputFile -import org.gradle.api.tasks.InputFiles -import org.gradle.api.tasks.PathSensitive -import org.gradle.api.tasks.PathSensitivity -import org.gradle.api.tasks.TaskAction - -object GitSourcePolicy { - private val gitExpression = Regex("""git\s*=\s*"([^"]+)"""") - private val revisionExpression = Regex("""rev\s*=\s*"([0-9a-f]{40})"""") - private val forbiddenSpec = Regex("""(?:branch|tag)\s*=""") - - fun validateDependency( - expression: String, - allowedGit: Set<String>, - ): String? { - val git = gitExpression.find(expression)?.groupValues?.get(1) ?: return null - require(git in allowedGit) { "Git dependency source is not allowlisted: $git" } - require(!forbiddenSpec.containsMatchIn(expression)) { "Git dependency uses a branch or tag" } - val revisions = revisionExpression.findAll(expression).map { it.groupValues[1] }.toList() - require(revisions.size == 1) { "Git dependency must use exactly one full revision pin" } - return revisions.single() - } -} - -abstract class VerifyGitSourcePolicy : DefaultTask() { - @get:InputFile - @get:PathSensitive(PathSensitivity.RELATIVE) - abstract val denyConfigFile: RegularFileProperty - - @get:InputFile - @get:PathSensitive(PathSensitivity.RELATIVE) - abstract val cargoLockFile: RegularFileProperty - - @get:InputFiles - @get:PathSensitive(PathSensitivity.RELATIVE) - abstract val cargoManifestFiles: ConfigurableFileCollection - - @TaskAction - fun verify() { - val denyConfig = denyConfigFile.get().asFile.readText() - check(Regex("(?m)^required-git-spec\\s*=\\s*\"rev\"$").containsMatchIn(denyConfig)) { - "cargo-deny must require revision-pinned Git sources" - } - val allowedGit = - Regex("(?s)allow-git\\s*=\\s*\\[(.*?)]") - .find(denyConfig) - ?.groupValues - ?.get(1) - ?.let { block -> Regex("\"([^\"]+)\"").findAll(block).map { it.groupValues[1] }.toSet() } - .orEmpty() - check(allowedGit.isNotEmpty()) { "cargo-deny Git allowlist is empty" } - - val revisions = mutableMapOf<String, MutableSet<String>>() - cargoManifestFiles.files.sortedBy { it.path }.forEach { manifest -> - manifest.readLines().forEachIndexed { index, line -> - if (!line.contains("git")) return@forEachIndexed - val git = Regex("""git\s*=\s*"([^"]+)""").find(line)?.groupValues?.get(1) ?: return@forEachIndexed - val revision = - runCatching { GitSourcePolicy.validateDependency(line, allowedGit) } - .getOrElse { error("${manifest.path}:${index + 1}: ${it.message}") } - ?: return@forEachIndexed - revisions.getOrPut(git) { mutableSetOf() } += revision - } - } - check(revisions.isNotEmpty()) { "No revision-pinned Git dependencies were inspected" } - check( - revisions["https://github.com/rust-nostr/nostr.git"] == - setOf("5bba5163eb77107f82c4a8262cf29d7f33a73219"), - ) { "The direct rust-nostr revision changed" } - - cargoLockFile.get().asFile.useLines { lines -> - lines.filter { it.startsWith("source = \"git+") }.forEach { source -> - check(Regex("\\?rev=[0-9a-f]{40}#[0-9a-f]{40}\"$").containsMatchIn(source)) { - "Cargo.lock contains a Git source without an immutable revision: $source" - } - } - } - - val allowed = allowedGit.first() - check( - GitSourcePolicy.validateDependency( - "dependency = { git = \"$allowed\", rev = \"${"a".repeat(40)}\" }", - allowedGit, - ) == "a".repeat(40), - ) - listOf( - "dependency = { git = \"$allowed\", branch = \"main\" }", - "dependency = { git = \"$allowed\", tag = \"v1.0.0\" }", - "dependency = { git = \"$allowed\" }", - "dependency = { git = \"https://example.invalid/repository\", rev = \"${"b".repeat(40)}\" }", - ).forEach { fixture -> - check(runCatching { GitSourcePolicy.validateDependency(fixture, allowedGit) }.isFailure) { - "Git source policy accepted a mutable or unknown fixture" - } - } - } -} diff --git a/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/tasks/ProductCoordinateConsumers.kt b/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/tasks/ProductCoordinateConsumers.kt @@ -1,104 +0,0 @@ -package org.harvestcircle.buildlogic.plugins.tasks - -import org.harvestcircle.buildlogic.contracts.ProductCoordinates -import org.gradle.api.DefaultTask -import org.gradle.api.file.RegularFileProperty -import org.gradle.api.tasks.InputFile -import org.gradle.api.tasks.PathSensitive -import org.gradle.api.tasks.PathSensitivity -import org.gradle.api.tasks.TaskAction - -abstract class VerifyProductCoordinateConsumers : DefaultTask() { - @get:InputFile - @get:PathSensitive(PathSensitivity.RELATIVE) - abstract val manifestFile: RegularFileProperty - - @get:InputFile - @get:PathSensitive(PathSensitivity.RELATIVE) - abstract val desktopBuildFile: RegularFileProperty - - @get:InputFile - @get:PathSensitive(PathSensitivity.RELATIVE) - abstract val desktopPluginFile: RegularFileProperty - - @get:InputFile - @get:PathSensitive(PathSensitivity.RELATIVE) - abstract val rustPluginFile: RegularFileProperty - - @get:InputFile - @get:PathSensitive(PathSensitivity.RELATIVE) - abstract val packagingPluginFile: RegularFileProperty - - @get:InputFile - @get:PathSensitive(PathSensitivity.RELATIVE) - abstract val uniFfiConfigFile: RegularFileProperty - - @get:InputFile - @get:PathSensitive(PathSensitivity.RELATIVE) - abstract val productBuildFile: RegularFileProperty - - @get:InputFile - @get:PathSensitive(PathSensitivity.RELATIVE) - abstract val ffiConsumerFile: RegularFileProperty - - @get:InputFile - @get:PathSensitive(PathSensitivity.RELATIVE) - abstract val keyringConsumerFile: RegularFileProperty - - @TaskAction - fun verify() { - val coordinates = ProductCoordinates.load(manifestFile.get().asFile) - val desktopBuild = - desktopBuildFile.get().asFile.readText() + - "\n" + - desktopPluginFile.get().asFile.readText() + - "\n" + - rustPluginFile.get().asFile.readText() + - "\n" + - packagingPluginFile.get().asFile.readText() - listOf( - "product.name", - "product.slug", - "desktop.application_id", - "desktop.bundle_id", - "desktop.main_class", - "ffi.kotlin_package", - "ffi.cdylib_name", - "environment.prefix", - "vendor.name", - "copyright.notice", - ).forEach { key -> - check(Regex("(?:productCoordinates|coordinates)\\[\\\"${Regex.escape(key)}\\\"\\]").containsMatchIn(desktopBuild)) { - "Desktop build logic does not consume product coordinate $key" - } - } - listOf( - "desktop.application_id", - "desktop.bundle_id", - "desktop.main_class", - "database.filename", - "keyring.service", - "environment.prefix", - ).forEach { key -> - check(!desktopBuild.contains("\"${coordinates[key]}\"")) { - "Desktop build duplicates the approved value for $key" - } - } - - val uniFfi = uniFfiConfigFile.get().asFile.readText() - check(uniFfi.contains("package_name = \"${coordinates["ffi.kotlin_package"]}\"")) - check(uniFfi.contains("cdylib_name = \"${coordinates["ffi.cdylib_name"]}\"")) - - val productBuild = productBuildFile.get().asFile.readText() - check(productBuild.contains("generate_rust_constants(&source)")) - val ffiConsumer = ffiConsumerFile.get().asFile.readText() - listOf( - "DATABASE_APPLICATION", - "DATABASE_FILENAME", - "DATABASE_ORGANIZATION", - "DATABASE_QUALIFIER", - "DEVELOPMENT_DATA_DIR_ENVIRONMENT", - ).forEach { constant -> check(ffiConsumer.contains(constant)) } - check(keyringConsumerFile.get().asFile.readText().contains("harvestcircle_product::KEYRING_SERVICE")) - } -} diff --git a/buildSrc/src/main/kotlin/org/harvestcircle/gradle/FoundationBoundaryAudit.kt b/buildSrc/src/main/kotlin/org/harvestcircle/gradle/FoundationBoundaryAudit.kt @@ -1,311 +0,0 @@ -package org.harvestcircle.gradle - -import org.gradle.api.DefaultTask -import org.gradle.api.file.DirectoryProperty -import org.gradle.api.provider.Property -import org.gradle.api.tasks.Input -import org.gradle.api.tasks.Internal -import org.gradle.api.tasks.TaskAction -import java.nio.charset.StandardCharsets -import java.nio.file.Files -import java.nio.file.Path -import kotlin.io.path.extension -import kotlin.io.path.name -import kotlin.io.path.readText - -abstract class VerifyFoundationBoundaries : DefaultTask() { - @get:Internal - abstract val repositoryRoot: DirectoryProperty - - @get:Input - abstract val gitAware: Property<Boolean> - - @TaskAction - fun verify() { - val root = repositoryRoot.get().asFile.toPath() - val useGitInventory = gitAware.get() && Files.exists(root.resolve(".git")) - val paths = if (useGitInventory) trackedPaths(root) else archivePaths(root) - FoundationBoundaryAudit(root, paths).verify() - if (!gitAware.get()) { - verifyNegativeFixtures(root, paths) - } - } - - private fun trackedPaths(root: Path): List<String> { - val process = - ProcessBuilder("git", "-C", root.toString(), "ls-files", "-z") - .redirectErrorStream(true) - .start() - val output = process.inputStream.readAllBytes() - require(process.waitFor() == 0) { - "Unable to enumerate tracked HarvestCircle sources: ${output.toString(StandardCharsets.UTF_8)}" - } - return output - .toString(StandardCharsets.UTF_8) - .split('\u0000') - .filter(String::isNotEmpty) - .filter { Files.exists(root.resolve(it)) } - .sorted() - } - - private fun archivePaths(root: Path): List<String> = - Files.walk(root).use { paths -> - paths - .filter { path -> - path != root && - shouldInspect(root.relativize(path).toString().replace('\\', '/')) - }.map { root.relativize(it).toString().replace('\\', '/') } - .sorted() - .toList() - } - - private fun shouldInspect(relative: String): Boolean { - val segments = relative.split('/') - return segments.none { it in setOf(".git", ".gradle", ".kotlin", ".idea", "build", "target", "out") } - } - - private fun verifyNegativeFixtures( - root: Path, - paths: List<String>, - ) { - val fixtures = - listOf( - ".github/ISSUE_TEMPLATE/bug.md" to "# Bug report", - "app/shared/src/commonMain/kotlin/org/harvestcircle/application/Leak.kt" to - ("import org.harvestcircle." + "ffi.BuildInfoDto"), - "app/desktop/src/main/kotlin/org/harvestcircle/desktop/Blocking.kt" to - ("fun bad() = run" + "Blocking {}"), - "app/desktop/src/main/kotlin/org/harvestcircle/desktop/Counter.kt" to - ("val bad = Atomic" + "Long(0)"), - "core/target/generated/native.bin" to "generated", - "config/credentials/release.key" to "not-a-real-key", - ) - fixtures.forEach { (path, source) -> - check( - runCatching { - FoundationBoundaryAudit(root, paths + path, mapOf(path to source)).verify() - }.isFailure, - ) { "Foundation audit accepted negative fixture $path" } - } - val symlinkPath = "docs/escape.md" - check( - runCatching { - FoundationBoundaryAudit( - root, - paths + symlinkPath, - overrides = mapOf(symlinkPath to "outside"), - symbolicLinks = setOf(symlinkPath), - ).verify() - }.isFailure, - ) { "Foundation audit accepted symlink fixture $symlinkPath" } - val provenancePath = "core/provenance/" + "stu" + "dio-import-v1.toml" - val altered = root.resolve(provenancePath).readText().replace("09065a610d95e57acdc895a14c07580fa099e7c3", "0".repeat(40)) - check( - runCatching { - FoundationBoundaryAudit(root, paths, mapOf(provenancePath to altered)).verify() - }.isFailure, - ) { "Foundation audit accepted altered source provenance" } - } -} - -private class FoundationBoundaryAudit( - private val root: Path, - paths: List<String>, - private val overrides: Map<String, String> = emptyMap(), - private val symbolicLinks: Set<String> = emptySet(), -) { - private val inventory = paths.distinct().sorted() - private val legacyProduct = "stu" + "dio" - private val provenancePath = "core/provenance/$legacyProduct-import-v1.toml" - private val legacyRepository = "https://github.com/radrootslabs/${legacyProduct}_app" - private val temporaryNamespace = listOf("org", "radroots", "harvestcircle").joinToString(".") - private val textExtensions = - setOf("gradle", "json", "kt", "kts", "lock", "md", "properties", "rs", "sql", "toml", "txt", "xml", "yaml", "yml") - private val textNames = - setOf(".gitattributes", ".gitignore", "AGENTS.md", "LICENSE", "Makefile", "NOTICE", "gradlew", "gradlew.bat") - - fun verify() { - val findings = mutableListOf<String>() - inventory.forEach { relative -> - verifyPath(relative, findings) - if (isText(relative)) { - val source = overrides[relative] ?: readText(relative) - verifyText(relative, source, findings) - } - } - verifyExactContracts(findings) - check(findings.isEmpty()) { findings.sorted().joinToString("\n") } - } - - private fun verifyPath( - relative: String, - findings: MutableList<String>, - ) { - val normalized = relative.lowercase() - if (normalized.startsWith("docs/") || normalized.startsWith("spec/") || - normalized.startsWith(".github/") || normalized.startsWith(".act/")) { - findings += "$relative: forbidden repository root" - } - if (relative in symbolicLinks || Files.isSymbolicLink(root.resolve(relative))) { - findings += "$relative: symbolic links are not allowed in public sources" - } - if (normalized.startsWith("core/target/") || normalized.contains("/build/") || - normalized.contains("/generated/") || - normalized.contains("generated/uniffi") || normalized.endsWith(".dylib") || - normalized.endsWith(".so") || normalized.endsWith(".dll") || normalized.endsWith(".class") - ) { - findings += "$relative: generated build output must not be source controlled" - } - if (normalized.endsWith(".pem") || normalized.endsWith(".key") || normalized.endsWith(".p12") || - normalized.endsWith(".pfx") || normalized.endsWith(".jks") || normalized.endsWith(".keystore") || - normalized.endsWith(".env") || normalized.contains("/credentials/") - ) { - findings += "$relative: credential or secret-shaped source path" - } - if (relative != provenancePath && normalized.contains(legacyProduct)) { - findings += "$relative: legacy product name in source path" - } - val kotlinMarker = "/kotlin/" - if (normalized.startsWith("app/") && normalized.contains(kotlinMarker) && normalized.endsWith(".kt")) { - val packagePath = normalized.substringAfter(kotlinMarker) - if (!packagePath.startsWith("org/harvestcircle/")) { - findings += "$relative: Kotlin source is outside the final namespace" - } - } - } - - private fun verifyText( - relative: String, - source: String, - findings: MutableList<String>, - ) { - if (relative != provenancePath) { - var inspected = if (relative == "core/Cargo.toml") source.replace(legacyRepository, "") else source - if (relative == "NOTICE") { - val legacyDisplayName = legacyProduct.replaceFirstChar { it.uppercase() } - inspected = - inspected - .replace("Radroots $legacyDisplayName application work", "") - .replace("core/provenance/$legacyProduct-import-v1.toml", "") - } - if (inspected.lowercase().contains(legacyProduct)) { - findings += "$relative: legacy product name outside the exact provenance allowlist" - } - } - if (source.contains(temporaryNamespace) || source.contains(temporaryNamespace.replace('.', '/'))) { - findings += "$relative: temporary product namespace" - } - val productionKotlin = - relative.startsWith("app/") && - relative.endsWith(".kt") && - (relative.contains("/src/main/") || relative.contains("/src/commonMain/") || relative.contains("/src/desktopMain/")) - val boundedDesktopHealthBridge = - relative == "app/desktop/src/main/kotlin/org/harvestcircle/desktop/Main.kt" && - source.contains("HEALTH_CHECK_ARGUMENT") && - source.contains("withTimeout(HEALTH_TIMEOUT_MILLIS)") - if (productionKotlin && source.contains("run" + "Blocking") && !boundedDesktopHealthBridge) { - findings += "$relative: blocking coroutine bridge in application source" - } - if (productionKotlin && (source.contains("Atomic" + "Long") || source.contains("desktop" + "-operation:"))) { - findings += "$relative: process-local operation counter" - } - if (relative.startsWith("app/shared/src/commonMain/") && - listOf("org.harvestcircle." + "ffi", "com.sun." + "jna", "java.", "javax.").any(source::contains) - ) { - findings += "$relative: platform dependency in shared common source" - } - inheritedPreferenceTokens().filter(source.lowercase()::contains).forEach { token -> - findings += "$relative: inherited non-product preference $token" - } - val secretMarkers = - listOf( - "-----BEGIN " + "PRIVATE KEY-----", - "AWS_" + "SECRET_ACCESS_KEY=", - "gh" + "p_", - "sk_" + "live_", - ) - if (secretMarkers.any(source::contains)) { - findings += "$relative: credential or private-key material in source text" - } - if (productionKotlin && source.lowercase().contains("nsec1")) { - findings += "$relative: secret key literal in production Kotlin" - } - } - - private fun verifyExactContracts(findings: MutableList<String>) { - val requiredPublicFiles = - setOf( - "README.md", - "NOTICE", - "CONTRIBUTING.md", - "SECURITY.md", - "LICENSE", - "LICENSES/GPL-3.0-only.txt", - ) - (requiredPublicFiles - inventory.toSet()).sorted().forEach { relative -> - findings += "$relative: required public repository file is missing" - } - val cargo = text("core/Cargo.toml") - if (cargo.lineSequence().count { it.trim() == "repository = \"$legacyRepository\"" } != 1) { - findings += "core/Cargo.toml: legacy repository allowlist must be exact" - } - val provenance = text(provenancePath) - if (!provenance.contains("source_repository = \"$legacyRepository\"") || - !provenance.contains("canonical_radroots_revision = \"09065a610d95e57acdc895a14c07580fa099e7c3\"") || - !provenance.contains("foundation_baseline = \"a2038b3e25b9e34f0b8fd001f26a8ed10b5772cb\"") - ) { - findings += "$provenancePath: exact source provenance changed" - } - val productCoordinates = - runCatching { - ProductCoordinates.parse(text("config/product/harvestcircle-v1.properties")) - }.getOrElse { error -> - findings += "config/product/harvestcircle-v1.properties: ${error.message}" - null - } - val uniFfi = text("core/crates/harvestcircle_ffi/uniffi.toml") - if (!uniFfi.contains("[crates.harvestcircle_ffi.bindings.kotlin]") || - productCoordinates == null || - !uniFfi.contains("package_name = \"${productCoordinates["ffi.kotlin_package"]}\"") || - !uniFfi.contains("cdylib_name = \"${productCoordinates["ffi.cdylib_name"]}\"") - ) { - findings += "core/crates/harvestcircle_ffi/uniffi.toml: final FFI identity changed" - } - val baseline = text("core/compatibility/harvestcircle-ffi-v4.properties") - if (!baseline.contains("contract.id=harvestcircle-desktop-ffi-v4") || !baseline.contains("contract.major=4")) { - findings += "core/compatibility/harvestcircle-ffi-v4.properties: FFI v4 identity changed" - } - val sharedBuild = text("app/shared/build.gradle.kts") - if (!sharedBuild.contains("id(\"org.harvestcircle.build.kmp-shared\")") || - listOf("androidTarget", "iosArm", "iosX", "js(", "wasm").any(sharedBuild::contains) - ) { - findings += "app/shared/build.gradle.kts: shared KMP target boundary changed" - } - } - - private fun inheritedPreferenceTokens(): List<String> { - val separator = "_" - return listOf( - listOf("use", "radroots", "dns").joinToString(separator), - listOf("use", "radroots", "subnets").joinToString(separator), - listOf("vpn", "on", "demand", "enabled").joinToString(separator), - listOf("run", "as", "exit", "node").joinToString(separator), - listOf("automatically", "check", "for", "updates").joinToString(separator), - listOf("update", "channel").joinToString(separator), - listOf("last", "update", "check", "summary").joinToString(separator), - listOf("alternate", "server", "url").joinToString(separator), - ) - } - - private fun isText(relative: String): Boolean { - val path = Path.of(relative) - return path.extension in textExtensions || path.name in textNames - } - - private fun text(relative: String): String = overrides[relative] ?: readText(relative) - - private fun readText(relative: String): String { - val path = root.resolve(relative) - return if (Files.isRegularFile(path)) path.readText() else "" - } -} diff --git a/buildSrc/src/main/kotlin/org/harvestcircle/gradle/GitSourcePolicy.kt b/buildSrc/src/main/kotlin/org/harvestcircle/gradle/GitSourcePolicy.kt @@ -1,102 +0,0 @@ -package org.harvestcircle.gradle - -import org.gradle.api.DefaultTask -import org.gradle.api.file.ConfigurableFileCollection -import org.gradle.api.file.RegularFileProperty -import org.gradle.api.tasks.InputFile -import org.gradle.api.tasks.InputFiles -import org.gradle.api.tasks.PathSensitive -import org.gradle.api.tasks.PathSensitivity -import org.gradle.api.tasks.TaskAction - -object GitSourcePolicy { - private val gitExpression = Regex("""git\s*=\s*"([^"]+)"""") - private val revisionExpression = Regex("""rev\s*=\s*"([0-9a-f]{40})"""") - private val forbiddenSpec = Regex("""(?:branch|tag)\s*=""") - - fun validateDependency( - expression: String, - allowedGit: Set<String>, - ): String? { - val git = gitExpression.find(expression)?.groupValues?.get(1) ?: return null - require(git in allowedGit) { "Git dependency source is not allowlisted: $git" } - require(!forbiddenSpec.containsMatchIn(expression)) { "Git dependency uses a branch or tag" } - val revisions = revisionExpression.findAll(expression).map { it.groupValues[1] }.toList() - require(revisions.size == 1) { "Git dependency must use exactly one full revision pin" } - return revisions.single() - } -} - -abstract class VerifyGitSourcePolicy : DefaultTask() { - @get:InputFile - @get:PathSensitive(PathSensitivity.RELATIVE) - abstract val denyConfigFile: RegularFileProperty - - @get:InputFile - @get:PathSensitive(PathSensitivity.RELATIVE) - abstract val cargoLockFile: RegularFileProperty - - @get:InputFiles - @get:PathSensitive(PathSensitivity.RELATIVE) - abstract val cargoManifestFiles: ConfigurableFileCollection - - @TaskAction - fun verify() { - val denyConfig = denyConfigFile.get().asFile.readText() - check(Regex("(?m)^required-git-spec\\s*=\\s*\"rev\"$").containsMatchIn(denyConfig)) { - "cargo-deny must require revision-pinned Git sources" - } - val allowedGit = - Regex("(?s)allow-git\\s*=\\s*\\[(.*?)]") - .find(denyConfig) - ?.groupValues - ?.get(1) - ?.let { block -> Regex("\"([^\"]+)\"").findAll(block).map { it.groupValues[1] }.toSet() } - .orEmpty() - check(allowedGit.isNotEmpty()) { "cargo-deny Git allowlist is empty" } - - val revisions = mutableMapOf<String, MutableSet<String>>() - cargoManifestFiles.files.sortedBy { it.path }.forEach { manifest -> - manifest.readLines().forEachIndexed { index, line -> - if (!line.contains("git")) return@forEachIndexed - val git = Regex("""git\s*=\s*"([^"]+)""").find(line)?.groupValues?.get(1) ?: return@forEachIndexed - val revision = - runCatching { GitSourcePolicy.validateDependency(line, allowedGit) } - .getOrElse { error("${manifest.path}:${index + 1}: ${it.message}") } - ?: return@forEachIndexed - revisions.getOrPut(git) { mutableSetOf() } += revision - } - } - check(revisions.isNotEmpty()) { "No revision-pinned Git dependencies were inspected" } - check( - revisions["https://github.com/rust-nostr/nostr.git"] == - setOf("5bba5163eb77107f82c4a8262cf29d7f33a73219"), - ) { "The direct rust-nostr revision changed" } - - cargoLockFile.get().asFile.useLines { lines -> - lines.filter { it.startsWith("source = \"git+") }.forEach { source -> - check(Regex("\\?rev=[0-9a-f]{40}#[0-9a-f]{40}\"$").containsMatchIn(source)) { - "Cargo.lock contains a Git source without an immutable revision: $source" - } - } - } - - val allowed = allowedGit.first() - check( - GitSourcePolicy.validateDependency( - "dependency = { git = \"$allowed\", rev = \"${"a".repeat(40)}\" }", - allowedGit, - ) == "a".repeat(40), - ) - listOf( - "dependency = { git = \"$allowed\", branch = \"main\" }", - "dependency = { git = \"$allowed\", tag = \"v1.0.0\" }", - "dependency = { git = \"$allowed\" }", - "dependency = { git = \"https://example.invalid/repository\", rev = \"${"b".repeat(40)}\" }", - ).forEach { fixture -> - check(runCatching { GitSourcePolicy.validateDependency(fixture, allowedGit) }.isFailure) { - "Git source policy accepted a mutable or unknown fixture" - } - } - } -} diff --git a/buildSrc/src/main/kotlin/org/harvestcircle/gradle/ProductCoordinateConsumers.kt b/buildSrc/src/main/kotlin/org/harvestcircle/gradle/ProductCoordinateConsumers.kt @@ -1,84 +0,0 @@ -package org.harvestcircle.gradle - -import org.gradle.api.DefaultTask -import org.gradle.api.file.RegularFileProperty -import org.gradle.api.tasks.InputFile -import org.gradle.api.tasks.PathSensitive -import org.gradle.api.tasks.PathSensitivity -import org.gradle.api.tasks.TaskAction - -abstract class VerifyProductCoordinateConsumers : DefaultTask() { - @get:InputFile - @get:PathSensitive(PathSensitivity.RELATIVE) - abstract val manifestFile: RegularFileProperty - - @get:InputFile - @get:PathSensitive(PathSensitivity.RELATIVE) - abstract val desktopBuildFile: RegularFileProperty - - @get:InputFile - @get:PathSensitive(PathSensitivity.RELATIVE) - abstract val uniFfiConfigFile: RegularFileProperty - - @get:InputFile - @get:PathSensitive(PathSensitivity.RELATIVE) - abstract val productBuildFile: RegularFileProperty - - @get:InputFile - @get:PathSensitive(PathSensitivity.RELATIVE) - abstract val ffiConsumerFile: RegularFileProperty - - @get:InputFile - @get:PathSensitive(PathSensitivity.RELATIVE) - abstract val keyringConsumerFile: RegularFileProperty - - @TaskAction - fun verify() { - val coordinates = ProductCoordinates.load(manifestFile.get().asFile) - val desktopBuild = desktopBuildFile.get().asFile.readText() - listOf( - "product.name", - "product.slug", - "desktop.application_id", - "desktop.bundle_id", - "desktop.main_class", - "ffi.kotlin_package", - "ffi.cdylib_name", - "environment.prefix", - "vendor.name", - "copyright.notice", - ).forEach { key -> - check(desktopBuild.contains("productCoordinates[\"$key\"]")) { - "Desktop build does not consume product coordinate $key" - } - } - listOf( - "desktop.application_id", - "desktop.bundle_id", - "desktop.main_class", - "database.filename", - "keyring.service", - "environment.prefix", - ).forEach { key -> - check(!desktopBuild.contains("\"${coordinates[key]}\"")) { - "Desktop build duplicates the approved value for $key" - } - } - - val uniFfi = uniFfiConfigFile.get().asFile.readText() - check(uniFfi.contains("package_name = \"${coordinates["ffi.kotlin_package"]}\"")) - check(uniFfi.contains("cdylib_name = \"${coordinates["ffi.cdylib_name"]}\"")) - - val productBuild = productBuildFile.get().asFile.readText() - check(productBuild.contains("generate_rust_constants(&source)")) - val ffiConsumer = ffiConsumerFile.get().asFile.readText() - listOf( - "DATABASE_APPLICATION", - "DATABASE_FILENAME", - "DATABASE_ORGANIZATION", - "DATABASE_QUALIFIER", - "DEVELOPMENT_DATA_DIR_ENVIRONMENT", - ).forEach { constant -> check(ffiConsumer.contains(constant)) } - check(keyringConsumerFile.get().asFile.readText().contains("harvestcircle_product::KEYRING_SERVICE")) - } -} diff --git a/tools/xtask/Cargo.lock b/tools/xtask/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "harvestcircle_xtask" +version = "0.1.0-alpha" diff --git a/tools/xtask/Cargo.toml b/tools/xtask/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "harvestcircle_xtask" +version = "0.1.0-alpha" +edition = "2024" +rust-version = "1.97.1" +license = "GPL-3.0-only" +publish = false + +[workspace] + +[lints.rust] +unsafe_code = "forbid" + +[lints.clippy] +dbg_macro = "deny" +todo = "deny" +unimplemented = "deny" diff --git a/tools/xtask/src/lib.rs b/tools/xtask/src/lib.rs @@ -0,0 +1,782 @@ +use std::collections::BTreeSet; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command as ProcessCommand; +use std::str::FromStr; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Command { + RepoAudit, + NamespaceAudit, + ProvenanceCheck, + QualificationReport, +} + +impl FromStr for Command { + type Err = String; + + fn from_str(value: &str) -> Result<Self, Self::Err> { + match value { + "repo-audit" => Ok(Self::RepoAudit), + "namespace-audit" => Ok(Self::NamespaceAudit), + "provenance-check" => Ok(Self::ProvenanceCheck), + "qualification-report" => Ok(Self::QualificationReport), + _ => Err(format!("unknown xtask command: {value}")), + } + } +} + +pub fn run(root: &Path, command: Command) -> Result<String, Vec<String>> { + let inventory = Inventory::load(root).map_err(|finding| vec![finding])?; + let mut findings = Vec::new(); + match command { + Command::RepoAudit => repo_audit(root, &inventory, &mut findings), + Command::NamespaceAudit => namespace_audit(root, &inventory, &mut findings), + Command::ProvenanceCheck => provenance_check(root, &inventory, &mut findings), + Command::QualificationReport => { + repo_audit(root, &inventory, &mut findings); + namespace_audit(root, &inventory, &mut findings); + provenance_check(root, &inventory, &mut findings); + } + } + findings.sort(); + findings.dedup(); + if findings.is_empty() { + let inventory_kind = if inventory.git_aware { + "git" + } else { + "archive" + }; + let command_name = match command { + Command::RepoAudit => "repo-audit", + Command::NamespaceAudit => "namespace-audit", + Command::ProvenanceCheck => "provenance-check", + Command::QualificationReport => "qualification-report", + }; + Ok(format!( + "harvestcircle.xtask.command={command_name}\nharvestcircle.xtask.inventory={inventory_kind}\nharvestcircle.xtask.result=pass\n" + )) + } else { + Err(findings) + } +} + +#[derive(Debug)] +struct Inventory { + paths: Vec<String>, + git_aware: bool, +} + +impl Inventory { + fn load(root: &Path) -> Result<Self, String> { + if root.join(".git").exists() { + let output = ProcessCommand::new("git") + .args([ + "-C", + &root.to_string_lossy(), + "ls-files", + "--cached", + "--others", + "--exclude-standard", + "-z", + ]) + .output() + .map_err(|error| { + format!("unable to enumerate tracked HarvestCircle sources: {error}") + })?; + if !output.status.success() { + return Err("unable to enumerate tracked HarvestCircle sources".to_owned()); + } + let mut paths = output + .stdout + .split(|byte| *byte == 0) + .filter(|path| !path.is_empty()) + .map(|path| String::from_utf8_lossy(path).replace('\\', "/")) + .filter(|path| root.join(path).symlink_metadata().is_ok()) + .collect::<Vec<_>>(); + paths.sort(); + paths.dedup(); + Ok(Self { + paths, + git_aware: true, + }) + } else { + let mut paths = Vec::new(); + archive_paths(root, root, &mut paths)?; + paths.sort(); + paths.dedup(); + Ok(Self { + paths, + git_aware: false, + }) + } + } +} + +fn archive_paths(root: &Path, directory: &Path, paths: &mut Vec<String>) -> Result<(), String> { + let entries = fs::read_dir(directory).map_err(|error| { + format!( + "{}: unable to read archive inventory: {error}", + directory.display() + ) + })?; + for entry in entries { + let entry = entry.map_err(|error| format!("archive inventory entry failed: {error}"))?; + let path = entry.path(); + let relative = relative(root, &path)?; + let first = relative.split('/').next().unwrap_or_default(); + if matches!( + first, + ".git" | ".gradle" | ".kotlin" | ".idea" | "build" | "target" | "out" + ) || relative + .split('/') + .any(|part| matches!(part, "build" | "target" | "out")) + { + continue; + } + paths.push(relative); + if entry + .file_type() + .map_err(|error| format!("unable to classify archive entry: {error}"))? + .is_dir() + { + archive_paths(root, &path, paths)?; + } + } + Ok(()) +} + +fn repo_audit(root: &Path, inventory: &Inventory, findings: &mut Vec<String>) { + let required = [ + "README.md", + "NOTICE", + "CONTRIBUTING.md", + "SECURITY.md", + "LICENSE", + "LICENSES/GPL-3.0-only.txt", + ]; + for required_path in required { + if !inventory.paths.iter().any(|path| path == required_path) { + findings.push(format!( + "{required_path}: required public repository file is missing" + )); + } + } + for path in &inventory.paths { + let normalized = path.to_ascii_lowercase(); + if ["docs/", "spec/", ".github/", ".act/"] + .iter() + .any(|prefix| normalized.starts_with(prefix)) + { + findings.push(format!("{path}: forbidden repository root")); + } + if fs::symlink_metadata(root.join(path)) + .is_ok_and(|metadata| metadata.file_type().is_symlink()) + { + findings.push(format!( + "{path}: symbolic links are not allowed in public sources" + )); + } + if normalized.starts_with("core/target/") + || normalized.contains("/build/") + || normalized.contains("/generated/") + || normalized.contains("generated/uniffi") + || [".dylib", ".so", ".dll", ".class"] + .iter() + .any(|suffix| normalized.ends_with(suffix)) + { + findings.push(format!( + "{path}: generated build output must not be source controlled" + )); + } + if [".pem", ".key", ".p12", ".pfx", ".jks", ".keystore", ".env"] + .iter() + .any(|suffix| normalized.ends_with(suffix)) + || normalized.contains("/credentials/") + { + findings.push(format!("{path}: credential or secret-shaped source path")); + } + if is_text(path) { + let source = read_text(root, path); + let markers = [ + ["-----BEGIN ", "PRIVATE KEY-----"].concat(), + ["AWS_", "SECRET_ACCESS_KEY="].concat(), + ["gh", "p_"].concat(), + ["sk_", "live_"].concat(), + ]; + if markers.iter().any(|marker| source.contains(marker)) { + findings.push(format!( + "{path}: credential or private-key material in source text" + )); + } + } + } + git_source_policy(root, findings); +} + +fn namespace_audit(root: &Path, inventory: &Inventory, findings: &mut Vec<String>) { + let legacy = ["stu", "dio"].concat(); + let provenance_path = format!("core/provenance/{legacy}-import-v1.toml"); + let legacy_repository = format!("https://github.com/radrootslabs/{legacy}_app"); + let temporary_namespace = ["org", "radroots", "harvestcircle"].join("."); + let inherited_preferences = [ + ["use", "radroots", "dns"].join("_"), + ["use", "radroots", "subnets"].join("_"), + ["vpn", "on", "demand", "enabled"].join("_"), + ["run", "as", "exit", "node"].join("_"), + ["automatically", "check", "for", "updates"].join("_"), + ["update", "channel"].join("_"), + ["last", "update", "check", "summary"].join("_"), + ["alternate", "server", "url"].join("_"), + ]; + for path in &inventory.paths { + let normalized = path.to_ascii_lowercase(); + if path != &provenance_path && normalized.contains(&legacy) { + findings.push(format!("{path}: legacy product name in source path")); + } + if normalized.starts_with("app/") + && normalized.contains("/kotlin/") + && normalized.ends_with(".kt") + { + let package_path = normalized + .split_once("/kotlin/") + .map(|(_, value)| value) + .unwrap_or_default(); + if !package_path.starts_with("org/harvestcircle/") { + findings.push(format!( + "{path}: Kotlin source is outside the final namespace" + )); + } + } + if !is_text(path) { + continue; + } + let source = read_text(root, path); + if path != &provenance_path { + let mut inspected = source.replace( + if path == "core/Cargo.toml" { + &legacy_repository + } else { + "__no_exact_allowlist__" + }, + "", + ); + if path == "NOTICE" { + inspected = inspected + .replace( + &format!("Radroots {} application work", title_case(&legacy)), + "", + ) + .replace(&provenance_path, ""); + } + if inspected.to_ascii_lowercase().contains(&legacy) { + findings.push(format!( + "{path}: legacy product name outside the exact provenance allowlist" + )); + } + } + if source.contains(&temporary_namespace) + || source.contains(&temporary_namespace.replace('.', "/")) + { + findings.push(format!("{path}: temporary product namespace")); + } + let production_kotlin = path.ends_with(".kt") + && path.starts_with("app/") + && ["/src/main/", "/src/commonMain/", "/src/desktopMain/"] + .iter() + .any(|segment| path.contains(segment)); + let bounded_health = path + == "app/desktop/src/main/kotlin/org/harvestcircle/desktop/Main.kt" + && source.contains("HEALTH_CHECK_ARGUMENT") + && source.contains("withTimeout(HEALTH_TIMEOUT_MILLIS)"); + if production_kotlin && source.contains(&["run", "Blocking"].concat()) && !bounded_health { + findings.push(format!( + "{path}: blocking coroutine bridge in application source" + )); + } + if production_kotlin + && (source.contains(&["Atomic", "Long"].concat()) + || source.contains(&["desktop", "-operation:"].concat())) + { + findings.push(format!("{path}: process-local operation counter")); + } + if path.starts_with("app/shared/src/commonMain/") + && [ + ["org.harvestcircle.", "ffi"].concat(), + ["com.sun.", "jna"].concat(), + "java.".to_owned(), + "javax.".to_owned(), + ] + .iter() + .any(|marker| source.contains(marker)) + { + findings.push(format!( + "{path}: platform dependency in shared common source" + )); + } + let lowercase = source.to_ascii_lowercase(); + for token in &inherited_preferences { + if lowercase.contains(token) { + findings.push(format!("{path}: inherited non-product preference {token}")); + } + } + if production_kotlin && lowercase.contains(&["nsec", "1"].concat()) { + findings.push(format!("{path}: secret key literal in production Kotlin")); + } + } +} + +fn provenance_check(root: &Path, inventory: &Inventory, findings: &mut Vec<String>) { + let legacy = ["stu", "dio"].concat(); + let legacy_repository = format!("https://github.com/radrootslabs/{legacy}_app"); + let provenance_path = format!("core/provenance/{legacy}-import-v1.toml"); + let cargo = read_text(root, "core/Cargo.toml"); + let repository_line = format!("repository = \"{legacy_repository}\""); + if cargo + .lines() + .filter(|line| line.trim() == repository_line) + .count() + != 1 + { + findings.push("core/Cargo.toml: legacy repository allowlist must be exact".to_owned()); + } + let provenance = read_text(root, &provenance_path); + if !provenance.contains(&format!("source_repository = \"{legacy_repository}\"")) + || !provenance + .contains("canonical_radroots_revision = \"09065a610d95e57acdc895a14c07580fa099e7c3\"") + || !provenance + .contains("foundation_baseline = \"a2038b3e25b9e34f0b8fd001f26a8ed10b5772cb\"") + { + findings.push(format!( + "{provenance_path}: exact source provenance changed" + )); + } + let coordinates = properties(&read_text( + root, + "config/product/harvestcircle-v1.properties", + )); + let uniffi = read_text(root, "core/crates/harvestcircle_ffi/uniffi.toml"); + let ffi_package = coordinates + .get("ffi.kotlin_package") + .map(String::as_str) + .unwrap_or_default(); + let cdylib = coordinates + .get("ffi.cdylib_name") + .map(String::as_str) + .unwrap_or_default(); + if !uniffi.contains("[crates.harvestcircle_ffi.bindings.kotlin]") + || !uniffi.contains(&format!("package_name = \"{ffi_package}\"")) + || !uniffi.contains(&format!("cdylib_name = \"{cdylib}\"")) + { + findings.push( + "core/crates/harvestcircle_ffi/uniffi.toml: final FFI identity changed".to_owned(), + ); + } + let baseline = read_text(root, "core/compatibility/harvestcircle-ffi-v4.properties"); + if !baseline.contains("contract.id=harvestcircle-desktop-ffi-v4") + || !baseline.contains("contract.major=4") + { + findings.push( + "core/compatibility/harvestcircle-ffi-v4.properties: FFI v4 identity changed" + .to_owned(), + ); + } + let shared_build = read_text(root, "app/shared/build.gradle.kts"); + if !shared_build.contains("id(\"org.harvestcircle.build.kmp-shared\")") + || ["androidTarget", "iosArm", "iosX", "js(", "wasm"] + .iter() + .any(|marker| shared_build.contains(marker)) + { + findings.push("app/shared/build.gradle.kts: shared KMP target boundary changed".to_owned()); + } + if !inventory.paths.iter().any(|path| path == &provenance_path) { + findings.push(format!( + "{provenance_path}: source provenance file is missing" + )); + } +} + +fn git_source_policy(root: &Path, findings: &mut Vec<String>) { + let deny = read_text(root, "core/deny.toml"); + if !deny + .lines() + .any(|line| line.trim() == "required-git-spec = \"rev\"") + { + findings + .push("core/deny.toml: cargo-deny must require revision-pinned Git sources".to_owned()); + } + let allowed_git = quoted_values(section_value(&deny, "allow-git")); + if allowed_git.is_empty() { + findings.push("core/deny.toml: cargo-deny Git allowlist is empty".to_owned()); + } + let mut inspected = false; + for manifest in cargo_manifests(root.join("core")) { + let source = fs::read_to_string(&manifest).unwrap_or_default(); + for (index, line) in source + .lines() + .enumerate() + .filter(|(_, line)| line.contains("git")) + { + let Some(git) = attribute(line, "git") else { + continue; + }; + inspected = true; + let relative_path = + relative(root, &manifest).unwrap_or_else(|_| manifest.display().to_string()); + if !allowed_git.contains(&git) { + findings.push(format!( + "{relative_path}:{}: Git dependency source is not allowlisted", + index + 1 + )); + } + if line.contains("branch =") || line.contains("tag =") { + findings.push(format!( + "{relative_path}:{}: Git dependency uses a branch or tag", + index + 1 + )); + } + let revision = attribute(line, "rev").unwrap_or_default(); + if !is_lower_hex(&revision, 40) { + findings.push(format!( + "{relative_path}:{}: Git dependency must use one full revision pin", + index + 1 + )); + } + if git == "https://github.com/rust-nostr/nostr.git" + && revision != "5bba5163eb77107f82c4a8262cf29d7f33a73219" + { + findings.push("core/Cargo.toml: direct rust-nostr revision changed".to_owned()); + } + } + } + if !inspected { + findings.push("core: no revision-pinned Git dependencies were inspected".to_owned()); + } + for line in read_text(root, "core/Cargo.lock") + .lines() + .filter(|line| line.starts_with("source = \"git+")) + { + let immutable = line.rsplit_once("?rev=").is_some_and(|(_, suffix)| { + suffix.len() == 82 + && suffix.as_bytes().get(40) == Some(&b'#') + && suffix.ends_with('"') + && is_lower_hex(&suffix[..40], 40) + && is_lower_hex(&suffix[41..81], 40) + }); + if !immutable { + findings.push(format!( + "core/Cargo.lock: Git source is not immutable: {line}" + )); + } + } +} + +fn cargo_manifests(core: PathBuf) -> Vec<PathBuf> { + let mut manifests = vec![core.join("Cargo.toml")]; + if let Ok(entries) = fs::read_dir(core.join("crates")) { + for entry in entries.flatten() { + let manifest = entry.path().join("Cargo.toml"); + if manifest.is_file() { + manifests.push(manifest); + } + } + } + manifests.sort(); + manifests +} + +fn properties(source: &str) -> std::collections::BTreeMap<String, String> { + source + .lines() + .filter_map(|line| { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + None + } else { + line.split_once('=') + .map(|(key, value)| (key.trim().to_owned(), value.trim().to_owned())) + } + }) + .collect() +} + +fn section_value<'a>(source: &'a str, key: &str) -> &'a str { + source + .split_once(key) + .map(|(_, tail)| tail.split_once(']').map_or(tail, |(value, _)| value)) + .unwrap_or_default() +} + +fn quoted_values(source: &str) -> BTreeSet<String> { + source + .split('"') + .enumerate() + .filter(|(index, _)| index % 2 == 1) + .map(|(_, value)| value.to_owned()) + .collect() +} + +fn attribute(source: &str, key: &str) -> Option<String> { + let tail = source.split_once(&format!("{key} = \""))?.1; + Some(tail.split_once('"')?.0.to_owned()) +} + +fn is_lower_hex(value: &str, length: usize) -> bool { + value.len() == length + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn title_case(value: &str) -> String { + let mut characters = value.chars(); + characters.next().map_or_else(String::new, |first| { + first.to_uppercase().collect::<String>() + characters.as_str() + }) +} + +fn is_text(relative: &str) -> bool { + let name = Path::new(relative) + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or_default(); + let extension = Path::new(relative) + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default(); + [ + "gradle", + "json", + "kt", + "kts", + "lock", + "md", + "properties", + "rs", + "sql", + "toml", + "txt", + "xml", + "yaml", + "yml", + ] + .contains(&extension) + || [ + ".gitattributes", + ".gitignore", + "AGENTS.md", + "LICENSE", + "Makefile", + "NOTICE", + "gradlew", + "gradlew.bat", + ] + .contains(&name) +} + +fn read_text(root: &Path, relative: &str) -> String { + fs::read_to_string(root.join(relative)).unwrap_or_default() +} + +fn relative(root: &Path, path: &Path) -> Result<String, String> { + path.strip_prefix(root) + .map(|relative| relative.to_string_lossy().replace('\\', "/")) + .map_err(|error| format!("{} is outside {}: {error}", path.display(), root.display())) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + #[test] + fn commands_are_exact_and_unknown_values_fail_closed() { + assert_eq!("repo-audit".parse(), Ok(Command::RepoAudit)); + assert_eq!("namespace-audit".parse(), Ok(Command::NamespaceAudit)); + assert_eq!("provenance-check".parse(), Ok(Command::ProvenanceCheck)); + assert_eq!( + "qualification-report".parse(), + Ok(Command::QualificationReport) + ); + assert!("all".parse::<Command>().is_err()); + } + + #[test] + fn archive_inventory_excludes_outputs_and_includes_source() { + let root = fixture("archive"); + write(&root, "src/main.rs", "fn main() {}\n"); + write(&root, "target/debug/generated.bin", "output"); + write(&root, "nested/build/generated.txt", "output"); + let inventory = Inventory::load(&root).expect("archive inventory"); + assert!(!inventory.git_aware); + assert!(inventory.paths.contains(&"src/main.rs".to_owned())); + assert!( + !inventory + .paths + .iter() + .any(|path| path.contains("target/") || path.contains("/build/")) + ); + fs::remove_dir_all(root).expect("remove fixture"); + } + + #[test] + fn repository_policy_rejects_secret_and_generated_shapes() { + let root = fixture("policy"); + write(&root, "README.md", "safe\n"); + write(&root, "config/credentials/release.key", "fixture\n"); + write(&root, "core/target/generated/native.bin", "fixture\n"); + write( + &root, + "safe.txt", + &["-----BEGIN ", "PRIVATE KEY-----"].concat(), + ); + write(&root, ".github/workflows/remote.yml", "fixture\n"); + let inventory = Inventory { + paths: vec![ + "README.md".to_owned(), + ".github/workflows/remote.yml".to_owned(), + "config/credentials/release.key".to_owned(), + "core/target/generated/native.bin".to_owned(), + "safe.txt".to_owned(), + ], + git_aware: true, + }; + let mut findings = Vec::new(); + repo_audit(&root, &inventory, &mut findings); + assert!( + findings + .iter() + .any(|finding| finding.contains("secret-shaped")) + ); + assert!( + findings + .iter() + .any(|finding| finding.contains("generated build output")) + ); + assert!( + findings + .iter() + .any(|finding| finding.contains("forbidden repository root")) + ); + assert!( + findings + .iter() + .any(|finding| finding.contains("private-key material")) + ); + fs::remove_dir_all(root).expect("remove fixture"); + } + + #[test] + fn namespace_policy_rejects_legacy_temporary_and_platform_sources() { + let root = fixture("namespace"); + let legacy = ["stu", "dio"].concat(); + write( + &root, + &format!("app/{legacy}/Leak.kt"), + &format!( + "package {}\n", + ["org", "radroots", "harvestcircle"].join(".") + ), + ); + write( + &root, + "app/shared/src/commonMain/kotlin/org/harvestcircle/Leak.kt", + "import com.sun.jna.Native\n", + ); + let inventory = Inventory::load(&root).expect("archive inventory"); + let mut findings = Vec::new(); + namespace_audit(&root, &inventory, &mut findings); + assert!( + findings + .iter() + .any(|finding| finding.contains("legacy product name")) + ); + assert!( + findings + .iter() + .any(|finding| finding.contains("temporary product namespace")) + ); + assert!( + findings + .iter() + .any(|finding| finding.contains("platform dependency")) + ); + fs::remove_dir_all(root).expect("remove fixture"); + } + + #[cfg(unix)] + #[test] + fn repository_policy_rejects_symbolic_links() { + use std::os::unix::fs::symlink; + let root = fixture("symlink"); + write(&root, "outside.txt", "outside\n"); + fs::create_dir_all(root.join("app")).expect("create app"); + symlink(root.join("outside.txt"), root.join("app/escape.txt")).expect("create symlink"); + let inventory = Inventory::load(&root).expect("archive inventory"); + let mut findings = Vec::new(); + repo_audit(&root, &inventory, &mut findings); + assert!( + findings + .iter() + .any(|finding| finding.contains("symbolic links")) + ); + fs::remove_dir_all(root).expect("remove fixture"); + } + + #[test] + fn mutable_git_dependency_and_provenance_mutation_fail_closed() { + let root = fixture("provenance"); + write( + &root, + "core/deny.toml", + "required-git-spec = \"rev\"\nallow-git = [\"https://example.invalid/lib\"]\n", + ); + write( + &root, + "core/Cargo.toml", + "[dependencies]\nlib = { git = \"https://example.invalid/lib\", branch = \"main\" }\n", + ); + write(&root, "core/Cargo.lock", ""); + let mut findings = Vec::new(); + git_source_policy(&root, &mut findings); + assert!( + findings + .iter() + .any(|finding| finding.contains("branch or tag")) + ); + assert!( + findings + .iter() + .any(|finding| finding.contains("full revision pin")) + ); + + findings.clear(); + let inventory = Inventory::load(&root).expect("archive inventory"); + provenance_check(&root, &inventory, &mut findings); + assert!( + findings + .iter() + .any(|finding| finding.contains("exact source provenance changed")) + ); + fs::remove_dir_all(root).expect("remove fixture"); + } + + fn fixture(name: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "harvestcircle-xtask-{name}-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&root).expect("create fixture"); + root + } + + fn write(root: &Path, relative: &str, source: &str) { + let path = root.join(relative); + fs::create_dir_all(path.parent().expect("fixture parent")).expect("create fixture parent"); + fs::write(path, source).expect("write fixture"); + } +} diff --git a/tools/xtask/src/main.rs b/tools/xtask/src/main.rs @@ -0,0 +1,43 @@ +use harvestcircle_xtask::{Command, run}; +use std::env; +use std::process::ExitCode; + +fn main() -> ExitCode { + let mut arguments = env::args().skip(1); + let Some(command) = arguments.next() else { + eprintln!( + "usage: cargo run --manifest-path tools/xtask/Cargo.toml -- <repo-audit|namespace-audit|provenance-check|qualification-report>" + ); + return ExitCode::FAILURE; + }; + if arguments.next().is_some() { + eprintln!("xtask commands do not accept positional arguments"); + return ExitCode::FAILURE; + } + let command = match command.parse::<Command>() { + Ok(command) => command, + Err(message) => { + eprintln!("{message}"); + return ExitCode::FAILURE; + } + }; + let root = match env::current_dir() { + Ok(root) => root, + Err(error) => { + eprintln!("unable to resolve the HarvestCircle repository root: {error}"); + return ExitCode::FAILURE; + } + }; + match run(&root, command) { + Ok(report) => { + print!("{report}"); + ExitCode::SUCCESS + } + Err(findings) => { + for finding in findings { + eprintln!("{finding}"); + } + ExitCode::FAILURE + } + } +}