Files
UA-.NETStandard/fuzzing/OneFuzz/Opc.Ua.OneFuzz.Validator/ContractPaths.cs
Klaus Landsdorf f9a5e96600 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.
2026-10-03 05:42:35 +02:00

214 lines
8.3 KiB
C#

/* ========================================================================
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
namespace Opc.Ua.OneFuzz
{
internal static class ContractPaths
{
internal static string Resolve(string root, string relative)
{
ArgumentException.ThrowIfNullOrWhiteSpace(relative);
string[] segments = relative.Replace('\\', '/').Split('/');
if (Path.IsPathRooted(relative) ||
segments.Any(static part => part.Length == 0 || part is "." or ".." ||
part.IndexOfAny([':', '*', '?']) >= 0))
{
throw new InvalidDataException($"Expected a portable relative path, not '{relative}'.");
}
string current = Path.GetFullPath(root);
foreach (string segment in segments)
{
string next = Path.Combine(current, segment);
if (!Directory.EnumerateFileSystemEntries(current)
.Any(path => string.Equals(Path.GetFileName(path), segment, StringComparison.Ordinal)))
{
throw new FileNotFoundException(
$"Required path is missing or has incorrect case: {relative}", next);
}
if ((File.GetAttributes(next) & FileAttributes.ReparsePoint) != 0)
{
throw new InvalidDataException($"Links are not allowed in a relocatable drop: {relative}");
}
current = next;
}
return current;
}
internal static string Relative(string root, string path)
{
string relative = Path.GetRelativePath(root, path).Replace('\\', '/');
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}");
}
return relative;
}
internal static List<string> CorpusFiles(string root, TargetSpec target)
{
var result = new HashSet<string>(StringComparer.Ordinal);
foreach (string relative in target.Corpus)
{
string path = Resolve(root, relative);
if (File.Exists(path))
{
result.Add(path);
continue;
}
List<string> files = EnumerateFiles(path);
if (files.Count == 0)
{
throw new InvalidDataException($"Required corpus is empty for {target.Id}: {relative}");
}
result.UnionWith(files);
}
if (result.Count == 0)
{
throw new InvalidDataException($"Target {target.Id} has no corpus files.");
}
return [.. result.Order(StringComparer.Ordinal)];
}
internal static List<string> EnumerateFiles(string directory)
{
var files = new List<string>();
var pending = new Stack<string>();
pending.Push(directory);
while (pending.TryPop(out string? current))
{
foreach (string path in Directory.EnumerateFileSystemEntries(current))
{
FileAttributes attributes = File.GetAttributes(path);
if ((attributes & FileAttributes.ReparsePoint) != 0)
{
throw new InvalidDataException($"Links are not allowed in a relocatable drop: {path}");
}
if ((attributes & FileAttributes.Directory) != 0)
{
pending.Push(path);
}
else
{
files.Add(path);
}
}
}
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;
}
}