From 63ed9ba5d973610aa1ff62f1b92ccd2ee42d0d46 Mon Sep 17 00:00:00 2001 From: John McChesney TenEyck Jr <59268465+jmcte@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:21:46 +0100 Subject: [PATCH 1/2] feat: strengthen licensing transition contracts Normalize license transitions to explicit from/to mode and SHA-256 bindings, add separate third-party notice reconciliation evidence, centralize SPDX identifier validation, and retain compatibility for existing flat transition manifests. Follow-up to #86. Signed-off-by: John McChesney TenEyck Jr <59268465+jmcte@users.noreply.github.com> --- src/licensing.ts | 8 +++--- src/manifest.ts | 58 +++++++++++++++++++++++++++--------------- src/spdx.ts | 7 +++++ src/types.ts | 22 +++++++++++++--- tests/manifest.test.ts | 52 +++++++++++++++++++++++++++++++++++++ 5 files changed, 118 insertions(+), 29 deletions(-) create mode 100644 src/spdx.ts diff --git a/src/licensing.ts b/src/licensing.ts index bc7e7d7..0fd4bcd 100644 --- a/src/licensing.ts +++ b/src/licensing.ts @@ -280,10 +280,10 @@ function requireTransitionEvidence( ); } const transitionMatches = - policy.transition.fromMode === beforeMode && - policy.transition.fromContentSha256.toLowerCase() === beforeContentSha256 && - policy.transition.toMode === afterMode && - policy.transition.toContentSha256.toLowerCase() === afterContentSha256; + policy.transition.from.mode === beforeMode && + policy.transition.from.licenseSha256 === beforeContentSha256 && + policy.transition.to.mode === afterMode && + policy.transition.to.licenseSha256 === afterContentSha256; if (!transitionMatches) { throw new Error( `PRS-LICENSE-TRANSITION-001: transition evidence does not match ${beforeMode}@${beforeContentSha256} -> ${afterMode}@${afterContentSha256}.` diff --git a/src/manifest.ts b/src/manifest.ts index 0b12373..3836947 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -1,9 +1,9 @@ import path from "node:path"; -import spdxLicenseIdentifiers from "spdx-license-list/simple.js"; import YAML from "yaml"; import { z } from "zod"; import { readTextIfExists } from "./lib/fs.js"; +import { isSupportedSpdxIdentifier } from "./spdx.js"; import type { AdditionalWorkflowConfig, DependabotConfig, @@ -214,6 +214,7 @@ const notificationSchema = z.object({ webhookUrlEnv: z.string().regex(/^[A-Z_][A-Z0-9_]*$/) }); +const sha256Schema = z.string().regex(/^[0-9a-fA-F]{64}$/).transform((value) => value.toLowerCase()); const licenseYearsSchema = z.string().regex(/^\d{4}(?:-\d{4})?$/, "Use a four-digit year or inclusive year range.").refine((value) => { const start = Number(value.slice(0, 4)); const end = value.length === 4 ? start : Number(value.slice(5)); @@ -231,8 +232,7 @@ const singleLineLicenseString = z const multilineLicenseText = z .string() - .trim() - .min(1) + .refine((value) => value.trim().length > 0, "Must not be blank.") .refine( (value) => !/[\u0000-\u0008\u000B-\u001F\u007F-\u009F\p{Cf}\p{Zl}\p{Zp}]/u.test(value), "Do not use control, format, or Unicode separator characters in legal text." @@ -240,9 +240,9 @@ const multilineLicenseText = z const licenseTemplateSchema = z.object({ path: singleLineLicenseString, - sha256: z.string().regex(/^[0-9a-fA-F]{64}$/), + sha256: sha256Schema, approval: singleLineLicenseString, - spdxIdentifier: z.string().optional() + spdxIdentifier: singleLineLicenseString.optional() }); const thirdPartyNoticeSchema = z.object({ @@ -253,16 +253,41 @@ const thirdPartyNoticeSchema = z.object({ notice: multilineLicenseText.optional() }); -const licenseTransitionSchema = z.object({ +const currentLicenseTransitionSchema = z.object({ + from: z.object({ mode: singleLineLicenseString, licenseSha256: sha256Schema }), + to: z.object({ mode: singleLineLicenseString, licenseSha256: sha256Schema }), approvedBy: singleLineLicenseString, issue: singleLineLicenseString, ownership: singleLineLicenseString, contributors: singleLineLicenseString, distributionHistory: singleLineLicenseString, + reconciles: z.array(z.object({ path: singleLineLicenseString, licenseSha256: sha256Schema })).optional() +}); + +const legacyLicenseTransitionSchema = z.object({ fromMode: singleLineLicenseString, - fromContentSha256: z.string().regex(/^[0-9a-fA-F]{64}$/), + fromContentSha256: sha256Schema, toMode: singleLineLicenseString, - toContentSha256: z.string().regex(/^[0-9a-fA-F]{64}$/) + toContentSha256: sha256Schema, + approvedBy: singleLineLicenseString, + issue: singleLineLicenseString, + ownership: singleLineLicenseString, + contributors: singleLineLicenseString, + distributionHistory: singleLineLicenseString +}).transform(({ fromMode, fromContentSha256, toMode, toContentSha256, ...evidence }) => ({ + from: { mode: fromMode, licenseSha256: fromContentSha256 }, + to: { mode: toMode, licenseSha256: toContentSha256 }, + ...evidence +})); + +const licenseTransitionSchema = z.union([currentLicenseTransitionSchema, legacyLicenseTransitionSchema]); + +const thirdPartyNoticesTransitionSchema = z.object({ + fromSha256: sha256Schema, + toSha256: sha256Schema, + approvedBy: singleLineLicenseString, + issue: singleLineLicenseString, + reconciliation: singleLineLicenseString }); const licensePolicyBase = { @@ -271,32 +296,23 @@ const licensePolicyBase = { years: licenseYearsSchema, template: licenseTemplateSchema, thirdPartyNotices: z.array(thirdPartyNoticeSchema), + thirdPartyNoticesTransition: thirdPartyNoticesTransitionSchema.optional(), transition: licenseTransitionSchema.optional() }; const licensePolicySchema = z.discriminatedUnion("mode", [ z.object({ mode: z.literal("spdx"), - identifier: z - .string() - .refine((value) => spdxLicenseIdentifiers.has(value), "Use a recognized SPDX license identifier."), + identifier: z.string().refine(isSupportedSpdxIdentifier, "Use a recognized SPDX license identifier from Bootstrap's supported list; LicenseRef values and expressions are not supported."), ...licensePolicyBase }), z.object({ mode: z.literal("proprietary"), ...licensePolicyBase }) ]).superRefine((policy, context) => { if (policy.mode === "spdx" && policy.template.spdxIdentifier !== policy.identifier) { - context.addIssue({ - code: "custom", - path: ["template", "spdxIdentifier"], - message: "SPDX templates must be approved for the selected license identifier." - }); + context.addIssue({ code: "custom", path: ["template", "spdxIdentifier"], message: "SPDX templates must be approved for the selected license identifier." }); } if (policy.mode === "proprietary" && policy.template.spdxIdentifier !== undefined) { - context.addIssue({ - code: "custom", - path: ["template", "spdxIdentifier"], - message: "Proprietary templates cannot declare an SPDX identifier approval." - }); + context.addIssue({ code: "custom", path: ["template", "spdxIdentifier"], message: "Proprietary templates cannot declare an SPDX identifier approval." }); } }); diff --git a/src/spdx.ts b/src/spdx.ts new file mode 100644 index 0000000..8a1ebf1 --- /dev/null +++ b/src/spdx.ts @@ -0,0 +1,7 @@ +import spdxLicenseIdentifiers from "spdx-license-list/simple.js"; + +export const SUPPORTED_SPDX_IDENTIFIERS = [...spdxLicenseIdentifiers].sort(); + +export function isSupportedSpdxIdentifier(value: string): boolean { + return spdxLicenseIdentifiers.has(value); +} diff --git a/src/types.ts b/src/types.ts index 2db97b1..3383943 100644 --- a/src/types.ts +++ b/src/types.ts @@ -33,15 +33,28 @@ export interface ThirdPartyNotice { } export interface LicenseTransitionEvidence { + from: { + mode: string; + licenseSha256: string; + }; + to: { + mode: string; + licenseSha256: string; + }; approvedBy: string; issue: string; ownership: string; contributors: string; distributionHistory: string; - fromMode: string; - fromContentSha256: string; - toMode: string; - toContentSha256: string; + reconciles?: Array<{ path: string; licenseSha256: string }>; +} + +export interface ThirdPartyNoticesTransitionEvidence { + fromSha256: string; + toSha256: string; + approvedBy: string; + issue: string; + reconciliation: string; } interface LicensePolicyBase { @@ -50,6 +63,7 @@ interface LicensePolicyBase { years: string; template: LicenseTemplateReference; thirdPartyNotices: ThirdPartyNotice[]; + thirdPartyNoticesTransition?: ThirdPartyNoticesTransitionEvidence; transition?: LicenseTransitionEvidence; } diff --git a/tests/manifest.test.ts b/tests/manifest.test.ts index 17e1279..c7cd2e8 100644 --- a/tests/manifest.test.ts +++ b/tests/manifest.test.ts @@ -105,6 +105,58 @@ describe("normalizeManifest", () => { archetype: { kind: "generic-empty" } })).toThrow("SPDX templates must be approved for the selected license identifier"); + expect(() => normalizeManifest({ + project: { name: "unbound-spdx-template", owner: "acme", visibility: "public" }, + license: { + mode: "spdx", identifier: "MIT", holder: "Acme LLC", holderVerification: "legal-entity:acme-llc", years: "2026", + template: { path: "legal/mit.txt", sha256: templateDigest, approval: "maintainer-approved" }, thirdPartyNotices: [] + }, + archetype: { kind: "generic-empty" } + })).toThrow("SPDX templates must be approved for the selected license identifier"); + + const fullRegistryPolicy = normalizeManifest({ + project: { name: "full-spdx-registry", owner: "acme", visibility: "public" }, + license: { mode: "spdx", identifier: "0BSD", holder: "Acme LLC", holderVerification: "legal-entity:acme-llc", years: "2026", template: { path: "legal/0bsd.txt", sha256: templateDigest, approval: "maintainer-approved", spdxIdentifier: "0BSD" }, thirdPartyNotices: [] }, + archetype: { kind: "generic-empty" } + }); + expect(fullRegistryPolicy.license).toMatchObject({ mode: "spdx", identifier: "0BSD" }); + + const legacyTransition = normalizeManifest({ + project: { name: "legacy-transition", owner: "acme", visibility: "private" }, + license: { mode: "proprietary", holder: "Acme LLC", holderVerification: "legal-entity:acme-llc", years: "2026", template: { path: "legal/proprietary.txt", sha256: templateDigest, approval: "counsel:P-1" }, thirdPartyNotices: [], transition }, + archetype: { kind: "generic-empty" } + }); + expect(legacyTransition.license?.transition).toMatchObject({ from: { mode: "existing-unclassified", licenseSha256: "b".repeat(64) }, to: { mode: "proprietary", licenseSha256: "c".repeat(64) } }); + + const currentTransition = normalizeManifest({ + project: { name: "current-transition", owner: "acme", visibility: "private" }, + license: { + mode: "proprietary", holder: "Acme LLC", holderVerification: "legal-entity:acme-llc", years: "2026", + template: { path: "legal/proprietary.txt", sha256: templateDigest.toUpperCase(), approval: "counsel:P-1" }, thirdPartyNotices: [], + transition: { + from: { mode: "existing-unclassified", licenseSha256: "B".repeat(64) }, + to: { mode: "proprietary", licenseSha256: "C".repeat(64) }, + approvedBy: "legal-reviewer", issue: "LEGAL-42", ownership: "Ownership verified", + contributors: "Contributor rights verified", distributionHistory: "Distribution history recorded", + reconciles: [{ path: "COPYING", licenseSha256: "D".repeat(64) }] + }, + thirdPartyNoticesTransition: { + fromSha256: "E".repeat(64), toSha256: "F".repeat(64), approvedBy: "legal-reviewer", + issue: "LEGAL-42", reconciliation: "Every third-party obligation was reviewed" + } + }, + archetype: { kind: "generic-empty" } + }); + expect(currentTransition.license).toMatchObject({ + template: { sha256: templateDigest }, + transition: { + from: { licenseSha256: "b".repeat(64) }, + to: { licenseSha256: "c".repeat(64) }, + reconciles: [{ path: "COPYING", licenseSha256: "d".repeat(64) }] + }, + thirdPartyNoticesTransition: { fromSha256: "e".repeat(64), toSha256: "f".repeat(64) } + }); + expect(() => normalizeManifest({ project: { name: "injected-holder", owner: "acme", visibility: "private" }, license: { From c124df25cad3266d29208997af455fa0e9f0b1a8 Mon Sep 17 00:00:00 2001 From: John McChesney TenEyck Jr <59268465+jmcte@users.noreply.github.com> Date: Thu, 23 Jul 2026 00:02:10 +0100 Subject: [PATCH 2/2] fix: require evidence for managed notice changes Validate approved SHA-bound third-party notice transitions before updating managed inventory, including an explicit empty-inventory regression case. Signed-off-by: John McChesney TenEyck Jr <59268465+jmcte@users.noreply.github.com> --- docs/bootstrap/licensing.md | 3 +- .../ADR-0002-explicit-license-modes.md | 2 +- src/licensing.ts | 42 +++++++- src/manifest.ts | 5 +- tests/licensing.test.ts | 96 +++++++++++++++++++ tests/manifest.test.ts | 23 ++++- 6 files changed, 164 insertions(+), 7 deletions(-) diff --git a/docs/bootstrap/licensing.md b/docs/bootstrap/licensing.md index 4b7e105..fb8b67d 100644 --- a/docs/bootstrap/licensing.md +++ b/docs/bootstrap/licensing.md @@ -13,6 +13,7 @@ Bootstrap copies an approved repository-local template into `LICENSE`, substitut | Private repository without a license decision | omitted | Does not create or infer a license; conformance blocks | Legal/stewardship decision | | Existing license matches the configured template | explicit matching mode | Adopts it without rewriting legal text | Review the manifest and template provenance | | Existing license would change | explicit replacement mode | Hard-stops until transition evidence is present | Legal approval plus ownership, contributor, and distribution-history evidence | +| Managed third-party notices would change | `thirdPartyNoticesTransition` | Hard-stops until SHA-bound notice reconciliation evidence is present | Legal approval and component-obligation reconciliation | | License policy is removed after Bootstrap managed it | omitted | Hard-stops; Bootstrap will not delete the license | Choose an explicit replacement mode and complete legal review | ## Manifest contract @@ -36,7 +37,7 @@ license: An SPDX policy uses `mode: spdx`, adds one `identifier`, such as `MIT`, and records the same value as `template.spdxIdentifier`. That binding prevents a template approved for one license from being relabeled as another, and any literal SPDX declaration in the rendered file must also match it. The approved template must contain `{{copyright_holder}}` and `{{copyright_years}}`; an SPDX template may also use `{{spdx_identifier}}`. Bootstrap substitutes supported tokens in one pass and preserves every other approved template byte exactly. Templates must be regular, singly linked UTF-8 files with no symlinked path components, must physically remain inside the target repository, cannot alias any selected managed or generated state output even on case-insensitive filesystems, and must match an exact byte-level SHA-256 pin. Existing `LICENSE` files must also be valid UTF-8, while transition decisions and hashes bind their exact bytes, including a leading BOM. Interpolated holder, approval, reference, and notice metadata rejects Unicode control, format, and separator characters; only notice bodies may span multiple LF-delimited lines. These constraints keep the reviewed source text versioned and prevent Bootstrap from inventing, downloading, silently changing, or spoofing terms. -Third-party entries are sorted deterministically. Their `kind` is one of `dependency`, `asset`, `font`, `media`, or `incorporated-source`. An optional `notice` preserves attribution or other required text. An existing unmanaged `THIRD_PARTY_NOTICES.md` is never overwritten; its obligations must first be reconciled into the manifest. +Third-party entries are sorted deterministically. Their `kind` is one of `dependency`, `asset`, `font`, `media`, or `incorporated-source`. An optional `notice` preserves attribution or other required text. An existing unmanaged `THIRD_PARTY_NOTICES.md` is never overwritten; its obligations must first be reconciled into the manifest. Changing an already managed notice inventory requires `thirdPartyNoticesTransition` evidence with `approvedBy`, `issue`, `reconciliation`, and SHA-256 hashes of the exact existing and rendered notice bytes. ## Existing-license hard stop diff --git a/docs/decisions/ADR-0002-explicit-license-modes.md b/docs/decisions/ADR-0002-explicit-license-modes.md index b08b373..2c40601 100644 --- a/docs/decisions/ADR-0002-explicit-license-modes.md +++ b/docs/decisions/ADR-0002-explicit-license-modes.md @@ -25,7 +25,7 @@ When configured, the policy must: 3. Keep third-party dependency, asset, font, media, and incorporated-source notices separate from the first-party notice. 4. Bind each SPDX template approval and rendered SPDX declaration to the one selected identifier, reject proprietary templates that contain SPDX declarations, and substitute approved tokens without rescanning legal metadata. 5. Treat adoption of an unmanaged license, any change to existing license bytes, or any managed legal-classification change as a hard stop until approver, issue, ownership, contributor, and distribution-history evidence is bound to the exact before/after modes and content hashes. -6. Preserve managed-file ownership and fail when a managed license is edited directly or unmanaged third-party notices would be overwritten. +6. Preserve managed-file ownership and fail when a managed license or notice inventory is edited directly, unmanaged third-party notices would be overwritten, or a managed notice inventory changes without approved SHA-bound reconciliation evidence. 7. Show the exact before and after modes in plan output before apply. The proprietary mode is a projection mechanism only. This decision does not approve a legal entity, copyright holder, or proprietary template for any downstream product. diff --git a/src/licensing.ts b/src/licensing.ts index 0fd4bcd..58e2ff2 100644 --- a/src/licensing.ts +++ b/src/licensing.ts @@ -291,6 +291,27 @@ function requireTransitionEvidence( } } +function requireThirdPartyNoticesTransitionEvidence( + policy: LicensePolicy, + beforeContentSha256: string, + afterContentSha256: string +): void { + const transition = policy.thirdPartyNoticesTransition; + if (!transition) { + throw new Error( + "PRS-LICENSE-NOTICES-TRANSITION-001: changing managed third-party notices is a legal hard stop; record approvedBy, issue, reconciliation, and exact before/after SHA-256 evidence." + ); + } + if ( + transition.fromSha256 !== beforeContentSha256 || + transition.toSha256 !== afterContentSha256 + ) { + throw new Error( + `PRS-LICENSE-NOTICES-TRANSITION-001: notices transition evidence does not match ${beforeContentSha256} -> ${afterContentSha256}.` + ); + } +} + export async function projectLicensePolicy( manifest: BootstrapManifest, targetDir: string, @@ -348,15 +369,34 @@ export async function projectLicensePolicy( } requireTransitionEvidence(policy, beforeMode, beforeContentSha256, afterMode, afterContentSha256); } + const managedNoticesHash = state?.managedFiles[THIRD_PARTY_NOTICES_PATH]; + if (existingNoticesFile === undefined && managedNoticesHash !== undefined) { + throw new Error("PRS-OWNERSHIP-001: managed THIRD_PARTY_NOTICES.md was deleted; restore it before planning a legal transition."); + } + const existingNoticesSha256 = existingNoticesFile === undefined ? undefined : sha256(existingNoticesFile.bytes); + if ( + existingNoticesSha256 !== undefined && + managedNoticesHash !== undefined && + managedNoticesHash !== existingNoticesSha256 + ) { + throw new Error("PRS-OWNERSHIP-001: managed THIRD_PARTY_NOTICES.md was directly modified; restore it before planning a legal transition."); + } if ( existingNotices !== undefined && existingNotices !== noticesContents && - state?.managedFiles[THIRD_PARTY_NOTICES_PATH] === undefined + managedNoticesHash === undefined ) { throw new Error( "PRS-LICENSE-NOTICES-001: existing third-party notices are unmanaged and would be replaced; incorporate every obligation into license.thirdPartyNotices before applying." ); } + if ( + existingNoticesSha256 !== undefined && + existingNotices !== noticesContents && + managedNoticesHash !== undefined + ) { + requireThirdPartyNoticesTransitionEvidence(policy, existingNoticesSha256, sha256(Buffer.from(noticesContents, "utf8"))); + } return { files: [ diff --git a/src/manifest.ts b/src/manifest.ts index 3836947..ef37c38 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -232,11 +232,12 @@ const singleLineLicenseString = z const multilineLicenseText = z .string() - .refine((value) => value.trim().length > 0, "Must not be blank.") .refine( (value) => !/[\u0000-\u0008\u000B-\u001F\u007F-\u009F\p{Cf}\p{Zl}\p{Zp}]/u.test(value), "Do not use control, format, or Unicode separator characters in legal text." - ); + ) + .transform((value) => value.trim()) + .refine((value) => value.length > 0, "Must not be blank."); const licenseTemplateSchema = z.object({ path: singleLineLicenseString, diff --git a/tests/licensing.test.ts b/tests/licensing.test.ts index 1ff2996..88d3d95 100644 --- a/tests/licensing.test.ts +++ b/tests/licensing.test.ts @@ -642,4 +642,100 @@ describe("license policy projection", () => { await writeFile(path.join(directory, "THIRD_PARTY_NOTICES.md"), "Existing obligations\n"); await expect(planRepo(manifest(proprietary()), directory)).rejects.toThrow("PRS-LICENSE-NOTICES-001"); }); + + it("requires SHA-bound evidence before changing managed third-party notices", async () => { + const directory = await fixture("managed-third-party-transition"); + const originalPolicy = proprietary({ + thirdPartyNotices: [{ name: "Existing SDK", kind: "dependency", license: "MIT", source: "https://example.invalid/sdk" }] + }); + await applyRepo(manifest(originalPolicy), directory); + const noticesPath = path.join(directory, "THIRD_PARTY_NOTICES.md"); + const originalNotices = await readFile(noticesPath, "utf8"); + + await writeFile(noticesPath, "Directly edited obligations\n"); + await expect(planRepo(manifest(originalPolicy), directory)).rejects.toThrow( + "PRS-OWNERSHIP-001: managed THIRD_PARTY_NOTICES.md was directly modified" + ); + await writeFile(noticesPath, originalNotices); + + await unlink(noticesPath); + await expect(planRepo(manifest(originalPolicy), directory)).rejects.toThrow( + "PRS-OWNERSHIP-001: managed THIRD_PARTY_NOTICES.md was deleted" + ); + await writeFile(noticesPath, originalNotices); + + const updatedNotices = [ + ...originalPolicy.thirdPartyNotices, + { name: "New Font", kind: "font" as const, license: "OFL-1.1", source: "assets/fonts/new-font" } + ]; + const withoutEvidence = manifest(proprietary({ thirdPartyNotices: updatedNotices })); + await expect(planRepo(withoutEvidence, directory)).rejects.toThrow("PRS-LICENSE-NOTICES-TRANSITION-001"); + + const mismatchedEvidence = manifest(proprietary({ + thirdPartyNotices: updatedNotices, + thirdPartyNoticesTransition: { + fromSha256: sha256("incorrect before\n"), + toSha256: sha256("incorrect after\n"), + approvedBy: "legal-reviewer", + issue: "LEGAL-42", + reconciliation: "Every third-party obligation was reviewed" + } + })); + await expect(planRepo(mismatchedEvidence, directory)).rejects.toThrow("notices transition evidence does not match"); + + const updatedNoticesContents = [ + "# Third-Party Notices", + "", + "This inventory is separate from the repository's first-party license. Each listed component remains subject to its own terms.", + "", + "## Existing SDK", + "", + "- Kind: dependency", + "- License: MIT", + "- Source: https://example.invalid/sdk", + "", + "## New Font", + "", + "- Kind: font", + "- License: OFL-1.1", + "- Source: assets/fonts/new-font", + "" + ].join("\n"); + const withEvidence = manifest(proprietary({ + thirdPartyNotices: updatedNotices, + thirdPartyNoticesTransition: { + fromSha256: sha256(originalNotices), + toSha256: sha256(updatedNoticesContents), + approvedBy: "legal-reviewer", + issue: "LEGAL-42", + reconciliation: "Every third-party obligation was reviewed" + } + })); + expect((await planRepo(withEvidence, directory)).changes).toContainEqual( + expect.objectContaining({ path: "THIRD_PARTY_NOTICES.md", type: "update" }) + ); + + await applyRepo(withEvidence, directory); + const noNoticesContents = [ + "# Third-Party Notices", + "", + "This inventory is separate from the repository's first-party license. Each listed component remains subject to its own terms.", + "", + "No third-party notices are declared in the Bootstrap manifest.", + "" + ].join("\n"); + const emptyInventory = manifest(proprietary({ + thirdPartyNotices: [], + thirdPartyNoticesTransition: { + fromSha256: sha256(await readFile(path.join(directory, "THIRD_PARTY_NOTICES.md"), "utf8")), + toSha256: sha256(noNoticesContents), + approvedBy: "legal-reviewer", + issue: "LEGAL-42", + reconciliation: "Every third-party obligation was reviewed" + } + })); + expect((await planRepo(emptyInventory, directory)).changes).toContainEqual( + expect.objectContaining({ path: "THIRD_PARTY_NOTICES.md", type: "update" }) + ); + }); }); diff --git a/tests/manifest.test.ts b/tests/manifest.test.ts index c7cd2e8..9077df4 100644 --- a/tests/manifest.test.ts +++ b/tests/manifest.test.ts @@ -209,11 +209,30 @@ describe("normalizeManifest", () => { kind: "dependency", license: "Apache-2.0", source: "https://example.invalid/sdk", - notice: "Copyright contributors.\nSee bundled NOTICE." + notice: "\nCopyright contributors.\nSee bundled NOTICE.\n" }] }, archetype: { kind: "generic-empty" } - }).license?.thirdPartyNotices[0]?.notice).toContain("\n"); + }).license?.thirdPartyNotices[0]?.notice).toBe("Copyright contributors.\nSee bundled NOTICE."); + + expect(() => normalizeManifest({ + project: { name: "unsafe-notice-boundary", owner: "acme", visibility: "private" }, + license: { + mode: "proprietary", + holder: "Acme LLC", + holderVerification: "legal-entity:acme-llc", + years: "2026", + template: { path: "legal/proprietary.txt", sha256: templateDigest, approval: "counsel:P-1" }, + thirdPartyNotices: [{ + name: "SDK", + kind: "dependency", + license: "Apache-2.0", + source: "https://example.invalid/sdk", + notice: "\ufeffCopyright contributors." + }] + }, + archetype: { kind: "generic-empty" } + })).toThrow("Do not use control, format, or Unicode separator characters in legal text."); }); it("accepts version 2 manifests as compatibility input", () => {