diff --git a/src/lib/LibFs.sol b/src/lib/LibFs.sol index a4439a5..10a4a2f 100644 --- a/src/lib/LibFs.sol +++ b/src/lib/LibFs.sol @@ -97,12 +97,18 @@ library LibFs { /// it found it: nothing is created, unlinked or written unless there is /// content to write. /// - /// Anything already at the path is unlinked before the write, so a symlink + /// 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, 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. + /// target, whether or not that target exists, and the path does not exist + /// between the last unlink and the write. Taking a live symlink off the + /// path takes what it resolves to with it, because that is what the unlink + /// acts on first. + /// 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. + /// + /// A directory at the path, and a symlink at the path that resolves to a + /// directory, are the cases this cannot unlink, and both revert rather than + /// being written into or through. /// /// 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 @@ -150,7 +156,11 @@ library LibFs { string.concat(LibCodeGen.filePrefix(), LibCodeGen.bytecodeHashConstantString(vm, instance), body); //forge-lint: disable-next-line(unsafe-cheatcode) vm.createDir(dir, true); - if (isPresent(vm, path)) { + // `vm.removeFile` resolves the path before it acts, so on a live symlink + // it takes what the link points at and leaves the link, now dangling. + // Every pass removes something the next one no longer finds, so this + // ends with the path holding nothing. + while (isPresent(vm, path)) { //forge-lint: disable-next-line(unsafe-cheatcode) vm.removeFile(path); } diff --git a/test/src/lib/LibFs.isPresent.t.sol b/test/src/lib/LibFs.isPresent.t.sol index 6bfdbc4..26fb33e 100644 --- a/test/src/lib/LibFs.isPresent.t.sol +++ b/test/src/lib/LibFs.isPresent.t.sol @@ -175,4 +175,48 @@ contract LibFsIsPresentTest is Test { remove(targetName); remove(controlFileName); } + + /// 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 + /// removed along the way, which is what makes the path free to hold a + /// regular file. + /// + /// 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 testBuildFileForContractReplacesLiveSymlink() external { + string memory name = "LibFsIsPresentLive"; + string memory linkName = "LibFsIsPresentLive.sol"; + string memory targetName = "LibFsIsPresentLiveTarget.txt"; + string memory controlName = "LibFsIsPresentLiveControl"; + string memory controlFileName = "LibFsIsPresentLiveControl.sol"; + remove(linkName); + remove(targetName); + remove(controlFileName); + + vm.writeFile(pathFor(targetName), "SENTINEL"); + 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"); + assertTrue(vm.exists(pathFor(linkName)), "the link does not resolve"); + assertEq(vm.readFile(pathFor(targetName)), "SENTINEL", "the link target is not the seeded file"); + + string memory body = "\n// live\n"; + LibFs.buildFileForContract(vm, address(this), name, body); + LibFs.buildFileForContract(vm, address(this), controlName, body); + + bool linkIsStillASymlink = readlink(linkName).exitCode == 0; + bool targetExists = vm.exists(pathFor(targetName)); + string memory written = vm.readFile(pathFor(linkName)); + string memory control = vm.readFile(pathFor(controlFileName)); + + remove(linkName); + remove(targetName); + remove(controlFileName); + + assertFalse(linkIsStillASymlink, "the path is still a symlink"); + assertFalse(targetExists, "the link target survived the write"); + assertEq(written, control, "the file at the path is not what a write to a path holding nothing produces"); + } }