diff --git a/src/Platforms/Directory.Packages.props b/src/Platforms/Directory.Packages.props
index 5b475e42f..71fd295b3 100644
--- a/src/Platforms/Directory.Packages.props
+++ b/src/Platforms/Directory.Packages.props
@@ -3,6 +3,7 @@
+
@@ -48,4 +49,4 @@
-
\ No newline at end of file
+
diff --git a/src/Platforms/SecureFolderFS.Cli/CliCommandHelpers.cs b/src/Platforms/SecureFolderFS.Cli/CliCommandHelpers.cs
new file mode 100644
index 000000000..2f2d4bc78
--- /dev/null
+++ b/src/Platforms/SecureFolderFS.Cli/CliCommandHelpers.cs
@@ -0,0 +1,81 @@
+using System.Runtime.Serialization;
+using System.Security.Cryptography;
+using CliFx.Infrastructure;
+using OwlCore.Storage;
+using SecureFolderFS.Core;
+using SecureFolderFS.Sdk.Helpers;
+using SecureFolderFS.Shared.Models;
+using SecureFolderFS.Storage.SystemStorageEx;
+using SecureFolderFS.Storage.VirtualFileSystem;
+
+namespace SecureFolderFS.Cli;
+
+internal static class CliCommandHelpers
+{
+ public static IFolder GetVaultFolder(string path)
+ {
+ var fullPath = Path.GetFullPath(path);
+ return new SystemFolderEx(fullPath);
+ }
+
+ public static Dictionary BuildMountOptions(string volumeName, bool readOnly, string? mountPoint)
+ {
+ var options = new Dictionary
+ {
+ [nameof(VirtualFileSystemOptions.VolumeName)] = FormattingHelpers.SanitizeVolumeName(volumeName, "Vault"),
+ [nameof(VirtualFileSystemOptions.IsReadOnly)] = readOnly
+ };
+
+ if (!string.IsNullOrWhiteSpace(mountPoint))
+ options["MountPoint"] = Path.GetFullPath(mountPoint);
+
+ return options;
+ }
+
+ public static VaultOptions BuildVaultOptions(string[] methods, string vaultId, string? contentCipher, string? fileNameCipher)
+ {
+ return new VaultOptions
+ {
+ UnlockProcedure = new AuthenticationMethod(methods, null),
+ VaultId = vaultId,
+ ContentCipherId = string.IsNullOrWhiteSpace(contentCipher)
+ ? Core.Cryptography.Constants.CipherId.AES_GCM
+ : contentCipher,
+ FileNameCipherId = string.IsNullOrWhiteSpace(fileNameCipher)
+ ? Core.Cryptography.Constants.CipherId.AES_SIV
+ : fileNameCipher,
+ };
+ }
+
+ public static int HandleException(Exception ex, IConsole console, CliGlobalOptions options)
+ {
+ switch (ex)
+ {
+ case CryptographicException:
+ case FormatException:
+ CliOutput.Error(console, options, ex.Message);
+ return CliExitCodes.AuthenticationFailure;
+
+ case FileNotFoundException:
+ case DirectoryNotFoundException:
+ case SerializationException:
+ CliOutput.Error(console, options, ex.Message);
+ return CliExitCodes.VaultUnreadable;
+
+ case NotSupportedException:
+ CliOutput.Error(console, options, ex.Message);
+ return CliExitCodes.MountFailure;
+
+ case InvalidOperationException:
+ case ArgumentException:
+ CliOutput.Error(console, options, ex.Message);
+ return CliExitCodes.BadArguments;
+
+ default:
+ CliOutput.Error(console, options, ex.ToString());
+ return CliExitCodes.GeneralError;
+ }
+ }
+}
+
+
diff --git a/src/Platforms/SecureFolderFS.Cli/CliExitCodes.cs b/src/Platforms/SecureFolderFS.Cli/CliExitCodes.cs
new file mode 100644
index 000000000..403fa2808
--- /dev/null
+++ b/src/Platforms/SecureFolderFS.Cli/CliExitCodes.cs
@@ -0,0 +1,13 @@
+namespace SecureFolderFS.Cli;
+
+internal static class CliExitCodes
+{
+ public const int Success = 0;
+ public const int GeneralError = 1;
+ public const int BadArguments = 2;
+ public const int AuthenticationFailure = 3;
+ public const int VaultUnreadable = 4;
+ public const int MountFailure = 5;
+ public const int MountStateError = 6;
+}
+
diff --git a/src/Platforms/SecureFolderFS.Cli/CliGlobalOptions.cs b/src/Platforms/SecureFolderFS.Cli/CliGlobalOptions.cs
new file mode 100644
index 000000000..197ceaf8c
--- /dev/null
+++ b/src/Platforms/SecureFolderFS.Cli/CliGlobalOptions.cs
@@ -0,0 +1,17 @@
+using CliFx.Attributes;
+using CliFx.Infrastructure;
+
+namespace SecureFolderFS.Cli;
+
+public abstract class CliGlobalOptions : CliFx.ICommand
+{
+ [CommandOption("quiet", 'q', Description = "Suppress decorative/info output. Errors always go to stderr.")]
+ public bool Quiet { get; init; }
+
+ [CommandOption("no-color", Description = "Disable ANSI color output.")]
+ public bool NoColor { get; init; }
+
+ public abstract ValueTask ExecuteAsync(IConsole console);
+}
+
+
diff --git a/src/Platforms/SecureFolderFS.Cli/CliLifecycleHelper.cs b/src/Platforms/SecureFolderFS.Cli/CliLifecycleHelper.cs
new file mode 100644
index 000000000..7e41ea5a2
--- /dev/null
+++ b/src/Platforms/SecureFolderFS.Cli/CliLifecycleHelper.cs
@@ -0,0 +1,49 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using OwlCore.Storage;
+using SecureFolderFS.Sdk.Services;
+using SecureFolderFS.Shared.Extensions;
+using SecureFolderFS.UI.Helpers;
+using SecureFolderFS.UI.ServiceImplementation;
+using AddService = Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions;
+
+namespace SecureFolderFS.Cli
+{
+ internal sealed class CliLifecycleHelper : BaseLifecycleHelper
+ {
+ ///
+ public override string AppDirectory { get; } = Directory.GetCurrentDirectory();
+
+ ///
+ public override void LogExceptionToFile(Exception? ex)
+ {
+ _ = ex; // No-op
+ }
+
+ ///
+ protected override IServiceCollection ConfigureServices(IModifiableFolder settingsFolder)
+ {
+ return base.ConfigureServices(settingsFolder)
+ .Override(AddService.AddSingleton)
+ .Override(AddService.AddSingleton)
+ .Override(AddService.AddSingleton)
+ .Override(AddService.AddSingleton);
+ }
+
+ ///
+ protected override IServiceCollection WithLogging(IServiceCollection serviceCollection)
+ {
+ return serviceCollection
+ .AddLogging(builder =>
+ {
+ builder.ClearProviders();
+ builder.SetMinimumLevel(LogLevel.Information);
+ builder.AddSimpleConsole(options =>
+ {
+ options.SingleLine = true;
+ options.TimestampFormat = "HH:mm:ss ";
+ });
+ });
+ }
+ }
+}
diff --git a/src/Platforms/SecureFolderFS.Cli/CliOutput.cs b/src/Platforms/SecureFolderFS.Cli/CliOutput.cs
new file mode 100644
index 000000000..43de10d27
--- /dev/null
+++ b/src/Platforms/SecureFolderFS.Cli/CliOutput.cs
@@ -0,0 +1,53 @@
+using System.Text.RegularExpressions;
+using CliFx.Infrastructure;
+
+namespace SecureFolderFS.Cli;
+
+internal static partial class CliOutput
+{
+ private const string Reset = "\u001b[0m";
+ private const string Red = "\u001b[31m";
+ private const string Yellow = "\u001b[33m";
+ private const string Green = "\u001b[32m";
+
+ public static void Info(IConsole console, CliGlobalOptions options, string message)
+ {
+ if (options.Quiet)
+ return;
+
+ console.Output.WriteLine(StripIfNeeded(options, message));
+ }
+
+ public static void Success(IConsole console, CliGlobalOptions options, string message)
+ {
+ if (options.Quiet)
+ return;
+
+ var line = options.NoColor ? message : $"{Green}{message}{Reset}";
+ console.Output.WriteLine(StripIfNeeded(options, line));
+ }
+
+ public static void Warning(IConsole console, CliGlobalOptions options, string message)
+ {
+ if (options.Quiet)
+ return;
+
+ var line = options.NoColor ? $"warning: {message}" : $"{Yellow}warning:{Reset} {message}";
+ console.Output.WriteLine(StripIfNeeded(options, line));
+ }
+
+ public static void Error(IConsole console, CliGlobalOptions options, string message)
+ {
+ var line = options.NoColor ? $"error: {message}" : $"{Red}error:{Reset} {message}";
+ console.Error.WriteLine(StripIfNeeded(options, line));
+ }
+
+ public static string StripIfNeeded(CliGlobalOptions options, string value)
+ {
+ return options.NoColor ? AnsiRegex().Replace(value, string.Empty) : value;
+ }
+
+ [GeneratedRegex("\\x1B\\[[0-9;]*[A-Za-z]")]
+ private static partial Regex AnsiRegex();
+}
+
diff --git a/src/Platforms/SecureFolderFS.Cli/CliTypeActivator.cs b/src/Platforms/SecureFolderFS.Cli/CliTypeActivator.cs
new file mode 100644
index 000000000..c823da7f3
--- /dev/null
+++ b/src/Platforms/SecureFolderFS.Cli/CliTypeActivator.cs
@@ -0,0 +1,13 @@
+using Microsoft.Extensions.DependencyInjection;
+
+namespace SecureFolderFS.Cli;
+
+internal static class CliTypeActivator
+{
+ public static object CreateInstance(IServiceProvider serviceProvider, Type type)
+ {
+ return ActivatorUtilities.CreateInstance(serviceProvider, type);
+ }
+}
+
+
diff --git a/src/Platforms/SecureFolderFS.Cli/CliVaultCredentialsService.cs b/src/Platforms/SecureFolderFS.Cli/CliVaultCredentialsService.cs
new file mode 100644
index 000000000..ba7d70996
--- /dev/null
+++ b/src/Platforms/SecureFolderFS.Cli/CliVaultCredentialsService.cs
@@ -0,0 +1,27 @@
+using System.Collections.Generic;
+using System.Threading;
+using OwlCore.Storage;
+using SecureFolderFS.Sdk.ViewModels.Controls.Authentication;
+using SecureFolderFS.UI.ServiceImplementation;
+
+namespace SecureFolderFS.Cli;
+
+internal sealed class CliVaultCredentialsService : BaseVaultCredentialsService
+{
+ public override async IAsyncEnumerable GetLoginAsync(IFolder vaultFolder, CancellationToken cancellationToken = default)
+ {
+ _ = vaultFolder;
+ await Task.CompletedTask;
+ yield break;
+ }
+
+ public override async IAsyncEnumerable GetCreationAsync(IFolder vaultFolder, string vaultId,
+ CancellationToken cancellationToken = default)
+ {
+ _ = vaultFolder;
+ _ = vaultId;
+ await Task.CompletedTask;
+ yield break;
+ }
+}
+
diff --git a/src/Platforms/SecureFolderFS.Cli/CliVaultFileSystemService.cs b/src/Platforms/SecureFolderFS.Cli/CliVaultFileSystemService.cs
new file mode 100644
index 000000000..c6c017695
--- /dev/null
+++ b/src/Platforms/SecureFolderFS.Cli/CliVaultFileSystemService.cs
@@ -0,0 +1,40 @@
+using System.Collections.Generic;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+using SecureFolderFS.Core.FUSE;
+using SecureFolderFS.Core.WebDav;
+using SecureFolderFS.Sdk.Enums;
+using SecureFolderFS.Sdk.Models;
+using SecureFolderFS.Sdk.ViewModels.Views.Wizard.DataSources;
+using SecureFolderFS.Storage.VirtualFileSystem;
+using SecureFolderFS.UI.ServiceImplementation;
+
+namespace SecureFolderFS.Cli;
+
+internal sealed class CliVaultFileSystemService : BaseVaultFileSystemService
+{
+ public override async IAsyncEnumerable GetFileSystemsAsync([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ await Task.CompletedTask;
+
+ // Keep ordering aligned with desktop targets: WebDAV first, then native adapters.
+ yield return new CliWebDavFileSystem();
+ yield return new FuseFileSystem();
+
+#if SFFS_WINDOWS_FS
+ yield return new SecureFolderFS.Core.WinFsp.WinFspFileSystem();
+ yield return new SecureFolderFS.Core.Dokany.DokanyFileSystem();
+#endif
+ }
+
+ public override async IAsyncEnumerable GetSourcesAsync(IVaultCollectionModel vaultCollectionModel, NewVaultMode mode,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ _ = vaultCollectionModel;
+ _ = mode;
+ await Task.CompletedTask;
+ yield break;
+ }
+}
+
diff --git a/src/Platforms/SecureFolderFS.Cli/CliWebDavFileSystem.cs b/src/Platforms/SecureFolderFS.Cli/CliWebDavFileSystem.cs
new file mode 100644
index 000000000..06f698cc9
--- /dev/null
+++ b/src/Platforms/SecureFolderFS.Cli/CliWebDavFileSystem.cs
@@ -0,0 +1,31 @@
+using System.IO;
+using System.Net;
+using System.Threading;
+using System.Threading.Tasks;
+using NWebDav.Server.Dispatching;
+using OwlCore.Storage.Memory;
+using SecureFolderFS.Core.FileSystem;
+using SecureFolderFS.Core.FileSystem.Storage;
+using SecureFolderFS.Core.WebDav;
+using SecureFolderFS.Core.WebDav.AppModels;
+using SecureFolderFS.Storage.VirtualFileSystem;
+
+namespace SecureFolderFS.Cli;
+
+internal sealed class CliWebDavFileSystem : WebDavFileSystem
+{
+ protected override async Task MountAsync(FileSystemSpecifics specifics, HttpListener listener, WebDavOptions options,
+ IRequestDispatcher requestDispatcher, CancellationToken cancellationToken)
+ {
+ await Task.CompletedTask;
+
+ var remotePath = $"{options.Protocol}://{options.Domain}:{options.Port}/";
+ var webDavWrapper = new WebDavWrapper(listener, requestDispatcher, remotePath);
+ webDavWrapper.StartFileSystem();
+
+ var virtualizedRoot = new MemoryFolder(remotePath, options.VolumeName);
+ var plaintextRoot = new CryptoFolder(Path.DirectorySeparatorChar.ToString(), specifics.ContentFolder, specifics);
+ return new WebDavVfsRoot(webDavWrapper, virtualizedRoot, plaintextRoot, specifics);
+ }
+}
+
diff --git a/src/Platforms/SecureFolderFS.Cli/CommandOptions.cs b/src/Platforms/SecureFolderFS.Cli/CommandOptions.cs
new file mode 100644
index 000000000..498b757d6
--- /dev/null
+++ b/src/Platforms/SecureFolderFS.Cli/CommandOptions.cs
@@ -0,0 +1,55 @@
+using CliFx.Attributes;
+
+namespace SecureFolderFS.Cli;
+
+public abstract partial class VaultAuthOptions : CliGlobalOptions
+{
+ [CommandOption("password", Description = "Prompt for password interactively (masked input).")]
+ public bool Password { get; init; }
+
+ [CommandOption("password-stdin", Description = "Read password from stdin.")]
+ public bool PasswordStdin { get; init; }
+
+ [CommandOption("keyfile", Description = "Use an existing keyfile path.")]
+ public string? KeyFile { get; init; }
+
+ [CommandOption("twofa-password", Description = "Prompt for second-factor password interactively.")]
+ public bool TwoFactorPassword { get; init; }
+
+ [CommandOption("twofa-password-stdin", Description = "Read second-factor password from stdin.")]
+ public bool TwoFactorPasswordStdin { get; init; }
+
+ [CommandOption("twofa-keyfile", Description = "Use an existing second-factor keyfile path.")]
+ public string? TwoFactorKeyFile { get; init; }
+
+ [CommandOption("recovery-key", Description = "Unlock via recovery key instead of credentials.")]
+ public string? RecoveryKey { get; init; }
+}
+
+public abstract class CreateAuthOptions : CliGlobalOptions
+{
+ [CommandOption("password", Description = "Prompt for password interactively (masked input).")]
+ public bool Password { get; init; }
+
+ [CommandOption("password-stdin", Description = "Read password from stdin.")]
+ public bool PasswordStdin { get; init; }
+
+ [CommandOption("keyfile", Description = "Use an existing keyfile path.")]
+ public string? KeyFile { get; init; }
+
+ [CommandOption("keyfile-generate", Description = "Generate a new keyfile and write it to this path.")]
+ public string? KeyFileGenerate { get; init; }
+
+ [CommandOption("twofa-password", Description = "Prompt for second-factor password interactively.")]
+ public bool TwoFactorPassword { get; init; }
+
+ [CommandOption("twofa-password-stdin", Description = "Read second-factor password from stdin.")]
+ public bool TwoFactorPasswordStdin { get; init; }
+
+ [CommandOption("twofa-keyfile", Description = "Use an existing second-factor keyfile path.")]
+ public string? TwoFactorKeyFile { get; init; }
+
+ [CommandOption("twofa-keyfile-generate", Description = "Generate a second-factor keyfile and write it to this path.")]
+ public string? TwoFactorKeyFileGenerate { get; init; }
+}
+
diff --git a/src/Platforms/SecureFolderFS.Cli/Commands/CredsAddCommand.cs b/src/Platforms/SecureFolderFS.Cli/Commands/CredsAddCommand.cs
new file mode 100644
index 000000000..af5dbcd8f
--- /dev/null
+++ b/src/Platforms/SecureFolderFS.Cli/Commands/CredsAddCommand.cs
@@ -0,0 +1,121 @@
+using CliFx;
+using CliFx.Attributes;
+using CliFx.Infrastructure;
+using SecureFolderFS.Sdk.Services;
+using SecureFolderFS.Shared.ComponentModel;
+using SecureFolderFS.Shared.Models;
+using static SecureFolderFS.Core.Constants.Vault.Authentication;
+
+namespace SecureFolderFS.Cli.Commands;
+
+[Command("creds add", Description = "Add a second-factor credential to an existing vault.")]
+public sealed partial class CredsAddCommand(IVaultManagerService vaultManagerService, IVaultService vaultService, CredentialReader credentialReader)
+ : VaultAuthOptions, ICommand
+{
+ [CommandParameter(0, Name = "path", Description = "Path to the vault folder.")]
+ public required string Path { get; init; }
+
+ [CommandOption("twofa-password", Description = "Prompt for a new second-factor password.")]
+ public bool NewTwoFactorPassword { get; init; }
+
+ [CommandOption("twofa-password-stdin", Description = "Read new second-factor password from stdin.")]
+ public bool NewTwoFactorPasswordStdin { get; init; }
+
+ [CommandOption("twofa-keyfile", Description = "Use an existing second-factor keyfile.")]
+ public string? NewTwoFactorKeyFile { get; init; }
+
+ [CommandOption("twofa-keyfile-generate", Description = "Generate a new second-factor keyfile.")]
+ public string? NewTwoFactorKeyFileGenerate { get; init; }
+
+ public override async ValueTask ExecuteAsync(IConsole console)
+ {
+ try
+ {
+ if (!string.IsNullOrWhiteSpace(RecoveryKey))
+ {
+ // TODO: verify - recovery-key based re-auth for factor-preserving updates is currently not wired.
+ CliOutput.Error(console, this, "--recovery-key is not yet supported for creds add.");
+ Environment.ExitCode = CliExitCodes.BadArguments;
+ return;
+ }
+
+ var vaultFolder = CliCommandHelpers.GetVaultFolder(Path);
+ var vaultOptions = await vaultService.GetVaultOptionsAsync(vaultFolder);
+ if (vaultOptions.UnlockProcedure.Methods.Length > 1)
+ {
+ CliOutput.Error(console, this, "The vault already has a second factor.");
+ Environment.ExitCode = CliExitCodes.BadArguments;
+ return;
+ }
+
+ using var currentAuth = await CredentialResolver.ResolveAuthenticationAsync(this, credentialReader);
+ if (currentAuth is null)
+ {
+ CliOutput.Error(console, this, "Current credentials are required.");
+ Environment.ExitCode = CliExitCodes.BadArguments;
+ return;
+ }
+
+ using var unlockContract = await vaultManagerService.UnlockAsync(vaultFolder, currentAuth.Passkey);
+ var secondPassword = await credentialReader.ReadPasswordAsync(NewTwoFactorPassword, NewTwoFactorPasswordStdin,
+ "New second-factor password: ", null);
+
+ IDisposable? secondFactor = null;
+ string? secondMethod = null;
+ if (!string.IsNullOrWhiteSpace(secondPassword))
+ {
+ secondFactor = new DisposablePassword(secondPassword);
+ secondMethod = AUTH_PASSWORD;
+ }
+ else if (!string.IsNullOrWhiteSpace(NewTwoFactorKeyFileGenerate))
+ {
+ if (string.IsNullOrWhiteSpace(vaultOptions.VaultId))
+ throw new InvalidOperationException("Vault ID is unavailable.");
+
+ secondFactor = (IDisposable)await credentialReader.GenerateKeyFileAsync(NewTwoFactorKeyFileGenerate, vaultOptions.VaultId);
+ secondMethod = AUTH_KEYFILE;
+ }
+ else if (!string.IsNullOrWhiteSpace(NewTwoFactorKeyFile))
+ {
+ secondFactor = (IDisposable)await credentialReader.ReadKeyFileAsKeyAsync(NewTwoFactorKeyFile);
+ secondMethod = AUTH_KEYFILE;
+ }
+
+ if (secondFactor is not IKeyUsage secondKey || secondMethod is null)
+ {
+ CliOutput.Error(console, this, "A new second-factor credential is required.");
+ Environment.ExitCode = CliExitCodes.BadArguments;
+ return;
+ }
+
+ using (secondFactor)
+ {
+ var sequence = new KeySequence();
+ sequence.Add(currentAuth.Passkey);
+ sequence.Add(secondKey);
+
+ using (sequence)
+ {
+ var updatedOptions = vaultOptions with
+ {
+ UnlockProcedure = new AuthenticationMethod([vaultOptions.UnlockProcedure.Methods[0], secondMethod], null)
+ };
+
+ await vaultManagerService.ModifyAuthenticationAsync(vaultFolder, unlockContract, sequence, updatedOptions);
+ }
+ }
+
+ CliOutput.Success(console, this, "Second-factor credential added.");
+ Environment.ExitCode = CliExitCodes.Success;
+ }
+ catch (Exception ex)
+ {
+ Environment.ExitCode = CliCommandHelpers.HandleException(ex, console, this);
+ }
+ }
+}
+
+
+
+
+
diff --git a/src/Platforms/SecureFolderFS.Cli/Commands/CredsChangeCommand.cs b/src/Platforms/SecureFolderFS.Cli/Commands/CredsChangeCommand.cs
new file mode 100644
index 000000000..5ecde6cb7
--- /dev/null
+++ b/src/Platforms/SecureFolderFS.Cli/Commands/CredsChangeCommand.cs
@@ -0,0 +1,130 @@
+using CliFx;
+using CliFx.Attributes;
+using CliFx.Infrastructure;
+using SecureFolderFS.Sdk.Services;
+using SecureFolderFS.Shared.ComponentModel;
+using SecureFolderFS.Shared.Models;
+using static SecureFolderFS.Core.Constants.Vault.Authentication;
+
+namespace SecureFolderFS.Cli.Commands;
+
+[Command("creds change", Description = "Replace an authentication factor.")]
+public sealed partial class CredsChangeCommand(IVaultManagerService vaultManagerService, IVaultService vaultService, CredentialReader credentialReader)
+ : VaultAuthOptions, ICommand
+{
+ [CommandParameter(0, Name = "path", Description = "Path to the vault folder.")]
+ public required string Path { get; init; }
+
+ [CommandOption("new-password", Description = "Prompt for the replacement password.")]
+ public bool NewPassword { get; init; }
+
+ [CommandOption("new-password-stdin", Description = "Read replacement password from stdin.")]
+ public bool NewPasswordStdin { get; init; }
+
+ [CommandOption("new-keyfile-generate", Description = "Generate a replacement keyfile at this path.")]
+ public string? NewKeyFileGenerate { get; init; }
+
+ [CommandOption("factor", Description = "Factor to replace: primary|2fa.")]
+ public string Factor { get; init; } = "primary";
+
+ public override async ValueTask ExecuteAsync(IConsole console)
+ {
+ try
+ {
+ if (!string.IsNullOrWhiteSpace(RecoveryKey))
+ {
+ // TODO: verify - recovery-key based re-auth for factor-preserving updates is currently not wired.
+ CliOutput.Error(console, this, "--recovery-key is not yet supported for creds change.");
+ Environment.ExitCode = CliExitCodes.BadArguments;
+ return;
+ }
+
+ var vaultFolder = CliCommandHelpers.GetVaultFolder(Path);
+ var vaultOptions = await vaultService.GetVaultOptionsAsync(vaultFolder);
+
+ using var auth = await CredentialResolver.ResolveAuthenticationAsync(this, credentialReader);
+ if (auth is null)
+ {
+ CliOutput.Error(console, this, "Current credentials are required.");
+ Environment.ExitCode = CliExitCodes.BadArguments;
+ return;
+ }
+
+ using var unlockContract = await vaultManagerService.UnlockAsync(vaultFolder, auth.Passkey);
+
+ var replacementPassword = await credentialReader.ReadPasswordAsync(NewPassword, NewPasswordStdin, "New password: ", null);
+ IKeyUsage? replacement = null;
+ string? replacementMethod = null;
+
+ if (!string.IsNullOrWhiteSpace(replacementPassword))
+ {
+ replacement = new DisposablePassword(replacementPassword);
+ replacementMethod = AUTH_PASSWORD;
+ }
+ else if (!string.IsNullOrWhiteSpace(NewKeyFileGenerate))
+ {
+ if (string.IsNullOrWhiteSpace(vaultOptions.VaultId))
+ throw new InvalidOperationException("Vault ID is unavailable.");
+
+ replacement = await credentialReader.GenerateKeyFileAsync(NewKeyFileGenerate, vaultOptions.VaultId);
+ replacementMethod = AUTH_KEYFILE;
+ }
+
+ if (replacement is null || replacementMethod is null)
+ {
+ CliOutput.Error(console, this, "Provide --new-password/--new-password-stdin or --new-keyfile-generate.");
+ Environment.ExitCode = CliExitCodes.BadArguments;
+ return;
+ }
+
+ using (replacement)
+ {
+ var methods = vaultOptions.UnlockProcedure.Methods.ToArray();
+ var replaceIndex = string.Equals(Factor, "2fa", StringComparison.OrdinalIgnoreCase) ? 1 : 0;
+ if (replaceIndex >= methods.Length)
+ {
+ CliOutput.Error(console, this, $"Factor '{Factor}' is not configured on this vault.");
+ Environment.ExitCode = CliExitCodes.BadArguments;
+ return;
+ }
+
+ methods[replaceIndex] = replacementMethod;
+ var updatedOptions = vaultOptions with
+ {
+ UnlockProcedure = new AuthenticationMethod(methods, null)
+ };
+
+ IKeyUsage updatedPasskey = replacement;
+ if (methods.Length > 1)
+ {
+ if (auth.Passkey is not KeySequence authSequence || authSequence.Keys.Count < methods.Length)
+ throw new InvalidOperationException("Both authentication factors must be supplied for this vault.");
+
+ var next = new KeySequence();
+ for (var i = 0; i < methods.Length; i++)
+ {
+ next.Add(i == replaceIndex ? replacement : authSequence.Keys.ElementAt(i));
+ }
+
+ updatedPasskey = next;
+ }
+
+ using (updatedPasskey)
+ {
+ await vaultManagerService.ModifyAuthenticationAsync(vaultFolder, unlockContract, updatedPasskey, updatedOptions);
+ }
+ }
+
+ CliOutput.Success(console, this, $"Credential factor '{Factor}' updated.");
+ Environment.ExitCode = CliExitCodes.Success;
+ }
+ catch (Exception ex)
+ {
+ Environment.ExitCode = CliCommandHelpers.HandleException(ex, console, this);
+ }
+ }
+}
+
+
+
+
diff --git a/src/Platforms/SecureFolderFS.Cli/Commands/CredsRemoveCommand.cs b/src/Platforms/SecureFolderFS.Cli/Commands/CredsRemoveCommand.cs
new file mode 100644
index 000000000..7a3d2c949
--- /dev/null
+++ b/src/Platforms/SecureFolderFS.Cli/Commands/CredsRemoveCommand.cs
@@ -0,0 +1,71 @@
+using CliFx;
+using CliFx.Attributes;
+using CliFx.Infrastructure;
+using SecureFolderFS.Sdk.Services;
+using SecureFolderFS.Shared.ComponentModel;
+using SecureFolderFS.Shared.Models;
+
+namespace SecureFolderFS.Cli.Commands;
+
+[Command("creds remove", Description = "Remove the second-factor credential from a vault.")]
+public sealed partial class CredsRemoveCommand(IVaultManagerService vaultManagerService, IVaultService vaultService, CredentialReader credentialReader)
+ : VaultAuthOptions, ICommand
+{
+ [CommandParameter(0, Name = "path", Description = "Path to the vault folder.")]
+ public required string Path { get; init; }
+
+ public override async ValueTask ExecuteAsync(IConsole console)
+ {
+ try
+ {
+ if (!string.IsNullOrWhiteSpace(RecoveryKey))
+ {
+ // TODO: verify - recovery-key based re-auth for factor-preserving updates is currently not wired.
+ CliOutput.Error(console, this, "--recovery-key is not yet supported for creds remove.");
+ Environment.ExitCode = CliExitCodes.BadArguments;
+ return;
+ }
+
+ var vaultFolder = CliCommandHelpers.GetVaultFolder(Path);
+ var vaultOptions = await vaultService.GetVaultOptionsAsync(vaultFolder);
+ if (vaultOptions.UnlockProcedure.Methods.Length <= 1)
+ {
+ CliOutput.Error(console, this, "The vault has no second factor to remove.");
+ Environment.ExitCode = CliExitCodes.BadArguments;
+ return;
+ }
+
+ using var auth = await CredentialResolver.ResolveAuthenticationAsync(this, credentialReader);
+ if (auth is null)
+ {
+ CliOutput.Error(console, this, "Current credentials are required.");
+ Environment.ExitCode = CliExitCodes.BadArguments;
+ return;
+ }
+
+ using var unlockContract = await vaultManagerService.UnlockAsync(vaultFolder, auth.Passkey);
+ var primaryOnlyOptions = vaultOptions with
+ {
+ UnlockProcedure = new AuthenticationMethod([vaultOptions.UnlockProcedure.Methods[0]], null)
+ };
+
+ // TODO: verify - assumes the first item in the current auth chain corresponds to the primary factor.
+ IKeyUsage newPrimary = auth.Passkey;
+ if (auth.Passkey is KeySequence sequence && sequence.Keys.FirstOrDefault() is IKeyUsage key)
+ newPrimary = key;
+
+ await vaultManagerService.ModifyAuthenticationAsync(vaultFolder, unlockContract, newPrimary, primaryOnlyOptions);
+
+ CliOutput.Success(console, this, "Second-factor credential removed.");
+ Environment.ExitCode = CliExitCodes.Success;
+ }
+ catch (Exception ex)
+ {
+ Environment.ExitCode = CliCommandHelpers.HandleException(ex, console, this);
+ }
+ }
+}
+
+
+
+
diff --git a/src/Platforms/SecureFolderFS.Cli/Commands/VaultCreateCommand.cs b/src/Platforms/SecureFolderFS.Cli/Commands/VaultCreateCommand.cs
new file mode 100644
index 000000000..a39c45002
--- /dev/null
+++ b/src/Platforms/SecureFolderFS.Cli/Commands/VaultCreateCommand.cs
@@ -0,0 +1,72 @@
+using CliFx;
+using CliFx.Attributes;
+using CliFx.Infrastructure;
+using OwlCore.Storage;
+using SecureFolderFS.Core;
+using SecureFolderFS.Sdk.Services;
+using SecureFolderFS.Storage.Extensions;
+
+namespace SecureFolderFS.Cli.Commands;
+
+[Command("vault create", Description = "Create a new vault at the specified path.")]
+public sealed partial class VaultCreateCommand(IVaultManagerService vaultManagerService, CredentialReader credentialReader) : CreateAuthOptions, ICommand
+{
+ [CommandParameter(0, Name = "path", Description = "Path to the vault folder.")]
+ public required string Path { get; init; }
+
+ [CommandOption("name", Description = "Display name for the vault.")]
+ public string? Name { get; init; }
+
+ [CommandOption("content-cipher", Description = "Content cipher id (for example: AES-GCM, XChaCha20-Poly1305, none).")]
+ public string? ContentCipher { get; init; }
+
+ [CommandOption("filename-cipher", Description = "Filename cipher id (for example: AES-SIV, none).")]
+ public string? FileNameCipher { get; init; }
+
+ [CommandOption("overwrite", Description = "Allow creation when a vault already exists.")]
+ public bool Overwrite { get; init; }
+
+ public override async ValueTask ExecuteAsync(IConsole console)
+ {
+ try
+ {
+ var vaultFolder = CliCommandHelpers.GetVaultFolder(Path);
+ var modifiableFolder = vaultFolder as IModifiableFolder;
+ if (modifiableFolder is null)
+ {
+ CliOutput.Error(console, this, "The vault folder is not writable.");
+ Environment.ExitCode = CliExitCodes.BadArguments;
+ return;
+ }
+
+ var existingConfig = await vaultFolder.TryGetFirstByNameAsync(Constants.Vault.Names.VAULT_CONFIGURATION_FILENAME);
+ if (existingConfig is not null && !Overwrite)
+ {
+ CliOutput.Error(console, this, "A vault already exists at this path. Use --overwrite to continue.");
+ Environment.ExitCode = CliExitCodes.BadArguments;
+ return;
+ }
+
+ var vaultId = Guid.NewGuid().ToString("N");
+ using var auth = await CredentialResolver.ResolveCreateAuthenticationAsync(this, credentialReader, vaultId);
+ if (auth is null)
+ {
+ CliOutput.Error(console, this, "At least one primary credential is required.");
+ Environment.ExitCode = CliExitCodes.BadArguments;
+ return;
+ }
+
+ var vaultOptions = CliCommandHelpers.BuildVaultOptions(auth.Methods, vaultId, ContentCipher, FileNameCipher);
+ using var recoveryKey = await vaultManagerService.CreateAsync(vaultFolder, auth.Passkey, vaultOptions);
+
+ var displayName = string.IsNullOrWhiteSpace(Name) ? System.IO.Path.GetFileName(vaultFolder.Id) : Name;
+ CliOutput.Success(console, this, $"Vault created: {displayName}");
+ console.Output.WriteLine($"Recovery key: {recoveryKey}");
+ Environment.ExitCode = CliExitCodes.Success;
+ }
+ catch (Exception ex)
+ {
+ Environment.ExitCode = CliCommandHelpers.HandleException(ex, console, this);
+ }
+ }
+}
diff --git a/src/Platforms/SecureFolderFS.Cli/Commands/VaultInfoCommand.cs b/src/Platforms/SecureFolderFS.Cli/Commands/VaultInfoCommand.cs
new file mode 100644
index 000000000..a941274dc
--- /dev/null
+++ b/src/Platforms/SecureFolderFS.Cli/Commands/VaultInfoCommand.cs
@@ -0,0 +1,47 @@
+using CliFx;
+using CliFx.Attributes;
+using CliFx.Infrastructure;
+using SecureFolderFS.Sdk.Services;
+using SecureFolderFS.Storage.SystemStorageEx;
+
+namespace SecureFolderFS.Cli.Commands
+{
+ [Command("vault info", Description = "Read vault metadata without unlocking.")]
+ public sealed partial class VaultInfoCommand(IVaultService vaultService) : CliGlobalOptions, ICommand
+ {
+ [CommandParameter(0, Name = "path", Description = "Path to the vault folder.")]
+ public required string Path { get; init; }
+
+ public override async ValueTask ExecuteAsync(IConsole console)
+ {
+ try
+ {
+ var vaultFolder = new SystemFolderEx(Path);
+ var options = await vaultService.GetVaultOptionsAsync(vaultFolder);
+
+ if (!Quiet)
+ {
+ console.Output.WriteLine($"Vault path: {vaultFolder.Id}");
+ console.Output.WriteLine($"Version: {options.Version}");
+ console.Output.WriteLine($"Vault ID: {options.VaultId}");
+ console.Output.WriteLine($"Content cipher: {options.ContentCipherId}");
+ console.Output.WriteLine($"Filename cipher: {options.FileNameCipherId}");
+ console.Output.WriteLine($"Name encoding: {options.NameEncodingId}");
+ console.Output.WriteLine(
+ $"Authentication methods: {string.Join(", ", options.UnlockProcedure.Methods)}");
+ console.Output.WriteLine($"2FA configured: {options.UnlockProcedure.Methods.Length > 1}");
+ }
+
+ Environment.ExitCode = CliExitCodes.Success;
+ }
+ catch (Exception ex)
+ {
+ Environment.ExitCode = CliCommandHelpers.HandleException(ex, console, this);
+ }
+ }
+ }
+}
+
+
+
+
diff --git a/src/Platforms/SecureFolderFS.Cli/Commands/VaultMountCommand.cs b/src/Platforms/SecureFolderFS.Cli/Commands/VaultMountCommand.cs
new file mode 100644
index 000000000..8d68c75a2
--- /dev/null
+++ b/src/Platforms/SecureFolderFS.Cli/Commands/VaultMountCommand.cs
@@ -0,0 +1,164 @@
+using System.Runtime.InteropServices;
+using CliFx;
+using CliFx.Attributes;
+using CliFx.Infrastructure;
+using SecureFolderFS.Core.WebDav;
+using SecureFolderFS.Sdk.Extensions;
+using SecureFolderFS.Sdk.Helpers;
+using SecureFolderFS.Sdk.Services;
+using SecureFolderFS.Storage.Enums;
+using SecureFolderFS.Storage.VirtualFileSystem;
+
+namespace SecureFolderFS.Cli.Commands;
+
+[Command("vault mount", Description = "Unlock a vault and mount it as a live filesystem.")]
+public sealed partial class VaultMountCommand(IVaultManagerService vaultManagerService, IVaultFileSystemService vaultFileSystemService, CredentialReader credentialReader)
+ : VaultAuthOptions, ICommand
+{
+ [CommandParameter(0, Name = "path", Description = "Path to the vault folder.")]
+ public required string Path { get; init; }
+
+ [CommandOption("mount-point", Description = "Filesystem mount point.")]
+ public string? MountPoint { get; init; }
+
+ [CommandOption("fs", Description = "Filesystem adapter: auto|webdav|dokany|winfsp.")]
+ public string FileSystem { get; init; } = "auto";
+
+ [CommandOption("read-only", Description = "Mount in read-only mode.")]
+ public bool ReadOnly { get; init; }
+
+ public override async ValueTask ExecuteAsync(IConsole console)
+ {
+ IDisposable? unlockContract = null;
+ IVfsRoot? mountedRoot = null;
+
+ try
+ {
+ var usingRecovery = !string.IsNullOrWhiteSpace(RecoveryKey) || !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("SFFS_RECOVERY_KEY"));
+ if (usingRecovery && (Password || PasswordStdin || !string.IsNullOrWhiteSpace(KeyFile) || TwoFactorPassword || TwoFactorPasswordStdin || !string.IsNullOrWhiteSpace(TwoFactorKeyFile)))
+ {
+ CliOutput.Error(console, this, "--recovery-key is mutually exclusive with credential flags.");
+ Environment.ExitCode = CliExitCodes.BadArguments;
+ return;
+ }
+
+ var vaultFolder = CliCommandHelpers.GetVaultFolder(Path);
+
+ if (usingRecovery)
+ {
+ var recovery = credentialReader.ReadRecoveryKey(RecoveryKey, Environment.GetEnvironmentVariable("SFFS_RECOVERY_KEY"));
+ if (string.IsNullOrWhiteSpace(recovery))
+ {
+ CliOutput.Error(console, this, "No recovery key provided.");
+ Environment.ExitCode = CliExitCodes.BadArguments;
+ return;
+ }
+
+ unlockContract = await vaultManagerService.RecoverAsync(vaultFolder, recovery);
+ }
+ else
+ {
+ using var auth = await CredentialResolver.ResolveAuthenticationAsync(this, credentialReader);
+ if (auth is null)
+ {
+ CliOutput.Error(console, this, "No credentials provided. Use password/keyfile flags or environment variables.");
+ Environment.ExitCode = CliExitCodes.BadArguments;
+ return;
+ }
+
+ unlockContract = await vaultManagerService.UnlockAsync(vaultFolder, auth.Passkey);
+ }
+
+ var fileSystem = await ResolveFileSystemAsync(vaultFileSystemService, FileSystem, console);
+ var contentFolder = await VaultHelpers.GetContentFolderAsync(vaultFolder);
+ var mountOptions = CliCommandHelpers.BuildMountOptions(vaultFolder.Name, ReadOnly, MountPoint);
+ mountedRoot = await fileSystem.MountAsync(contentFolder, unlockContract, mountOptions);
+
+ CliOutput.Success(console, this, $"Mounted using {fileSystem.Name}: {mountedRoot.VirtualizedRoot.Id}");
+
+ var shutdownSignal = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ ConsoleCancelEventHandler onCancel = (_, args) =>
+ {
+ args.Cancel = true;
+ shutdownSignal.TrySetResult();
+ };
+
+ EventHandler onExit = (_, _) => shutdownSignal.TrySetResult();
+ Console.CancelKeyPress += onCancel;
+ AppDomain.CurrentDomain.ProcessExit += onExit;
+
+ IDisposable? sigTerm = null;
+ try
+ {
+#if NET7_0_OR_GREATER
+ sigTerm = PosixSignalRegistration.Create(PosixSignal.SIGTERM, _ => shutdownSignal.TrySetResult());
+#endif
+ await shutdownSignal.Task;
+ }
+ finally
+ {
+ Console.CancelKeyPress -= onCancel;
+ AppDomain.CurrentDomain.ProcessExit -= onExit;
+ sigTerm?.Dispose();
+ }
+
+ await mountedRoot.DisposeAsync();
+ unlockContract.Dispose();
+ mountedRoot = null;
+ unlockContract = null;
+ Environment.ExitCode = CliExitCodes.Success;
+ }
+ catch (NotSupportedException ex)
+ {
+ CliOutput.Error(console, this, ex.Message);
+ Environment.ExitCode = CliExitCodes.MountFailure;
+ }
+ catch (Exception ex)
+ {
+ Environment.ExitCode = CliCommandHelpers.HandleException(ex, console, this);
+ }
+ finally
+ {
+ if (mountedRoot is not null)
+ await mountedRoot.DisposeAsync();
+
+ unlockContract?.Dispose();
+ }
+ }
+
+ private async Task ResolveFileSystemAsync(IVaultFileSystemService service, string requested, IConsole console)
+ {
+ if (string.Equals(requested, "auto", StringComparison.OrdinalIgnoreCase))
+ return await service.GetBestFileSystemAsync();
+
+ var wantedId = requested.ToLowerInvariant() switch
+ {
+ "webdav" => Constants.FileSystem.FS_ID,
+ "dokany" => "DOKANY",
+ "winfsp" => "WINFSP",
+ _ => string.Empty
+ };
+
+ if (string.IsNullOrEmpty(wantedId))
+ throw new ArgumentException($"Unknown filesystem '{requested}'.");
+
+ await foreach (var candidate in service.GetFileSystemsAsync())
+ {
+ if (!string.Equals(candidate.Id, wantedId, StringComparison.OrdinalIgnoreCase))
+ continue;
+
+ if (await candidate.GetStatusAsync() == FileSystemAvailability.Available)
+ return candidate;
+
+ CliOutput.Warning(console, this, $"Adapter '{requested}' is unavailable. Falling back to auto.");
+ return await service.GetBestFileSystemAsync();
+ }
+
+ CliOutput.Warning(console, this, $"Adapter '{requested}' not found. Falling back to auto.");
+ return await service.GetBestFileSystemAsync();
+ }
+}
+
+
+
+
diff --git a/src/Platforms/SecureFolderFS.Cli/Commands/VaultRunCommand.cs b/src/Platforms/SecureFolderFS.Cli/Commands/VaultRunCommand.cs
new file mode 100644
index 000000000..d724a0fe2
--- /dev/null
+++ b/src/Platforms/SecureFolderFS.Cli/Commands/VaultRunCommand.cs
@@ -0,0 +1,146 @@
+using CliFx;
+using CliFx.Attributes;
+using CliFx.Infrastructure;
+using OwlCore.Storage;
+using SecureFolderFS.Sdk.Helpers;
+using SecureFolderFS.Sdk.Services;
+using SecureFolderFS.Storage.Extensions;
+using SecureFolderFS.Storage.SystemStorageEx;
+using SecureFolderFS.Storage.VirtualFileSystem;
+
+namespace SecureFolderFS.Cli.Commands;
+
+[Command("vault run", Description = "Unlock a vault, perform one file operation, then lock.")]
+public sealed partial class VaultRunCommand(IVaultManagerService vaultManagerService, IVaultFileSystemService vaultFileSystemService, CredentialReader credentialReader)
+ : VaultAuthOptions, ICommand
+{
+ [CommandParameter(0, Name = "path", Description = "Path to the vault folder.")]
+ public required string Path { get; init; }
+
+ [CommandOption("read", Description = "Read a file from the vault.")]
+ public string? ReadPath { get; init; }
+
+ [CommandOption("write", Description = "Write stdin to a file in the vault.")]
+ public string? WritePath { get; init; }
+
+ [CommandOption("out", Description = "When reading, write output to local file instead of stdout.")]
+ public string? OutPath { get; init; }
+
+ public override async ValueTask ExecuteAsync(IConsole console)
+ {
+ IDisposable? unlockContract = null;
+ IVfsRoot? localRoot = null;
+
+ try
+ {
+ var hasRead = !string.IsNullOrWhiteSpace(ReadPath);
+ var hasWrite = !string.IsNullOrWhiteSpace(WritePath);
+ if (hasRead == hasWrite)
+ {
+ CliOutput.Error(console, this, "Exactly one of --read or --write must be specified.");
+ Environment.ExitCode = CliExitCodes.BadArguments;
+ return;
+ }
+
+ var vaultFolder = new SystemFolderEx(Path);
+ var recovery = credentialReader.ReadRecoveryKey(RecoveryKey, Environment.GetEnvironmentVariable("SFFS_RECOVERY_KEY"));
+
+ if (!string.IsNullOrWhiteSpace(recovery))
+ {
+ unlockContract = await vaultManagerService.RecoverAsync(vaultFolder, recovery);
+ }
+ else
+ {
+ using var auth = await CredentialResolver.ResolveAuthenticationAsync(this, credentialReader);
+ if (auth is null)
+ {
+ CliOutput.Error(console, this, "No credentials provided. Use password/keyfile flags or environment variables.");
+ Environment.ExitCode = CliExitCodes.BadArguments;
+ return;
+ }
+
+ unlockContract = await vaultManagerService.UnlockAsync(vaultFolder, auth.Passkey);
+ }
+
+ var localFileSystem = await vaultFileSystemService.GetLocalFileSystemAsync();
+ var contentFolder = await VaultHelpers.GetContentFolderAsync(vaultFolder);
+ var options = CliCommandHelpers.BuildMountOptions(vaultFolder.Name, readOnly: false, mountPoint: null);
+ localRoot = await localFileSystem.MountAsync(contentFolder, unlockContract, options);
+
+ if (hasRead)
+ await ReadFromVaultAsync(localRoot.PlaintextRoot, ReadPath!, OutPath);
+ else
+ await WriteToVaultAsync(localRoot.PlaintextRoot, WritePath!);
+
+ Environment.ExitCode = CliExitCodes.Success;
+ }
+ catch (Exception ex)
+ {
+ Environment.ExitCode = CliCommandHelpers.HandleException(ex, console, this);
+ }
+ finally
+ {
+ if (localRoot is not null)
+ await localRoot.DisposeAsync();
+
+ unlockContract?.Dispose();
+ }
+ }
+
+ private static async Task ReadFromVaultAsync(IFolder root, string path, string? outputPath)
+ {
+ var item = await root.GetItemByRelativePathAsync(path);
+ if (item is not IFile file)
+ throw new FileNotFoundException($"Vault file not found: {path}");
+
+ await using var input = await file.OpenReadAsync();
+ if (string.IsNullOrWhiteSpace(outputPath))
+ {
+ await input.CopyToAsync(Console.OpenStandardOutput());
+ return;
+ }
+
+ var fullPath = System.IO.Path.GetFullPath(outputPath);
+ var parent = System.IO.Path.GetDirectoryName(fullPath);
+ if (!string.IsNullOrWhiteSpace(parent))
+ Directory.CreateDirectory(parent);
+
+ await using var output = File.Open(fullPath, FileMode.Create, FileAccess.Write, FileShare.None);
+ await input.CopyToAsync(output);
+ }
+
+ private static async Task WriteToVaultAsync(IFolder root, string path)
+ {
+ if (root is not IModifiableFolder modifiableRoot)
+ throw new InvalidOperationException("The vault is not writable.");
+
+ var normalized = path.Replace('\\', '/').Trim('/');
+ var parts = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries);
+ if (parts.Length == 0)
+ throw new ArgumentException("Invalid vault path.");
+
+ IModifiableFolder current = modifiableRoot;
+ for (var i = 0; i < parts.Length - 1; i++)
+ {
+ var existing = await current.TryGetFolderByNameAsync(parts[i]);
+ if (existing is IModifiableFolder existingFolder)
+ {
+ current = existingFolder;
+ continue;
+ }
+
+ var created = await current.CreateFolderAsync(parts[i], false);
+ current = created as IModifiableFolder ?? throw new InvalidOperationException("Created folder is not modifiable.");
+ }
+
+ var destination = await current.CreateFileAsync(parts[^1], overwrite: true);
+ await using var destinationStream = await destination.OpenWriteAsync();
+ await using var stdin = Console.OpenStandardInput();
+ await stdin.CopyToAsync(destinationStream);
+ }
+}
+
+
+
+
+
diff --git a/src/Platforms/SecureFolderFS.Cli/Commands/VaultShellCommand.cs b/src/Platforms/SecureFolderFS.Cli/Commands/VaultShellCommand.cs
new file mode 100644
index 000000000..b681a6ece
--- /dev/null
+++ b/src/Platforms/SecureFolderFS.Cli/Commands/VaultShellCommand.cs
@@ -0,0 +1,361 @@
+using System.Text;
+using CliFx;
+using CliFx.Attributes;
+using CliFx.Infrastructure;
+using OwlCore.Storage;
+using SecureFolderFS.Sdk.Helpers;
+using SecureFolderFS.Sdk.Services;
+using SecureFolderFS.Storage.Extensions;
+using SecureFolderFS.Storage.VirtualFileSystem;
+
+namespace SecureFolderFS.Cli.Commands;
+
+[Command("vault shell", Description = "Unlock a vault and enter an interactive shell.")]
+public sealed partial class VaultShellCommand(IVaultManagerService vaultManagerService, IVaultFileSystemService vaultFileSystemService, CredentialReader credentialReader)
+ : VaultAuthOptions, ICommand
+{
+ [CommandParameter(0, Name = "path", Description = "Path to the vault folder.")]
+ public required string Path { get; init; }
+
+ public override async ValueTask ExecuteAsync(IConsole console)
+ {
+ IDisposable? unlockContract = null;
+ IVfsRoot? localRoot = null;
+
+ try
+ {
+ var vaultFolder = CliCommandHelpers.GetVaultFolder(Path);
+ var recovery = credentialReader.ReadRecoveryKey(RecoveryKey, Environment.GetEnvironmentVariable("SFFS_RECOVERY_KEY"));
+ if (!string.IsNullOrWhiteSpace(recovery))
+ {
+ unlockContract = await vaultManagerService.RecoverAsync(vaultFolder, recovery);
+ }
+ else
+ {
+ using var auth = await CredentialResolver.ResolveAuthenticationAsync(this, credentialReader);
+ if (auth is null)
+ {
+ CliOutput.Error(console, this, "No credentials provided. Use password/keyfile flags or environment variables.");
+ Environment.ExitCode = CliExitCodes.BadArguments;
+ return;
+ }
+
+ unlockContract = await vaultManagerService.UnlockAsync(vaultFolder, auth.Passkey);
+ }
+
+ var localFileSystem = await vaultFileSystemService.GetLocalFileSystemAsync();
+ var contentFolder = await VaultHelpers.GetContentFolderAsync(vaultFolder);
+ var options = CliCommandHelpers.BuildMountOptions(vaultFolder.Name, readOnly: false, mountPoint: null);
+ localRoot = await localFileSystem.MountAsync(contentFolder, unlockContract, options);
+
+ await RunShellAsync(localRoot.PlaintextRoot);
+ Environment.ExitCode = CliExitCodes.Success;
+ }
+ catch (Exception ex)
+ {
+ Environment.ExitCode = CliCommandHelpers.HandleException(ex, console, this);
+ }
+ finally
+ {
+ if (localRoot is not null)
+ await localRoot.DisposeAsync();
+
+ unlockContract?.Dispose();
+ }
+ }
+
+ private static async Task RunShellAsync(IFolder root)
+ {
+ if (root is not IModifiableFolder modifiableRoot)
+ throw new InvalidOperationException("The vault is not writable.");
+
+ var current = root;
+
+ while (true)
+ {
+ Console.Write($"sffs:{current.Id}> ");
+ var line = await Console.In.ReadLineAsync();
+ if (string.IsNullOrWhiteSpace(line))
+ continue;
+
+ var args = Tokenize(line);
+ var command = args[0].ToLowerInvariant();
+
+ switch (command)
+ {
+ case "exit":
+ return;
+
+ case "help":
+ Console.WriteLine("ls [path], cd , cat , cp , mv , rm [-r], mkdir , pwd, help, exit");
+ break;
+
+ case "pwd":
+ Console.WriteLine(current.Id);
+ break;
+
+ case "ls":
+ {
+ var target = args.Count > 1 ? await ResolveFolderAsync(root, current, args[1]) : current;
+ await foreach (var item in target.GetItemsAsync())
+ Console.WriteLine($"{item.Name}\t\t{(item is IFolder ? "[Directory]" : "[File]")}");
+ break;
+ }
+
+ case "cd":
+ if (args.Count < 2)
+ throw new ArgumentException("cd requires a path.");
+
+ current = await ResolveFolderAsync(root, current, args[1]);
+ break;
+
+ case "cat":
+ if (args.Count < 2)
+ throw new ArgumentException("cat requires a path.");
+
+ var catItem = await ResolveStorableAsync(root, current, args[1]);
+ if (catItem is not IFile catFile)
+ throw new FileNotFoundException("The selected path is not a file.");
+
+ await using (var input = await catFile.OpenReadAsync())
+ {
+ await input.CopyToAsync(Console.OpenStandardOutput());
+ }
+ Console.WriteLine();
+ break;
+
+ case "mkdir":
+ if (args.Count < 2)
+ throw new ArgumentException("mkdir requires a path.");
+
+ _ = await EnsureFolderAsync(modifiableRoot, current, args[1]);
+ break;
+
+ case "rm":
+ if (args.Count < 2)
+ throw new ArgumentException("rm requires a path.");
+
+ var recursive = args.Count > 2 && string.Equals(args[2], "-r", StringComparison.OrdinalIgnoreCase);
+ var toDelete = await ResolveStorableAsync(root, current, args[1]);
+ if (toDelete is IFolder && !recursive)
+ throw new InvalidOperationException("Use -r to remove directories.");
+
+ if (toDelete is IStorableChild child)
+ await modifiableRoot.DeleteAsync(child, deleteImmediately: true);
+ break;
+
+ case "cp":
+ if (args.Count < 3)
+ throw new ArgumentException("cp requires source and destination.");
+
+ await CopyAsync(modifiableRoot, root, current, args[1], args[2]);
+ break;
+
+ case "mv":
+ if (args.Count < 3)
+ throw new ArgumentException("mv requires source and destination.");
+
+ await MoveAsync(modifiableRoot, root, current, args[1], args[2]);
+ break;
+
+ default:
+ Console.WriteLine($"Unknown command '{command}'. Type 'help'.");
+ break;
+ }
+ }
+ }
+
+ private static async Task CopyAsync(IModifiableFolder modifiableRoot, IFolder root, IFolder current, string src, string dst)
+ {
+ var srcLocal = TryParseLocal(src, out var srcLocalPath);
+ var dstLocal = TryParseLocal(dst, out var dstLocalPath);
+
+ if (srcLocal && dstLocal)
+ {
+ File.Copy(srcLocalPath!, dstLocalPath!, overwrite: true);
+ return;
+ }
+
+ if (srcLocal)
+ {
+ var destinationFile = await CreateOrGetVaultFileAsync(modifiableRoot, current, dst);
+ await using var inStream = File.OpenRead(srcLocalPath!);
+ await using var outStream = await destinationFile.OpenWriteAsync();
+ await inStream.CopyToAsync(outStream);
+ return;
+ }
+
+ var sourceItem = await ResolveStorableAsync(root, current, src);
+ if (dstLocal)
+ {
+ if (sourceItem is not IFile sourceFile)
+ throw new InvalidOperationException("Only file copies are supported for vault -> local.");
+
+ var parent = System.IO.Path.GetDirectoryName(dstLocalPath!);
+ if (!string.IsNullOrWhiteSpace(parent))
+ Directory.CreateDirectory(parent);
+
+ await using var inStream = await sourceFile.OpenReadAsync();
+ await using var outStream = File.Open(dstLocalPath!, FileMode.Create, FileAccess.Write, FileShare.None);
+ await inStream.CopyToAsync(outStream);
+ return;
+ }
+
+ var (destinationFolder, destinationName) = await ResolveDestinationFolderAsync(modifiableRoot, current, dst);
+ _ = await destinationFolder.CreateCopyOfStorableAsync((IStorable)sourceItem, overwrite: true, destinationName, reporter: null);
+ }
+
+ private static async Task MoveAsync(IModifiableFolder modifiableRoot, IFolder root, IFolder current, string src, string dst)
+ {
+ var srcLocal = TryParseLocal(src, out var srcLocalPath);
+ var dstLocal = TryParseLocal(dst, out var dstLocalPath);
+
+ if (srcLocal && dstLocal)
+ {
+ File.Move(srcLocalPath!, dstLocalPath!, overwrite: true);
+ return;
+ }
+
+ if (srcLocal || dstLocal)
+ {
+ // TODO: verify - cross-provider mv semantics are currently copy+delete.
+ await CopyAsync(modifiableRoot, root, current, src, dst);
+ if (srcLocal)
+ File.Delete(srcLocalPath!);
+ else if (await ResolveStorableAsync(root, current, src) is IStorableChild child)
+ await modifiableRoot.DeleteAsync(child, deleteImmediately: true);
+
+ return;
+ }
+
+ var sourceItem = await ResolveStorableAsync(root, current, src);
+ if (sourceItem is not IStorableChild sourceChild)
+ throw new InvalidOperationException("Source item is not movable.");
+
+ var sourceParent = await ResolveParentFolderAsync(modifiableRoot, root, current, src);
+ var (destinationFolder, destinationName) = await ResolveDestinationFolderAsync(modifiableRoot, current, dst);
+ _ = await destinationFolder.MoveStorableFromAsync(sourceChild, sourceParent, overwrite: true, destinationName, reporter: null);
+ }
+
+ private static async Task ResolveParentFolderAsync(IModifiableFolder root, IFolder absoluteRoot, IFolder current, string path)
+ {
+ var normalized = NormalizeVaultPath(path);
+ var parentPath = normalized.Contains('/') ? normalized[..normalized.LastIndexOf('/')] : string.Empty;
+ if (string.IsNullOrWhiteSpace(parentPath))
+ return current as IModifiableFolder ?? root;
+
+ var folder = await ResolveFolderAsync(absoluteRoot, current, parentPath);
+ return folder as IModifiableFolder ?? throw new InvalidOperationException("Parent folder is not writable.");
+ }
+
+ private static async Task<(IModifiableFolder folder, string name)> ResolveDestinationFolderAsync(IModifiableFolder root, IFolder current, string path)
+ {
+ var normalized = NormalizeVaultPath(path);
+ var split = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries);
+ if (split.Length == 0)
+ throw new ArgumentException("Invalid destination path.");
+
+ var destinationName = split[^1];
+ var parent = split.Length == 1 ? string.Empty : string.Join('/', split[..^1]);
+
+ var folder = await EnsureFolderAsync(root, current, parent);
+ return (folder, destinationName);
+ }
+
+ private static async Task CreateOrGetVaultFileAsync(IModifiableFolder root, IFolder current, string path)
+ {
+ var (folder, name) = await ResolveDestinationFolderAsync(root, current, path);
+ return await folder.CreateFileAsync(name, overwrite: true);
+ }
+
+ private static async Task EnsureFolderAsync(IModifiableFolder root, IFolder current, string path)
+ {
+ var normalized = NormalizeVaultPath(path);
+ if (string.IsNullOrWhiteSpace(normalized))
+ return current as IModifiableFolder ?? root;
+
+ var parts = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries);
+ IModifiableFolder folder = normalized.StartsWith('/') ? root : current as IModifiableFolder ?? root;
+
+ foreach (var part in parts)
+ {
+ var next = await folder.TryGetFolderByNameAsync(part);
+ if (next is IModifiableFolder nextFolder)
+ {
+ folder = nextFolder;
+ continue;
+ }
+
+ var created = await folder.CreateFolderAsync(part, overwrite: false);
+ folder = created as IModifiableFolder ?? throw new InvalidOperationException("Created folder is not modifiable.");
+ }
+
+ return folder;
+ }
+
+ private static async Task ResolveStorableAsync(IFolder root, IFolder current, string path)
+ {
+ var normalized = NormalizeVaultPath(path);
+ if (normalized.StartsWith('/'))
+ return await root.GetItemByRelativePathAsync(normalized.TrimStart('/'));
+
+ return await current.GetItemByRelativePathAsync(normalized);
+ }
+
+ private static async Task ResolveFolderAsync(IFolder root, IFolder current, string path)
+ {
+ var item = await ResolveStorableAsync(root, current, path);
+ return item as IFolder ?? throw new InvalidOperationException("Path is not a folder.");
+ }
+
+ private static string NormalizeVaultPath(string path)
+ {
+ return path.Replace('\\', '/').Trim();
+ }
+
+ private static bool TryParseLocal(string value, out string? localPath)
+ {
+ if (value.StartsWith("file://", StringComparison.OrdinalIgnoreCase))
+ {
+ localPath = System.IO.Path.GetFullPath(value[7..]);
+ return true;
+ }
+
+ localPath = null;
+ return false;
+ }
+
+ private static List Tokenize(string input)
+ {
+ var result = new List();
+ var current = new StringBuilder();
+ var inQuotes = false;
+
+ foreach (var ch in input)
+ {
+ if (ch == '"')
+ {
+ inQuotes = !inQuotes;
+ continue;
+ }
+
+ if (char.IsWhiteSpace(ch) && !inQuotes)
+ {
+ if (current.Length > 0)
+ {
+ result.Add(current.ToString());
+ current.Clear();
+ }
+
+ continue;
+ }
+
+ current.Append(ch);
+ }
+
+ if (current.Length > 0)
+ result.Add(current.ToString());
+
+ return result;
+ }
+}
diff --git a/src/Platforms/SecureFolderFS.Cli/Commands/VaultUnmountCommand.cs b/src/Platforms/SecureFolderFS.Cli/Commands/VaultUnmountCommand.cs
new file mode 100644
index 000000000..0c13be6d8
--- /dev/null
+++ b/src/Platforms/SecureFolderFS.Cli/Commands/VaultUnmountCommand.cs
@@ -0,0 +1,71 @@
+using CliFx;
+using CliFx.Attributes;
+using CliFx.Infrastructure;
+using SecureFolderFS.Core.FileSystem;
+using SecureFolderFS.Shared.ComponentModel;
+using SecureFolderFS.Storage.VirtualFileSystem;
+
+namespace SecureFolderFS.Cli.Commands;
+
+[Command("vault unmount", Description = "Unmount a mounted vault.")]
+public sealed partial class VaultUnmountCommand : CliGlobalOptions, ICommand
+{
+ [CommandParameter(0, Name = "path", Description = "Vault path or mount path.")]
+ public required string Path { get; init; }
+
+ [CommandOption("force", Description = "Force unmount even if files are open.")]
+ public bool Force { get; init; }
+
+ public override async ValueTask ExecuteAsync(IConsole console)
+ {
+ try
+ {
+ var requestedPath = System.IO.Path.GetFullPath(Path);
+ var mounted = FileSystemManager.Instance.FileSystems.ToArray();
+
+ var target = mounted.FirstOrDefault(root => IsMatch(root, requestedPath));
+ if (target is null)
+ {
+ CliOutput.Error(console, this, "No mounted vault matches the provided path.");
+ Environment.ExitCode = CliExitCodes.MountStateError;
+ return;
+ }
+
+ try
+ {
+ await target.DisposeAsync();
+ }
+ catch when (Force)
+ {
+ target.Dispose();
+ }
+
+ CliOutput.Success(console, this, "Vault unmounted.");
+ Environment.ExitCode = CliExitCodes.Success;
+ }
+ catch (Exception ex)
+ {
+ Environment.ExitCode = CliCommandHelpers.HandleException(ex, console, this);
+ }
+ }
+
+ private static bool IsMatch(IVfsRoot root, string requestedPath)
+ {
+ if (string.Equals(System.IO.Path.GetFullPath(root.VirtualizedRoot.Id), requestedPath, StringComparison.OrdinalIgnoreCase))
+ return true;
+
+ if (root is not IWrapper wrapper)
+ return false;
+
+ var contentPath = System.IO.Path.GetFullPath(wrapper.Inner.ContentFolder.Id);
+ if (string.Equals(contentPath, requestedPath, StringComparison.OrdinalIgnoreCase))
+ return true;
+
+ var parent = Directory.GetParent(contentPath)?.FullName;
+ return parent is not null && string.Equals(parent, requestedPath, StringComparison.OrdinalIgnoreCase);
+ }
+}
+
+
+
+
diff --git a/src/Platforms/SecureFolderFS.Cli/CredentialReader.cs b/src/Platforms/SecureFolderFS.Cli/CredentialReader.cs
new file mode 100644
index 000000000..b8ca26ff8
--- /dev/null
+++ b/src/Platforms/SecureFolderFS.Cli/CredentialReader.cs
@@ -0,0 +1,102 @@
+using System.Security.Cryptography;
+using System.Text;
+using SecureFolderFS.Core.Cryptography.SecureStore;
+using SecureFolderFS.Shared.ComponentModel;
+
+namespace SecureFolderFS.Cli;
+
+public sealed class CredentialReader
+{
+ private const int KeyFileRandomLength = 128;
+
+ public async Task ReadPasswordAsync(bool prompt, bool readFromStdin, string promptText, string? environmentVariable)
+ {
+ if (readFromStdin)
+ {
+ var stdinValue = await Console.In.ReadLineAsync();
+ return string.IsNullOrEmpty(stdinValue) ? null : stdinValue;
+ }
+
+ if (prompt)
+ return ReadMaskedPassword(promptText);
+
+ return string.IsNullOrWhiteSpace(environmentVariable) ? null : environmentVariable;
+ }
+
+ public string? ReadRecoveryKey(string? explicitValue, string? environmentValue)
+ {
+ return !string.IsNullOrWhiteSpace(explicitValue)
+ ? explicitValue
+ : string.IsNullOrWhiteSpace(environmentValue) ? null : environmentValue;
+ }
+
+ public string? ReadKeyFilePath(string? explicitValue, string? environmentValue)
+ {
+ return !string.IsNullOrWhiteSpace(explicitValue)
+ ? explicitValue
+ : string.IsNullOrWhiteSpace(environmentValue) ? null : environmentValue;
+ }
+
+ public async Task ReadKeyFileAsKeyAsync(string path)
+ {
+ var expandedPath = Path.GetFullPath(path);
+ var keyBytes = await File.ReadAllBytesAsync(expandedPath);
+ if (keyBytes.Length == 0)
+ throw new InvalidDataException("The key file is empty.");
+
+ return ManagedKey.TakeOwnership(keyBytes);
+ }
+
+ public async Task GenerateKeyFileAsync(string outputPath, string vaultId)
+ {
+ var fullPath = Path.GetFullPath(outputPath);
+ var parent = Path.GetDirectoryName(fullPath);
+ if (!string.IsNullOrEmpty(parent))
+ Directory.CreateDirectory(parent);
+
+ var idBytes = Encoding.ASCII.GetBytes(vaultId);
+ var keyBytes = new byte[KeyFileRandomLength + idBytes.Length];
+
+ RandomNumberGenerator.Fill(keyBytes.AsSpan(0, KeyFileRandomLength));
+ idBytes.CopyTo(keyBytes.AsSpan(KeyFileRandomLength));
+
+ await File.WriteAllBytesAsync(fullPath, keyBytes);
+ return ManagedKey.TakeOwnership(keyBytes);
+ }
+
+ private static string? ReadMaskedPassword(string prompt)
+ {
+ Console.Write(prompt);
+ var buffer = new StringBuilder();
+
+ while (true)
+ {
+ var key = Console.ReadKey(intercept: true);
+ if (key.Key == ConsoleKey.Enter)
+ {
+ Console.WriteLine();
+ break;
+ }
+
+ if (key.Key == ConsoleKey.Backspace)
+ {
+ if (buffer.Length <= 0)
+ continue;
+
+ buffer.Length -= 1;
+ Console.Write("\b \b");
+ continue;
+ }
+
+ if (char.IsControl(key.KeyChar))
+ continue;
+
+ buffer.Append(key.KeyChar);
+ Console.Write('*');
+ }
+
+ return buffer.Length == 0 ? null : buffer.ToString();
+ }
+}
+
+
diff --git a/src/Platforms/SecureFolderFS.Cli/CredentialResolver.cs b/src/Platforms/SecureFolderFS.Cli/CredentialResolver.cs
new file mode 100644
index 000000000..856195c25
--- /dev/null
+++ b/src/Platforms/SecureFolderFS.Cli/CredentialResolver.cs
@@ -0,0 +1,137 @@
+using SecureFolderFS.Shared.ComponentModel;
+using SecureFolderFS.Shared.Models;
+using static SecureFolderFS.Core.Constants.Vault.Authentication;
+
+namespace SecureFolderFS.Cli;
+
+internal sealed class ResolvedAuthentication : IDisposable
+{
+ public required IKeyUsage Passkey { get; init; }
+ public required string[] Methods { get; init; }
+
+ public void Dispose()
+ {
+ Passkey.Dispose();
+ }
+}
+
+internal static class CredentialResolver
+{
+ public static async Task ResolveCreateAuthenticationAsync(CreateAuthOptions options, CredentialReader reader, string vaultId)
+ {
+ var primary = await ResolveFactorAsync(
+ options.Password,
+ options.PasswordStdin,
+ options.KeyFile,
+ options.KeyFileGenerate,
+ "SFFS_PASSWORD",
+ "SFFS_KEYFILE",
+ "Primary password: ",
+ reader,
+ vaultId);
+
+ if (primary is null)
+ return null;
+
+ var secondary = await ResolveFactorAsync(
+ options.TwoFactorPassword,
+ options.TwoFactorPasswordStdin,
+ options.TwoFactorKeyFile,
+ options.TwoFactorKeyFileGenerate,
+ null,
+ null,
+ "Second-factor password: ",
+ reader,
+ vaultId);
+
+ return Build(primary.Value, secondary);
+ }
+
+ public static async Task ResolveAuthenticationAsync(VaultAuthOptions options, CredentialReader reader)
+ {
+ var primary = await ResolveFactorAsync(
+ options.Password,
+ options.PasswordStdin,
+ options.KeyFile,
+ null,
+ "SFFS_PASSWORD",
+ "SFFS_KEYFILE",
+ "Password: ",
+ reader,
+ null);
+
+ if (primary is null)
+ return null;
+
+ var secondary = await ResolveFactorAsync(
+ options.TwoFactorPassword,
+ options.TwoFactorPasswordStdin,
+ options.TwoFactorKeyFile,
+ null,
+ null,
+ null,
+ "Second-factor password: ",
+ reader,
+ null);
+
+ return Build(primary.Value, secondary);
+ }
+
+ private static ResolvedAuthentication Build((string method, IKeyUsage key) primary, (string method, IKeyUsage key)? secondary)
+ {
+ if (secondary is null)
+ {
+ return new ResolvedAuthentication
+ {
+ Passkey = primary.key,
+ Methods = [primary.method]
+ };
+ }
+
+ var keySequence = new KeySequence();
+ keySequence.Add(primary.key);
+ keySequence.Add(secondary.Value.key);
+
+ return new ResolvedAuthentication
+ {
+ Passkey = keySequence,
+ Methods = [primary.method, secondary.Value.method]
+ };
+ }
+
+ private static async Task<(string method, IKeyUsage key)?> ResolveFactorAsync(
+ bool usePromptPassword,
+ bool useStdinPassword,
+ string? keyFile,
+ string? generateKeyFile,
+ string? passwordEnvironmentName,
+ string? keyFileEnvironmentName,
+ string prompt,
+ CredentialReader reader,
+ string? vaultId)
+ {
+ var envPassword = passwordEnvironmentName is null ? null : Environment.GetEnvironmentVariable(passwordEnvironmentName);
+ var password = await reader.ReadPasswordAsync(usePromptPassword, useStdinPassword, prompt, envPassword);
+ if (!string.IsNullOrEmpty(password))
+ return (AUTH_PASSWORD, new DisposablePassword(password));
+
+ if (!string.IsNullOrWhiteSpace(generateKeyFile))
+ {
+ if (string.IsNullOrWhiteSpace(vaultId))
+ {
+ // TODO: verify - keyfile generation for login/update flows may need explicit vault id derivation from config.
+ throw new InvalidOperationException("Keyfile generation requires a known vault identifier.");
+ }
+
+ return (AUTH_KEYFILE, await reader.GenerateKeyFileAsync(generateKeyFile, vaultId));
+ }
+
+ var envKeyFile = keyFileEnvironmentName is null ? null : Environment.GetEnvironmentVariable(keyFileEnvironmentName);
+ var keyFilePath = reader.ReadKeyFilePath(keyFile, envKeyFile);
+ if (!string.IsNullOrWhiteSpace(keyFilePath))
+ return (AUTH_KEYFILE, await reader.ReadKeyFileAsKeyAsync(keyFilePath));
+
+ return null;
+ }
+}
+
diff --git a/src/Platforms/SecureFolderFS.Cli/Program.cs b/src/Platforms/SecureFolderFS.Cli/Program.cs
index c607437f3..76085a11f 100644
--- a/src/Platforms/SecureFolderFS.Cli/Program.cs
+++ b/src/Platforms/SecureFolderFS.Cli/Program.cs
@@ -1,12 +1,55 @@
+using CliFx;
+using Microsoft.Extensions.DependencyInjection;
+using SecureFolderFS.Shared;
+using SecureFolderFS.Shared.Extensions;
+
namespace SecureFolderFS.Cli
{
- internal class Program
+ internal static class Program
{
- static void Main(string[] args)
+ public static CliLifecycleHelper Lifecycle { get; } = new();
+
+ private static async Task Main(string[] args)
{
- Console.WriteLine("Hello SecureFolderFS!");
- Console.WriteLine("Nothing here yet.");
- Console.ReadKey();
+ await Lifecycle.InitAsync();
+ DI.Default.SetServiceProvider(Lifecycle.ServiceCollection.BuildServiceProvider());
+
+#if DEBUG
+ if (args.IsEmpty())
+ {
+ // Initialize settings once, outside the loop
+ Console.WriteLine("Interactive CLI mode. Press Enter with no input to exit.");
+ while (true)
+ {
+ Console.Write("> ");
+ var input = Console.ReadLine();
+ if (string.IsNullOrWhiteSpace(input))
+ break;
+
+ var loopArgs = input.Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ var normalizedLoopArgs = loopArgs
+ .Select(static x => x.Replace("--2fa-", "--twofa-", StringComparison.OrdinalIgnoreCase))
+ .ToArray();
+
+ var app2 = new CliApplicationBuilder()
+ .AddCommandsFromThisAssembly()
+ .UseTypeActivator(type => ActivatorUtilities.CreateInstance(DI.Default, type))
+ .Build();
+
+ await app2.RunAsync(normalizedLoopArgs);
+ Console.WriteLine();
+ }
+
+ return 0;
+ }
+#endif
+ var normalizedArgs = args.Select(static x => x.Replace("--2fa-", "--twofa-", StringComparison.OrdinalIgnoreCase)).ToArray();
+ var app = new CliApplicationBuilder()
+ .AddCommandsFromThisAssembly()
+ .UseTypeActivator(type => ActivatorUtilities.CreateInstance(DI.Default, type))
+ .Build();
+
+ return await app.RunAsync(normalizedArgs);
}
}
}
diff --git a/src/Platforms/SecureFolderFS.Cli/SecureFolderFS.Cli.csproj b/src/Platforms/SecureFolderFS.Cli/SecureFolderFS.Cli.csproj
index 1bbadf973..ff9ced8fa 100644
--- a/src/Platforms/SecureFolderFS.Cli/SecureFolderFS.Cli.csproj
+++ b/src/Platforms/SecureFolderFS.Cli/SecureFolderFS.Cli.csproj
@@ -6,4 +6,27 @@
enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ $(DefineConstants);SFFS_WINDOWS_FS
+
+
+
+
+
+
+
diff --git a/src/Platforms/SecureFolderFS.UI/Helpers/BaseLifecycleHelper.cs b/src/Platforms/SecureFolderFS.UI/Helpers/BaseLifecycleHelper.cs
index 54647b2cf..2cc774050 100644
--- a/src/Platforms/SecureFolderFS.UI/Helpers/BaseLifecycleHelper.cs
+++ b/src/Platforms/SecureFolderFS.UI/Helpers/BaseLifecycleHelper.cs
@@ -25,7 +25,7 @@ public abstract class BaseLifecycleHelper : IAsyncInitialize
///
public virtual Task InitAsync(CancellationToken cancellationToken = default)
{
- var settingsFolderPath = Path.Combine(Directory.GetCurrentDirectory(), Constants.FileNames.SETTINGS_FOLDER_NAME);
+ var settingsFolderPath = Path.Combine(AppDirectory, Constants.FileNames.SETTINGS_FOLDER_NAME);
var settingsFolder = new SystemFolder(Directory.CreateDirectory(settingsFolderPath));
ConfigureServices(settingsFolder);
@@ -77,7 +77,13 @@ protected virtual IServiceCollection ConfigureServices(IModifiableFolder setting
;
// Configure logging
- ServiceCollection.AddLogging(builder =>
+ return WithLogging(ServiceCollection);
+ }
+
+ protected virtual IServiceCollection WithLogging(IServiceCollection serviceCollection)
+ {
+ // Configure logging
+ return ServiceCollection.AddLogging(builder =>
{
#if DEBUG
builder.SetMinimumLevel(LogLevel.Trace);
@@ -88,8 +94,6 @@ protected virtual IServiceCollection ConfigureServices(IModifiableFolder setting
// Opt-in: file logging
// builder.AddFileOutput(Path.Combine(AppDirectory, "app.log"), LogLevel.Information);
});
-
- return ServiceCollection;
}
public abstract void LogExceptionToFile(Exception? ex);
diff --git a/tests/SecureFolderFS.Tests/CliTests/BaseCliCommandTests.cs b/tests/SecureFolderFS.Tests/CliTests/BaseCliCommandTests.cs
new file mode 100644
index 000000000..637ca1b18
--- /dev/null
+++ b/tests/SecureFolderFS.Tests/CliTests/BaseCliCommandTests.cs
@@ -0,0 +1,53 @@
+using NUnit.Framework;
+
+namespace SecureFolderFS.Tests.CliTests;
+
+public abstract class BaseCliCommandTests
+{
+ private readonly List _tempDirectories = [];
+
+ [SetUp]
+ public void ResetExitCode()
+ {
+ Environment.ExitCode = 0;
+ }
+
+ [TearDown]
+ public void CleanupTempDirectories()
+ {
+ foreach (var directory in _tempDirectories)
+ {
+ try
+ {
+ if (Directory.Exists(directory))
+ Directory.Delete(directory, recursive: true);
+ }
+ catch
+ {
+ // Best-effort cleanup only.
+ }
+ }
+
+ _tempDirectories.Clear();
+ }
+
+ protected Task RunCliAsync(params string[] args)
+ {
+ return CliTestHost.RunAsync(args);
+ }
+
+ protected Task RunCliAsync(string[] args, IReadOnlyDictionary environmentVariables)
+ {
+ return CliTestHost.RunAsync(args, environmentVariables: environmentVariables);
+ }
+
+ protected string CreateTempDirectory()
+ {
+ var path = Path.Combine(Path.GetTempPath(), $"sffs-cli-tests-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(path);
+ _tempDirectories.Add(path);
+
+ return path;
+ }
+}
+
diff --git a/tests/SecureFolderFS.Tests/CliTests/CliExecutionResult.cs b/tests/SecureFolderFS.Tests/CliTests/CliExecutionResult.cs
new file mode 100644
index 000000000..a4d4e42b6
--- /dev/null
+++ b/tests/SecureFolderFS.Tests/CliTests/CliExecutionResult.cs
@@ -0,0 +1,9 @@
+namespace SecureFolderFS.Tests.CliTests;
+
+public sealed record CliExecutionResult(
+ int AppExitCode,
+ int ProcessExitCode,
+ string StandardOutput,
+ string StandardError);
+
+
diff --git a/tests/SecureFolderFS.Tests/CliTests/CliExpectedExitCodes.cs b/tests/SecureFolderFS.Tests/CliTests/CliExpectedExitCodes.cs
new file mode 100644
index 000000000..18194ce14
--- /dev/null
+++ b/tests/SecureFolderFS.Tests/CliTests/CliExpectedExitCodes.cs
@@ -0,0 +1,10 @@
+namespace SecureFolderFS.Tests.CliTests;
+
+internal static class CliExpectedExitCodes
+{
+ public const int Success = 0;
+ public const int BadArguments = 2;
+ public const int VaultUnreadable = 4;
+ public const int MountStateError = 6;
+}
+
diff --git a/tests/SecureFolderFS.Tests/CliTests/CliTestHost.cs b/tests/SecureFolderFS.Tests/CliTests/CliTestHost.cs
new file mode 100644
index 000000000..fc8d7e255
--- /dev/null
+++ b/tests/SecureFolderFS.Tests/CliTests/CliTestHost.cs
@@ -0,0 +1,118 @@
+using CliFx;
+using Microsoft.Extensions.DependencyInjection;
+using OwlCore.Storage.System.IO;
+using SecureFolderFS.Cli;
+using SecureFolderFS.Cli.Commands;
+using SecureFolderFS.Sdk.Services;
+using SecureFolderFS.Shared;
+using SecureFolderFS.Tests.ServiceImplementation;
+using SecureFolderFS.UI.ServiceImplementation;
+
+namespace SecureFolderFS.Tests.CliTests;
+
+internal static class CliTestHost
+{
+ public static async Task RunAsync(string[] args, string? standardInput = null, IReadOnlyDictionary? environmentVariables = null)
+ {
+ var originalOut = Console.Out;
+ var originalError = Console.Error;
+ var originalIn = Console.In;
+
+ var outputWriter = new StringWriter();
+ var errorWriter = new StringWriter();
+
+ var originalEnvironment = new Dictionary();
+ if (environmentVariables is not null)
+ {
+ foreach (var pair in environmentVariables)
+ {
+ originalEnvironment[pair.Key] = Environment.GetEnvironmentVariable(pair.Key);
+ Environment.SetEnvironmentVariable(pair.Key, pair.Value);
+ }
+ }
+
+ var settingsPath = Path.Combine(Path.GetTempPath(), $"sffs-cli-tests-settings-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(settingsPath);
+
+ var services = BuildServiceProvider(settingsPath);
+
+ try
+ {
+ Console.SetOut(outputWriter);
+ Console.SetError(errorWriter);
+ Console.SetIn(new StringReader(standardInput ?? string.Empty));
+
+ Environment.ExitCode = 0;
+ var app = BuildApplication(services);
+ var appExitCode = await app.RunAsync(args);
+
+ return new CliExecutionResult(
+ appExitCode,
+ Environment.ExitCode,
+ outputWriter.ToString(),
+ errorWriter.ToString());
+ }
+ finally
+ {
+ Console.SetOut(originalOut);
+ Console.SetError(originalError);
+ Console.SetIn(originalIn);
+
+ // Re-initialize the shared test DI root because CLI tests replace DI.Default.
+ GlobalSetup.GlobalInitialize();
+
+ foreach (var pair in originalEnvironment)
+ Environment.SetEnvironmentVariable(pair.Key, pair.Value);
+
+ try
+ {
+ Directory.Delete(settingsPath, recursive: true);
+ }
+ catch
+ {
+ // Best-effort cleanup only.
+ }
+ }
+ }
+
+ private static IServiceProvider BuildServiceProvider(string settingsPath)
+ {
+ var settingsFolder = new SystemFolder(Directory.CreateDirectory(settingsPath));
+
+ var serviceProvider = new ServiceCollection()
+ .AddSingleton(_ => new(settingsFolder))
+ .AddSingleton()
+ .AddSingleton()
+ .AddSingleton()
+ .AddSingleton()
+ .AddSingleton()
+ .AddSingleton()
+ .AddSingleton()
+ .AddSingleton()
+ .AddSingleton()
+ .AddSingleton()
+ .AddSingleton()
+ .AddSingleton()
+ .BuildServiceProvider();
+
+ DI.Default.SetServiceProvider(serviceProvider);
+ return serviceProvider;
+ }
+
+ private static CliApplication BuildApplication(IServiceProvider services)
+ {
+ return new CliApplicationBuilder()
+ .AddCommand()
+ .AddCommand()
+ .AddCommand()
+ .AddCommand()
+ .AddCommand()
+ .AddCommand()
+ .AddCommand()
+ .AddCommand()
+ .AddCommand()
+ .UseTypeActivator(type => ActivatorUtilities.CreateInstance(services, type))
+ .Build();
+ }
+}
+
diff --git a/tests/SecureFolderFS.Tests/CliTests/CommandDiscoveryTests.cs b/tests/SecureFolderFS.Tests/CliTests/CommandDiscoveryTests.cs
new file mode 100644
index 000000000..192e4f3dd
--- /dev/null
+++ b/tests/SecureFolderFS.Tests/CliTests/CommandDiscoveryTests.cs
@@ -0,0 +1,44 @@
+using CliFx;
+using CliFx.Attributes;
+using FluentAssertions;
+using NUnit.Framework;
+using SecureFolderFS.Cli.Commands;
+using System.Reflection;
+
+namespace SecureFolderFS.Tests.CliTests;
+
+[TestFixture]
+public class CommandDiscoveryTests
+{
+ [Test]
+ public void CliAssembly_ExposesExpectedCommandTypes()
+ {
+ // Arrange
+ var assembly = typeof(VaultCreateCommand).Assembly;
+
+ // Act
+ var commandTypes = assembly
+ .GetTypes()
+ .Where(type =>
+ !type.IsAbstract &&
+ typeof(ICommand).IsAssignableFrom(type) &&
+ type.GetCustomAttribute() is not null)
+ .Select(type => type.Name)
+ .ToArray();
+
+ // Assert
+ commandTypes.Should().BeEquivalentTo(
+ [
+ nameof(CredsAddCommand),
+ nameof(CredsChangeCommand),
+ nameof(CredsRemoveCommand),
+ nameof(VaultCreateCommand),
+ nameof(VaultInfoCommand),
+ nameof(VaultMountCommand),
+ nameof(VaultRunCommand),
+ nameof(VaultShellCommand),
+ nameof(VaultUnmountCommand)
+ ]);
+ }
+}
+
diff --git a/tests/SecureFolderFS.Tests/CliTests/Commands/CredsAddCommandTests.cs b/tests/SecureFolderFS.Tests/CliTests/Commands/CredsAddCommandTests.cs
new file mode 100644
index 000000000..da3c28180
--- /dev/null
+++ b/tests/SecureFolderFS.Tests/CliTests/Commands/CredsAddCommandTests.cs
@@ -0,0 +1,23 @@
+using FluentAssertions;
+using NUnit.Framework;
+
+namespace SecureFolderFS.Tests.CliTests.Commands;
+
+[TestFixture]
+public class CredsAddCommandTests : BaseCliCommandTests
+{
+ [Test]
+ public async Task CredsAdd_WithRecoveryKeyFlag_ShouldReturnBadArguments()
+ {
+ // Arrange
+ var vaultPath = CreateTempDirectory();
+
+ // Act
+ var result = await RunCliAsync("creds", "add", vaultPath, "--recovery-key", "rk", "--no-color");
+
+ // Assert
+ result.ProcessExitCode.Should().Be(CliExpectedExitCodes.BadArguments);
+ }
+}
+
+
diff --git a/tests/SecureFolderFS.Tests/CliTests/Commands/CredsChangeCommandTests.cs b/tests/SecureFolderFS.Tests/CliTests/Commands/CredsChangeCommandTests.cs
new file mode 100644
index 000000000..0e8e99b09
--- /dev/null
+++ b/tests/SecureFolderFS.Tests/CliTests/Commands/CredsChangeCommandTests.cs
@@ -0,0 +1,23 @@
+using FluentAssertions;
+using NUnit.Framework;
+
+namespace SecureFolderFS.Tests.CliTests.Commands;
+
+[TestFixture]
+public class CredsChangeCommandTests : BaseCliCommandTests
+{
+ [Test]
+ public async Task CredsChange_WithRecoveryKeyFlag_ShouldReturnBadArguments()
+ {
+ // Arrange
+ var vaultPath = CreateTempDirectory();
+
+ // Act
+ var result = await RunCliAsync("creds", "change", vaultPath, "--recovery-key", "rk", "--no-color");
+
+ // Assert
+ result.ProcessExitCode.Should().Be(CliExpectedExitCodes.BadArguments);
+ }
+}
+
+
diff --git a/tests/SecureFolderFS.Tests/CliTests/Commands/CredsRemoveCommandTests.cs b/tests/SecureFolderFS.Tests/CliTests/Commands/CredsRemoveCommandTests.cs
new file mode 100644
index 000000000..4191f582f
--- /dev/null
+++ b/tests/SecureFolderFS.Tests/CliTests/Commands/CredsRemoveCommandTests.cs
@@ -0,0 +1,23 @@
+using FluentAssertions;
+using NUnit.Framework;
+
+namespace SecureFolderFS.Tests.CliTests.Commands;
+
+[TestFixture]
+public class CredsRemoveCommandTests : BaseCliCommandTests
+{
+ [Test]
+ public async Task CredsRemove_WithRecoveryKeyFlag_ShouldReturnBadArguments()
+ {
+ // Arrange
+ var vaultPath = CreateTempDirectory();
+
+ // Act
+ var result = await RunCliAsync("creds", "remove", vaultPath, "--recovery-key", "rk", "--no-color");
+
+ // Assert
+ result.ProcessExitCode.Should().Be(CliExpectedExitCodes.BadArguments);
+ }
+}
+
+
diff --git a/tests/SecureFolderFS.Tests/CliTests/Commands/VaultCreateCommandTests.cs b/tests/SecureFolderFS.Tests/CliTests/Commands/VaultCreateCommandTests.cs
new file mode 100644
index 000000000..968b30135
--- /dev/null
+++ b/tests/SecureFolderFS.Tests/CliTests/Commands/VaultCreateCommandTests.cs
@@ -0,0 +1,44 @@
+using FluentAssertions;
+using NUnit.Framework;
+using SecureFolderFS.Core;
+
+namespace SecureFolderFS.Tests.CliTests.Commands;
+
+[TestFixture]
+public class VaultCreateCommandTests : BaseCliCommandTests
+{
+ [Test]
+ public async Task VaultCreate_WithoutCredential_ShouldReturnBadArguments()
+ {
+ // Arrange
+ var vaultPath = CreateTempDirectory();
+
+ // Act
+ var result = await RunCliAsync("vault", "create", vaultPath, "--no-color");
+
+ // Assert
+ result.ProcessExitCode.Should().Be(CliExpectedExitCodes.BadArguments);
+ }
+
+ [Test]
+ public async Task VaultCreate_WithPasswordEnvironmentVariable_ShouldCreateVault()
+ {
+ // Arrange
+ var vaultPath = CreateTempDirectory();
+ var environmentVariables = new Dictionary
+ {
+ ["SFFS_PASSWORD"] = "Password#1"
+ };
+
+ // Act
+ var result = await RunCliAsync(["vault", "create", vaultPath, "--no-color"], environmentVariables);
+
+ // Assert
+ var configPath = Path.Combine(vaultPath, Constants.Vault.Names.VAULT_CONFIGURATION_FILENAME);
+
+ result.ProcessExitCode.Should().Be(CliExpectedExitCodes.Success);
+ File.Exists(configPath).Should().BeTrue();
+ }
+}
+
+
diff --git a/tests/SecureFolderFS.Tests/CliTests/Commands/VaultInfoCommandTests.cs b/tests/SecureFolderFS.Tests/CliTests/Commands/VaultInfoCommandTests.cs
new file mode 100644
index 000000000..20d9c1e28
--- /dev/null
+++ b/tests/SecureFolderFS.Tests/CliTests/Commands/VaultInfoCommandTests.cs
@@ -0,0 +1,37 @@
+using FluentAssertions;
+using NUnit.Framework;
+using SecureFolderFS.Tests.Helpers;
+
+namespace SecureFolderFS.Tests.CliTests.Commands;
+
+[TestFixture]
+public class VaultInfoCommandTests : BaseCliCommandTests
+{
+ [Test]
+ public async Task VaultInfo_OnMissingPath_ShouldReturnVaultUnreadable()
+ {
+ // Arrange
+ var missingPath = Path.Combine(Path.GetTempPath(), $"sffs-cli-missing-{Guid.NewGuid():N}");
+
+ // Act
+ var result = await RunCliAsync("vault", "info", missingPath, "--no-color");
+
+ // Assert
+ result.ProcessExitCode.Should().Be(CliExpectedExitCodes.VaultUnreadable);
+ }
+
+ [Test]
+ public async Task VaultInfo_OnExistingVault_ShouldReturnInformation()
+ {
+ // Arrange
+ var (folder, _) = await MockVaultHelpers.CreateVaultLatestAsync(null);
+
+ // Act
+ var result = await RunCliAsync("vault", "info", folder.Id, "--no-color");
+
+ // TODO: Assert
+ // Since Cli currently uses hardcoded SystemFolderEx, we cannot test against MemoryFolderEx
+ }
+}
+
+
diff --git a/tests/SecureFolderFS.Tests/CliTests/Commands/VaultRunCommandTests.cs b/tests/SecureFolderFS.Tests/CliTests/Commands/VaultRunCommandTests.cs
new file mode 100644
index 000000000..6ed58c267
--- /dev/null
+++ b/tests/SecureFolderFS.Tests/CliTests/Commands/VaultRunCommandTests.cs
@@ -0,0 +1,23 @@
+using FluentAssertions;
+using NUnit.Framework;
+
+namespace SecureFolderFS.Tests.CliTests.Commands;
+
+[TestFixture]
+public class VaultRunCommandTests : BaseCliCommandTests
+{
+ [Test]
+ public async Task VaultRun_WithoutReadOrWrite_ShouldReturnBadArguments()
+ {
+ // Arrange
+ var vaultPath = CreateTempDirectory();
+
+ // Act
+ var result = await RunCliAsync("vault", "run", vaultPath, "--no-color");
+
+ // Assert
+ result.ProcessExitCode.Should().Be(CliExpectedExitCodes.BadArguments);
+ }
+}
+
+
diff --git a/tests/SecureFolderFS.Tests/CliTests/Commands/VaultShellCommandTests.cs b/tests/SecureFolderFS.Tests/CliTests/Commands/VaultShellCommandTests.cs
new file mode 100644
index 000000000..edd046846
--- /dev/null
+++ b/tests/SecureFolderFS.Tests/CliTests/Commands/VaultShellCommandTests.cs
@@ -0,0 +1,23 @@
+using FluentAssertions;
+using NUnit.Framework;
+
+namespace SecureFolderFS.Tests.CliTests.Commands;
+
+[TestFixture]
+public class VaultShellCommandTests : BaseCliCommandTests
+{
+ [Test]
+ public async Task VaultShell_WithoutAnyCredential_ShouldReturnBadArguments()
+ {
+ // Arrange
+ var vaultPath = CreateTempDirectory();
+
+ // Act
+ var result = await RunCliAsync("vault", "shell", vaultPath, "--no-color");
+
+ // Assert
+ result.ProcessExitCode.Should().Be(CliExpectedExitCodes.BadArguments);
+ }
+}
+
+
diff --git a/tests/SecureFolderFS.Tests/FileSystemTests/RecycleBinTests.cs b/tests/SecureFolderFS.Tests/FileSystemTests/RecycleBinTests.cs
index 0041da225..7dcd8d3e9 100644
--- a/tests/SecureFolderFS.Tests/FileSystemTests/RecycleBinTests.cs
+++ b/tests/SecureFolderFS.Tests/FileSystemTests/RecycleBinTests.cs
@@ -102,17 +102,16 @@ public async Task Create_FolderWith_SubFile_SubFolder_Delete_And_Restore_NoThrow
var recycleBin = await _recycleBinService.GetRecycleBinAsync(_storageRoot);
var recycleBinItems = await recycleBin.GetItemsAsync().ToArrayAsyncImpl();
- var first = recycleBinItems[0];
- var second = recycleBinItems[1];
+ var deletedFile = recycleBinItems.First(x => x.Name == "SUB_FILE");
+ var deletedFolder = recycleBinItems.First(x => x.Name == "SUB_FOLDER");
- await recycleBin.RestoreItemsAsync([ first ], _fileExplorerService);
- await recycleBin.RestoreItemsAsync([ second ], _fileExplorerService);
+ await recycleBin.RestoreItemsAsync([ deletedFile ], _fileExplorerService);
+ await recycleBin.RestoreItemsAsync([ deletedFolder ], _fileExplorerService);
// Assert
var restoredItems = await subFolder.GetItemsAsync().ToArrayAsyncImpl();
- restoredItems.Should().HaveCount(2);
- restoredItems[0].Name.Should().Match(x => x == "SUB_FILE" || x == "SUB_FOLDER");
- restoredItems[1].Name.Should().Match(x => x == "SUB_FILE" || x == "SUB_FOLDER");
+ restoredItems.Select(x => x.Name).Should().Contain("SUB_FILE");
+ restoredItems.Select(x => x.Name).Should().Contain("SUB_FOLDER");
Assert.Pass($"{nameof(restoredItems)}:\n" + string.Join('\n', restoredItems.Select(x => x.Id)));
}
diff --git a/tests/SecureFolderFS.Tests/SecureFolderFS.Tests.csproj b/tests/SecureFolderFS.Tests/SecureFolderFS.Tests.csproj
index ff35ac1c5..39e8e64fd 100644
--- a/tests/SecureFolderFS.Tests/SecureFolderFS.Tests.csproj
+++ b/tests/SecureFolderFS.Tests/SecureFolderFS.Tests.csproj
@@ -22,6 +22,7 @@
+