Files
Marc Schier f0ab7878ea Core stack: reusable file directory binder, geolocation seam and state machines (#4123)
**Stack 1/5** — base `master`. Part of the #4096 split; see #4096 for
the overall picture.

## What this is

The core stack changes the Robotics, OpenUSD and Positioning work
depends on. No new projects; every change is to an existing one, so this
is reviewable without any of the companion specs.

## Contents

**`Opc.Ua.Server`**
- `FileSystem` gains a reusable `IFileDirectoryBinder` and
`IFileSystemHost`, so any node manager can serve a Part 5
`FileDirectoryType` over `IFileSystemProvider` instead of that binding
living inside one companion model.
- Entry names are validated before they reach the provider. The contract
promises forward-slash provider-relative paths but never enforced it, so
a backslash payload such as `..\..\..\Windows\System32\evil.txt`
bypassed the obvious provider-side check on Windows. Defence in depth —
the shipped `PhysicalFileSystemProvider` was already safe.
- `Location` adds a single `IGeoLocationProvider` seam so one provider
implementation serves both OPC 10000-211 GPOS and OPC 10030 ISA-95. The
two specs disagree on representation, not on content, so the difference
is a projection rather than a reason to write the provider twice. Ships
a WKT text formatter and an in-memory provider.
- `StateMachines` adds `FiniteStateMachineDispatcher`, the Part 16
transition guard used by DI software update and later by Robotics task
control.

**`Opc.Ua.Types`**
- Node copies no longer consume NodeIds from the `NodeIdFactory`.
`Initialize` materialises children through `CreateChild`, which assigns
a per-instance NodeId, but the copy then overwrites every child NodeId
from its source, so the assignment is discarded. A counting factory
silently burned identifiers; a tracking one leaked them.
- `JsonDecoder` resolves a relative encodeable type id against the
namespace table before the factory lookup.

**`Opc.Ua.Client`** — a file system path segment written without a
namespace prefix is qualified with the namespace of the parent it is
resolved against, matching the provider-owned BrowseNames.

**`Directory.Build.targets`** — the `RestrictForLegacyTfm` no-op now
also applies when a project pins a single `TargetFramework` and
`CustomTestTarget` names a different one. That is the same situation it
already handled for the legacy frameworks, and it is what keeps the
per-TFM CI matrix green once net10.0-only projects appear later in the
stack.

## Validation

`dotnet build UA.slnx` — 0 errors.

| Suite | Result |
| --- | --- |
| Opc.Ua.Types.Tests | 8088 / 0 |
| Opc.Ua.Server.Tests | 3873 / 1 |
| Opc.Ua.Client.Tests | 2105 / 0 |
| Opc.Ua.Core.Tests | 4145 / 0 |
| Opc.Ua.Features.Tests | 134 / 0 |

The single `Opc.Ua.Server.Tests` failure is
`ConfigureApplicationBuildsSharedClientAndServerConfigurationAsync`,
which is pre-existing and environmental: the PKI path in my working
directory exceeds MAX_PATH. It reproduces on clean `master` and passes
from a shorter directory.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8cbb8cd0-f0cb-4ab0-bea2-6202fbf69485
2026-07-31 13:13:47 +02:00

866 lines
33 KiB
C#

/* ========================================================================
* Copyright (c) 2005-2025 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/
* ======================================================================*/
#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using NUnit.Framework;
using Opc.Ua.Server.FileSystem;
using Opc.Ua.Server.Fluent;
using Opc.Ua.Server.Hosting;
using Opc.Ua.Tests;
namespace Opc.Ua.Server.Tests.FileSystem
{
/// <summary>
/// Tests for materialised FileDirectoryType bindings.
/// </summary>
[TestFixture]
[Category("FileSystem")]
public class FileDirectoryBinderTests
{
[Test]
public async Task BindMaterialisesFilesAndDirectoriesWithoutInitialRegistrationsAsync()
{
InMemoryFileSystemProvider provider = CreateProvider();
provider.AddFile("readme.txt", "hello");
provider.AddDirectory("programs");
FileDirectoryState root = CreateRoot();
SessionSystemContext context = CreateContext();
var registered = new List<NodeState>();
await using IFileDirectoryBinding binding = await CreateBinder().BindAsync(
root,
provider,
context,
registerNode: (node, _) =>
{
registered.Add(node);
return default;
}).ConfigureAwait(false);
Assert.That(binding.Directory, Is.SameAs(root));
Assert.That(binding.Provider, Is.SameAs(provider));
Assert.That(Find<FileState>(root, context, "readme.txt"), Is.Not.Null);
Assert.That(Find<FileDirectoryState>(root, context, "programs"), Is.Not.Null);
Assert.That(registered, Is.Empty);
}
[Test]
public async Task ReadFileReturnsProviderContentAsync()
{
InMemoryFileSystemProvider provider = CreateProvider();
provider.AddFile("program.mod", "movej");
FileDirectoryState root = CreateRoot();
SessionSystemContext context = CreateContext();
await using IFileDirectoryBinding binding = await CreateBinder().BindAsync(
root, provider, context).ConfigureAwait(false);
FileState file = Find<FileState>(root, context, "program.mod")!;
byte[] content = ReadAll(context, file);
Assert.That(Encoding.UTF8.GetString(content), Is.EqualTo("movej"));
}
[Test]
public async Task WriteFileUpdatesProviderContentAsync()
{
InMemoryFileSystemProvider provider = CreateProvider();
provider.AddFile("program.mod", "old");
FileDirectoryState root = CreateRoot();
SessionSystemContext context = CreateContext();
await using IFileDirectoryBinding binding = await CreateBinder().BindAsync(
root, provider, context).ConfigureAwait(false);
FileState file = Find<FileState>(root, context, "program.mod")!;
WriteAll(context, file, "new-content");
Assert.That(provider.ReadText("program.mod"), Is.EqualTo("new-content"));
}
[Test]
public async Task CreateReturnsAccessDeniedWhenProviderIsReadOnlyAsync()
{
InMemoryFileSystemProvider provider = CreateProvider(isWritable: false);
FileDirectoryState root = CreateRoot();
SessionSystemContext context = CreateContext();
await using IFileDirectoryBinding binding = await CreateBinder().BindAsync(
root, provider, context).ConfigureAwait(false);
CreateFileMethodStateResult result = await root.CreateFile!.OnCallAsync!(
context, root.CreateFile!, root.NodeId, "denied.txt", false, CancellationToken.None)
.ConfigureAwait(false);
Assert.That(result.ServiceResult.StatusCode, Is.EqualTo(StatusCodes.BadUserAccessDenied));
}
[Test]
public async Task CreateReturnsAccessDeniedWhenOptionsWithholdCreateAsync()
{
InMemoryFileSystemProvider provider = CreateProvider();
FileDirectoryState root = CreateRoot();
SessionSystemContext context = CreateContext();
var options = new FileDirectoryBindingOptions { AllowCreate = false };
await using IFileDirectoryBinding binding = await CreateBinder().BindAsync(
root, provider, context, options).ConfigureAwait(false);
CreateDirectoryMethodStateResult result = await root.CreateDirectory!.OnCallAsync!(
context, root.CreateDirectory!, root.NodeId, "denied", CancellationToken.None)
.ConfigureAwait(false);
Assert.That(result.ServiceResult.StatusCode, Is.EqualTo(StatusCodes.BadUserAccessDenied));
}
[Test]
[TestCase("../escape")]
[TestCase("..\\escape")]
[TestCase("sub/child")]
[TestCase("sub\\child")]
[TestCase("/rooted")]
[TestCase("\\rooted")]
[TestCase("C:\\Windows\\System32\\evil")]
[TestCase("..")]
[TestCase(".")]
[TestCase("stream:name")]
[TestCase(" ")]
public async Task CreateRejectsANameThatIsNotASingleSegmentAsync(string name)
{
InMemoryFileSystemProvider provider = CreateProvider();
FileDirectoryState root = CreateRoot();
SessionSystemContext context = CreateContext();
await using IFileDirectoryBinding binding = await CreateBinder().BindAsync(
root, provider, context).ConfigureAwait(false);
CreateDirectoryMethodStateResult directoryResult =
await root.CreateDirectory!.OnCallAsync!(
context, root.CreateDirectory!, root.NodeId, name, CancellationToken.None)
.ConfigureAwait(false);
CreateFileMethodStateResult fileResult = await root.CreateFile!.OnCallAsync!(
context, root.CreateFile!, root.NodeId, name, false, CancellationToken.None)
.ConfigureAwait(false);
Assert.Multiple(() =>
{
Assert.That(
directoryResult.ServiceResult.StatusCode,
Is.EqualTo(StatusCodes.BadInvalidArgument));
Assert.That(
fileResult.ServiceResult.StatusCode,
Is.EqualTo(StatusCodes.BadInvalidArgument));
});
}
[Test]
public async Task MoveOrCopyRejectsANewNameThatIsNotASingleSegmentAsync()
{
InMemoryFileSystemProvider provider = CreateProvider();
provider.AddDirectory("target");
provider.AddFile("old.txt", "x");
FileDirectoryState root = CreateRoot();
SessionSystemContext context = CreateContext();
await using IFileDirectoryBinding binding = await CreateBinder().BindAsync(
root, provider, context).ConfigureAwait(false);
NodeId sourceId = Find<FileState>(root, context, "old.txt")!.NodeId;
NodeId targetId = Find<FileDirectoryState>(root, context, "target")!.NodeId;
MoveOrCopyMethodStateResult result = await root.MoveOrCopy!.OnCallAsync!(
context,
root.MoveOrCopy!,
root.NodeId,
sourceId,
targetId,
false,
"..\\..\\escape",
CancellationToken.None).ConfigureAwait(false);
Assert.That(
result.ServiceResult.StatusCode,
Is.EqualTo(StatusCodes.BadInvalidArgument));
}
[Test]
public async Task CreateDeleteAndMoveReconcileMaterialisedNodesAsync()
{
InMemoryFileSystemProvider provider = CreateProvider();
provider.AddDirectory("target");
provider.AddFile("old.txt", "x");
FileDirectoryState root = CreateRoot();
SessionSystemContext context = CreateContext();
var registered = new List<NodeState>();
await using IFileDirectoryBinding binding = await CreateBinder().BindAsync(
root,
provider,
context,
registerNode: (node, _) =>
{
registered.Add(node);
return default;
}).ConfigureAwait(false);
CreateFileMethodStateResult createResult = await root.CreateFile!.OnCallAsync!(
context, root.CreateFile!, root.NodeId, "new.txt", false, CancellationToken.None)
.ConfigureAwait(false);
FileDirectoryState target = Find<FileDirectoryState>(root, context, "target")!;
FileState oldFile = Find<FileState>(root, context, "old.txt")!;
MoveOrCopyMethodStateResult moveResult = await root.MoveOrCopy!.OnCallAsync!(
context, root.MoveOrCopy!, root.NodeId, oldFile.NodeId, target.NodeId, false, "moved.txt",
CancellationToken.None).ConfigureAwait(false);
FileState newFile = Find<FileState>(root, context, "new.txt")!;
DeleteFileMethodStateResult deleteResult = await root.DeleteFileSystemObject!.OnCallAsync!(
context, root.DeleteFileSystemObject!, root.NodeId, newFile.NodeId, CancellationToken.None)
.ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(createResult.ServiceResult), Is.True);
Assert.That(ServiceResult.IsGood(moveResult.ServiceResult), Is.True);
Assert.That(ServiceResult.IsGood(deleteResult.ServiceResult), Is.True);
Assert.That(provider.Exists("new.txt"), Is.False);
Assert.That(provider.Exists("target/moved.txt"), Is.True);
Assert.That(Find<FileState>(root, context, "new.txt"), Is.Null);
Assert.That(Find<FileState>(target, context, "moved.txt"), Is.Not.Null);
Assert.That(registered, Is.Not.Empty);
}
[Test]
public void MaxEntriesThrowsBeforeMaterialisingOverflow()
{
InMemoryFileSystemProvider provider = CreateProvider();
provider.AddFile("a.txt", "a");
provider.AddFile("b.txt", "b");
FileDirectoryState root = CreateRoot();
SessionSystemContext context = CreateContext();
var options = new FileDirectoryBindingOptions { MaxEntries = 1 };
Assert.That(
async () => await CreateBinder().BindAsync(root, provider, context, options).ConfigureAwait(false),
Throws.TypeOf<ServiceResultException>());
Assert.That(Find<FileState>(root, context, "a.txt"), Is.Null);
}
[Test]
public async Task MaxDepthStopsNestedMaterialisationAsync()
{
InMemoryFileSystemProvider provider = CreateProvider();
provider.AddDirectory("level1");
provider.AddFile("level1/deep.txt", "deep");
FileDirectoryState root = CreateRoot();
SessionSystemContext context = CreateContext();
var options = new FileDirectoryBindingOptions { MaxDepth = 1 };
await using IFileDirectoryBinding binding = await CreateBinder().BindAsync(
root, provider, context, options).ConfigureAwait(false);
FileDirectoryState level1 = Find<FileDirectoryState>(root, context, "level1")!;
Assert.That(level1, Is.Not.Null);
Assert.That(Find<FileState>(level1, context, "deep.txt"), Is.Null);
}
[Test]
public async Task RefreshAddsAndRemovesChildrenAsync()
{
InMemoryFileSystemProvider provider = CreateProvider();
provider.AddFile("old.txt", "old");
FileDirectoryState root = CreateRoot();
SessionSystemContext context = CreateContext();
await using IFileDirectoryBinding binding = await CreateBinder().BindAsync(
root, provider, context).ConfigureAwait(false);
provider.Delete("old.txt");
provider.AddFile("new.txt", "new");
await binding.RefreshAsync().ConfigureAwait(false);
Assert.That(Find<FileState>(root, context, "old.txt"), Is.Null);
Assert.That(Find<FileState>(root, context, "new.txt"), Is.Not.Null);
}
[Test]
public async Task DisposeClosesHandlesAndDetachesCallbacksAsync()
{
InMemoryFileSystemProvider provider = CreateProvider();
provider.AddFile("open.txt", "content");
FileDirectoryState root = CreateRoot();
SessionSystemContext context = CreateContext();
IFileDirectoryBinding binding = await CreateBinder().BindAsync(root, provider, context).ConfigureAwait(false);
FileState file = Find<FileState>(root, context, "open.txt")!;
_ = Open(context, file, 0x1);
await binding.DisposeAsync().ConfigureAwait(false);
Assert.That(provider.OpenStreamCount, Is.Zero);
Assert.That(file.Close!.OnCall, Is.Null);
Assert.That(root.CreateFile!.OnCallAsync!, Is.Null);
}
[Test]
public void AddFileDirectoryBinderRegistersDefaultBinder()
{
var services = new ServiceCollection();
services.AddSingleton<ITelemetryContext>(NUnitTelemetryContext.Create());
IOpcUaServerBuilder builder = new TestServerBuilder(services);
builder.AddFileDirectoryBinder();
using ServiceProvider provider = services.BuildServiceProvider();
Assert.That(provider.GetService<IFileDirectoryBinder>(), Is.TypeOf<FileDirectoryBinder>());
}
private static FileDirectoryBinder CreateBinder()
{
return new FileDirectoryBinder();
}
private static SessionSystemContext CreateContext()
{
var namespaceUris = new NamespaceTable();
namespaceUris.Append("http://opcfoundation.org/UA/");
namespaceUris.Append("urn:test");
return new SessionSystemContext(NUnitTelemetryContext.Create())
{
NamespaceUris = namespaceUris,
ServerUris = new StringTable(),
TypeTable = new TypeTable(namespaceUris),
SessionId = new NodeId("test-session", 0)
};
}
private static FileDirectoryState CreateRoot()
{
return new FileDirectoryState(null)
{
TypeDefinitionId = ObjectTypeIds.FileDirectoryType,
NodeId = new NodeId("Programs", 2),
BrowseName = new QualifiedName("Programs", 2),
DisplayName = new LocalizedText("Programs")
};
}
private static T? Find<T>(FileDirectoryState directory, ISystemContext context, string browseName)
where T : BaseInstanceState
{
return directory.FindChild(context, new QualifiedName(browseName, directory.BrowseName.NamespaceIndex)) as T;
}
private static uint Open(ISystemContext context, FileState file, byte mode)
{
uint handle = 0;
ServiceResult result = file.Open!.OnCall!(context, file.Open!, file.NodeId, mode, ref handle);
Assert.That(ServiceResult.IsGood(result), Is.True);
return handle;
}
private static byte[] ReadAll(ISystemContext context, FileState file)
{
uint handle = Open(context, file, 0x1);
ByteString data = ByteString.From([]);
ServiceResult readResult = file.Read!.OnCall!(context, file.Read!, file.NodeId, handle, 1024, ref data);
ServiceResult closeResult = file.Close!.OnCall!(context, file.Close!, file.NodeId, handle);
Assert.That(ServiceResult.IsGood(readResult), Is.True);
Assert.That(ServiceResult.IsGood(closeResult), Is.True);
return data.ToArray();
}
private static void WriteAll(ISystemContext context, FileState file, string text)
{
uint handle = Open(context, file, 0x6);
ServiceResult writeResult = file.Write!.OnCall!(
context, file.Write!, file.NodeId, handle, ByteString.From(Encoding.UTF8.GetBytes(text)));
ServiceResult closeResult = file.Close!.OnCall!(context, file.Close!, file.NodeId, handle);
Assert.That(ServiceResult.IsGood(writeResult), Is.True);
Assert.That(ServiceResult.IsGood(closeResult), Is.True);
}
private static InMemoryFileSystemProvider CreateProvider(bool isWritable = true)
{
return new InMemoryFileSystemProvider(isWritable);
}
private sealed class TestServerBuilder : IOpcUaServerBuilder
{
public TestServerBuilder(IServiceCollection services)
{
Services = services;
}
public IServiceCollection Services { get; }
public IOpcUaServerBuilder AddNodeManager<TFactory>()
where TFactory : class, IAsyncNodeManagerFactory
{
throw new NotSupportedException();
}
public IOpcUaServerBuilder AddNodeManager(string namespaceUri, Action<INodeManagerBuilder> build)
{
throw new NotSupportedException();
}
public IOpcUaServerBuilder AddSyncNodeManager<TFactory>()
where TFactory : class, INodeManagerFactory
{
throw new NotSupportedException();
}
}
private sealed class InMemoryFileSystemProvider : IFileSystemProvider
{
public InMemoryFileSystemProvider(bool isWritable)
{
IsWritable = isWritable;
m_entries[string.Empty] = Entry.Directory("Programs");
}
public string MountName => "Programs";
public bool IsWritable { get; }
public int OpenStreamCount
{
get
{
lock (m_lock)
{
return m_openStreamCount;
}
}
}
public ValueTask<FileSystemEntry?> GetEntryAsync(string path, CancellationToken ct)
{
lock (m_lock)
{
return new ValueTask<FileSystemEntry?>(
m_entries.TryGetValue(Normalize(path), out Entry? entry)
? entry.ToFileSystemEntry(Normalize(path), IsWritable)
: null);
}
}
public async IAsyncEnumerable<FileSystemEntry> EnumerateAsync(
string path,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct)
{
await Task.CompletedTask.ConfigureAwait(false);
List<FileSystemEntry> entries;
string parent = Normalize(path);
lock (m_lock)
{
if (!m_entries.TryGetValue(parent, out Entry? parentEntry) || !parentEntry.IsDirectory)
{
throw new DirectoryNotFoundException(parent);
}
entries = m_entries
.Where(kv => IsImmediateChild(parent, kv.Key))
.OrderBy(kv => kv.Key, StringComparer.Ordinal)
.Select(kv => kv.Value.ToFileSystemEntry(kv.Key, IsWritable))
.ToList();
}
foreach (FileSystemEntry entry in entries)
{
ct.ThrowIfCancellationRequested();
yield return entry;
}
}
public ValueTask<Stream> OpenReadAsync(string path, CancellationToken ct)
{
lock (m_lock)
{
string normalized = Normalize(path);
if (!m_entries.TryGetValue(normalized, out Entry? entry) || entry.IsDirectory)
{
throw new FileNotFoundException(normalized);
}
m_openStreamCount++;
return new ValueTask<Stream>(new TrackingReadStream(
entry.Content.ToArray(),
() =>
{
lock (m_lock)
{
m_openStreamCount--;
}
}));
}
}
public ValueTask<Stream> OpenWriteAsync(string path, FileWriteMode mode, CancellationToken ct)
{
if (!IsWritable)
{
throw new UnauthorizedAccessException();
}
lock (m_lock)
{
string normalized = Normalize(path);
byte[] initial = [];
if (m_entries.TryGetValue(normalized, out Entry? entry))
{
if (entry.IsDirectory)
{
throw new IOException(normalized);
}
if (mode == FileWriteMode.Append)
{
initial = entry.Content.ToArray();
}
}
else
{
EnsureParentDirectory(normalized);
m_entries[normalized] = Entry.File([]);
}
var stream = new CommitMemoryStream(bytes => Commit(normalized, bytes));
stream.Write(initial, 0, initial.Length);
if (mode == FileWriteMode.Append)
{
stream.Position = stream.Length;
}
else
{
stream.SetLength(0);
}
return new ValueTask<Stream>(stream);
}
}
public ValueTask CreateDirectoryAsync(string path, CancellationToken ct)
{
if (!IsWritable)
{
throw new UnauthorizedAccessException();
}
lock (m_lock)
{
AddDirectory(Normalize(path));
}
return default;
}
public ValueTask CreateFileAsync(string path, CancellationToken ct)
{
if (!IsWritable)
{
throw new UnauthorizedAccessException();
}
lock (m_lock)
{
string normalized = Normalize(path);
if (m_entries.ContainsKey(normalized))
{
throw new IOException(normalized);
}
EnsureParentDirectory(normalized);
m_entries[normalized] = Entry.File([]);
}
return default;
}
public ValueTask DeleteAsync(string path, CancellationToken ct)
{
if (!IsWritable)
{
throw new UnauthorizedAccessException();
}
lock (m_lock)
{
Delete(Normalize(path));
}
return default;
}
public ValueTask MoveAsync(string source, string target, CancellationToken ct)
{
if (!IsWritable)
{
throw new UnauthorizedAccessException();
}
lock (m_lock)
{
CopyCore(Normalize(source), Normalize(target));
Delete(Normalize(source));
}
return default;
}
public ValueTask CopyAsync(string source, string target, CancellationToken ct)
{
if (!IsWritable)
{
throw new UnauthorizedAccessException();
}
lock (m_lock)
{
CopyCore(Normalize(source), Normalize(target));
}
return default;
}
public void AddDirectory(string path)
{
string normalized = Normalize(path);
EnsureParentDirectory(normalized);
if (m_entries.TryGetValue(normalized, out Entry? existing) && !existing.IsDirectory)
{
throw new IOException(normalized);
}
m_entries[normalized] = Entry.Directory(NameOf(normalized));
}
public void AddFile(string path, string content)
{
string normalized = Normalize(path);
EnsureParentDirectory(normalized);
m_entries[normalized] = Entry.File(Encoding.UTF8.GetBytes(content));
}
public bool Exists(string path)
{
lock (m_lock)
{
return m_entries.ContainsKey(Normalize(path));
}
}
public string ReadText(string path)
{
lock (m_lock)
{
return Encoding.UTF8.GetString(m_entries[Normalize(path)].Content);
}
}
public void Delete(string path)
{
string normalized = Normalize(path);
if (string.IsNullOrEmpty(normalized) || !m_entries.ContainsKey(normalized))
{
throw new FileNotFoundException(normalized);
}
string prefix = normalized + "/";
foreach (string key in m_entries.Keys.Where(key => key == normalized ||
key.StartsWith(prefix, StringComparison.Ordinal)).ToList())
{
m_entries.Remove(key);
}
}
private void Commit(string path, byte[] bytes)
{
lock (m_lock)
{
m_entries[path] = Entry.File(bytes);
}
}
private void CopyCore(string source, string target)
{
if (!m_entries.TryGetValue(source, out Entry? sourceEntry))
{
throw new FileNotFoundException(source);
}
if (m_entries.ContainsKey(target))
{
throw new IOException(target);
}
EnsureParentDirectory(target);
m_entries[target] = sourceEntry.Clone(NameOf(target));
string sourcePrefix = source + "/";
foreach (KeyValuePair<string, Entry> kv in m_entries.ToArray())
{
if (kv.Key.StartsWith(sourcePrefix, StringComparison.Ordinal))
{
string suffix = kv.Key[sourcePrefix.Length..];
m_entries[target + "/" + suffix] = kv.Value.Clone(NameOf(suffix));
}
}
}
private void EnsureParentDirectory(string path)
{
int slash = path.LastIndexOf('/');
if (slash < 0)
{
return;
}
string parent = path[..slash];
if (!m_entries.TryGetValue(parent, out Entry? entry) || !entry.IsDirectory)
{
throw new DirectoryNotFoundException(parent);
}
}
private static bool IsImmediateChild(string parent, string candidate)
{
if (string.IsNullOrEmpty(candidate))
{
return false;
}
if (string.IsNullOrEmpty(parent))
{
return !candidate.Contains('/', StringComparison.Ordinal);
}
if (!candidate.StartsWith(parent + "/", StringComparison.Ordinal))
{
return false;
}
return !candidate[(parent.Length + 1)..].Contains('/', StringComparison.Ordinal);
}
private static string NameOf(string path)
{
int slash = path.LastIndexOf('/');
return slash < 0 ? path : path[(slash + 1)..];
}
private static string Normalize(string path)
{
return string.IsNullOrEmpty(path) || path == "/" ? string.Empty : path.Trim('/');
}
private readonly Dictionary<string, Entry> m_entries = new(StringComparer.Ordinal);
private readonly Lock m_lock = new();
private int m_openStreamCount;
private sealed class CommitMemoryStream : MemoryStream
{
public CommitMemoryStream(Action<byte[]> commit)
{
m_commit = commit;
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
m_commit(ToArray());
}
base.Dispose(disposing);
}
private readonly Action<byte[]> m_commit;
}
private sealed class TrackingReadStream : MemoryStream
{
public TrackingReadStream(byte[] buffer, Action onDispose)
: base(buffer, writable: false)
{
m_onDispose = onDispose;
}
protected override void Dispose(bool disposing)
{
if (disposing && !m_disposed)
{
m_disposed = true;
m_onDispose();
}
base.Dispose(disposing);
}
private readonly Action m_onDispose;
private bool m_disposed;
}
private sealed class Entry
{
private Entry(string name, bool isDirectory, byte[] content)
{
Name = name;
IsDirectory = isDirectory;
Content = content;
LastModifiedUtc = DateTime.UtcNow;
}
public string Name { get; }
public bool IsDirectory { get; }
public byte[] Content { get; }
public DateTime LastModifiedUtc { get; }
public static Entry Directory(string name)
{
return new Entry(name, isDirectory: true, []);
}
public static Entry File(byte[] content)
{
return new Entry(string.Empty, isDirectory: false, content);
}
public Entry Clone(string name)
{
return IsDirectory ? Directory(name) : File(Content.ToArray());
}
public FileSystemEntry ToFileSystemEntry(string path, bool isWritable)
{
return new FileSystemEntry(
path,
string.IsNullOrEmpty(path) ? Name : NameOf(path),
IsDirectory,
Content.Length,
isWritable,
LastModifiedUtc,
IsDirectory ? string.Empty : "text/plain");
}
}
}
}
}