diff --git a/.github/workflows/develop.yml b/.github/workflows/develop.yml index a3ab8b1..0feedfe 100644 --- a/.github/workflows/develop.yml +++ b/.github/workflows/develop.yml @@ -60,18 +60,10 @@ jobs: uses: github/codeql-action/init@v4 with: languages: 'csharp' - - name: Setup .NET 6.0 + - name: Setup .NET 11.0 uses: actions/setup-dotnet@v5 with: - dotnet-version: 6.0.x - - name: Setup .NET 7.0 - uses: actions/setup-dotnet@v5 - with: - dotnet-version: 7.0.x - - name: Setup .NET 8.0 - uses: actions/setup-dotnet@v5 - with: - dotnet-version: 8.0.x + global-json-file: global.json - name: Restore dependencies run: dotnet restore - name: Build diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 8091de3..32c07b8 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -29,13 +29,13 @@ jobs: - name: Setup Pages uses: actions/configure-pages@v6 - - name: Setup .NET 8.0 + - name: Setup .NET 11.0 uses: actions/setup-dotnet@v5 with: - dotnet-version: 8.x + global-json-file: global.json - name: Setup docfx - run: dotnet tool update -g docfx + run: dotnet tool install -g docfx --allow-roll-forward - name: Build documentation run: docfx ./docfx/docfx.json diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6f88c17..69a6a4a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -21,18 +21,10 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v7 - - name: Setup .NET 6.0 + - name: Setup .NET 11.0 uses: actions/setup-dotnet@v5 with: - dotnet-version: 6.0.x - - name: Setup .NET 7.0 - uses: actions/setup-dotnet@v5 - with: - dotnet-version: 7.0.x - - name: Setup .NET 8.0 - uses: actions/setup-dotnet@v5 - with: - dotnet-version: 8.0.x + global-json-file: global.json - name: Restore dependencies run: dotnet restore - name: Build diff --git a/LICENSE.md b/LICENSE.md index 55da079..29d820c 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2021-2023 Jean-Sebastien Carle +Copyright (c) 2021-2026 Jean-Sebastien Carle Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/OnePassword.NET.Tests/AccountPreTests.cs b/OnePassword.NET.Tests/AccountPreTests.cs index e67b5c6..11d4999 100644 --- a/OnePassword.NET.Tests/AccountPreTests.cs +++ b/OnePassword.NET.Tests/AccountPreTests.cs @@ -76,14 +76,14 @@ public void GetAccounts() var account = accounts.First(); - Assert.Multiple((Action)delegate + using (Assert.EnterMultipleScope()) { Assert.That(account.Id, Is.Not.Empty); Assert.That(account.Url, Is.EqualTo($"https://{AccountAddress}")); Assert.That(account.UserId, Is.Not.Empty); Assert.That(account.Email, Is.EqualTo(AccountEmail)); Assert.That(account.Shorthand, Is.EqualTo(AccountAddress[..AccountAddress.IndexOf('.', StringComparison.Ordinal)])); - }); + } }); } @@ -98,7 +98,7 @@ public void GetAccount() { var account = OnePassword.GetAccount(); - Assert.Multiple((Action)delegate + using (Assert.EnterMultipleScope()) { Assert.That(account.Id, Is.Not.Empty); Assert.That(account.Name, Is.EqualTo(AccountName)); @@ -106,7 +106,7 @@ public void GetAccount() Assert.That(account.Type, Is.Not.EqualTo(AccountType.Unknown)); Assert.That(account.State, Is.EqualTo(State.Active)); Assert.That(account.Created, Is.Not.EqualTo(default(DateTimeOffset))); - }); + } }); } diff --git a/OnePassword.NET.Tests/Common/TestsBase.cs b/OnePassword.NET.Tests/Common/TestsBase.cs index 92383ae..3cd99aa 100644 --- a/OnePassword.NET.Tests/Common/TestsBase.cs +++ b/OnePassword.NET.Tests/Common/TestsBase.cs @@ -28,6 +28,7 @@ public class TestsBase private static readonly CancellationTokenSource TestCancellationTokenSource = new(); private static readonly CancellationTokenSource TearDownCancellationTokenSource = new(); private static readonly Uri DownloadSource = GetDownloadSource(); + private static readonly HttpClient CliDownloadClient = new(); private static readonly string ExecutableName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "op.exe" : "op"; private static bool _initialSetupDone; @@ -55,8 +56,7 @@ public async Task Setup() var zipFileName = Path.Combine(WorkingDirectory, Path.GetFileName(DownloadSource.LocalPath)); var extractFilename = Path.Combine(WorkingDirectory, ExecutableName); - using var httpClient = new HttpClient(); - await using (var stream = await httpClient.GetStreamAsync(DownloadSource.OriginalString)) + await using (var stream = await CliDownloadClient.GetStreamAsync(DownloadSource.OriginalString)) await using (var fileStream = new FileStream(zipFileName, FileMode.Create)) await stream.CopyToAsync(fileStream); @@ -98,7 +98,7 @@ protected static void Run(RunType runType, Action action) { action(); } - catch (NUnit.Framework.IgnoreException) + catch (IgnoreException) { throw; } @@ -145,7 +145,7 @@ private static string GetEnv(string name, string value) private static Uri GetDownloadSource() { - const string version = "v2.32.1"; + const string version = "v2.42.0"; if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return new Uri($"https://cache.agilebits.com/dist/1P/op2/pkg/{version}/op_windows_amd64_{version}.zip"); diff --git a/OnePassword.NET.Tests/OnePassword.NET.Tests.csproj b/OnePassword.NET.Tests/OnePassword.NET.Tests.csproj index eea1c14..7079e3e 100644 --- a/OnePassword.NET.Tests/OnePassword.NET.Tests.csproj +++ b/OnePassword.NET.Tests/OnePassword.NET.Tests.csproj @@ -1,7 +1,7 @@ - net8.0 + net11.0 enable enable false diff --git a/OnePassword.NET.Tests/OnePasswordManagerCommandTests.cs b/OnePassword.NET.Tests/OnePasswordManagerCommandTests.cs index 7f8bb92..0ae3f4e 100644 --- a/OnePassword.NET.Tests/OnePasswordManagerCommandTests.cs +++ b/OnePassword.NET.Tests/OnePasswordManagerCommandTests.cs @@ -19,11 +19,11 @@ public class OnePasswordManagerCommandTests [Test] public void VersionIsTrimmed() { - using var fakeCli = new FakeCli(versionOutput: "2.32.1\r\n"); + using var fakeCli = new FakeCli(versionOutput: "2.42.0\r\n"); var manager = fakeCli.CreateManager(); - Assert.That(manager.Version, Is.EqualTo("2.32.1")); + Assert.That(manager.Version, Is.EqualTo("2.42.0")); } [Test] @@ -101,7 +101,7 @@ public void SaveFileAttachmentContentUsesGeneratedReference() { manager.SaveFileAttachmentContent("file-id", "item-id", "vault-id", outputPath); - Assert.That(fakeCli.LastArguments, Does.StartWith("read op://vault-id/item-id/file-id?attr=content --no-newline --force --out-file ")); + Assert.That(fakeCli.LastArguments, Does.Match("^read \"?op://vault-id/item-id/file-id\\?attr=content\"? --no-newline --force --out-file ")); Assert.That(fakeCli.LastArguments, Does.Contain(outputPath)); } finally @@ -119,12 +119,12 @@ public void MoveItemStringOverloadUsesResolvedVaultIds() manager.MoveItem("item-id", "current-vault-id", "destination-vault-id"); - Assert.Multiple((Action)delegate + using (Assert.EnterMultipleScope()) { Assert.That(fakeCli.LastArguments, Does.StartWith("item move item-id --current-vault current-vault-id --destination-vault destination-vault-id")); Assert.That(fakeCli.LastArguments, Does.Not.Contain("{currentVaultId}")); Assert.That(fakeCli.LastArguments, Does.Not.Contain("{destinationVaultId}")); - }); + } } [Test] @@ -139,13 +139,13 @@ public void SearchForDocumentCreatesMissingOutputDirectory() { manager.SearchForDocument("document-id", outputFilePath, "vault-id"); - Assert.Multiple((Action)delegate + using (Assert.EnterMultipleScope()) { Assert.That(Directory.Exists(outputDirectory), Is.True); Assert.That(fakeCli.LastArguments, Does.StartWith("document get document-id --out-file ")); Assert.That(fakeCli.LastArguments, Does.Contain(outputFilePath)); Assert.That(fakeCli.LastArguments, Does.Contain(" --force --vault vault-id")); - }); + } } finally { @@ -163,7 +163,7 @@ public void GetSecretUsesTrimmedReference() manager.GetSecret(" op://vault/item/field "); - Assert.That(fakeCli.LastArguments, Does.StartWith("read op://vault/item/field --no-newline")); + Assert.That(fakeCli.LastArguments, Does.Match("^read \"?op://vault/item/field\"? --no-newline")); } [Test] @@ -177,7 +177,7 @@ public void SaveSecretUsesTrimmedReference() { manager.SaveSecret(" op://vault/item/field ", outputPath); - Assert.That(fakeCli.LastArguments, Does.StartWith("read op://vault/item/field --no-newline --force --out-file ")); + Assert.That(fakeCli.LastArguments, Does.Match("^read \"?op://vault/item/field\"? --no-newline --force --out-file ")); Assert.That(fakeCli.LastArguments, Does.Contain(outputPath)); } finally @@ -196,22 +196,22 @@ public void RevokeGroupPermissionsUsesVaultGroupCommand() manager.RevokeGroupPermissions("vault-id", "group-id", [VaultPermission.ViewItems]); Assert.That(fakeCli.LastArguments, Does.StartWith("vault group revoke --vault vault-id --group group-id --permissions ")); - Assert.That(fakeCli.LastArguments, Does.Contain("View Items")); + Assert.That(fakeCli.LastArguments, Does.Contain("view_items")); } [Test] public void UpdateExtractsCurrentPlatformExecutablePayload() { - using var fakeCli = new FakeCli(updateVersionOutput: "2.33.0\n"); + using var fakeCli = new FakeCli(versionOutput: "2.41.1\n", updateVersionOutput: "2.42.0\n"); var manager = fakeCli.CreateManager(); var updated = manager.Update(); - Assert.Multiple((Action)delegate + using (Assert.EnterMultipleScope()) { Assert.That(updated, Is.True); - Assert.That(manager.Version, Is.EqualTo("2.33.0")); - }); + Assert.That(manager.Version, Is.EqualTo("2.42.0")); + } } [Test] @@ -263,7 +263,7 @@ public void CreateItemFailureKeepsTemplateDirty() template.Title = "Updated Title"; - Assert.Throws((Action)delegate + Assert.Throws(() => { manager.CreateItem(template, "vault-id"); }); @@ -281,7 +281,7 @@ public void EditItemFailureKeepsItemDirty() item.Title = "Updated Title"; - Assert.Throws((Action)delegate + Assert.Throws(() => { manager.EditItem(item, "vault-id"); }); @@ -303,13 +303,13 @@ public void EditItemPreservesCustomTemplateCategoryIdInPayload() var root = jsonDocument.RootElement; var fields = root.GetProperty("fields").EnumerateArray().ToList(); - Assert.Multiple((Action)delegate + using (Assert.EnterMultipleScope()) { Assert.That(root.GetProperty("category_id").GetString(), Is.EqualTo("custom-template-uuid")); Assert.That(fields.Any(field => field.GetProperty("label").GetString() == "Environment" && field.GetProperty("value").GetString() == "Production"), Is.True); - }); + } } [Test] @@ -320,7 +320,7 @@ public void ShareItemWithoutEmailsOmitsEmailsFlag() var result = manager.ShareItem("item-id", "vault-id"); - Assert.Multiple((Action)delegate + using (Assert.EnterMultipleScope()) { Assert.That(result.Url, Is.EqualTo(new Uri("https://share.example/item"))); Assert.That(result.ExpiresAt, Is.Null); @@ -328,7 +328,7 @@ public void ShareItemWithoutEmailsOmitsEmailsFlag() Assert.That(result.ViewOnce, Is.Null); Assert.That(fakeCli.LastArguments, Does.StartWith("item share item-id --vault vault-id")); Assert.That(fakeCli.LastArguments, Does.Not.Contain("--emails")); - }); + } } [Test] @@ -339,12 +339,12 @@ public void ShareItemStringSingleEmailOverloadUsesEmailsFlag() var result = manager.ShareItem("item-id", "vault-id", "recipient@example.com"); - Assert.Multiple((Action)delegate + using (Assert.EnterMultipleScope()) { Assert.That(result.Url, Is.Null); Assert.That(result.Recipients, Is.Empty); Assert.That(fakeCli.LastArguments, Does.Contain("--emails recipient@example.com")); - }); + } } [Test] @@ -433,48 +433,43 @@ public void ShareItemParsesStructuredShareResult() var result = manager.ShareItem("item-id", "vault-id"); - Assert.Multiple((Action)delegate + using (Assert.EnterMultipleScope()) { Assert.That(result.Url, Is.EqualTo(new Uri("https://share.example/item"))); Assert.That(result.ExpiresAt, Is.EqualTo(DateTimeOffset.Parse("2026-03-15T12:00:00Z", CultureInfo.InvariantCulture))); Assert.That(result.ViewOnce, Is.True); Assert.That(result.Recipients, Is.EqualTo(ParsedRecipients)); - }); + } } private sealed class FakeCli : IDisposable { private readonly string _argumentsPath; private readonly string _directoryPath; - private readonly string _errorOutputPath; private readonly string _inputPath; - private readonly string _nextOutputPath; - private readonly string _updateMessagePath; private readonly string _updatePayloadPath; - private readonly string _updatedVersionOutputPath; - private readonly string _versionOutputPath; - public FakeCli(string versionOutput = "2.32.1\n", string nextOutput = "{}", string? updateVersionOutput = null, string? errorOutput = null) + public FakeCli(string versionOutput = "2.42.0\n", string nextOutput = "{}", string? updateVersionOutput = null, string? errorOutput = null) { _directoryPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); _argumentsPath = Path.Combine(_directoryPath, "last-arguments.txt"); - _errorOutputPath = Path.Combine(_directoryPath, "error-output.txt"); + var errorOutputPath = Path.Combine(_directoryPath, "error-output.txt"); _inputPath = Path.Combine(_directoryPath, "last-input.txt"); - _nextOutputPath = Path.Combine(_directoryPath, "next-output.txt"); - _updateMessagePath = Path.Combine(_directoryPath, "update-output.txt"); + var nextOutputPath = Path.Combine(_directoryPath, "next-output.txt"); + var updateMessagePath = Path.Combine(_directoryPath, "update-output.txt"); _updatePayloadPath = Path.Combine(_directoryPath, "update-payload.zip"); - _updatedVersionOutputPath = Path.Combine(_directoryPath, "updated-version-output.txt"); - _versionOutputPath = Path.Combine(_directoryPath, "version-output.txt"); + var updatedVersionOutputPath = Path.Combine(_directoryPath, "updated-version-output.txt"); + var versionOutputPath = Path.Combine(_directoryPath, "version-output.txt"); Directory.CreateDirectory(_directoryPath); - File.WriteAllText(_nextOutputPath, nextOutput); - File.WriteAllText(_versionOutputPath, versionOutput); + File.WriteAllText(nextOutputPath, nextOutput); + File.WriteAllText(versionOutputPath, versionOutput); if (errorOutput is not null) - File.WriteAllText(_errorOutputPath, errorOutput); + File.WriteAllText(errorOutputPath, errorOutput); if (updateVersionOutput is not null) { - File.WriteAllText(_updateMessagePath, $"Version {updateVersionOutput.Trim()} is now available."); - File.WriteAllText(_updatedVersionOutputPath, updateVersionOutput); + File.WriteAllText(updateMessagePath, $"Version {updateVersionOutput.Trim()} is now available."); + File.WriteAllText(updatedVersionOutputPath, updateVersionOutput); CreateUpdatePayload(); } @@ -633,7 +628,7 @@ private sealed class TestDocument(string id) : IDocument public string Id { get; } = id; } - private sealed class TestItem(string id) : OnePassword.Items.IItem + private sealed class TestItem(string id) : IItem { public string Id { get; } = id; } diff --git a/OnePassword.NET.Tests/SetUpGroup.cs b/OnePassword.NET.Tests/SetUpGroup.cs index c9c778f..4f9c540 100644 --- a/OnePassword.NET.Tests/SetUpGroup.cs +++ b/OnePassword.NET.Tests/SetUpGroup.cs @@ -34,7 +34,7 @@ public void CreateGroup() Assert.Ignore("Group management is not authorized for the current account."); } - Assert.Multiple((Action)delegate + using (Assert.EnterMultipleScope()) { Assert.That(_initialGroup.Id, Is.Not.Empty); Assert.That(_initialGroup.Name, Is.EqualTo(InitialName)); @@ -44,7 +44,7 @@ public void CreateGroup() Assert.That(_initialGroup.Created, Is.Not.EqualTo(default(DateTimeOffset))); Assert.That(_initialGroup.Updated, Is.Not.EqualTo(default(DateTimeOffset))); Assert.That(_initialGroup.Permissions, Has.Count.EqualTo(0)); - }); + } }); } @@ -77,11 +77,11 @@ public void GetGroups() var group = groups.First(x => x.Name == FinalName); - Assert.Multiple((Action)delegate + using (Assert.EnterMultipleScope()) { Assert.That(group.Id, Is.Not.Empty); Assert.That(group.Name, Is.EqualTo(FinalName)); - }); + } TestGroup = group; }); @@ -100,7 +100,7 @@ public void GetGroup() { var group = OnePassword.GetGroup(TestGroup); - Assert.Multiple((Action)delegate + using (Assert.EnterMultipleScope()) { Assert.That(group.Id, Is.Not.Empty); Assert.That(group.Name, Is.EqualTo(FinalName)); @@ -110,7 +110,7 @@ public void GetGroup() Assert.That(group.Created, Is.Not.EqualTo(default(DateTimeOffset))); Assert.That(group.Updated, Is.Not.EqualTo(default(DateTimeOffset))); Assert.That(group.Permissions, Has.Count.EqualTo(0)); - }); + } }); } } diff --git a/OnePassword.NET.Tests/SetUpUser.cs b/OnePassword.NET.Tests/SetUpUser.cs index f250e98..bbc4748 100644 --- a/OnePassword.NET.Tests/SetUpUser.cs +++ b/OnePassword.NET.Tests/SetUpUser.cs @@ -33,7 +33,7 @@ public void ProvisionUser() Assert.Ignore("User management is not authorized for the current account."); } - Assert.Multiple((Action)delegate + using (Assert.EnterMultipleScope()) { Assert.That(_initialUser.Id, Is.Not.Empty); Assert.That(_initialUser.Name, Is.EqualTo(InitialName)); @@ -43,7 +43,7 @@ public void ProvisionUser() Assert.That(_initialUser.Created, Is.Not.EqualTo(default(DateTimeOffset))); Assert.That(_initialUser.Updated, Is.Not.EqualTo(default(DateTimeOffset))); Assert.That(_initialUser.LastAuthentication, Is.Null); - }); + } }); } @@ -105,14 +105,14 @@ public void GetUsers() var user = users.First(x => x.Name == FinalName); - Assert.Multiple((Action)delegate + using (Assert.EnterMultipleScope()) { Assert.That(user.Id, Is.Not.Empty); Assert.That(user.Name, Is.EqualTo(FinalName)); Assert.That(user.Email, Is.EqualTo(TestUserEmail)); Assert.That(user.Type, Is.EqualTo(UserType.Member)); Assert.That(user.State, Is.EqualTo(State.Active)); - }); + } TestUser = user; }); @@ -131,7 +131,7 @@ public void GetUser() { var user = OnePassword.GetUser(TestUser); - Assert.Multiple((Action)delegate + using (Assert.EnterMultipleScope()) { Assert.That(user.Id, Is.Not.Empty); Assert.That(user.Name, Is.EqualTo(FinalName)); @@ -141,7 +141,7 @@ public void GetUser() Assert.That(user.Created, Is.Not.EqualTo(default(DateTimeOffset))); Assert.That(user.Updated, Is.Not.EqualTo(default(DateTimeOffset))); Assert.That(user.LastAuthentication, Is.Null); - }); + } }); } diff --git a/OnePassword.NET.Tests/SetUpVault.cs b/OnePassword.NET.Tests/SetUpVault.cs index 5acc465..e154b3d 100644 --- a/OnePassword.NET.Tests/SetUpVault.cs +++ b/OnePassword.NET.Tests/SetUpVault.cs @@ -26,14 +26,14 @@ public void CreateVault() { _initialVault = OnePassword.CreateVault(InitialName, InitialDescription, InitialIcon, true); - Assert.Multiple((Action)delegate + using (Assert.EnterMultipleScope()) { Assert.That(_initialVault.Id, Is.Not.Empty); Assert.That(_initialVault.Name, Is.EqualTo(InitialName)); Assert.That(_initialVault.Type, Is.EqualTo(VaultType.User)); Assert.That(_initialVault.Items, Is.EqualTo(0)); Assert.That(_initialVault.Created, Is.Not.EqualTo(default(DateTimeOffset))); - }); + } }); } @@ -62,11 +62,11 @@ public void GetVaults() var vault = vaults.First(x => x.Name == FinalName); - Assert.Multiple((Action)delegate + using (Assert.EnterMultipleScope()) { Assert.That(vault.Id, Is.Not.Empty); Assert.That(vault.Name, Is.EqualTo(FinalName)); - }); + } TestVault = vault; }); @@ -83,14 +83,14 @@ public void GetVault() { var vault = OnePassword.GetVault(TestVault); - Assert.Multiple((Action)delegate + using (Assert.EnterMultipleScope()) { Assert.That(vault.Id, Is.Not.Empty); Assert.That(vault.Name, Is.EqualTo(FinalName)); Assert.That(vault.Type, Is.EqualTo(VaultType.User)); Assert.That(vault.Items, Is.EqualTo(0)); Assert.That(vault.Created, Is.Not.EqualTo(default(DateTimeOffset))); - }); + } }); } } diff --git a/OnePassword.NET.Tests/TestDocuments.cs b/OnePassword.NET.Tests/TestDocuments.cs index f47483f..a65238d 100644 --- a/OnePassword.NET.Tests/TestDocuments.cs +++ b/OnePassword.NET.Tests/TestDocuments.cs @@ -30,7 +30,7 @@ public void CreateDocument() _initialDocument = OnePassword.CreateDocument(TestVault, _filePath, InitialFileName, InitialTitle); - Assert.Multiple((Action)delegate { Assert.That(_initialDocument.Id, Is.Not.Empty); }); + Assert.That(_initialDocument.Id, Is.Not.Empty); }); } @@ -62,11 +62,11 @@ public void GetDocuments() var document = documents.First(x => x.Title == FinalTitle); - Assert.Multiple((Action)delegate + using (Assert.EnterMultipleScope()) { Assert.That(document.Id, Is.EqualTo(_initialDocument.Id)); Assert.That(document.Title, Is.EqualTo(FinalTitle)); - }); + } TestDocument = document; }); diff --git a/OnePassword.NET.Tests/TestItems.cs b/OnePassword.NET.Tests/TestItems.cs index 17f08f7..12437ae 100644 --- a/OnePassword.NET.Tests/TestItems.cs +++ b/OnePassword.NET.Tests/TestItems.cs @@ -60,7 +60,7 @@ public void CreateItem() _initialItem = OnePassword.CreateItem(template, TestVault); - Assert.Multiple((Action)delegate + using (Assert.EnterMultipleScope()) { Assert.That(_initialItem.Id, Is.Not.Empty); Assert.That(_initialItem.Title, Is.EqualTo(InitialTitle)); @@ -74,7 +74,7 @@ public void CreateItem() Assert.That(_initialItem.FileAttachments.First(x => x.Name == InitialAttachmentName).Size, Is.GreaterThan(0)); Assert.That(_initialItem.Tags, Does.Contain(Tag1)); Assert.That(_initialItem.Tags, Does.Contain(Tag2)); - }); + } }); } @@ -96,7 +96,7 @@ public void EditItem() var item = OnePassword.EditItem(_initialItem, TestVault); - Assert.Multiple((Action)delegate + using (Assert.EnterMultipleScope()) { Assert.That(item.Id, Is.Not.Empty); Assert.That(item.Title, Is.EqualTo(FinalTitle)); @@ -108,7 +108,7 @@ public void EditItem() Assert.That(item.FileAttachments.Any(x => x.Name == InitialAttachmentName), Is.True); Assert.That(item.Tags, Does.Contain(Tag1)); Assert.That(item.Tags, Does.Not.Contain(Tag2)); - }); + } }); } @@ -146,11 +146,11 @@ public void EditItemAddsFileAttachment() var editedItem = OnePassword.EditItem(item, TestVault); - Assert.Multiple((Action)delegate + using (Assert.EnterMultipleScope()) { Assert.That(editedItem.FileAttachments.Any(x => x.Name == InitialAttachmentName), Is.True); Assert.That(editedItem.FileAttachments.Any(x => x.Name == AddedAttachmentName), Is.True); - }); + } _initialItem = editedItem; }); @@ -170,11 +170,11 @@ public void EditItemRemovesFileAttachment() var editedItem = OnePassword.EditItem(item, TestVault); - Assert.Multiple((Action)delegate + using (Assert.EnterMultipleScope()) { Assert.That(editedItem.FileAttachments.Any(x => x.Name == InitialAttachmentName), Is.False); Assert.That(editedItem.FileAttachments.Any(x => x.Name == AddedAttachmentName), Is.True); - }); + } _initialItem = editedItem; }); @@ -195,11 +195,11 @@ public void GetItems() var item = items.First(x => x.Title == FinalTitle); - Assert.Multiple((Action)delegate + using (Assert.EnterMultipleScope()) { Assert.That(item.Id, Is.Not.Empty); Assert.That(item.Title, Is.EqualTo(FinalTitle)); - }); + } TestItem = item; }); @@ -216,7 +216,7 @@ public void GetItem() { var item = OnePassword.GetItem(TestItem, TestVault); - Assert.Multiple((Action)delegate + using (Assert.EnterMultipleScope()) { Assert.That(item.Id, Is.Not.Empty); Assert.That(item.Title, Is.EqualTo(FinalTitle)); @@ -227,7 +227,7 @@ public void GetItem() Assert.That(item.Fields.FirstOrDefault(x => x.Section?.Label == DeleteSection && x.Label == DeleteField), Is.Null); Assert.That(item.FileAttachments.Any(x => x.Name == InitialAttachmentName), Is.False); Assert.That(item.FileAttachments.Any(x => x.Name == AddedAttachmentName), Is.True); - }); + } var attachment = item.FileAttachments.First(x => x.Name == AddedAttachmentName); OnePassword.SaveFileAttachmentContent(attachment, item, TestVault, _addedAttachmentOutputFilePath); diff --git a/OnePassword.NET.Tests/TestTemplates.cs b/OnePassword.NET.Tests/TestTemplates.cs index b8af411..45b0dd0 100644 --- a/OnePassword.NET.Tests/TestTemplates.cs +++ b/OnePassword.NET.Tests/TestTemplates.cs @@ -38,11 +38,11 @@ public void GetTemplatesName() { TestTemplate = OnePassword.GetTemplate(_template); - Assert.Multiple((Action)delegate + using (Assert.EnterMultipleScope()) { Assert.That(TestTemplate.Name, Is.EqualTo(_template.Name)); Assert.That(TestTemplate.Category, Is.Not.EqualTo(Category.Unknown)); - }); + } }); } @@ -64,11 +64,11 @@ public void GetTemplatesByEnum() var template = OnePassword.GetTemplate(enumValue); - Assert.Multiple((Action)delegate + using (Assert.EnterMultipleScope()) { Assert.That(template.Name, Is.EqualTo(enumMemberString)); Assert.That(template.Category, Is.Not.EqualTo(Category.Unknown)); - }); + } Thread.Sleep(RateLimit); } diff --git a/OnePassword.NET/Common/CommonExtensions.cs b/OnePassword.NET/Common/CommonExtensions.cs index b8d9c3e..aee63ff 100644 --- a/OnePassword.NET/Common/CommonExtensions.cs +++ b/OnePassword.NET/Common/CommonExtensions.cs @@ -58,7 +58,7 @@ internal static string ToHumanReadable(this TimeSpan timeSpan) if (timeSpan.Days > 7) { result += $"{timeSpan.Days / 7}w"; - timeSpan = TimeSpan.FromDays(timeSpan.Days % 7); + timeSpan -= TimeSpan.FromDays(timeSpan.Days / 7 * 7); } if (timeSpan.Days > 0) @@ -75,4 +75,4 @@ internal static string ToHumanReadable(this TimeSpan timeSpan) return result.Length == 0 ? "0s" : result; } -} \ No newline at end of file +} diff --git a/OnePassword.NET/Common/JsonStringEnumConverterEx.cs b/OnePassword.NET/Common/JsonStringEnumConverterEx.cs index 94cfa8f..ce9dc4c 100644 --- a/OnePassword.NET/Common/JsonStringEnumConverterEx.cs +++ b/OnePassword.NET/Common/JsonStringEnumConverterEx.cs @@ -17,7 +17,7 @@ internal sealed class JsonStringEnumConverterEx : JsonConverter wh [SuppressMessage("AssemblyLoadTrimming", "IL2090:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to 'target method'.", Justification = "https://github.com/dotnet/runtime/issues/97737")] public JsonStringEnumConverterEx() { - foreach (TEnum enumMemberValue in Enum.GetValues(typeof(TEnum))) + foreach (var enumMemberValue in Enum.GetValues()) { var enumMemberName = enumMemberValue.ToString(); diff --git a/OnePassword.NET/Common/State.cs b/OnePassword.NET/Common/State.cs index 1353de0..786d7e7 100644 --- a/OnePassword.NET/Common/State.cs +++ b/OnePassword.NET/Common/State.cs @@ -57,5 +57,11 @@ public enum State /// /// The state is unknown. /// - Unknown + Unknown, + + /// + /// Account recovery has started. + /// + [EnumMember(Value = "Recovery Started")] + RecoveryStarted } diff --git a/OnePassword.NET/IOnePasswordManager.Items.cs b/OnePassword.NET/IOnePasswordManager.Items.cs index 35d2081..49d9c5a 100644 --- a/OnePassword.NET/IOnePasswordManager.Items.cs +++ b/OnePassword.NET/IOnePasswordManager.Items.cs @@ -94,6 +94,7 @@ public ImmutableList SearchForItems(string? vaultId = null, bool? includeA public Item CreateItem(Template template, string vaultId); /// Edits an item. + /// Uses a JSON item template. Do not use this method on items containing passkeys: the CLI overwrites passkeys during JSON template edits. The CLI does not support editing SSH Key items. /// The item to edit. /// The vault that contains the item to edit. /// The edited item. @@ -101,6 +102,7 @@ public ImmutableList SearchForItems(string? vaultId = null, bool? includeA public Item EditItem(Item item, IVault vault); /// Edits an item. + /// Uses a JSON item template. Do not use this method on items containing passkeys: the CLI overwrites passkeys during JSON template edits. The CLI does not support editing SSH Key items. /// The item to edit. /// The ID of the vault that contains the item to edit. /// The edited item. @@ -166,6 +168,7 @@ public ImmutableList SearchForItems(string? vaultId = null, bool? includeA public void DeleteItem(string itemId, string vaultId); /// Moves an item. + /// The CLI creates a new item ID in the destination vault. The supplied item still contains its original ID. /// The item to move. /// The vault that contains the item to move. /// The destination vault to move the item to. @@ -173,6 +176,7 @@ public ImmutableList SearchForItems(string? vaultId = null, bool? includeA public void MoveItem(IItem item, IVault currentVault, IVault destinationVault); /// Moves an item. + /// The CLI creates a new item ID in the destination vault. The original item ID no longer identifies the destination item. /// The ID of the item to move. /// The ID of the vault that contains the item to move. /// The ID of the destination vault to move the item to. diff --git a/OnePassword.NET/Items/Category.cs b/OnePassword.NET/Items/Category.cs index ce418a7..fe56a72 100644 --- a/OnePassword.NET/Items/Category.cs +++ b/OnePassword.NET/Items/Category.cs @@ -143,5 +143,11 @@ public enum Category /// Wireless Router /// [EnumMember(Value = "Wireless Router")] - WirelessRouter -} \ No newline at end of file + WirelessRouter, + + /// + /// Crypto Wallet + /// + [EnumMember(Value = "Crypto Wallet")] + CryptoWallet +} diff --git a/OnePassword.NET/OnePassword.NET.csproj b/OnePassword.NET/OnePassword.NET.csproj index 60594c3..bde50e5 100644 --- a/OnePassword.NET/OnePassword.NET.csproj +++ b/OnePassword.NET/OnePassword.NET.csproj @@ -1,7 +1,7 @@ - netstandard2.1 + net11.0 OnePassword OnePassword.NET OnePassword.NET @@ -9,15 +9,14 @@ Jean-Sebastien Carle OnePassword.NET 1Password CLI Wrapper - 2.5.0 - 2.5.0.0 - 2.5.0.0 - Copyright © Jean-Sebastien Carle 2021-2025 + 2.6.0 + 2.6.0.0 + 2.6.0.0 + Copyright © Jean-Sebastien Carle 2021-2026 https://github.com/jscarle/OnePassword.NET git 1Password OnePassword LICENSE.md - latest enable enable README.md @@ -36,12 +35,10 @@ - - - + True \ diff --git a/OnePassword.NET/OnePasswordManager.Accounts.cs b/OnePassword.NET/OnePasswordManager.Accounts.cs index 0c25466..1c155e2 100644 --- a/OnePassword.NET/OnePasswordManager.Accounts.cs +++ b/OnePassword.NET/OnePasswordManager.Accounts.cs @@ -32,8 +32,8 @@ public AccountDetails GetAccount(string? account = "") var trimmedAccount = account?.Trim() ?? ""; - var command = trimmedAccount.Length > 0 ? $"account get --account \"{trimmedAccount}\"" : "account get"; - return Op(JsonContext.Default.AccountDetails, command); + const string command = "account get"; + return Op(JsonContext.Default.AccountDetails, command, account: trimmedAccount.Length > 0 ? trimmedAccount : null); } /// @@ -62,26 +62,21 @@ public void AddAccount(string address, string email, string secretKey, string pa if (password is null || password.Length == 0) throw new ArgumentException($"{nameof(password)} cannot be empty.", nameof(password)); - var trimmedPassword = password.Trim(); - if (trimmedPassword.Length == 0) - throw new ArgumentException($"{nameof(password)} cannot be empty.", nameof(password)); var trimmedShorthand = shorthand?.Trim() ?? ""; - var command = $"account add --address \"{trimmedAddress}\" --email \"{trimmedEmail}\" --secret-key \"{trimmedSecretKey}\""; + var command = $"account add --address {QuoteArgument(trimmedAddress)} --email {QuoteArgument(trimmedEmail)}"; if (trimmedShorthand.Length > 0) - command += $" --shorthand \"{trimmedShorthand}\""; + command += $" --shorthand {QuoteArgument(trimmedShorthand)}"; - var result = Op(command, trimmedPassword, true); + var result = Op(command, password, allowDeviceRegistration: true, secretKey: trimmedSecretKey); if (result.Contains("No saved device ID.", StringComparison.Ordinal)) { var deviceUuid = DeviceRegex.Match(result).Groups["UUID"].Value; + if (deviceUuid.Length == 0) + throw new InvalidOperationException(result.Trim()); Environment.SetEnvironmentVariable("OP_DEVICE", deviceUuid); - Op(command, password); - } - else if (result.StartsWith("[ERROR]", StringComparison.InvariantCulture)) - { - throw new InvalidOperationException(result.Length > 28 ? result[28..].Trim() : result.Trim()); + Op(command, password, secretKey: trimmedSecretKey); } _account = trimmedShorthand.Length > 0 ? trimmedShorthand : trimmedAddress; @@ -106,17 +101,16 @@ public void SignIn(string? password = null) if (_mode == Mode.ServiceAccount) throw new InvalidOperationException($"{nameof(SignIn)} is not supported when using service accounts."); - var trimmedPassword = password?.Trim(); switch (_mode == Mode.AppIntegrated) { - case true when trimmedPassword is not null: + case true when password is not null: throw new ArgumentException($"{nameof(password)} cannot be supplied when authentication is integrated into the 1Password desktop application.", nameof(password)); - case false when trimmedPassword is null || trimmedPassword.Length == 0: + case false when password is null || password.Length == 0: throw new ArgumentException($"{nameof(password)} cannot be empty.", nameof(password)); } const string command = "signin --force --raw"; - var result = Op(command, password?.Trim()); + var result = Op(command, password); _session = result.Trim(); } @@ -129,8 +123,6 @@ public void SignOut(bool all = false) var command = "signout"; if (all) command += " --all"; - else if (_account.Length > 0) - command += $" --account \"{_account}\""; Op(command); _session = ""; } @@ -147,7 +139,7 @@ public ImmutableList ForgetAccount(bool all = false) SignOut(all); var command = "account forget"; - command += all ? " --all" : $" \"{_account}\""; + command += all ? " --all" : $" {QuoteArgument(_account)}"; var result = Op(command); diff --git a/OnePassword.NET/OnePasswordManager.Documents.cs b/OnePassword.NET/OnePasswordManager.Documents.cs index dc2e122..0241b4c 100644 --- a/OnePassword.NET/OnePasswordManager.Documents.cs +++ b/OnePassword.NET/OnePasswordManager.Documents.cs @@ -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}"; - if (trimmedFileMode is not null) - command += $" --file-mode {trimmedFileMode}"; + var command = $"document get {documentId} --out-file {QuoteArgument(trimmedFilePath)} --force --vault {vaultId}"; + if (!string.IsNullOrEmpty(trimmedFileMode)) + command += $" --file-mode {QuoteArgument(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 = $"document get {documentId} --out-file {QuoteArgument(trimmedFilePath)} --force"; if (vaultId is not null) command += $" --vault {vaultId}"; if (includeArchive is not null && includeArchive.Value) command += " --include-archive"; - if (trimmedFileMode is not null) - command += $" --file-mode {trimmedFileMode}"; + if (!string.IsNullOrEmpty(trimmedFileMode)) + command += $" --file-mode {QuoteArgument(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 = $"document create {QuoteArgument(trimmedFilePath)} --vault {vaultId}"; if (trimmedFileName is not null) - command += $" --file-name \"{trimmedFileName}\""; + command += $" --file-name {QuoteArgument(trimmedFileName)}"; if (trimmedTitle is not null) - command += $" --title \"{trimmedTitle}\""; + command += $" --title {QuoteArgument(trimmedTitle)}"; if (tags is not null && tags.Count > 0) - command += $" --tags \"{tags.ToCommaSeparated()}\""; + command += $" --tags {QuoteArgument(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 = $"document edit {documentId} {QuoteArgument(trimmedFilePath)} --vault {vaultId}"; if (trimmedFileName is not null) - command += $" --file-name \"{trimmedFileName}\""; + command += $" --file-name {QuoteArgument(trimmedFileName)}"; if (trimmedTitle is not null) - command += $" --title \"{trimmedTitle}\""; - if (tags is not null && tags.Count > 0) - command += $" --tags \"{tags.ToCommaSeparated()}\""; + command += $" --title {QuoteArgument(trimmedTitle)}"; + if (tags is not null) + command += $" --tags {QuoteArgument(tags.ToCommaSeparated())}"; Op(command); } diff --git a/OnePassword.NET/OnePasswordManager.Groups.cs b/OnePassword.NET/OnePasswordManager.Groups.cs index ca43c66..c32b20c 100644 --- a/OnePassword.NET/OnePasswordManager.Groups.cs +++ b/OnePassword.NET/OnePasswordManager.Groups.cs @@ -83,9 +83,9 @@ public GroupDetails CreateGroup(string name, string? description = null) var trimmedDescription = description?.Trim(); - var command = $"group create \"{trimmedName}\""; + var command = $"group create {QuoteArgument(trimmedName)}"; if (trimmedDescription is not null) - command += $" --description \"{trimmedDescription}\""; + command += $" --description {QuoteArgument(trimmedDescription)}"; return Op(JsonContext.Default.GroupDetails, command); } @@ -122,9 +122,9 @@ public void EditGroup(string groupId, string? name = null, string? description = var command = $"group edit {groupId}"; if (trimmedName is not null) - command += $" --name \"{trimmedName}\""; + command += $" --name {QuoteArgument(trimmedName)}"; if (trimmedDescription is not null) - command += $" --description \"{trimmedDescription}\""; + command += $" --description {QuoteArgument(trimmedDescription)}"; 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 = $"group user grant --group {groupId} --user {userId} --role {QuoteArgument(userRole.ToEnumString())}"; Op(command); } diff --git a/OnePassword.NET/OnePasswordManager.Items.cs b/OnePassword.NET/OnePasswordManager.Items.cs index e244074..ebb2c1e 100644 --- a/OnePassword.NET/OnePasswordManager.Items.cs +++ b/OnePassword.NET/OnePasswordManager.Items.cs @@ -52,9 +52,9 @@ public ImmutableList SearchForItems(string? vaultId = null, bool? includeA if (favorite is not null && favorite.Value) command += " --favorite"; if (categories is not null && categories.Count > 0) - command += $" --categories \"{categories.ToCommaSeparated(true)}\""; + command += $" --categories {QuoteArgument(categories.ToCommaSeparated(true))}"; if (tags is not null && tags.Count > 0) - command += $" --tags \"{tags.ToCommaSeparated()}\""; + command += $" --tags {QuoteArgument(tags.ToCommaSeparated())}"; return Op(JsonContext.Default.ImmutableListItem, command); } @@ -153,6 +153,8 @@ public Item EditItem(Item item, string vaultId) throw new ArgumentException($"{nameof(item.Id)} cannot be empty.", nameof(item)); if (vaultId is null || vaultId.Length == 0) throw new ArgumentException($"{nameof(vaultId)} cannot be empty.", nameof(vaultId)); + if (item.Category == Category.SshKey) + throw new InvalidOperationException("The 1Password CLI does not support editing SSH Key items."); var command = $"item edit {item.Id} --vault {vaultId}"; foreach (var assignmentStatement in GetFileAttachmentAssignmentStatements(item.Fields, item.FileAttachments) @@ -161,13 +163,14 @@ public Item EditItem(Item item, string vaultId) var json = SerializeItemForEditCommand(item); if (item.TitleChanged) - command += $" --title \"{item.Title}\""; + command += $" --title {QuoteArgument(item.Title)}"; if (((ITracked)item.Tags).Changed) - command += $" --tags \"{item.Tags.ToCommaSeparated()}\""; + command += $" --tags {QuoteArgument(item.Tags.ToCommaSeparated())}"; if (((ITracked)item.Urls).Changed) { var changedUrl = item.Urls.FirstOrDefault(url => url.Primary && ((ITracked)url).Changed); - command += $" --url \"{changedUrl}\""; + if (changedUrl is not null) + command += $" --url {QuoteArgument(changedUrl.Href)}"; } var editedItem = Op(JsonContext.Default.Item, command, json); ((ITracked)item).AcceptChanges(); @@ -436,7 +439,7 @@ private static string SerializeTemplateForItemCommand(Template template) var commandTemplate = template.Clone(); RemoveFileAttachmentFields(commandTemplate.Fields); commandTemplate.FileAttachments = []; - return JsonSerializer.Serialize(commandTemplate, JsonContext.Default.Template) + "\x04"; + return JsonSerializer.Serialize(commandTemplate, JsonContext.Default.Template); } private static string SerializeItemForEditCommand(Item item) @@ -454,8 +457,7 @@ private static string SerializeItemForEditCommand(Item item) #if NET7_0_OR_GREATER , JsonContext.Default.Item #endif - ) - + "\x04"; + ); } private static void RemoveFileAttachmentFields(TrackedList fields) @@ -528,12 +530,15 @@ private static string BuildFileAttachmentTarget(string? name, Section? section, return ""; } - return trimmedSection.Length == 0 ? trimmedName : $"{trimmedSection}.{trimmedName}"; - } + // Assignment targets escape these characters separately from process argument quoting. + var escapedName = trimmedName.Replace("\\", "\\\\", StringComparison.Ordinal) + .Replace(".", "\\.", StringComparison.Ordinal) + .Replace("=", "\\=", StringComparison.Ordinal); + var escapedSection = trimmedSection.Replace("\\", "\\\\", StringComparison.Ordinal) + .Replace(".", "\\.", StringComparison.Ordinal) + .Replace("=", "\\=", StringComparison.Ordinal); - private static string QuoteArgument(string argument) - { - return $"\"{argument.Replace("\"", "\\\"", StringComparison.InvariantCulture)}\""; + return escapedSection.Length == 0 ? escapedName : $"{escapedSection}.{escapedName}"; } private static ItemShare ParseItemShare(string result) diff --git a/OnePassword.NET/OnePasswordManager.Templates.cs b/OnePassword.NET/OnePasswordManager.Templates.cs index 078c185..6f43e3c 100644 --- a/OnePassword.NET/OnePasswordManager.Templates.cs +++ b/OnePassword.NET/OnePasswordManager.Templates.cs @@ -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 = $"item template get {QuoteArgument(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 = $"item template get {QuoteArgument(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 = $"item template get {QuoteArgument(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..fdcb0e5 100644 --- a/OnePassword.NET/OnePasswordManager.Users.cs +++ b/OnePassword.NET/OnePasswordManager.Users.cs @@ -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 = $"user provision --name {QuoteArgument(trimmedName)} --email {QuoteArgument(trimmedEmailAddress)}"; if (language != Language.Default) - command += $" --language \"{language.ToEnumString()}\""; + command += $" --language {QuoteArgument(language.ToEnumString())}"; return Op(JsonContext.Default.UserDetails, command); } @@ -150,7 +150,7 @@ public void EditUser(string userId, string? name = null, bool? travelMode = null var command = $"user edit {userId}"; if (trimmedName is not null) - command += $" --name \"{trimmedName}\""; + command += $" --name {QuoteArgument(trimmedName)}"; if (travelMode.HasValue) command += $" --travel-mode {(travelMode.Value ? "on" : "off")}"; Op(command); diff --git a/OnePassword.NET/OnePasswordManager.Vaults.cs b/OnePassword.NET/OnePasswordManager.Vaults.cs index ed24fb8..0478c41 100644 --- a/OnePassword.NET/OnePasswordManager.Vaults.cs +++ b/OnePassword.NET/OnePasswordManager.Vaults.cs @@ -83,11 +83,11 @@ public VaultDetails CreateVault(string name, string? description = null, VaultIc var trimmedDescription = description?.Trim(); - var command = $"vault create \"{trimmedName}\""; + var command = $"vault create {QuoteArgument(trimmedName)}"; if (trimmedDescription is not null) - command += $" --description \"{trimmedDescription}\""; + command += $" --description {QuoteArgument(trimmedDescription)}"; if (icon != VaultIcon.Default && icon != VaultIcon.Unknown) - command += $" --icon \"{icon.ToEnumString()}\""; + command += $" --icon {QuoteArgument(icon.ToEnumString())}"; if (allowAdminsToManage.HasValue) command += $" --allow-admins-to-manage {(allowAdminsToManage.Value ? "true" : "false")}"; return Op(JsonContext.Default.VaultDetails, command); @@ -126,11 +126,11 @@ public void EditVault(string vaultId, string? name = null, string? description = var command = $"vault edit {vaultId}"; if (trimmedName is not null) - command += $" --name \"{trimmedName}\""; + command += $" --name {QuoteArgument(trimmedName)}"; if (trimmedDescription is not null) - command += $" --description \"{trimmedDescription}\""; + command += $" --description {QuoteArgument(trimmedDescription)}"; if (icon != VaultIcon.Default && icon != VaultIcon.Unknown) - command += $" --icon \"{icon.ToEnumString()}\""; + command += $" --icon {QuoteArgument(icon.ToEnumString())}"; if (travelMode.HasValue) command += $" --travel-mode {(travelMode.Value ? "on" : "off")}"; Op(command); @@ -191,7 +191,8 @@ 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 permissionNames = permissions.ToCommaSeparated(); + var command = $"vault group grant --vault {vaultId} --group {groupId} --permissions {QuoteArgument(permissionNames)} --no-input"; Op(command); } @@ -205,7 +206,8 @@ 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 permissionNames = permissions.ToCommaSeparated(); + var command = $"vault user grant --vault {vaultId} --user {userId} --permissions {QuoteArgument(permissionNames)} --no-input"; Op(command); } @@ -245,7 +247,8 @@ 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 permissionNames = permissions.ToCommaSeparated(); + var command = $"vault group revoke --vault {vaultId} --group {groupId} --permissions {QuoteArgument(permissionNames)} --no-input"; Op(command); } @@ -259,7 +262,8 @@ 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 permissionNames = permissions.ToCommaSeparated(); + var command = $"vault user revoke --vault {vaultId} --user {userId} --permissions {QuoteArgument(permissionNames)} --no-input"; Op(command); } } diff --git a/OnePassword.NET/OnePasswordManager.cs b/OnePassword.NET/OnePasswordManager.cs index 0408290..9a9cd7c 100644 --- a/OnePassword.NET/OnePasswordManager.cs +++ b/OnePassword.NET/OnePasswordManager.cs @@ -25,7 +25,11 @@ public sealed partial class OnePasswordManager : IOnePasswordManager private readonly Mode _mode = Mode.Interactive; private readonly string _opPath; private readonly string _serviceAccountToken; - private readonly string[] _serviceAccountUnsupportedCommands = ["events-api", "group", "user"]; + private readonly string[] _serviceAccountUnsupportedCommands = + [ + "connect", "events-api", "group create", "group delete", "group edit", "group user", + "user provision", "user confirm", "user suspend", "user delete", "user recovery", "user reactivate", "user edit", "vault edit" + ]; private readonly bool _verbose; private string _account = ""; private string _session = ""; @@ -70,29 +74,34 @@ public bool Update() var tempDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); Directory.CreateDirectory(tempDirectory); - var command = $"update --directory \"{tempDirectory}\""; - var result = Op(command); - - var match = VersionRegex.Match(result); - if (match.Success) + try { - foreach (var file in Directory.GetFiles(tempDirectory, "*.zip")) - { - using var zipArchive = ZipFile.Open(file, ZipArchiveMode.Read); + var command = $"update --directory {QuoteArgument(tempDirectory)} --channel stable"; + var result = Op(command); - var entry = zipArchive.Entries.FirstOrDefault(zipEntry => string.Equals(Path.GetFileName(zipEntry.FullName), packagedExecutableName, StringComparison.OrdinalIgnoreCase)); - if (entry is null) - continue; - entry.ExtractToFile(_opPath, true); - EnsureExecutablePermissions(_opPath); - - Version = GetVersion(); - updated = true; - break; + var match = VersionRegex.Match(result); + if (match.Success) + { + foreach (var file in Directory.GetFiles(tempDirectory, "*.zip")) + { + using var zipArchive = ZipFile.Open(file, ZipArchiveMode.Read); + + var entry = zipArchive.Entries.FirstOrDefault(zipEntry => string.Equals(Path.GetFileName(zipEntry.FullName), packagedExecutableName, StringComparison.OrdinalIgnoreCase)); + if (entry is null) + continue; + entry.ExtractToFile(_opPath, true); + EnsureExecutablePermissions(_opPath); + + Version = GetVersion(); + updated = true; + break; + } } } - - Directory.Delete(tempDirectory, true); + finally + { + Directory.Delete(tempDirectory, true); + } return updated; } @@ -106,7 +115,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 = $"read {QuoteArgument(trimmedReference)} --no-newline"; return Op(command); } @@ -125,9 +134,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 = $"read {QuoteArgument(trimmedReference)} --no-newline --force --out-file {QuoteArgument(trimmedFilePath)}"; if (trimmedFileMode is not null && trimmedFileMode.Length > 0) - command += $" --file-mode {trimmedFileMode}"; + command += $" --file-mode {QuoteArgument(trimmedFileMode)}"; Op(command); } @@ -169,34 +178,44 @@ private static void EnsureExecutablePermissions(string executablePath) chmod?.WaitForExit(); } - private static string GetStandardError(Process process) + private static string QuoteArgument(string argument) { - var error = new StringBuilder(); - while (process.StandardError.Peek() > -1) - error.Append((char)process.StandardError.Read()); - return error.ToString(); - } + var quotedArgument = new StringBuilder(argument.Length + 2); + quotedArgument.Append('"'); + var backslashCount = 0; - private static string GetStandardOutput(Process process) - { - var output = new StringBuilder(); - while (process.StandardOutput.Peek() > -1) - output.Append((char)process.StandardOutput.Read()); - return output.ToString(); + foreach (var character in argument) + { + if (character == '\\') + { + backslashCount++; + continue; + } + + // Backslashes before a quote, including the closing quote, must be doubled. + quotedArgument.Append('\\', character == '"' ? backslashCount * 2 + 1 : backslashCount); + quotedArgument.Append(character); + backslashCount = 0; + } + + quotedArgument.Append('\\', backslashCount * 2); + quotedArgument.Append('"'); + return quotedArgument.ToString(); } - private TResult Op(JsonTypeInfo jsonTypeInfo, string command, string? input = null, bool returnError = false) where TResult : class + private TResult Op(JsonTypeInfo jsonTypeInfo, string command, string? input = null, string? account = null) where TResult : class { - var result = Op(command, input is null ? Array.Empty() : [input], returnError); + var result = Op(command, input is null ? Array.Empty() : [input], account: account); var obj = JsonSerializer.Deserialize(result, jsonTypeInfo) ?? throw new SerializationException("Could not deserialize the command result."); if (obj is ITracked item) item.AcceptChanges(); 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(string command, string? input = null, bool allowDeviceRegistration = false, string? secretKey = null) => + Op(command, input is null ? Array.Empty() : [input], allowDeviceRegistration, secretKey: secretKey); - private string Op(string command, IEnumerable input, bool returnError) + private string Op(string command, IEnumerable input, bool allowDeviceRegistration = false, string? account = null, string? secretKey = null) { var arguments = command; if (command != "--version") @@ -213,7 +232,8 @@ private string Op(string command, IEnumerable input, bool returnError) default: var excluded = IsExcludedCommand(command, _excludedAccountCommands); var requireAccount = _mode != Mode.AppIntegrated && !excluded; - var passAccount = _account.Length != 0 && !excluded; + var selectedAccount = account ?? _account; + var passAccount = selectedAccount.Length != 0 && !excluded; if (requireAccount && !passAccount) throw new InvalidOperationException("Cannot execute command because account has not been set."); @@ -222,14 +242,14 @@ private string Op(string command, IEnumerable input, bool returnError) throw new InvalidOperationException("Cannot execute command because account has not been signed in."); if (passAccount) - arguments += $" --account {_account}"; + arguments += $" --account {QuoteArgument(selectedAccount)}"; if (passSession) arguments += $" --session {_session}"; break; } if (_verbose) - Console.WriteLine($"{Path.GetDirectoryName(_opPath)}>op {arguments}"); + Console.WriteLine($"{Path.GetDirectoryName(_opPath)}>op {command}"); var startInfo = new ProcessStartInfo(_opPath, arguments) { @@ -238,55 +258,60 @@ private string Op(string command, IEnumerable input, bool returnError) RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true, + StandardInputEncoding = new UTF8Encoding(false), StandardOutputEncoding = Encoding.UTF8, StandardErrorEncoding = Encoding.UTF8 }; + startInfo.Environment["OP_BIOMETRIC_UNLOCK_ENABLED"] = _mode == Mode.AppIntegrated ? "true" : "false"; if (_mode == Mode.ServiceAccount) - startInfo.EnvironmentVariables["OP_SERVICE_ACCOUNT_TOKEN"] = _serviceAccountToken; + { + // Connect variables take precedence over an explicitly selected service account. + startInfo.Environment.Remove("OP_CONNECT_HOST"); + startInfo.Environment.Remove("OP_CONNECT_TOKEN"); + startInfo.Environment["OP_SERVICE_ACCOUNT_TOKEN"] = _serviceAccountToken; + } + else + { + startInfo.Environment.Remove("OP_SERVICE_ACCOUNT_TOKEN"); + } + + if (secretKey is not null) + startInfo.Environment["OP_SECRET_KEY"] = secretKey; - var process = Process.Start(startInfo) ?? throw new InvalidOperationException($"Could not start process for {_opPath}."); + using var process = Process.Start(startInfo) ?? throw new InvalidOperationException($"Could not start process for {_opPath}."); + // Drain both pipes before writing input so either output stream can grow without blocking the CLI. + var outputTask = process.StandardOutput.ReadToEndAsync(); + var errorTask = process.StandardError.ReadToEndAsync(); foreach (var inputLine in input) { - var lastChar = inputLine.Substring(inputLine.Length - 1, 1); - if (lastChar == "\x04") - { - process.StandardInput.WriteLine(inputLine[..^1]); - process.StandardInput.Flush(); - } - else - { - process.StandardInput.WriteLine(inputLine); - process.StandardInput.Flush(); - } + process.StandardInput.WriteLine(inputLine); + process.StandardInput.Flush(); } process.StandardInput.Close(); - var output = GetStandardOutput(process); - if (_verbose) - Console.WriteLine(output); - - var error = GetStandardError(process); - if (_verbose) - Console.WriteLine(error); + process.WaitForExit(); + var output = outputTask.GetAwaiter().GetResult(); + var error = errorTask.GetAwaiter().GetResult(); - if (!error.StartsWith("[ERROR]", StringComparison.InvariantCulture)) + if (process.ExitCode == 0 && !error.StartsWith("[ERROR]", StringComparison.Ordinal)) return output; - if (returnError) + if (allowDeviceRegistration && error.Contains("No saved device ID.", StringComparison.Ordinal)) return error; - throw new InvalidOperationException(error.Length > 28 ? error[28..].Trim() : error); + var errorMessage = string.IsNullOrWhiteSpace(error) ? $"1Password CLI exited with code {process.ExitCode}." : error.Trim(); + throw new InvalidOperationException(errorMessage); } private static bool IsExcludedCommand(string command, IEnumerable excludedCommands) { - return excludedCommands.Any(x => command.StartsWith(x, StringComparison.InvariantCulture)); + return excludedCommands.Any(x => string.Equals(command, x, StringComparison.Ordinal) || command.StartsWith(x + " ", StringComparison.Ordinal)); } private static bool IsUnsupportedCommand(string command, IEnumerable unsupportedCommands) { - return unsupportedCommands.Any(x => command.StartsWith(x, StringComparison.InvariantCulture)); + return unsupportedCommands.Any(x => string.Equals(command, x, StringComparison.Ordinal) || command.StartsWith(x + " ", StringComparison.Ordinal)); } #if NET7_0_OR_GREATER diff --git a/OnePassword.NET/Users/UserType.cs b/OnePassword.NET/Users/UserType.cs index fc27103..e36cf98 100644 --- a/OnePassword.NET/Users/UserType.cs +++ b/OnePassword.NET/Users/UserType.cs @@ -23,5 +23,11 @@ public enum UserType /// /// The user type is unknown. /// - Unknown -} \ No newline at end of file + Unknown, + + /// + /// Service account + /// + [EnumMember(Value = "Service Account")] + ServiceAccount +} diff --git a/OnePassword.NET/Vaults/VaultPermission.cs b/OnePassword.NET/Vaults/VaultPermission.cs index ce9c249..7feb4e5 100644 --- a/OnePassword.NET/Vaults/VaultPermission.cs +++ b/OnePassword.NET/Vaults/VaultPermission.cs @@ -13,95 +13,95 @@ public enum VaultPermission /// /// Allow Editing /// - [EnumMember(Value = "Allow Editing")] + [EnumMember(Value = "allow_editing")] AllowEditing, /// /// Allow Managing /// - [EnumMember(Value = "Allow Managing")] + [EnumMember(Value = "allow_managing")] AllowManaging, /// /// Allow Viewing /// - [EnumMember(Value = "Allow Viewing")] + [EnumMember(Value = "allow_viewing")] AllowViewing, /// /// Archive Items /// - [EnumMember(Value = "Archive Items")] + [EnumMember(Value = "archive_items")] ArchiveItems, /// /// Copy and Share Items /// - [EnumMember(Value = "Copy And Share Items")] + [EnumMember(Value = "copy_and_share_items")] CopyAndShareItems, /// /// Create Items /// - [EnumMember(Value = "Create Items")] + [EnumMember(Value = "create_items")] CreateItems, /// /// Delete Items /// - [EnumMember(Value = "Delete Items")] + [EnumMember(Value = "delete_items")] DeleteItems, /// /// Edit Items /// - [EnumMember(Value = "Edit Items")] + [EnumMember(Value = "edit_items")] EditItems, /// /// Export Items /// - [EnumMember(Value = "Export Items")] + [EnumMember(Value = "export_items")] ExportItems, /// /// Import Items /// - [EnumMember(Value = "Import Items")] + [EnumMember(Value = "import_items")] ImportItems, /// /// Manage Vault /// - [EnumMember(Value = "Manage Vault")] + [EnumMember(Value = "manage_vault")] ManageVault, /// /// Print Items /// - [EnumMember(Value = "Print Items")] + [EnumMember(Value = "print_items")] PrintItems, /// /// View and Copy Passwords /// - [EnumMember(Value = "View And Copy Passwords")] + [EnumMember(Value = "view_and_copy_passwords")] ViewAndCopyPasswords, /// /// View Item History /// - [EnumMember(Value = "View Item History")] + [EnumMember(Value = "view_item_history")] ViewItemHistory, /// /// View Items /// - [EnumMember(Value = "View Items")] + [EnumMember(Value = "view_items")] ViewItems, /// /// The vault permission is unknown. /// Unknown -} \ No newline at end of file +} diff --git a/README.md b/README.md index 3a0c6d6..82c122c 100644 --- a/README.md +++ b/README.md @@ -11,12 +11,18 @@ This library serves as a .NET wrapper for the [1Password](https://1password.com/ ## References -This library targets .NET Standard 2.1. +This library targets .NET 11. + +The referenced 1Password CLI version is **2.42.0**. Compatibility was reviewed against the official command reference and full CLI 2 changelog on November 10, 2026; this version has not been validated against a live account. See the [CLI compatibility review](https://github.com/jscarle/OnePassword.NET/blob/develop/docfx/docs/cli-compatibility.md). ## Dependencies This library has no dependencies. +## Breaking changes for 2.6.0 + +- Requires .NET 11. The previous .NET Standard 2.1 target is no longer included. + ## Breaking changes for 2.5.0 - `ShareItem(...)` now returns `ItemShare` instead of `void`. @@ -117,6 +123,8 @@ var item = onePassword.GetItem(itemSummary, vault); ### Editing a specific item +**Passkeys:** `EditItem(...)` uses a JSON item template. The CLI overwrites passkeys when an item is edited with a JSON template, so do not use this method on items containing passkeys. The CLI also does not support editing SSH Key items. See the [official item editing documentation](https://www.1password.dev/cli/item-edit). + ```csharp item.Fields.First(x => x.Label == "password").Value = "newpass"; onePassword.EditItem(item, vault); @@ -218,7 +226,7 @@ Due to the fact that this library acts as a wrapper for the CLI and in order for the majority of tests are integration tests which must run against an active 1Password account (preferably a Business account). -The live test harness is pinned to and was last verified against 1Password CLI `2.32.1`. +The live test harness is pinned to 1Password CLI `2.42.0`. Its last recorded live verification was against `2.32.1`; the `2.42.0` compatibility review was performed statically without a live account. Automated test results are recorded in the release pull request. ### Effects on the active account diff --git a/docfx/docs/cli-compatibility.md b/docfx/docs/cli-compatibility.md new file mode 100644 index 0000000..338ac99 --- /dev/null +++ b/docfx/docs/cli-compatibility.md @@ -0,0 +1,92 @@ +# 1Password CLI compatibility review + +The referenced stable CLI version is **2.42.0**, reviewed on November 10, 2026. The [release archive](https://app-updates.agilebits.com/product_history/CLI2) dates its binary release November 3; the [release announcement](https://releases.1password.com/developers/cli/) was updated November 4. The archive also lists `2.44.0-beta.01`, which is a prerelease and is not the compatibility target. + +This documentation and source review covered the complete published CLI 2 history: **116 entries, comprising 60 stable releases and 56 prereleases**, from `2.0.0-beta.3` through the entries available on the review date. All 20 command families in the current reference were compared with the wrapper. The compatibility review itself did not run tests, account operations, project builds, or 1Password CLI commands. Subsequent release validation is described below. The last recorded live validation remains CLI `2.32.1`. + +## Changes since the previous CLI reference + +These changes affect compatibility or explain functionality outside the wrapper. Earlier releases were also checked for command syntax, JSON changes, authentication, attachments, permissions, and secret references. Source: [complete CLI 2 release history](https://app-updates.agilebits.com/product_history/CLI2). + +| Release | Relevant change | Wrapper assessment | +| --- | --- | --- | +| 2.33.0 | Tag replacement and clearing; unset date defaults; Account Trust Log support. | Tracked item tags already support replacement and clearing. Missing field values remain empty; no zero date is synthesized. Trust Log behavior belongs to the CLI. | +| 2.33.1 | Desktop authentication fixes; passkey warning for JSON edits. | Passkey limitation now appears in API and usage documentation. | +| 2.34.0 and 2.34.1 | Shell plugins, subprocess cancellation, and clearer vault Travel Mode help. | Plugin and subprocess commands are not wrapped. Existing vault boolean mapping matches the documented flag. | +| 2.35.0 | Plugin additions and deletion fixes for invited users. | Existing user deletion delegates to the corrected CLI. | +| 2.38.1 and 2.39.0 | Faster reads, improved diagnostics, and vault lookup failures. | Existing read paths benefit automatically; command failures now honor exit status. | +| 2.40.0 | Google Workspace synchronization and dependency security fixes. | Synchronization has no wrapper API. | +| 2.41.0 and 2.41.1 | Environment support in `run`; security updates. | `run` has no wrapper API. | +| 2.42.0 | Environment secret listing; address fixes; plugin changes. | Environment commands are not wrapped. Item and secret reads use the corrected CLI behavior. | + +## Command coverage + +Coverage describes existing public APIs and does not imply complete flag or workflow coverage. Sources: [command reference](https://www.1password.dev/cli/reference), the individual command pages, and the manager implementations. + +| Command family | Existing wrapper coverage | +| --- | --- | +| `account` | Add, list, get, and forget. Account selection is maintained by the manager. | +| `completion` | None. | +| `connect` | No management API. Ambient Connect configuration can affect CLI authentication. | +| `document` | Create, list, get, edit, delete, and archive. | +| `environment` | None, including the new secret listing command. | +| `events-api` | None. | +| `group` | Create, list, get, edit, delete, and membership management. | +| `inject` | None. | +| `item` | List, get, create, edit, move, share, delete, archive, templates, and attachments. Several generation, filtering, and output options are not exposed. | +| `plugin` | None. | +| `provisioning` | None. | +| `read` | Read text secrets or save secret and attachment content to a file. Query parameters pass through in secret references. | +| `run` | None, including Environment loading. | +| `service-account` | Authentication with an existing token; no creation or rate-limit API. | +| `signin` | Manual and desktop integration modes. | +| `signout` | Current account or all accounts. | +| `update` | Download and replace the executable using the stable channel. | +| `user` | List, get, provision, confirm, edit, suspend, reactivate, and delete. Recovery is not exposed. | +| `vault` | List, get, create, edit, delete, and user/group permission management. Permission-filtered listing and omission of permissions to revoke all access are not exposed. | +| `whoami` | None. | + +Ten of the 20 command families have direct wrapper methods. Service account authentication is configuration rather than a `service-account` management method. New command families and optional workflows were not added as part of compatibility corrections. + +## Confirmed issues corrected + +### Command execution and authentication + +* **Failures:** The command runner previously decided success from a stderr prefix alone and could return normally after a nonzero exit. It now waits for the process, checks its exit code, and preserves diagnostics without removing a fixed number of characters. Both output streams are drained concurrently to avoid pipe deadlocks. Standard input explicitly uses UTF-8, matching the [CLI encoding contract](https://www.1password.dev/cli/reference#alternative-character-encoding). +* **Account registration:** `AddAccount` used an undocumented `--secret-key` flag. The Secret Key now travels only in the child process's `OP_SECRET_KEY` environment variable. Passwords retain their exact contents instead of losing surrounding whitespace. See [account commands](https://www.1password.dev/cli/reference/management-commands/account) and the documented Secret Key environment variable in the CLI 2.0.1 release notes. +* **Account selection:** An explicit `GetAccount` argument could be followed by a second, conflicting `--account` flag. Selection now reaches the runner as one override. `SignOut` also emits only one account selector. See [multiple account selection](https://www.1password.dev/cli/use-multiple-accounts). +* **Authentication environment:** An explicit service account could silently use an inherited Connect backend, which has higher precedence. Service account mode removes Connect variables from the child environment. Human modes remove an inherited service account token, and the selected desktop integration setting is explicit. See [service account precedence and supported commands](https://www.1password.dev/service-accounts/use-with-1password-cli). +* **Service account restrictions:** A blanket rejection of all user and group commands also blocked documented read operations. User/group get and list can now reach the CLI; unsupported management operations remain blocked. The same service account guide discourages using these read operations as a provisioning workflow. +* **Verbose output:** Verbose mode previously printed the session token and complete secret-bearing responses. It now prints the base command without appended authentication tokens or response bodies. + +### Items and files + +* **Secret references and argument quoting:** References containing spaces were split into separate arguments. References are now quoted, and the existing argument quoting routine handles embedded quotes and trailing backslashes. Names, descriptions, paths, titles, tags, and template names use that routine. See [secret reference syntax](https://www.1password.dev/cli/secret-reference-syntax). +* **Attachment assignment targets:** Literal periods, equals signs, and backslashes in attachment or section names were interpreted as assignment syntax. These characters are now escaped before argument quoting. See [item assignment syntax](https://www.1password.dev/cli/reference/management-commands/item#item-create). +* **URL edits:** Editing a primary URL interpolated the `Url` object, producing its type name. The command now uses `Href`; changes elsewhere in the URL collection do not emit an accidental empty primary URL override. See [item editing flags](https://www.1password.dev/cli/reference/management-commands/item#item-edit). +* **Document tags and modes:** An empty tag collection was ignored during replacement, preventing tag removal. It now emits an empty `--tags` value. Blank optional file modes are omitted. See [document editing and download flags](https://www.1password.dev/cli/reference/management-commands/document). +* **Share expiry:** Converting a duration greater than seven days to weeks discarded its hours, minutes, and seconds. The conversion now subtracts complete weeks and preserves the remainder. See [share duration units](https://www.1password.dev/cli/reference/management-commands/item#item-share). +* **Known model values:** The models now recognize the documented Crypto Wallet category, service account user type, and recovery-started state. Existing enum numeric values are preserved. Sources: [item categories](https://www.1password.dev/cli/item-fields), [service account user details](https://www.1password.dev/service-accounts/use-with-1password-cli), and [user examples](https://www.1password.dev/cli/reference/management-commands/user). + +### Vaults and updates + +* **Permission names:** Grant/revoke methods sent names such as `View Items` where the CLI expects `view_items`. All four methods now use lowercase names with underscores and disable interactive input. Permission dependencies remain the caller's responsibility; no extra privileges are added automatically. See [vault command flags](https://www.1password.dev/cli/reference/management-commands/vault) and [permission dependencies](https://www.1password.dev/cli/vault-permissions). +* **Updates:** The updater explicitly selects `stable` and cleans its temporary directory on failure as well as success. See [update options](https://www.1password.dev/cli/reference/commands/update). + +## Remaining limitations + +**Passkey preservation is a CLI limitation.** `EditItem` sends JSON, and the CLI cannot preserve passkeys in JSON template edits. This method must not be used on passkey-bearing items. The documented JSON schema does not provide a reliable passkey-presence check, so the wrapper cannot claim automatic protection. The API remarks, README, and quick start now explain this limitation. SSH Key edits are rejected before starting the process because that operation is also unsupported. See [editing restrictions](https://www.1password.dev/cli/item-edit). + +**Moving changes the item's ID.** Existing `MoveItem` methods return `void`, so a supplied model retains its original ID. Their API remarks now document that callers must retrieve the destination item. Returning the new item would require a public API change. See [item move behavior](https://www.1password.dev/cli/reference/management-commands/item#item-move). + +**Documentation does not fully specify every JSON response.** `ItemShare` keeps optional metadata optional, enum conversion retains its unknown fallback, and document creation retains the existing `uuid` mapping. No response properties were guessed. The updater still recognizes a human-readable availability message; the update reference does not promise a structured response schema. + +**Live behavior remains unverified.** The download pin and simulated CLI version fixtures now reference `2.42.0`; existing assertions were adjusted for corrected command strings. Static review cannot establish account capabilities, server policy, MFA interactions, or undocumented response shapes. Package, assembly, and file versions are managed separately during release preparation. + +## Static verification + +Roslyn syntax parsing of all **88 C# source files** found no syntax errors. Project XML and SDK JSON parsed successfully, and `git diff --check` found no patch whitespace errors. MSBuild configuration evaluation resolved both projects to `net11.0`, SDK `11.0.100`, and the default C# `15.0`. Project builds, Rider inspections, and tests were excluded from the original compatibility review. + +## Release validation + +Subsequent preparation of release **2.6.0** included a Release solution build with **zero compiler warnings and errors**, Rider 2026.3 solution inspections with **zero warnings**, and creation of the NuGet and symbol packages. The package contains the `net11.0` library. No tests were executed locally; CI test results are recorded in the release pull request. diff --git a/docfx/docs/quick-start.md b/docfx/docs/quick-start.md index 9f426e3..e95c7e5 100644 --- a/docfx/docs/quick-start.md +++ b/docfx/docs/quick-start.md @@ -96,6 +96,8 @@ var item = onePassword.GetItem(itemSummary, vault); ### Editing a specific item +**Passkeys:** `EditItem(...)` uses a JSON item template. The CLI overwrites passkeys when an item is edited with a JSON template, so do not use this method on items containing passkeys. The CLI also does not support editing SSH Key items. See the [official item editing documentation](https://www.1password.dev/cli/item-edit). + ```csharp item.Fields.First(x => x.Label == "password").Value = "newpass"; onePassword.EditItem(item, vault); diff --git a/docfx/docs/running-tests.md b/docfx/docs/running-tests.md index cc24c82..b3d8dfc 100644 --- a/docfx/docs/running-tests.md +++ b/docfx/docs/running-tests.md @@ -4,7 +4,7 @@ Due to the fact that this library acts as a wrapper for the CLI and in order for the majority of tests are integration tests which must run against an active 1Password account (preferably a Business account). -The live test harness is pinned to and was last verified against 1Password CLI `2.32.1`. +The live test harness is pinned to 1Password CLI `2.42.0`. Its last recorded live verification was against `2.32.1`; the `2.42.0` compatibility review was performed statically without a live account. Automated test results are recorded in the release pull request. ### Effects on the active account diff --git a/docfx/docs/toc.yml b/docfx/docs/toc.yml index f3e2a15..dba2503 100644 --- a/docfx/docs/toc.yml +++ b/docfx/docs/toc.yml @@ -2,4 +2,6 @@ href: quick-start.md - name: Running tests href: running-tests.md +- name: CLI compatibility + href: cli-compatibility.md diff --git a/docfx/index.md b/docfx/index.md index e815fd5..5d4d00a 100644 --- a/docfx/index.md +++ b/docfx/index.md @@ -15,7 +15,11 @@ This library serves as a .NET wrapper for the [1Password](https://1password.com/ ## References -This library targets .NET Standard 2.1. +This library targets .NET 11. + +Version 2.6.0 requires .NET 11 and no longer includes the previous .NET Standard 2.1 target. + +The referenced 1Password CLI version is **2.42.0**. Compatibility was reviewed against the official command reference and full CLI 2 changelog on November 10, 2026; this version has not been validated against a live account. See the [CLI compatibility review](docs/cli-compatibility.md). ## Dependencies diff --git a/global.json b/global.json new file mode 100644 index 0000000..568ee5c --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "11.0.100", + "rollForward": "latestFeature", + "allowPrerelease": false + } +}