From 9eeaad01c0c03c912ea404851180b1946ed53ecd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 18:19:59 +0000 Subject: [PATCH 1/3] Unlink a symlink with no target before writing the generated file vm.exists answers for what a path resolves to, so a symlink with no target read as nothing at the path and the write followed the link. LibFs.isPresent answers for the path itself and is what the write now asks. Co-Authored-By: Claude Opus 5 (1M context) --- foundry.toml | 4 + src/lib/LibFs.sol | 26 ++++- test/lib/LibFs.isPresent.t.sol | 167 +++++++++++++++++++++++++++++++++ 3 files changed, 195 insertions(+), 2 deletions(-) create mode 100644 test/lib/LibFs.isPresent.t.sol diff --git a/foundry.toml b/foundry.toml index 1e3e105..d6d358b 100644 --- a/foundry.toml +++ b/foundry.toml @@ -27,6 +27,10 @@ cbor_metadata = false evm_version = "cancun" +# No cheatcode in forge-std 1.16.1 creates a symlink, so the tests that pin what +# this library does when it finds one at a generated path build it with `ln`. +ffi = true + fs_permissions = [ { access = "read", path = "foundry.toml" }, { access = "read-write", path = "src/generated" }, diff --git a/src/lib/LibFs.sol b/src/lib/LibFs.sol index 9ad096f..637ba62 100644 --- a/src/lib/LibFs.sol +++ b/src/lib/LibFs.sol @@ -35,6 +35,27 @@ library LibFs { return string.concat(GENERATED_DIR, "/", contractName, ".sol"); } + /// @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 + /// a symlink with no target as absent. `vm.readLink` answers for the path + /// itself and reverts unless the path is a symlink, so it sees the link that + /// `vm.exists` does not. + /// @param vm The Vm instance for file operations. + /// @param path The path to check, which must be readable under + /// `fs_permissions`. + /// @return True if the path holds a file, a directory or a symlink. + function isPresent(Vm vm, string memory path) internal view returns (bool) { + if (vm.exists(path)) { + return true; + } + try vm.readLink(path) returns (string memory) { + return true; + } catch { + return false; + } + } + /// @notice Builds a file for a generated contract at /// `pathForContract(contractName)`. /// @@ -48,7 +69,8 @@ library LibFs { /// /// Anything already at the path is unlinked before the write, so a symlink /// there is replaced by a regular file rather than written through to its - /// target, and the path does not exist between the unlink and the write. + /// target, including a symlink whose target does not exist, and the path + /// does not exist between the unlink and the write. /// Any manual changes to the generated file, or any other existing file at /// that path, are lost. /// @@ -65,7 +87,7 @@ library LibFs { string memory path = pathForContract(contractName); //forge-lint: disable-next-line(unsafe-cheatcode) vm.createDir(GENERATED_DIR, true); - if (vm.exists(path)) { + if (isPresent(vm, path)) { //forge-lint: disable-next-line(unsafe-cheatcode) vm.removeFile(path); } diff --git a/test/lib/LibFs.isPresent.t.sol b/test/lib/LibFs.isPresent.t.sol new file mode 100644 index 0000000..91652b0 --- /dev/null +++ b/test/lib/LibFs.isPresent.t.sol @@ -0,0 +1,167 @@ +// 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 {VmSafe} from "forge-std-1.16.1/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. +/// +/// Symlinks are built with `ln` because forge-std 1.16.1 has no cheatcode that +/// creates one, and they are read back with `readlink`, which reports the path +/// itself and fails on anything that is not a symlink. `vm.readLink` is what the +/// library uses, so it is deliberately not what asserts here. +contract LibFsIsPresentTest is Test { + /// Every path this contract hands to the shell is built here from a bare + /// name, so no test in it can name a path outside the generated directory. + function pathFor(string memory name) internal pure returns (string memory) { + return string.concat(GENERATED_DIR, "/", name); + } + + /// Creates a symlink at `name` pointing at `target`, which is resolved + /// relative to the generated directory because that is where the link + /// itself is. The exit code is asserted so that a link that was never + /// created cannot read as the library behaving. + function symlink(string memory name, string memory target) internal { + string[] memory command = new string[](4); + command[0] = "ln"; + command[1] = "-s"; + command[2] = target; + command[3] = pathFor(name); + VmSafe.FfiResult memory result = vm.tryFfi(command); + assertEq(result.exitCode, 0, string(result.stderr)); + } + + /// The target of the symlink at `name`, and a non-zero exit code if `name` + /// is not a symlink at all. + function readlink(string memory name) internal returns (VmSafe.FfiResult memory) { + string[] memory command = new string[](2); + command[0] = "readlink"; + command[1] = pathFor(name); + return vm.tryFfi(command); + } + + /// `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 + /// exactly when the test fails. + function remove(string memory name) internal { + string[] memory command = new string[](3); + command[0] = "rm"; + command[1] = "-rf"; + command[2] = pathFor(name); + VmSafe.FfiResult memory result = vm.tryFfi(command); + assertEq(result.exitCode, 0, string(result.stderr)); + } + + /// A path that holds nothing is absent. The write removes what it finds + /// before writing, and removing a path that holds nothing reverts, so + /// answering true here would break every first generation. + function testIsPresentNothing() external { + string memory name = "LibFsIsPresentNothing.txt"; + remove(name); + + assertFalse(LibFs.isPresent(vm, pathFor(name)), "a path holding nothing is present"); + } + + /// A regular file at the path is present. + function testIsPresentFile() external { + string memory name = "LibFsIsPresentFile.txt"; + remove(name); + vm.writeFile(pathFor(name), "content"); + + assertTrue(LibFs.isPresent(vm, pathFor(name)), "a file is not present"); + + remove(name); + } + + /// A directory at the path is present. Nothing generates a directory there, + /// so the write has to find one rather than land inside it. + function testIsPresentDirectory() external { + string memory name = "LibFsIsPresentDir"; + remove(name); + vm.createDir(pathFor(name), false); + + assertTrue(LibFs.isPresent(vm, pathFor(name)), "a directory is not present"); + + remove(name); + } + + /// A symlink whose target exists is present, and is the case that already + /// resolves, so it is what the dangling case is measured against. + function testIsPresentSymlink() external { + string memory name = "LibFsIsPresentLink.txt"; + string memory targetName = "LibFsIsPresentLinkTarget.txt"; + remove(name); + remove(targetName); + vm.writeFile(pathFor(targetName), "content"); + symlink(name, targetName); + assertEq(readlink(name).exitCode, 0, "the path under test is not a symlink"); + assertTrue(vm.exists(pathFor(name)), "the link does not resolve"); + + assertTrue(LibFs.isPresent(vm, pathFor(name)), "a symlink is not present"); + + remove(name); + remove(targetName); + } + + /// A symlink whose target does not exist is present. The path resolves to + /// nothing, which is why `vm.exists` alone is not what answers this. + function testIsPresentDanglingSymlink() external { + string memory name = "LibFsIsPresentDanglingLink.txt"; + string memory targetName = "LibFsIsPresentDanglingLinkTarget.txt"; + remove(name); + remove(targetName); + symlink(name, targetName); + assertEq(readlink(name).exitCode, 0, "the path under test is not a symlink"); + assertEq(string(readlink(name).stdout), targetName, "the link points elsewhere"); + assertFalse(vm.exists(pathFor(name)), "the link is not dangling"); + + assertTrue(LibFs.isPresent(vm, pathFor(name)), "a dangling symlink is not present"); + + remove(name); + } + + /// A symlink at the generated path whose target does not exist is replaced + /// by a regular file holding the generated content, and the target is not + /// created: the write goes to the path, not through it. + /// + /// The content is compared against a second contract generated 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 testBuildFileForContractReplacesDanglingSymlink() external { + string memory name = "LibFsIsPresentDangling"; + string memory linkName = "LibFsIsPresentDangling.sol"; + string memory targetName = "LibFsIsPresentDanglingTarget.txt"; + string memory controlName = "LibFsIsPresentControl"; + string memory controlFileName = "LibFsIsPresentControl.sol"; + remove(linkName); + remove(targetName); + remove(controlFileName); + + symlink(linkName, targetName); + 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"; + LibFs.buildFileForContract(vm, address(this), name, body); + LibFs.buildFileForContract(vm, address(this), controlName, 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(linkName); + remove(targetName); + remove(controlFileName); + } +} From c1a779e5e4c09a92c2767b29ae312c5d6fe1cfa5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 18:24:57 +0000 Subject: [PATCH 2/3] Assert the write's behaviour for a symlink with no target Places the test at the mirror path for src/lib/LibFs.sol and ties the link under test to pathForContract. Co-Authored-By: Claude Opus 5 (1M context) --- test/{ => src}/lib/LibFs.isPresent.t.sol | 1 + 1 file changed, 1 insertion(+) rename test/{ => src}/lib/LibFs.isPresent.t.sol (98%) diff --git a/test/lib/LibFs.isPresent.t.sol b/test/src/lib/LibFs.isPresent.t.sol similarity index 98% rename from test/lib/LibFs.isPresent.t.sol rename to test/src/lib/LibFs.isPresent.t.sol index 91652b0..45c5866 100644 --- a/test/lib/LibFs.isPresent.t.sol +++ b/test/src/lib/LibFs.isPresent.t.sol @@ -144,6 +144,7 @@ contract LibFsIsPresentTest is Test { remove(controlFileName); symlink(linkName, targetName); + assertEq(LibFs.pathForContract(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"); From ed3bca5414345f82a6ed2e57a6f16bc60777740b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 18:27:22 +0000 Subject: [PATCH 3/3] Silence slither's unused-return on the readLink probe Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/LibFs.sol | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/lib/LibFs.sol b/src/lib/LibFs.sol index 637ba62..920118b 100644 --- a/src/lib/LibFs.sol +++ b/src/lib/LibFs.sol @@ -49,6 +49,8 @@ library LibFs { if (vm.exists(path)) { return true; } + // What the target is does not matter here, only that the path has one. + //slither-disable-next-line unused-return try vm.readLink(path) returns (string memory) { return true; } catch {