diff --git a/src/lib/LibCodeGen.sol b/src/lib/LibCodeGen.sol index 4e605e2..cc481f7 100644 --- a/src/lib/LibCodeGen.sol +++ b/src/lib/LibCodeGen.sol @@ -26,6 +26,18 @@ error InvalidIdentifier(string name); /// @param instance The address that holds no code. error CodelessInstance(address instance); +/// Thrown when an SPDX licence identifier is not a non-empty single line. An +/// empty identifier names no licence on a tag that a presence check accepts, and +/// solc refuses the file it heads with "Invalid SPDX license identifier". A line +/// break ends the tag's line so that everything after it lands as source. +/// @param spdxLicenseIdentifier The rejected identifier. +error InvalidSpdxLicenseIdentifier(string spdxLicenseIdentifier); + +/// Thrown when a copyright text is not a non-empty single line, for the same +/// reasons the licence identifier has to be one. +/// @param copyrightText The rejected text. +error InvalidCopyrightText(string copyrightText); + /// @title LibCodeGen /// @notice Library for generating Solidity code snippets for contract function /// pointers, code hashes, associated comments, etc. All snippets are returned @@ -71,16 +83,58 @@ library LibCodeGen { return bytes(comment).length == 0 ? "\n" : string.concat("\n", comment, "\n"); } + /// True when `text` can be interpolated into a header line as itself: at + /// least one byte, and no byte that ends a line. Solidity ends a `//` + /// comment at either a line feed or a carriage return, so a value carrying + /// one would close the tag's line and continue as source. + /// @param text The text to check. + /// @return Whether the text is a non-empty single line. + function isSingleLine(string memory text) internal pure returns (bool) { + bytes memory textBytes = bytes(text); + if (textBytes.length == 0) { + return false; + } + for (uint256 i = 0; i < textBytes.length; i++) { + if (textBytes[i] == 0x0A || textBytes[i] == 0x0D) { + return false; + } + } + return true; + } + /// The file prefix for autogenerated files outlines the license, pragma, /// and a note about the file being autogenerated. The pragma is ^ as the /// generated code is expected to be imported into some concrete contract /// with pragma = version. - function filePrefix() internal pure returns (string memory) { + /// + /// The generated file lands in the calling project's repo, so the licence it + /// is under and the copyright holder it names are the calling project's to + /// state and are taken from the caller. Both are interpolated verbatim into + /// their tags, and both have to be a non-empty single line for the tag they + /// land on to say what it appears to. + /// @param spdxLicenseIdentifier The SPDX licence identifier for the + /// generated file, interpolated verbatim. + /// @param copyrightText The copyright text for the generated file, + /// interpolated verbatim. + /// @return The text that heads the generated file. + function filePrefix(string memory spdxLicenseIdentifier, string memory copyrightText) + internal + pure + returns (string memory) + { + if (!isSingleLine(spdxLicenseIdentifier)) { + revert InvalidSpdxLicenseIdentifier(spdxLicenseIdentifier); + } + if (!isSingleLine(copyrightText)) { + revert InvalidCopyrightText(copyrightText); + } //REUSE-IgnoreStart return string.concat( - "// SPDX-License-Identifier: LicenseRef-DCL-1.0\n" - "// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd\n" - "pragma solidity ^0.8.25;\n\n", + "// SPDX-License-Identifier: ", + spdxLicenseIdentifier, + "\n" "// SPDX-FileCopyrightText: ", + copyrightText, + "\n" "pragma solidity ^0.8.25;\n\n", "// THIS FILE IS AUTOGENERATED BY THE BUILD SCRIPT. DO NOT EDIT BY HAND.\n" ); //REUSE-IgnoreEnd diff --git a/src/lib/LibFs.sol b/src/lib/LibFs.sol index bcb2753..248ad4f 100644 --- a/src/lib/LibFs.sol +++ b/src/lib/LibFs.sol @@ -91,11 +91,12 @@ library LibFs { /// committed already. /// /// The whole file content is built before anything on disk is touched, and - /// building it reverts for an `instance` that holds no code. A revert does - /// not roll back cheatcode filesystem effects, so ordering the build first - /// is what keeps a failed generation from leaving the directory worse than - /// it found it: nothing is created, unlinked or written unless there is - /// content to write. + /// building it reverts for an `instance` that holds no code and for a + /// licence or copyright `filePrefix` refuses. A revert does not roll back + /// cheatcode filesystem effects, so ordering the build first is what keeps + /// a failed generation from leaving the directory worse than it found it: + /// nothing is created, unlinked or written unless there is content to + /// write. /// /// The path is unlinked until it holds nothing, then written, so a symlink /// there is replaced by a regular file rather than written through to its @@ -114,13 +115,27 @@ library LibFs { /// produce the same bytes. The prefix and bytecode hash constant are always /// included, further content is provided in the body parameter, which is /// expected to be generated by `LibCodeGen` by the caller. + /// + /// The file lands in the calling project's repo, so the licence it is under + /// and the copyright holder it names come from the caller and are subject to + /// `LibCodeGen.filePrefix`'s rule for them. /// @param vm The Vm instance for file operations. /// @param instance The contract instance whose bytecode hash is to be /// included. /// @param contractName The name of the contract. + /// @param spdxLicenseIdentifier The SPDX licence identifier the written file + /// declares. + /// @param copyrightText The copyright text the written file declares. /// @param body The body of the contract file to be written. - function buildFileForContract(Vm vm, address instance, string memory contractName, string memory body) internal { - buildFileForContract(vm, instance, GENERATED_DIR, contractName, body); + function buildFileForContract( + Vm vm, + address instance, + string memory contractName, + string memory spdxLicenseIdentifier, + string memory copyrightText, + string memory body + ) internal { + buildFileForContract(vm, instance, GENERATED_DIR, contractName, spdxLicenseIdentifier, copyrightText, body); } /// @notice Builds a file for a generated contract inside `dir` rather than @@ -143,17 +158,25 @@ library LibFs { /// @param dir The directory to put the file in, without a trailing /// separator, interpolated verbatim. /// @param contractName The name of the contract. + /// @param spdxLicenseIdentifier The SPDX licence identifier the written file + /// declares. + /// @param copyrightText The copyright text the written file declares. /// @param body The body of the contract file to be written. function buildFileForContract( Vm vm, address instance, string memory dir, string memory contractName, + string memory spdxLicenseIdentifier, + string memory copyrightText, string memory body ) internal { string memory path = pathForContractIn(dir, contractName); - string memory content = - string.concat(LibCodeGen.filePrefix(), LibCodeGen.bytecodeHashConstantString(vm, instance), body); + string memory content = string.concat( + LibCodeGen.filePrefix(spdxLicenseIdentifier, copyrightText), + LibCodeGen.bytecodeHashConstantString(vm, instance), + body + ); //forge-lint: disable-next-line(unsafe-cheatcode) vm.createDir(dir, true); // `vm.removeFile` resolves the path before it acts, so on a live symlink diff --git a/test/concrete/LibFsExternal.sol b/test/concrete/LibFsExternal.sol index daa4560..73a2cee 100644 --- a/test/concrete/LibFsExternal.sol +++ b/test/concrete/LibFsExternal.sol @@ -9,8 +9,15 @@ import {LibFs} from "src/lib/LibFs.sol"; /// Puts `LibFs` behind a call frame. `vm.expectRevert` needs one, and the /// library functions are internal so they are inlined into whatever calls them. contract LibFsExternal { - function buildFileForContract(Vm vm, address instance, string memory contractName, string memory body) external { - LibFs.buildFileForContract(vm, instance, contractName, body); + function buildFileForContract( + Vm vm, + address instance, + string memory contractName, + string memory spdxLicenseIdentifier, + string memory copyrightText, + string memory body + ) external { + LibFs.buildFileForContract(vm, instance, contractName, spdxLicenseIdentifier, copyrightText, body); } function pathForContract(string memory contractName) external pure returns (string memory) { diff --git a/test/src/lib/LibCodeGen.filePrefix.t.sol b/test/src/lib/LibCodeGen.filePrefix.t.sol index 68ed64c..74fb606 100644 --- a/test/src/lib/LibCodeGen.filePrefix.t.sol +++ b/test/src/lib/LibCodeGen.filePrefix.t.sol @@ -3,14 +3,48 @@ pragma solidity =0.8.25; import {Test} from "forge-std-1.16.2/src/Test.sol"; -import {LibCodeGen} from "src/lib/LibCodeGen.sol"; +import {LibCodeGen, InvalidSpdxLicenseIdentifier, InvalidCopyrightText} from "src/lib/LibCodeGen.sol"; + +// The subject of this whole file is emitted licence text, so SPDX tag prefixes +// appear throughout it without a value attached. The file's own header is above +// this line and still registers. +//REUSE-IgnoreStart /// @title LibCodeGenFilePrefixTest +/// @notice The prefix heads every file that is generated through this library, +/// in every consumer repo in every org. The licence and the copyright holder it +/// states belong to the repo the file lands in, so what is asserted here is that +/// the caller's values reach the header verbatim and that nothing this repo +/// believes about its own licensing survives into a file it does not own. contract LibCodeGenFilePrefixTest is Test { + /// Reachable only through an external call so that a revert can be caught + /// rather than aborting the test. + function callFilePrefix(string memory spdxLicenseIdentifier, string memory copyrightText) + external + pure + returns (string memory) + { + return LibCodeGen.filePrefix(spdxLicenseIdentifier, copyrightText); + } + + /// Printable ASCII of at least one character, drawn from a seed. Fuzzing a + /// string directly cannot reach the accepted half of the domain in a form + /// the string cheatcodes can read back, because arbitrary bytes are not + /// valid UTF-8. + function textFromSeed(bytes memory seed) internal pure returns (string memory) { + bytes memory text = new bytes(seed.length + 1); + text[0] = "X"; + for (uint256 i = 0; i < seed.length; i++) { + text[i + 1] = bytes1(0x20 + (uint8(seed[i]) % 95)); + } + return string(text); + } + /// The prefix heads every generated file in every consumer repo, so a - /// change here rewrites committed files org wide. Pinned exactly so that - /// lands as a deliberate, reviewable diff rather than a surprise on the - /// next regeneration. + /// change to the part of it this library owns rewrites committed files org + /// wide. Pinned exactly, with this repo's own licence and copyright as the + /// caller's values, so that lands as a deliberate, reviewable diff rather + /// than a surprise on the next regeneration. /// /// The pinned text names no script. Each consumer names its own build /// script, so a script path in the prefix would be a claim this library @@ -18,13 +52,140 @@ contract LibCodeGenFilePrefixTest is Test { /// names a `.sol` file fails, and pinning the whole prefix is what /// refuses them. function testFilePrefixExact() external pure { - //REUSE-IgnoreStart assertEq( - LibCodeGen.filePrefix(), + LibCodeGen.filePrefix("LicenseRef-DCL-1.0", "Copyright (c) 2020 Rain Open Source Software Ltd"), "// SPDX-License-Identifier: LicenseRef-DCL-1.0\n" "// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd\n" "pragma solidity ^0.8.25;\n\n" "// THIS FILE IS AUTOGENERATED BY THE BUILD SCRIPT. DO NOT EDIT BY HAND.\n" ); - //REUSE-IgnoreEnd + } + + /// A consumer in another org gets its own licence and its own copyright + /// holder, and none of this repo's. `S01-Issuer/st0x.deploy` generates + /// through this library into append-only deploy-pin snapshots, so what those + /// files claim about their licensing is permanent once written. + function testFilePrefixCarriesTheCallersLicenceAndCopyright() external pure { + string memory prefix = LibCodeGen.filePrefix("MIT", "Copyright (c) 2026 S01 Issuer GmbH"); + assertEq( + prefix, + "// SPDX-License-Identifier: MIT\n" "// SPDX-FileCopyrightText: Copyright (c) 2026 S01 Issuer GmbH\n" + "pragma solidity ^0.8.25;\n\n" "// THIS FILE IS AUTOGENERATED BY THE BUILD SCRIPT. DO NOT EDIT BY HAND.\n" + ); + assertFalse(vm.contains(prefix, "LicenseRef-DCL-1.0"), "this repo's licence reached another repo's file"); + assertFalse( + vm.contains(prefix, "Rain Open Source Software Ltd"), + "this repo's copyright holder reached another repo's file" + ); + } + + /// Over arbitrary accepted values, the first two lines of the prefix are the + /// two SPDX tags carrying the caller's values verbatim, and the three lines + /// after them are fixed. Read by splitting the emitted prefix on newlines + /// rather than by rebuilding it with the concatenation the library itself + /// uses, so a change to either tag, to the order of the two lines, or to the + /// number of lines fails here. + function testFilePrefixLinesAreTheCallersValues(bytes memory licenceSeed, bytes memory copyrightSeed) + external + view + { + string memory spdxLicenseIdentifier = textFromSeed(licenceSeed); + string memory copyrightText = textFromSeed(copyrightSeed); + + string[] memory lines = vm.split(this.callFilePrefix(spdxLicenseIdentifier, copyrightText), "\n"); + + assertEq(lines.length, 6, "prefix is not five lines and a trailing newline"); + assertEq(lines[0], string.concat("// SPDX-License-Identifier: ", spdxLicenseIdentifier)); + assertEq(lines[1], string.concat("// SPDX-FileCopyrightText: ", copyrightText)); + assertEq(lines[2], "pragma solidity ^0.8.25;"); + assertEq(lines[3], ""); + assertEq(lines[4], "// THIS FILE IS AUTOGENERATED BY THE BUILD SCRIPT. DO NOT EDIT BY HAND."); + assertEq(lines[5], ""); + } + + /// An empty licence identifier emits the licence tag with nothing after it. + /// A REUSE lint that tests for the tag's presence passes that file, so the + /// file would report as licensed while naming no licence, and solc refuses + /// it with "Invalid SPDX license identifier" so the repo it is generated + /// into stops compiling. + function testFilePrefixRejectsEmptyLicence() external { + vm.expectRevert(abi.encodeWithSelector(InvalidSpdxLicenseIdentifier.selector, "")); + this.callFilePrefix("", "Copyright (c) 2020 Rain Open Source Software Ltd"); + } + + /// An empty copyright text names no holder, for the same reason. + function testFilePrefixRejectsEmptyCopyright() external { + vm.expectRevert(abi.encodeWithSelector(InvalidCopyrightText.selector, "")); + this.callFilePrefix("LicenseRef-DCL-1.0", ""); + } + + /// A line break ends the header line the value is interpolated into, so the + /// remainder lands as source rather than as part of the tag. A value chosen + /// to do it deliberately writes a second, contradicting SPDX tag that a + /// presence check accepts. Carriage return counts: Solidity ends a `//` + /// comment at either. + function testFilePrefixRejectsLineBreakInLicence() external { + vm.expectRevert( + abi.encodeWithSelector( + InvalidSpdxLicenseIdentifier.selector, "MIT\n// SPDX-License-Identifier: GPL-3.0-only" + ) + ); + this.callFilePrefix("MIT\n// SPDX-License-Identifier: GPL-3.0-only", "Copyright (c) 2026 Someone"); + + vm.expectRevert(abi.encodeWithSelector(InvalidSpdxLicenseIdentifier.selector, "MIT\r")); + this.callFilePrefix("MIT\r", "Copyright (c) 2026 Someone"); + } + + /// The copyright text is interpolated into its own header line and carries + /// the same rule. + function testFilePrefixRejectsLineBreakInCopyright() external { + vm.expectRevert( + abi.encodeWithSelector( + InvalidCopyrightText.selector, "Copyright (c) 2026 Someone\n// SPDX-FileCopyrightText: Someone Else" + ) + ); + this.callFilePrefix("MIT", "Copyright (c) 2026 Someone\n// SPDX-FileCopyrightText: Someone Else"); + + vm.expectRevert(abi.encodeWithSelector(InvalidCopyrightText.selector, "Copyright (c) 2026 Someone\r")); + this.callFilePrefix("MIT", "Copyright (c) 2026 Someone\r"); + } + + /// A line break anywhere in the value is refused, not only one at the end or + /// one at a boundary. Fuzzed over the position and over the surrounding + /// text, so a check that looked at the first or the last byte only would + /// fail here. + function testFilePrefixRejectsLineBreakAtAnyPosition(bytes memory seed, uint256 position, bool carriageReturn) + external + { + bytes memory licenceBytes = bytes(textFromSeed(seed)); + licenceBytes[position % licenceBytes.length] = carriageReturn ? bytes1(0x0D) : bytes1(0x0A); + string memory spdxLicenseIdentifier = string(licenceBytes); + + vm.expectRevert(abi.encodeWithSelector(InvalidSpdxLicenseIdentifier.selector, spdxLicenseIdentifier)); + this.callFilePrefix(spdxLicenseIdentifier, "Copyright (c) 2026 Someone"); + + bytes memory copyrightBytes = bytes(textFromSeed(seed)); + copyrightBytes[position % copyrightBytes.length] = carriageReturn ? bytes1(0x0D) : bytes1(0x0A); + string memory copyrightText = string(copyrightBytes); + + vm.expectRevert(abi.encodeWithSelector(InvalidCopyrightText.selector, copyrightText)); + this.callFilePrefix("MIT", copyrightText); + } + + /// Acceptance is decided by the line break rule and by nothing else: a value + /// made only of printable ASCII is not refused. The claim is that the call + /// does not revert; what the header holds for an accepted value is pinned by + /// `testFilePrefixLinesAreTheCallersValues` over the same domain. + function testFilePrefixAcceptsEveryNonLineBreakValue(bytes memory seed) external view { + string memory text = textFromSeed(seed); + this.callFilePrefix(text, text); + } + + /// The licence is checked before the copyright, so a call that gets both + /// wrong reverts on the licence rather than on whichever the compiler + /// happened to evaluate first. + function testFilePrefixChecksLicenceFirst() external { + vm.expectRevert(abi.encodeWithSelector(InvalidSpdxLicenseIdentifier.selector, "")); + this.callFilePrefix("", ""); } } +//REUSE-IgnoreEnd diff --git a/test/src/lib/LibFs.buildFileForContract.t.sol b/test/src/lib/LibFs.buildFileForContract.t.sol index 025ad2d..af0aa0c 100644 --- a/test/src/lib/LibFs.buildFileForContract.t.sol +++ b/test/src/lib/LibFs.buildFileForContract.t.sol @@ -4,11 +4,26 @@ pragma solidity =0.8.25; import {Test} from "forge-std-1.16.2/src/Test.sol"; import {LibFs, GENERATED_DIR} from "src/lib/LibFs.sol"; -import {InvalidIdentifier, CodelessInstance} from "src/lib/LibCodeGen.sol"; +import { + InvalidIdentifier, + InvalidSpdxLicenseIdentifier, + InvalidCopyrightText, + CodelessInstance +} from "src/lib/LibCodeGen.sol"; import {CodeGennable} from "test/concrete/CodeGennable.sol"; import {LibFsExternal} from "test/concrete/LibFsExternal.sol"; import {LibCodeGenSlow} from "test/lib/LibCodeGenSlow.sol"; +/// @dev The licence identifier this repo declares for its own source. Which +/// values a caller passes is incidental to every test here except +/// `testBuildFileForContractCarriesTheCallersLicenceAndCopyright`, which passes +/// another org's, so these are this repo's own rather than invented. +string constant SPDX_LICENSE_IDENTIFIER = "LicenseRef-DCL-1.0"; + +/// @dev The copyright text this repo declares for its own source, for the same +/// reason. +string constant COPYRIGHT_TEXT = "Copyright (c) 2020 Rain Open Source Software Ltd"; + /// @title LibFsBuildFileForContractTest /// @notice `buildFileForContract` is the only thing in this repo that touches /// disk. What it writes is Solidity source that a consumer commits and imports, @@ -54,14 +69,25 @@ contract LibFsBuildFileForContractTest is Test { } /// The whole file, byte for byte: prefix, then the bytecode hash constant, - /// then the body, with nothing between them and nothing after. - function expectedFile(address instance, string memory body) internal view returns (string memory) { + /// then the body, with nothing between them and nothing after. The licence + /// and copyright are the caller's, so they are interpolated here the same + /// way the file under test interpolates them, and the structure around them + /// is the literal. + function expectedFile( + address instance, + string memory spdxLicenseIdentifier, + string memory copyrightText, + string memory body + ) internal view returns (string memory) { //REUSE-IgnoreStart return string.concat( - "// SPDX-License-Identifier: LicenseRef-DCL-1.0\n" - "// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd\n" - "pragma solidity ^0.8.25;\n\n" "// THIS FILE IS AUTOGENERATED BY THE BUILD SCRIPT. DO NOT EDIT BY HAND.\n" - "\n" "/// @dev Hash of the known bytecode.\n" "bytes32 constant BYTECODE_HASH = bytes32(", + "// SPDX-License-Identifier: ", + spdxLicenseIdentifier, + "\n" "// SPDX-FileCopyrightText: ", + copyrightText, + "\n" "pragma solidity ^0.8.25;\n\n" + "// THIS FILE IS AUTOGENERATED BY THE BUILD SCRIPT. DO NOT EDIT BY HAND.\n" "\n" + "/// @dev Hash of the known bytecode.\n" "bytes32 constant BYTECODE_HASH = bytes32(", vm.toString(instance.codehash), ");\n", body @@ -79,9 +105,12 @@ contract LibFsBuildFileForContractTest is Test { address instance = address(new CodeGennable()); string memory body = "\n/// @dev Body.\nuint256 constant BODY = 1;\n"; - LibFs.buildFileForContract(vm, instance, name, body); + LibFs.buildFileForContract(vm, instance, name, SPDX_LICENSE_IDENTIFIER, COPYRIGHT_TEXT, body); - assertEq(vm.readFile(LibFs.pathForContract(name)), expectedFile(instance, body)); + assertEq( + vm.readFile(LibFs.pathForContract(name)), + expectedFile(instance, SPDX_LICENSE_IDENTIFIER, COPYRIGHT_TEXT, body) + ); cleanup(name); } @@ -94,7 +123,7 @@ contract LibFsBuildFileForContractTest is Test { assertFalse(vm.exists("src/generated/LibFsBuildPath.sol"), "dirty precondition"); address instance = address(new CodeGennable()); - LibFs.buildFileForContract(vm, instance, name, "\n// body\n"); + LibFs.buildFileForContract(vm, instance, name, SPDX_LICENSE_IDENTIFIER, COPYRIGHT_TEXT, "\n// body\n"); assertEq(LibFs.pathForContract(name), "src/generated/LibFsBuildPath.sol", "path disagreement"); assertTrue(vm.exists("src/generated/LibFsBuildPath.sol"), "not written to the expected path"); @@ -112,9 +141,12 @@ contract LibFsBuildFileForContractTest is Test { address instance = address(new CodeGennable()); string memory body = "\n// fresh\n"; - LibFs.buildFileForContract(vm, instance, name, body); + LibFs.buildFileForContract(vm, instance, name, SPDX_LICENSE_IDENTIFIER, COPYRIGHT_TEXT, body); - assertEq(vm.readFile(LibFs.pathForContract(name)), expectedFile(instance, body)); + assertEq( + vm.readFile(LibFs.pathForContract(name)), + expectedFile(instance, SPDX_LICENSE_IDENTIFIER, COPYRIGHT_TEXT, body) + ); cleanup(name); } @@ -133,9 +165,12 @@ contract LibFsBuildFileForContractTest is Test { address instance = address(new CodeGennable()); string memory body = "\n// replaced\n"; - LibFs.buildFileForContract(vm, instance, name, body); + LibFs.buildFileForContract(vm, instance, name, SPDX_LICENSE_IDENTIFIER, COPYRIGHT_TEXT, body); - assertEq(vm.readFile(LibFs.pathForContract(name)), expectedFile(instance, body)); + assertEq( + vm.readFile(LibFs.pathForContract(name)), + expectedFile(instance, SPDX_LICENSE_IDENTIFIER, COPYRIGHT_TEXT, body) + ); cleanup(name); } @@ -147,13 +182,13 @@ contract LibFsBuildFileForContractTest is Test { address instance = address(new CodeGennable()); string memory body = "\n// idempotent\n"; - LibFs.buildFileForContract(vm, instance, name, body); + LibFs.buildFileForContract(vm, instance, name, SPDX_LICENSE_IDENTIFIER, COPYRIGHT_TEXT, body); string memory first = vm.readFile(LibFs.pathForContract(name)); - LibFs.buildFileForContract(vm, instance, name, body); + LibFs.buildFileForContract(vm, instance, name, SPDX_LICENSE_IDENTIFIER, COPYRIGHT_TEXT, body); string memory second = vm.readFile(LibFs.pathForContract(name)); assertEq(first, second, "second run differs from the first"); - assertEq(second, expectedFile(instance, body)); + assertEq(second, expectedFile(instance, SPDX_LICENSE_IDENTIFIER, COPYRIGHT_TEXT, body)); cleanup(name); } @@ -165,9 +200,12 @@ contract LibFsBuildFileForContractTest is Test { cleanup(name); address instance = address(new CodeGennable()); - LibFs.buildFileForContract(vm, instance, name, ""); + LibFs.buildFileForContract(vm, instance, name, SPDX_LICENSE_IDENTIFIER, COPYRIGHT_TEXT, ""); - assertEq(vm.readFile(LibFs.pathForContract(name)), expectedFile(instance, "")); + assertEq( + vm.readFile(LibFs.pathForContract(name)), + expectedFile(instance, SPDX_LICENSE_IDENTIFIER, COPYRIGHT_TEXT, "") + ); cleanup(name); } @@ -181,10 +219,10 @@ contract LibFsBuildFileForContractTest is Test { address instance = address(new CodeGennable()); string memory body = "string constant S = \"a\\\"b\";\n"; - LibFs.buildFileForContract(vm, instance, name, body); + LibFs.buildFileForContract(vm, instance, name, SPDX_LICENSE_IDENTIFIER, COPYRIGHT_TEXT, body); string memory written = vm.readFile(LibFs.pathForContract(name)); - assertEq(written, expectedFile(instance, body)); + assertEq(written, expectedFile(instance, SPDX_LICENSE_IDENTIFIER, COPYRIGHT_TEXT, body)); assertTrue(vm.contains(written, "DO NOT EDIT BY HAND.\n\n/// @dev Hash"), "prefix and hash are separated"); assertTrue(vm.contains(written, ");\nstring constant S ="), "a separator was inserted before the body"); cleanup(name); @@ -201,11 +239,19 @@ contract LibFsBuildFileForContractTest is Test { string memory bodyA = "\n// sibling a\n"; string memory bodyB = "\n// sibling b\n"; - LibFs.buildFileForContract(vm, instance, nameA, bodyA); - LibFs.buildFileForContract(vm, instance, nameB, bodyB); + LibFs.buildFileForContract(vm, instance, nameA, SPDX_LICENSE_IDENTIFIER, COPYRIGHT_TEXT, bodyA); + LibFs.buildFileForContract(vm, instance, nameB, SPDX_LICENSE_IDENTIFIER, COPYRIGHT_TEXT, bodyB); - assertEq(vm.readFile(LibFs.pathForContract(nameA)), expectedFile(instance, bodyA), "sibling a was disturbed"); - assertEq(vm.readFile(LibFs.pathForContract(nameB)), expectedFile(instance, bodyB), "sibling b is wrong"); + assertEq( + vm.readFile(LibFs.pathForContract(nameA)), + expectedFile(instance, SPDX_LICENSE_IDENTIFIER, COPYRIGHT_TEXT, bodyA), + "sibling a was disturbed" + ); + assertEq( + vm.readFile(LibFs.pathForContract(nameB)), + expectedFile(instance, SPDX_LICENSE_IDENTIFIER, COPYRIGHT_TEXT, bodyB), + "sibling b is wrong" + ); cleanup(nameA); cleanup(nameB); } @@ -221,17 +267,88 @@ contract LibFsBuildFileForContractTest is Test { assertTrue(instance.code.length > 0 && other.code.length > 0, "both addresses must hold code"); assertNotEq(instance.codehash, other.codehash, "addresses must hold different code"); - LibFs.buildFileForContract(vm, instance, name, ""); + LibFs.buildFileForContract(vm, instance, name, SPDX_LICENSE_IDENTIFIER, COPYRIGHT_TEXT, ""); string memory fromInstance = vm.readFile(LibFs.pathForContract(name)); - LibFs.buildFileForContract(vm, other, name, ""); + LibFs.buildFileForContract(vm, other, name, SPDX_LICENSE_IDENTIFIER, COPYRIGHT_TEXT, ""); string memory fromOther = vm.readFile(LibFs.pathForContract(name)); assertNotEq(fromInstance, fromOther, "instance is not what the hash is read from"); - assertEq(fromInstance, expectedFile(instance, "")); - assertEq(fromOther, expectedFile(other, "")); + assertEq(fromInstance, expectedFile(instance, SPDX_LICENSE_IDENTIFIER, COPYRIGHT_TEXT, "")); + assertEq(fromOther, expectedFile(other, SPDX_LICENSE_IDENTIFIER, COPYRIGHT_TEXT, "")); cleanup(name); } + /// The file that lands on disk declares the caller's licence and the + /// caller's copyright holder, and none of this repo's. Every consumer of + /// this library commits what it writes, and `S01-Issuer/st0x.deploy` commits + /// it into append-only deploy-pin snapshots, so a header naming the wrong + /// holder is permanent in another org's release record. + /// + /// The file is read and removed before anything is asserted. A failing + /// assertion ends the test where it stands, so asserting first would leave + /// the written file in `src/generated/` for whatever runs next. + function testBuildFileForContractCarriesTheCallersLicenceAndCopyright() external { + string memory name = "LibFsBuildForeignLicence"; + cleanup(name); + address instance = address(new CodeGennable()); + string memory body = "\n// foreign\n"; + + LibFs.buildFileForContract(vm, instance, name, "MIT", "Copyright (c) 2026 S01 Issuer GmbH", body); + + string memory written = vm.readFile(LibFs.pathForContract(name)); + cleanup(name); + + assertEq(written, expectedFile(instance, "MIT", "Copyright (c) 2026 S01 Issuer GmbH", body)); + assertFalse(vm.contains(written, "LicenseRef-DCL-1.0"), "this repo's licence reached another repo's file"); + assertFalse( + vm.contains(written, "Rain Open Source Software Ltd"), + "this repo's copyright holder reached another repo's file" + ); + } + + /// The revert data from a write, or empty bytes when the write was + /// accepted. `vm.expectRevert` would end the test at the call that is + /// supposed to revert and does not, and a write that reaches disk is not + /// undone by the revert that follows it, so the file would be left in + /// `src/generated/` for the next compile to pick up. Catching the revert + /// keeps the assertion for after the file is removed. + function headerRejection( + string memory contractName, + string memory spdxLicenseIdentifier, + string memory copyrightText + ) internal returns (bytes memory) { + try iExternal.buildFileForContract(vm, address(this), contractName, spdxLicenseIdentifier, copyrightText, "") { + return ""; + } catch (bytes memory reason) { + return reason; + } + } + + /// The write inherits the rule `filePrefix` puts on the licence and the + /// copyright, so a build script that names neither cannot produce a file + /// that claims to be licensed and is not. + function testBuildFileForContractRejectsInvalidLicenceAndCopyright() external { + string memory name = "LibFsBuildInvalidHeader"; + cleanup(name); + + bytes memory emptyLicence = headerRejection(name, "", COPYRIGHT_TEXT); + bytes memory brokenCopyright = headerRejection(name, SPDX_LICENSE_IDENTIFIER, "Copyright (c) 2026 Someone\n"); + bool written = vm.exists(LibFs.pathForContract(name)); + cleanup(name); + + assertEq( + emptyLicence, + abi.encodeWithSelector(InvalidSpdxLicenseIdentifier.selector, ""), + "an empty licence was not refused" + ); + assertEq( + brokenCopyright, + abi.encodeWithSelector(InvalidCopyrightText.selector, "Copyright (c) 2026 Someone\n"), + "a copyright carrying a line break was not refused" + ); + assertFalse(written, "a refused header still wrote a file"); + } + /// Generating the content can revert: a codeless instance has no bytecode /// hash to name. The content is built before anything on disk is touched, /// so a build that fails leaves the file it was about to replace exactly as @@ -248,7 +365,7 @@ contract LibFsBuildFileForContractTest is Test { vm.writeFile(path, "PRE-EXISTING"); vm.expectRevert(abi.encodeWithSelector(CodelessInstance.selector, codeless)); - iExternal.buildFileForContract(vm, codeless, name, "\n// body\n"); + iExternal.buildFileForContract(vm, codeless, name, SPDX_LICENSE_IDENTIFIER, COPYRIGHT_TEXT, "\n// body\n"); assertTrue(vm.exists(path), "a failed build destroyed the existing file"); assertEq(vm.readFile(path), "PRE-EXISTING", "a failed build rewrote the existing file"); @@ -286,9 +403,12 @@ contract LibFsBuildFileForContractTest is Test { address instance = address(new CodeGennable()); string memory body = "\n// created\n"; - LibFs.buildFileForContract(vm, instance, dir, name, body); + LibFs.buildFileForContract(vm, instance, dir, name, SPDX_LICENSE_IDENTIFIER, COPYRIGHT_TEXT, body); - assertEq(vm.readFile(string.concat(dir, "/", name, ".sol")), expectedFile(instance, body)); + assertEq( + vm.readFile(string.concat(dir, "/", name, ".sol")), + expectedFile(instance, SPDX_LICENSE_IDENTIFIER, COPYRIGHT_TEXT, body) + ); cleanupPath(root); } @@ -300,7 +420,7 @@ contract LibFsBuildFileForContractTest is Test { /// disk when it does. function assertNameRejected(string memory contractName) internal { vm.expectRevert(abi.encodeWithSelector(InvalidIdentifier.selector, contractName)); - iExternal.buildFileForContract(vm, address(this), contractName, ""); + iExternal.buildFileForContract(vm, address(this), contractName, SPDX_LICENSE_IDENTIFIER, COPYRIGHT_TEXT, ""); } /// The path for an empty name is `src/generated/.sol`: valid generated diff --git a/test/src/lib/LibFs.isPresent.t.sol b/test/src/lib/LibFs.isPresent.t.sol index 26fb33e..534a99d 100644 --- a/test/src/lib/LibFs.isPresent.t.sol +++ b/test/src/lib/LibFs.isPresent.t.sol @@ -160,8 +160,19 @@ contract LibFsIsPresentTest is Test { assertFalse(vm.exists(pathFor(linkName)), "the link is not dangling"); string memory body = "\n// dangling\n"; - LibFs.buildFileForContract(vm, address(this), name, body); - LibFs.buildFileForContract(vm, address(this), controlName, body); + // The licence and copyright reach the header and nothing here reads the + // header, so this repo's own values stand in for a caller's. + LibFs.buildFileForContract( + vm, address(this), name, "LicenseRef-DCL-1.0", "Copyright (c) 2020 Rain Open Source Software Ltd", body + ); + LibFs.buildFileForContract( + vm, + address(this), + controlName, + "LicenseRef-DCL-1.0", + "Copyright (c) 2020 Rain Open Source Software Ltd", + body + ); assertFalse(vm.exists(pathFor(targetName)), "the write followed the link to its target"); assertTrue(readlink(linkName).exitCode != 0, "the path is still a symlink"); @@ -203,8 +214,19 @@ contract LibFsIsPresentTest is Test { assertEq(vm.readFile(pathFor(targetName)), "SENTINEL", "the link target is not the seeded file"); string memory body = "\n// live\n"; - LibFs.buildFileForContract(vm, address(this), name, body); - LibFs.buildFileForContract(vm, address(this), controlName, body); + // The licence and copyright reach the header and nothing here reads the + // header, so this repo's own values stand in for a caller's. + LibFs.buildFileForContract( + vm, address(this), name, "LicenseRef-DCL-1.0", "Copyright (c) 2020 Rain Open Source Software Ltd", body + ); + LibFs.buildFileForContract( + vm, + address(this), + controlName, + "LicenseRef-DCL-1.0", + "Copyright (c) 2020 Rain Open Source Software Ltd", + body + ); bool linkIsStillASymlink = readlink(linkName).exitCode == 0; bool targetExists = vm.exists(pathFor(targetName));