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 @@ +