OneFuzz validator: accept drop files reported through resolved links (#4640)

# Description

`ContractPaths.Relative` now also compares a path that seems to escape
against the root's real location, with every link along it resolved.
Links inside the drop are still rejected, because every caller resolves
the returned relative path segment by segment.

The new contract test
`DropReachedThroughALinkedDirectoryIsValidatedAsync` validates a drop
through a directory link and fails on every platform without the fix.
Its link is created like the one in the existing link test (a junction
on Windows, a symlink elsewhere), now through one shared helper, so it
needs no privileges and is never skipped.

Tests:

- On macOS, master passes 53 of 209 contract tests; with the fix 210/210
on net10.0 (Release, 0 skipped).
- Fork CI (full matrix incl. macOS) and CodeQL: green on `358eb33d5`
(the same change on master `029ac0f33`) — [CI run
36892610480](https://github.com/biancode/UA-.NETStandard/actions/runs/36892610480)
(189 jobs), [CodeQL run
36892614293](https://github.com/biancode/UA-.NETStandard/actions/runs/36892614293).
The branch is now on a newer master with no overlapping files; the run
on the current head is in progress: [CI run
37016188861](https://github.com/biancode/UA-.NETStandard/actions/runs/37016188861).

## Related Issues

- Fixes #4639

## Checklist

- [x] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed. (Affected
suites on net10.0 and net8.0 locally on macOS; .NET Framework is covered
by the fork CI's Windows net48 legs.)
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.
This commit is contained in:
Klaus Landsdorf
2026-10-03 05:42:35 +02:00
committed by GitHub
parent d98eb6b28c
commit f9a5e96600
2 changed files with 118 additions and 17 deletions
@@ -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);
}
}
/// <summary>
/// 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.
/// </summary>
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);
}
}
}
}
@@ -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;
}
/// <summary>
/// Gets whether a relative path leaves the directory it is relative to.
/// </summary>
private static bool Escapes(string relative)
{
return relative == ".." ||
relative.StartsWith("../", StringComparison.Ordinal) ||
Path.IsPathRooted(relative);
}
/// <summary>
/// Returns the path of a directory with every symbolic link along it
/// resolved, the way the host reports the files below it.
/// </summary>
/// <exception cref="InvalidDataException">
/// The links along the path do not resolve within a bounded number of hops.
/// </exception>
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;
}
}