diff --git a/OnePassword.NET.Tests/OnePasswordManagerCommandTests.cs b/OnePassword.NET.Tests/OnePasswordManagerCommandTests.cs index da3d181..881601a 100644 --- a/OnePassword.NET.Tests/OnePasswordManagerCommandTests.cs +++ b/OnePassword.NET.Tests/OnePasswordManagerCommandTests.cs @@ -14,6 +14,8 @@ namespace OnePassword; [TestFixture] public class OnePasswordManagerCommandTests { + private static readonly string[] EnvironmentVariableNames = ["API_URL", "CONNECTION", "EMPTY"]; + private static readonly string[] EnvironmentVariableValues = ["https://example.com", "Server=db;Password=secret=", ""]; private static readonly string[] ParsedRecipients = ["one@example.com", "two@example.com"]; [Test] @@ -166,6 +168,17 @@ public void GetSecretUsesTrimmedReference() Assert.That(fakeCli.LastArguments, Does.StartWith("read op://vault/item/field --no-newline")); } + [Test] + public void GetSecretPassesReferenceWithSpacesAsSingleArgument() + { + using var fakeCli = new FakeCli(); + var manager = fakeCli.CreateManager(); + + manager.GetSecret("op://vault/item/field with spaces"); + + Assert.That(fakeCli.LastArgumentLines, Does.Contain("op://vault/item/field with spaces")); + } + [Test] public void SaveSecretUsesTrimmedReference() { @@ -187,6 +200,46 @@ public void SaveSecretUsesTrimmedReference() } } + [Test] + public void GetEnvironmentVariablesUsesTrimmedEnvironmentIdAndParsesOutput() + { + using var fakeCli = new FakeCli(nextOutput: "API_URL=https://example.com\nCONNECTION=Server=db;Password=secret=\nEMPTY=\n"); + var manager = fakeCli.CreateManager(); + + var variables = manager.GetEnvironmentVariables(" env-id "); + + Assert.Multiple(() => + { + Assert.That(fakeCli.LastArguments, Is.EqualTo("environment read env-id")); + Assert.That(variables.Select(x => x.Name), Is.EqualTo(EnvironmentVariableNames)); + Assert.That(variables.Select(x => x.Value), Is.EqualTo(EnvironmentVariableValues)); + }); + } + + [Test] + public void SaveEnvironmentVariablesUsesTrimmedEnvironmentIdAndWritesOutput() + { + using var fakeCli = new FakeCli(nextOutput: "API_URL=https://example.com\n"); + var manager = fakeCli.CreateManager(); + var outputPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + + try + { + manager.SaveEnvironmentVariables(" env-id ", outputPath); + + Assert.Multiple(() => + { + Assert.That(fakeCli.LastArguments, Is.EqualTo("environment read env-id")); + Assert.That(File.ReadAllText(outputPath), Is.EqualTo("API_URL=https://example.com\n")); + }); + } + finally + { + if (File.Exists(outputPath)) + File.Delete(outputPath); + } + } + [Test] public void RevokeGroupPermissionsUsesVaultGroupCommand() { @@ -374,6 +427,19 @@ public void ShareItemWithMultipleEmailsUsesCommaSeparatedEmails() Assert.That(fakeCli.LastArguments, Does.Contain("--emails one@example.com,two@example.com")); } + [Test] + public void ShareItemPassesEmailValueWithSpacesAsSingleArgument() + { + using var fakeCli = new FakeCli(); + var manager = fakeCli.CreateManager(); + + manager.ShareItem("item-id", "vault-id", ["recipient@example.com --view-once"]); + + var emailFlagIndex = Array.IndexOf(fakeCli.LastArgumentLines, "--emails"); + Assert.That(emailFlagIndex, Is.GreaterThanOrEqualTo(0)); + Assert.That(fakeCli.LastArgumentLines[emailFlagIndex + 1], Is.EqualTo("recipient@example.com --view-once")); + } + [Test] public void ShareItemWithEmptyEmailCollectionOmitsEmailsFlag() { @@ -439,6 +505,7 @@ public void ShareItemParsesStructuredShareResult() private sealed class FakeCli : IDisposable { private readonly string _argumentsPath; + private readonly string _argumentLinesPath; private readonly string _directoryPath; private readonly string _errorOutputPath; private readonly string _inputPath; @@ -452,6 +519,7 @@ public FakeCli(string versionOutput = "2.32.1\n", string nextOutput = "{}", stri { _directoryPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); _argumentsPath = Path.Combine(_directoryPath, "last-arguments.txt"); + _argumentLinesPath = Path.Combine(_directoryPath, "last-argument-lines.txt"); _errorOutputPath = Path.Combine(_directoryPath, "error-output.txt"); _inputPath = Path.Combine(_directoryPath, "last-input.txt"); _nextOutputPath = Path.Combine(_directoryPath, "next-output.txt"); @@ -485,7 +553,9 @@ public FakeCli(string versionOutput = "2.32.1\n", string nextOutput = "{}", stri public string ExecutableName { get; } = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "op.cmd" : "op"; - public string LastArguments => File.Exists(_argumentsPath) ? File.ReadAllText(_argumentsPath) : ""; + public string LastArguments => File.Exists(_argumentsPath) ? File.ReadAllText(_argumentsPath).TrimEnd('\r', '\n') : ""; + + public string[] LastArgumentLines => File.Exists(_argumentLinesPath) ? File.ReadAllLines(_argumentLinesPath) : []; public string LastInput => File.Exists(_inputPath) ? File.ReadAllText(_inputPath) : ""; @@ -529,6 +599,16 @@ private static string GetScript(string versionOutputFileName) @echo off setlocal > "%~dp0last-arguments.txt" echo %* + break > "%~dp0last-argument-lines.txt" + if not "%~1"=="" >> "%~dp0last-argument-lines.txt" echo(%~1 + if not "%~2"=="" >> "%~dp0last-argument-lines.txt" echo(%~2 + if not "%~3"=="" >> "%~dp0last-argument-lines.txt" echo(%~3 + if not "%~4"=="" >> "%~dp0last-argument-lines.txt" echo(%~4 + if not "%~5"=="" >> "%~dp0last-argument-lines.txt" echo(%~5 + if not "%~6"=="" >> "%~dp0last-argument-lines.txt" echo(%~6 + if not "%~7"=="" >> "%~dp0last-argument-lines.txt" echo(%~7 + if not "%~8"=="" >> "%~dp0last-argument-lines.txt" echo(%~8 + if not "%~9"=="" >> "%~dp0last-argument-lines.txt" echo(%~9 > "%~dp0last-input.txt" more if "%~1"=="update" ( if exist "%~dp0update-payload.zip" copy /y "%~dp0update-payload.zip" "%~3\update-payload.zip" > nul @@ -549,6 +629,7 @@ @echo off #!/bin/sh script_dir=$(CDPATH= cd -- "$(dirname "$0")" && pwd) printf '%s' "$*" > "$script_dir/last-arguments.txt" + printf '%s\n' "$@" > "$script_dir/last-argument-lines.txt" cat > "$script_dir/last-input.txt" if [ "$1" = "update" ]; then if [ -f "$script_dir/update-payload.zip" ]; then diff --git a/OnePassword.NET/Environments/EnvironmentVariable.cs b/OnePassword.NET/Environments/EnvironmentVariable.cs new file mode 100644 index 0000000..87b7efb --- /dev/null +++ b/OnePassword.NET/Environments/EnvironmentVariable.cs @@ -0,0 +1,28 @@ +namespace OnePassword.Environments; + +/// Represents a variable from a 1Password Environment. +public sealed class EnvironmentVariable +{ + /// The variable name. + public string Name { get; internal set; } = ""; + + /// The variable value. + public string Value { get; internal set; } = ""; + + /// Initializes a new instance of . + public EnvironmentVariable() + { + } + + /// Initializes a new instance of with the specified name and value. + /// The variable name. + /// The variable value. + public EnvironmentVariable(string name, string value) + { + Name = name ?? ""; + Value = value ?? ""; + } + + /// + public override string ToString() => $"{Name}={Value}"; +} diff --git a/OnePassword.NET/IOnePasswordManager.Environments.cs b/OnePassword.NET/IOnePasswordManager.Environments.cs new file mode 100644 index 0000000..65b3606 --- /dev/null +++ b/OnePassword.NET/IOnePasswordManager.Environments.cs @@ -0,0 +1,19 @@ +using OnePassword.Environments; + +namespace OnePassword; + +public partial interface IOnePasswordManager +{ + /// Gets the environment variables for a 1Password Environment. + /// The Environment ID. + /// The environment variables. + /// Thrown when there is an invalid argument. + public ImmutableList GetEnvironmentVariables(string environmentId); + + /// Saves the environment variables for a 1Password Environment to disk. + /// The Environment ID. + /// The output file path. + /// The file mode to use when creating the file. + /// Thrown when there is an invalid argument. + public void SaveEnvironmentVariables(string environmentId, string filePath, string? fileMode = null); +} diff --git a/OnePassword.NET/OnePassword.NET.csproj b/OnePassword.NET/OnePassword.NET.csproj index 1e75cae..fc7b37c 100644 --- a/OnePassword.NET/OnePassword.NET.csproj +++ b/OnePassword.NET/OnePassword.NET.csproj @@ -9,7 +9,7 @@ Jean-Sebastien Carle OnePassword.NET 1Password CLI Wrapper - 2.5.0 + 2.5.0-daydream.1 2.5.0.0 2.5.0.0 Copyright © Jean-Sebastien Carle 2021-2025 @@ -65,6 +65,9 @@ OnePasswordManager.Documents.cs + + OnePasswordManager.Environments.cs + OnePasswordManager.Groups.cs diff --git a/OnePassword.NET/OnePasswordManager.Accounts.cs b/OnePassword.NET/OnePasswordManager.Accounts.cs index e9d39cc..ddaca55 100644 --- a/OnePassword.NET/OnePasswordManager.Accounts.cs +++ b/OnePassword.NET/OnePasswordManager.Accounts.cs @@ -20,7 +20,7 @@ public ImmutableList GetAccounts() if (_mode == Mode.ServiceAccount) throw new InvalidOperationException($"{nameof(GetAccounts)} is not supported when using service accounts."); - const string command = "account list"; + var command = new OpCommand("account", "list"); return Op(JsonContext.Default.ImmutableListAccount, command); } @@ -32,7 +32,9 @@ public AccountDetails GetAccount(string account = "") var trimmedAccount = account?.Trim() ?? ""; - var command = trimmedAccount.Length > 0 ? $"account get --account \"{trimmedAccount}\"" : "account get"; + var command = new OpCommand("account", "get"); + if (trimmedAccount.Length > 0) + command.Add("--account", trimmedAccount); return Op(JsonContext.Default.AccountDetails, command); } @@ -68,9 +70,9 @@ public void AddAccount(string address, string email, string secretKey, string pa var trimmedShorthand = shorthand?.Trim() ?? ""; - var command = $"account add --address \"{trimmedAddress}\" --email \"{trimmedEmail}\" --secret-key \"{trimmedSecretKey}\""; + var command = new OpCommand("account", "add", "--address", trimmedAddress, "--email", trimmedEmail, "--secret-key", trimmedSecretKey); if (trimmedShorthand.Length > 0) - command += $" --shorthand \"{trimmedShorthand}\""; + command.Add("--shorthand", trimmedShorthand); var result = Op(command, trimmedPassword, true); if (result.Contains("No saved device ID.", StringComparison.Ordinal)) @@ -115,7 +117,7 @@ public void SignIn(string? password = null) throw new ArgumentException($"{nameof(password)} cannot be empty.", nameof(password)); } - const string command = "signin --force --raw"; + var command = new OpCommand("signin", "--force", "--raw"); var result = Op(command, password?.Trim()); _session = result.Trim(); } @@ -126,11 +128,11 @@ public void SignOut(bool all = false) if (_mode == Mode.ServiceAccount) throw new InvalidOperationException($"{nameof(SignOut)} is not supported when using service accounts."); - var command = "signout"; + var command = new OpCommand("signout"); if (all) - command += " --all"; + command.Add("--all"); else if (_account.Length > 0) - command += $" --account \"{_account}\""; + command.Add("--account", _account); Op(command); _session = ""; } @@ -146,8 +148,8 @@ public ImmutableList ForgetAccount(bool all = false) if (_session.Length > 0) SignOut(all); - var command = "account forget"; - command += all ? " --all" : $" \"{_account}\""; + var command = new OpCommand("account", "forget"); + command.Add(all ? "--all" : _account); var result = Op(command); diff --git a/OnePassword.NET/OnePasswordManager.Documents.cs b/OnePassword.NET/OnePasswordManager.Documents.cs index dc2e122..d80ce9b 100644 --- a/OnePassword.NET/OnePasswordManager.Documents.cs +++ b/OnePassword.NET/OnePasswordManager.Documents.cs @@ -21,7 +21,7 @@ public ImmutableList GetDocuments(string vaultId) if (vaultId is null || vaultId.Length == 0) throw new ArgumentException($"{nameof(vaultId)} cannot be empty.", nameof(vaultId)); - var command = $"document list --vault {vaultId}"; + var command = new OpCommand("document", "list", "--vault", vaultId); return Op(JsonContext.Default.ImmutableListDocumentDetails, command); } @@ -40,11 +40,11 @@ public ImmutableList SearchForDocuments(string? vaultId = null, if (vaultId is not null && vaultId.Length == 0) throw new ArgumentException($"{nameof(vaultId)} cannot be empty.", nameof(vaultId)); - var command = "document list"; + var command = new OpCommand("document", "list"); if (vaultId is not null) - command += $" --vault {vaultId}"; + command.Add("--vault", vaultId); if (includeArchive is not null && includeArchive.Value) - command += " --include-archive"; + command.Add("--include-archive"); return Op(JsonContext.Default.ImmutableListDocumentDetails, command); } @@ -80,9 +80,9 @@ public void GetDocument(string documentId, string vaultId, string filePath, stri var trimmedFileMode = fileMode?.Trim(); // Not specifying --force will hang waiting for user input if the file exists. - var command = $"document get {documentId} --out-file \"{trimmedFilePath}\" --force --vault {vaultId}"; + var command = new OpCommand("document", "get", documentId, "--out-file", trimmedFilePath, "--force", "--vault", vaultId); if (trimmedFileMode is not null) - command += $" --file-mode {trimmedFileMode}"; + command.Add("--file-mode", trimmedFileMode); Op(command); } @@ -119,13 +119,13 @@ public void SearchForDocument(string documentId, string filePath, string? vaultI var trimmedFileMode = fileMode?.Trim(); // Not specifying --force will hang waiting for user input if the file exists. - var command = $"document get {documentId} --out-file \"{trimmedFilePath}\" --force"; + var command = new OpCommand("document", "get", documentId, "--out-file", trimmedFilePath, "--force"); if (vaultId is not null) - command += $" --vault {vaultId}"; + command.Add("--vault", vaultId); if (includeArchive is not null && includeArchive.Value) - command += " --include-archive"; + command.Add("--include-archive"); if (trimmedFileMode is not null) - command += $" --file-mode {trimmedFileMode}"; + command.Add("--file-mode", trimmedFileMode); Op(command); } @@ -177,13 +177,13 @@ public Document CreateDocument(string vaultId, string filePath, string? fileName var trimmedFileName = fileName?.Trim(); var trimmedTitle = title?.Trim(); - var command = $"document create \"{trimmedFilePath}\" --vault {vaultId}"; + var command = new OpCommand("document", "create", trimmedFilePath, "--vault", vaultId); if (trimmedFileName is not null) - command += $" --file-name \"{trimmedFileName}\""; + command.Add("--file-name", trimmedFileName); if (trimmedTitle is not null) - command += $" --title \"{trimmedTitle}\""; + command.Add("--title", trimmedTitle); if (tags is not null && tags.Count > 0) - command += $" --tags \"{tags.ToCommaSeparated()}\""; + command.Add("--tags", tags.ToCommaSeparated()); return Op(JsonContext.Default.Document, command); } @@ -223,13 +223,13 @@ public void ReplaceDocument(string documentId, string vaultId, string filePath, var trimmedFileName = fileName?.Trim(); var trimmedTitle = title?.Trim(); - var command = $"document edit {documentId} \"{trimmedFilePath}\" --vault {vaultId}"; + var command = new OpCommand("document", "edit", documentId, trimmedFilePath, "--vault", vaultId); if (trimmedFileName is not null) - command += $" --file-name \"{trimmedFileName}\""; + command.Add("--file-name", trimmedFileName); if (trimmedTitle is not null) - command += $" --title \"{trimmedTitle}\""; + command.Add("--title", trimmedTitle); if (tags is not null && tags.Count > 0) - command += $" --tags \"{tags.ToCommaSeparated()}\""; + command.Add("--tags", tags.ToCommaSeparated()); Op(command); } @@ -252,7 +252,7 @@ public void ArchiveDocument(string documentId, string vaultId) if (vaultId is null || vaultId.Length == 0) throw new ArgumentException($"{nameof(vaultId)} cannot be empty.", nameof(vaultId)); - var command = $"document delete {documentId} --vault {vaultId} --archive"; + var command = new OpCommand("document", "delete", documentId, "--vault", vaultId, "--archive"); Op(command); } @@ -275,7 +275,7 @@ public void DeleteDocument(string documentId, string vaultId) if (vaultId is null || vaultId.Length == 0) throw new ArgumentException($"{nameof(vaultId)} cannot be empty.", nameof(vaultId)); - var command = $"document delete {documentId} --vault {vaultId}"; + var command = new OpCommand("document", "delete", documentId, "--vault", vaultId); Op(command); } } diff --git a/OnePassword.NET/OnePasswordManager.Environments.cs b/OnePassword.NET/OnePasswordManager.Environments.cs new file mode 100644 index 0000000..d6713ca --- /dev/null +++ b/OnePassword.NET/OnePasswordManager.Environments.cs @@ -0,0 +1,69 @@ +using OnePassword.Environments; + +namespace OnePassword; + +public sealed partial class OnePasswordManager +{ + /// + public ImmutableList GetEnvironmentVariables(string environmentId) + { + ValidateEnvironmentId(environmentId); + var result = ReadEnvironmentVariables(environmentId); + return ParseEnvironmentVariables(result); + } + + /// + public void SaveEnvironmentVariables(string environmentId, string filePath, string? fileMode = null) + { + ValidateEnvironmentId(environmentId); + if (filePath is null || filePath.Length == 0) + throw new ArgumentException($"{nameof(filePath)} cannot be empty.", nameof(filePath)); + var trimmedFilePath = filePath.Trim(); + if (trimmedFilePath.Length == 0) + throw new ArgumentException($"{nameof(trimmedFilePath)} cannot be empty.", nameof(filePath)); + + var trimmedFileMode = fileMode?.Trim(); + var result = ReadEnvironmentVariables(environmentId); + + File.WriteAllText(trimmedFilePath, result); + if (trimmedFileMode is not null && trimmedFileMode.Length > 0) + ApplyFileMode(trimmedFileMode, trimmedFilePath); + } + + private string ReadEnvironmentVariables(string environmentId) + { + ValidateEnvironmentId(environmentId); + var trimmedEnvironmentId = environmentId.Trim(); + var command = new OpCommand("environment", "read", trimmedEnvironmentId); + return Op(command, Array.Empty(), false, false); + } + + private static ImmutableList ParseEnvironmentVariables(string result) + { + var variables = ImmutableList.CreateBuilder(); + using var reader = new StringReader(result); + string? line; + while ((line = reader.ReadLine()) is not null) + { + if (line.Length == 0) + continue; + + var separatorIndex = line.IndexOf('=', StringComparison.Ordinal); + if (separatorIndex <= 0) + throw new SerializationException("Could not deserialize the command result."); + + variables.Add(new EnvironmentVariable(line[..separatorIndex], line[(separatorIndex + 1)..])); + } + + return variables.ToImmutable(); + } + + private static void ValidateEnvironmentId(string environmentId) + { + if (environmentId is null || environmentId.Length == 0) + throw new ArgumentException($"{nameof(environmentId)} cannot be empty.", nameof(environmentId)); + var trimmedEnvironmentId = environmentId.Trim(); + if (trimmedEnvironmentId.Length == 0) + throw new ArgumentException($"{nameof(trimmedEnvironmentId)} cannot be empty.", nameof(environmentId)); + } +} diff --git a/OnePassword.NET/OnePasswordManager.Groups.cs b/OnePassword.NET/OnePasswordManager.Groups.cs index ca43c66..9fdec6c 100644 --- a/OnePassword.NET/OnePasswordManager.Groups.cs +++ b/OnePassword.NET/OnePasswordManager.Groups.cs @@ -10,7 +10,7 @@ public sealed partial class OnePasswordManager /// public ImmutableList GetGroups() { - const string command = "group list"; + var command = new OpCommand("group", "list"); return Op(JsonContext.Default.ImmutableListGroup, command); } @@ -38,7 +38,7 @@ public ImmutableList GetVaultGroups(string vaultId) if (vaultId is null || vaultId.Length == 0) throw new ArgumentException($"{nameof(vaultId)} cannot be empty.", nameof(vaultId)); - var command = $"vault group list {vaultId}"; + var command = new OpCommand("vault", "group", "list", vaultId); return Op(JsonContext.Default.ImmutableListVaultGroup, command); } @@ -48,7 +48,7 @@ public ImmutableList GetUserGroups(string userId) if (userId is null || userId.Length == 0) throw new ArgumentException($"{nameof(userId)} cannot be empty.", nameof(userId)); - var command = $"group list --user {userId}"; + var command = new OpCommand("group", "list", "--user", userId); return Op(JsonContext.Default.ImmutableListUserGroup, command); } @@ -67,7 +67,7 @@ public GroupDetails GetGroup(string groupId) if (groupId is null || groupId.Length == 0) throw new ArgumentException($"{nameof(groupId)} cannot be empty.", nameof(groupId)); - var command = $"group get {groupId}"; + var command = new OpCommand("group", "get", groupId); return Op(JsonContext.Default.GroupDetails, command); } @@ -83,9 +83,9 @@ public GroupDetails CreateGroup(string name, string? description = null) var trimmedDescription = description?.Trim(); - var command = $"group create \"{trimmedName}\""; + var command = new OpCommand("group", "create", trimmedName); if (trimmedDescription is not null) - command += $" --description \"{trimmedDescription}\""; + command.Add("--description", trimmedDescription); return Op(JsonContext.Default.GroupDetails, command); } @@ -120,11 +120,11 @@ public void EditGroup(string groupId, string? name = null, string? description = if (name is null && description is null) throw new InvalidOperationException("Nothing to edit."); - var command = $"group edit {groupId}"; + var command = new OpCommand("group", "edit", groupId); if (trimmedName is not null) - command += $" --name \"{trimmedName}\""; + command.Add("--name", trimmedName); if (trimmedDescription is not null) - command += $" --description \"{trimmedDescription}\""; + command.Add("--description", trimmedDescription); Op(command); } @@ -143,7 +143,7 @@ public void DeleteGroup(string groupId) if (groupId is null || groupId.Length == 0) throw new ArgumentException($"{nameof(groupId)} cannot be empty.", nameof(groupId)); - var command = $"group delete {groupId}"; + var command = new OpCommand("group", "delete", groupId); Op(command); } @@ -170,7 +170,7 @@ public void GrantAccess(string groupId, string userId, UserRole userRole = UserR if (userRole != UserRole.Member && userRole != UserRole.Manager) throw new ArgumentException($"{nameof(userRole)} must be {nameof(UserRole.Member)} or {nameof(UserRole.Manager)}.", nameof(userRole)); - var command = $"group user grant --group {groupId} --user {userId} --role \"{userRole.ToEnumString()}\""; + var command = new OpCommand("group", "user", "grant", "--group", groupId, "--user", userId, "--role", userRole.ToEnumString()); Op(command); } @@ -193,7 +193,7 @@ public void RevokeAccess(string groupId, string userId) if (userId is null || userId.Length == 0) throw new ArgumentException($"{nameof(userId)} cannot be empty.", nameof(userId)); - var command = $"group user revoke --group {groupId} --user {userId}"; + var command = new OpCommand("group", "user", "revoke", "--group", groupId, "--user", userId); Op(command); } } diff --git a/OnePassword.NET/OnePasswordManager.Items.cs b/OnePassword.NET/OnePasswordManager.Items.cs index 01391a8..81e2c6f 100644 --- a/OnePassword.NET/OnePasswordManager.Items.cs +++ b/OnePassword.NET/OnePasswordManager.Items.cs @@ -24,7 +24,7 @@ public ImmutableList GetItems(string vaultId) if (vaultId is null || vaultId.Length == 0) throw new ArgumentException($"{nameof(vaultId)} cannot be empty.", nameof(vaultId)); - var command = $"item list --vault {vaultId}"; + var command = new OpCommand("item", "list", "--vault", vaultId); return Op(JsonContext.Default.ImmutableListItem, command); } @@ -44,17 +44,17 @@ public ImmutableList SearchForItems(string? vaultId = null, bool? includeA if (vaultId is not null && vaultId.Length == 0) throw new ArgumentException($"{nameof(vaultId)} cannot be empty.", nameof(vaultId)); - var command = "item list"; + var command = new OpCommand("item", "list"); if (vaultId is not null) - command += $" --vault {vaultId}"; + command.Add("--vault", vaultId); if (includeArchive is not null && includeArchive.Value) - command += " --include-archive"; + command.Add("--include-archive"); if (favorite is not null && favorite.Value) - command += " --favorite"; + command.Add("--favorite"); if (categories is not null && categories.Count > 0) - command += $" --categories \"{categories.ToCommaSeparated(true)}\""; + command.Add("--categories", categories.ToCommaSeparated(true)); if (tags is not null && tags.Count > 0) - command += $" --tags \"{tags.ToCommaSeparated()}\""; + command.Add("--tags", tags.ToCommaSeparated()); return Op(JsonContext.Default.ImmutableListItem, command); } @@ -77,7 +77,7 @@ public Item GetItem(string itemId, string vaultId) if (vaultId is null || vaultId.Length == 0) throw new ArgumentException($"{nameof(vaultId)} cannot be empty.", nameof(vaultId)); - var command = $"item get {itemId} --vault {vaultId}"; + var command = new OpCommand("item", "get", itemId, "--vault", vaultId); return Op(JsonContext.Default.Item, command); } @@ -100,11 +100,11 @@ public Item SearchForItem(string itemId, string? vaultId = null, bool? includeAr if (vaultId is not null && vaultId.Length == 0) throw new ArgumentException($"{nameof(vaultId)} cannot be empty.", nameof(vaultId)); - var command = $"item get {itemId}"; + var command = new OpCommand("item", "get", itemId); if (vaultId is not null) - command += $" --vault {vaultId}"; + command.Add("--vault", vaultId); if (includeArchive is not null && includeArchive.Value) - command += " --include-archive"; + command.Add("--include-archive"); return Op(JsonContext.Default.Item, command); } @@ -125,9 +125,9 @@ public Item CreateItem(Template template, string vaultId) if (vaultId is null || vaultId.Length == 0) throw new ArgumentException($"{nameof(vaultId)} cannot be empty.", nameof(vaultId)); - var command = $"item create --vault {vaultId} -"; + var command = new OpCommand("item", "create", "--vault", vaultId, "-"); foreach (var assignmentStatement in GetFileAttachmentAssignmentStatements(template.Fields, template.FileAttachments)) - command += $" {QuoteArgument(assignmentStatement)}"; + command.Add(assignmentStatement); var json = SerializeTemplateForItemCommand(template); var createdItem = Op(JsonContext.Default.Item, command, json); @@ -154,20 +154,20 @@ public Item EditItem(Item item, string vaultId) if (vaultId is null || vaultId.Length == 0) throw new ArgumentException($"{nameof(vaultId)} cannot be empty.", nameof(vaultId)); - var command = $"item edit {item.Id} --vault {vaultId}"; + var command = new OpCommand("item", "edit", item.Id, "--vault", vaultId); foreach (var assignmentStatement in GetFileAttachmentAssignmentStatements(item.Fields, item.FileAttachments) .Concat(GetDeletedFileAttachmentAssignmentStatements(item.FileAttachments))) - command += $" {QuoteArgument(assignmentStatement)}"; + command.Add(assignmentStatement); var json = SerializeItemForEditCommand(item); if (item.TitleChanged) - command += $" --title \"{item.Title}\""; + command.Add("--title", item.Title); if (((ITracked)item.Tags).Changed) - command += $" --tags \"{item.Tags.ToCommaSeparated()}\""; + command.Add("--tags", item.Tags.ToCommaSeparated()); if (((ITracked)item.Urls).Changed) { var changedUrl = item.Urls.FirstOrDefault(url => url.Primary && ((ITracked)url).Changed); - command += $" --url \"{changedUrl}\""; + command.Add("--url", $"{changedUrl}"); } var editedItem = Op(JsonContext.Default.Item, command, json); ((ITracked)item).AcceptChanges(); @@ -233,7 +233,7 @@ public void ArchiveItem(string itemId, string vaultId) if (vaultId is null || vaultId.Length == 0) throw new ArgumentException($"{nameof(vaultId)} cannot be empty.", nameof(vaultId)); - var command = $"item delete {itemId} --vault {vaultId} --archive"; + var command = new OpCommand("item", "delete", itemId, "--vault", vaultId, "--archive"); Op(command); } @@ -256,7 +256,7 @@ public void DeleteItem(string itemId, string vaultId) if (vaultId is null || vaultId.Length == 0) throw new ArgumentException($"{nameof(vaultId)} cannot be empty.", nameof(vaultId)); - var command = $"item delete {itemId} --vault {vaultId}"; + var command = new OpCommand("item", "delete", itemId, "--vault", vaultId); Op(command); } @@ -283,7 +283,7 @@ public void MoveItem(string itemId, string currentVaultId, string destinationVau if (destinationVaultId is null || destinationVaultId.Length == 0) throw new ArgumentException($"{nameof(destinationVaultId)} cannot be empty.", nameof(destinationVaultId)); - var command = $"item move {itemId} --current-vault {currentVaultId} --destination-vault {destinationVaultId}"; + var command = new OpCommand("item", "move", itemId, "--current-vault", currentVaultId, "--destination-vault", destinationVaultId); Op(command); } @@ -338,14 +338,14 @@ public ItemShare ShareItem(string itemId, string vaultId, IReadOnlyCollection 0) - command += $" --emails {string.Join(',', normalizedEmailAddresses)}"; + command.Add("--emails", string.Join(',', normalizedEmailAddresses)); if (expiresIn is not null) - command += $" --expires-in {expiresIn.Value.ToHumanReadable()}"; + command.Add("--expires-in", expiresIn.Value.ToHumanReadable()); if (viewOnce is not null && viewOnce.Value) - command += " --view-once"; + command.Add("--view-once"); return ParseItemShare(Op(command)); } @@ -531,11 +531,6 @@ private static string BuildFileAttachmentTarget(string? name, Section? section, return trimmedSection.Length == 0 ? trimmedName : $"{trimmedSection}.{trimmedName}"; } - private static string QuoteArgument(string argument) - { - return $"\"{argument.Replace("\"", "\\\"", StringComparison.InvariantCulture)}\""; - } - private static ItemShare ParseItemShare(string result) { var trimmedResult = result.Trim(); diff --git a/OnePassword.NET/OnePasswordManager.Templates.cs b/OnePassword.NET/OnePasswordManager.Templates.cs index 078c185..609e649 100644 --- a/OnePassword.NET/OnePasswordManager.Templates.cs +++ b/OnePassword.NET/OnePasswordManager.Templates.cs @@ -9,7 +9,7 @@ public sealed partial class OnePasswordManager /// public ImmutableList GetTemplates() { - const string command = "item template list"; + var command = new OpCommand("item", "template", "list"); return Op(JsonContext.Default.ImmutableListTemplateInfo, command); } @@ -19,7 +19,7 @@ public Template GetTemplate(ITemplate template) if (template is null || template.Name.Length == 0) throw new ArgumentException($"{nameof(template.Name)} cannot be empty.", nameof(template)); - var command = $"item template get \"{template.Name}\""; + var command = new OpCommand("item", "template", "get", template.Name); var result = Op(JsonContext.Default.Template, command); result.Name = template.Name; @@ -33,7 +33,7 @@ public Template GetTemplate(string name) if (string.IsNullOrEmpty(name)) throw new ArgumentException($"{nameof(name)} cannot be empty.", nameof(name)); - var command = $"item template get \"{name}\""; + var command = new OpCommand("item", "template", "get", name); var result = Op(JsonContext.Default.Template, command); result.Name = name; @@ -49,7 +49,7 @@ public Template GetTemplate(Category category) var templateName = category.ToEnumString(); - var command = $"item template get \"{templateName}\""; + var command = new OpCommand("item", "template", "get", templateName); var result = Op(JsonContext.Default.Template, command); result.Name = templateName; diff --git a/OnePassword.NET/OnePasswordManager.Users.cs b/OnePassword.NET/OnePasswordManager.Users.cs index 5b66d3f..3dfd6cb 100644 --- a/OnePassword.NET/OnePasswordManager.Users.cs +++ b/OnePassword.NET/OnePasswordManager.Users.cs @@ -10,7 +10,7 @@ public sealed partial class OnePasswordManager /// public ImmutableList GetUsers() { - const string command = "user list"; + var command = new OpCommand("user", "list"); return Op(JsonContext.Default.ImmutableListUser, command); } @@ -38,7 +38,7 @@ public ImmutableList GetGroupUsers(string groupId) if (groupId is null || groupId.Length == 0) throw new ArgumentException($"{nameof(groupId)} cannot be empty.", nameof(groupId)); - var command = $"group user list {groupId}"; + var command = new OpCommand("group", "user", "list", groupId); return Op(JsonContext.Default.ImmutableListGroupUser, command); } @@ -48,7 +48,7 @@ public ImmutableList GetVaultUsers(string vaultId) if (vaultId is null || vaultId.Length == 0) throw new ArgumentException($"{nameof(vaultId)} cannot be empty.", nameof(vaultId)); - var command = $"vault user list {vaultId}"; + var command = new OpCommand("vault", "user", "list", vaultId); return Op(JsonContext.Default.ImmutableListVaultUser, command); } @@ -67,7 +67,7 @@ public UserDetails GetUser(string userId) if (userId is null || userId.Length == 0) throw new ArgumentException($"{nameof(userId)} cannot be empty.", nameof(userId)); - var command = $"user get {userId}"; + var command = new OpCommand("user", "get", userId); return Op(JsonContext.Default.UserDetails, command); } @@ -87,9 +87,9 @@ public UserDetails ProvisionUser(string name, string emailAddress, Language lang if (trimmedEmailAddress.Length == 0) throw new ArgumentException($"{nameof(emailAddress)} cannot be empty.", nameof(emailAddress)); - var command = $"user provision --name \"{trimmedName}\" --email \"{trimmedEmailAddress}\""; + var command = new OpCommand("user", "provision", "--name", trimmedName, "--email", trimmedEmailAddress); if (language != Language.Default) - command += $" --language \"{language.ToEnumString()}\""; + command.Add("--language", language.ToEnumString()); return Op(JsonContext.Default.UserDetails, command); } @@ -108,14 +108,14 @@ public void ConfirmUser(string userId) if (userId is null || userId.Length == 0) throw new ArgumentException($"{nameof(userId)} cannot be empty.", nameof(userId)); - var command = $"user confirm {userId}"; + var command = new OpCommand("user", "confirm", userId); Op(command); } /// public void ConfirmAllUsers() { - const string command = "user confirm --all"; + var command = new OpCommand("user", "confirm", "--all"); Op(command); } @@ -148,11 +148,11 @@ public void EditUser(string userId, string? name = null, bool? travelMode = null if (name is null && travelMode is null) throw new InvalidOperationException("Nothing to edit."); - var command = $"user edit {userId}"; + var command = new OpCommand("user", "edit", userId); if (trimmedName is not null) - command += $" --name \"{trimmedName}\""; + command.Add("--name", trimmedName); if (travelMode.HasValue) - command += $" --travel-mode {(travelMode.Value ? "on" : "off")}"; + command.Add("--travel-mode", travelMode.Value ? "on" : "off"); Op(command); } @@ -171,7 +171,7 @@ public void DeleteUser(string userId) if (userId is null || userId.Length == 0) throw new ArgumentException($"{nameof(userId)} cannot be empty.", nameof(userId)); - var command = $"user delete {userId}"; + var command = new OpCommand("user", "delete", userId); Op(command); } @@ -190,9 +190,9 @@ public void SuspendUser(string userId, int? deauthorizeDevicesDelay = null) if (userId is null || userId.Length == 0) throw new ArgumentException($"{nameof(userId)} cannot be empty.", nameof(userId)); - var command = $"user suspend {userId}"; + var command = new OpCommand("user", "suspend", userId); if (deauthorizeDevicesDelay is not null) - command += $" --deauthorize-devices-after {deauthorizeDevicesDelay.Value}s"; + command.Add("--deauthorize-devices-after", $"{deauthorizeDevicesDelay.Value}s"); Op(command); } @@ -211,7 +211,7 @@ public void ReactivateUser(string userId) if (userId is null || userId.Length == 0) throw new ArgumentException($"{nameof(userId)} cannot be empty.", nameof(userId)); - var command = $"user reactivate {userId}"; + var command = new OpCommand("user", "reactivate", userId); Op(command); } } diff --git a/OnePassword.NET/OnePasswordManager.Vaults.cs b/OnePassword.NET/OnePasswordManager.Vaults.cs index ed24fb8..4746f4c 100644 --- a/OnePassword.NET/OnePasswordManager.Vaults.cs +++ b/OnePassword.NET/OnePasswordManager.Vaults.cs @@ -10,7 +10,7 @@ public sealed partial class OnePasswordManager /// public ImmutableList GetVaults() { - const string command = "vault list"; + var command = new OpCommand("vault", "list"); return Op(JsonContext.Default.ImmutableListVault, command); } @@ -38,7 +38,7 @@ public ImmutableList GetGroupVaults(string groupId) if (groupId is null || groupId.Length == 0) throw new ArgumentException($"{nameof(groupId)} cannot be empty.", nameof(groupId)); - var command = $"vault list --group {groupId}"; + var command = new OpCommand("vault", "list", "--group", groupId); return Op(JsonContext.Default.ImmutableListVault, command); } @@ -48,7 +48,7 @@ public ImmutableList GetUserVaults(string userId) if (userId is null || userId.Length == 0) throw new ArgumentException($"{nameof(userId)} cannot be empty.", nameof(userId)); - var command = $"vault list --user {userId}"; + var command = new OpCommand("vault", "list", "--user", userId); return Op(JsonContext.Default.ImmutableListVault, command); } @@ -67,7 +67,7 @@ public VaultDetails GetVault(string vaultId) if (vaultId is null || vaultId.Length == 0) throw new ArgumentException($"{nameof(vaultId)} cannot be empty.", nameof(vaultId)); - var command = $"vault get {vaultId}"; + var command = new OpCommand("vault", "get", vaultId); return Op(JsonContext.Default.VaultDetails, command); } @@ -83,13 +83,13 @@ public VaultDetails CreateVault(string name, string? description = null, VaultIc var trimmedDescription = description?.Trim(); - var command = $"vault create \"{trimmedName}\""; + var command = new OpCommand("vault", "create", trimmedName); if (trimmedDescription is not null) - command += $" --description \"{trimmedDescription}\""; + command.Add("--description", trimmedDescription); if (icon != VaultIcon.Default && icon != VaultIcon.Unknown) - command += $" --icon \"{icon.ToEnumString()}\""; + command.Add("--icon", icon.ToEnumString()); if (allowAdminsToManage.HasValue) - command += $" --allow-admins-to-manage {(allowAdminsToManage.Value ? "true" : "false")}"; + command.Add("--allow-admins-to-manage", allowAdminsToManage.Value ? "true" : "false"); return Op(JsonContext.Default.VaultDetails, command); } @@ -124,15 +124,15 @@ public void EditVault(string vaultId, string? name = null, string? description = if (name is null && description is null && icon is VaultIcon.Default or VaultIcon.Unknown && travelMode is null) throw new InvalidOperationException("Nothing to edit."); - var command = $"vault edit {vaultId}"; + var command = new OpCommand("vault", "edit", vaultId); if (trimmedName is not null) - command += $" --name \"{trimmedName}\""; + command.Add("--name", trimmedName); if (trimmedDescription is not null) - command += $" --description \"{trimmedDescription}\""; + command.Add("--description", trimmedDescription); if (icon != VaultIcon.Default && icon != VaultIcon.Unknown) - command += $" --icon \"{icon.ToEnumString()}\""; + command.Add("--icon", icon.ToEnumString()); if (travelMode.HasValue) - command += $" --travel-mode {(travelMode.Value ? "on" : "off")}"; + command.Add("--travel-mode", travelMode.Value ? "on" : "off"); Op(command); } @@ -151,7 +151,7 @@ public void DeleteVault(string vaultId) if (vaultId is null || vaultId.Length == 0) throw new ArgumentException($"{nameof(vaultId)} cannot be empty.", nameof(vaultId)); - var command = $"vault delete {vaultId}"; + var command = new OpCommand("vault", "delete", vaultId); Op(command); } @@ -191,7 +191,7 @@ public void GrantGroupPermissions(string vaultId, string groupId, IReadOnlyColle if (permissions is null || permissions.Count == 0) throw new ArgumentException($"{nameof(permissions)} cannot be empty.", nameof(permissions)); - var command = $"vault group grant --vault {vaultId} --group {groupId} --permissions \"{permissions.ToCommaSeparated()}\""; + var command = new OpCommand("vault", "group", "grant", "--vault", vaultId, "--group", groupId, "--permissions", permissions.ToCommaSeparated()); Op(command); } @@ -205,7 +205,7 @@ public void GrantUserPermissions(string vaultId, string userId, IReadOnlyCollect if (permissions is null || permissions.Count == 0) throw new ArgumentException($"{nameof(permissions)} cannot be empty.", nameof(permissions)); - var command = $"vault user grant --vault {vaultId} --user {userId} --permissions \"{permissions.ToCommaSeparated()}\""; + var command = new OpCommand("vault", "user", "grant", "--vault", vaultId, "--user", userId, "--permissions", permissions.ToCommaSeparated()); Op(command); } @@ -245,7 +245,7 @@ public void RevokeGroupPermissions(string vaultId, string groupId, IReadOnlyColl if (permissions is null || permissions.Count == 0) throw new ArgumentException($"{nameof(permissions)} cannot be empty.", nameof(permissions)); - var command = $"vault group revoke --vault {vaultId} --group {groupId} --permissions \"{permissions.ToCommaSeparated()}\""; + var command = new OpCommand("vault", "group", "revoke", "--vault", vaultId, "--group", groupId, "--permissions", permissions.ToCommaSeparated()); Op(command); } @@ -259,7 +259,7 @@ public void RevokeUserPermissions(string vaultId, string userId, IReadOnlyCollec if (permissions is null || permissions.Count == 0) throw new ArgumentException($"{nameof(permissions)} cannot be empty.", nameof(permissions)); - var command = $"vault user revoke --vault {vaultId} --user {userId} --permissions \"{permissions.ToCommaSeparated()}\""; + var command = new OpCommand("vault", "user", "revoke", "--vault", vaultId, "--user", userId, "--permissions", permissions.ToCommaSeparated()); Op(command); } } diff --git a/OnePassword.NET/OnePasswordManager.cs b/OnePassword.NET/OnePasswordManager.cs index 9c4a42f..3f7d900 100644 --- a/OnePassword.NET/OnePasswordManager.cs +++ b/OnePassword.NET/OnePasswordManager.cs @@ -1,4 +1,5 @@ -using System.Diagnostics; +using System.Collections; +using System.Diagnostics; using System.IO.Compression; using System.Runtime.InteropServices; using System.Text; @@ -20,12 +21,38 @@ public sealed partial class OnePasswordManager : IOnePasswordManager #else private static readonly Regex VersionRegex = new (@"Version ([^\s]+) is now available\.", RegexOptions.Compiled); #endif - private readonly string[] _excludedAccountCommands = ["--version", "update", "account list", "account add", "account forget", "signout --all"]; - private readonly string[] _excludedSessionCommands = ["--version", "update", "account list", "account add", "account forget", "signin", "signout", "signout --all"]; + private static readonly string[][] ExcludedAccountCommands = + [ + ["--version"], + ["update"], + ["account", "list"], + ["account", "add"], + ["account", "forget"], + ["signout", "--all"] + ]; + + private static readonly string[][] ExcludedSessionCommands = + [ + ["--version"], + ["update"], + ["account", "list"], + ["account", "add"], + ["account", "forget"], + ["signin"], + ["signout"], + ["signout", "--all"] + ]; + + private static readonly string[][] ServiceAccountUnsupportedCommands = + [ + ["events-api"], + ["group"], + ["user"] + ]; + private readonly Mode _mode = Mode.Interactive; private readonly string _opPath; private readonly string _serviceAccountToken; - private readonly string[] _serviceAccountUnsupportedCommands = ["events-api", "group", "user"]; private readonly bool _verbose; private string _account = ""; private string _session = ""; @@ -70,7 +97,7 @@ public bool Update() var tempDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); Directory.CreateDirectory(tempDirectory); - var command = $"update --directory \"{tempDirectory}\""; + var command = new OpCommand("update", "--directory", tempDirectory); var result = Op(command); var match = VersionRegex.Match(result); @@ -106,7 +133,7 @@ public string GetSecret(string reference) if (trimmedReference.Length == 0) throw new ArgumentException($"{nameof(trimmedReference)} cannot be empty.", nameof(reference)); - var command = $"read {trimmedReference} --no-newline"; + var command = new OpCommand("read", trimmedReference, "--no-newline"); return Op(command); } @@ -125,9 +152,9 @@ public void SaveSecret(string reference, string filePath, string? fileMode = nul throw new ArgumentException($"{nameof(trimmedFilePath)} cannot be empty.", nameof(filePath)); var trimmedFileMode = fileMode?.Trim(); - var command = $"read {trimmedReference} --no-newline --force --out-file \"{trimmedFilePath}\""; + var command = new OpCommand("read", trimmedReference, "--no-newline", "--force", "--out-file", trimmedFilePath); if (trimmedFileMode is not null && trimmedFileMode.Length > 0) - command += $" --file-mode {trimmedFileMode}"; + command.Add("--file-mode", trimmedFileMode); Op(command); } @@ -150,7 +177,7 @@ private static OnePasswordManagerOptions ValidateOptions(OnePasswordManagerOptio private string GetVersion() { - const string command = "--version"; + var command = new OpCommand("--version"); return Op(command).Trim(); } @@ -169,6 +196,21 @@ private static void EnsureExecutablePermissions(string executablePath) chmod?.WaitForExit(); } + private static void ApplyFileMode(string fileMode, string filePath) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return; + + using var chmod = Process.Start(new ProcessStartInfo("chmod", $"{fileMode} \"{filePath}\"") + { + CreateNoWindow = true, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true + }); + chmod?.WaitForExit(); + } + private static string GetStandardError(Process process) { var error = new StringBuilder(); @@ -185,7 +227,7 @@ private static string GetStandardOutput(Process process) return output.ToString(); } - private TResult Op(JsonTypeInfo jsonTypeInfo, string command, string? input = null, bool returnError = false) where TResult : class + private TResult Op(JsonTypeInfo jsonTypeInfo, OpCommand command, string? input = null, bool returnError = false) where TResult : class { var result = Op(command, input is null ? Array.Empty() : [input], returnError); var obj = JsonSerializer.Deserialize(result, jsonTypeInfo) ?? throw new SerializationException("Could not deserialize the command result."); @@ -194,44 +236,44 @@ private TResult Op(JsonTypeInfo jsonTypeInfo, string command, return obj; } - private string Op(string command, string? input = null, bool returnError = false) => Op(command, input is null ? Array.Empty() : [input], returnError); + private string Op(OpCommand command, string? input = null, bool returnError = false, bool formatOutput = true) => Op(command, input is null ? Array.Empty() : [input], returnError, formatOutput); - private string Op(string command, IEnumerable input, bool returnError) + private string Op(OpCommand command, IEnumerable input, bool returnError, bool formatOutput = true) { - var arguments = command; - if (command != "--version") - arguments += " --format json --no-color"; + var arguments = command.Clone(); + if (!command.StartsWith(["--version"]) && formatOutput) + arguments.Add("--format", "json", "--no-color"); switch (_mode) { case Mode.ServiceAccount: - if (IsUnsupportedCommand(command, _serviceAccountUnsupportedCommands)) + if (IsCommandMatch(command, ServiceAccountUnsupportedCommands)) throw new InvalidOperationException($"Unsupported command {command} when using ServiceAccount"); break; case Mode.Interactive: case Mode.AppIntegrated: default: - var excluded = IsExcludedCommand(command, _excludedAccountCommands); + var excluded = IsCommandMatch(command, ExcludedAccountCommands); var requireAccount = _mode != Mode.AppIntegrated && !excluded; var passAccount = _account.Length != 0 && !excluded; if (requireAccount && !passAccount) throw new InvalidOperationException("Cannot execute command because account has not been set."); - var passSession = !(_mode == Mode.AppIntegrated || IsExcludedCommand(command, _excludedSessionCommands)); + var passSession = !(_mode == Mode.AppIntegrated || IsCommandMatch(command, ExcludedSessionCommands)); if (passSession && _session.Length == 0) throw new InvalidOperationException("Cannot execute command because account has not been signed in."); if (passAccount) - arguments += $" --account {_account}"; + arguments.Add("--account", _account); if (passSession) - arguments += $" --session {_session}"; + arguments.Add("--session", _session); break; } if (_verbose) Console.WriteLine($"{Path.GetDirectoryName(_opPath)}>op {arguments}"); - var startInfo = new ProcessStartInfo(_opPath, arguments) + var startInfo = new ProcessStartInfo(_opPath) { CreateNoWindow = true, UseShellExecute = false, @@ -241,6 +283,15 @@ private string Op(string command, IEnumerable input, bool returnError) StandardOutputEncoding = Encoding.UTF8, StandardErrorEncoding = Encoding.UTF8 }; + if (IsWindowsCommandScript(_opPath)) + { + startInfo.Arguments = arguments.ToString(); + } + else + { + foreach (var argument in arguments) + startInfo.ArgumentList.Add(argument); + } if (_mode == Mode.ServiceAccount) startInfo.EnvironmentVariables["OP_SERVICE_ACCOUNT_TOKEN"] = _serviceAccountToken; @@ -279,14 +330,59 @@ private string Op(string command, IEnumerable input, bool returnError) throw new InvalidOperationException(error.Length > 28 ? error[28..].Trim() : error); } - private static bool IsExcludedCommand(string command, IEnumerable excludedCommands) + private static bool IsCommandMatch(OpCommand command, IEnumerable> commandPrefixes) + { + return commandPrefixes.Any(command.StartsWith); + } + + private static bool IsWindowsCommandScript(string filePath) { - return excludedCommands.Any(x => command.StartsWith(x, StringComparison.InvariantCulture)); + var extension = Path.GetExtension(filePath); + return RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + && (string.Equals(extension, ".cmd", StringComparison.OrdinalIgnoreCase) + || string.Equals(extension, ".bat", StringComparison.OrdinalIgnoreCase)); } - private static bool IsUnsupportedCommand(string command, IEnumerable unsupportedCommands) + private sealed class OpCommand : IEnumerable { - return unsupportedCommands.Any(x => command.StartsWith(x, StringComparison.InvariantCulture)); + private readonly List _arguments; + + public OpCommand(params string[] arguments) + { + _arguments = [.. arguments]; + } + + private OpCommand(IEnumerable arguments) + { + _arguments = [.. arguments]; + } + + public OpCommand Add(params string[] arguments) + { + _arguments.AddRange(arguments); + return this; + } + + public OpCommand Clone() => new(_arguments); + + public IEnumerator GetEnumerator() => _arguments.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + public bool StartsWith(IReadOnlyList prefix) + { + return prefix.Count <= _arguments.Count + && prefix.Select((argument, index) => string.Equals(argument, _arguments[index], StringComparison.Ordinal)).All(static matches => matches); + } + + public override string ToString() => string.Join(" ", _arguments.Select(FormatArgument)); + + private static string FormatArgument(string argument) + { + return argument.Any(char.IsWhiteSpace) || argument.Contains('"', StringComparison.Ordinal) + ? $"\"{argument.Replace("\"", "\\\"", StringComparison.Ordinal)}\"" + : argument; + } } #if NET7_0_OR_GREATER diff --git a/README.md b/README.md index c85d976..57f763b 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,25 @@ var attachment = itemWithAttachments.FileAttachments.First(); onePassword.SaveFileAttachmentContent(attachment, itemWithAttachments, vault, @"C:\Files\Production.env"); ``` +### Reading variables from a 1Password Environment + +This feature requires the beta 1Password CLI `2.33.0-beta.02` or later. + +```csharp +var variables = onePassword.GetEnvironmentVariables("environment-id"); + +foreach (var variable in variables) + Console.WriteLine($"{variable.Name}={variable.Value}"); +``` + +### Saving variables from a 1Password Environment + +This writes the Environment output to disk using the same `KEY=value` format returned by the CLI. + +```csharp +onePassword.SaveEnvironmentVariables("environment-id", @".env"); +``` + ### Sharing an item without email restrictions ```csharp diff --git a/docfx/docs/quick-start.md b/docfx/docs/quick-start.md index 9f426e3..84aaec2 100644 --- a/docfx/docs/quick-start.md +++ b/docfx/docs/quick-start.md @@ -141,6 +141,25 @@ var attachment = itemWithAttachments.FileAttachments.First(); onePassword.SaveFileAttachmentContent(attachment, itemWithAttachments, vault, @"C:\Files\Production.env"); ``` +### Reading variables from a 1Password Environment + +This feature requires the beta 1Password CLI `2.33.0-beta.02` or later. + +```csharp +var variables = onePassword.GetEnvironmentVariables("environment-id"); + +foreach (var variable in variables) + Console.WriteLine($"{variable.Name}={variable.Value}"); +``` + +### Saving variables from a 1Password Environment + +This writes the Environment output to disk using the same `KEY=value` format returned by the CLI. + +```csharp +onePassword.SaveEnvironmentVariables("environment-id", @".env"); +``` + ### Sharing an item without email restrictions ```csharp