commit 55bbcedf5679b5a15d4e7c2c25bee29825f009a6 parent 2c6c30842e47c8678b38b9732a16793bd7bdf31d Author: triesap <tyson@radroots.org> Date: Fri, 4 Sep 2026 21:47:37 +0000 Separate HarvestCircle unsigned candidate gates - freeze the exact macOS artifact and package-coordinate contract - bind unsigned readiness to the macOS aarch64 DMG without signer authority - fail closed on hostile, missing, linked, and nonregular Git inventory paths - preserve standalone and governed package surfaces with focused regressions Diffstat:
13 files changed, 630 insertions(+), 18 deletions(-)
diff --git a/Makefile b/Makefile @@ -17,10 +17,10 @@ else override BUILD_RUNNER := endif -.PHONY: help doctor governed-doctor lock metadata build-logic-check build-logic-stability-check mode-check design-source-check design-goldens-update format format-fix lint test check governed-check build bindings api-check dev-check dev run audit licenses foundation-check package host-package-check governed-package-check source-check governed-source-check package-check integration-check governed-integration-check development-provenance-check development-check governed-development-check governed-linux-x86_64-development-check host-ui-lifecycle-check acceptance-check signing-check _signing-check notarization-check _notarization-check release-check _release-check clean +.PHONY: help doctor governed-doctor lock metadata build-logic-check build-logic-stability-check mode-check design-source-check design-goldens-update format format-fix lint test check governed-check build bindings api-check dev-check dev run audit licenses foundation-check package host-package-check governed-package-check source-check governed-source-check package-check integration-check governed-integration-check development-provenance-check development-check governed-development-check governed-linux-x86_64-development-check host-ui-lifecycle-check acceptance-check unsigned-release-check _unsigned-release-check signing-check _signing-check notarization-check _notarization-check release-check _release-check clean help: - @printf '%s\n' doctor governed-doctor lock metadata build-logic-check build-logic-stability-check mode-check design-source-check design-goldens-update format format-fix lint test check governed-check build bindings api-check dev-check dev run audit licenses foundation-check package host-package-check governed-package-check source-check governed-source-check package-check integration-check governed-integration-check development-check governed-development-check governed-linux-x86_64-development-check host-ui-lifecycle-check acceptance-check signing-check notarization-check release-check clean + @printf '%s\n' doctor governed-doctor lock metadata build-logic-check build-logic-stability-check mode-check design-source-check design-goldens-update format format-fix lint test check governed-check build bindings api-check dev-check dev run audit licenses foundation-check package host-package-check governed-package-check source-check governed-source-check package-check integration-check governed-integration-check development-check governed-development-check governed-linux-x86_64-development-check host-ui-lifecycle-check acceptance-check unsigned-release-check signing-check notarization-check release-check clean design-source-check: doctor HARVESTCIRCLE_BUILD_MODE=$(BUILD_MODE) $(BUILD_RUNNER) $(CARGO) run --manifest-path $(XTASK_MANIFEST) --locked -- design-source-audit @@ -121,7 +121,7 @@ host-package-check: doctor $(BUILD_RUNNER) $(GRADLE) --no-daemon :app:desktop:verifyHostPackage governed-package-check: - $(MAKE) --no-print-directory BUILD_MODE=governed host-package-check + $(MAKE) --no-print-directory BUILD_MODE=governed package-check source-check: build-logic-check check bindings api-check licenses dev-check $(BUILD_RUNNER) $(GRADLE) --no-daemon :app:desktop:sourceReadiness @@ -129,8 +129,13 @@ source-check: build-logic-check check bindings api-check licenses dev-check governed-source-check: $(MAKE) --no-print-directory BUILD_MODE=governed source-check +package-check: export HARVESTCIRCLE_BUILD_SOURCE_COMMIT = $(shell git rev-parse --verify HEAD) +package-check: export HARVESTCIRCLE_BUILD_SOURCE_DIRTY = $(if $(strip $(shell git status --porcelain --untracked-files=all)),true,false) +package-check: export HARVESTCIRCLE_BUILD_RADROOTS_REVISION = $(shell sed -n 's/^revision = "\([0-9a-f]\{40\}\)"$$/\1/p' radroots.lib.source-lock.v1.toml) +package-check: export HARVESTCIRCLE_BUILD_RUST_TOOLCHAIN = 1.97.1 +package-check: export SOURCE_DATE_EPOCH = $(shell git show -s --format=%ct HEAD) package-check: source-check - $(BUILD_RUNNER) $(GRADLE) --no-daemon :app:desktop:packageReadiness + $(BUILD_RUNNER) $(GRADLE) --no-daemon --no-parallel --no-configuration-cache :app:desktop:unsignedReleaseReadiness integration-check: build-logic-check check $(BUILD_RUNNER) $(GRADLE) --no-daemon :app:desktop:integrationTest :app:desktop:verifyTestBridgeIsolation @@ -162,6 +167,20 @@ host-ui-lifecycle-check: doctor acceptance-check: integration-check host-package-check +unsigned-release-check: + $(MAKE) --no-print-directory BUILD_MODE=governed _unsigned-release-check + +_unsigned-release-check: export HARVESTCIRCLE_BUILD_SOURCE_COMMIT = $(shell git rev-parse --verify HEAD) +_unsigned-release-check: export HARVESTCIRCLE_BUILD_SOURCE_DIRTY = $(if $(strip $(shell git status --porcelain --untracked-files=all)),true,false) +_unsigned-release-check: export HARVESTCIRCLE_BUILD_RADROOTS_REVISION = $(shell sed -n 's/^revision = "\([0-9a-f]\{40\}\)"$$/\1/p' radroots.lib.source-lock.v1.toml) +_unsigned-release-check: export HARVESTCIRCLE_BUILD_RUST_TOOLCHAIN = 1.97.1 +_unsigned-release-check: export SOURCE_DATE_EPOCH = $(shell git show -s --format=%ct HEAD) +_unsigned-release-check: doctor + @test "$(BUILD_MODE)" = governed || { printf '%s\n' 'unsigned-release-check requires governed mode'; exit 2; } + $(BUILD_RUNNER) $(CARGO) audit --file core/Cargo.lock + $(BUILD_RUNNER) $(CARGO) deny --manifest-path $(CARGO_MANIFEST) check --config core/deny.toml advisories licenses sources + $(BUILD_RUNNER) $(GRADLE) --no-daemon --no-parallel --no-configuration-cache :app:desktop:unsignedReleaseReadiness + signing-check: $(MAKE) --no-print-directory BUILD_MODE=governed _signing-check diff --git a/build-logic/contracts/src/main/kotlin/org/harvestcircle/buildlogic/contracts/HarvestCircleArtifactContract.kt b/build-logic/contracts/src/main/kotlin/org/harvestcircle/buildlogic/contracts/HarvestCircleArtifactContract.kt @@ -0,0 +1,101 @@ +package org.harvestcircle.buildlogic.contracts + +import java.io.File +import java.nio.ByteBuffer +import java.nio.channels.SeekableByteChannel +import java.nio.file.Files +import java.nio.file.LinkOption +import java.nio.file.Path +import java.nio.file.StandardOpenOption +import java.nio.file.attribute.BasicFileAttributes + +public object HarvestCircleArtifactContract { + private const val MAX_CONTRACT_BYTES: Long = 64L * 1024L + private const val PRODUCT_NAME: String = "HarvestCircle" + private const val IDENTITY: String = "org.harvestcircle.desktop" + private const val PRODUCT_VERSION: String = "0.1.0-alpha" + private const val PACKAGE_VERSION: String = "1.0.0" + private const val BUILD_VERSION: String = "1" + private const val FILE_NAME: String = "HarvestCircle-1.0.0.dmg" + + public const val CANONICAL_JSON: String = + "{\"artifact_policy\":{\"checksums\":\"required\",\"cyclonedx_version\":\"1.6\",\"exact_tree_source_archives\":\"required\",\"fresh_install\":\"required\",\"git_history_bundles\":\"forbidden\",\"intoto_statement\":\"required\",\"notices\":\"required\",\"reproducibility_build_count\":2,\"secret_scan\":\"required\",\"unsigned_packages\":\"required\",\"unsigned_slsa_provenance\":\"required\"},\"contract_version\":3,\"delivery\":{\"candidate_class\":\"unsigned_nonpublishing\",\"developer_id_signing\":\"forbidden\",\"developer_team_id\":\"forbidden\",\"distribution_signing\":\"forbidden\",\"embedded_platform_adhoc_signing\":\"permitted_non_distribution_only\",\"g2\":\"unauthorized\",\"notarization\":\"unauthorized\",\"production_activation\":\"unauthorized\",\"publication\":\"unauthorized\",\"signing\":\"unauthorized\"},\"implementation_owner_step\":290,\"output\":[{\"classification\":\"production\",\"id\":\"unsigned_macos_package\",\"platforms\":[\"macos_aarch64\"]}],\"package_contract\":{\"build_version\":\"1\",\"filename\":\"HarvestCircle-1.0.0.dmg\",\"format\":\"dmg\",\"identity\":\"org.harvestcircle.desktop\",\"package_version\":\"1.0.0\",\"product_name\":\"HarvestCircle\",\"product_version\":\"0.1.0-alpha\"},\"platforms\":[\"macos_aarch64\"],\"producer\":{\"command_authority\":\"gradle_wrapper\",\"kind\":\"compose_desktop_native_distributions_jpackage\",\"nix_binding\":\"forbidden\",\"nix_produced\":false,\"source_task\":\"packageDmg\"},\"repository\":\"oss/harvestcircle\",\"schema\":\"radroots.release.artifact-contract.v3\",\"source_archive\":{\"binding\":\"canonical_exact_source_revision_tree_archive\",\"compression\":\"none\",\"compression_timestamp\":\"not_applicable\",\"content\":\"exact_source_revision_tree\",\"directory_entries\":\"omitted\",\"entry_order\":\"bytewise_git_path\",\"file_mode\":\"git_index_100644_or_100755\",\"format\":\"ustar\",\"gid\":0,\"git_history\":\"forbidden\",\"gname\":\"\",\"hardlinks\":\"forbidden\",\"mtime_source\":\"candidate_source_date_epoch\",\"path_prefix\":\"none\",\"pax_headers\":\"forbidden\",\"submodules\":\"forbidden\",\"symlinks\":\"forbidden\",\"trailer\":\"two_zero_blocks\",\"uid\":0,\"uname\":\"\"},\"source_binding\":{\"dirty_tree\":\"forbidden\",\"kind\":\"exact_clean_git_commit\",\"revision_location\":\"aggregate_source_revision\"},\"sqlite\":{\"high_level_authority\":\"sqlx_only\",\"incremental_backup_adapter\":\"sealed_native_sqlx_owned_locked_handle_only\",\"native_linkage_count\":1,\"second_pool_connection_query_transaction_migration_authority\":\"forbidden\"}}" + + public fun load( + contractFile: File, + repositoryRoot: File, + ) { + val root = repositoryRoot.toPath().toAbsolutePath().normalize() + val contractPath = contractFile.toPath().toAbsolutePath().normalize() + require(contractPath.startsWith(root)) { "Artifact contract must be inside the repository root" } + val bytes = readBoundedNoFollow(root, root.relativize(contractPath)) + require(bytes.contentEquals(CANONICAL_JSON.toByteArray(Charsets.UTF_8))) { + "HarvestCircle artifact contract differs from canonical compact JSON" + } + } + + public fun parse(source: String) { + require(source == CANONICAL_JSON) { + "HarvestCircle artifact contract differs from canonical compact JSON" + } + } + + public fun validatePackageCoordinates( + productName: String, + identity: String, + productVersion: String, + packageVersion: String, + buildVersion: String, + fileName: String, + ) { + require(productName == PRODUCT_NAME) { "Artifact product name differs from product coordinates" } + require(identity == IDENTITY) { "Artifact identity differs from product coordinates" } + require(productVersion == PRODUCT_VERSION) { "Artifact product version differs from the FFI baseline" } + require(packageVersion == PACKAGE_VERSION) { "Artifact package version differs from the FFI baseline" } + require(buildVersion == BUILD_VERSION) { "Artifact build version differs from the package convention" } + require(fileName == FILE_NAME) { "Artifact filename differs from the package convention" } + } + + private fun readBoundedNoFollow( + root: Path, + relative: Path, + ): ByteArray { + require(!relative.isAbsolute && relative.nameCount > 0 && relative.normalize() == relative) { + "Artifact contract path must be normalized and relative" + } + var current = root + relative.forEachIndexed { index, component -> + current = current.resolve(component) + val attributes = + Files.readAttributes( + current, + BasicFileAttributes::class.java, + LinkOption.NOFOLLOW_LINKS, + ) + require(!attributes.isSymbolicLink) { "Artifact contract path must not traverse a symbolic link" } + if (index < relative.nameCount - 1) { + require(attributes.isDirectory) { "Artifact contract path parent must be a directory" } + } else { + require(attributes.isRegularFile) { "Artifact contract path must identify a regular file" } + require(attributes.size() <= MAX_CONTRACT_BYTES) { "Artifact contract exceeds its byte limit" } + } + } + return Files.newByteChannel( + current, + StandardOpenOption.READ, + LinkOption.NOFOLLOW_LINKS, + ).use(::readBounded) + } + + private fun readBounded(channel: SeekableByteChannel): ByteArray { + val admittedSize = channel.size() + require(admittedSize <= MAX_CONTRACT_BYTES) { "Artifact contract exceeds its byte limit" } + val bytes = ByteArray(admittedSize.toInt()) + val buffer = ByteBuffer.wrap(bytes) + while (buffer.hasRemaining()) { + require(channel.read(buffer) >= 0) { "Artifact contract was truncated while reading" } + } + require(channel.read(ByteBuffer.allocate(1)) == -1) { "Artifact contract grew beyond its admitted size" } + return bytes + } +} diff --git a/build-logic/contracts/src/test/kotlin/org/harvestcircle/buildlogic/contracts/BuildContractsTest.kt b/build-logic/contracts/src/test/kotlin/org/harvestcircle/buildlogic/contracts/BuildContractsTest.kt @@ -15,6 +15,75 @@ import kotlin.test.assertTrue class BuildContractsTest { @Test + fun harvestCircleArtifactContractRequiresExactCanonicalUnsignedBytes() { + val root = createTempDirectory("harvestcircle-artifact-contract-") + val contract = + root.resolve("contracts/release/harvestcircle-artifact-contract.v3.json") + contract.parent.createDirectories() + contract.writeText(HarvestCircleArtifactContract.CANONICAL_JSON) + + HarvestCircleArtifactContract.load(contract.toFile(), root.toFile()) + HarvestCircleArtifactContract.parse(HarvestCircleArtifactContract.CANONICAL_JSON) + + assertFails { + HarvestCircleArtifactContract.parse(HarvestCircleArtifactContract.CANONICAL_JSON + "\n") + } + listOf( + "\"identity\":\"org.harvestcircle.desktop\"" to "\"identity\":\"invalid\"", + "\"product_name\":\"HarvestCircle\"" to "\"product_name\":\"Invalid\"", + "\"product_version\":\"0.1.0-alpha\"" to "\"product_version\":\"0.1.1\"", + "\"package_version\":\"1.0.0\"" to "\"package_version\":\"1.0.1\"", + "\"build_version\":\"1\"" to "\"build_version\":\"2\"", + "\"filename\":\"HarvestCircle-1.0.0.dmg\"" to "\"filename\":\"HarvestCircle.dmg\"", + "\"source_task\":\"packageDmg\"" to "\"source_task\":\"releaseReadiness\"", + "\"g2\":\"unauthorized\"" to "\"g2\":\"authorized\"", + "\"signing\":\"unauthorized\"" to "\"signing\":\"required\"", + "\"submodules\":\"forbidden\"," to "", + ).forEach { (original, replacement) -> + assertFails { + HarvestCircleArtifactContract.parse( + HarvestCircleArtifactContract.CANONICAL_JSON.replace(original, replacement), + ) + } + } + + HarvestCircleArtifactContract.validatePackageCoordinates( + productName = "HarvestCircle", + identity = "org.harvestcircle.desktop", + productVersion = "0.1.0-alpha", + packageVersion = "1.0.0", + buildVersion = "1", + fileName = "HarvestCircle-1.0.0.dmg", + ) + assertFails { + HarvestCircleArtifactContract.validatePackageCoordinates( + productName = "HarvestCircle", + identity = "org.harvestcircle.desktop", + productVersion = "0.1.0-alpha", + packageVersion = "1.0.1", + buildVersion = "1", + fileName = "HarvestCircle-1.0.1.dmg", + ) + } + } + + @Test + fun harvestCircleArtifactContractRejectsSymlinkTraversal() { + val root = createTempDirectory("harvestcircle-artifact-contract-link-") + val actual = root.resolve("actual").createDirectories() + actual.resolve("harvestcircle-artifact-contract.v3.json") + .writeText(HarvestCircleArtifactContract.CANONICAL_JSON) + Files.createSymbolicLink(root.resolve("contracts"), actual) + + assertFails { + HarvestCircleArtifactContract.load( + root.resolve("contracts/harvestcircle-artifact-contract.v3.json").toFile(), + root.toFile(), + ) + } + } + + @Test fun radrootsLibSourceLockHashesActualBoundedNoFollowBytes() { val root = createTempDirectory("harvestcircle-source-lock-") val lockfile = root.resolve("core/Cargo.lock") 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 @@ -6,6 +6,7 @@ import kotlin.io.path.createTempDirectory import kotlin.io.path.createDirectories import kotlin.io.path.writeText import kotlin.test.Test +import kotlin.test.assertFalse import kotlin.test.assertTrue class ConventionPluginSmokeTest { @@ -463,6 +464,81 @@ class ConventionPluginSmokeTest { assertTrue(failure.output.contains("Release source commit provenance is unknown or malformed"), failure.output) } + @Test + fun unsignedReleaseReadinessIsBoundToTheMacOsAarch64Package() { + val fixture = createTempDirectory("harvestcircle-unsigned-release-") + preparePackagingBuild(fixture, "exit 0") + + val unsigned = + GradleRunner.create() + .withProjectDir(fixture.toFile()) + .withPluginClasspath() + .withArguments( + ":app:desktop:unsignedReleaseReadiness", + "-PnativeOs=Mac OS X", + "-PnativeArch=aarch64", + "--dry-run", + "--stacktrace", + ) + .build() + assertTrue(unsigned.output.contains(":app:desktop:unsignedReleaseReadiness"), unsigned.output) + assertTrue(unsigned.output.contains(":app:desktop:packageDmg"), unsigned.output) + assertTrue(unsigned.output.contains(":app:desktop:verifyMacOsPackage"), unsigned.output) + listOf( + ":app:desktop:signingReadiness", + ":app:desktop:notarizationReadiness", + ":app:desktop:verifyMacOsDeveloperIdSignature", + ":app:desktop:verifyMacOsNotarization", + ).forEach { forbidden -> assertFalse(unsigned.output.contains(forbidden), unsigned.output) } + + val signed = + GradleRunner.create() + .withProjectDir(fixture.toFile()) + .withPluginClasspath() + .withArguments( + ":app:desktop:releaseReadiness", + "-PnativeOs=Mac OS X", + "-PnativeArch=aarch64", + "--dry-run", + "--stacktrace", + ) + .build() + assertTrue(signed.output.contains(":app:desktop:unsignedReleaseReadiness"), signed.output) + assertTrue(signed.output.contains(":app:desktop:signingReadiness"), signed.output) + assertTrue(signed.output.contains(":app:desktop:notarizationReadiness"), signed.output) + } + + @Test + fun unsignedReleaseReadinessRejectsEveryNonContractPlatformDuringConfiguration() { + listOf( + Triple("linux", "Linux", "aarch64"), + Triple("macos-x86", "Mac OS X", "x86_64"), + ).forEach { (caseName, osName, architecture) -> + val fixture = createTempDirectory("harvestcircle-unsigned-release-$caseName-") + preparePackagingBuild(fixture, "exit 0") + + val result = + GradleRunner.create() + .withProjectDir(fixture.toFile()) + .withPluginClasspath() + .withArguments( + ":app:desktop:unsignedReleaseReadiness", + "-PnativeOs=$osName", + "-PnativeArch=$architecture", + "--dry-run", + "--stacktrace", + ).buildAndFail() + + assertTrue( + result.output.contains( + "Unsigned release contract requires macOS/aarch64, not $osName/$architecture", + ), + result.output, + ) + assertTrue(result.tasks.none { it.path.startsWith(":app:desktop:") }, result.output) + } + } + private fun prepareDesktopBuild( fixture: java.nio.file.Path, withUnitTest: Boolean, diff --git a/build-logic/plugins/src/functionalTest/kotlin/org/harvestcircle/buildlogic/plugins/TaskPolicyTest.kt b/build-logic/plugins/src/functionalTest/kotlin/org/harvestcircle/buildlogic/plugins/TaskPolicyTest.kt @@ -8,6 +8,7 @@ import org.harvestcircle.buildlogic.plugins.tasks.StageReleaseNativeLibrary import org.harvestcircle.buildlogic.plugins.tasks.VerifyDesktopBuildMetadataArtifact import org.harvestcircle.buildlogic.plugins.tasks.VerifyGeneratedCompatibilityExpectations import org.harvestcircle.buildlogic.plugins.tasks.VerifyGeneratedDesktopBuildMetadata +import org.harvestcircle.buildlogic.plugins.tasks.VerifyHarvestCircleArtifactContract import org.harvestcircle.buildlogic.plugins.tasks.VerifyMacOsDeveloperIdSignature import org.harvestcircle.buildlogic.plugins.tasks.VerifyMacOsDistribution import org.harvestcircle.buildlogic.plugins.tasks.VerifyMacOsNotarization @@ -36,6 +37,7 @@ class TaskPolicyTest { listOf( VerifyGeneratedDesktopBuildMetadata::class.java, + VerifyHarvestCircleArtifactContract::class.java, VerifyProductCoordinates::class.java, VerifyVerificationLanes::class.java, VerifySharedBoundary::class.java, 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 @@ -46,9 +46,12 @@ public class HarvestCirclePackagingPlugin : Plugin<Project> { val productSlug = coordinates["product.slug"] val bundleId = coordinates["desktop.bundle_id"] val nativeOsName = rustFfi.nativeOsName.get() + val nativeArchitecture = rustFfi.nativeArchitecture.get() val isMacOsHost = nativeOsName.lowercase().startsWith("mac") val isLinuxHost = nativeOsName.lowercase().startsWith("linux") val isWindowsHost = nativeOsName.lowercase().startsWith("windows") + val isGovernedMacOsTarget = + isMacOsHost && nativeArchitecture.lowercase() == coordinates["platform.macos.architecture"] if (!isMacOsHost && !isLinuxHost && !isWindowsHost) { throw GradleException("Unsupported desktop package host: $nativeOsName") } @@ -235,6 +238,7 @@ public class HarvestCirclePackagingPlugin : Plugin<Project> { target.tasks.register("sourceReadiness") { task -> task.dependsOn( ":verifyProductCoordinates", + ":verifyHarvestCircleArtifactContract", ":verifyVerificationLanes", ":app:shared:check", "check", @@ -247,9 +251,23 @@ public class HarvestCirclePackagingPlugin : Plugin<Project> { } val signingReadiness = target.tasks.register("signingReadiness") { it.dependsOn(verifySignature) } val notarizationReadiness = target.tasks.register("notarizationReadiness") { it.dependsOn(verifyNotarization) } + val unsignedReleaseReadiness = target.tasks.register("unsignedReleaseReadiness") { task -> + if (!isGovernedMacOsTarget) { + throw GradleException( + "Unsigned release contract requires macOS/aarch64, not $nativeOsName/$nativeArchitecture", + ) + } + task.dependsOn( + "checkLicense", + "dependencyCheckAnalyze", + sourceReadiness, + packageReadiness, + "packageDmg", + "verifyMacOsPackage", + ) + } target.tasks.register("releaseReadiness") { task -> - task.dependsOn("checkLicense", "dependencyCheckAnalyze", sourceReadiness, packageReadiness) - if (isMacOsHost) task.dependsOn(signingReadiness, notarizationReadiness) + task.dependsOn(unsignedReleaseReadiness, signingReadiness, notarizationReadiness) } target.tasks.named("check") { it.dependsOn(verifyMetadata) } } 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,6 +2,7 @@ package org.harvestcircle.buildlogic.plugins import org.gradle.api.Plugin import org.gradle.api.Project +import org.harvestcircle.buildlogic.plugins.tasks.VerifyHarvestCircleArtifactContract import org.harvestcircle.buildlogic.plugins.tasks.VerifyProductCoordinates import org.harvestcircle.buildlogic.plugins.tasks.VerifyVerificationLanes @@ -13,6 +14,8 @@ public class HarvestCircleRootPlugin : Plugin<Project> { val ffiCompatibilityBaselineFile = target.layout.projectDirectory.file("core/compatibility/harvestcircle-ffi-v4.properties") val verificationLanesFile = target.layout.projectDirectory.file("config/verification/lanes-v3.properties") + val artifactContractFile = + target.layout.projectDirectory.file("contracts/release/harvestcircle-artifact-contract.v3.json") val verifyProductCoordinates = target.tasks.register("verifyProductCoordinates", VerifyProductCoordinates::class.java) { task -> @@ -43,6 +46,18 @@ public class HarvestCircleRootPlugin : Plugin<Project> { task.description = "Validates canonical source provenance and its governed digest." task.dependsOn(verifyProductCoordinates) } + target.tasks.register( + "verifyHarvestCircleArtifactContract", + VerifyHarvestCircleArtifactContract::class.java, + ) { task -> + task.group = "verification" + task.description = "Validates the canonical unsigned HarvestCircle artifact contract." + task.contractFile.set(artifactContractFile) + task.productCoordinatesFile.set(productCoordinatesFile) + task.ffiCompatibilityBaselineFile.set(ffiCompatibilityBaselineFile) + task.expectedBuildVersion.set("1") + task.repositoryRoot.set(target.layout.projectDirectory) + } target.tasks.register("verifyVerificationLanes", VerifyVerificationLanes::class.java) { task -> task.group = "verification" task.description = "Validates forge-agnostic verification lanes and least-privilege policy." diff --git a/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/tasks/ArtifactContractTask.kt b/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/tasks/ArtifactContractTask.kt @@ -0,0 +1,57 @@ +package org.harvestcircle.buildlogic.plugins.tasks + +import org.gradle.api.DefaultTask +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import org.gradle.work.DisableCachingByDefault +import org.harvestcircle.buildlogic.contracts.FfiCompatibilityBaseline +import org.harvestcircle.buildlogic.contracts.HarvestCircleArtifactContract +import org.harvestcircle.buildlogic.contracts.ProductCoordinates + +@DisableCachingByDefault(because = "Artifact contract verification produces no reusable output") +public abstract class VerifyHarvestCircleArtifactContract : DefaultTask() { + @get:InputFile + @get:PathSensitive(PathSensitivity.RELATIVE) + public abstract val contractFile: RegularFileProperty + + @get:InputFile + @get:PathSensitive(PathSensitivity.RELATIVE) + public abstract val productCoordinatesFile: RegularFileProperty + + @get:InputFile + @get:PathSensitive(PathSensitivity.RELATIVE) + public abstract val ffiCompatibilityBaselineFile: RegularFileProperty + + @get:Input + public abstract val expectedBuildVersion: Property<String> + + @get:Internal + public abstract val repositoryRoot: DirectoryProperty + + @TaskAction + public fun verify() { + HarvestCircleArtifactContract.load( + contractFile = contractFile.get().asFile, + repositoryRoot = repositoryRoot.get().asFile, + ) + val coordinates = ProductCoordinates.load(productCoordinatesFile.get().asFile) + val baseline = FfiCompatibilityBaseline.load(ffiCompatibilityBaselineFile.get().asFile) + val productName = coordinates["product.name"] + val packageVersion = baseline["package.version"] + HarvestCircleArtifactContract.validatePackageCoordinates( + productName = productName, + identity = coordinates["desktop.bundle_id"], + productVersion = baseline["product.version"], + packageVersion = packageVersion, + buildVersion = expectedBuildVersion.get(), + fileName = "$productName-$packageVersion.dmg", + ) + } +} 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 @@ -93,7 +93,7 @@ public abstract class VerifyMacOsDistribution .filter { it.isFile && it.extension == "icns" } .count { it.readBytes().contentEquals(sourceIcon) } require(matchingIcons == 1) { "Packaged macOS icon does not match the canonical icon" } - verifyPackagedNativeLibraries(app, releaseLibrary.get().asFile, expectedNativeEntry.get(), true) + verifyPackagedNativeLibraries(app, releaseLibrary.get().asFile, expectedNativeEntry.get()) } private fun plistValue( @@ -105,18 +105,14 @@ public abstract class VerifyMacOsDistribution app: File, release: File, expectedEntry: String, - verifyMachO: Boolean, ) { val packagedLibraries = packagedNativeLibraries(app, expectedEntry) require(packagedLibraries.size == 1) { "Packaged application must contain exactly one release native library" } val packaged = temporaryDir.resolve(release.name).apply { writeBytes(packagedLibraries.single()) } - if (verifyMachO) { - require(machOIdentity(packaged) == machOIdentity(release)) { - "Packaged native library identity does not match the Cargo release artifact" - } - execOperations.commandOutput("/usr/bin/codesign", "--verify", "--strict", packaged.absolutePath) + require(machOIdentity(packaged) == machOIdentity(release)) { + "Packaged native library identity does not match the Cargo release artifact" } } diff --git a/build-logic/plugins/src/test/kotlin/org/harvestcircle/buildlogic/plugins/tasks/PackagingTasksTest.kt b/build-logic/plugins/src/test/kotlin/org/harvestcircle/buildlogic/plugins/tasks/PackagingTasksTest.kt @@ -3,6 +3,7 @@ package org.harvestcircle.buildlogic.plugins.tasks import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertFalse class PackagingTasksTest { @Test @@ -33,4 +34,19 @@ class PackagingTasksTest { val failure = assertFailsWith<IllegalArgumentException> { requireSuccessfulCommand(17, "tool failed\n") } assertEquals("External package inspection failed with exit 17: tool failed", failure.message) } + + @Test + fun unsignedDistributionVerifierContainsNoSigningToolInvocation() { + val bytecode = + checkNotNull( + javaClass.classLoader.getResourceAsStream( + "org/harvestcircle/buildlogic/plugins/tasks/VerifyMacOsDistribution.class", + ), + ) { "VerifyMacOsDistribution bytecode is unavailable" } + .use { it.readBytes().toString(Charsets.ISO_8859_1) } + + listOf("/usr/bin/codesign", "/usr/bin/xcrun", "stapler", "/usr/sbin/spctl").forEach { forbidden -> + assertFalse(bytecode.contains(forbidden), "Unsigned distribution verifier invokes $forbidden") + } + } } diff --git a/contracts/release/harvestcircle-artifact-contract.v3.json b/contracts/release/harvestcircle-artifact-contract.v3.json @@ -0,0 +1 @@ +{"artifact_policy":{"checksums":"required","cyclonedx_version":"1.6","exact_tree_source_archives":"required","fresh_install":"required","git_history_bundles":"forbidden","intoto_statement":"required","notices":"required","reproducibility_build_count":2,"secret_scan":"required","unsigned_packages":"required","unsigned_slsa_provenance":"required"},"contract_version":3,"delivery":{"candidate_class":"unsigned_nonpublishing","developer_id_signing":"forbidden","developer_team_id":"forbidden","distribution_signing":"forbidden","embedded_platform_adhoc_signing":"permitted_non_distribution_only","g2":"unauthorized","notarization":"unauthorized","production_activation":"unauthorized","publication":"unauthorized","signing":"unauthorized"},"implementation_owner_step":290,"output":[{"classification":"production","id":"unsigned_macos_package","platforms":["macos_aarch64"]}],"package_contract":{"build_version":"1","filename":"HarvestCircle-1.0.0.dmg","format":"dmg","identity":"org.harvestcircle.desktop","package_version":"1.0.0","product_name":"HarvestCircle","product_version":"0.1.0-alpha"},"platforms":["macos_aarch64"],"producer":{"command_authority":"gradle_wrapper","kind":"compose_desktop_native_distributions_jpackage","nix_binding":"forbidden","nix_produced":false,"source_task":"packageDmg"},"repository":"oss/harvestcircle","schema":"radroots.release.artifact-contract.v3","source_archive":{"binding":"canonical_exact_source_revision_tree_archive","compression":"none","compression_timestamp":"not_applicable","content":"exact_source_revision_tree","directory_entries":"omitted","entry_order":"bytewise_git_path","file_mode":"git_index_100644_or_100755","format":"ustar","gid":0,"git_history":"forbidden","gname":"","hardlinks":"forbidden","mtime_source":"candidate_source_date_epoch","path_prefix":"none","pax_headers":"forbidden","submodules":"forbidden","symlinks":"forbidden","trailer":"two_zero_blocks","uid":0,"uname":""},"source_binding":{"dirty_tree":"forbidden","kind":"exact_clean_git_commit","revision_location":"aggregate_source_revision"},"sqlite":{"high_level_authority":"sqlx_only","incremental_backup_adapter":"sealed_native_sqlx_owned_locked_handle_only","native_linkage_count":1,"second_pool_connection_query_transaction_migration_authority":"forbidden"}} +\ No newline at end of file diff --git a/tools/test-build-modes.sh b/tools/test-build-modes.sh @@ -113,4 +113,36 @@ if "$make_command" --no-print-directory -C "$repository_root" BUILD_MODE=standal fi grep -q 'release-check requires governed mode' "$fixture/release.log" +if "$make_command" --no-print-directory -C "$repository_root" BUILD_MODE=standalone _unsigned-release-check > "$fixture/unsigned-release.log" 2>&1; then + printf '%s\n' 'unsigned release execution accepted standalone mode' >&2 + exit 1 +fi +grep -q 'unsigned-release-check requires governed mode' "$fixture/unsigned-release.log" + +unsigned_release_output=$(PATH="$fixture:$PATH" "$make_command" --no-print-directory -n BUILD_MODE=governed -C "$repository_root" _unsigned-release-check) +if ! printf '%s\n' "$unsigned_release_output" | grep -q ':app:desktop:unsignedReleaseReadiness'; then + printf '%s\n' 'unsigned release command did not select the unsigned readiness gate' >&2 + exit 1 +fi +for forbidden in ':app:desktop:releaseReadiness' ':app:desktop:signingReadiness' ':app:desktop:notarizationReadiness'; do + if printf '%s\n' "$unsigned_release_output" | grep -q "$forbidden"; then + printf '%s\n' "unsigned release command activated signer authority: $forbidden" >&2 + exit 1 + fi +done + +standalone_package_output=$(PATH="$fixture:$PATH" "$make_command" --no-print-directory -n BUILD_MODE=standalone -C "$repository_root" package-check) +standalone_unsigned_gate_count=$(printf '%s\n' "$standalone_package_output" | grep -c -- '^./gradlew --no-daemon --no-parallel --no-configuration-cache :app:desktop:unsignedReleaseReadiness$') +if [ "$standalone_unsigned_gate_count" -ne 1 ] || printf '%s\n' "$standalone_package_output" | grep -q 'cargo extbuild'; then + printf '%s\n' 'standalone package-check must invoke the unsigned gate once without probing extbuild' >&2 + exit 1 +fi + +governed_package_output=$(PATH="$fixture:$PATH" "$make_command" --no-print-directory -n BUILD_MODE=standalone -C "$repository_root" governed-package-check) +governed_unsigned_gate_count=$(printf '%s\n' "$governed_package_output" | grep -c -- 'cargo extbuild run -- ./gradlew --no-daemon --no-parallel --no-configuration-cache :app:desktop:unsignedReleaseReadiness') +if [ "$governed_unsigned_gate_count" -ne 1 ]; then + printf '%s\n' 'governed-package-check must invoke the extbuild-routed unsigned gate exactly once' >&2 + exit 1 +fi + printf '%s\n' 'harvestcircle.build-mode-contract=pass' diff --git a/tools/xtask/src/lib.rs b/tools/xtask/src/lib.rs @@ -1,7 +1,7 @@ use sha2::{Digest, Sha256}; use std::collections::BTreeSet; use std::fs::{self, OpenOptions}; -use std::io::Read; +use std::io::{ErrorKind, Read}; use std::path::{Component, Path, PathBuf}; use std::process::Command as ProcessCommand; use std::str::FromStr; @@ -112,13 +112,17 @@ impl Inventory { if !output.status.success() { return Err("unable to enumerate tracked HarvestCircle sources".to_owned()); } - let mut paths = output + let mut paths = Vec::new(); + for raw_path in 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<_>>(); + { + let path = String::from_utf8(raw_path.to_vec()) + .map_err(|_| "Git inventory path is not valid UTF-8".to_owned())?; + validate_git_inventory_path(root, Path::new(&path))?; + paths.push(path); + } paths.sort(); paths.dedup(); Ok(Self { @@ -138,6 +142,60 @@ impl Inventory { } } +fn validate_git_inventory_path(root: &Path, relative: &Path) -> Result<(), String> { + if relative.is_absolute() + || relative.components().next().is_none() + || relative + .components() + .any(|component| !matches!(component, Component::Normal(_))) + { + return Err(format!( + "{}: Git inventory path must be normalized and relative", + relative.display() + )); + } + let components = relative.components().collect::<Vec<_>>(); + let mut current = root.to_path_buf(); + for (index, component) in components.iter().enumerate() { + current.push(component.as_os_str()); + let metadata = match fs::symlink_metadata(¤t) { + Ok(metadata) => metadata, + Err(error) if error.kind() == ErrorKind::NotFound => { + return Err(format!( + "{}: Git inventory path is missing", + relative.display() + )); + } + Err(error) => { + return Err(format!( + "{}: unable to inspect Git inventory path: {error}", + relative.display() + )); + } + }; + if metadata.file_type().is_symlink() { + return Err(format!( + "{}: Git inventory path traverses a symbolic link", + relative.display() + )); + } + if index + 1 < components.len() { + if !metadata.is_dir() { + return Err(format!( + "{}: Git inventory path parent is not a directory", + relative.display() + )); + } + } else if !metadata.is_file() { + return Err(format!( + "{}: Git inventory path is not a regular file", + relative.display() + )); + } + } + Ok(()) +} + fn archive_paths(root: &Path, directory: &Path, paths: &mut Vec<String>) -> Result<(), String> { let entries = fs::read_dir(directory).map_err(|error| { format!( @@ -2127,6 +2185,132 @@ mod tests { fs::remove_dir_all(root).expect("remove fixture"); } + #[cfg(unix)] + #[test] + fn git_inventory_rejects_an_intermediate_symbolic_link() { + use std::os::unix::fs::symlink; + + let root = fixture("git-inventory-intermediate-symlink"); + initialize_git_fixture(&root); + write(&root, "tracked/file.txt", "tracked\n"); + add_git_fixture_path(&root, Path::new("tracked/file.txt")); + fs::rename(root.join("tracked"), root.join("actual")).expect("move tracked directory"); + symlink(root.join("actual"), root.join("tracked")).expect("create intermediate symlink"); + + let error = Inventory::load(&root).expect_err("intermediate symlink must fail closed"); + assert!(error.contains("Git inventory path traverses a symbolic link")); + fs::remove_dir_all(root).expect("remove fixture"); + } + + #[cfg(unix)] + #[test] + fn git_inventory_rejects_invalid_utf8_before_filesystem_traversal() { + use std::io::Write as _; + use std::process::Stdio; + + let root = fixture("git-inventory-invalid-utf8-symlink"); + initialize_git_fixture(&root); + write(&root, "blob.txt", "tracked\n"); + let object = ProcessCommand::new("git") + .arg("-C") + .arg(&root) + .args(["hash-object", "-w", "blob.txt"]) + .output() + .expect("write fixture blob"); + assert!(object.status.success()); + let object = String::from_utf8(object.stdout).expect("Git object ID is UTF-8"); + let mut index_entry = format!("100644 blob {}\ttracked/", object.trim()).into_bytes(); + index_entry.push(0x80); + index_entry.extend_from_slice(b"/file.txt\0"); + let mut update = ProcessCommand::new("git") + .arg("-C") + .arg(&root) + .args(["update-index", "-z", "--index-info"]) + .stdin(Stdio::piped()) + .spawn() + .expect("start hostile index update"); + update + .stdin + .take() + .expect("hostile index stdin") + .write_all(&index_entry) + .expect("write hostile index entry"); + assert!( + update + .wait() + .expect("finish hostile index update") + .success() + ); + let error = + Inventory::load(&root).expect_err("invalid UTF-8 inventory path must fail closed"); + assert_eq!(error, "Git inventory path is not valid UTF-8"); + fs::remove_dir_all(root).expect("remove fixture"); + } + + #[cfg(unix)] + #[test] + fn git_inventory_preserves_a_literal_backslash_without_aliasing_a_separator() { + let root = fixture("git-inventory-backslash"); + initialize_git_fixture(&root); + write(&root, r"tracked\file.txt", "literal backslash\n"); + write(&root, "tracked/file.txt", "path separator\n"); + add_git_fixture_path(&root, Path::new(r"tracked\file.txt")); + add_git_fixture_path(&root, Path::new("tracked/file.txt")); + + let inventory = Inventory::load(&root).expect("distinct Git paths must remain distinct"); + assert!(inventory.paths.contains(&r"tracked\file.txt".to_owned())); + assert!(inventory.paths.contains(&"tracked/file.txt".to_owned())); + fs::remove_dir_all(root).expect("remove fixture"); + } + + #[test] + fn git_inventory_rejects_a_missing_leaf() { + let root = fixture("git-inventory-missing-leaf"); + initialize_git_fixture(&root); + write(&root, "tracked/file.txt", "tracked\n"); + add_git_fixture_path(&root, Path::new("tracked/file.txt")); + fs::remove_file(root.join("tracked/file.txt")).expect("remove tracked file"); + + let error = Inventory::load(&root).expect_err("missing inventory leaf must fail closed"); + assert!(error.contains("Git inventory path is missing")); + fs::remove_dir_all(root).expect("remove fixture"); + } + + #[test] + fn git_inventory_rejects_a_directory_leaf() { + let root = fixture("git-inventory-directory-leaf"); + initialize_git_fixture(&root); + write(&root, "tracked/file.txt", "tracked\n"); + add_git_fixture_path(&root, Path::new("tracked/file.txt")); + fs::remove_file(root.join("tracked/file.txt")).expect("remove tracked file"); + fs::create_dir(root.join("tracked/file.txt")).expect("create directory leaf"); + + let error = Inventory::load(&root).expect_err("directory inventory leaf must fail closed"); + assert!(error.contains("Git inventory path is not a regular file")); + fs::remove_dir_all(root).expect("remove fixture"); + } + + #[cfg(unix)] + #[test] + fn git_inventory_rejects_a_fifo_leaf_without_opening_it() { + let root = fixture("git-inventory-fifo-leaf"); + initialize_git_fixture(&root); + write(&root, "tracked/file.txt", "tracked\n"); + add_git_fixture_path(&root, Path::new("tracked/file.txt")); + fs::remove_file(root.join("tracked/file.txt")).expect("remove tracked file"); + assert!( + ProcessCommand::new("mkfifo") + .arg(root.join("tracked/file.txt")) + .status() + .expect("create FIFO leaf") + .success() + ); + + let error = Inventory::load(&root).expect_err("FIFO inventory leaf must fail closed"); + assert!(error.contains("Git inventory path is not a regular file")); + fs::remove_dir_all(root).expect("remove fixture"); + } + #[test] fn mutable_git_dependency_and_provenance_mutation_fail_closed() { let root = fixture("provenance"); @@ -2246,6 +2430,31 @@ mod tests { root } + fn initialize_git_fixture(root: &Path) { + assert!( + ProcessCommand::new("git") + .arg("-C") + .arg(root) + .args(["init", "--quiet"]) + .status() + .expect("initialize Git fixture") + .success() + ); + } + + fn add_git_fixture_path(root: &Path, relative: &Path) { + assert!( + ProcessCommand::new("git") + .arg("-C") + .arg(root) + .args(["add", "--"]) + .arg(relative) + .status() + .expect("index tracked fixture") + .success() + ); + } + 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");