diff --git a/fuzzing/OneFuzz/Opc.Ua.OneFuzz.Validator.Tests/ValidatorContractTests.Paths.cs b/fuzzing/OneFuzz/Opc.Ua.OneFuzz.Validator.Tests/ValidatorContractTests.Paths.cs
index 1536aa31b8..fbfe270e1f 100644
--- a/fuzzing/OneFuzz/Opc.Ua.OneFuzz.Validator.Tests/ValidatorContractTests.Paths.cs
+++ b/fuzzing/OneFuzz/Opc.Ua.OneFuzz.Validator.Tests/ValidatorContractTests.Paths.cs
@@ -33,6 +33,29 @@ namespace Opc.Ua.OneFuzz.Validator.Tests
.ConfigureAwait(false);
}
+ [Test]
+ public async Task DropReachedThroughALinkedDirectoryIsValidatedAsync()
+ {
+ // The host reports dependency paths with links resolved (macOS reports
+ // /private/var for /var, where every temporary drop lives), so a drop
+ // addressed through a link must still recognise its own files.
+ using var drop = new ContractDrop(m_fixture);
+ drop.Save();
+ string link = Path.Combine(drop.Home, "linked drop");
+ await CreateDirectoryLinkAsync(drop, link, drop.Root).ConfigureAwait(false);
+ try
+ {
+ ProcessResult result = await RunAsync(
+ drop, "validate", "--drop", link, "--results", drop.Results).ConfigureAwait(false);
+
+ AssertCompleted(drop, result, hasConfig: true);
+ }
+ finally
+ {
+ Directory.Delete(link);
+ }
+ }
+
[Test]
public async Task MissingExactSeedCannotBeReplacedByANeighbouringFileOrAnotherBucketAsync()
{
@@ -138,21 +161,7 @@ namespace Opc.Ua.OneFuzz.Validator.Tests
string relative = nested ? ContractDrop.FirstBucket + "/linked seeds" : "linked corpus";
string link = drop.PathFor(relative);
string destination = drop.PathFor(ContractDrop.SecondBucket);
- if (OperatingSystem.IsWindows())
- {
- // An NTFS directory junction does not require symlink privileges or
- // Developer Mode. It still exercises the reparse-point contract.
- ProcessResult creation = await ProcessRunner.RunAsync(
- Environment.GetEnvironmentVariable("COMSPEC") ?? "cmd.exe",
- ["/d", "/c", "mklink", "/J", link, destination],
- drop.Home,
- TimeSpan.FromSeconds(15)).ConfigureAwait(false);
- AssertSuccess(creation);
- }
- else
- {
- Directory.CreateSymbolicLink(link, destination);
- }
+ await CreateDirectoryLinkAsync(drop, link, destination).ConfigureAwait(false);
try
{
@@ -171,5 +180,27 @@ namespace Opc.Ua.OneFuzz.Validator.Tests
Directory.Delete(link);
}
}
+
+ ///
+ /// Creates a directory link: an NTFS directory junction on Windows, which
+ /// does not require symlink privileges or Developer Mode and still
+ /// exercises the reparse-point contract, and a symbolic link elsewhere.
+ ///
+ private static async Task CreateDirectoryLinkAsync(ContractDrop drop, string link, string destination)
+ {
+ if (OperatingSystem.IsWindows())
+ {
+ ProcessResult creation = await ProcessRunner.RunAsync(
+ Environment.GetEnvironmentVariable("COMSPEC") ?? "cmd.exe",
+ ["/d", "/c", "mklink", "/J", link, destination],
+ drop.Home,
+ TimeSpan.FromSeconds(15)).ConfigureAwait(false);
+ AssertSuccess(creation);
+ }
+ else
+ {
+ Directory.CreateSymbolicLink(link, destination);
+ }
+ }
}
}
diff --git a/fuzzing/OneFuzz/Opc.Ua.OneFuzz.Validator/ContractPaths.cs b/fuzzing/OneFuzz/Opc.Ua.OneFuzz.Validator/ContractPaths.cs
index 4aca6d2dd5..2067375429 100644
--- a/fuzzing/OneFuzz/Opc.Ua.OneFuzz.Validator/ContractPaths.cs
+++ b/fuzzing/OneFuzz/Opc.Ua.OneFuzz.Validator/ContractPaths.cs
@@ -72,8 +72,22 @@ namespace Opc.Ua.OneFuzz
internal static string Relative(string root, string path)
{
string relative = Path.GetRelativePath(root, path).Replace('\\', '/');
- if (relative == ".." || relative.StartsWith("../", StringComparison.Ordinal) ||
- Path.IsPathRooted(relative))
+ if (Escapes(relative))
+ {
+ // The host resolves symbolic links in the paths it reports (the
+ // dependency resolver answers /private/var/... on macOS, where /var
+ // is a link), while the drop root is the path it was given. Compare
+ // against the root's real location too; links inside the drop are
+ // still rejected, because every caller resolves the result segment
+ // by segment through Resolve.
+ string realRoot = RealDirectory(root);
+ if (!string.Equals(realRoot, Path.GetFullPath(root), StringComparison.Ordinal))
+ {
+ relative = Path.GetRelativePath(realRoot, path).Replace('\\', '/');
+ }
+ }
+
+ if (Escapes(relative))
{
throw new InvalidDataException($"Dependency escaped the published drop: {path}");
}
@@ -139,5 +153,61 @@ namespace Opc.Ua.OneFuzz
files.Sort(StringComparer.Ordinal);
return files;
}
+
+ ///
+ /// Gets whether a relative path leaves the directory it is relative to.
+ ///
+ private static bool Escapes(string relative)
+ {
+ return relative == ".." ||
+ relative.StartsWith("../", StringComparison.Ordinal) ||
+ Path.IsPathRooted(relative);
+ }
+
+ ///
+ /// Returns the path of a directory with every symbolic link along it
+ /// resolved, the way the host reports the files below it.
+ ///
+ ///
+ /// The links along the path do not resolve within a bounded number of hops.
+ ///
+ private static string RealDirectory(string directory)
+ {
+ string full = Path.GetFullPath(directory);
+ for (int hops = 0; hops < kMaxLinkHops; hops++)
+ {
+ string? linked = null;
+ string current = Path.GetPathRoot(full) ?? string.Empty;
+ string[] segments = full[current.Length..].Split(
+ Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries);
+ for (int ii = 0; ii < segments.Length && linked == null; ii++)
+ {
+ string next = Path.Combine(current, segments[ii]);
+ FileSystemInfo? target = Directory.Exists(next)
+ ? new DirectoryInfo(next).ResolveLinkTarget(returnFinalTarget: true)
+ : null;
+ if (target == null)
+ {
+ current = next;
+ continue;
+ }
+
+ // A link target may itself run through links (a target below
+ // /var on macOS), so resolution restarts on the rewritten path.
+ linked = Path.GetFullPath(Path.Combine([target.FullName, .. segments[(ii + 1)..]]));
+ }
+
+ if (linked == null)
+ {
+ return current;
+ }
+
+ full = linked;
+ }
+
+ throw new InvalidDataException($"Too many levels of links below the drop root: {directory}");
+ }
+
+ private const int kMaxLinkHops = 40;
}
}