diff --git a/src/lib/LibFs.sol b/src/lib/LibFs.sol index 248ad4f..8c83984 100644 --- a/src/lib/LibFs.sol +++ b/src/lib/LibFs.sol @@ -10,11 +10,62 @@ import {LibCodeGen} from "./LibCodeGen.sol"; /// path, so it is a cross repo contract rather than an internal detail. string constant GENERATED_DIR = "src/generated"; +/// Thrown when a tag is not a single path segment drawn from the tag alphabet. +/// Such a tag cannot be interpolated into a directory path. +/// @param tag The rejected tag. +error InvalidTag(string tag); + /// @title LibFs /// @notice A library for file system operations related to code generation. /// @dev Uses foundry's Vm cheat codes for file operations. Notably standardizes /// the placement and idempotent creation of generated files. library LibFs { + /// Reverts unless `tag` is drawn from the tag alphabet: at least one + /// character, each of them an ASCII letter, a digit, `_` or `$`. That is + /// the Solidity identifier alphabet without the rule against a leading + /// digit, because a tag names a directory rather than a declaration and the + /// release tags this org freezes are `__`, which opens + /// with one. Restricting a tag to that alphabet is what makes it safe to + /// interpolate into a path: no character in the set is a path separator, + /// none of them is `.`, so no tag is `.` or `..` and none of them reaches + /// past the single directory it names. + /// @param tag The tag to check. + function requireTag(string memory tag) internal pure { + bytes memory tagBytes = bytes(tag); + if (tagBytes.length == 0) { + revert InvalidTag(tag); + } + for (uint256 i = 0; i < tagBytes.length; i++) { + bytes1 char = tagBytes[i]; + bool isLetter = (char >= 0x41 && char <= 0x5A) || (char >= 0x61 && char <= 0x7A); + bool isDigit = char >= 0x30 && char <= 0x39; + bool isUnderscoreOrDollar = char == 0x5F || char == 0x24; + if (!(isLetter || isDigit || isUnderscoreOrDollar)) { + revert InvalidTag(tag); + } + } + } + + /// @notice Constructs the directory that a tag's generated files live in. + /// + /// Reverts unless `tag` is drawn from the tag alphabet, so every directory + /// this function returns is a direct child of `GENERATED_DIR`. The check is + /// here rather than at the write because the directory is what carries the + /// tag out of this library: a caller that takes the returned directory and + /// does its own IO with it gets the same confinement + /// `buildFileForTaggedContract` does, and there is no tag for which this + /// library produces a directory at all without producing a safe one. + /// + /// An accepted tag is interpolated verbatim, so it reaches the directory + /// byte for byte and is never quoted, escaped, trimmed, case folded or + /// truncated. + /// @param tag The tag, interpolated verbatim. + /// @return The directory as a string. + function dirForTag(string memory tag) internal pure returns (string memory) { + requireTag(tag); + return string.concat(GENERATED_DIR, "/", tag); + } + /// @notice Constructs the file path for a contract's generated file. /// /// Reverts unless `contractName` is a Solidity identifier, so every path @@ -55,6 +106,35 @@ library LibFs { return string.concat(dir, "/", contractName, ".sol"); } + /// @notice Constructs the file path for a contract's generated file inside a + /// tag's directory, which is the layout per release deploy pin snapshots + /// use. + /// + /// Reverts unless `tag` is drawn from the tag alphabet and `contractName` + /// is a Solidity identifier, so every path this function returns is exactly + /// two segments inside `GENERATED_DIR`: neither argument can express a path + /// separator, `.` or `..`, so neither of them can add a segment, remove + /// one, or leave the directory. The tag is checked first, so a call that + /// gets both wrong names the tag. + /// + /// Both accepted arguments are interpolated verbatim, so they reach the + /// path byte for byte and are never quoted, escaped, trimmed, case folded + /// or truncated. + /// @dev This is `pathForContractIn` applied to `dirForTag(tag)`, so the name + /// rule and the verbatim interpolation of the name are exactly + /// `pathForContract`'s, one directory deeper. + /// @param tag The tag whose directory the file lives in, interpolated + /// verbatim. + /// @param contractName The name of the contract, interpolated verbatim. + /// @return The file path as a string. + function pathForTaggedContract(string memory tag, string memory contractName) + internal + pure + returns (string memory) + { + return pathForContractIn(dirForTag(tag), contractName); + } + /// @notice True if anything occupies `path`, including a symlink whose /// target does not exist. /// @dev `vm.exists` answers for whatever the path resolves to, so it reports @@ -190,4 +270,58 @@ library LibFs { //forge-lint: disable-next-line(unsafe-cheatcode) vm.writeFile(path, content); } + + /// @notice Builds a file for a generated contract at + /// `pathForTaggedContract(tag, contractName)`. + /// + /// `tag` must be drawn from the tag alphabet and `contractName` must be a + /// Solidity identifier, which `pathForTaggedContract` requires of every + /// path it returns, so the file is always two segments inside + /// `GENERATED_DIR` and a rejected tag or name reverts before any cheatcode + /// is reached. + /// + /// The tag's directory is created if it does not exist, along with + /// `GENERATED_DIR` itself, so the first generation for a tag does not need + /// it committed already. + /// + /// 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 + /// target, whether or not that target exists, and the path does not exist + /// between the last unlink and the write. + /// Any manual changes to the generated file, any other existing file at + /// that path, and whatever a symlink at that path resolves to, are lost. + /// + /// The whole file is written on every call, so the same arguments always + /// 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. + /// @dev This is the `dir` overload of `buildFileForContract` applied to + /// `dirForTag(tag)`, so everything that overload states holds here, and the + /// only thing this function adds is that the directory is not the caller's + /// to choose: it is derived from a tag that `dirForTag` refuses unless it + /// names exactly one directory inside `GENERATED_DIR`. + /// @param vm The Vm instance for file operations. + /// @param instance The contract instance whose bytecode hash is to be + /// included. + /// @param tag The tag whose directory the file lives in. + /// @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 buildFileForTaggedContract( + Vm vm, + address instance, + string memory tag, + string memory contractName, + string memory spdxLicenseIdentifier, + string memory copyrightText, + string memory body + ) internal { + buildFileForContract(vm, instance, dirForTag(tag), contractName, spdxLicenseIdentifier, copyrightText, body); + } } diff --git a/test/concrete/LibFsExternal.sol b/test/concrete/LibFsExternal.sol index 73a2cee..31c4938 100644 --- a/test/concrete/LibFsExternal.sol +++ b/test/concrete/LibFsExternal.sol @@ -23,4 +23,32 @@ contract LibFsExternal { function pathForContract(string memory contractName) external pure returns (string memory) { return LibFs.pathForContract(contractName); } + + function buildFileForTaggedContract( + Vm vm, + address instance, + string memory tag, + string memory contractName, + string memory spdxLicenseIdentifier, + string memory copyrightText, + string memory body + ) external { + LibFs.buildFileForTaggedContract(vm, instance, tag, contractName, spdxLicenseIdentifier, copyrightText, body); + } + + function pathForTaggedContract(string memory tag, string memory contractName) + external + pure + returns (string memory) + { + return LibFs.pathForTaggedContract(tag, contractName); + } + + function dirForTag(string memory tag) external pure returns (string memory) { + return LibFs.dirForTag(tag); + } + + function requireTag(string memory tag) external pure { + LibFs.requireTag(tag); + } } diff --git a/test/lib/LibCodeGenSlow.sol b/test/lib/LibCodeGenSlow.sol index 6fe7aa3..0bb2b9c 100644 --- a/test/lib/LibCodeGenSlow.sol +++ b/test/lib/LibCodeGenSlow.sol @@ -185,6 +185,17 @@ library LibCodeGenSlow { return haystack.length; } + /// Copies `len` bytes out of `data` starting at `start`, so a test can name + /// each region of a constructed path independently rather than rebuilding + /// it with the same `string.concat` the library uses and asserting it + /// equals itself. + function sliceSlow(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 text between the first `open` in `text` and the first `close` after /// it, so a test can state a property of the literal a declaration carries /// rather than of a value the test formatted for itself. Reverts when @@ -279,6 +290,39 @@ library LibCodeGenSlow { return true; } + /// True if `tag` is drawn from the tag alphabet, decided by membership of + /// the written out alphabet rather than by arithmetic. The tag alphabet is + /// the identifier alphabet with no rule about the first character, which is + /// exactly `SLOW_TAIL_ALPHABET`: a tail character is any character an + /// identifier admits at all. + function isTagSlow(string memory tag) internal pure returns (bool) { + bytes memory tagBytes = bytes(tag); + if (tagBytes.length == 0) { + return false; + } + for (uint256 i = 0; i < tagBytes.length; i++) { + if (!containsSlow(SLOW_TAIL_ALPHABET, tagBytes[i])) { + return false; + } + } + return true; + } + + /// Folds arbitrary bytes into a tag, so that the accepted half of the tag + /// domain can be fuzzed at all. Unlike `nameFromSeedSlow` the first + /// character is drawn from the same alphabet as the rest, because a tag has + /// no rule about its first character, so the digit opening tags such as + /// `0_1_1` is reachable here. + function tagFromSeedSlow(bytes memory seed) internal pure returns (string memory) { + bytes memory alphabet = bytes(SLOW_TAIL_ALPHABET); + uint256 length = seed.length == 0 ? 1 : seed.length; + bytes memory tag = new bytes(length); + for (uint256 i = 0; i < length; i++) { + tag[i] = alphabet[(seed.length == 0 ? 0 : uint256(uint8(seed[i]))) % alphabet.length]; + } + return string(tag); + } + /// Folds arbitrary bytes into a name that is a Solidity identifier, so that /// the accepted half of the domain can be fuzzed at all. Random bytes are /// essentially never an identifier, so fuzzing names directly only ever diff --git a/test/src/lib/LibFs.buildFileForTaggedContract.t.sol b/test/src/lib/LibFs.buildFileForTaggedContract.t.sol new file mode 100644 index 0000000..e8bf364 --- /dev/null +++ b/test/src/lib/LibFs.buildFileForTaggedContract.t.sol @@ -0,0 +1,443 @@ +// 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.2/src/Test.sol"; +import {LibFs, GENERATED_DIR, InvalidTag} from "src/lib/LibFs.sol"; +import {InvalidIdentifier} 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, which are about the +/// tag and the path rather than the header, 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 LibFsBuildFileForTaggedContractTest +/// @notice `buildFileForTaggedContract` writes the per tag deploy pin snapshots +/// the org's release convention freezes. What it writes is Solidity source that +/// a consumer commits, imports and pins deployed addresses against, 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 LibFsBuildFileForTaggedContractTest is Test { + /// `vm.expectRevert` needs a call frame, and `buildFileForTaggedContract` is + /// an internal library function that is inlined into its caller. + LibFsExternal internal immutable iExternal; + + constructor() { + iExternal = new LibFsExternal(); + } + + /// `src/generated/` holds no committed file, so nothing in a fresh clone + /// creates it, and none of `vm.writeFile` or `vm.createDir` for a child of + /// it creates the parent. `buildFileForTaggedContract` creates it for + /// itself, but the untagged sentinel below is written directly, and suites + /// run in any order, so this contract creates it rather than inheriting it + /// from whatever ran first. + function setUp() external { + vm.createDir(GENERATED_DIR, true); + } + + /// Every test writes under `src/generated/`. Each test owns a distinct tag + /// so parallel suites cannot collide, and each removes the directory it + /// created. + function cleanup(string memory tag) internal { + string memory dir = LibFs.dirForTag(tag); + if (vm.exists(dir)) { + vm.removeDir(dir, true); + } + } + + /// 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, + /// which is byte for byte what the untagged write produces. A snapshot is + /// the same generated file in a different directory, not a different format. + function testBuildFileForTaggedContractExactContent() external { + string memory tag = "0_1_1$taggedExact"; + cleanup(tag); + address instance = address(new CodeGennable()); + string memory body = "\n/// @dev Body.\nuint256 constant BODY = 1;\n"; + + LibFs.buildFileForTaggedContract( + vm, instance, tag, "LibFsTaggedExact", SPDX_LICENSE_IDENTIFIER, COPYRIGHT_TEXT, body + ); + + assertEq(vm.readFile(LibFs.pathForTaggedContract(tag, "LibFsTaggedExact")), expectedFile(instance, body)); + cleanup(tag); + } + + /// The file lands at `pathForTaggedContract(tag, 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 testBuildFileForTaggedContractWritesToPathForTaggedContract() external { + string memory tag = "0_1_1$taggedPath"; + cleanup(tag); + assertFalse(vm.exists("src/generated/0_1_1$taggedPath/LibFsTaggedPath.sol"), "dirty precondition"); + address instance = address(new CodeGennable()); + + LibFs.buildFileForTaggedContract( + vm, instance, tag, "LibFsTaggedPath", SPDX_LICENSE_IDENTIFIER, COPYRIGHT_TEXT, "\n// body\n" + ); + + assertEq( + LibFs.pathForTaggedContract(tag, "LibFsTaggedPath"), + "src/generated/0_1_1$taggedPath/LibFsTaggedPath.sol", + "path disagreement" + ); + assertTrue(vm.exists("src/generated/0_1_1$taggedPath/LibFsTaggedPath.sol"), "not written to the expected path"); + cleanup(tag); + } + + /// The first generation for a tag has no directory yet, which is the normal + /// case for a release: the snapshot directory is created by the run that + /// fills it. `vm.writeFile` does not create a missing parent, so the + /// directory creation is load bearing and this is what proves it. + function testBuildFileForTaggedContractCreatesTheTagDir() external { + string memory tag = "0_1_1$taggedFreshDir"; + cleanup(tag); + assertFalse(vm.exists(LibFs.dirForTag(tag)), "tag directory is not fresh"); + address instance = address(new CodeGennable()); + string memory body = "\n// fresh dir\n"; + + LibFs.buildFileForTaggedContract( + vm, instance, tag, "LibFsTaggedFreshDir", SPDX_LICENSE_IDENTIFIER, COPYRIGHT_TEXT, body + ); + + assertTrue(vm.isDir(LibFs.dirForTag(tag)), "tag directory was not created"); + assertEq(vm.readFile(LibFs.pathForTaggedContract(tag, "LibFsTaggedFreshDir")), expectedFile(instance, body)); + cleanup(tag); + } + + /// An existing tag directory is written into rather than replaced, so the + /// snapshot's other files survive a regeneration of one of them. + function testBuildFileForTaggedContractReusesAnExistingTagDir() external { + string memory tag = "0_1_1$taggedExistingDir"; + cleanup(tag); + vm.createDir(LibFs.dirForTag(tag), true); + vm.writeFile(string.concat(LibFs.dirForTag(tag), "/bystander.txt"), "BYSTANDER"); + address instance = address(new CodeGennable()); + string memory body = "\n// existing dir\n"; + + LibFs.buildFileForTaggedContract( + vm, instance, tag, "LibFsTaggedExistingDir", SPDX_LICENSE_IDENTIFIER, COPYRIGHT_TEXT, body + ); + + assertEq( + vm.readFile(string.concat(LibFs.dirForTag(tag), "/bystander.txt")), + "BYSTANDER", + "the directory was replaced" + ); + assertEq(vm.readFile(LibFs.pathForTaggedContract(tag, "LibFsTaggedExistingDir")), expectedFile(instance, body)); + cleanup(tag); + } + + /// 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. It is + /// also a formatted Solidity comment, because it lands at a `.sol` path + /// under `src/` that both the compiler and `forge fmt` read on the next run. + function testBuildFileForTaggedContractReplacesExistingContent() external { + string memory tag = "0_1_1$taggedOverwrite"; + cleanup(tag); + vm.createDir(LibFs.dirForTag(tag), true); + string memory stale = "// STALE\n"; + for (uint256 i = 0; i < 8; i++) { + stale = string.concat(stale, stale); + } + vm.writeFile(LibFs.pathForTaggedContract(tag, "LibFsTaggedOverwrite"), 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.buildFileForTaggedContract( + vm, instance, tag, "LibFsTaggedOverwrite", SPDX_LICENSE_IDENTIFIER, COPYRIGHT_TEXT, body + ); + + assertEq(vm.readFile(LibFs.pathForTaggedContract(tag, "LibFsTaggedOverwrite")), expectedFile(instance, body)); + cleanup(tag); + } + + /// 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 testBuildFileForTaggedContractIdempotent() external { + string memory tag = "0_1_1$taggedIdempotent"; + cleanup(tag); + address instance = address(new CodeGennable()); + string memory body = "\n// idempotent\n"; + + LibFs.buildFileForTaggedContract( + vm, instance, tag, "LibFsTaggedIdempotent", SPDX_LICENSE_IDENTIFIER, COPYRIGHT_TEXT, body + ); + string memory first = vm.readFile(LibFs.pathForTaggedContract(tag, "LibFsTaggedIdempotent")); + LibFs.buildFileForTaggedContract( + vm, instance, tag, "LibFsTaggedIdempotent", SPDX_LICENSE_IDENTIFIER, COPYRIGHT_TEXT, body + ); + string memory second = vm.readFile(LibFs.pathForTaggedContract(tag, "LibFsTaggedIdempotent")); + + assertEq(first, second, "second run differs from the first"); + assertEq(second, expectedFile(instance, body)); + cleanup(tag); + } + + /// 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 testBuildFileForTaggedContractEmptyBody() external { + string memory tag = "0_1_1$taggedEmptyBody"; + cleanup(tag); + address instance = address(new CodeGennable()); + + LibFs.buildFileForTaggedContract( + vm, instance, tag, "LibFsTaggedEmptyBody", SPDX_LICENSE_IDENTIFIER, COPYRIGHT_TEXT, "" + ); + + assertEq(vm.readFile(LibFs.pathForTaggedContract(tag, "LibFsTaggedEmptyBody")), expectedFile(instance, "")); + cleanup(tag); + } + + /// The body is concatenated verbatim with no separator inserted in front of + /// it, and quotes and backslashes in it are not escaped. + function testBuildFileForTaggedContractBodyVerbatim() external { + string memory tag = "0_1_1$taggedVerbatim"; + cleanup(tag); + address instance = address(new CodeGennable()); + string memory body = "string constant S = \"a\\\"b\";\n"; + + LibFs.buildFileForTaggedContract( + vm, instance, tag, "LibFsTaggedVerbatim", SPDX_LICENSE_IDENTIFIER, COPYRIGHT_TEXT, body + ); + + string memory written = vm.readFile(LibFs.pathForTaggedContract(tag, "LibFsTaggedVerbatim")); + 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(tag); + } + + /// The whole point of the per tag layout: the same contract generated under + /// one tag must not disturb the file under another. A frozen snapshot is + /// only frozen if regenerating the rolling tag leaves it byte for byte + /// alone. + function testBuildFileForTaggedContractLeavesOtherTagsAlone() external { + string memory frozen = "0_1_1$taggedFrozen"; + string memory rolling = "candidate$taggedRolling"; + cleanup(frozen); + cleanup(rolling); + address frozenInstance = address(new CodeGennable()); + address rollingInstance = address(this); + assertNotEq(frozenInstance.codehash, rollingInstance.codehash, "instances must hold different code"); + string memory frozenBody = "\n// frozen\n"; + + LibFs.buildFileForTaggedContract( + vm, frozenInstance, frozen, "LibFsTaggedShared", SPDX_LICENSE_IDENTIFIER, COPYRIGHT_TEXT, frozenBody + ); + string memory frozenFile = vm.readFile(LibFs.pathForTaggedContract(frozen, "LibFsTaggedShared")); + + LibFs.buildFileForTaggedContract( + vm, rollingInstance, rolling, "LibFsTaggedShared", SPDX_LICENSE_IDENTIFIER, COPYRIGHT_TEXT, "\n// rolling\n" + ); + + assertEq( + vm.readFile(LibFs.pathForTaggedContract(frozen, "LibFsTaggedShared")), + frozenFile, + "the frozen snapshot was disturbed" + ); + assertEq(frozenFile, expectedFile(frozenInstance, frozenBody)); + assertEq( + vm.readFile(LibFs.pathForTaggedContract(rolling, "LibFsTaggedShared")), + expectedFile(rollingInstance, "\n// rolling\n"), + "the rolling snapshot is wrong" + ); + cleanup(frozen); + cleanup(rolling); + } + + /// Generating one contract must not disturb another contract's generated + /// file within the same tag. A snapshot holds many files. + function testBuildFileForTaggedContractLeavesSiblingsAlone() external { + string memory tag = "0_1_1$taggedSiblings"; + cleanup(tag); + address instance = address(new CodeGennable()); + string memory bodyA = "\n// sibling a\n"; + string memory bodyB = "\n// sibling b\n"; + + LibFs.buildFileForTaggedContract( + vm, instance, tag, "LibFsTaggedSiblingA", SPDX_LICENSE_IDENTIFIER, COPYRIGHT_TEXT, bodyA + ); + LibFs.buildFileForTaggedContract( + vm, instance, tag, "LibFsTaggedSiblingB", SPDX_LICENSE_IDENTIFIER, COPYRIGHT_TEXT, bodyB + ); + + assertEq( + vm.readFile(LibFs.pathForTaggedContract(tag, "LibFsTaggedSiblingA")), + expectedFile(instance, bodyA), + "sibling a was disturbed" + ); + assertEq( + vm.readFile(LibFs.pathForTaggedContract(tag, "LibFsTaggedSiblingB")), + expectedFile(instance, bodyB), + "sibling b is wrong" + ); + cleanup(tag); + } + + /// The tagged write never touches the untagged file for the same contract + /// name, so adding a snapshot cannot silently replace a current pin. The + /// sentinel is a Solidity comment, because it lands at a `.sol` path under + /// `src/` that the compiler reads on the next run. + function testBuildFileForTaggedContractLeavesTheUntaggedFileAlone() external { + string memory tag = "0_1_1$taggedUntagged"; + cleanup(tag); + address instance = address(new CodeGennable()); + string memory untaggedPath = LibFs.pathForContract("LibFsTaggedUntagged"); + vm.writeFile(untaggedPath, "// UNTAGGED\n"); + + LibFs.buildFileForTaggedContract( + vm, instance, tag, "LibFsTaggedUntagged", SPDX_LICENSE_IDENTIFIER, COPYRIGHT_TEXT, "\n// tagged\n" + ); + + assertEq(vm.readFile(untaggedPath), "// UNTAGGED\n", "the untagged file was disturbed"); + vm.removeFile(untaggedPath); + cleanup(tag); + } + + /// 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 testBuildFileForTaggedContractUsesTheGivenInstance() external { + string memory tag = "0_1_1$taggedInstance"; + cleanup(tag); + 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.buildFileForTaggedContract( + vm, instance, tag, "LibFsTaggedInstance", SPDX_LICENSE_IDENTIFIER, COPYRIGHT_TEXT, "" + ); + string memory fromInstance = vm.readFile(LibFs.pathForTaggedContract(tag, "LibFsTaggedInstance")); + LibFs.buildFileForTaggedContract( + vm, other, tag, "LibFsTaggedInstance", SPDX_LICENSE_IDENTIFIER, COPYRIGHT_TEXT, "" + ); + string memory fromOther = vm.readFile(LibFs.pathForTaggedContract(tag, "LibFsTaggedInstance")); + + assertNotEq(fromInstance, fromOther, "instance is not what the hash is read from"); + assertEq(fromInstance, expectedFile(instance, "")); + assertEq(fromOther, expectedFile(other, "")); + cleanup(tag); + } + + /// Removes whatever is at `path`, so that a test asserting nothing was + /// written there establishes its own precondition rather than assuming one. + function cleanupPath(string memory path) internal { + if (vm.exists(path)) { + if (vm.isDir(path)) { + vm.removeDir(path, true); + } else { + vm.removeFile(path); + } + } + } + + /// A tag that is outside the alphabet gets no path from + /// `pathForTaggedContract`, and `buildFileForTaggedContract` asks for the + /// path before it reaches a cheatcode, so the refusal arrives before + /// anything is written. + function assertTagRejected(string memory tag, string memory contractName) internal { + vm.expectRevert(abi.encodeWithSelector(InvalidTag.selector, tag)); + iExternal.buildFileForTaggedContract( + vm, address(this), tag, contractName, SPDX_LICENSE_IDENTIFIER, COPYRIGHT_TEXT, "" + ); + } + + /// A contract name that is not a Solidity identifier is refused the same + /// way. + function assertNameRejected(string memory tag, string memory contractName) internal { + vm.expectRevert(abi.encodeWithSelector(InvalidIdentifier.selector, contractName)); + iExternal.buildFileForTaggedContract( + vm, address(this), tag, contractName, SPDX_LICENSE_IDENTIFIER, COPYRIGHT_TEXT, "" + ); + } + + /// An empty tag would write to `src/generated//Foo.sol`, which resolves to + /// the untagged file. Refused, and the untagged file does not appear. + function testBuildFileForTaggedContractRejectsEmptyTag() external { + cleanupPath("src/generated/LibFsTaggedEmptyTag.sol"); + assertTagRejected("", "LibFsTaggedEmptyTag"); + assertFalse(vm.exists("src/generated/LibFsTaggedEmptyTag.sol"), "an empty tag still wrote a file"); + } + + /// A separator in the tag reaches a deeper directory, which the `read-write` + /// grant on the generated directory admits, so the refusal has to come from + /// the library. + function testBuildFileForTaggedContractRejectsSubdirectoryTag() external { + cleanupPath("src/generated/sub"); + assertFalse(vm.exists("src/generated/sub"), "dirty precondition"); + assertTagRejected("sub/0_1_1", "LibFsTaggedSubTag"); + assertFalse(vm.exists("src/generated/sub"), "a separator still created a subdirectory"); + } + + /// A relative directory reference in the tag leaves the generated directory + /// entirely. Under this repo's `fs_permissions` the write is refused anyway, + /// but under the `read-write` grant on `.` that consumers commonly write it + /// is not, so the refusal has to come from here. + function testBuildFileForTaggedContractRejectsTraversalTag() external { + assertTagRejected("..", "LibFsTaggedTraversalTag"); + assertTagRejected("../../ESCAPED", "LibFsTaggedTraversalTag"); + } + + /// A separator in the contract name reaches a directory below the tag's, and + /// `..` climbs back out of it onto an untagged file. + function testBuildFileForTaggedContractRejectsNameEscapes() external { + cleanupPath("src/generated/0_1_1$taggedNameEscape"); + assertNameRejected("0_1_1$taggedNameEscape", ""); + assertNameRejected("0_1_1$taggedNameEscape", "sub/LibFsTaggedNameEscape"); + assertNameRejected("0_1_1$taggedNameEscape", ".."); + assertNameRejected("0_1_1$taggedNameEscape", "../LibFsTaggedNameEscape"); + assertNameRejected("0_1_1$taggedNameEscape", "LibFsTaggedNameEscape.sol"); + assertFalse(vm.exists("src/generated/0_1_1$taggedNameEscape"), "a rejected name still created the tag dir"); + } + + /// Every tag outside the alphabet is refused, not just the ones named above. + function testBuildFileForTaggedContractRejectsEveryNonTag(bytes memory tagBytes) external { + string memory tag = string(tagBytes); + vm.assume(!LibCodeGenSlow.isTagSlow(tag)); + assertTagRejected(tag, "LibFsTaggedFuzzTag"); + } + + /// Every contract name that is not a Solidity identifier is refused, not + /// just the ones named above. + function testBuildFileForTaggedContractRejectsEveryNonIdentifierName(bytes memory nameBytes) external { + string memory contractName = string(nameBytes); + vm.assume(!LibCodeGenSlow.isIdentifierSlow(contractName)); + assertNameRejected("0_1_1$taggedFuzzName", contractName); + } +} diff --git a/test/src/lib/LibFs.dirForTag.t.sol b/test/src/lib/LibFs.dirForTag.t.sol new file mode 100644 index 0000000..cf23579 --- /dev/null +++ b/test/src/lib/LibFs.dirForTag.t.sol @@ -0,0 +1,139 @@ +// 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.2/src/Test.sol"; +import {LibFs, GENERATED_DIR, InvalidTag} from "src/lib/LibFs.sol"; +import {LibFsExternal} from "test/concrete/LibFsExternal.sol"; +import {LibCodeGenSlow} from "test/lib/LibCodeGenSlow.sol"; + +/// @title LibFsDirForTagTest +/// @notice `dirForTag` is the only thing that puts a caller supplied tag into a +/// directory path, so it owns that half of the confinement: it reverts unless +/// the tag is drawn from the tag alphabet, and the directory it does return +/// carries that tag verbatim. +/// +/// The properties below are split by domain rather than asserted over arbitrary +/// strings. Over the accepted domain, tags are CONSTRUCTED from the alphabet, +/// because an arbitrary string is essentially never a tag and filtering for one +/// would leave the property proven over nothing. Over the rejected domain, +/// arbitrary bytes are exactly the right generator, because that is what the +/// rejected domain is. +contract LibFsDirForTagTest is Test { + /// `vm.expectRevert` needs a call frame, and `dirForTag` is an internal + /// library function that is inlined into its caller. + LibFsExternal internal immutable iExternal; + + constructor() { + iExternal = new LibFsExternal(); + } + + /// A tag's files live in a directory named for the tag directly inside the + /// generated directory. Pinned exactly because consumers commit this + /// directory and import from it by path: the location is a cross repo + /// contract, not an internal detail. + function testDirForTag() external pure { + assertEq(LibFs.dirForTag("0_1_1"), "src/generated/0_1_1"); + assertEq(LibFs.dirForTag("candidate"), "src/generated/candidate"); + } + + /// The directory is two regions and nothing else: the generated directory + /// and the tag byte for byte. Asserted positionally over tags built from + /// the alphabet, at every length, so that a tag is never quoted, escaped, + /// trimmed, case folded or truncated on its way into the directory. The + /// length equality is what makes it exhaustive: it forbids any extra byte + /// anywhere. + function testDirForTagStructure(bytes memory seed) external pure { + string memory tag = LibCodeGenSlow.tagFromSeedSlow(seed); + bytes memory dir = bytes(LibFs.dirForTag(tag)); + bytes memory tagBytes = bytes(tag); + + assertEq(dir.length, 14 + tagBytes.length, "directory has bytes beyond generated dir + tag"); + assertEq(LibCodeGenSlow.sliceSlow(dir, 0, 14), bytes("src/generated/"), "generated directory"); + assertEq(LibCodeGenSlow.sliceSlow(dir, 14, tagBytes.length), tagBytes, "tag is not verbatim"); + } + + /// Two tags must never be handed the same directory: a frozen snapshot + /// would be overwritten by another release's generation. Distinct tags give + /// distinct directories. + function testDirForTagDistinctTagsDistinctDirs(bytes memory seedA, bytes memory seedB) external pure { + string memory a = LibCodeGenSlow.tagFromSeedSlow(seedA); + string memory b = LibCodeGenSlow.tagFromSeedSlow(seedB); + vm.assume(keccak256(bytes(a)) != keccak256(bytes(b))); + assertNotEq(LibFs.dirForTag(a), LibFs.dirForTag(b)); + } + + /// The directory 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 testDirForTagIsRelative(bytes memory seed) external pure { + bytes memory dir = bytes(LibFs.dirForTag(LibCodeGenSlow.tagFromSeedSlow(seed))); + assertTrue(dir.length > 0, "empty directory"); + assertNotEq(uint8(dir[0]), uint8(bytes1("/")), "directory is absolute"); + } + + /// What the check buys: the directory built for a tag the library accepts is + /// one segment directly inside the generated directory, with no dot in it at + /// all. Asserted by counting, over generated tags, so no accepted tag can + /// reach a deeper directory, a parent directory, or a hidden one. + function testDirForTagAcceptedTagsStayInGeneratedDir(bytes memory seed) external pure { + bytes memory dir = bytes(LibFs.dirForTag(LibCodeGenSlow.tagFromSeedSlow(seed))); + bytes memory generated = bytes(GENERATED_DIR); + + uint256 separators = 0; + for (uint256 i = 0; i < dir.length; i++) { + if (dir[i] == "/") { + separators++; + } + assertNotEq(uint8(dir[i]), uint8(bytes1(".")), "a dot in the directory"); + } + uint256 generatedSeparators = 0; + for (uint256 i = 0; i < generated.length; i++) { + if (generated[i] == "/") { + generatedSeparators++; + } + } + assertEq(separators, generatedSeparators + 1, "the directory is not a direct child of the generated directory"); + } + + /// No directory is produced for the tag at all, and the error carries the + /// tag that was rejected so a build failure says which one it was. + function assertTagRejected(string memory tag) internal { + vm.expectRevert(abi.encodeWithSelector(InvalidTag.selector, tag)); + iExternal.dirForTag(tag); + } + + /// The tags that motivate the check get no directory, so a consumer that + /// calls `dirForTag` and then does its own IO with the result cannot be + /// handed one that escapes the generated directory or hides inside it. + function testDirForTagRejectsNamedEscapes() external { + // `src/generated/`: the generated directory itself, so a tagged write + // would land on an untagged file. + assertTagRejected(""); + // Leaves the generated directory. + assertTagRejected(".."); + assertTagRejected("../../ESCAPED"); + assertTagRejected("../0_1_1"); + // Reaches a deeper directory of, or out of, the generated directory. + assertTagRejected("0_1_1/sub"); + assertTagRejected("/0_1_1"); + assertTagRejected("0_1_1/"); + assertTagRejected("/"); + // Hidden. + assertTagRejected("."); + assertTagRejected(".hidden"); + // The version separators a consumer reaches for before the org's. + assertTagRejected("0.1.1"); + assertTagRejected("0-1-1"); + } + + /// The whole rejected domain, not only the tags above: no string outside the + /// alphabet produces a directory. Fuzzed over arbitrary bytes, which is the + /// right generator here precisely because an arbitrary byte string is + /// essentially never a tag. + function testDirForTagRejectsEveryNonTag(bytes memory tagBytes) external { + string memory tag = string(tagBytes); + vm.assume(!LibCodeGenSlow.isTagSlow(tag)); + assertTagRejected(tag); + } +} diff --git a/test/src/lib/LibFs.isPresent.t.sol b/test/src/lib/LibFs.isPresent.t.sol index 534a99d..e11d6be 100644 --- a/test/src/lib/LibFs.isPresent.t.sol +++ b/test/src/lib/LibFs.isPresent.t.sol @@ -7,9 +7,10 @@ import {VmSafe} from "forge-std-1.16.2/src/Vm.sol"; import {LibFs, GENERATED_DIR} from "src/lib/LibFs.sol"; /// @title LibFsIsPresentTest -/// @notice `isPresent` is what stands between `buildFileForContract` and a write -/// that lands somewhere other than the path it was given, so what it answers for -/// a symlink is asserted here together with the write that depends on it. +/// @notice `isPresent` is what stands between the two write functions and a +/// write that lands somewhere other than the path it was given, so what it +/// answers for a symlink is asserted here together with both writes that depend +/// on it. /// /// Symlinks are built with `ln` because forge-std 1.16.2 has no cheatcode that /// creates one, and they are read back with `readlink`, which reports the path @@ -55,6 +56,17 @@ contract LibFsIsPresentTest is Test { return vm.tryFfi(command); } + /// Creates the directory at `name` and every missing parent of it, so a + /// symlink can be placed inside a directory that the write also creates. + function makeDir(string memory name) internal { + string[] memory command = new string[](3); + command[0] = "mkdir"; + command[1] = "-p"; + command[2] = pathFor(name); + VmSafe.FfiResult memory result = vm.tryFfi(command); + assertEq(result.exitCode, 0, string(result.stderr)); + } + /// `rm -rf` removes a dangling symlink and succeeds on a path that holds /// nothing, neither of which is true of the cheatcodes. Setup and cleanup /// that leaned on the behaviour under test would leave the tree dirty @@ -187,6 +199,61 @@ contract LibFsIsPresentTest is Test { remove(controlFileName); } + /// The tagged write carries the same guarantee at its own path: a symlink + /// with no target inside the tag's directory is replaced by a regular file + /// holding the generated content, and the link's target is not created. + /// + /// The link's target is named relative to the directory the link sits in, + /// which is the tag's directory rather than the generated directory, so the + /// two names for it differ by that segment. + /// + /// The content is compared against a second contract generated in the same + /// tag at a path that held nothing, so the claim is that the two cases + /// produce the same file rather than that some particular bytes appear. + function testBuildFileForTaggedContractReplacesDanglingSymlink() external { + string memory tag = "0_1_1$isPresentDangling"; + string memory name = "LibFsIsPresentTaggedDangling"; + string memory controlName = "LibFsIsPresentTaggedControl"; + string memory linkName = string.concat(tag, "/", name, ".sol"); + string memory linkTarget = "LibFsIsPresentTaggedDanglingTarget.txt"; + string memory targetName = string.concat(tag, "/", linkTarget); + string memory controlFileName = string.concat(tag, "/", controlName, ".sol"); + remove(tag); + makeDir(tag); + + symlink(linkName, linkTarget); + assertEq(LibFs.pathForTaggedContract(tag, name), pathFor(linkName), "the link is not where the write goes"); + assertEq(readlink(linkName).exitCode, 0, "the path under test is not a symlink"); + assertFalse(vm.exists(pathFor(targetName)), "the link target is already there"); + assertFalse(vm.exists(pathFor(linkName)), "the link is not dangling"); + + string memory body = "\n// dangling\n"; + // 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.buildFileForTaggedContract( + vm, address(this), tag, name, "LicenseRef-DCL-1.0", "Copyright (c) 2020 Rain Open Source Software Ltd", body + ); + LibFs.buildFileForTaggedContract( + vm, + address(this), + tag, + 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"); + assertEq( + vm.readFile(pathFor(linkName)), + vm.readFile(pathFor(controlFileName)), + "the file at the path is not what a write to a path holding nothing produces" + ); + + remove(tag); + } + /// A symlink at the generated path whose target exists is replaced by a /// regular file holding the generated content, and the target does not /// receive it: the write goes to the path, not through it. The target is diff --git a/test/src/lib/LibFs.pathForTaggedContract.t.sol b/test/src/lib/LibFs.pathForTaggedContract.t.sol new file mode 100644 index 0000000..06ac12d --- /dev/null +++ b/test/src/lib/LibFs.pathForTaggedContract.t.sol @@ -0,0 +1,313 @@ +// 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.2/src/Test.sol"; +import {LibFs, GENERATED_DIR, InvalidTag} from "src/lib/LibFs.sol"; +import {InvalidIdentifier} from "src/lib/LibCodeGen.sol"; +import {LibFsExternal} from "test/concrete/LibFsExternal.sol"; +import {LibCodeGenSlow} from "test/lib/LibCodeGenSlow.sol"; + +/// @title LibFsPathForTaggedContractTest +/// @notice `pathForTaggedContract` puts two caller supplied strings into one +/// path, so it owns the confinement for both of them: it reverts unless the tag +/// is drawn from the tag alphabet and the contract name is a Solidity +/// identifier, and the path it does return carries both of them verbatim. +/// +/// The claim under test is that no pair of arguments produces a path outside +/// `GENERATED_DIR`. That is asserted two ways: positionally over constructed +/// arguments, where every byte of the path is accounted for, and as a property +/// over the whole input domain, where any pair the function accepts at all is +/// checked for confinement whatever it was. +contract LibFsPathForTaggedContractTest is Test { + /// `vm.expectRevert` needs a call frame, and `pathForTaggedContract` is an + /// internal library function that is inlined into its caller. + LibFsExternal internal immutable iExternal; + + constructor() { + iExternal = new LibFsExternal(); + } + + /// The layout the org's release convention puts a frozen deploy pin snapshot + /// in, and the rolling directory a deploy repo keeps beside it. Pinned + /// exactly because consumers commit these files and import them by path: the + /// location is a cross repo contract, not an internal detail. + function testPathForTaggedContractProducesTheOrgLayout() external pure { + assertEq(LibFs.pathForTaggedContract("0_1_1", "StoxReceipt"), "src/generated/0_1_1/StoxReceipt.sol"); + assertEq(LibFs.pathForTaggedContract("candidate", "StoxReceipt"), "src/generated/candidate/StoxReceipt.sol"); + } + + /// The untagged path is unchanged by the tagged one existing: a name that + /// carries its own separator is still refused, so a caller cannot reach the + /// tagged layout by smuggling the tag through `pathForContract`. + function testPathForContractStillRefusesASmuggledTag() external { + vm.expectRevert(abi.encodeWithSelector(InvalidIdentifier.selector, "0_1_1/StoxReceipt")); + iExternal.pathForContract("0_1_1/StoxReceipt"); + } + + /// The path is four regions and nothing else: the generated directory, the + /// tag byte for byte, a separator then the contract name byte for byte, and + /// the Solidity extension. Asserted positionally over arguments built from + /// their alphabets, at every length, so that neither argument is 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 testPathForTaggedContractStructure(bytes memory tagSeed, bytes memory nameSeed) external pure { + string memory tag = LibCodeGenSlow.tagFromSeedSlow(tagSeed); + string memory contractName = LibCodeGenSlow.nameFromSeedSlow(nameSeed); + bytes memory path = bytes(LibFs.pathForTaggedContract(tag, contractName)); + bytes memory tagBytes = bytes(tag); + bytes memory nameBytes = bytes(contractName); + + assertEq(path.length, 14 + tagBytes.length + 1 + nameBytes.length + 4, "path has bytes beyond its four regions"); + assertEq(LibCodeGenSlow.sliceSlow(path, 0, 14), bytes("src/generated/"), "generated directory"); + assertEq(LibCodeGenSlow.sliceSlow(path, 14, tagBytes.length), tagBytes, "tag is not verbatim"); + assertEq(LibCodeGenSlow.sliceSlow(path, 14 + tagBytes.length, 1), bytes("/"), "tag separator"); + assertEq( + LibCodeGenSlow.sliceSlow(path, 15 + tagBytes.length, nameBytes.length), + nameBytes, + "contract name is not verbatim" + ); + assertEq(LibCodeGenSlow.sliceSlow(path, 15 + tagBytes.length + nameBytes.length, 4), bytes(".sol"), "extension"); + } + + /// 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 testPathForTaggedContractIsRelative(bytes memory tagSeed, bytes memory nameSeed) external pure { + bytes memory path = bytes( + LibFs.pathForTaggedContract( + LibCodeGenSlow.tagFromSeedSlow(tagSeed), LibCodeGenSlow.nameFromSeedSlow(nameSeed) + ) + ); + assertTrue(path.length > 0, "empty path"); + assertNotEq(uint8(path[0]), uint8(bytes1("/")), "path is absolute"); + } + + /// Two tagged contracts must never be handed the same file: generation would + /// silently overwrite one with the other, and across tags that would rewrite + /// a frozen snapshot. A different tag or a different name gives a different + /// path. + function testPathForTaggedContractDistinctArgumentsDistinctPaths( + bytes memory tagSeedA, + bytes memory nameSeedA, + bytes memory tagSeedB, + bytes memory nameSeedB + ) external pure { + string memory tagA = LibCodeGenSlow.tagFromSeedSlow(tagSeedA); + string memory nameA = LibCodeGenSlow.nameFromSeedSlow(nameSeedA); + string memory tagB = LibCodeGenSlow.tagFromSeedSlow(tagSeedB); + string memory nameB = LibCodeGenSlow.nameFromSeedSlow(nameSeedB); + vm.assume( + keccak256(bytes(tagA)) != keccak256(bytes(tagB)) || keccak256(bytes(nameA)) != keccak256(bytes(nameB)) + ); + assertNotEq(LibFs.pathForTaggedContract(tagA, nameA), LibFs.pathForTaggedContract(tagB, nameB)); + } + + /// The tagged path and the untagged path never collide, so generating into a + /// tag cannot overwrite a file that belongs to no snapshot. + function testPathForTaggedContractNeverCollidesWithUntagged(bytes memory tagSeed, bytes memory nameSeed) + external + pure + { + string memory tag = LibCodeGenSlow.tagFromSeedSlow(tagSeed); + string memory contractName = LibCodeGenSlow.nameFromSeedSlow(nameSeed); + assertNotEq(LibFs.pathForTaggedContract(tag, contractName), LibFs.pathForContract(contractName)); + } + + /// The number of `/` in `GENERATED_DIR`, counted rather than assumed, so the + /// segment counting below states a relationship to the constant instead of + /// to a hardcoded number that moves independently of it. + function generatedDirSeparators() internal pure returns (uint256 separators) { + bytes memory generated = bytes(GENERATED_DIR); + for (uint256 i = 0; i < generated.length; i++) { + if (generated[i] == "/") { + separators++; + } + } + } + + /// Every way a path can leave `GENERATED_DIR` or hide inside it, checked + /// against one path: it begins with the generated directory and a separator, + /// it adds exactly two segments to it, no segment is empty, and the only dot + /// anywhere is the extension this library appends. `..` needs a dot, a deeper + /// directory needs a third separator, and an absolute path or a doubled + /// separator needs an empty segment, so all of them are excluded together. + function assertConfined(string memory path) internal pure { + bytes memory pathBytes = bytes(path); + bytes memory generated = bytes(GENERATED_DIR); + + assertGt(pathBytes.length, generated.length, "path is no longer than the generated directory"); + for (uint256 i = 0; i < generated.length; i++) { + assertEq(uint8(pathBytes[i]), uint8(generated[i]), "path does not begin with the generated directory"); + } + assertEq(uint8(pathBytes[generated.length]), uint8(bytes1("/")), "generated directory is not a path prefix"); + + uint256 separators = 0; + uint256 sinceSeparator = 0; + for (uint256 i = 0; i < pathBytes.length; i++) { + if (pathBytes[i] == "/") { + assertGt(sinceSeparator, 0, "empty path segment"); + separators++; + sinceSeparator = 0; + } else { + if (pathBytes[i] == ".") { + assertEq(i, pathBytes.length - 4, "a dot outside the appended extension"); + } + sinceSeparator++; + } + } + assertGt(sinceSeparator, 0, "path ends in a separator"); + assertEq(separators, generatedDirSeparators() + 2, "the path is not exactly two segments inside the dir"); + } + + /// The confinement invariant over the whole input domain: whatever pair of + /// strings `pathForTaggedContract` accepts, the path it hands back is inside + /// `GENERATED_DIR`. Each argument is either arbitrary bytes or built from its + /// alphabet, chosen by the fuzzer, so the rejected domain is reached by the + /// raw branches and the accepted domain — which arbitrary bytes essentially + /// never reach — by the constructed ones. Nothing is assumed away: every + /// pair reaches an assertion, because which side of the domain it is on is + /// decided by the oracles rather than by whether the call reverted. A pair + /// both oracles accept must produce a confined path, and a revert is a + /// claim that at least one of them rejects it. + function testPathForTaggedContractAcceptedArgumentsAreConfined( + bytes memory tagSeed, + bytes memory nameSeed, + bool rawTag, + bool rawName + ) external { + string memory tag = rawTag ? string(tagSeed) : LibCodeGenSlow.tagFromSeedSlow(tagSeed); + string memory contractName = rawName ? string(nameSeed) : LibCodeGenSlow.nameFromSeedSlow(nameSeed); + try iExternal.pathForTaggedContract(tag, contractName) returns (string memory path) { + assertTrue(LibCodeGenSlow.isTagSlow(tag), "a tag outside the alphabet was accepted"); + assertTrue(LibCodeGenSlow.isIdentifierSlow(contractName), "a name outside the alphabet was accepted"); + assertConfined(path); + } catch { + assertFalse( + LibCodeGenSlow.isTagSlow(tag) && LibCodeGenSlow.isIdentifierSlow(contractName), + "a pair both oracles accept produced no path" + ); + } + } + + /// The same confinement and the same agreement with the oracles, stated over + /// a pair that is an accepted tag and an accepted name with one byte of one + /// of them replaced by an arbitrary one. This is the neighbourhood of the + /// accepted domain, which uniform fuzzing never reaches, and it is where an + /// off by one in either check shows: such a byte is admitted or refused by + /// the library against an oracle that spells its alphabet out, so a range + /// that opens or closes one too far disagrees here rather than producing a + /// path that still happens to be confined. + function testPathForTaggedContractOneBadByteIsConfined( + bytes memory tagSeed, + bytes memory nameSeed, + uint256 position, + uint8 badByte, + bool inTag + ) external { + bytes memory tagBytes = bytes(LibCodeGenSlow.tagFromSeedSlow(tagSeed)); + bytes memory nameBytes = bytes(LibCodeGenSlow.nameFromSeedSlow(nameSeed)); + if (inTag) { + tagBytes[position % tagBytes.length] = bytes1(badByte); + } else { + nameBytes[position % nameBytes.length] = bytes1(badByte); + } + string memory tag = string(tagBytes); + string memory contractName = string(nameBytes); + try iExternal.pathForTaggedContract(tag, contractName) returns (string memory path) { + assertTrue(LibCodeGenSlow.isTagSlow(tag), "a tag outside the alphabet was accepted"); + assertTrue(LibCodeGenSlow.isIdentifierSlow(contractName), "a name outside the alphabet was accepted"); + assertConfined(path); + } catch { + assertFalse( + LibCodeGenSlow.isTagSlow(tag) && LibCodeGenSlow.isIdentifierSlow(contractName), + "a pair both oracles accept produced no path" + ); + } + } + + /// No path is produced for the tag at all, and the error names the tag + /// rather than the contract name, so a build failure says which argument was + /// wrong. + function assertTagRejected(string memory tag, string memory contractName) internal { + vm.expectRevert(abi.encodeWithSelector(InvalidTag.selector, tag)); + iExternal.pathForTaggedContract(tag, contractName); + } + + /// No path is produced for the contract name at all, and the error names the + /// contract name. + function assertNameRejected(string memory tag, string memory contractName) internal { + vm.expectRevert(abi.encodeWithSelector(InvalidIdentifier.selector, contractName)); + iExternal.pathForTaggedContract(tag, contractName); + } + + /// The tags that would escape the generated directory or hide inside it get + /// no path, behind a contract name that is fine, so the tag is what decides + /// it. + function testPathForTaggedContractRejectsTagEscapes() external { + assertTagRejected("", "Foo"); + assertTagRejected("..", "Foo"); + assertTagRejected("../..", "Foo"); + assertTagRejected("../../ESCAPED", "Foo"); + assertTagRejected(".", "Foo"); + assertTagRejected(".hidden", "Foo"); + assertTagRejected("sub/0_1_1", "Foo"); + assertTagRejected("0_1_1/sub", "Foo"); + assertTagRejected("/0_1_1", "Foo"); + assertTagRejected("0_1_1/", "Foo"); + assertTagRejected("0.1.1", "Foo"); + } + + /// The contract names that would escape or hide get no path either, behind a + /// tag that is fine. The tagged path is one segment deeper than the untagged + /// one, so a name that traverses once lands back on an untagged file rather + /// than outside the repo, which is why the name check has to hold here too. + function testPathForTaggedContractRejectsNameEscapes() external { + assertNameRejected("0_1_1", ""); + assertNameRejected("0_1_1", ".."); + assertNameRejected("0_1_1", "../Foo"); + assertNameRejected("0_1_1", "../../ESCAPED"); + assertNameRejected("0_1_1", "."); + assertNameRejected("0_1_1", ".hidden"); + assertNameRejected("0_1_1", "sub/Foo"); + assertNameRejected("0_1_1", "/Foo"); + assertNameRejected("0_1_1", "Foo/"); + assertNameRejected("0_1_1", "Foo.sol"); + } + + /// A contract name that is a tag but not an identifier is still refused: the + /// two rules are applied to the argument each belongs to, not to whichever + /// one happens to pass. + function testPathForTaggedContractDoesNotApplyTheTagRuleToTheName() external { + assertNameRejected("0_1_1", "0_1_1"); + } + + /// A tag that is a Solidity identifier is accepted, so the tag rule is not + /// merely the identifier rule under another name in the direction that + /// matters either. + function testPathForTaggedContractAcceptsAnIdentifierTag() external pure { + assertEq(LibFs.pathForTaggedContract("candidate", "Foo"), "src/generated/candidate/Foo.sol"); + } + + /// Both arguments wrong names the tag, because the tag is checked first. A + /// build script that gets both wrong is told about the outer one, which is + /// the one that decides the directory. + function testPathForTaggedContractRejectsTheTagFirst() external { + assertTagRejected("../escape", "../escape"); + } + + /// The whole rejected tag domain, not only the tags above. + function testPathForTaggedContractRejectsEveryNonTag(bytes memory tagBytes) external { + string memory tag = string(tagBytes); + vm.assume(!LibCodeGenSlow.isTagSlow(tag)); + assertTagRejected(tag, "Foo"); + } + + /// The whole rejected name domain, not only the names above. + function testPathForTaggedContractRejectsEveryNonIdentifierName(bytes memory nameBytes) external { + string memory contractName = string(nameBytes); + vm.assume(!LibCodeGenSlow.isIdentifierSlow(contractName)); + assertNameRejected("0_1_1", contractName); + } +} diff --git a/test/src/lib/LibFs.requireTag.t.sol b/test/src/lib/LibFs.requireTag.t.sol new file mode 100644 index 0000000..833114f --- /dev/null +++ b/test/src/lib/LibFs.requireTag.t.sol @@ -0,0 +1,232 @@ +// 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.2/src/Test.sol"; +import {LibFs, InvalidTag} from "src/lib/LibFs.sol"; +import {LibCodeGen, InvalidIdentifier} from "src/lib/LibCodeGen.sol"; +import {LibCodeGenSlow, SLOW_TAIL_ALPHABET} from "test/lib/LibCodeGenSlow.sol"; + +/// @title LibFsRequireTagTest +/// @notice `requireTag` is what stands between a caller supplied tag and a +/// directory path built out of it. The rule it enforces is the Solidity +/// identifier alphabet with no rule about the first character: that admits the +/// `__` release tags whose whole point is to open with a +/// digit, and it is still a character set that cannot express a path separator, +/// a parent directory or an empty segment. +contract LibFsRequireTagTest is Test { + /// Reachable only through an external call so that the revert can be caught + /// rather than aborting the test. + function callRequireTag(string memory tag) external pure { + LibFs.requireTag(tag); + } + + /// `requireIdentifier` behind a call frame too, so the two rules can be + /// asserted to disagree on the same string. + function callRequireContractName(string memory name) external pure { + LibCodeGen.requireIdentifier(name); + } + + function assertAccepted(string memory tag) internal view { + this.callRequireTag(tag); + } + + function assertRejected(string memory tag) internal { + vm.expectRevert(abi.encodeWithSelector(InvalidTag.selector, tag)); + this.callRequireTag(tag); + } + + /// The tags real consumers write. `frozen-snapshots-append-only` in the + /// shared CI recognises a frozen snapshot directory by exactly the + /// `__` shape, and a deploy repo carries a rolling + /// `candidate` directory beside the frozen ones, so these are the shapes + /// that have to be accepted for the layout to be reachable at all. + function testRequireTagAcceptsReleaseTags() external view { + assertAccepted("0_1_1"); + assertAccepted("0_1_4"); + assertAccepted("0_1_10"); + assertAccepted("12_0_255"); + assertAccepted("candidate"); + } + + /// A tag opening with a digit is the case that separates this rule from + /// `requireIdentifier`, which refuses one because a Solidity identifier + /// cannot open with a digit. Both verdicts on the same string are asserted + /// here, so collapsing the two rules into one fails. + function testRequireTagAcceptsLeadingDigitContractNameDoesNot() external { + assertAccepted("0_1_1"); + vm.expectRevert(abi.encodeWithSelector(InvalidIdentifier.selector, "0_1_1")); + this.callRequireContractName("0_1_1"); + } + + /// Every character class the tag alphabet allows, at both the first + /// position and a later one. + function testRequireTagAcceptsAlphabetCharacters() external view { + assertAccepted("A"); + assertAccepted("z"); + assertAccepted("_"); + assertAccepted("$"); + assertAccepted("0"); + assertAccepted("9"); + assertAccepted("_0_1_1"); + assertAccepted("$tag"); + assertAccepted("v0_1_1"); + assertAccepted("0"); + } + + /// Each of the three character ranges is closed at exactly its ends. Pinned + /// from both sides: the first and last character of every range is + /// accepted, and the character immediately outside each end is refused. + /// A range that runs one past either of its ends admits one of the six + /// characters sitting against them: at sign, `[`, backtick, `{`, `/` or + /// `:` — one of which is a path separator. + function testRequireTagRangeBoundaries() external { + assertAccepted("A"); + assertAccepted("Z"); + assertAccepted("a"); + assertAccepted("z"); + assertAccepted("0"); + assertAccepted("9"); + assertRejected("@"); + assertRejected("["); + assertRejected("`"); + assertRejected("{"); + assertRejected("/"); + assertRejected(":"); + } + + /// An empty tag would build `src/generated//Foo.sol`, which resolves to the + /// untagged path and so silently overwrites a file that belongs to no + /// snapshot. + function testRequireTagRejectsEmpty() external { + assertRejected(""); + } + + /// A separator would reach a deeper directory, and `..` would escape the + /// generated directory entirely. Both are refused by the character rule + /// rather than by a special case for them. + function testRequireTagRejectsPathCharacters() external { + assertRejected(".."); + assertRejected("."); + assertRejected("0_1_1/sub"); + assertRejected("../../ESCAPED"); + assertRejected("/0_1_1"); + assertRejected("0_1_1/"); + assertRejected("/"); + assertRejected("0_1_1\\sub"); + assertRejected(".hidden"); + } + + /// A tag that is otherwise fine but carries anything outside the alphabet + /// is refused. The version separators consumers might reach for first are + /// the ones that matter: `0.1.1` carries dots and `0-1-1` a dash. + function testRequireTagRejectsOtherCharacters() external { + assertRejected("0.1.1"); + assertRejected("0-1-1"); + assertRejected("0 1 1"); + assertRejected("0_1_1\n"); + assertRejected(unicode"tag€"); + assertRejected(string(hex"7461670a")); + assertRejected(string(hex"74616700")); + } + + /// The rejection carries the tag that was rejected, so a build script that + /// generates many files says which one it choked on. + function testRequireTagErrorCarriesTheTag() external { + vm.expectRevert(abi.encodeWithSelector(InvalidTag.selector, "0.1.1")); + this.callRequireTag("0.1.1"); + } + + /// Exhaustive over the single byte of a one character tag: all 256 of them, + /// accepted exactly when the byte is in the alphabet. Nothing about the + /// boundaries of the accepted ranges is left to a chosen example, and the + /// oracle is the alphabet written out character by character rather than + /// the same range arithmetic the library uses. + function testRequireTagEveryLeadingByte() external { + for (uint256 i = 0; i < 256; i++) { + string memory tag = string(bytes.concat(bytes1(uint8(i)))); + if (LibCodeGenSlow.containsSlow(SLOW_TAIL_ALPHABET, bytes1(uint8(i)))) { + assertAccepted(tag); + } else { + assertRejected(tag); + } + } + } + + /// Exhaustive over the trailing byte, behind a leading byte that is itself + /// accepted. The same alphabet decides both positions, which is what makes + /// a tag differ from a contract name. + function testRequireTagEveryTrailingByte() external { + for (uint256 i = 0; i < 256; i++) { + string memory tag = string(bytes.concat(bytes("0"), bytes1(uint8(i)))); + if (LibCodeGenSlow.containsSlow(SLOW_TAIL_ALPHABET, bytes1(uint8(i)))) { + assertAccepted(tag); + } else { + assertRejected(tag); + } + } + } + + /// Over arbitrary tags of arbitrary length, acceptance agrees with the + /// reference alphabet exactly. The reference is spelled out character by + /// character, so this fails if either end of any range moves, rather than + /// following the library the way an inlined copy of its own arithmetic + /// would. + function testRequireTagMatchesAlphabet(bytes memory tagBytes) external { + string memory tag = string(tagBytes); + if (LibCodeGenSlow.isTagSlow(tag)) { + assertAccepted(tag); + } else { + assertRejected(tag); + } + } + + /// Tags built from the alphabet are accepted at any length. Fuzzing a tag + /// directly essentially never produces one, so without constructing them the + /// accepted half of the domain is never exercised at all and a check that + /// rejected everything would still pass. + function testRequireTagAcceptsGeneratedTags(bytes memory seed) external view { + string memory tag = LibCodeGenSlow.tagFromSeedSlow(seed); + assertTrue(bytes(tag).length > 0, "generated an empty tag"); + assertAccepted(tag); + } + + /// Every `__` tag is accepted, which is the exact + /// shape the shared CI's frozen snapshot check recognises as a release. Built + /// from three arbitrary numbers rather than from the alphabet, so this states + /// the cross repo contract rather than restating the character rule. + function testRequireTagAcceptsEveryNumericReleaseTag(uint64 major, uint64 minor, uint64 patch) external view { + assertAccepted( + string.concat( + vm.toString(uint256(major)), "_", vm.toString(uint256(minor)), "_", vm.toString(uint256(patch)) + ) + ); + } + + /// A single byte outside the alphabet is enough to reject a tag that is + /// otherwise fine, wherever in the tag it sits. A check that only looked at + /// the first or the last character would pass this. + function testRequireTagRejectsOneBadByte(bytes memory seed, uint256 position, uint8 badByte) external { + bytes memory tagBytes = bytes(LibCodeGenSlow.tagFromSeedSlow(seed)); + vm.assume(!LibCodeGenSlow.containsSlow(SLOW_TAIL_ALPHABET, bytes1(badByte))); + tagBytes[position % tagBytes.length] = bytes1(badByte); + assertRejected(string(tagBytes)); + } + + /// No accepted tag contains a byte that means anything to a filesystem, so + /// no accepted tag can leave the directory it is interpolated into. Stated + /// as a property of the accepted set rather than as a list of the sequences + /// that would escape. + function testRequireTagAcceptedTagsCannotTraverse(string memory tag) external { + try this.callRequireTag(tag) { + bytes memory tagBytes = bytes(tag); + assertGt(tagBytes.length, 0, "empty tag accepted"); + for (uint256 i = 0; i < tagBytes.length; i++) { + assertNotEq(uint8(tagBytes[i]), uint8(bytes1("/")), "separator accepted"); + assertNotEq(uint8(tagBytes[i]), uint8(bytes1("\\")), "backslash accepted"); + assertNotEq(uint8(tagBytes[i]), uint8(bytes1(".")), "dot accepted"); + assertNotEq(uint8(tagBytes[i]), uint8(bytes1(hex"00")), "nul accepted"); + } + } catch {} + } +}