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
237 changes: 237 additions & 0 deletions test/lib/LibFs.buildFileForContract.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
// SPDX-License-Identifier: LicenseRef-DCL-1.0
// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd
pragma solidity =0.8.25;

import {Test} from "forge-std-1.16.1/src/Test.sol";
import {LibFs} from "src/lib/LibFs.sol";
import {LibCodeGen} from "src/lib/LibCodeGen.sol";
import {CodeGennable} from "test/concrete/CodeGennable.sol";

/// @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,
/// so these assert the exact bytes of the file on disk, at the exact path, for
/// the whole file rather than for a fragment of it.
///
/// The expected content is rebuilt here from the literal text and from
/// `address.codehash`, deliberately NOT by calling `LibCodeGen.filePrefix` and
/// `LibCodeGen.bytecodeHashConstantString`. Calling those would assert the
/// library agrees with itself and would follow any drift in them silently;
/// consumers have the literal committed in their repos, so the literal is the
/// oracle.
contract LibFsBuildFileForContractTest is Test {
/// Every test writes under `src/generated/`, which is a committed directory
/// in this repo. Each test owns a distinct name so parallel suites cannot
/// collide, none of them is `CodeGennable` (the committed artifact), and
/// each removes its file again.
function cleanup(string memory contractName) internal {
string memory path = LibFs.pathForContract(contractName);
if (vm.exists(path)) {
vm.removeFile(path);
}
}

/// 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) {
//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(",
vm.toString(instance.codehash),
");\n",
body
);
//REUSE-IgnoreEnd
}

/// The file that lands on disk is exactly prefix + bytecode hash + body.
/// This pins the order of the three parts as well as their content: the
/// pragma has to precede the constant it applies to, and the body is
/// appended after both.
function testBuildFileForContractExactContent() external {
string memory name = "LibFsBuildExact";
cleanup(name);
address instance = address(new CodeGennable());
string memory body = "\n/// @dev Body.\nuint256 constant BODY = 1;\n";

LibFs.buildFileForContract(vm, instance, name, body);

assertEq(vm.readFile(LibFs.pathForContract(name)), expectedFile(instance, body));
cleanup(name);
}

/// The file lands at `pathForContract(name)` and only there. Asserted
/// against the literal path as well, so that the two functions agreeing
/// with each other is not what makes this pass.
function testBuildFileForContractWritesToPathForContract() external {
string memory name = "LibFsBuildPath";
cleanup(name);
assertFalse(vm.exists("src/generated/LibFsBuildPath.sol"), "dirty precondition");
address instance = address(new CodeGennable());

LibFs.buildFileForContract(vm, instance, name, "\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");
cleanup(name);
}

/// First generation for a contract is the normal case: nothing is at the
/// path yet. Removing a file that is not there reverts, so the existence
/// check in front of the removal is load bearing and this is what proves
/// it.
function testBuildFileForContractFreshPath() external {
string memory name = "LibFsBuildFresh";
cleanup(name);
assertFalse(vm.exists(LibFs.pathForContract(name)), "path is not fresh");
address instance = address(new CodeGennable());
string memory body = "\n// fresh\n";

LibFs.buildFileForContract(vm, instance, name, body);

assertEq(vm.readFile(LibFs.pathForContract(name)), expectedFile(instance, body));
cleanup(name);
}

/// An existing file at the path is replaced, not appended to and not
/// partially overwritten. The pre-existing content is deliberately longer
/// than what is generated, so any tail of it left behind fails here.
function testBuildFileForContractReplacesExistingContent() external {
string memory name = "LibFsBuildOverwrite";
cleanup(name);
string memory stale = "STALE";
for (uint256 i = 0; i < 8; i++) {
stale = string.concat(stale, stale);
}
vm.writeFile(LibFs.pathForContract(name), stale);
assertTrue(bytes(stale).length > 1000, "stale content is not long enough to detect a tail");

address instance = address(new CodeGennable());
string memory body = "\n// replaced\n";
LibFs.buildFileForContract(vm, instance, name, body);

assertEq(vm.readFile(LibFs.pathForContract(name)), expectedFile(instance, body));
cleanup(name);
}

/// Generation is idempotent: consumers commit the result and CI regenerates
/// it, so a second run over the same inputs must produce the same bytes.
function testBuildFileForContractIdempotent() external {
string memory name = "LibFsBuildIdempotent";
cleanup(name);
address instance = address(new CodeGennable());
string memory body = "\n// idempotent\n";

LibFs.buildFileForContract(vm, instance, name, body);
string memory first = vm.readFile(LibFs.pathForContract(name));
LibFs.buildFileForContract(vm, instance, name, body);
string memory second = vm.readFile(LibFs.pathForContract(name));

assertEq(first, second, "second run differs from the first");
assertEq(second, expectedFile(instance, body));
cleanup(name);
}

/// An empty body still produces the prefix and the bytecode hash constant,
/// which the docstring says are always included. Nothing is substituted for
/// the missing body.
function testBuildFileForContractEmptyBody() external {
string memory name = "LibFsBuildEmptyBody";
cleanup(name);
address instance = address(new CodeGennable());

LibFs.buildFileForContract(vm, instance, name, "");

assertEq(vm.readFile(LibFs.pathForContract(name)), expectedFile(instance, ""));
cleanup(name);
}

/// The body is concatenated verbatim with no separator inserted in front of
/// it. A body that does not begin with a newline therefore continues the
/// bytecode hash constant's own trailing newline, and the caller owns the
/// spacing. Quotes and backslashes in the body are not escaped either.
function testBuildFileForContractBodyVerbatim() external {
string memory name = "LibFsBuildBodyVerbatim";
cleanup(name);
address instance = address(new CodeGennable());
string memory body = "string constant S = \"a\\\"b\";\n";

LibFs.buildFileForContract(vm, instance, name, body);

string memory written = vm.readFile(LibFs.pathForContract(name));
assertEq(written, expectedFile(instance, 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);
}

/// Generating one contract must not disturb another contract's generated
/// file. Consumers generate many files into the same directory.
function testBuildFileForContractLeavesSiblingsAlone() external {
string memory nameA = "LibFsBuildSiblingA";
string memory nameB = "LibFsBuildSiblingB";
cleanup(nameA);
cleanup(nameB);
address instance = address(new CodeGennable());
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);

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");
cleanup(nameA);
cleanup(nameB);
}

/// `src/generated/CodeGennable.sol` is committed, and `script/Build.sol`
/// builds it through this function. Nothing in `forge test` noticed when it
/// went stale — only the separate `rainix-copy-artifacts` job did, by
/// regenerating and diffing. This asserts the committed file still opens
/// with what `buildFileForContract` writes today, so drift between the
/// library and the artifact it produced reds the suite too.
///
/// Deliberately built from `LibCodeGen` here, unlike the tests above: the
/// claim is that the committed bytes match what the library emits now, so
/// the library is the correct side to read it from and the file on disk is
/// the oracle.
function testBuildFileForContractCommittedArtifactIsCurrent() external {
address instance = address(new CodeGennable());
string memory header =
string.concat(LibCodeGen.filePrefix(), LibCodeGen.bytecodeHashConstantString(vm, instance));
bytes memory committed = bytes(vm.readFile(LibFs.pathForContract("CodeGennable")));

assertTrue(committed.length >= bytes(header).length, "committed artifact is shorter than the header");
bytes memory actual = new bytes(bytes(header).length);
for (uint256 i = 0; i < actual.length; i++) {
actual[i] = committed[i];
}
assertEq(actual, bytes(header), "committed artifact is stale, regenerate with script/Build.sol");
}

/// The bytecode hash is read from the instance that was passed in, not from
/// the caller and not from a fixed address. Two addresses holding different
/// code produce different files.
function testBuildFileForContractUsesTheGivenInstance() external {
string memory name = "LibFsBuildInstance";
cleanup(name);
address instance = address(new CodeGennable());
address other = address(this);
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, "");
string memory fromInstance = vm.readFile(LibFs.pathForContract(name));
LibFs.buildFileForContract(vm, other, name, "");
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, ""));
cleanup(name);
}
}
44 changes: 44 additions & 0 deletions test/lib/LibFs.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,48 @@ contract LibFsTest is Test {
function testPathForContract() external pure {
assertEq(LibFs.pathForContract("Foo"), "src/generated/Foo.sol");
}

/// Copies `len` bytes out of `data` starting at `start`. Used so the
/// structural assertions below can name each of the three regions of the
/// path independently, rather than rebuilding the path with the same
/// `string.concat` the library uses and asserting it equals itself.
function slice(bytes memory data, uint256 start, uint256 len) internal pure returns (bytes memory out) {
out = new bytes(len);
for (uint256 i = 0; i < len; i++) {
out[i] = data[start + i];
}
}

/// The path is three regions and nothing else: the generated directory, the
/// contract name byte for byte, and the Solidity extension. Asserted
/// positionally over arbitrary names so that a name is never quoted,
/// escaped, trimmed, case folded or truncated on its way into the path.
/// The length equality is what makes it exhaustive: it forbids any extra
/// byte anywhere.
function testPathForContractStructure(string memory contractName) external pure {
bytes memory path = bytes(LibFs.pathForContract(contractName));
bytes memory name = bytes(contractName);

assertEq(path.length, 14 + name.length + 4, "path has bytes beyond dir + name + extension");
assertEq(slice(path, 0, 14), bytes("src/generated/"), "directory");
assertEq(slice(path, 14, name.length), name, "contract name is not verbatim");
assertEq(slice(path, 14 + name.length, 4), bytes(".sol"), "extension");
}

/// Two contracts must never be handed the same file: generation would
/// silently overwrite one with the other. Distinct names give distinct
/// paths.
function testPathForContractDistinctNamesDistinctPaths(string memory a, string memory b) external pure {
vm.assume(keccak256(bytes(a)) != keccak256(bytes(b)));
assertNotEq(LibFs.pathForContract(a), LibFs.pathForContract(b));
}

/// The path is relative to the project root. An absolute path would resolve
/// outside the consumer's repo entirely, so the first byte is never a
/// separator.
function testPathForContractIsRelative(string memory contractName) external pure {
bytes memory path = bytes(LibFs.pathForContract(contractName));
assertTrue(path.length > 0, "empty path");
assertNotEq(uint8(path[0]), uint8(bytes1("/")), "path is absolute");
}
}
Loading