also looking at this
fix(install): materializing a tree over its own symlinks is repeatable
#264
1 file
+84
-1
| @@ -4041,7 +4041,27 @@ pub const Installer = struct { | ||
| 4041 | 4041 | } else if (entry.kind == .sym_link) { |
| 4042 | 4042 | const target = try io_helper.readLinkAlloc(self.allocator, src_path); |
| 4043 | 4043 | defer self.allocator.free(target); |
| 4044 | try io_helper.symLink(target, dst_path); | |
| 4044 | // Materialization has to be repeatable. Directories here go | |
| 4045 | // through makePath and regular files through createFile, which | |
| 4046 | // truncates — both fine over an existing tree. A bare | |
| 4047 | // symlink(2) is not: it returns EEXIST, and that escaped as | |
| 4048 | // PathAlreadyExists from an install over a restored cache or a | |
| 4049 | // retried materialization (#250). | |
| 4050 | io_helper.symLink(target, dst_path) catch |err| switch (err) { | |
| 4051 | error.PathAlreadyExists => { | |
| 4052 | const existing = io_helper.readLinkAlloc(self.allocator, dst_path) catch null; | |
| 4053 | defer if (existing) |value| self.allocator.free(value); | |
| 4054 | // Already pointing where the source says: nothing to | |
| 4055 | // do. Pointing elsewhere, or not a link at all: | |
| 4056 | // replace it, because the source is authoritative and | |
| 4057 | // a stale link is how a tree half-materializes. | |
| 4058 | if (existing == null or !std.mem.eql(u8, existing.?, target)) { | |
| 4059 | io_helper.deleteFile(dst_path) catch {}; | |
| 4060 | try io_helper.symLink(target, dst_path); | |
| 4061 | } | |
| 4062 | }, | |
| 4063 | else => return err, | |
| 4064 | }; | |
| 4045 | 4065 | } else { |
| 4046 | 4066 | try io_helper.copyFile(src_path, dst_path); |
| 4047 | 4067 | } |
| @@ -5715,3 +5735,66 @@ test "native materialization: concurrent cached publications both succeed withou | ||
| 5715 | 5735 | try std.testing.expectEqual(fixture.payload_bytes, try materializationRegularBytes(allocator, fixture.extraction.payload)); |
| 5716 | 5736 | try fixture.expectNoTransaction(); |
| 5717 | 5737 | } |
| 5738 | ||
| 5739 | test "native materialization: re-copying a tree over its own symlinks is repeatable" { | |
| 5740 | // #250: a macOS job that restored an exact dependency cache failed with | |
| 5741 | // PathAlreadyExists for git-scm.org@2.53.0 while fourteen of fifteen | |
| 5742 | // packages installed. `copyDirectoryStructure` recreates directories with | |
| 5743 | // makePath and overwrites regular files through createFile, both | |
| 5744 | // repeatable — but it created symlinks with a bare `symlink(2)`, which | |
| 5745 | // returns EEXIST over a destination that already holds the link. git is | |
| 5746 | // the most symlink-dense package in that cohort (its libexec is a wall of | |
| 5747 | // them), which is consistent with git alone failing. | |
| 5748 | // | |
| 5749 | // This does not claim to be the reported tree; it identifies a colliding | |
| 5750 | // operation on the implicated path and holds it shut. | |
| 5751 | if (.os.tag == .windows) return error.SkipZigTest; | |
| 5752 | const allocator = std.testing.allocator; | |
| 5753 | ||
| 5754 | var tmp = std.testing.tmpDir(.{}); | |
| 5755 | defer tmp.cleanup(); | |
| 5756 | var root_buf: [std.fs.max_path_bytes]u8 = undefined; | |
| 5757 | const root = root_buf[0..try tmp.dir.realPath(io_helper.io, &root_buf)]; | |
| 5758 | ||
| 5759 | const source = try std.fmt.allocPrint(allocator, "{s}/source/libexec", .{root}); | |
| 5760 | defer allocator.free(source); | |
| 5761 | const dest = try std.fmt.allocPrint(allocator, "{s}/prefix/libexec", .{root}); | |
| 5762 | defer allocator.free(dest); | |
| 5763 | try io_helper.makePath(source); | |
| 5764 | ||
| 5765 | const real = try std.fmt.allocPrint(allocator, "{s}/git", .{source}); | |
| 5766 | defer allocator.free(real); | |
| 5767 | const file = try io_helper.createFile(real, .{}); | |
| 5768 | try io_helper.writeAllToFile(file, "#!/bin/sh\nexit 0\n"); | |
| 5769 | io_helper.closeFile(file); | |
| 5770 | const link = try std.fmt.allocPrint(allocator, "{s}/git-receive-pack", .{source}); | |
| 5771 | defer allocator.free(link); | |
| 5772 | try io_helper.symLink("git", link); | |
| 5773 | ||
| 5774 | var pkg_cache = try PackageCache.init(allocator); | |
| 5775 | defer pkg_cache.deinit(); | |
| 5776 | var installer = try Installer.init(allocator, &pkg_cache); | |
| 5777 | defer installer.deinit(); | |
| 5778 | ||
| 5779 | try installer.copyDirectoryStructure(source, dest); | |
| 5780 | // The second pass is the restored-cache case: every entry is already | |
| 5781 | // there. Before the fix this returned error.PathAlreadyExists — asserted | |
| 5782 | // with expectError while diagnosing, which is how the operation was | |
| 5783 | // identified rather than inferred. | |
| 5784 | try installer.copyDirectoryStructure(source, dest); | |
| 5785 | ||
| 5786 | const copied_link = try std.fmt.allocPrint(allocator, "{s}/git-receive-pack", .{dest}); | |
| 5787 | defer allocator.free(copied_link); | |
| 5788 | const target = try io_helper.readLinkAlloc(allocator, copied_link); | |
| 5789 | defer allocator.free(target); | |
| 5790 | try std.testing.expectEqualStrings("git", target); | |
| 5791 | ||
| 5792 | // A link left pointing somewhere else — a half-written or older tree — | |
| 5793 | // must end up at the source's target, since the source is authoritative. | |
| 5794 | io_helper.deleteFile(copied_link) catch {}; | |
| 5795 | try io_helper.symLink("git-upload-pack", copied_link); | |
| 5796 | try installer.copyDirectoryStructure(source, dest); | |
| 5797 | const repaired = try io_helper.readLinkAlloc(allocator, copied_link); | |
| 5798 | defer allocator.free(repaired); | |
| 5799 | try std.testing.expectEqualStrings("git", repaired); | |
| 5800 | } | |