From e86104d40629d7f9d96411ad2db91861ad27384b Mon Sep 17 00:00:00 2001 From: Philippe Matray Date: Tue, 2 Jun 2026 11:54:54 +0200 Subject: [PATCH 01/26] feat(providers): scaffold Providers.Abstractions project Co-Authored-By: Claude Opus 4.8 (1M context) --- Atypical.VirtualFileSystem.sln | 15 +++++++++++++++ ...irtualFileSystem.Providers.Abstractions.csproj | 8 ++++++++ .../GlobalUsings.cs | 2 ++ 3 files changed, 25 insertions(+) create mode 100644 src/Atypical.VirtualFileSystem.Providers.Abstractions/Atypical.VirtualFileSystem.Providers.Abstractions.csproj create mode 100644 src/Atypical.VirtualFileSystem.Providers.Abstractions/GlobalUsings.cs diff --git a/Atypical.VirtualFileSystem.sln b/Atypical.VirtualFileSystem.sln index 8b83d8c..af894ee 100644 --- a/Atypical.VirtualFileSystem.sln +++ b/Atypical.VirtualFileSystem.sln @@ -23,6 +23,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Atypical.VirtualFileSystem. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Atypical.VirtualFileSystem.GitHub.Tests", "tests\Atypical.VirtualFileSystem.GitHub.Tests\Atypical.VirtualFileSystem.GitHub.Tests.csproj", "{31971414-5519-4149-B5C0-C88454EBE7AD}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Atypical.VirtualFileSystem.Providers.Abstractions", "src\Atypical.VirtualFileSystem.Providers.Abstractions\Atypical.VirtualFileSystem.Providers.Abstractions.csproj", "{B2E9D21C-6C01-4F28-AF7C-2EF16148E964}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -117,6 +119,18 @@ Global {31971414-5519-4149-B5C0-C88454EBE7AD}.Release|x64.Build.0 = Release|Any CPU {31971414-5519-4149-B5C0-C88454EBE7AD}.Release|x86.ActiveCfg = Release|Any CPU {31971414-5519-4149-B5C0-C88454EBE7AD}.Release|x86.Build.0 = Release|Any CPU + {B2E9D21C-6C01-4F28-AF7C-2EF16148E964}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2E9D21C-6C01-4F28-AF7C-2EF16148E964}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2E9D21C-6C01-4F28-AF7C-2EF16148E964}.Debug|x64.ActiveCfg = Debug|Any CPU + {B2E9D21C-6C01-4F28-AF7C-2EF16148E964}.Debug|x64.Build.0 = Debug|Any CPU + {B2E9D21C-6C01-4F28-AF7C-2EF16148E964}.Debug|x86.ActiveCfg = Debug|Any CPU + {B2E9D21C-6C01-4F28-AF7C-2EF16148E964}.Debug|x86.Build.0 = Debug|Any CPU + {B2E9D21C-6C01-4F28-AF7C-2EF16148E964}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2E9D21C-6C01-4F28-AF7C-2EF16148E964}.Release|Any CPU.Build.0 = Release|Any CPU + {B2E9D21C-6C01-4F28-AF7C-2EF16148E964}.Release|x64.ActiveCfg = Release|Any CPU + {B2E9D21C-6C01-4F28-AF7C-2EF16148E964}.Release|x64.Build.0 = Release|Any CPU + {B2E9D21C-6C01-4F28-AF7C-2EF16148E964}.Release|x86.ActiveCfg = Release|Any CPU + {B2E9D21C-6C01-4F28-AF7C-2EF16148E964}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -129,5 +143,6 @@ Global {A1B2C3D4-E5F6-7890-1234-567890ABCDEF} = {10E2E10B-AB65-4800-93ED-CDB737917154} {0F803EA9-AD74-46E3-9404-5D536E0F262C} = {BA11C821-EFDE-4714-A0DE-B7D4D93CE336} {31971414-5519-4149-B5C0-C88454EBE7AD} = {10E2E10B-AB65-4800-93ED-CDB737917154} + {B2E9D21C-6C01-4F28-AF7C-2EF16148E964} = {BA11C821-EFDE-4714-A0DE-B7D4D93CE336} EndGlobalSection EndGlobal diff --git a/src/Atypical.VirtualFileSystem.Providers.Abstractions/Atypical.VirtualFileSystem.Providers.Abstractions.csproj b/src/Atypical.VirtualFileSystem.Providers.Abstractions/Atypical.VirtualFileSystem.Providers.Abstractions.csproj new file mode 100644 index 0000000..f417615 --- /dev/null +++ b/src/Atypical.VirtualFileSystem.Providers.Abstractions/Atypical.VirtualFileSystem.Providers.Abstractions.csproj @@ -0,0 +1,8 @@ + + + Atypical.VirtualFileSystem.Providers.Abstractions + + + + + diff --git a/src/Atypical.VirtualFileSystem.Providers.Abstractions/GlobalUsings.cs b/src/Atypical.VirtualFileSystem.Providers.Abstractions/GlobalUsings.cs new file mode 100644 index 0000000..aa46b3f --- /dev/null +++ b/src/Atypical.VirtualFileSystem.Providers.Abstractions/GlobalUsings.cs @@ -0,0 +1,2 @@ +global using Atypical.VirtualFileSystem.Core; +global using Atypical.VirtualFileSystem.Core.Contracts; From 72eb3414b26200406bdfa9e30f827e69229eb26a Mon Sep 17 00:00:00 2001 From: Philippe Matray Date: Tue, 2 Jun 2026 11:55:38 +0200 Subject: [PATCH 02/26] feat(providers): add AuthKind, ProviderCapabilities, ProviderAccountInfo Co-Authored-By: Claude Opus 4.8 (1M context) --- .../AuthKind.cs | 12 ++++++++++++ .../ProviderAccountInfo.cs | 14 ++++++++++++++ .../ProviderCapabilities.cs | 12 ++++++++++++ 3 files changed, 38 insertions(+) create mode 100644 src/Atypical.VirtualFileSystem.Providers.Abstractions/AuthKind.cs create mode 100644 src/Atypical.VirtualFileSystem.Providers.Abstractions/ProviderAccountInfo.cs create mode 100644 src/Atypical.VirtualFileSystem.Providers.Abstractions/ProviderCapabilities.cs diff --git a/src/Atypical.VirtualFileSystem.Providers.Abstractions/AuthKind.cs b/src/Atypical.VirtualFileSystem.Providers.Abstractions/AuthKind.cs new file mode 100644 index 0000000..710312b --- /dev/null +++ b/src/Atypical.VirtualFileSystem.Providers.Abstractions/AuthKind.cs @@ -0,0 +1,12 @@ +namespace Atypical.VirtualFileSystem.Providers.Abstractions; + +/// How a provider authenticates. +public enum AuthKind +{ + /// A single bearer token / PAT. + Token, + /// Host + username/password style credentials. + Credentials, + /// Interactive OAuth (reserved for future providers). + OAuth +} diff --git a/src/Atypical.VirtualFileSystem.Providers.Abstractions/ProviderAccountInfo.cs b/src/Atypical.VirtualFileSystem.Providers.Abstractions/ProviderAccountInfo.cs new file mode 100644 index 0000000..32ec35c --- /dev/null +++ b/src/Atypical.VirtualFileSystem.Providers.Abstractions/ProviderAccountInfo.cs @@ -0,0 +1,14 @@ +namespace Atypical.VirtualFileSystem.Providers.Abstractions; + +/// Identity and quota/limit info for the authenticated account. +public sealed record ProviderAccountInfo +{ + public static readonly ProviderAccountInfo Anonymous = new() { DisplayName = "Anonymous" }; + + public required string DisplayName { get; init; } + public long? QuotaUsedBytes { get; init; } + public long? QuotaTotalBytes { get; init; } + public int? RateLimitRemaining { get; init; } + public int? RateLimitTotal { get; init; } + public DateTimeOffset? RateLimitReset { get; init; } +} diff --git a/src/Atypical.VirtualFileSystem.Providers.Abstractions/ProviderCapabilities.cs b/src/Atypical.VirtualFileSystem.Providers.Abstractions/ProviderCapabilities.cs new file mode 100644 index 0000000..8218c44 --- /dev/null +++ b/src/Atypical.VirtualFileSystem.Providers.Abstractions/ProviderCapabilities.cs @@ -0,0 +1,12 @@ +namespace Atypical.VirtualFileSystem.Providers.Abstractions; + +/// Describes which optional operations a storage provider supports. +public sealed record ProviderCapabilities +{ + public bool SupportsBranches { get; init; } + public bool SupportsPullRequests { get; init; } + public bool SupportsFork { get; init; } + public bool SupportsAtomicMultiFileCommit { get; init; } + public bool SupportsVersioning { get; init; } + public AuthKind AuthKind { get; init; } +} From e8bf2b07c70809c92a1fd316c701bae13c197551 Mon Sep 17 00:00:00 2001 From: Philippe Matray Date: Tue, 2 Jun 2026 11:56:27 +0200 Subject: [PATCH 03/26] feat(providers): add neutral load/read/write records Co-Authored-By: Claude Opus 4.8 (1M context) --- .../CommitContext.cs | 13 +++++++++++++ .../ProviderFileChange.cs | 11 +++++++++++ .../ProviderFileContent.cs | 4 ++++ .../ProviderFileMetadata.cs | 11 +++++++++++ .../ProviderLoadOptions.cs | 19 +++++++++++++++++++ .../ProviderLoadResult.cs | 15 +++++++++++++++ .../ProviderWriteResult.cs | 12 ++++++++++++ 7 files changed, 85 insertions(+) create mode 100644 src/Atypical.VirtualFileSystem.Providers.Abstractions/CommitContext.cs create mode 100644 src/Atypical.VirtualFileSystem.Providers.Abstractions/ProviderFileChange.cs create mode 100644 src/Atypical.VirtualFileSystem.Providers.Abstractions/ProviderFileContent.cs create mode 100644 src/Atypical.VirtualFileSystem.Providers.Abstractions/ProviderFileMetadata.cs create mode 100644 src/Atypical.VirtualFileSystem.Providers.Abstractions/ProviderLoadOptions.cs create mode 100644 src/Atypical.VirtualFileSystem.Providers.Abstractions/ProviderLoadResult.cs create mode 100644 src/Atypical.VirtualFileSystem.Providers.Abstractions/ProviderWriteResult.cs diff --git a/src/Atypical.VirtualFileSystem.Providers.Abstractions/CommitContext.cs b/src/Atypical.VirtualFileSystem.Providers.Abstractions/CommitContext.cs new file mode 100644 index 0000000..ce2a153 --- /dev/null +++ b/src/Atypical.VirtualFileSystem.Providers.Abstractions/CommitContext.cs @@ -0,0 +1,13 @@ +namespace Atypical.VirtualFileSystem.Providers.Abstractions; + +/// +/// Optional context for a write. Providers honor only what their capabilities allow +/// and ignore the rest (e.g. FTP ignores all of these). +/// +public sealed record CommitContext +{ + public static readonly CommitContext Empty = new(); + public string? Message { get; init; } + public string? Branch { get; init; } + public bool Draft { get; init; } +} diff --git a/src/Atypical.VirtualFileSystem.Providers.Abstractions/ProviderFileChange.cs b/src/Atypical.VirtualFileSystem.Providers.Abstractions/ProviderFileChange.cs new file mode 100644 index 0000000..95bee5a --- /dev/null +++ b/src/Atypical.VirtualFileSystem.Providers.Abstractions/ProviderFileChange.cs @@ -0,0 +1,11 @@ +namespace Atypical.VirtualFileSystem.Providers.Abstractions; + +public enum ChangeKind { Add, Update, Delete } + +public sealed record ProviderFileChange +{ + public required string RemotePath { get; init; } + public required ChangeKind Kind { get; init; } + public byte[]? Content { get; init; } + public string? BaseVersionToken { get; init; } +} diff --git a/src/Atypical.VirtualFileSystem.Providers.Abstractions/ProviderFileContent.cs b/src/Atypical.VirtualFileSystem.Providers.Abstractions/ProviderFileContent.cs new file mode 100644 index 0000000..8ca7e87 --- /dev/null +++ b/src/Atypical.VirtualFileSystem.Providers.Abstractions/ProviderFileContent.cs @@ -0,0 +1,4 @@ +namespace Atypical.VirtualFileSystem.Providers.Abstractions; + +/// The content of a single remote file plus an opaque version token (ETag/SHA/mtime). +public sealed record ProviderFileContent(byte[] Content, string VersionToken, bool IsBinary); diff --git a/src/Atypical.VirtualFileSystem.Providers.Abstractions/ProviderFileMetadata.cs b/src/Atypical.VirtualFileSystem.Providers.Abstractions/ProviderFileMetadata.cs new file mode 100644 index 0000000..6276362 --- /dev/null +++ b/src/Atypical.VirtualFileSystem.Providers.Abstractions/ProviderFileMetadata.cs @@ -0,0 +1,11 @@ +namespace Atypical.VirtualFileSystem.Providers.Abstractions; + +/// Maps a VFS file back to its remote origin so writes can target the right place. +public sealed record ProviderFileMetadata +{ + public required string ProviderId { get; init; } + public required string ContainerKey { get; init; } // repo "owner/name" or host+base path + public required string RelativePath { get; init; } + public required string VersionToken { get; init; } + public DateTimeOffset ImportedAt { get; init; } +} diff --git a/src/Atypical.VirtualFileSystem.Providers.Abstractions/ProviderLoadOptions.cs b/src/Atypical.VirtualFileSystem.Providers.Abstractions/ProviderLoadOptions.cs new file mode 100644 index 0000000..3606910 --- /dev/null +++ b/src/Atypical.VirtualFileSystem.Providers.Abstractions/ProviderLoadOptions.cs @@ -0,0 +1,19 @@ +namespace Atypical.VirtualFileSystem.Providers.Abstractions; + +public enum ProviderLoadingStrategy { Eager, Lazy, MetadataOnly } + +/// Options controlling an import into the VFS. +public sealed record ProviderLoadOptions +{ + /// Remote sub-path to import from (provider-specific root if null/empty). + public string? RemoteRoot { get; init; } + public ProviderLoadingStrategy Strategy { get; init; } = ProviderLoadingStrategy.Eager; + public long MaxFileSizeBytes { get; init; } = 10 * 1024 * 1024; + public IReadOnlyList? AllowedExtensions { get; init; } + public IReadOnlyList? BlockedExtensions { get; init; } + public string? GlobPattern { get; init; } + /// Invoked as each file is loaded: (vfsPath, remotePath, versionToken). + public Action? MetadataCallback { get; init; } + /// Invoked for progress: (filesLoaded, message). + public Action? ProgressCallback { get; init; } +} diff --git a/src/Atypical.VirtualFileSystem.Providers.Abstractions/ProviderLoadResult.cs b/src/Atypical.VirtualFileSystem.Providers.Abstractions/ProviderLoadResult.cs new file mode 100644 index 0000000..cde24e2 --- /dev/null +++ b/src/Atypical.VirtualFileSystem.Providers.Abstractions/ProviderLoadResult.cs @@ -0,0 +1,15 @@ +namespace Atypical.VirtualFileSystem.Providers.Abstractions; + +public enum ProviderSkipReason { TooLarge, BlockedExtension, NotMatchingGlob, LoadError } + +public sealed record ProviderSkippedFile(string RemotePath, ProviderSkipReason Reason, long SizeBytes, string? Message); + +public sealed record ProviderLoadResult +{ + public required string RemoteRoot { get; init; } + public int FilesLoaded { get; init; } + public int DirectoriesCreated { get; init; } + public long TotalBytes { get; init; } + public IReadOnlyList Skipped { get; init; } = []; + public TimeSpan Duration { get; init; } +} diff --git a/src/Atypical.VirtualFileSystem.Providers.Abstractions/ProviderWriteResult.cs b/src/Atypical.VirtualFileSystem.Providers.Abstractions/ProviderWriteResult.cs new file mode 100644 index 0000000..50525ad --- /dev/null +++ b/src/Atypical.VirtualFileSystem.Providers.Abstractions/ProviderWriteResult.cs @@ -0,0 +1,12 @@ +namespace Atypical.VirtualFileSystem.Providers.Abstractions; + +public sealed record ProviderFileWriteResult(string RemotePath, bool Success, string? Message); + +public sealed record ProviderWriteResult +{ + public required bool Success { get; init; } + public IReadOnlyList FileResults { get; init; } = []; + /// For PR-capable providers, the URL of the created pull request (else null). + public string? PullRequestUrl { get; init; } + public string? Message { get; init; } +} From 39822237482605c46645571845e3e5b62f826734 Mon Sep 17 00:00:00 2001 From: Philippe Matray Date: Tue, 2 Jun 2026 11:57:01 +0200 Subject: [PATCH 04/26] feat(providers): add IStorageProvider and IStorageProviderAuth Co-Authored-By: Claude Opus 4.8 (1M context) --- .../AuthRequest.cs | 17 +++++++++++++++++ .../IStorageProvider.cs | 15 +++++++++++++++ .../IStorageProviderAuth.cs | 11 +++++++++++ 3 files changed, 43 insertions(+) create mode 100644 src/Atypical.VirtualFileSystem.Providers.Abstractions/AuthRequest.cs create mode 100644 src/Atypical.VirtualFileSystem.Providers.Abstractions/IStorageProvider.cs create mode 100644 src/Atypical.VirtualFileSystem.Providers.Abstractions/IStorageProviderAuth.cs diff --git a/src/Atypical.VirtualFileSystem.Providers.Abstractions/AuthRequest.cs b/src/Atypical.VirtualFileSystem.Providers.Abstractions/AuthRequest.cs new file mode 100644 index 0000000..3382a6a --- /dev/null +++ b/src/Atypical.VirtualFileSystem.Providers.Abstractions/AuthRequest.cs @@ -0,0 +1,17 @@ +namespace Atypical.VirtualFileSystem.Providers.Abstractions; + +/// Kind-specific authentication payload. Only the fields relevant to the provider's AuthKind are used. +public sealed record AuthRequest +{ + // Token providers (e.g. GitHub) + public string? Token { get; init; } + + // Credentials providers (e.g. FTP) + public string? Host { get; init; } + public int Port { get; init; } + public string? Username { get; init; } + public string? Password { get; init; } + public bool UseTls { get; init; } +} + +public sealed record AuthResult(bool Success, string? ErrorMessage, ProviderAccountInfo? Account); diff --git a/src/Atypical.VirtualFileSystem.Providers.Abstractions/IStorageProvider.cs b/src/Atypical.VirtualFileSystem.Providers.Abstractions/IStorageProvider.cs new file mode 100644 index 0000000..a4d484e --- /dev/null +++ b/src/Atypical.VirtualFileSystem.Providers.Abstractions/IStorageProvider.cs @@ -0,0 +1,15 @@ +namespace Atypical.VirtualFileSystem.Providers.Abstractions; + +/// A pluggable storage backend that can import into, and write back from, a VFS. +public interface IStorageProvider +{ + string Id { get; } + string DisplayName { get; } + ProviderCapabilities Capabilities { get; } + IStorageProviderAuth Auth { get; } + + Task ImportAsync(IVirtualFileSystem vfs, ProviderLoadOptions options, CancellationToken cancellationToken = default); + Task ReadFileAsync(string remotePath, CancellationToken cancellationToken = default); + Task WriteChangesAsync(IReadOnlyList changes, CommitContext context, CancellationToken cancellationToken = default); + Task GetAccountInfoAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Atypical.VirtualFileSystem.Providers.Abstractions/IStorageProviderAuth.cs b/src/Atypical.VirtualFileSystem.Providers.Abstractions/IStorageProviderAuth.cs new file mode 100644 index 0000000..175ebba --- /dev/null +++ b/src/Atypical.VirtualFileSystem.Providers.Abstractions/IStorageProviderAuth.cs @@ -0,0 +1,11 @@ +namespace Atypical.VirtualFileSystem.Providers.Abstractions; + +public interface IStorageProviderAuth +{ + AuthKind Kind { get; } + bool IsAuthenticated { get; } + ProviderAccountInfo? Account { get; } + Task AuthenticateAsync(AuthRequest request, CancellationToken cancellationToken = default); + Task SignOutAsync(CancellationToken cancellationToken = default); + event Action? AuthChanged; +} From 9868aba6bb01dafbad3849f4942b96c6180b121b Mon Sep 17 00:00:00 2001 From: Philippe Matray Date: Tue, 2 Jun 2026 11:57:51 +0200 Subject: [PATCH 05/26] test(providers): abstractions test project + capability tests Co-Authored-By: Claude Opus 4.8 (1M context) --- Atypical.VirtualFileSystem.sln | 15 +++++++++++ ...System.Providers.Abstractions.Tests.csproj | 26 +++++++++++++++++++ .../CommitContextTests.cs | 22 ++++++++++++++++ .../GlobalUsings.cs | 3 +++ 4 files changed, 66 insertions(+) create mode 100644 tests/Atypical.VirtualFileSystem.Providers.Abstractions.Tests/Atypical.VirtualFileSystem.Providers.Abstractions.Tests.csproj create mode 100644 tests/Atypical.VirtualFileSystem.Providers.Abstractions.Tests/CommitContextTests.cs create mode 100644 tests/Atypical.VirtualFileSystem.Providers.Abstractions.Tests/GlobalUsings.cs diff --git a/Atypical.VirtualFileSystem.sln b/Atypical.VirtualFileSystem.sln index af894ee..d3c9c3b 100644 --- a/Atypical.VirtualFileSystem.sln +++ b/Atypical.VirtualFileSystem.sln @@ -25,6 +25,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Atypical.VirtualFileSystem. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Atypical.VirtualFileSystem.Providers.Abstractions", "src\Atypical.VirtualFileSystem.Providers.Abstractions\Atypical.VirtualFileSystem.Providers.Abstractions.csproj", "{B2E9D21C-6C01-4F28-AF7C-2EF16148E964}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Atypical.VirtualFileSystem.Providers.Abstractions.Tests", "tests\Atypical.VirtualFileSystem.Providers.Abstractions.Tests\Atypical.VirtualFileSystem.Providers.Abstractions.Tests.csproj", "{2E8D3BE3-F419-46B1-BBD3-FC5F753261D0}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -131,6 +133,18 @@ Global {B2E9D21C-6C01-4F28-AF7C-2EF16148E964}.Release|x64.Build.0 = Release|Any CPU {B2E9D21C-6C01-4F28-AF7C-2EF16148E964}.Release|x86.ActiveCfg = Release|Any CPU {B2E9D21C-6C01-4F28-AF7C-2EF16148E964}.Release|x86.Build.0 = Release|Any CPU + {2E8D3BE3-F419-46B1-BBD3-FC5F753261D0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2E8D3BE3-F419-46B1-BBD3-FC5F753261D0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2E8D3BE3-F419-46B1-BBD3-FC5F753261D0}.Debug|x64.ActiveCfg = Debug|Any CPU + {2E8D3BE3-F419-46B1-BBD3-FC5F753261D0}.Debug|x64.Build.0 = Debug|Any CPU + {2E8D3BE3-F419-46B1-BBD3-FC5F753261D0}.Debug|x86.ActiveCfg = Debug|Any CPU + {2E8D3BE3-F419-46B1-BBD3-FC5F753261D0}.Debug|x86.Build.0 = Debug|Any CPU + {2E8D3BE3-F419-46B1-BBD3-FC5F753261D0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2E8D3BE3-F419-46B1-BBD3-FC5F753261D0}.Release|Any CPU.Build.0 = Release|Any CPU + {2E8D3BE3-F419-46B1-BBD3-FC5F753261D0}.Release|x64.ActiveCfg = Release|Any CPU + {2E8D3BE3-F419-46B1-BBD3-FC5F753261D0}.Release|x64.Build.0 = Release|Any CPU + {2E8D3BE3-F419-46B1-BBD3-FC5F753261D0}.Release|x86.ActiveCfg = Release|Any CPU + {2E8D3BE3-F419-46B1-BBD3-FC5F753261D0}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -144,5 +158,6 @@ Global {0F803EA9-AD74-46E3-9404-5D536E0F262C} = {BA11C821-EFDE-4714-A0DE-B7D4D93CE336} {31971414-5519-4149-B5C0-C88454EBE7AD} = {10E2E10B-AB65-4800-93ED-CDB737917154} {B2E9D21C-6C01-4F28-AF7C-2EF16148E964} = {BA11C821-EFDE-4714-A0DE-B7D4D93CE336} + {2E8D3BE3-F419-46B1-BBD3-FC5F753261D0} = {10E2E10B-AB65-4800-93ED-CDB737917154} EndGlobalSection EndGlobal diff --git a/tests/Atypical.VirtualFileSystem.Providers.Abstractions.Tests/Atypical.VirtualFileSystem.Providers.Abstractions.Tests.csproj b/tests/Atypical.VirtualFileSystem.Providers.Abstractions.Tests/Atypical.VirtualFileSystem.Providers.Abstractions.Tests.csproj new file mode 100644 index 0000000..5204b2c --- /dev/null +++ b/tests/Atypical.VirtualFileSystem.Providers.Abstractions.Tests/Atypical.VirtualFileSystem.Providers.Abstractions.Tests.csproj @@ -0,0 +1,26 @@ + + + + false + Atypical.VirtualFileSystem.Providers.Abstractions.Tests + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/tests/Atypical.VirtualFileSystem.Providers.Abstractions.Tests/CommitContextTests.cs b/tests/Atypical.VirtualFileSystem.Providers.Abstractions.Tests/CommitContextTests.cs new file mode 100644 index 0000000..9e737d8 --- /dev/null +++ b/tests/Atypical.VirtualFileSystem.Providers.Abstractions.Tests/CommitContextTests.cs @@ -0,0 +1,22 @@ +namespace Atypical.VirtualFileSystem.Providers.Abstractions.Tests; + +public class CommitContextTests +{ + [Fact] + public void Empty_has_no_message_branch_or_draft() + { + var ctx = CommitContext.Empty; + ctx.Message.ShouldBeNull(); + ctx.Branch.ShouldBeNull(); + ctx.Draft.ShouldBeFalse(); + } + + [Fact] + public void Capabilities_default_to_false_with_token_auth() + { + var caps = new ProviderCapabilities(); + caps.SupportsBranches.ShouldBeFalse(); + caps.SupportsPullRequests.ShouldBeFalse(); + caps.AuthKind.ShouldBe(AuthKind.Token); + } +} diff --git a/tests/Atypical.VirtualFileSystem.Providers.Abstractions.Tests/GlobalUsings.cs b/tests/Atypical.VirtualFileSystem.Providers.Abstractions.Tests/GlobalUsings.cs new file mode 100644 index 0000000..b295513 --- /dev/null +++ b/tests/Atypical.VirtualFileSystem.Providers.Abstractions.Tests/GlobalUsings.cs @@ -0,0 +1,3 @@ +global using Atypical.VirtualFileSystem.Providers.Abstractions; +global using Shouldly; +global using Xunit; From 7ccca01e4c44cb812b2be91cc30234efc41336e2 Mon Sep 17 00:00:00 2001 From: Philippe Matray Date: Tue, 2 Jun 2026 12:05:24 +0200 Subject: [PATCH 06/26] refactor(providers): add abstraction XML docs and broaden contract tests Co-Authored-By: Claude Opus 4.8 (1M context) --- .../IStorageProvider.cs | 37 +++++++++++++++++++ .../IStorageProviderAuth.cs | 23 ++++++++++++ .../CommitContextTests.cs | 9 ----- .../ProviderAccountInfoTests.cs | 17 +++++++++ .../ProviderCapabilitiesTests.cs | 13 +++++++ .../ProviderLoadOptionsTests.cs | 13 +++++++ .../ProviderWriteResultTests.cs | 26 +++++++++++++ 7 files changed, 129 insertions(+), 9 deletions(-) create mode 100644 tests/Atypical.VirtualFileSystem.Providers.Abstractions.Tests/ProviderAccountInfoTests.cs create mode 100644 tests/Atypical.VirtualFileSystem.Providers.Abstractions.Tests/ProviderCapabilitiesTests.cs create mode 100644 tests/Atypical.VirtualFileSystem.Providers.Abstractions.Tests/ProviderLoadOptionsTests.cs create mode 100644 tests/Atypical.VirtualFileSystem.Providers.Abstractions.Tests/ProviderWriteResultTests.cs diff --git a/src/Atypical.VirtualFileSystem.Providers.Abstractions/IStorageProvider.cs b/src/Atypical.VirtualFileSystem.Providers.Abstractions/IStorageProvider.cs index a4d484e..e116fba 100644 --- a/src/Atypical.VirtualFileSystem.Providers.Abstractions/IStorageProvider.cs +++ b/src/Atypical.VirtualFileSystem.Providers.Abstractions/IStorageProvider.cs @@ -3,13 +3,50 @@ namespace Atypical.VirtualFileSystem.Providers.Abstractions; /// A pluggable storage backend that can import into, and write back from, a VFS. public interface IStorageProvider { + /// A stable, lowercase identifier for the provider (e.g. "github", "ftp"). string Id { get; } + + /// A human-readable name for the provider, suitable for display in a UI. string DisplayName { get; } + + /// Describes which optional operations this provider supports. ProviderCapabilities Capabilities { get; } + + /// The authentication handler for this provider. IStorageProviderAuth Auth { get; } + /// + /// Imports remote files into the given Virtual File System according to the supplied options. + /// + /// The Virtual File System to import the remote files into. + /// Options controlling the import (root, strategy, filters, callbacks). + /// Cancellation token for the operation. + /// A result summarizing the files loaded, directories created, and any skipped files. Task ImportAsync(IVirtualFileSystem vfs, ProviderLoadOptions options, CancellationToken cancellationToken = default); + + /// + /// Reads the content of a single remote file. + /// + /// The provider-specific path of the file to read. + /// Cancellation token for the operation. + /// The file content along with an opaque version token. Task ReadFileAsync(string remotePath, CancellationToken cancellationToken = default); + + /// + /// Writes a batch of file changes (adds, updates, deletes) back to the remote storage. + /// + /// The changes to apply. + /// Optional commit context; providers honor only what their capabilities allow. + /// Cancellation token for the operation. + /// A result reporting overall success and per-file outcomes. Task WriteChangesAsync(IReadOnlyList changes, CommitContext context, CancellationToken cancellationToken = default); + + /// + /// Returns identity and quota/limit information for the authenticated account. + /// When the provider is not authenticated this returns ; + /// it never returns and does not throw for the unauthenticated case. + /// + /// Cancellation token for the operation. + /// The authenticated account's info, or when not authenticated. Task GetAccountInfoAsync(CancellationToken cancellationToken = default); } diff --git a/src/Atypical.VirtualFileSystem.Providers.Abstractions/IStorageProviderAuth.cs b/src/Atypical.VirtualFileSystem.Providers.Abstractions/IStorageProviderAuth.cs index 175ebba..1e07136 100644 --- a/src/Atypical.VirtualFileSystem.Providers.Abstractions/IStorageProviderAuth.cs +++ b/src/Atypical.VirtualFileSystem.Providers.Abstractions/IStorageProviderAuth.cs @@ -1,11 +1,34 @@ namespace Atypical.VirtualFileSystem.Providers.Abstractions; +/// +/// Handles authentication for a storage provider, exposing the current authenticated +/// account and notifying subscribers when the authentication state changes. +/// public interface IStorageProviderAuth { + /// The kind of authentication this provider uses (token, credentials, OAuth). AuthKind Kind { get; } + + /// Whether the provider currently has a valid authenticated session. bool IsAuthenticated { get; } + + /// The authenticated account, or when not authenticated. ProviderAccountInfo? Account { get; } + + /// + /// Attempts to authenticate using the supplied request payload. + /// + /// The kind-specific authentication payload. + /// Cancellation token for the operation. + /// A result indicating success or failure, with the account on success. Task AuthenticateAsync(AuthRequest request, CancellationToken cancellationToken = default); + + /// + /// Clears the current authenticated session. + /// + /// Cancellation token for the operation. Task SignOutAsync(CancellationToken cancellationToken = default); + + /// Raised whenever the authentication state changes (sign-in or sign-out). event Action? AuthChanged; } diff --git a/tests/Atypical.VirtualFileSystem.Providers.Abstractions.Tests/CommitContextTests.cs b/tests/Atypical.VirtualFileSystem.Providers.Abstractions.Tests/CommitContextTests.cs index 9e737d8..ef0832d 100644 --- a/tests/Atypical.VirtualFileSystem.Providers.Abstractions.Tests/CommitContextTests.cs +++ b/tests/Atypical.VirtualFileSystem.Providers.Abstractions.Tests/CommitContextTests.cs @@ -10,13 +10,4 @@ public void Empty_has_no_message_branch_or_draft() ctx.Branch.ShouldBeNull(); ctx.Draft.ShouldBeFalse(); } - - [Fact] - public void Capabilities_default_to_false_with_token_auth() - { - var caps = new ProviderCapabilities(); - caps.SupportsBranches.ShouldBeFalse(); - caps.SupportsPullRequests.ShouldBeFalse(); - caps.AuthKind.ShouldBe(AuthKind.Token); - } } diff --git a/tests/Atypical.VirtualFileSystem.Providers.Abstractions.Tests/ProviderAccountInfoTests.cs b/tests/Atypical.VirtualFileSystem.Providers.Abstractions.Tests/ProviderAccountInfoTests.cs new file mode 100644 index 0000000..bec54ff --- /dev/null +++ b/tests/Atypical.VirtualFileSystem.Providers.Abstractions.Tests/ProviderAccountInfoTests.cs @@ -0,0 +1,17 @@ +namespace Atypical.VirtualFileSystem.Providers.Abstractions.Tests; + +public class ProviderAccountInfoTests +{ + [Fact] + public void Anonymous_has_anonymous_display_name_and_null_quota_and_rate_fields() + { + var account = ProviderAccountInfo.Anonymous; + + account.DisplayName.ShouldBe("Anonymous"); + account.QuotaUsedBytes.ShouldBeNull(); + account.QuotaTotalBytes.ShouldBeNull(); + account.RateLimitRemaining.ShouldBeNull(); + account.RateLimitTotal.ShouldBeNull(); + account.RateLimitReset.ShouldBeNull(); + } +} diff --git a/tests/Atypical.VirtualFileSystem.Providers.Abstractions.Tests/ProviderCapabilitiesTests.cs b/tests/Atypical.VirtualFileSystem.Providers.Abstractions.Tests/ProviderCapabilitiesTests.cs new file mode 100644 index 0000000..e58593f --- /dev/null +++ b/tests/Atypical.VirtualFileSystem.Providers.Abstractions.Tests/ProviderCapabilitiesTests.cs @@ -0,0 +1,13 @@ +namespace Atypical.VirtualFileSystem.Providers.Abstractions.Tests; + +public class ProviderCapabilitiesTests +{ + [Fact] + public void Capabilities_default_to_false_with_token_auth() + { + var caps = new ProviderCapabilities(); + caps.SupportsBranches.ShouldBeFalse(); + caps.SupportsPullRequests.ShouldBeFalse(); + caps.AuthKind.ShouldBe(AuthKind.Token); + } +} diff --git a/tests/Atypical.VirtualFileSystem.Providers.Abstractions.Tests/ProviderLoadOptionsTests.cs b/tests/Atypical.VirtualFileSystem.Providers.Abstractions.Tests/ProviderLoadOptionsTests.cs new file mode 100644 index 0000000..338a803 --- /dev/null +++ b/tests/Atypical.VirtualFileSystem.Providers.Abstractions.Tests/ProviderLoadOptionsTests.cs @@ -0,0 +1,13 @@ +namespace Atypical.VirtualFileSystem.Providers.Abstractions.Tests; + +public class ProviderLoadOptionsTests +{ + [Fact] + public void Defaults_use_eager_strategy_and_ten_megabyte_max_file_size() + { + var options = new ProviderLoadOptions(); + + options.Strategy.ShouldBe(ProviderLoadingStrategy.Eager); + options.MaxFileSizeBytes.ShouldBe(10 * 1024 * 1024); + } +} diff --git a/tests/Atypical.VirtualFileSystem.Providers.Abstractions.Tests/ProviderWriteResultTests.cs b/tests/Atypical.VirtualFileSystem.Providers.Abstractions.Tests/ProviderWriteResultTests.cs new file mode 100644 index 0000000..4ca37ad --- /dev/null +++ b/tests/Atypical.VirtualFileSystem.Providers.Abstractions.Tests/ProviderWriteResultTests.cs @@ -0,0 +1,26 @@ +namespace Atypical.VirtualFileSystem.Providers.Abstractions.Tests; + +public class ProviderWriteResultTests +{ + [Fact] + public void Result_with_one_failed_file_result_round_trips() + { + var result = new ProviderWriteResult + { + Success = false, + FileResults = + [ + new ProviderFileWriteResult("/data/file.txt", false, "boom") + ], + Message = "write failed" + }; + + result.Success.ShouldBeFalse(); + result.PullRequestUrl.ShouldBeNull(); + result.Message.ShouldBe("write failed"); + result.FileResults.Count.ShouldBe(1); + result.FileResults[0].RemotePath.ShouldBe("/data/file.txt"); + result.FileResults[0].Success.ShouldBeFalse(); + result.FileResults[0].Message.ShouldBe("boom"); + } +} From 070d1f38c1e4c49a5448ffc460d2407cc9ecfbcd Mon Sep 17 00:00:00 2001 From: Philippe Matray Date: Tue, 2 Jun 2026 12:07:13 +0200 Subject: [PATCH 07/26] build(github): reference Providers.Abstractions Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Atypical.VirtualFileSystem.GitHub.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Atypical.VirtualFileSystem.GitHub/Atypical.VirtualFileSystem.GitHub.csproj b/src/Atypical.VirtualFileSystem.GitHub/Atypical.VirtualFileSystem.GitHub.csproj index abbb65b..2bbf22a 100644 --- a/src/Atypical.VirtualFileSystem.GitHub/Atypical.VirtualFileSystem.GitHub.csproj +++ b/src/Atypical.VirtualFileSystem.GitHub/Atypical.VirtualFileSystem.GitHub.csproj @@ -20,6 +20,7 @@ + From 5cc063297c072100e1621a07c8163209f8d91d0f Mon Sep 17 00:00:00 2001 From: Philippe Matray Date: Tue, 2 Jun 2026 12:08:50 +0200 Subject: [PATCH 08/26] feat(github): add GitHub<->neutral provider adapters Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Providers/GitHubProviderAdapters.cs | 66 +++++++++++++++++++ .../Providers/GitHubProviderAdaptersTests.cs | 55 ++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 src/Atypical.VirtualFileSystem.GitHub/Providers/GitHubProviderAdapters.cs create mode 100644 tests/Atypical.VirtualFileSystem.GitHub.Tests/Providers/GitHubProviderAdaptersTests.cs diff --git a/src/Atypical.VirtualFileSystem.GitHub/Providers/GitHubProviderAdapters.cs b/src/Atypical.VirtualFileSystem.GitHub/Providers/GitHubProviderAdapters.cs new file mode 100644 index 0000000..06dfc07 --- /dev/null +++ b/src/Atypical.VirtualFileSystem.GitHub/Providers/GitHubProviderAdapters.cs @@ -0,0 +1,66 @@ +// Copyright (c) 2022-2025, Atypical Consulting SRL +// All rights reserved... but seriously, we're open to sharing if you ask nicely! +// +// This source code is licensed under the BSD-style license found in the +// LICENSE file in the root directory of this source tree. + +using Atypical.VirtualFileSystem.Providers.Abstractions; + +namespace Atypical.VirtualFileSystem.GitHub.Providers; + +public static class GitHubProviderAdapters +{ + /// + /// Maps a neutral to a . + /// + /// The neutral provider load options. + /// The GitHub personal access token. + public static GitHubLoaderOptions ToGitHubLoaderOptions(ProviderLoadOptions o, string? accessToken) + => new( + AccessToken: accessToken, + TargetPath: o.RemoteRoot ?? "/", + MaxFileSize: o.MaxFileSizeBytes, + Strategy: o.Strategy switch + { + ProviderLoadingStrategy.Lazy => GitHubLoadingStrategy.Lazy, + ProviderLoadingStrategy.MetadataOnly => GitHubLoadingStrategy.MetadataOnly, + _ => GitHubLoadingStrategy.Eager + }, + IncludeExtensions: o.AllowedExtensions is null + ? null + : new HashSet(o.AllowedExtensions, StringComparer.OrdinalIgnoreCase), + ExcludeExtensions: o.BlockedExtensions is null + ? null + : new HashSet(o.BlockedExtensions, StringComparer.OrdinalIgnoreCase), + MetadataCallback: o.MetadataCallback, + ProgressCallback: o.ProgressCallback is null + ? null + : (current, total, path) => o.ProgressCallback(current, path) + ); + + /// + /// Maps a to a neutral . + /// + public static ProviderLoadResult ToProviderLoadResult(GitHubLoadResult r) + => new() + { + RemoteRoot = r.TargetPath ?? string.Empty, + FilesLoaded = r.FilesLoaded, + DirectoriesCreated = r.DirectoriesCreated, + TotalBytes = r.TotalBytesLoaded, + Duration = r.LoadDuration, + Skipped = r.SkippedFiles + .Select(s => new ProviderSkippedFile(s.Path, ToSkipReason(s.Reason), s.Size ?? 0, s.ErrorMessage)) + .ToList(), + }; + + private static ProviderSkipReason ToSkipReason(SkipReason r) => r switch + { + SkipReason.TooLarge => ProviderSkipReason.TooLarge, + SkipReason.ExtensionNotIncluded => ProviderSkipReason.BlockedExtension, + SkipReason.ExtensionExcluded => ProviderSkipReason.BlockedExtension, + SkipReason.BinaryExcluded => ProviderSkipReason.BlockedExtension, + SkipReason.PatternExcluded => ProviderSkipReason.NotMatchingGlob, + _ => ProviderSkipReason.LoadError + }; +} diff --git a/tests/Atypical.VirtualFileSystem.GitHub.Tests/Providers/GitHubProviderAdaptersTests.cs b/tests/Atypical.VirtualFileSystem.GitHub.Tests/Providers/GitHubProviderAdaptersTests.cs new file mode 100644 index 0000000..8e6f957 --- /dev/null +++ b/tests/Atypical.VirtualFileSystem.GitHub.Tests/Providers/GitHubProviderAdaptersTests.cs @@ -0,0 +1,55 @@ +// Copyright (c) 2022-2025, Atypical Consulting SRL +// All rights reserved... but seriously, we're open to sharing if you ask nicely! +// +// This source code is licensed under the BSD-style license found in the +// LICENSE file in the root directory of this source tree. + +using Atypical.VirtualFileSystem.GitHub.Providers; +using Atypical.VirtualFileSystem.Providers.Abstractions; + +namespace Atypical.VirtualFileSystem.GitHub.Tests.Providers; + +public class GitHubProviderAdaptersTests +{ + [Fact] + public void ToGitHubLoaderOptions_maps_strategy_and_filters() + { + var opts = new ProviderLoadOptions + { + RemoteRoot = "src", + Strategy = ProviderLoadingStrategy.Lazy, + MaxFileSizeBytes = 1234, + }; + + var gh = GitHubProviderAdapters.ToGitHubLoaderOptions(opts, accessToken: "tok"); + + gh.Strategy.ShouldBe(GitHubLoadingStrategy.Lazy); + gh.MaxFileSize.ShouldBe(1234); + gh.TargetPath.ShouldBe("src"); + gh.AccessToken.ShouldBe("tok"); + } + + [Fact] + public void ToProviderLoadResult_maps_counts_and_skips() + { + var ghResult = new GitHubLoadResult( + RepositoryOwner: "o", + RepositoryName: "r", + Branch: "main", + CommitSha: "sha", + FilesLoaded: 3, + DirectoriesCreated: 2, + TotalBytesLoaded: 99, + SkippedFiles: new List { new("a.bin", SkipReason.LoadError, 5, "boom") }, + LoadDuration: TimeSpan.FromSeconds(1), + TargetPath: "src"); + + var result = GitHubProviderAdapters.ToProviderLoadResult(ghResult); + + result.FilesLoaded.ShouldBe(3); + result.DirectoriesCreated.ShouldBe(2); + result.TotalBytes.ShouldBe(99); + result.Skipped.Count.ShouldBe(1); + result.Skipped[0].Reason.ShouldBe(ProviderSkipReason.LoadError); + } +} From 785732026fc2d3c4caee60ce1fbbb063b7655031 Mon Sep 17 00:00:00 2001 From: Philippe Matray Date: Tue, 2 Jun 2026 12:09:48 +0200 Subject: [PATCH 09/26] feat(github): add GitHubProviderAuth (Token) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Providers/GitHubProviderAuth.cs | 83 +++++++++++++++++++ .../Providers/GitHubProviderAuthTests.cs | 30 +++++++ 2 files changed, 113 insertions(+) create mode 100644 src/Atypical.VirtualFileSystem.GitHub/Providers/GitHubProviderAuth.cs create mode 100644 tests/Atypical.VirtualFileSystem.GitHub.Tests/Providers/GitHubProviderAuthTests.cs diff --git a/src/Atypical.VirtualFileSystem.GitHub/Providers/GitHubProviderAuth.cs b/src/Atypical.VirtualFileSystem.GitHub/Providers/GitHubProviderAuth.cs new file mode 100644 index 0000000..f82e78e --- /dev/null +++ b/src/Atypical.VirtualFileSystem.GitHub/Providers/GitHubProviderAuth.cs @@ -0,0 +1,83 @@ +// Copyright (c) 2022-2025, Atypical Consulting SRL +// All rights reserved... but seriously, we're open to sharing if you ask nicely! +// +// This source code is licensed under the BSD-style license found in the +// LICENSE file in the root directory of this source tree. + +using Atypical.VirtualFileSystem.Providers.Abstractions; +using Octokit; + +namespace Atypical.VirtualFileSystem.GitHub.Providers; + +/// +/// Handles token-based authentication for the GitHub storage provider. +/// Validates the personal access token against the GitHub API and populates +/// with the authenticated user's identity and rate-limit info. +/// +public sealed class GitHubProviderAuth : IStorageProviderAuth +{ + private const string ProductHeader = "Atypical.VirtualFileSystem.GitHub"; + private string? _token; + + /// + public AuthKind Kind => AuthKind.Token; + + /// + public bool IsAuthenticated => !string.IsNullOrEmpty(_token) && Account is not null; + + /// + public ProviderAccountInfo? Account { get; private set; } + + /// + public event Action? AuthChanged; + + /// + /// Returns the current access token, or when not authenticated. + /// + public string? Token => _token; + + /// + public async Task AuthenticateAsync(AuthRequest request, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(request.Token)) + return new AuthResult(false, "Token cannot be empty.", null); + + try + { + var client = new GitHubClient(new Octokit.ProductHeaderValue(ProductHeader)) + { + Credentials = new Credentials(request.Token) + }; + var user = await client.User.Current(); + var limits = await client.RateLimit.GetRateLimits(); + + _token = request.Token; + Account = new ProviderAccountInfo + { + DisplayName = user.Login, + RateLimitRemaining = limits.Resources.Core.Remaining, + RateLimitTotal = limits.Resources.Core.Limit, + RateLimitReset = limits.Resources.Core.Reset + }; + AuthChanged?.Invoke(); + return new AuthResult(true, null, Account); + } + catch (AuthorizationException) + { + return new AuthResult(false, "Invalid token.", null); + } + catch (ApiException ex) + { + return new AuthResult(false, $"GitHub API error: {ex.Message}", null); + } + } + + /// + public Task SignOutAsync(CancellationToken cancellationToken = default) + { + _token = null; + Account = null; + AuthChanged?.Invoke(); + return Task.CompletedTask; + } +} diff --git a/tests/Atypical.VirtualFileSystem.GitHub.Tests/Providers/GitHubProviderAuthTests.cs b/tests/Atypical.VirtualFileSystem.GitHub.Tests/Providers/GitHubProviderAuthTests.cs new file mode 100644 index 0000000..8937032 --- /dev/null +++ b/tests/Atypical.VirtualFileSystem.GitHub.Tests/Providers/GitHubProviderAuthTests.cs @@ -0,0 +1,30 @@ +// Copyright (c) 2022-2025, Atypical Consulting SRL +// All rights reserved... but seriously, we're open to sharing if you ask nicely! +// +// This source code is licensed under the BSD-style license found in the +// LICENSE file in the root directory of this source tree. + +using Atypical.VirtualFileSystem.GitHub.Providers; +using Atypical.VirtualFileSystem.Providers.Abstractions; + +namespace Atypical.VirtualFileSystem.GitHub.Tests.Providers; + +public class GitHubProviderAuthTests +{ + [Fact] + public void New_auth_is_token_kind_and_unauthenticated() + { + var auth = new GitHubProviderAuth(); + auth.Kind.ShouldBe(AuthKind.Token); + auth.IsAuthenticated.ShouldBeFalse(); + auth.Account.ShouldBeNull(); + } + + [Fact] + public async Task AuthenticateAsync_with_empty_token_returns_failure() + { + var auth = new GitHubProviderAuth(); + var result = await auth.AuthenticateAsync(new AuthRequest { Token = "" }); + result.Success.ShouldBeFalse(); + } +} From 7ff23782caf9c4213170978ca1bee9b9045534cf Mon Sep 17 00:00:00 2001 From: Philippe Matray Date: Tue, 2 Jun 2026 12:11:19 +0200 Subject: [PATCH 10/26] feat(github): add GitHubStorageProvider wrapping the loader Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Providers/GitHubStorageProvider.cs | 117 ++++++++++++++++++ .../Providers/GitHubStorageProviderTests.cs | 24 ++++ 2 files changed, 141 insertions(+) create mode 100644 src/Atypical.VirtualFileSystem.GitHub/Providers/GitHubStorageProvider.cs create mode 100644 tests/Atypical.VirtualFileSystem.GitHub.Tests/Providers/GitHubStorageProviderTests.cs diff --git a/src/Atypical.VirtualFileSystem.GitHub/Providers/GitHubStorageProvider.cs b/src/Atypical.VirtualFileSystem.GitHub/Providers/GitHubStorageProvider.cs new file mode 100644 index 0000000..f77fbf3 --- /dev/null +++ b/src/Atypical.VirtualFileSystem.GitHub/Providers/GitHubStorageProvider.cs @@ -0,0 +1,117 @@ +// Copyright (c) 2022-2025, Atypical Consulting SRL +// All rights reserved... but seriously, we're open to sharing if you ask nicely! +// +// This source code is licensed under the BSD-style license found in the +// LICENSE file in the root directory of this source tree. + +using Atypical.VirtualFileSystem.Providers.Abstractions; + +namespace Atypical.VirtualFileSystem.GitHub.Providers; + +/// +/// An that wraps the existing +/// and behind the neutral provider abstraction. +/// All import logic is delegated to the loader; no loader/write-service logic is duplicated here. +/// +/// +/// Design note: GitHub's write orchestration (fork→branch→commit→PR) already lives in the +/// Blazor GitHubPendingChangesService. To avoid duplicating it, the provider's +/// throws for now and the app's StoragePendingChangesService +/// calls the GitHub write service directly when the active provider is GitHub. +/// This is a deliberate, documented seam — revisit when extracting writes fully in a later milestone. +/// +public sealed class GitHubStorageProvider : IStorageProvider +{ + private readonly IGitHubRepositoryLoader _loader; + private readonly IGitHubWriteService _writeService; + private readonly GitHubProviderAuth _auth; + + /// + /// Initialises a new . + /// + /// The repository loader. + /// The write service (used for future write orchestration). + /// The token-based auth handler. + public GitHubStorageProvider(IGitHubRepositoryLoader loader, IGitHubWriteService writeService, GitHubProviderAuth auth) + { + _loader = loader; + _writeService = writeService; + _auth = auth; + } + + /// + public string Id => "github"; + + /// + public string DisplayName => "GitHub"; + + /// + public IStorageProviderAuth Auth => _auth; + + /// + public ProviderCapabilities Capabilities => new() + { + SupportsBranches = true, + SupportsPullRequests = true, + SupportsFork = true, + SupportsAtomicMultiFileCommit = true, + SupportsVersioning = true, + AuthKind = AuthKind.Token + }; + + /// + /// + /// . must be in the form + /// owner/repo or owner/repo/subpath. The sub-path (if any) is forwarded to the + /// loader as the filter so that only files under that + /// path are imported. The VFS target root defaults to /. + /// + public async Task ImportAsync( + IVirtualFileSystem vfs, + ProviderLoadOptions options, + CancellationToken cancellationToken = default) + { + var (owner, repo, subPath) = ParseRemoteRoot(options.RemoteRoot); + var ghOptions = GitHubProviderAdapters.ToGitHubLoaderOptions( + options with { RemoteRoot = subPath }, + _auth.Token); + var result = await _loader.LoadRepositoryAsync(vfs, owner, repo, ghOptions, cancellationToken); + return GitHubProviderAdapters.ToProviderLoadResult(result); + } + + /// + /// + /// Always thrown — single-file reads are performed during import for GitHub and are not + /// exposed through the provider surface in the current milestone. + /// + public Task ReadFileAsync(string remotePath, CancellationToken cancellationToken = default) + => throw new NotSupportedException( + "Single-file read is performed during import for GitHub; not used by the current UI."); + + /// + /// + /// Always thrown — GitHub writes are orchestrated by StoragePendingChangesService in Task 4.x. + /// + public Task WriteChangesAsync( + IReadOnlyList changes, + CommitContext context, + CancellationToken cancellationToken = default) + => throw new NotSupportedException( + "GitHub writes are orchestrated by StoragePendingChangesService in Task 4.x."); + + /// + public Task GetAccountInfoAsync(CancellationToken cancellationToken = default) + => Task.FromResult(_auth.Account ?? ProviderAccountInfo.Anonymous); + + /// + /// Parses a GitHub remote root string of the form owner/repo[/subpath]. + /// + private static (string owner, string repo, string? subPath) ParseRemoteRoot(string? remoteRoot) + { + var parts = (remoteRoot ?? string.Empty).Trim('/').Split('/', 3); + if (parts.Length < 2 || string.IsNullOrEmpty(parts[0]) || string.IsNullOrEmpty(parts[1])) + throw new ArgumentException( + "GitHub RemoteRoot must be 'owner/repo[/subpath]'.", nameof(remoteRoot)); + return (parts[0], parts[1], parts.Length == 3 ? parts[2] : null); + } +} diff --git a/tests/Atypical.VirtualFileSystem.GitHub.Tests/Providers/GitHubStorageProviderTests.cs b/tests/Atypical.VirtualFileSystem.GitHub.Tests/Providers/GitHubStorageProviderTests.cs new file mode 100644 index 0000000..4bce811 --- /dev/null +++ b/tests/Atypical.VirtualFileSystem.GitHub.Tests/Providers/GitHubStorageProviderTests.cs @@ -0,0 +1,24 @@ +// Copyright (c) 2022-2025, Atypical Consulting SRL +// All rights reserved... but seriously, we're open to sharing if you ask nicely! +// +// This source code is licensed under the BSD-style license found in the +// LICENSE file in the root directory of this source tree. + +using Atypical.VirtualFileSystem.GitHub.Providers; +using Atypical.VirtualFileSystem.Providers.Abstractions; + +namespace Atypical.VirtualFileSystem.GitHub.Tests.Providers; + +public class GitHubStorageProviderTests +{ + [Fact] + public void Provider_exposes_github_id_and_pr_capabilities() + { + var provider = new GitHubStorageProvider(new GitHubRepositoryLoader(), new GitHubWriteService(), new GitHubProviderAuth()); + provider.Id.ShouldBe("github"); + provider.Capabilities.SupportsPullRequests.ShouldBeTrue(); + provider.Capabilities.SupportsBranches.ShouldBeTrue(); + provider.Capabilities.SupportsAtomicMultiFileCommit.ShouldBeTrue(); + provider.Capabilities.AuthKind.ShouldBe(AuthKind.Token); + } +} From 0d9d3224f9611beba7dd8317b496bfeabf851e09 Mon Sep 17 00:00:00 2001 From: Philippe Matray Date: Tue, 2 Jun 2026 12:15:37 +0200 Subject: [PATCH 11/26] fix(github): map ProviderLoadOptions.RemoteRoot to SubPath (remote filter), not TargetPath Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Providers/GitHubProviderAdapters.cs | 5 ++++- .../Providers/GitHubProviderAdaptersTests.cs | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/Atypical.VirtualFileSystem.GitHub/Providers/GitHubProviderAdapters.cs b/src/Atypical.VirtualFileSystem.GitHub/Providers/GitHubProviderAdapters.cs index 06dfc07..0c7524e 100644 --- a/src/Atypical.VirtualFileSystem.GitHub/Providers/GitHubProviderAdapters.cs +++ b/src/Atypical.VirtualFileSystem.GitHub/Providers/GitHubProviderAdapters.cs @@ -18,7 +18,10 @@ public static class GitHubProviderAdapters public static GitHubLoaderOptions ToGitHubLoaderOptions(ProviderLoadOptions o, string? accessToken) => new( AccessToken: accessToken, - TargetPath: o.RemoteRoot ?? "/", + // RemoteRoot is the REMOTE sub-path to import FROM, so it maps to the loader's + // SubPath (a remote filter). TargetPath is the VFS DESTINATION root and is left + // at the loader's default ("/"), preserving the original load layout. + SubPath: o.RemoteRoot, MaxFileSize: o.MaxFileSizeBytes, Strategy: o.Strategy switch { diff --git a/tests/Atypical.VirtualFileSystem.GitHub.Tests/Providers/GitHubProviderAdaptersTests.cs b/tests/Atypical.VirtualFileSystem.GitHub.Tests/Providers/GitHubProviderAdaptersTests.cs index 8e6f957..9a54970 100644 --- a/tests/Atypical.VirtualFileSystem.GitHub.Tests/Providers/GitHubProviderAdaptersTests.cs +++ b/tests/Atypical.VirtualFileSystem.GitHub.Tests/Providers/GitHubProviderAdaptersTests.cs @@ -25,7 +25,9 @@ public void ToGitHubLoaderOptions_maps_strategy_and_filters() gh.Strategy.ShouldBe(GitHubLoadingStrategy.Lazy); gh.MaxFileSize.ShouldBe(1234); - gh.TargetPath.ShouldBe("src"); + // RemoteRoot maps to the REMOTE filter (SubPath), not the VFS destination (TargetPath). + gh.SubPath.ShouldBe("src"); + gh.TargetPath.ShouldBe("/"); gh.AccessToken.ShouldBe("tok"); } From b28400a1712b8022c238738477c9dea4daa43a92 Mon Sep 17 00:00:00 2001 From: Philippe Matray Date: Tue, 2 Jun 2026 12:48:49 +0200 Subject: [PATCH 12/26] refactor(github): forward glob filter intent and fix result RemoteRoot Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Providers/GitHubProviderAdapters.cs | 17 +++++++++++++++-- .../Providers/GitHubStorageProvider.cs | 2 +- .../Providers/GitHubProviderAdaptersTests.cs | 4 +++- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/Atypical.VirtualFileSystem.GitHub/Providers/GitHubProviderAdapters.cs b/src/Atypical.VirtualFileSystem.GitHub/Providers/GitHubProviderAdapters.cs index 0c7524e..aed0407 100644 --- a/src/Atypical.VirtualFileSystem.GitHub/Providers/GitHubProviderAdapters.cs +++ b/src/Atypical.VirtualFileSystem.GitHub/Providers/GitHubProviderAdapters.cs @@ -35,6 +35,10 @@ public static GitHubLoaderOptions ToGitHubLoaderOptions(ProviderLoadOptions o, s ExcludeExtensions: o.BlockedExtensions is null ? null : new HashSet(o.BlockedExtensions, StringComparer.OrdinalIgnoreCase), + // NOTE: GlobPattern is not forwarded this milestone (GitHub exposes only exclude patterns + // via ExcludePatterns; include-glob support is a follow-on). The neutral GlobPattern is an + // include-style filter, so mapping it onto the exclude-style ExcludePatterns would invert + // its meaning. Dropping it is intentional rather than mismapping it. MetadataCallback: o.MetadataCallback, ProgressCallback: o.ProgressCallback is null ? null @@ -44,15 +48,22 @@ public static GitHubLoaderOptions ToGitHubLoaderOptions(ProviderLoadOptions o, s /// /// Maps a to a neutral . /// - public static ProviderLoadResult ToProviderLoadResult(GitHubLoadResult r) + /// The GitHub load result. + /// + /// The originally requested remote root (e.g. owner/repo[/subpath]) echoed back on the + /// result. The GitHub result only carries the VFS TargetPath (usually /), so the + /// neutral is supplied by the caller instead. + /// + public static ProviderLoadResult ToProviderLoadResult(GitHubLoadResult r, string requestedRemoteRoot) => new() { - RemoteRoot = r.TargetPath ?? string.Empty, + RemoteRoot = requestedRemoteRoot, FilesLoaded = r.FilesLoaded, DirectoriesCreated = r.DirectoriesCreated, TotalBytes = r.TotalBytesLoaded, Duration = r.LoadDuration, Skipped = r.SkippedFiles + // Size is unknown for some skip reasons; the neutral record uses 0 as 'unknown'. .Select(s => new ProviderSkippedFile(s.Path, ToSkipReason(s.Reason), s.Size ?? 0, s.ErrorMessage)) .ToList(), }; @@ -60,6 +71,8 @@ public static ProviderLoadResult ToProviderLoadResult(GitHubLoadResult r) private static ProviderSkipReason ToSkipReason(SkipReason r) => r switch { SkipReason.TooLarge => ProviderSkipReason.TooLarge, + // BinaryExcluded / ExtensionNotIncluded / ExtensionExcluded intentionally collapse to + // BlockedExtension: the neutral enum is coarser and does not distinguish those reasons. SkipReason.ExtensionNotIncluded => ProviderSkipReason.BlockedExtension, SkipReason.ExtensionExcluded => ProviderSkipReason.BlockedExtension, SkipReason.BinaryExcluded => ProviderSkipReason.BlockedExtension, diff --git a/src/Atypical.VirtualFileSystem.GitHub/Providers/GitHubStorageProvider.cs b/src/Atypical.VirtualFileSystem.GitHub/Providers/GitHubStorageProvider.cs index f77fbf3..e7d2aa2 100644 --- a/src/Atypical.VirtualFileSystem.GitHub/Providers/GitHubStorageProvider.cs +++ b/src/Atypical.VirtualFileSystem.GitHub/Providers/GitHubStorageProvider.cs @@ -76,7 +76,7 @@ public async Task ImportAsync( options with { RemoteRoot = subPath }, _auth.Token); var result = await _loader.LoadRepositoryAsync(vfs, owner, repo, ghOptions, cancellationToken); - return GitHubProviderAdapters.ToProviderLoadResult(result); + return GitHubProviderAdapters.ToProviderLoadResult(result, options.RemoteRoot ?? string.Empty); } /// diff --git a/tests/Atypical.VirtualFileSystem.GitHub.Tests/Providers/GitHubProviderAdaptersTests.cs b/tests/Atypical.VirtualFileSystem.GitHub.Tests/Providers/GitHubProviderAdaptersTests.cs index 9a54970..054133b 100644 --- a/tests/Atypical.VirtualFileSystem.GitHub.Tests/Providers/GitHubProviderAdaptersTests.cs +++ b/tests/Atypical.VirtualFileSystem.GitHub.Tests/Providers/GitHubProviderAdaptersTests.cs @@ -46,11 +46,13 @@ public void ToProviderLoadResult_maps_counts_and_skips() LoadDuration: TimeSpan.FromSeconds(1), TargetPath: "src"); - var result = GitHubProviderAdapters.ToProviderLoadResult(ghResult); + var result = GitHubProviderAdapters.ToProviderLoadResult(ghResult, requestedRemoteRoot: "o/r/src"); + result.RemoteRoot.ShouldBe("o/r/src"); result.FilesLoaded.ShouldBe(3); result.DirectoriesCreated.ShouldBe(2); result.TotalBytes.ShouldBe(99); + result.Duration.ShouldBe(TimeSpan.FromSeconds(1)); result.Skipped.Count.ShouldBe(1); result.Skipped[0].Reason.ShouldBe(ProviderSkipReason.LoadError); } From 51bcc536f4b45288f67acea424746c0edffe68cb Mon Sep 17 00:00:00 2001 From: Philippe Matray Date: Tue, 2 Jun 2026 12:52:43 +0200 Subject: [PATCH 13/26] feat(ftp): scaffold Ftp project with FluentFTP Co-Authored-By: Claude Opus 4.8 (1M context) --- Atypical.VirtualFileSystem.sln | 15 +++++++++++++++ .../Atypical.VirtualFileSystem.Ftp.csproj | 11 +++++++++++ .../GlobalUsings.cs | 3 +++ 3 files changed, 29 insertions(+) create mode 100644 src/Atypical.VirtualFileSystem.Ftp/Atypical.VirtualFileSystem.Ftp.csproj create mode 100644 src/Atypical.VirtualFileSystem.Ftp/GlobalUsings.cs diff --git a/Atypical.VirtualFileSystem.sln b/Atypical.VirtualFileSystem.sln index d3c9c3b..3ffb037 100644 --- a/Atypical.VirtualFileSystem.sln +++ b/Atypical.VirtualFileSystem.sln @@ -27,6 +27,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Atypical.VirtualFileSystem. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Atypical.VirtualFileSystem.Providers.Abstractions.Tests", "tests\Atypical.VirtualFileSystem.Providers.Abstractions.Tests\Atypical.VirtualFileSystem.Providers.Abstractions.Tests.csproj", "{2E8D3BE3-F419-46B1-BBD3-FC5F753261D0}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Atypical.VirtualFileSystem.Ftp", "src\Atypical.VirtualFileSystem.Ftp\Atypical.VirtualFileSystem.Ftp.csproj", "{9601A06C-4C43-4E68-8576-F92BBFFEA73B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -145,6 +147,18 @@ Global {2E8D3BE3-F419-46B1-BBD3-FC5F753261D0}.Release|x64.Build.0 = Release|Any CPU {2E8D3BE3-F419-46B1-BBD3-FC5F753261D0}.Release|x86.ActiveCfg = Release|Any CPU {2E8D3BE3-F419-46B1-BBD3-FC5F753261D0}.Release|x86.Build.0 = Release|Any CPU + {9601A06C-4C43-4E68-8576-F92BBFFEA73B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9601A06C-4C43-4E68-8576-F92BBFFEA73B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9601A06C-4C43-4E68-8576-F92BBFFEA73B}.Debug|x64.ActiveCfg = Debug|Any CPU + {9601A06C-4C43-4E68-8576-F92BBFFEA73B}.Debug|x64.Build.0 = Debug|Any CPU + {9601A06C-4C43-4E68-8576-F92BBFFEA73B}.Debug|x86.ActiveCfg = Debug|Any CPU + {9601A06C-4C43-4E68-8576-F92BBFFEA73B}.Debug|x86.Build.0 = Debug|Any CPU + {9601A06C-4C43-4E68-8576-F92BBFFEA73B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9601A06C-4C43-4E68-8576-F92BBFFEA73B}.Release|Any CPU.Build.0 = Release|Any CPU + {9601A06C-4C43-4E68-8576-F92BBFFEA73B}.Release|x64.ActiveCfg = Release|Any CPU + {9601A06C-4C43-4E68-8576-F92BBFFEA73B}.Release|x64.Build.0 = Release|Any CPU + {9601A06C-4C43-4E68-8576-F92BBFFEA73B}.Release|x86.ActiveCfg = Release|Any CPU + {9601A06C-4C43-4E68-8576-F92BBFFEA73B}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -159,5 +173,6 @@ Global {31971414-5519-4149-B5C0-C88454EBE7AD} = {10E2E10B-AB65-4800-93ED-CDB737917154} {B2E9D21C-6C01-4F28-AF7C-2EF16148E964} = {BA11C821-EFDE-4714-A0DE-B7D4D93CE336} {2E8D3BE3-F419-46B1-BBD3-FC5F753261D0} = {10E2E10B-AB65-4800-93ED-CDB737917154} + {9601A06C-4C43-4E68-8576-F92BBFFEA73B} = {BA11C821-EFDE-4714-A0DE-B7D4D93CE336} EndGlobalSection EndGlobal diff --git a/src/Atypical.VirtualFileSystem.Ftp/Atypical.VirtualFileSystem.Ftp.csproj b/src/Atypical.VirtualFileSystem.Ftp/Atypical.VirtualFileSystem.Ftp.csproj new file mode 100644 index 0000000..bd61dfa --- /dev/null +++ b/src/Atypical.VirtualFileSystem.Ftp/Atypical.VirtualFileSystem.Ftp.csproj @@ -0,0 +1,11 @@ + + + Atypical.VirtualFileSystem.Ftp + + + + + + + + diff --git a/src/Atypical.VirtualFileSystem.Ftp/GlobalUsings.cs b/src/Atypical.VirtualFileSystem.Ftp/GlobalUsings.cs new file mode 100644 index 0000000..b3e9dcc --- /dev/null +++ b/src/Atypical.VirtualFileSystem.Ftp/GlobalUsings.cs @@ -0,0 +1,3 @@ +global using Atypical.VirtualFileSystem.Core; +global using Atypical.VirtualFileSystem.Core.Contracts; +global using Atypical.VirtualFileSystem.Providers.Abstractions; From fa427d6d6908c58bbe180ae9f88d1ae54e3ee0b7 Mon Sep 17 00:00:00 2001 From: Philippe Matray Date: Tue, 2 Jun 2026 12:53:27 +0200 Subject: [PATCH 14/26] feat(ftp): IFtpConnection seam + FluentFTP implementation Co-Authored-By: Claude Opus 4.8 (1M context) --- .../FluentFtpConnection.cs | 48 +++++++++++++++++++ .../FtpConnectionSettings.cs | 10 ++++ .../IFtpConnection.cs | 14 ++++++ 3 files changed, 72 insertions(+) create mode 100644 src/Atypical.VirtualFileSystem.Ftp/FluentFtpConnection.cs create mode 100644 src/Atypical.VirtualFileSystem.Ftp/FtpConnectionSettings.cs create mode 100644 src/Atypical.VirtualFileSystem.Ftp/IFtpConnection.cs diff --git a/src/Atypical.VirtualFileSystem.Ftp/FluentFtpConnection.cs b/src/Atypical.VirtualFileSystem.Ftp/FluentFtpConnection.cs new file mode 100644 index 0000000..075eadf --- /dev/null +++ b/src/Atypical.VirtualFileSystem.Ftp/FluentFtpConnection.cs @@ -0,0 +1,48 @@ +using FluentFTP; + +namespace Atypical.VirtualFileSystem.Ftp; + +/// +/// Wraps from FluentFTP (v54.x). +/// Recursive listing is handled by ; this lists one directory at a time. +/// +public sealed class FluentFtpConnection : IFtpConnection, IAsyncDisposable +{ + private readonly AsyncFtpClient _client; + + public FluentFtpConnection(FtpConnectionSettings settings) + { + _client = new AsyncFtpClient(settings.Host, settings.Username, settings.Password, settings.Port); + _client.Config.EncryptionMode = settings.UseTls ? FtpEncryptionMode.Explicit : FtpEncryptionMode.None; + // For demo purposes, accept any certificate when TLS is enabled; document for production hardening. + _client.Config.ValidateAnyCertificate = settings.UseTls; + } + + public Task ConnectAsync(CancellationToken ct) => _client.Connect(ct); + public Task DisconnectAsync(CancellationToken ct) => _client.Disconnect(ct); + + public async Task> ListAsync(string remoteDir, CancellationToken ct) + { + // GetListing(string path, CancellationToken token) → Task + var items = await _client.GetListing(remoteDir, ct); + return items.Select(i => new FtpRemoteItem( + i.FullName, + i.Type == FtpObjectType.Directory, + i.Size, + i.Modified.ToUniversalTime())).ToList(); + } + + public Task DownloadAsync(string remotePath, CancellationToken ct) + // DownloadBytes(string remotePath, CancellationToken token) → Task + => _client.DownloadBytes(remotePath, ct); + + public async Task UploadAsync(string remotePath, byte[] content, CancellationToken ct) + // UploadBytes(byte[] fileData, string remotePath, FtpRemoteExists existsMode, bool createRemoteDir, + // IProgress progress, CancellationToken token) → Task + => await _client.UploadBytes(content, remotePath, FtpRemoteExists.Overwrite, createRemoteDir: true, progress: null, token: ct); + + public Task DeleteFileAsync(string remotePath, CancellationToken ct) + => _client.DeleteFile(remotePath, ct); + + public async ValueTask DisposeAsync() => await _client.DisposeAsync(); +} diff --git a/src/Atypical.VirtualFileSystem.Ftp/FtpConnectionSettings.cs b/src/Atypical.VirtualFileSystem.Ftp/FtpConnectionSettings.cs new file mode 100644 index 0000000..3d45104 --- /dev/null +++ b/src/Atypical.VirtualFileSystem.Ftp/FtpConnectionSettings.cs @@ -0,0 +1,10 @@ +namespace Atypical.VirtualFileSystem.Ftp; + +public sealed record FtpConnectionSettings +{ + public required string Host { get; init; } + public int Port { get; init; } = 21; + public string? Username { get; init; } + public string? Password { get; init; } + public bool UseTls { get; init; } +} diff --git a/src/Atypical.VirtualFileSystem.Ftp/IFtpConnection.cs b/src/Atypical.VirtualFileSystem.Ftp/IFtpConnection.cs new file mode 100644 index 0000000..cff99d5 --- /dev/null +++ b/src/Atypical.VirtualFileSystem.Ftp/IFtpConnection.cs @@ -0,0 +1,14 @@ +namespace Atypical.VirtualFileSystem.Ftp; + +public sealed record FtpRemoteItem(string FullPath, bool IsDirectory, long SizeBytes, DateTime LastModifiedUtc); + +/// Minimal async FTP surface used by FtpStorageProvider; backed by FluentFTP in production. +public interface IFtpConnection +{ + Task ConnectAsync(CancellationToken ct); + Task DisconnectAsync(CancellationToken ct); + Task> ListAsync(string remoteDir, CancellationToken ct); + Task DownloadAsync(string remotePath, CancellationToken ct); + Task UploadAsync(string remotePath, byte[] content, CancellationToken ct); + Task DeleteFileAsync(string remotePath, CancellationToken ct); +} From 94932ec07986d08705cf7466d8da1e8b0cedc0fa Mon Sep 17 00:00:00 2001 From: Philippe Matray Date: Tue, 2 Jun 2026 12:56:01 +0200 Subject: [PATCH 15/26] feat(ftp): FtpStorageProvider import + FtpProviderAuth (TDD with fake) Co-Authored-By: Claude Opus 4.8 (1M context) --- Atypical.VirtualFileSystem.sln | 15 ++ .../FtpProviderAuth.cs | 56 ++++++ .../FtpStorageProvider.cs | 165 ++++++++++++++++++ .../GlobalUsings.cs | 1 + ...typical.VirtualFileSystem.Ftp.Tests.csproj | 26 +++ .../FakeFtpConnection.cs | 48 +++++ .../FtpStorageProviderImportTests.cs | 42 +++++ .../GlobalUsings.cs | 5 + 8 files changed, 358 insertions(+) create mode 100644 src/Atypical.VirtualFileSystem.Ftp/FtpProviderAuth.cs create mode 100644 src/Atypical.VirtualFileSystem.Ftp/FtpStorageProvider.cs create mode 100644 tests/Atypical.VirtualFileSystem.Ftp.Tests/Atypical.VirtualFileSystem.Ftp.Tests.csproj create mode 100644 tests/Atypical.VirtualFileSystem.Ftp.Tests/FakeFtpConnection.cs create mode 100644 tests/Atypical.VirtualFileSystem.Ftp.Tests/FtpStorageProviderImportTests.cs create mode 100644 tests/Atypical.VirtualFileSystem.Ftp.Tests/GlobalUsings.cs diff --git a/Atypical.VirtualFileSystem.sln b/Atypical.VirtualFileSystem.sln index 3ffb037..fea8114 100644 --- a/Atypical.VirtualFileSystem.sln +++ b/Atypical.VirtualFileSystem.sln @@ -29,6 +29,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Atypical.VirtualFileSystem. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Atypical.VirtualFileSystem.Ftp", "src\Atypical.VirtualFileSystem.Ftp\Atypical.VirtualFileSystem.Ftp.csproj", "{9601A06C-4C43-4E68-8576-F92BBFFEA73B}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Atypical.VirtualFileSystem.Ftp.Tests", "tests\Atypical.VirtualFileSystem.Ftp.Tests\Atypical.VirtualFileSystem.Ftp.Tests.csproj", "{37D1D6B7-C4E5-44EC-96C1-C2C6CDB900C5}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -159,6 +161,18 @@ Global {9601A06C-4C43-4E68-8576-F92BBFFEA73B}.Release|x64.Build.0 = Release|Any CPU {9601A06C-4C43-4E68-8576-F92BBFFEA73B}.Release|x86.ActiveCfg = Release|Any CPU {9601A06C-4C43-4E68-8576-F92BBFFEA73B}.Release|x86.Build.0 = Release|Any CPU + {37D1D6B7-C4E5-44EC-96C1-C2C6CDB900C5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {37D1D6B7-C4E5-44EC-96C1-C2C6CDB900C5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {37D1D6B7-C4E5-44EC-96C1-C2C6CDB900C5}.Debug|x64.ActiveCfg = Debug|Any CPU + {37D1D6B7-C4E5-44EC-96C1-C2C6CDB900C5}.Debug|x64.Build.0 = Debug|Any CPU + {37D1D6B7-C4E5-44EC-96C1-C2C6CDB900C5}.Debug|x86.ActiveCfg = Debug|Any CPU + {37D1D6B7-C4E5-44EC-96C1-C2C6CDB900C5}.Debug|x86.Build.0 = Debug|Any CPU + {37D1D6B7-C4E5-44EC-96C1-C2C6CDB900C5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {37D1D6B7-C4E5-44EC-96C1-C2C6CDB900C5}.Release|Any CPU.Build.0 = Release|Any CPU + {37D1D6B7-C4E5-44EC-96C1-C2C6CDB900C5}.Release|x64.ActiveCfg = Release|Any CPU + {37D1D6B7-C4E5-44EC-96C1-C2C6CDB900C5}.Release|x64.Build.0 = Release|Any CPU + {37D1D6B7-C4E5-44EC-96C1-C2C6CDB900C5}.Release|x86.ActiveCfg = Release|Any CPU + {37D1D6B7-C4E5-44EC-96C1-C2C6CDB900C5}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -174,5 +188,6 @@ Global {B2E9D21C-6C01-4F28-AF7C-2EF16148E964} = {BA11C821-EFDE-4714-A0DE-B7D4D93CE336} {2E8D3BE3-F419-46B1-BBD3-FC5F753261D0} = {10E2E10B-AB65-4800-93ED-CDB737917154} {9601A06C-4C43-4E68-8576-F92BBFFEA73B} = {BA11C821-EFDE-4714-A0DE-B7D4D93CE336} + {37D1D6B7-C4E5-44EC-96C1-C2C6CDB900C5} = {10E2E10B-AB65-4800-93ED-CDB737917154} EndGlobalSection EndGlobal diff --git a/src/Atypical.VirtualFileSystem.Ftp/FtpProviderAuth.cs b/src/Atypical.VirtualFileSystem.Ftp/FtpProviderAuth.cs new file mode 100644 index 0000000..0d53135 --- /dev/null +++ b/src/Atypical.VirtualFileSystem.Ftp/FtpProviderAuth.cs @@ -0,0 +1,56 @@ +namespace Atypical.VirtualFileSystem.Ftp; + +public sealed class FtpProviderAuth : IStorageProviderAuth +{ + private readonly Func _factory; + + public FtpProviderAuth(Func factory) => _factory = factory; + + public AuthKind Kind => AuthKind.Credentials; + public bool IsAuthenticated => Settings is not null; + public ProviderAccountInfo? Account { get; private set; } + public FtpConnectionSettings? Settings { get; private set; } + public event Action? AuthChanged; + + public async Task AuthenticateAsync(AuthRequest request, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(request.Host)) + return new AuthResult(false, "Host is required.", null); + + var settings = new FtpConnectionSettings + { + Host = request.Host, + Port = request.Port == 0 ? 21 : request.Port, + Username = request.Username, + Password = request.Password, + UseTls = request.UseTls + }; + var conn = _factory(settings); + try + { + await conn.ConnectAsync(ct); + await conn.DisconnectAsync(ct); + } + catch (Exception ex) + { + return new AuthResult(false, $"Connection failed: {ex.Message}", null); + } + finally + { + if (conn is IAsyncDisposable d) await d.DisposeAsync(); + } + + Settings = settings; + Account = new ProviderAccountInfo { DisplayName = $"{request.Username}@{request.Host}" }; + AuthChanged?.Invoke(); + return new AuthResult(true, null, Account); + } + + public Task SignOutAsync(CancellationToken ct = default) + { + Settings = null; + Account = null; + AuthChanged?.Invoke(); + return Task.CompletedTask; + } +} diff --git a/src/Atypical.VirtualFileSystem.Ftp/FtpStorageProvider.cs b/src/Atypical.VirtualFileSystem.Ftp/FtpStorageProvider.cs new file mode 100644 index 0000000..78951f0 --- /dev/null +++ b/src/Atypical.VirtualFileSystem.Ftp/FtpStorageProvider.cs @@ -0,0 +1,165 @@ +using System.Diagnostics; + +namespace Atypical.VirtualFileSystem.Ftp; + +public sealed class FtpStorageProvider : IStorageProvider +{ + private readonly Func _connectionFactory; + private readonly FtpProviderAuth _auth; + + public FtpStorageProvider(Func connectionFactory) + { + _connectionFactory = connectionFactory; + _auth = new FtpProviderAuth(connectionFactory); + } + + public string Id => "ftp"; + public string DisplayName => "FTP"; + public IStorageProviderAuth Auth => _auth; + + public ProviderCapabilities Capabilities => new() { AuthKind = AuthKind.Credentials }; + + public async Task ImportAsync(IVirtualFileSystem vfs, ProviderLoadOptions options, CancellationToken ct = default) + { + // Use authenticated settings if available; fall back to an empty placeholder so the factory + // (which may be a test-double ignoring its argument) still receives a non-null value. + var settings = _auth.Settings ?? new FtpConnectionSettings { Host = string.Empty }; + var root = string.IsNullOrWhiteSpace(options.RemoteRoot) ? "/" : options.RemoteRoot!; + var sw = Stopwatch.StartNew(); + var skipped = new List(); + int files = 0, dirs = 0; + long bytes = 0; + + var conn = _connectionFactory(settings); + try + { + await conn.ConnectAsync(ct); + + var queue = new Queue(); + queue.Enqueue(root); + while (queue.Count > 0) + { + ct.ThrowIfCancellationRequested(); + var current = queue.Dequeue(); + foreach (var item in await conn.ListAsync(current, ct)) + { + if (item.IsDirectory) + { + queue.Enqueue(item.FullPath); + dirs++; + continue; + } + + if (item.SizeBytes > options.MaxFileSizeBytes) + { + skipped.Add(new(item.FullPath, ProviderSkipReason.TooLarge, item.SizeBytes, null)); + continue; + } + + try + { + var data = await conn.DownloadAsync(item.FullPath, ct); + var vfsPath = ToVfsPath(item.FullPath); + var versionToken = $"{item.LastModifiedUtc:O}:{item.SizeBytes}"; + if (IsBinary(item.FullPath)) + vfs.CreateBinaryFileWithDirectories(vfsPath, data); + else + vfs.CreateFileWithDirectories(vfsPath, System.Text.Encoding.UTF8.GetString(data)); + options.MetadataCallback?.Invoke(vfsPath, item.FullPath, versionToken); + files++; + bytes += data.Length; + options.ProgressCallback?.Invoke(files, item.FullPath); + } + catch (Exception ex) + { + skipped.Add(new(item.FullPath, ProviderSkipReason.LoadError, item.SizeBytes, ex.Message)); + } + } + } + } + finally + { + await conn.DisconnectAsync(ct); + if (conn is IAsyncDisposable d) await d.DisposeAsync(); + } + + sw.Stop(); + return new ProviderLoadResult + { + RemoteRoot = root, + FilesLoaded = files, + DirectoriesCreated = dirs, + TotalBytes = bytes, + Skipped = skipped, + Duration = sw.Elapsed + }; + } + + public async Task ReadFileAsync(string remotePath, CancellationToken ct = default) + { + var settings = _auth.Settings ?? throw new InvalidOperationException("FTP provider is not authenticated."); + var conn = _connectionFactory(settings); + try + { + await conn.ConnectAsync(ct); + var data = await conn.DownloadAsync(remotePath, ct); + return new ProviderFileContent(data, VersionToken: string.Empty, IsBinary: IsBinary(remotePath)); + } + finally + { + await conn.DisconnectAsync(ct); + if (conn is IAsyncDisposable d) await d.DisposeAsync(); + } + } + + public async Task WriteChangesAsync(IReadOnlyList changes, CommitContext context, CancellationToken ct = default) + { + var settings = _auth.Settings ?? throw new InvalidOperationException("FTP provider is not authenticated."); + var fileResults = new List(); + + var conn = _connectionFactory(settings); + try + { + await conn.ConnectAsync(ct); + foreach (var change in changes) + { + ct.ThrowIfCancellationRequested(); + try + { + if (change.Kind == ChangeKind.Delete) + await conn.DeleteFileAsync(change.RemotePath, ct); + else + await conn.UploadAsync(change.RemotePath, change.Content ?? [], ct); + fileResults.Add(new(change.RemotePath, true, null)); + } + catch (Exception ex) + { + fileResults.Add(new(change.RemotePath, false, ex.Message)); + } + } + } + finally + { + await conn.DisconnectAsync(ct); + if (conn is IAsyncDisposable d) await d.DisposeAsync(); + } + + return new ProviderWriteResult + { + Success = fileResults.All(r => r.Success), + FileResults = fileResults + }; + } + + public Task GetAccountInfoAsync(CancellationToken ct = default) + => Task.FromResult(_auth.Account ?? ProviderAccountInfo.Anonymous); + + // Map an absolute remote path (e.g. "/data/sub/a.txt") to a VFS-relative path ("data/sub/a.txt"). + private static string ToVfsPath(string remotePath) => remotePath.TrimStart('/'); + + private static bool IsBinary(string path) + { + var ext = Path.GetExtension(path).ToLowerInvariant(); + return ext is ".png" or ".jpg" or ".jpeg" or ".gif" or ".pdf" or ".zip" or ".exe" or ".dll" or ".bin"; + } +} diff --git a/src/Atypical.VirtualFileSystem.Ftp/GlobalUsings.cs b/src/Atypical.VirtualFileSystem.Ftp/GlobalUsings.cs index b3e9dcc..1b2cf45 100644 --- a/src/Atypical.VirtualFileSystem.Ftp/GlobalUsings.cs +++ b/src/Atypical.VirtualFileSystem.Ftp/GlobalUsings.cs @@ -1,3 +1,4 @@ global using Atypical.VirtualFileSystem.Core; global using Atypical.VirtualFileSystem.Core.Contracts; +global using Atypical.VirtualFileSystem.Core.Extensions; global using Atypical.VirtualFileSystem.Providers.Abstractions; diff --git a/tests/Atypical.VirtualFileSystem.Ftp.Tests/Atypical.VirtualFileSystem.Ftp.Tests.csproj b/tests/Atypical.VirtualFileSystem.Ftp.Tests/Atypical.VirtualFileSystem.Ftp.Tests.csproj new file mode 100644 index 0000000..d249ff0 --- /dev/null +++ b/tests/Atypical.VirtualFileSystem.Ftp.Tests/Atypical.VirtualFileSystem.Ftp.Tests.csproj @@ -0,0 +1,26 @@ + + + + false + Atypical.VirtualFileSystem.Ftp.Tests + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/tests/Atypical.VirtualFileSystem.Ftp.Tests/FakeFtpConnection.cs b/tests/Atypical.VirtualFileSystem.Ftp.Tests/FakeFtpConnection.cs new file mode 100644 index 0000000..fc2833e --- /dev/null +++ b/tests/Atypical.VirtualFileSystem.Ftp.Tests/FakeFtpConnection.cs @@ -0,0 +1,48 @@ +namespace Atypical.VirtualFileSystem.Ftp.Tests; + +/// In-memory FTP server: a path->bytes map plus directory entries. +public sealed class FakeFtpConnection : IFtpConnection +{ + public Dictionary Files { get; } = new(); + public HashSet Directories { get; } = new(); + public List Uploaded { get; } = new(); + public List Deleted { get; } = new(); + + public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask; + public Task DisconnectAsync(CancellationToken ct) => Task.CompletedTask; + + public Task> ListAsync(string remoteDir, CancellationToken ct) + { + var dir = remoteDir.TrimEnd('/'); + IReadOnlyList Children() => + Directories.Where(d => ParentOf(d) == dir) + .Select(d => new FtpRemoteItem(d, true, 0, DateTime.UnixEpoch)) + .Concat(Files.Where(f => ParentOf(f.Key) == dir) + .Select(f => new FtpRemoteItem(f.Key, false, f.Value.Length, DateTime.UnixEpoch))) + .ToList(); + return Task.FromResult(Children()); + } + + public Task DownloadAsync(string remotePath, CancellationToken ct) => Task.FromResult(Files[remotePath]); + + public Task UploadAsync(string remotePath, byte[] content, CancellationToken ct) + { + Files[remotePath] = content; + Uploaded.Add(remotePath); + return Task.CompletedTask; + } + + public Task DeleteFileAsync(string remotePath, CancellationToken ct) + { + Files.Remove(remotePath); + Deleted.Add(remotePath); + return Task.CompletedTask; + } + + private static string ParentOf(string path) + { + var p = path.TrimEnd('/'); + var idx = p.LastIndexOf('/'); + return idx <= 0 ? "" : p[..idx]; + } +} diff --git a/tests/Atypical.VirtualFileSystem.Ftp.Tests/FtpStorageProviderImportTests.cs b/tests/Atypical.VirtualFileSystem.Ftp.Tests/FtpStorageProviderImportTests.cs new file mode 100644 index 0000000..e08f0b9 --- /dev/null +++ b/tests/Atypical.VirtualFileSystem.Ftp.Tests/FtpStorageProviderImportTests.cs @@ -0,0 +1,42 @@ +namespace Atypical.VirtualFileSystem.Ftp.Tests; + +public class FtpStorageProviderImportTests +{ + private static FakeFtpConnection BuildTree() + { + var fake = new FakeFtpConnection(); + fake.Directories.Add("/data"); + fake.Directories.Add("/data/sub"); + fake.Files["/data/readme.txt"] = "hello"u8.ToArray(); + fake.Files["/data/sub/a.txt"] = "A"u8.ToArray(); + fake.Files["/data/big.bin"] = new byte[20]; // for size-filter test + return fake; + } + + [Fact] + public async Task ImportAsync_loads_files_recursively_into_the_vfs() + { + var fake = BuildTree(); + var provider = new FtpStorageProvider(_ => fake); + var vfs = new VFS(); + + var result = await provider.ImportAsync(vfs, new ProviderLoadOptions { RemoteRoot = "/data" }); + + result.FilesLoaded.ShouldBe(3); + vfs.Index.ContainsKey(new VFSFilePath("data/readme.txt")).ShouldBeTrue(); + vfs.Index.ContainsKey(new VFSFilePath("data/sub/a.txt")).ShouldBeTrue(); + } + + [Fact] + public async Task ImportAsync_skips_files_over_the_max_size() + { + var fake = BuildTree(); + var provider = new FtpStorageProvider(_ => fake); + var vfs = new VFS(); + + var result = await provider.ImportAsync(vfs, new ProviderLoadOptions { RemoteRoot = "/data", MaxFileSizeBytes = 10 }); + + result.Skipped.ShouldContain(s => s.RemotePath == "/data/big.bin" && s.Reason == ProviderSkipReason.TooLarge); + vfs.Index.ContainsKey(new VFSFilePath("data/big.bin")).ShouldBeFalse(); + } +} diff --git a/tests/Atypical.VirtualFileSystem.Ftp.Tests/GlobalUsings.cs b/tests/Atypical.VirtualFileSystem.Ftp.Tests/GlobalUsings.cs new file mode 100644 index 0000000..659c77e --- /dev/null +++ b/tests/Atypical.VirtualFileSystem.Ftp.Tests/GlobalUsings.cs @@ -0,0 +1,5 @@ +global using Atypical.VirtualFileSystem.Core; +global using Atypical.VirtualFileSystem.Ftp; +global using Atypical.VirtualFileSystem.Providers.Abstractions; +global using Shouldly; +global using Xunit; From 210ee6a917ce223f758f3cd4924439572962ce5f Mon Sep 17 00:00:00 2001 From: Philippe Matray Date: Tue, 2 Jun 2026 12:56:56 +0200 Subject: [PATCH 16/26] feat(ftp): WriteChangesAsync overwrite/delete with per-file results Co-Authored-By: Claude Opus 4.8 (1M context) --- .../FtpStorageProviderWriteTests.cs | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 tests/Atypical.VirtualFileSystem.Ftp.Tests/FtpStorageProviderWriteTests.cs diff --git a/tests/Atypical.VirtualFileSystem.Ftp.Tests/FtpStorageProviderWriteTests.cs b/tests/Atypical.VirtualFileSystem.Ftp.Tests/FtpStorageProviderWriteTests.cs new file mode 100644 index 0000000..d1e067c --- /dev/null +++ b/tests/Atypical.VirtualFileSystem.Ftp.Tests/FtpStorageProviderWriteTests.cs @@ -0,0 +1,47 @@ +namespace Atypical.VirtualFileSystem.Ftp.Tests; + +public class FtpStorageProviderWriteTests +{ + private static FtpStorageProvider AuthedProvider(FakeFtpConnection fake) + { + var provider = new FtpStorageProvider(_ => fake); + // authenticate (fake connect succeeds) + provider.Auth.AuthenticateAsync(new AuthRequest { Host = "h", Username = "u", Password = "p" }).GetAwaiter().GetResult(); + return provider; + } + + [Fact] + public async Task WriteChangesAsync_uploads_adds_and_updates_and_reports_per_file() + { + var fake = new FakeFtpConnection(); + var provider = AuthedProvider(fake); + + var changes = new List + { + new() { RemotePath = "/data/new.txt", Kind = ChangeKind.Add, Content = "x"u8.ToArray() }, + new() { RemotePath = "/data/edit.txt", Kind = ChangeKind.Update, Content = "y"u8.ToArray() }, + }; + + var result = await provider.WriteChangesAsync(changes, CommitContext.Empty); + + result.Success.ShouldBeTrue(); + result.FileResults.Count.ShouldBe(2); + fake.Uploaded.ShouldContain("/data/new.txt"); + fake.Files["/data/edit.txt"].ShouldBe("y"u8.ToArray()); + result.PullRequestUrl.ShouldBeNull(); + } + + [Fact] + public async Task WriteChangesAsync_deletes_files() + { + var fake = new FakeFtpConnection(); + fake.Files["/data/gone.txt"] = "z"u8.ToArray(); + var provider = AuthedProvider(fake); + + var result = await provider.WriteChangesAsync( + [new() { RemotePath = "/data/gone.txt", Kind = ChangeKind.Delete }], CommitContext.Empty); + + result.Success.ShouldBeTrue(); + fake.Deleted.ShouldContain("/data/gone.txt"); + } +} From a5fb72325bcef026d34c1e6f13185ec6d4f629d6 Mon Sep 17 00:00:00 2001 From: Philippe Matray Date: Tue, 2 Jun 2026 12:57:21 +0200 Subject: [PATCH 17/26] feat(ftp): AddVirtualFileSystemFtp DI registration Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ServiceCollectionExtensions.cs | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 src/Atypical.VirtualFileSystem.Ftp/ServiceCollectionExtensions.cs diff --git a/src/Atypical.VirtualFileSystem.Ftp/ServiceCollectionExtensions.cs b/src/Atypical.VirtualFileSystem.Ftp/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..2a6ce2c --- /dev/null +++ b/src/Atypical.VirtualFileSystem.Ftp/ServiceCollectionExtensions.cs @@ -0,0 +1,27 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Atypical.VirtualFileSystem.Ftp; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddVirtualFileSystemFtp(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(sp => + { + var factory = sp.GetRequiredService(); + return new FtpStorageProvider(settings => factory.Create(settings)); + }); + return services; + } +} + +public interface IFtpConnectionFactory +{ + IFtpConnection Create(FtpConnectionSettings settings); +} + +public sealed class FluentFtpConnectionFactory : IFtpConnectionFactory +{ + public IFtpConnection Create(FtpConnectionSettings settings) => new FluentFtpConnection(settings); +} From 6bcbbc557842b970e8ef60ef52b9edce9eb471ed Mon Sep 17 00:00:00 2001 From: Philippe Matray Date: Tue, 2 Jun 2026 13:08:35 +0200 Subject: [PATCH 18/26] refactor(ftp): consistent auth guard, leak-safe disconnect, dir nodes, TLS hardening Co-Authored-By: Claude Opus 4.8 (1M context) --- .../FluentFtpConnection.cs | 7 +-- .../FluentFtpConnectionFactory.cs | 8 +++ .../FtpConnectionSettings.cs | 18 +++++++ .../FtpProviderAuth.cs | 19 ++++++- .../FtpRemoteItem.cs | 8 +++ .../FtpStorageProvider.cs | 54 +++++++++++++++---- .../IFtpConnection.cs | 15 ++++-- .../IFtpConnectionFactory.cs | 8 +++ .../ServiceCollectionExtensions.cs | 16 +++--- .../FakeFtpConnection.cs | 4 +- .../FtpStorageProviderImportTests.cs | 29 ++++++++-- .../FtpStorageProviderWriteTests.cs | 8 +-- 12 files changed, 159 insertions(+), 35 deletions(-) create mode 100644 src/Atypical.VirtualFileSystem.Ftp/FluentFtpConnectionFactory.cs create mode 100644 src/Atypical.VirtualFileSystem.Ftp/FtpRemoteItem.cs create mode 100644 src/Atypical.VirtualFileSystem.Ftp/IFtpConnectionFactory.cs diff --git a/src/Atypical.VirtualFileSystem.Ftp/FluentFtpConnection.cs b/src/Atypical.VirtualFileSystem.Ftp/FluentFtpConnection.cs index 075eadf..ae62887 100644 --- a/src/Atypical.VirtualFileSystem.Ftp/FluentFtpConnection.cs +++ b/src/Atypical.VirtualFileSystem.Ftp/FluentFtpConnection.cs @@ -14,8 +14,9 @@ public FluentFtpConnection(FtpConnectionSettings settings) { _client = new AsyncFtpClient(settings.Host, settings.Username, settings.Password, settings.Port); _client.Config.EncryptionMode = settings.UseTls ? FtpEncryptionMode.Explicit : FtpEncryptionMode.None; - // For demo purposes, accept any certificate when TLS is enabled; document for production hardening. - _client.Config.ValidateAnyCertificate = settings.UseTls; + // Validate server certificates by default; skip validation only when explicitly opted in + // (e.g. for a trusted self-signed test server). + _client.Config.ValidateAnyCertificate = settings is { UseTls: true, AllowInvalidCertificate: true }; } public Task ConnectAsync(CancellationToken ct) => _client.Connect(ct); @@ -29,7 +30,7 @@ public async Task> ListAsync(string remoteDir, Canc i.FullName, i.Type == FtpObjectType.Directory, i.Size, - i.Modified.ToUniversalTime())).ToList(); + new DateTimeOffset(i.Modified.ToUniversalTime(), TimeSpan.Zero))).ToList(); } public Task DownloadAsync(string remotePath, CancellationToken ct) diff --git a/src/Atypical.VirtualFileSystem.Ftp/FluentFtpConnectionFactory.cs b/src/Atypical.VirtualFileSystem.Ftp/FluentFtpConnectionFactory.cs new file mode 100644 index 0000000..53b94f6 --- /dev/null +++ b/src/Atypical.VirtualFileSystem.Ftp/FluentFtpConnectionFactory.cs @@ -0,0 +1,8 @@ +namespace Atypical.VirtualFileSystem.Ftp; + +/// The production that creates FluentFTP-backed connections. +public sealed class FluentFtpConnectionFactory : IFtpConnectionFactory +{ + /// + public IFtpConnection Create(FtpConnectionSettings settings) => new FluentFtpConnection(settings); +} diff --git a/src/Atypical.VirtualFileSystem.Ftp/FtpConnectionSettings.cs b/src/Atypical.VirtualFileSystem.Ftp/FtpConnectionSettings.cs index 3d45104..1de338d 100644 --- a/src/Atypical.VirtualFileSystem.Ftp/FtpConnectionSettings.cs +++ b/src/Atypical.VirtualFileSystem.Ftp/FtpConnectionSettings.cs @@ -1,10 +1,28 @@ namespace Atypical.VirtualFileSystem.Ftp; +/// +/// Connection parameters used to open an FTP/FTPS session. +/// public sealed record FtpConnectionSettings { + /// The FTP server host name or IP address. public required string Host { get; init; } + + /// The FTP control-channel port. Defaults to 21. public int Port { get; init; } = 21; + + /// The user name used to authenticate, or null for anonymous access. public string? Username { get; init; } + + /// The password used to authenticate, or null for anonymous access. public string? Password { get; init; } + + /// When true, connects using explicit FTPS (TLS). public bool UseTls { get; init; } + + /// + /// When true, accepts any server certificate without validation (TLS only). + /// Defaults to false; enable only for trusted test servers. + /// + public bool AllowInvalidCertificate { get; init; } } diff --git a/src/Atypical.VirtualFileSystem.Ftp/FtpProviderAuth.cs b/src/Atypical.VirtualFileSystem.Ftp/FtpProviderAuth.cs index 0d53135..2516af5 100644 --- a/src/Atypical.VirtualFileSystem.Ftp/FtpProviderAuth.cs +++ b/src/Atypical.VirtualFileSystem.Ftp/FtpProviderAuth.cs @@ -1,17 +1,32 @@ namespace Atypical.VirtualFileSystem.Ftp; +/// +/// Credentials-based authentication for the FTP provider. Validates credentials by opening +/// (and immediately closing) a connection, and exposes the resulting . +/// public sealed class FtpProviderAuth : IStorageProviderAuth { private readonly Func _factory; + /// Creates the auth helper using the given connection factory. public FtpProviderAuth(Func factory) => _factory = factory; + /// public AuthKind Kind => AuthKind.Credentials; + + /// public bool IsAuthenticated => Settings is not null; + + /// public ProviderAccountInfo? Account { get; private set; } + + /// The validated connection settings, or null when not authenticated. public FtpConnectionSettings? Settings { get; private set; } + + /// public event Action? AuthChanged; + /// public async Task AuthenticateAsync(AuthRequest request, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(request.Host)) @@ -29,7 +44,8 @@ public async Task AuthenticateAsync(AuthRequest request, Cancellatio try { await conn.ConnectAsync(ct); - await conn.DisconnectAsync(ct); + // Disconnect with a non-cancellable token so a cancelled request still closes cleanly. + await conn.DisconnectAsync(CancellationToken.None); } catch (Exception ex) { @@ -46,6 +62,7 @@ public async Task AuthenticateAsync(AuthRequest request, Cancellatio return new AuthResult(true, null, Account); } + /// public Task SignOutAsync(CancellationToken ct = default) { Settings = null; diff --git a/src/Atypical.VirtualFileSystem.Ftp/FtpRemoteItem.cs b/src/Atypical.VirtualFileSystem.Ftp/FtpRemoteItem.cs new file mode 100644 index 0000000..df028fa --- /dev/null +++ b/src/Atypical.VirtualFileSystem.Ftp/FtpRemoteItem.cs @@ -0,0 +1,8 @@ +namespace Atypical.VirtualFileSystem.Ftp; + +/// A single entry returned by an FTP directory listing. +/// The absolute remote path of the entry. +/// Whether the entry is a directory. +/// The size of the entry in bytes (0 for directories). +/// The last-modified timestamp, in UTC. +public sealed record FtpRemoteItem(string FullPath, bool IsDirectory, long SizeBytes, DateTimeOffset LastModifiedUtc); diff --git a/src/Atypical.VirtualFileSystem.Ftp/FtpStorageProvider.cs b/src/Atypical.VirtualFileSystem.Ftp/FtpStorageProvider.cs index 78951f0..e1948f7 100644 --- a/src/Atypical.VirtualFileSystem.Ftp/FtpStorageProvider.cs +++ b/src/Atypical.VirtualFileSystem.Ftp/FtpStorageProvider.cs @@ -2,28 +2,37 @@ namespace Atypical.VirtualFileSystem.Ftp; +/// +/// A read+write storage provider over FTP/FTPS (backed by FluentFTP through the seam). +/// public sealed class FtpStorageProvider : IStorageProvider { private readonly Func _connectionFactory; private readonly FtpProviderAuth _auth; + /// Creates the provider using the given connection factory. public FtpStorageProvider(Func connectionFactory) { _connectionFactory = connectionFactory; _auth = new FtpProviderAuth(connectionFactory); } + /// public string Id => "ftp"; + + /// public string DisplayName => "FTP"; + + /// public IStorageProviderAuth Auth => _auth; + /// public ProviderCapabilities Capabilities => new() { AuthKind = AuthKind.Credentials }; + /// public async Task ImportAsync(IVirtualFileSystem vfs, ProviderLoadOptions options, CancellationToken ct = default) { - // Use authenticated settings if available; fall back to an empty placeholder so the factory - // (which may be a test-double ignoring its argument) still receives a non-null value. - var settings = _auth.Settings ?? new FtpConnectionSettings { Host = string.Empty }; + var settings = _auth.Settings ?? throw new InvalidOperationException("FTP provider is not authenticated."); var root = string.IsNullOrWhiteSpace(options.RemoteRoot) ? "/" : options.RemoteRoot!; var sw = Stopwatch.StartNew(); var skipped = new List(); @@ -47,6 +56,12 @@ public async Task ImportAsync(IVirtualFileSystem vfs, Provid { queue.Enqueue(item.FullPath); dirs++; + + // Materialize the directory node so empty directories appear in the VFS. + var dirPath = ToVfsPath(item.FullPath); + if (!vfs.Index.ContainsKey(new VFSDirectoryPath(dirPath))) + vfs.CreateDirectory(dirPath); + continue; } @@ -79,8 +94,7 @@ public async Task ImportAsync(IVirtualFileSystem vfs, Provid } finally { - await conn.DisconnectAsync(ct); - if (conn is IAsyncDisposable d) await d.DisposeAsync(); + await CloseAsync(conn); } sw.Stop(); @@ -95,6 +109,7 @@ public async Task ImportAsync(IVirtualFileSystem vfs, Provid }; } + /// public async Task ReadFileAsync(string remotePath, CancellationToken ct = default) { var settings = _auth.Settings ?? throw new InvalidOperationException("FTP provider is not authenticated."); @@ -107,11 +122,11 @@ public async Task ReadFileAsync(string remotePath, Cancella } finally { - await conn.DisconnectAsync(ct); - if (conn is IAsyncDisposable d) await d.DisposeAsync(); + await CloseAsync(conn); } } + /// public async Task WriteChangesAsync(IReadOnlyList changes, CommitContext context, CancellationToken ct = default) { var settings = _auth.Settings ?? throw new InvalidOperationException("FTP provider is not authenticated."); @@ -140,8 +155,7 @@ public async Task WriteChangesAsync(IReadOnlyList WriteChangesAsync(IReadOnlyList public Task GetAccountInfoAsync(CancellationToken ct = default) => Task.FromResult(_auth.Account ?? ProviderAccountInfo.Anonymous); + /// + /// Disconnects (best-effort, non-cancellable) and always disposes the connection so the + /// underlying socket is never leaked, even if disconnect throws or the operation was cancelled. + /// + private static async Task CloseAsync(IFtpConnection conn) + { + try + { + await conn.DisconnectAsync(CancellationToken.None); + } + catch + { + // Ignore disconnect failures; disposal below still releases the socket. + } + finally + { + if (conn is IAsyncDisposable d) + await d.DisposeAsync(); + } + } + // Map an absolute remote path (e.g. "/data/sub/a.txt") to a VFS-relative path ("data/sub/a.txt"). private static string ToVfsPath(string remotePath) => remotePath.TrimStart('/'); diff --git a/src/Atypical.VirtualFileSystem.Ftp/IFtpConnection.cs b/src/Atypical.VirtualFileSystem.Ftp/IFtpConnection.cs index cff99d5..aa14927 100644 --- a/src/Atypical.VirtualFileSystem.Ftp/IFtpConnection.cs +++ b/src/Atypical.VirtualFileSystem.Ftp/IFtpConnection.cs @@ -1,14 +1,23 @@ namespace Atypical.VirtualFileSystem.Ftp; -public sealed record FtpRemoteItem(string FullPath, bool IsDirectory, long SizeBytes, DateTime LastModifiedUtc); - -/// Minimal async FTP surface used by FtpStorageProvider; backed by FluentFTP in production. +/// Minimal async FTP surface used by ; backed by FluentFTP in production. public interface IFtpConnection { + /// Opens the FTP control connection. Task ConnectAsync(CancellationToken ct); + + /// Closes the FTP control connection. Task DisconnectAsync(CancellationToken ct); + + /// Lists the immediate children (files and directories) of the given remote directory. Task> ListAsync(string remoteDir, CancellationToken ct); + + /// Downloads the full contents of a remote file. Task DownloadAsync(string remotePath, CancellationToken ct); + + /// Uploads (overwriting) the given content to a remote path, creating parent directories as needed. Task UploadAsync(string remotePath, byte[] content, CancellationToken ct); + + /// Deletes a remote file. Task DeleteFileAsync(string remotePath, CancellationToken ct); } diff --git a/src/Atypical.VirtualFileSystem.Ftp/IFtpConnectionFactory.cs b/src/Atypical.VirtualFileSystem.Ftp/IFtpConnectionFactory.cs new file mode 100644 index 0000000..d8139ad --- /dev/null +++ b/src/Atypical.VirtualFileSystem.Ftp/IFtpConnectionFactory.cs @@ -0,0 +1,8 @@ +namespace Atypical.VirtualFileSystem.Ftp; + +/// Creates instances from connection settings. +public interface IFtpConnectionFactory +{ + /// Creates a new connection for the given settings. + IFtpConnection Create(FtpConnectionSettings settings); +} diff --git a/src/Atypical.VirtualFileSystem.Ftp/ServiceCollectionExtensions.cs b/src/Atypical.VirtualFileSystem.Ftp/ServiceCollectionExtensions.cs index 2a6ce2c..aca6669 100644 --- a/src/Atypical.VirtualFileSystem.Ftp/ServiceCollectionExtensions.cs +++ b/src/Atypical.VirtualFileSystem.Ftp/ServiceCollectionExtensions.cs @@ -2,8 +2,14 @@ namespace Atypical.VirtualFileSystem.Ftp; +/// +/// Extension methods for registering the FTP storage provider in the dependency injection container. +/// public static class ServiceCollectionExtensions { + /// Registers the FTP connection factory and as scoped services. + /// The service collection. + /// The service collection. public static IServiceCollection AddVirtualFileSystemFtp(this IServiceCollection services) { services.AddScoped(); @@ -15,13 +21,3 @@ public static IServiceCollection AddVirtualFileSystemFtp(this IServiceCollection return services; } } - -public interface IFtpConnectionFactory -{ - IFtpConnection Create(FtpConnectionSettings settings); -} - -public sealed class FluentFtpConnectionFactory : IFtpConnectionFactory -{ - public IFtpConnection Create(FtpConnectionSettings settings) => new FluentFtpConnection(settings); -} diff --git a/tests/Atypical.VirtualFileSystem.Ftp.Tests/FakeFtpConnection.cs b/tests/Atypical.VirtualFileSystem.Ftp.Tests/FakeFtpConnection.cs index fc2833e..db76a89 100644 --- a/tests/Atypical.VirtualFileSystem.Ftp.Tests/FakeFtpConnection.cs +++ b/tests/Atypical.VirtualFileSystem.Ftp.Tests/FakeFtpConnection.cs @@ -16,9 +16,9 @@ public Task> ListAsync(string remoteDir, Cancellati var dir = remoteDir.TrimEnd('/'); IReadOnlyList Children() => Directories.Where(d => ParentOf(d) == dir) - .Select(d => new FtpRemoteItem(d, true, 0, DateTime.UnixEpoch)) + .Select(d => new FtpRemoteItem(d, true, 0, DateTimeOffset.UnixEpoch)) .Concat(Files.Where(f => ParentOf(f.Key) == dir) - .Select(f => new FtpRemoteItem(f.Key, false, f.Value.Length, DateTime.UnixEpoch))) + .Select(f => new FtpRemoteItem(f.Key, false, f.Value.Length, DateTimeOffset.UnixEpoch))) .ToList(); return Task.FromResult(Children()); } diff --git a/tests/Atypical.VirtualFileSystem.Ftp.Tests/FtpStorageProviderImportTests.cs b/tests/Atypical.VirtualFileSystem.Ftp.Tests/FtpStorageProviderImportTests.cs index e08f0b9..8064987 100644 --- a/tests/Atypical.VirtualFileSystem.Ftp.Tests/FtpStorageProviderImportTests.cs +++ b/tests/Atypical.VirtualFileSystem.Ftp.Tests/FtpStorageProviderImportTests.cs @@ -13,14 +13,22 @@ private static FakeFtpConnection BuildTree() return fake; } + private static async Task AuthedProviderAsync(FakeFtpConnection fake) + { + var provider = new FtpStorageProvider(_ => fake); + await provider.Auth.AuthenticateAsync(new AuthRequest { Host = "h", Username = "u", Password = "p" }); + return provider; + } + [Fact] public async Task ImportAsync_loads_files_recursively_into_the_vfs() { var fake = BuildTree(); - var provider = new FtpStorageProvider(_ => fake); + var provider = await AuthedProviderAsync(fake); var vfs = new VFS(); - var result = await provider.ImportAsync(vfs, new ProviderLoadOptions { RemoteRoot = "/data" }); + // No size cap so all three files (including the 20-byte big.bin) load. + var result = await provider.ImportAsync(vfs, new ProviderLoadOptions { RemoteRoot = "/data", MaxFileSizeBytes = long.MaxValue }); result.FilesLoaded.ShouldBe(3); vfs.Index.ContainsKey(new VFSFilePath("data/readme.txt")).ShouldBeTrue(); @@ -31,7 +39,7 @@ public async Task ImportAsync_loads_files_recursively_into_the_vfs() public async Task ImportAsync_skips_files_over_the_max_size() { var fake = BuildTree(); - var provider = new FtpStorageProvider(_ => fake); + var provider = await AuthedProviderAsync(fake); var vfs = new VFS(); var result = await provider.ImportAsync(vfs, new ProviderLoadOptions { RemoteRoot = "/data", MaxFileSizeBytes = 10 }); @@ -39,4 +47,19 @@ public async Task ImportAsync_skips_files_over_the_max_size() result.Skipped.ShouldContain(s => s.RemotePath == "/data/big.bin" && s.Reason == ProviderSkipReason.TooLarge); vfs.Index.ContainsKey(new VFSFilePath("data/big.bin")).ShouldBeFalse(); } + + [Fact] + public async Task ImportAsync_materializes_empty_directories_in_the_vfs() + { + var fake = new FakeFtpConnection(); + fake.Directories.Add("/data"); + fake.Directories.Add("/data/empty"); // empty subdirectory: no files inside + var provider = await AuthedProviderAsync(fake); + var vfs = new VFS(); + + var result = await provider.ImportAsync(vfs, new ProviderLoadOptions { RemoteRoot = "/data" }); + + result.FilesLoaded.ShouldBe(0); + vfs.Index.ContainsKey(new VFSDirectoryPath("data/empty")).ShouldBeTrue(); + } } diff --git a/tests/Atypical.VirtualFileSystem.Ftp.Tests/FtpStorageProviderWriteTests.cs b/tests/Atypical.VirtualFileSystem.Ftp.Tests/FtpStorageProviderWriteTests.cs index d1e067c..62391f5 100644 --- a/tests/Atypical.VirtualFileSystem.Ftp.Tests/FtpStorageProviderWriteTests.cs +++ b/tests/Atypical.VirtualFileSystem.Ftp.Tests/FtpStorageProviderWriteTests.cs @@ -2,11 +2,11 @@ namespace Atypical.VirtualFileSystem.Ftp.Tests; public class FtpStorageProviderWriteTests { - private static FtpStorageProvider AuthedProvider(FakeFtpConnection fake) + private static async Task AuthedProviderAsync(FakeFtpConnection fake) { var provider = new FtpStorageProvider(_ => fake); // authenticate (fake connect succeeds) - provider.Auth.AuthenticateAsync(new AuthRequest { Host = "h", Username = "u", Password = "p" }).GetAwaiter().GetResult(); + await provider.Auth.AuthenticateAsync(new AuthRequest { Host = "h", Username = "u", Password = "p" }); return provider; } @@ -14,7 +14,7 @@ private static FtpStorageProvider AuthedProvider(FakeFtpConnection fake) public async Task WriteChangesAsync_uploads_adds_and_updates_and_reports_per_file() { var fake = new FakeFtpConnection(); - var provider = AuthedProvider(fake); + var provider = await AuthedProviderAsync(fake); var changes = new List { @@ -36,7 +36,7 @@ public async Task WriteChangesAsync_deletes_files() { var fake = new FakeFtpConnection(); fake.Files["/data/gone.txt"] = "z"u8.ToArray(); - var provider = AuthedProvider(fake); + var provider = await AuthedProviderAsync(fake); var result = await provider.WriteChangesAsync( [new() { RemotePath = "/data/gone.txt", Kind = ChangeKind.Delete }], CommitContext.Empty); From 0736337d2d12f89f18d2706920e73ad26864fdd4 Mon Sep 17 00:00:00 2001 From: Philippe Matray Date: Tue, 2 Jun 2026 13:11:04 +0200 Subject: [PATCH 19/26] feat(blazor): storage provider registry Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Services/IStorageProviderRegistry.cs | 22 +++++++++ .../Services/StorageProviderRegistry.cs | 47 +++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 src/Atypical.VirtualFileSystem.DemoBlazorApp/Services/IStorageProviderRegistry.cs create mode 100644 src/Atypical.VirtualFileSystem.DemoBlazorApp/Services/StorageProviderRegistry.cs diff --git a/src/Atypical.VirtualFileSystem.DemoBlazorApp/Services/IStorageProviderRegistry.cs b/src/Atypical.VirtualFileSystem.DemoBlazorApp/Services/IStorageProviderRegistry.cs new file mode 100644 index 0000000..6d41101 --- /dev/null +++ b/src/Atypical.VirtualFileSystem.DemoBlazorApp/Services/IStorageProviderRegistry.cs @@ -0,0 +1,22 @@ +using Atypical.VirtualFileSystem.Providers.Abstractions; + +namespace Atypical.VirtualFileSystem.DemoBlazorApp.Services; + +/// +/// Tracks the registered instances and exposes the currently active one. +/// +public interface IStorageProviderRegistry +{ + /// All registered providers, in registration order. + IReadOnlyList Providers { get; } + + /// The currently active provider. Defaults to the first registered provider. + IStorageProvider Active { get; } + + /// Switches the active provider to the one with the given . + /// Thrown when no provider with the given id is registered. + void SetActive(string providerId); + + /// Raised whenever changes. + event Action? ActiveProviderChanged; +} diff --git a/src/Atypical.VirtualFileSystem.DemoBlazorApp/Services/StorageProviderRegistry.cs b/src/Atypical.VirtualFileSystem.DemoBlazorApp/Services/StorageProviderRegistry.cs new file mode 100644 index 0000000..65c7591 --- /dev/null +++ b/src/Atypical.VirtualFileSystem.DemoBlazorApp/Services/StorageProviderRegistry.cs @@ -0,0 +1,47 @@ +using Atypical.VirtualFileSystem.Providers.Abstractions; + +namespace Atypical.VirtualFileSystem.DemoBlazorApp.Services; + +/// +/// Default implementation of . +/// Scoped per Blazor Server circuit; the active provider defaults to the first registered provider. +/// +public sealed class StorageProviderRegistry : IStorageProviderRegistry +{ + private IStorageProvider _active; + + /// + public IReadOnlyList Providers { get; } + + /// + public event Action? ActiveProviderChanged; + + /// + /// Creates a registry from all instances registered in DI. + /// + /// The full set of registered providers (injected as IEnumerable). + /// Thrown when the provider list is empty. + public StorageProviderRegistry(IEnumerable providers) + { + Providers = providers.ToList(); + if (Providers.Count == 0) + throw new InvalidOperationException("No storage providers registered."); + _active = Providers[0]; + } + + /// + public IStorageProvider Active => _active; + + /// + public void SetActive(string providerId) + { + var match = Providers.FirstOrDefault(p => p.Id == providerId) + ?? throw new ArgumentException($"Unknown provider '{providerId}'.", nameof(providerId)); + + if (!ReferenceEquals(match, _active)) + { + _active = match; + ActiveProviderChanged?.Invoke(); + } + } +} From 47e9fd440b50bdf02a6c92ffc277ccc0507e22ae Mon Sep 17 00:00:00 2001 From: Philippe Matray Date: Tue, 2 Jun 2026 13:11:40 +0200 Subject: [PATCH 20/26] feat(blazor): register GitHub and FTP providers + registry Co-Authored-By: Claude Opus 4.8 (1M context) --- ...Atypical.VirtualFileSystem.DemoBlazorApp.csproj | 2 ++ .../Program.cs | 14 ++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/src/Atypical.VirtualFileSystem.DemoBlazorApp/Atypical.VirtualFileSystem.DemoBlazorApp.csproj b/src/Atypical.VirtualFileSystem.DemoBlazorApp/Atypical.VirtualFileSystem.DemoBlazorApp.csproj index a45cd7e..6265c0f 100644 --- a/src/Atypical.VirtualFileSystem.DemoBlazorApp/Atypical.VirtualFileSystem.DemoBlazorApp.csproj +++ b/src/Atypical.VirtualFileSystem.DemoBlazorApp/Atypical.VirtualFileSystem.DemoBlazorApp.csproj @@ -13,6 +13,8 @@ + + diff --git a/src/Atypical.VirtualFileSystem.DemoBlazorApp/Program.cs b/src/Atypical.VirtualFileSystem.DemoBlazorApp/Program.cs index 87789c7..ea06557 100644 --- a/src/Atypical.VirtualFileSystem.DemoBlazorApp/Program.cs +++ b/src/Atypical.VirtualFileSystem.DemoBlazorApp/Program.cs @@ -1,6 +1,10 @@ using Atypical.VirtualFileSystem.DemoBlazorApp.Components; +using Atypical.VirtualFileSystem.DemoBlazorApp.Services; using Atypical.VirtualFileSystem.Core.Services; +using Atypical.VirtualFileSystem.Ftp; using Atypical.VirtualFileSystem.GitHub; +using Atypical.VirtualFileSystem.GitHub.Providers; +using Atypical.VirtualFileSystem.Providers.Abstractions; var builder = WebApplication.CreateBuilder(args); @@ -31,6 +35,16 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); +// Storage providers (neutral abstraction layer — alongside existing GitHub services) +builder.Services.AddVirtualFileSystemFtp(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// Expose both providers as IStorageProvider so the registry receives them via IEnumerable +builder.Services.AddScoped(sp => sp.GetRequiredService()); +builder.Services.AddScoped(sp => sp.GetRequiredService()); +builder.Services.AddScoped(); + var app = builder.Build(); // Configure the HTTP request pipeline. From 1a56b622067d3eebf28879a2273abba67f0afd38 Mon Sep 17 00:00:00 2001 From: Philippe Matray Date: Tue, 2 Jun 2026 13:12:42 +0200 Subject: [PATCH 21/26] feat(blazor): neutral StorageImportService + encrypted credential store Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Program.cs | 4 ++ .../Services/StorageCredentialStore.cs | 58 +++++++++++++++++++ .../Services/StorageImportService.cs | 46 +++++++++++++++ 3 files changed, 108 insertions(+) create mode 100644 src/Atypical.VirtualFileSystem.DemoBlazorApp/Services/StorageCredentialStore.cs create mode 100644 src/Atypical.VirtualFileSystem.DemoBlazorApp/Services/StorageImportService.cs diff --git a/src/Atypical.VirtualFileSystem.DemoBlazorApp/Program.cs b/src/Atypical.VirtualFileSystem.DemoBlazorApp/Program.cs index ea06557..145106e 100644 --- a/src/Atypical.VirtualFileSystem.DemoBlazorApp/Program.cs +++ b/src/Atypical.VirtualFileSystem.DemoBlazorApp/Program.cs @@ -45,6 +45,10 @@ builder.Services.AddScoped(sp => sp.GetRequiredService()); builder.Services.AddScoped(); +// Provider-neutral credential store and import service +builder.Services.AddScoped(); +builder.Services.AddScoped(); + var app = builder.Build(); // Configure the HTTP request pipeline. diff --git a/src/Atypical.VirtualFileSystem.DemoBlazorApp/Services/StorageCredentialStore.cs b/src/Atypical.VirtualFileSystem.DemoBlazorApp/Services/StorageCredentialStore.cs new file mode 100644 index 0000000..047ef0c --- /dev/null +++ b/src/Atypical.VirtualFileSystem.DemoBlazorApp/Services/StorageCredentialStore.cs @@ -0,0 +1,58 @@ +using Atypical.VirtualFileSystem.Providers.Abstractions; +using Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage; + +namespace Atypical.VirtualFileSystem.DemoBlazorApp.Services; + +/// +/// Persists per-provider payloads in encrypted browser local storage +/// (via ). All access is wrapped in try/catch so that the +/// service is prerender-safe (JS interop is unavailable during static prerendering). +/// +public sealed class StorageCredentialStore +{ + private readonly ProtectedLocalStorage _storage; + + public StorageCredentialStore(ProtectedLocalStorage storage) => _storage = storage; + + private static string Key(string providerId) => $"provider_creds_{providerId}"; + + /// Saves the credentials for to encrypted local storage. + public async Task SaveAsync(string providerId, AuthRequest request) + { + try + { + await _storage.SetAsync(Key(providerId), request); + } + catch + { + // Silently fail: JS interop unavailable during prerender, or storage is locked. + } + } + + /// Loads previously saved credentials for , or null if none. + public async Task LoadAsync(string providerId) + { + try + { + var result = await _storage.GetAsync(Key(providerId)); + return result.Success ? result.Value : null; + } + catch + { + return null; + } + } + + /// Removes stored credentials for . + public async Task ClearAsync(string providerId) + { + try + { + await _storage.DeleteAsync(Key(providerId)); + } + catch + { + // Silently fail: unavailable during prerender. + } + } +} diff --git a/src/Atypical.VirtualFileSystem.DemoBlazorApp/Services/StorageImportService.cs b/src/Atypical.VirtualFileSystem.DemoBlazorApp/Services/StorageImportService.cs new file mode 100644 index 0000000..5328d22 --- /dev/null +++ b/src/Atypical.VirtualFileSystem.DemoBlazorApp/Services/StorageImportService.cs @@ -0,0 +1,46 @@ +using Atypical.VirtualFileSystem.Core.Contracts; +using Atypical.VirtualFileSystem.Providers.Abstractions; + +namespace Atypical.VirtualFileSystem.DemoBlazorApp.Services; + +/// +/// Provider-neutral import service that delegates to whichever +/// is currently active in the . +/// Exposes and so UI components can +/// react to import progress without being coupled to a specific provider. +/// +public sealed class StorageImportService +{ + private readonly IStorageProviderRegistry _registry; + private readonly IVirtualFileSystem _vfs; + + public StorageImportService(IStorageProviderRegistry registry, IVirtualFileSystem vfs) + { + _registry = registry; + _vfs = vfs; + } + + /// Raised when changes (import started or finished). + public event Action? OnStateChanged; + + /// True while an import is in progress. + public bool IsImporting { get; private set; } + + /// + /// Imports into the VFS using the active provider and the supplied . + /// + public async Task ImportAsync(ProviderLoadOptions options, CancellationToken ct = default) + { + IsImporting = true; + OnStateChanged?.Invoke(); + try + { + return await _registry.Active.ImportAsync(_vfs, options, ct); + } + finally + { + IsImporting = false; + OnStateChanged?.Invoke(); + } + } +} From eb46f26595aa5b95386c1cd9175f38b04a7cf374 Mon Sep 17 00:00:00 2001 From: Philippe Matray Date: Tue, 2 Jun 2026 13:19:23 +0200 Subject: [PATCH 22/26] refactor(blazor): guard concurrent imports and align registry event naming Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Atypical.VirtualFileSystem.DemoBlazorApp/Program.cs | 3 ++- .../Services/IStorageProviderRegistry.cs | 2 +- .../Services/StorageImportService.cs | 3 +++ .../Services/StorageProviderRegistry.cs | 4 ++-- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/Atypical.VirtualFileSystem.DemoBlazorApp/Program.cs b/src/Atypical.VirtualFileSystem.DemoBlazorApp/Program.cs index 145106e..744332d 100644 --- a/src/Atypical.VirtualFileSystem.DemoBlazorApp/Program.cs +++ b/src/Atypical.VirtualFileSystem.DemoBlazorApp/Program.cs @@ -40,7 +40,8 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); -// Expose both providers as IStorageProvider so the registry receives them via IEnumerable +// Expose both providers as IStorageProvider so the registry receives them via IEnumerable. +// Registration order is intentional: it determines the registry's default active provider (GitHub first = default). builder.Services.AddScoped(sp => sp.GetRequiredService()); builder.Services.AddScoped(sp => sp.GetRequiredService()); builder.Services.AddScoped(); diff --git a/src/Atypical.VirtualFileSystem.DemoBlazorApp/Services/IStorageProviderRegistry.cs b/src/Atypical.VirtualFileSystem.DemoBlazorApp/Services/IStorageProviderRegistry.cs index 6d41101..4e661d7 100644 --- a/src/Atypical.VirtualFileSystem.DemoBlazorApp/Services/IStorageProviderRegistry.cs +++ b/src/Atypical.VirtualFileSystem.DemoBlazorApp/Services/IStorageProviderRegistry.cs @@ -18,5 +18,5 @@ public interface IStorageProviderRegistry void SetActive(string providerId); /// Raised whenever changes. - event Action? ActiveProviderChanged; + event Action? OnActiveProviderChanged; } diff --git a/src/Atypical.VirtualFileSystem.DemoBlazorApp/Services/StorageImportService.cs b/src/Atypical.VirtualFileSystem.DemoBlazorApp/Services/StorageImportService.cs index 5328d22..ad34863 100644 --- a/src/Atypical.VirtualFileSystem.DemoBlazorApp/Services/StorageImportService.cs +++ b/src/Atypical.VirtualFileSystem.DemoBlazorApp/Services/StorageImportService.cs @@ -31,6 +31,9 @@ public StorageImportService(IStorageProviderRegistry registry, IVirtualFileSyste /// public async Task ImportAsync(ProviderLoadOptions options, CancellationToken ct = default) { + if (IsImporting) + throw new InvalidOperationException("An import is already in progress."); + IsImporting = true; OnStateChanged?.Invoke(); try diff --git a/src/Atypical.VirtualFileSystem.DemoBlazorApp/Services/StorageProviderRegistry.cs b/src/Atypical.VirtualFileSystem.DemoBlazorApp/Services/StorageProviderRegistry.cs index 65c7591..70b3ff9 100644 --- a/src/Atypical.VirtualFileSystem.DemoBlazorApp/Services/StorageProviderRegistry.cs +++ b/src/Atypical.VirtualFileSystem.DemoBlazorApp/Services/StorageProviderRegistry.cs @@ -14,7 +14,7 @@ public sealed class StorageProviderRegistry : IStorageProviderRegistry public IReadOnlyList Providers { get; } /// - public event Action? ActiveProviderChanged; + public event Action? OnActiveProviderChanged; /// /// Creates a registry from all instances registered in DI. @@ -41,7 +41,7 @@ public void SetActive(string providerId) if (!ReferenceEquals(match, _active)) { _active = match; - ActiveProviderChanged?.Invoke(); + OnActiveProviderChanged?.Invoke(); } } } From c8c4056965676d5cd5ade68ffb32425091e56fa7 Mon Sep 17 00:00:00 2001 From: Philippe Matray Date: Tue, 2 Jun 2026 13:25:08 +0200 Subject: [PATCH 23/26] feat(blazor): capability-driven import dialog with provider picker Add a provider + @foreach (var p in Registry.Providers) + { + + } + + + + @if (IsGitHub) + { + @* ===== GitHub field set (SupportsBranches) ===== *@ @* Authentication Status *@
@@ -200,18 +217,107 @@
} + } + else if (IsCredentials) + { + @* ===== Credentials field set (e.g. FTP) ===== *@ + @if (_ftpBusy) + { +
+
+ @_ftpBusyMessage +
+ } + else + { + @if (!string.IsNullOrEmpty(_ftpError)) + { +
+
+ + + +
+

Import Failed

+

@_ftpError

+
+
+
+ } + + @if (_ftpResult is not null) + { +
+
+ + + +
+

Import Complete

+

+ Imported @_ftpResult.FilesLoaded files (@_ftpResult.Skipped.Count skipped) from @_ftpResult.RemoteRoot +

+
+
+
+ } + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +

Remote directory to import from (defaults to /)

+
+ } + } }