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; } }