Skip to content

Commit e6de342

Browse files
authored
Patch the file a symlinked source names (#845)
* Patch the file a symlinked source names The applier rewrites the whole file through a temporary and swaps it in with File.Replace, which on Linux and macOS is a rename. A rename replaces the name it is given, so for a source reached through a symlink - a worktree, a shared checkout, a vendored copy - the link itself was replaced by a regular file: the link stopped being one, and the file it named still held the snapshot that had failed. Every later run reported the same snapshot again, against source the compiler was still reading from the target. The link is followed before anything else, so the lock, the mutex, the read and the swap all name the file that actually holds the source. Two links to one file get the same lock as a side effect. The rename also leaves the temporary's permissions on the file. A source that was executable, or group writable, came back as whatever this process's umask said, so the destination's mode is carried onto the temporary first. Windows keeps the destination's ACLs across a Replace and needs none of this. Both tests are Unix only: a symlink needs elevation on Windows and a file mode is not a thing it has. The two behaviours behind them were confirmed against rename(2) directly. * Restrict the Unix tests with TUnit's own RunOn
1 parent cde27f5 commit e6de342

2 files changed

Lines changed: 145 additions & 1 deletion

File tree

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
#if NET10_0
2+
/// <summary>
3+
/// What the applier does to the file itself, rather than to the text in it. The patch is written
4+
/// through a temporary and swapped in, and on Linux and macOS that swap is a rename - so what
5+
/// survives it is the temporary, with the temporary's identity.
6+
/// </summary>
7+
public class InlineApplierUnixTests :
8+
IDisposable
9+
{
10+
const string source = "class C\n{\n void M() => Verify(value).Snapshot(\"old\");\n}";
11+
12+
/// <summary>
13+
/// A source file reached through a symlink - a worktree, a vendored copy, a checkout shared
14+
/// between two trees. The rename replaced the link with a regular file: the link stopped being
15+
/// one, and the file it pointed at still held the old literal, so the next run reported the
16+
/// same snapshot again and the patched copy was invisible to the compiler.
17+
/// </summary>
18+
[Test]
19+
// A symlink on Windows needs elevation or developer mode, so this cannot be arranged there.
20+
[RunOn(TUnit.Core.Enums.OS.Linux | TUnit.Core.Enums.OS.MacOs)]
21+
public async Task A_symlinked_source_is_followed_to_the_file_it_names()
22+
{
23+
var real = Path.Combine(directory, "Real.cs");
24+
File.WriteAllText(real, source);
25+
var link = Path.Combine(directory, "Link.cs");
26+
File.CreateSymbolicLink(link, real);
27+
28+
var result = InlineApplier.Apply(Patch(link));
29+
30+
await Assert.That(result.Status).IsEqualTo(InlineApplyStatus.Applied);
31+
await Assert.That(File.ReadAllText(real)).Contains("\"new\"");
32+
// Still a link, rather than a regular file holding the patch while the real one holds the
33+
// snapshot that failed
34+
await Assert.That(new FileInfo(link).LinkTarget).IsNotNull();
35+
}
36+
37+
/// <summary>
38+
/// The temporary is created with this process's umask, so without carrying the mode across, a
39+
/// source file that was executable - or group writable, or read only to the world - came back
40+
/// as whatever the umask said.
41+
/// </summary>
42+
[Test]
43+
// A Unix file mode is not a thing Windows has.
44+
[RunOn(TUnit.Core.Enums.OS.Linux | TUnit.Core.Enums.OS.MacOs)]
45+
public async Task The_file_keeps_the_permissions_it_had()
46+
{
47+
var path = Path.Combine(directory, "Sample.cs");
48+
File.WriteAllText(path, source);
49+
const UnixFileMode mode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
50+
UnixFileMode.GroupRead | UnixFileMode.GroupExecute |
51+
UnixFileMode.OtherRead | UnixFileMode.OtherExecute;
52+
File.SetUnixFileMode(path, mode);
53+
54+
var result = InlineApplier.Apply(Patch(path));
55+
56+
await Assert.That(result.Status).IsEqualTo(InlineApplyStatus.Applied);
57+
await Assert.That(File.GetUnixFileMode(path)).IsEqualTo(mode);
58+
}
59+
60+
/// <summary>
61+
/// Nothing here queues a patch, so none of them has a reviewable identity.
62+
/// </summary>
63+
static InlinePatch Patch(string sourceFile) =>
64+
new(sourceFile, 3, "\"old\"", "new")
65+
{
66+
TestName = null
67+
};
68+
69+
public InlineApplierUnixTests()
70+
{
71+
directory = Path.Combine(Path.GetTempPath(), $"InlineApplierUnixTests_{Guid.NewGuid():N}");
72+
Directory.CreateDirectory(directory);
73+
}
74+
75+
public void Dispose() =>
76+
Directory.Delete(directory, true);
77+
78+
readonly string directory;
79+
}
80+
#endif

src/DiffEngine/Inline/InlineApplier.cs

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
namespace DiffEngine;
1+
namespace DiffEngine;
22

33
/// <summary>
44
/// Applies an <see cref="InlinePatch"/> to a source file, preserving the file's
@@ -56,6 +56,10 @@ static InlineApplyResult Run(InlinePatch patch, bool write)
5656
return InlineApplyResult.Failed($"Invalid InlinePatch.SourceFile: {patch.SourceFile}", exception);
5757
}
5858

59+
// Followed before anything else, so the lock, the mutex, the read and the swap all name
60+
// the file that actually holds the source
61+
fullPath = ResolveLink(fullPath);
62+
5963
var newContent = SourceLanguage.NormalizeNewlines(patch.NewContent);
6064
var normalizedPath = fullPath.ToLowerInvariant();
6165
lock (gates.GetOrAdd(normalizedPath, static _ => new()))
@@ -199,6 +203,65 @@ static InlineApplyResult LockedApply(string fullPath, InlinePatch patch, string
199203
/// framework this targets.
200204
/// </para>
201205
/// </summary>
206+
/// <summary>
207+
/// The file a symlinked source points at, which is the file to patch.
208+
/// <para>
209+
/// The whole file is rewritten through a temporary and swapped in, and on Linux and macOS that
210+
/// swap is a rename: it replaces the link itself with a regular file, leaving the target still
211+
/// holding the old literal and the link no longer a link. Following it first puts the patch on
212+
/// the real file, and gives two links to one file the same lock into the bargain.
213+
/// </para>
214+
/// <para>
215+
/// The final target rather than one hop, since a chain has the same problem, and the path as
216+
/// it stands when nothing resolves: a broken link is a file that cannot be read, which the
217+
/// read reports better than this could.
218+
/// </para>
219+
/// </summary>
220+
static string ResolveLink(string path)
221+
{
222+
#if NET6_0_OR_GREATER
223+
try
224+
{
225+
return File.ResolveLinkTarget(path, true)?.FullName ?? path;
226+
}
227+
catch (Exception exception)
228+
when (exception is IOException or UnauthorizedAccessException)
229+
{
230+
return path;
231+
}
232+
#else
233+
return path;
234+
#endif
235+
}
236+
237+
/// <summary>
238+
/// The destination's Unix permissions onto the temporary, because the swap is a rename and the
239+
/// file that survives it is the temporary - created with this process's umask. A source file
240+
/// that was executable, or group writable, or anything else out of the ordinary, came back as
241+
/// whatever the umask happened to say. Windows keeps the destination's ACLs across a Replace,
242+
/// so there is nothing to carry there.
243+
/// </summary>
244+
static void CopyMode(string destination, string temporary)
245+
{
246+
#if NET7_0_OR_GREATER
247+
if (OperatingSystem.IsWindows())
248+
{
249+
return;
250+
}
251+
252+
try
253+
{
254+
File.SetUnixFileMode(temporary, File.GetUnixFileMode(destination));
255+
}
256+
catch (Exception exception)
257+
when (exception is IOException or UnauthorizedAccessException)
258+
{
259+
// Best effort. The content is the point, and a mode that could not be read or set is
260+
// not worth failing a patch that otherwise applied.
261+
}
262+
#endif
263+
}
264+
202265
static void WriteThroughTemporary(string fullPath, byte[] output)
203266
{
204267
var directory = Path.GetDirectoryName(fullPath)!;
@@ -208,6 +271,7 @@ static void WriteThroughTemporary(string fullPath, byte[] output)
208271
try
209272
{
210273
File.WriteAllBytes(temporary, output);
274+
CopyMode(fullPath, temporary);
211275
File.Replace(temporary, fullPath, null);
212276
}
213277
finally

0 commit comments

Comments
 (0)