Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 58 additions & 4 deletions src/lib/LibCodeGen.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
41 changes: 32 additions & 9 deletions src/lib/LibFs.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
11 changes: 9 additions & 2 deletions test/concrete/LibFsExternal.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
175 changes: 168 additions & 7 deletions test/src/lib/LibCodeGen.filePrefix.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,189 @@
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
/// cannot keep. Exactly one string passes here, so every string that
/// 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
Loading
Loading