diff --git a/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificate.kt b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificate.kt new file mode 100644 index 000000000..860d6c737 --- /dev/null +++ b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificate.kt @@ -0,0 +1,117 @@ +package dev.tramai.build.quality + +import java.security.MessageDigest + +/** + * Base-side digest-migration certificate (0.7.1g1P2, M40-M47). + * + * This is the artifact that carries a *semantic* upgrade of the whole-population authority digest + * without rewriting historical authority. The P1 authorizations are bound to `raw-v1` raw-status + * digests and must stay byte-identical; the certificate stands beside them and states, in base + * authority, that one exact `fromDigest` is equivalent to one exact `toDigest` for one exact, + * bounded set of authorizations. M36's payload comparison never sees it, because it lives in its + * own ledger - no exception branch, no permitted-field list, is added to the admissions rules. + * + * ``` + * mint against the exact base (M45) + * -> retain byte-identically (M46) + * -> consume only from base (M43 + M42 + M41 + M40) + * -> remove in the valid consuming transition (M44 + M47) + * ``` + * + * ## Field semantics + * + * - [fromAlgorithm] / [fromDigest] - the digest semantics the covered authorizations were minted + * under, and that exact digest. M41 requires this to match the cited admission's + * `populationDigest`; a certificate can never be applied to a different authorization than the + * one whose digest it names. + * - [toAlgorithm] / [toDigest] - the digest semantics the covered authorizations are to be consumed + * under, and that exact digest. M40 requires [toDigest] to equal the verifier's own fresh + * authority projection, never anything the candidate supplies. + * - [admissionSetDigest] - SHA-256 over the exact set of covered identities, sorted and joined with + * `"\n"` with a trailing newline (the same recipe this repository uses for manifest digests). M42 + * requires it to equal the exact set of base authorizations, which is what makes the certificate + * *bounded*: it cannot be widened to cover a different or later set. + * - [fromBaseSha] - the authority base the certificate is minted against. **Enforced at + * introduction only (M45); thereafter immutable provenance.** Same semantics as + * [MutationPopulationAdmission.fromBaseSha]: a certificate legitimately survives intermediate + * merges, so requiring it to track the immediate base would make delayed consumption impossible; + * its enforcement value is anti-replay at mint time. + * - [reason] - the recorded provenance of the supersession, part of the enforced payload. + * - [authorizedBy], [authorizedAt] - **audit only, no enforcement value.** Recording them is fine; + * letting identity or actor carry trust would make them a bypass. + * + * ## Rule ownership + * + * M45 (mint binding), M46 (retained immutability) and M47 (removal custody) are lifecycle rules and + * live in [MutationAuthorityDigestCertificateCeremony]. M40-M44 are *consumption* rules and arrive + * with the certificate-aware consumption path; until then a base certificate may not disappear at + * all, which is fail-closed and is the stricter half of M47. + */ +data class MutationAuthorityDigestCertificate( + val fromAlgorithm: String, + val fromDigest: String, + val toAlgorithm: String, + val toDigest: String, + val admissionSetDigest: String, + val fromBaseSha: String, + val reason: String, + val authorizedBy: String? = null, + val authorizedAt: String? = null, +) { + /** + * The enforceable payload: every bound field except the audit-only ones. Base/candidate + * byte-identity is judged over this (M46), so rewriting any bound field fails while touching + * audit metadata alone can never manufacture authority. + */ + fun enforcedPayload(): List = + listOf( + fromAlgorithm, + fromDigest, + toAlgorithm, + toDigest, + admissionSetDigest, + fromBaseSha, + reason, + ) +} + +data class MutationAuthorityDigestCertificates( + val schemaVersion: String, + val certificates: List, +) { + /** + * Keyed by [MutationAuthorityDigestCertificate.fromDigest]: the source of a migration is + * unique. Two certificates claiming the same source digest would contradict each other, and the + * loader rejects that rather than leaving the winner to map iteration order. + */ + fun byFromDigest(): Map = certificates.associateBy { it.fromDigest } + + companion object { + /** The digest semantics a certificate may migrate from and to. */ + const val ALGORITHM_RAW_V1 = "raw-v1" + const val ALGORITHM_AUTHORITY_V2 = "authority-v2" + + /** Byte mask for the hex rendering of a digest. */ + private const val HEX_BYTE_MASK = 0xFF + + /** No certificates: strictly the most restrictive state, so no migration is possible. */ + val NONE = MutationAuthorityDigestCertificates(schemaVersion = "1", certificates = emptyList()) + + /** + * The bounded admission-set digest (M42): SHA-256 over the covered identities, sorted and + * joined with `"\n"` with a trailing newline. + * + * The recipe is part of the contract, not an implementation detail: whoever mints a + * certificate must be able to reproduce this value exactly from the authorization set, and a + * test pins it against the real ledger. + */ + fun admissionSetDigest(identities: Collection): String { + val payload = identities.sorted().joinToString("\n", postfix = if (identities.isEmpty()) "" else "\n") + return MessageDigest + .getInstance("SHA-256") + .digest(payload.toByteArray(Charsets.UTF_8)) + .joinToString("") { "%02x".format(it.toInt() and HEX_BYTE_MASK) } + } + } +} diff --git a/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificateCeremony.kt b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificateCeremony.kt new file mode 100644 index 000000000..09cd01aea --- /dev/null +++ b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificateCeremony.kt @@ -0,0 +1,142 @@ +package dev.tramai.build.quality + +/** + * The digest-migration certificate's authority lifecycle (0.7.1g1P2, M45-M47). + * + * The certificate is itself base authority, so its whole lifecycle is fail-closed, not only its + * consumption. These three rules are the lifecycle half; M40-M44 are consumption rules and arrive + * with the certificate-aware consumption path. + * + * ``` + * mint against the exact base (M45) <- this file + * -> retain byte-identically (M46) <- this file + * -> consume only from base (M43 + M42 + M41 + M40) + * -> remove in the valid consuming transition (M44 + M47) <- M47's consuming half is here + * ``` + * + * Written standalone over the two ledgers plus the authority [baseSha], because a lifecycle rule is + * a transition property: it cannot be expressed by parsing one file, and it needs no coupling to the + * ratchet's own types. + */ +object MutationAuthorityDigestCertificateCeremony { + /** + * M45, M46 and M47 over the base/candidate certificate ledgers. + * + * @param base the certificate ledger as it exists in the PR's authority base + * @param candidate the certificate ledger as the PR proposes it + * @param baseSha the exact authority base SHA this transition is proposed against + */ + fun checks( + base: MutationAuthorityDigestCertificates, + candidate: MutationAuthorityDigestCertificates, + baseSha: String, + ): List = + mintChecks(base, candidate, baseSha) + + retentionChecks(base, candidate) + + removalChecks(base, candidate) + + /** + * M45: a newly introduced certificate binds the exact authority base it is proposed against. + * Anti-replay at mint time: a certificate payload cannot be replayed onto a different authority + * base. Deliberately not re-checked later - a certificate legitimately survives intermediate + * merges, so requiring it to track the immediate base would make delayed consumption impossible. + */ + private fun mintChecks( + base: MutationAuthorityDigestCertificates, + candidate: MutationAuthorityDigestCertificates, + baseSha: String, + ): List { + val diagnostics = mutableListOf() + val baseByFromDigest = base.byFromDigest() + for ((fromDigest, certificate) in candidate.byFromDigest()) { + if (fromDigest in baseByFromDigest) continue + if (certificate.fromBaseSha != baseSha) { + diagnostics += + certificateFailure( + fromDigest, + "M45: the digest-migration certificate for source digest ${short(fromDigest)} " + + "records fromBaseSha '${certificate.fromBaseSha}', not the authority base " + + "'$baseSha' this transition is proposed against. fromBaseSha is the base of " + + "the minting transition.", + ) + } + } + return diagnostics + } + + /** + * M46: a certificate present in the base is immutable from the moment it is introduced. A + * retained copy differing in any field of its enforced payload is a rewrite, whether or not the + * migration it authorizes is being consumed in this transition. Correcting a certificate means + * minting a new one in a later transition, never editing a retained one. + * + * Judged over [MutationAuthorityDigestCertificate.enforcedPayload], so touching audit metadata + * alone never fails - and never manufactures authority either. + */ + private fun retentionChecks( + base: MutationAuthorityDigestCertificates, + candidate: MutationAuthorityDigestCertificates, + ): List { + val diagnostics = mutableListOf() + val candidateByFromDigest = candidate.byFromDigest() + for ((fromDigest, baseCertificate) in base.byFromDigest()) { + val candidateCertificate = candidateByFromDigest[fromDigest] ?: continue + if (baseCertificate.enforcedPayload() != candidateCertificate.enforcedPayload()) { + diagnostics += + certificateFailure( + fromDigest, + "M46: the retained digest-migration certificate for source digest " + + "${short(fromDigest)} does not match the base certificate. A certificate, " + + "once introduced, is immutable.", + ) + } + } + return diagnostics + } + + /** + * M47: removal custody. A certificate may only disappear by being consumed; it may not be + * cancelled silently. + * + * Until the certificate-aware consumption path exists there is no way to *prove* a valid + * consumption, so this rule takes its strictly fail-closed half: **any** base certificate absent + * from the candidate fails. That is deliberate, not an unfinished branch. A disappearance is + * never its own evidence - when M40-M44 land, this becomes "unless validly consumed by the same + * transition", judged against an independently established consumption, and never against the + * mere absence of the certificate. + */ + private fun removalChecks( + base: MutationAuthorityDigestCertificates, + candidate: MutationAuthorityDigestCertificates, + ): List { + val diagnostics = mutableListOf() + val candidateByFromDigest = candidate.byFromDigest() + for ((fromDigest, _) in base.byFromDigest()) { + if (fromDigest !in candidateByFromDigest) { + diagnostics += + certificateFailure( + fromDigest, + "M47: the digest-migration certificate for source digest ${short(fromDigest)} " + + "was removed without being consumed. A certificate may not be cancelled " + + "silently.", + ) + } + } + return diagnostics + } + + private fun certificateFailure( + fromDigest: String, + message: String, + ): VerificationDiagnostic = + VerificationDiagnostic.failure( + DiagnosticCode.MUTATION_RATCHET_AUTHORITY_INVALID, + message, + findingId = fromDigest, + ) + + private fun short(digest: String): String = digest.take(SHORT_HASH_LENGTH) + + /** Prefix length used to identify a digest in diagnostics; the full value stays in findingId. */ + private const val SHORT_HASH_LENGTH = 8 +} diff --git a/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificateLoader.kt b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificateLoader.kt new file mode 100644 index 000000000..6b716b366 --- /dev/null +++ b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificateLoader.kt @@ -0,0 +1,201 @@ +package dev.tramai.build.quality + +import org.gradle.api.GradleException +import org.yaml.snakeyaml.LoaderOptions +import org.yaml.snakeyaml.Yaml +import org.yaml.snakeyaml.error.YAMLException +import java.io.File + +/** + * Parses config/quality/mutation-authority-digest-certificates.yml. + * + * An absent file means "no certificates", which is fail-closed: no semantic migration of a + * population-authority digest is then possible, and every authorization whose digest predates + * authority-v2 stays unconsumable. A present but malformed file is a hard failure - a broken + * certificate ledger must never silently degrade into "no certificates that happen to be unused". + * + * Every enforcement-critical field is required and validated here, because a partially specified + * certificate is indistinguishable from one that authorizes a different migration. + * + * **This loader validates shape only.** Mint-time base binding (M45), retained immutability (M46) + * and removal custody (M47) are transition properties and cannot be enforced by parsing a single + * file; they live in [MutationAuthorityDigestCertificateCeremony]. Leaving this ledger's lifecycle + * to a generic loader would give it none of those properties. + */ +object MutationAuthorityDigestCertificateLoader { + const val FILE_NAME = "config/quality/mutation-authority-digest-certificates.yml" + + private const val SCHEMA_VERSION = "1" + private const val MAX_ALIASES_FOR_COLLECTIONS = 20 + private val DIGEST = Regex("[0-9a-f]{64}") + private val COMMIT_SHA = Regex("[0-9a-f]{40}") + private val ALGORITHMS = + setOf( + MutationAuthorityDigestCertificates.ALGORITHM_RAW_V1, + MutationAuthorityDigestCertificates.ALGORITHM_AUTHORITY_V2, + ) + + fun load(repositoryRoot: File): MutationAuthorityDigestCertificates { + val file = File(repositoryRoot, FILE_NAME) + if (!file.isFile) { + return MutationAuthorityDigestCertificates.NONE + } + val raw = readLedger(file) + val schemaVersion = requireSchemaVersion(raw) + val certificates = parseCertificates(raw) + requireUniqueFromDigests(certificates) + requireDistinctAlgorithms(certificates) + return MutationAuthorityDigestCertificates( + schemaVersion = schemaVersion, + certificates = certificates, + ) + } + + private fun readLedger(file: File): Map { + val loaderOptions = + LoaderOptions().apply { + isAllowDuplicateKeys = false + maxAliasesForCollections = MAX_ALIASES_FOR_COLLECTIONS + } + val text = file.readText(Charsets.UTF_8) + return try { + @Suppress("UNCHECKED_CAST") + Yaml(loaderOptions).load>(text) ?: emptyMap() + } catch (e: YAMLException) { + throw GradleException("Invalid mutation-authority-digest-certificates.yml: ${e.message}", e) + } + } + + private fun requireSchemaVersion(raw: Map): String { + val schemaVersion = + raw["schemaVersion"]?.toString() + ?: throw GradleException( + "mutation-authority-digest-certificates.yml: schemaVersion is required", + ) + if (schemaVersion != SCHEMA_VERSION) { + throw GradleException( + "Unsupported mutation-authority-digest-certificates.yml schemaVersion '$schemaVersion'", + ) + } + return schemaVersion + } + + private fun parseCertificates(ledger: Map): List { + val certificatesRaw = + ledger["certificates"] as? List<*> + ?: throw GradleException( + "mutation-authority-digest-certificates.yml: certificates must be a list", + ) + return certificatesRaw.mapIndexed { index, item -> + val entry = + item as? Map<*, *> + ?: throw GradleException( + "mutation-authority-digest-certificates.yml: certificates[$index] must be a mapping", + ) + MutationAuthorityDigestCertificate( + fromAlgorithm = requireAlgorithm(entry, "fromAlgorithm", index), + fromDigest = requireCanonical(entry, "fromDigest", index, DIGEST, "a 64-hex digest"), + toAlgorithm = requireAlgorithm(entry, "toAlgorithm", index), + toDigest = requireCanonical(entry, "toDigest", index, DIGEST, "a 64-hex digest"), + admissionSetDigest = + requireCanonical( + entry, + "admissionSetDigest", + index, + DIGEST, + "a 64-hex admission-set digest", + ), + fromBaseSha = + requireCanonical(entry, "fromBaseSha", index, COMMIT_SHA, "a 40-hex commit SHA"), + reason = requireString(entry, "reason", index), + authorizedBy = optionalString(entry, "authorizedBy"), + authorizedAt = optionalString(entry, "authorizedAt"), + ) + } + } + + private fun requireAlgorithm( + entry: Map<*, *>, + field: String, + index: Int, + ): String { + val value = + entry[field]?.toString() + ?: throw GradleException( + "mutation-authority-digest-certificates.yml: certificates[$index].$field is required", + ) + if (value !in ALGORITHMS) { + throw GradleException( + "mutation-authority-digest-certificates.yml: certificates[$index].$field '$value' is " + + "not a known digest semantics (${ALGORITHMS.sorted().joinToString(", ")})", + ) + } + return value + } + + private fun requireCanonical( + entry: Map<*, *>, + field: String, + index: Int, + pattern: Regex, + description: String, + ): String { + val value = + entry[field]?.toString() + ?: throw GradleException( + "mutation-authority-digest-certificates.yml: certificates[$index].$field is required", + ) + if (!pattern.matches(value)) { + throw GradleException( + "mutation-authority-digest-certificates.yml: certificates[$index].$field '$value' is not " + + "$description", + ) + } + return value + } + + private fun requireString( + entry: Map<*, *>, + field: String, + index: Int, + ): String { + val value = entry[field]?.toString()?.takeIf { it.isNotBlank() } + return value + ?: throw GradleException( + "mutation-authority-digest-certificates.yml: certificates[$index].$field is required and " + + "must be non-blank", + ) + } + + private fun optionalString( + entry: Map<*, *>, + field: String, + ): String? = entry[field]?.toString()?.takeIf { it.isNotBlank() } + + private fun requireUniqueFromDigests(certificates: List) { + val duplicates = + certificates + .groupingBy { it.fromDigest } + .eachCount() + .filterValues { it > 1 } + .keys + if (duplicates.isNotEmpty()) { + throw GradleException( + "mutation-authority-digest-certificates.yml: duplicate fromDigest ${duplicates.sorted()} - " + + "the source of a migration is unique, so two certificates claiming it would contradict " + + "each other", + ) + } + } + + private fun requireDistinctAlgorithms(certificates: List) { + for (certificate in certificates) { + if (certificate.fromAlgorithm == certificate.toAlgorithm) { + throw GradleException( + "mutation-authority-digest-certificates.yml: certificate ${certificate.fromDigest} " + + "migrates ${certificate.fromAlgorithm} to itself, which certifies nothing", + ) + } + } + } +} diff --git a/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificateCeremonyTest.kt b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificateCeremonyTest.kt new file mode 100644 index 000000000..65fa08177 --- /dev/null +++ b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificateCeremonyTest.kt @@ -0,0 +1,161 @@ +package dev.tramai.build.quality + +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The digest-migration certificate's authority lifecycle (M45/M46/M47). + * + * A certificate is itself base authority, so a strong consumption check would still be worthless if + * its creation, retention or disappearance were weak. Each rule gets a discriminator in both + * directions: the property holds for the legitimate transition, and fails for the attack that + * property exists to defeat. + * + * M40-M44 are consumption rules and are not implemented here. The consequence is visible in + * [M47 removal custody is never satisfied by the mere absence of the certificate]: with no + * certificate-aware consumption path there is no way to *prove* a valid consumption, so a + * disappearance always fails. That is the fail-closed half of M47, not an unfinished branch - a + * disappearance must never become its own evidence. + */ +class MutationAuthorityDigestCertificateCeremonyTest { + private val baseSha = "ec8b4da59bc9e22711b0cd802417d297f803b4e0" + private val otherSha = "e6e0d69ed2adfa47c6cdfa4485ef30bc184bb881" + private val rawV1Digest = "9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0" + private val authorityV2Digest = "e6ad01dc1d2966894a6555304bc8ca9a04c8174e3c83ae88760fcfebf1464dad" + private val admissionSetDigest = "98a9587a1068ec0cd7158a05ba6909c61c522308c272e7fa9cb27d5edd93a79c" + + /** The legitimate certificate: raw-v1 to authority-v2, minted against [baseSha]. */ + private val certificate = + MutationAuthorityDigestCertificate( + fromAlgorithm = MutationAuthorityDigestCertificates.ALGORITHM_RAW_V1, + fromDigest = rawV1Digest, + toAlgorithm = MutationAuthorityDigestCertificates.ALGORITHM_AUTHORITY_V2, + toDigest = authorityV2Digest, + admissionSetDigest = admissionSetDigest, + fromBaseSha = baseSha, + reason = "digest migration", + ) + + /** A copy of [certificate] with exactly one bound field replaced. */ + private fun rewritten( + field: String, + value: String, + ): MutationAuthorityDigestCertificate = + when (field) { + "fromAlgorithm" -> certificate.copy(fromAlgorithm = value) + "toAlgorithm" -> certificate.copy(toAlgorithm = value) + "toDigest" -> certificate.copy(toDigest = value) + "admissionSetDigest" -> certificate.copy(admissionSetDigest = value) + "fromBaseSha" -> certificate.copy(fromBaseSha = value) + "reason" -> certificate.copy(reason = value) + else -> error("$field is not a field of the enforced payload") + } + + // ── M45 mint-time base binding (T17) ── + + @Test + fun `M45 a certificate minted against the authority base is accepted`() { + val diagnostics = checks(base = ledger(), candidate = ledger(certificate)) + + assertEquals(emptyList(), diagnostics, "the legitimate mint must pass") + } + + @Test + fun `M45 a certificate minted against a different base is rejected`() { + val diagnostics = checks(base = ledger(), candidate = ledger(certificate.copy(fromBaseSha = otherSha))) + + val failure = diagnostics.single() + assertTrue(failure.message.contains("M45"), failure.message) + assertEquals(rawV1Digest, failure.findingId, "the certificate's source digest identifies it") + } + + @Test + fun `M45 binding is enforced at mint only, so a retained certificate survives later merges`() { + // A pending certificate legitimately outlives intermediate merges: requiring fromBaseSha to + // track the immediate base would make delayed consumption impossible and would itself violate + // certificate immutability. If this fails, mint binding has been turned into a retention rule. + val retained = certificate.copy(fromBaseSha = otherSha) + + val diagnostics = checks(base = ledger(retained), candidate = ledger(retained)) + + assertEquals(emptyList(), diagnostics, "anti-replay applies at introduction, not forever") + } + + // ── M46 retained immutability (T18) ── + + @Test + fun `M46 a retained certificate rewritten in any enforced field is rejected`() { + // Driven as a loop so the enforced payload is proven to cover exactly these fields: adding a + // bound field without classifying it would leave it unprotected and fail this discriminator. + val rewrites = + listOf( + "fromAlgorithm" to "raw-v2", + "toAlgorithm" to "authority-v3", + "toDigest" to "f".repeat(64), + "admissionSetDigest" to "f".repeat(64), + "fromBaseSha" to otherSha, + "reason" to "a later, more convenient story", + ) + + for ((field, value) in rewrites) { + val diagnostics = checks(base = ledger(certificate), candidate = ledger(rewritten(field, value))) + assertTrue(diagnostics.isNotEmpty(), "rewriting $field must fail M46") + assertTrue( + diagnostics.any { it.message.contains("M46") }, + "rewriting $field: ${diagnostics.map { it.message }}", + ) + } + } + + @Test + fun `M46 audit-only metadata may change, and cannot manufacture authority`() { + val diagnostics = + checks( + base = ledger(certificate.copy(authorizedBy = "giona", authorizedAt = "2026-10-01T00:00:00Z")), + candidate = ledger(certificate.copy(authorizedBy = "someone", authorizedAt = "2027-01-01T00:00:00Z")), + ) + + assertEquals(emptyList(), diagnostics, "who/when carry no enforcement value in either direction") + } + + // ── M47 removal custody (T19, lifecycle half) ── + + @Test + fun `M47 a retained certificate is not a removal`() { + val diagnostics = checks(base = ledger(certificate), candidate = ledger(certificate)) + + assertEquals(emptyList(), diagnostics) + } + + @Test + fun `M47 removal custody is never satisfied by the mere absence of the certificate`() { + // The exact attack T19 exists to defeat: a transition that simply deletes the certificate and + // calls the deletion evidence of consumption. With no consumption path implemented, no + // disappearance can be proven valid, so every disappearance must fail closed. + val diagnostics = checks(base = ledger(certificate), candidate = ledger()) + + val failure = diagnostics.single() + assertTrue(failure.message.contains("M47"), failure.message) + assertTrue(failure.message.contains("removed without being consumed"), failure.message) + assertEquals(rawV1Digest, failure.findingId) + } + + @Test + fun `a certificate introduced and removed by the same transition is not a way to consume it`() { + // Net-zero: nothing is gained, and M45 still judges the introduction against the base. What + // must never happen is this being read as a consumption, which is M43's job in Step 3. + val diagnostics = checks(base = ledger(), candidate = ledger(certificate.copy(fromBaseSha = otherSha))) + + assertTrue(diagnostics.single().message.contains("M45"), "the introduction is still judged") + } + + private fun checks( + base: MutationAuthorityDigestCertificates, + candidate: MutationAuthorityDigestCertificates, + ): List = + MutationAuthorityDigestCertificateCeremony.checks(base = base, candidate = candidate, baseSha = baseSha) + + private fun ledger(vararg certificates: MutationAuthorityDigestCertificate) = + MutationAuthorityDigestCertificates(schemaVersion = "1", certificates = certificates.toList()) +} diff --git a/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificateLoaderTest.kt b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificateLoaderTest.kt new file mode 100644 index 000000000..8ec44a9cc --- /dev/null +++ b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificateLoaderTest.kt @@ -0,0 +1,228 @@ +package dev.tramai.build.quality + +import org.gradle.api.GradleException +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * The digest-migration certificate ledger's shape contract, and the one value a future mint must be + * able to reproduce exactly. + * + * The loader validates *shape* only: mint-time base binding (M45), retained immutability (M46) and + * removal custody (M47) are transition properties and are tested in + * [MutationAuthorityDigestCertificateCeremonyTest]. What is tested here is that a partially + * specified certificate - indistinguishable from one describing a different migration - is a hard + * failure rather than a silently weaker authority. + */ +class MutationAuthorityDigestCertificateLoaderTest { + @TempDir + lateinit var tempDir: File + + private val fromDigest = "9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0" + private val toDigest = "e6ad01dc1d2966894a6555304bc8ca9a04c8174e3c83ae88760fcfebf1464dad" + private val admissionSetDigest = "98a9587a1068ec0cd7158a05ba6909c61c522308c272e7fa9cb27d5edd93a79c" + private val fromBaseSha = "ec8b4da59bc9e22711b0cd802417d297f803b4e0" + + @Test + fun `an absent certificate ledger is no migration authority rather than a failure`() { + val loaded = MutationAuthorityDigestCertificateLoader.load(tempDir) + + assertEquals(MutationAuthorityDigestCertificates.NONE.certificates, loaded.certificates) + assertEquals("1", loaded.schemaVersion) + } + + @Test + fun `a fully specified certificate round-trips, audit metadata included`() { + writeLedger(auditedEntry("giona", "2026-10-01T00:00:00Z")) + + val certificate = + MutationAuthorityDigestCertificateLoader + .load(tempDir) + .certificates + .single() + + assertEquals("raw-v1", certificate.fromAlgorithm) + assertEquals(fromDigest, certificate.fromDigest) + assertEquals("authority-v2", certificate.toAlgorithm) + assertEquals(toDigest, certificate.toDigest) + assertEquals(admissionSetDigest, certificate.admissionSetDigest) + assertEquals(fromBaseSha, certificate.fromBaseSha) + assertEquals("test reason", certificate.reason) + assertEquals("giona", certificate.authorizedBy) + } + + @Test + fun `the admission-set digest recipe reproduces the committed 67-admission set`() { + // The recipe is part of the contract, not an implementation detail: whoever mints the P1M + // certificate must be able to reproduce this value exactly from the authorization set the + // certificate is meant to bound, and M42 later compares against it. + val admissions = MutationPopulationAdmissionLoader.load(repositoryRoot()).admissions + + assertEquals(67, admissions.size, "the committed P1 authorization set") + assertEquals( + admissionSetDigest, + MutationAuthorityDigestCertificates.admissionSetDigest(admissions.map { it.identity }), + "sorted identities joined with a newline and a trailing newline, SHA-256", + ) + } + + @Test + fun `the admission-set digest is order independent and set dependent`() { + val a = "a".repeat(64) + val b = "b".repeat(64) + + assertEquals( + MutationAuthorityDigestCertificates.admissionSetDigest(listOf(a, b)), + MutationAuthorityDigestCertificates.admissionSetDigest(listOf(b, a)), + ) + assertTrue( + MutationAuthorityDigestCertificates.admissionSetDigest(listOf(a, b)) != + MutationAuthorityDigestCertificates.admissionSetDigest(listOf(a)), + "bounding a different set must change the digest, otherwise the certificate is not bounded", + ) + } + + @Test + fun `a malformed from digest is a hard failure, not a weaker authority`() { + failingLoad(entry(mapOf("fromDigest" to "not-a-digest"))) + } + + @Test + fun `a short fromBaseSha is a hard failure`() { + val failure = failingLoad(entry(mapOf("fromBaseSha" to "ec8b4da5"))) + + assertTrue(failure.message!!.contains("fromBaseSha"), failure.message) + } + + @Test + fun `an unknown digest semantics is a hard failure`() { + val failure = failingLoad(entry(mapOf("toAlgorithm" to "authority-v3"))) + + assertTrue(failure.message!!.contains("known digest semantics"), failure.message) + } + + @Test + fun `a missing reason is a hard failure`() { + val failure = failingLoad(entry(mapOf("reason" to null))) + + assertTrue(failure.message!!.contains("reason"), failure.message) + } + + @Test + fun `two certificates claiming the same source digest contradict each other and fail`() { + val failure = failingLoad(entry() + entry(mapOf("toDigest" to "f".repeat(64)))) + + assertTrue(failure.message!!.contains("duplicate fromDigest"), failure.message) + } + + @Test + fun `a certificate that migrates a digest to itself fails, because it certifies nothing`() { + val failure = failingLoad(entry(mapOf("toAlgorithm" to "raw-v1"))) + + assertTrue(failure.message!!.contains("certifies nothing"), failure.message) + } + + @Test + fun `an absent schemaVersion is a hard failure`() { + val file = File(tempDir, MutationAuthorityDigestCertificateLoader.FILE_NAME) + file.parentFile.mkdirs() + file.writeText("certificates:\n" + entry(), Charsets.UTF_8) + + val failure = assertFailsWith { MutationAuthorityDigestCertificateLoader.load(tempDir) } + + assertTrue(failure.message!!.contains("schemaVersion"), failure.message) + } + + @Test + fun `the enforced payload excludes audit metadata and includes every bound field`() { + val certificate = + MutationAuthorityDigestCertificate( + fromAlgorithm = "raw-v1", + fromDigest = fromDigest, + toAlgorithm = "authority-v2", + toDigest = toDigest, + admissionSetDigest = admissionSetDigest, + fromBaseSha = fromBaseSha, + reason = "test reason", + authorizedBy = "someone", + authorizedAt = "sometime", + ) + + assertEquals( + listOf("raw-v1", fromDigest, "authority-v2", toDigest, admissionSetDigest, fromBaseSha, "test reason"), + certificate.enforcedPayload(), + ) + assertEquals( + certificate.enforcedPayload(), + certificate.copy(authorizedBy = "another", authorizedAt = "elsewhere").enforcedPayload(), + "audit metadata alone can never manufacture or break authority", + ) + } + + /** Writes [entries] as the ledger, then asserts that loading it fails, returning the failure. */ + private fun failingLoad(entries: String): GradleException { + writeLedger(entries) + return assertFailsWith { MutationAuthorityDigestCertificateLoader.load(tempDir) } + } + + /** + * The repository root, found by walking up to the `gradlew` marker (the same idiom + * `CanonicalProbeFunctionalTest` uses) rather than assuming this test starts a fixed number of + * levels below it. This test is deliberately pinned against the real committed admission ledger, + * so a brittle path guess would fail for a reason that has nothing to do with the recipe. + */ + private fun repositoryRoot(): File { + var candidate = File(System.getProperty("user.dir")) + while (candidate.parentFile != null && !File(candidate, "gradlew").isFile) { + candidate = candidate.parentFile!! + } + check(File(candidate, "gradlew").isFile) { + "no repository root (directory containing gradlew) above ${System.getProperty("user.dir")}" + } + return candidate + } + + private fun auditedEntry( + authorizedBy: String, + authorizedAt: String, + ): String = + entry().replace( + " reason: \"test reason\"", + " authorizedBy: \"$authorizedBy\"\n authorizedAt: \"$authorizedAt\"\n reason: \"test reason\"", + ) + + /** + * The canonical certificate YAML, with selected fields replaced. + * + * Malformed-input cases need raw values that a typed certificate cannot represent, so overrides + * are raw strings (or null to omit a field) rather than certificate fields. + */ + private fun entry(overrides: Map = emptyMap()): String { + val fields = + linkedMapOf( + "fromAlgorithm" to "raw-v1", + "fromDigest" to fromDigest, + "toAlgorithm" to "authority-v2", + "toDigest" to toDigest, + "admissionSetDigest" to admissionSetDigest, + "fromBaseSha" to fromBaseSha, + "reason" to "test reason", + ) + fields.putAll(overrides) + val lines = + fields.entries + .filter { it.value != null } + .map { " ${it.key}: \"${it.value}\"" } + return " - " + lines.first().trimStart() + "\n" + lines.drop(1).joinToString("") { "$it\n" } + } + + private fun writeLedger(entries: String) { + val file = File(tempDir, MutationAuthorityDigestCertificateLoader.FILE_NAME) + file.parentFile.mkdirs() + file.writeText("schemaVersion: \"1\"\ncertificates:\n$entries", Charsets.UTF_8) + } +}