Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ This repository contains `OnePassword.NET`, a .NET wrapper for the 1Password CLI
- If the current branch is not already a feature branch named in the format `feature/<appropriate-short-name>`, create one before committing.
- Choose a short, specific branch suffix that describes the work. Keep it lowercase and hyphenated.
- If already on an appropriate `feature/...` branch, do not create an additional branch unless the user asks for one.
- Do not leave your own repository changes uncommitted; commit them before ending the work unless the user explicitly asks you not to commit.
- When creating a commit, include all current unstaged changes in that repository in the commit unless the user explicitly asks to exclude something.
- Commit messages must be a single-line short sentence in past tense that summarizes the commit.
- Commit messages must be written as a proper sentence and must end with a period.
- Do not use multiline commit messages, bullet lists, prefixes, or issue numbers in the commit message unless the user explicitly asks for them.
Expand All @@ -24,3 +26,8 @@ This repository contains `OnePassword.NET`, a .NET wrapper for the 1Password CLI

- Do not read, search, or summarize generated documentation/site assets unless the user explicitly asks for them.
- In particular, avoid generated docfx output and bundled vendor assets such as minified JavaScript, CSS, or copied third-party files; prefer the markdown and source files under `docfx/` instead.

## API Abstraction

- Never expose or leak raw 1Password CLI responses through the public API unless the user explicitly asks for that exact behavior.
- Keep the wrapper abstraction stable and consumer-focused: parse CLI output into library models and shield consumers from CLI output-shape changes whenever practical.
2 changes: 1 addition & 1 deletion NEXT_RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Breaking changes

- `ShareItem(...)` now returns `ItemShareResult` instead of `void`.
- `ShareItem(...)` now returns `ItemShare` instead of `void`.

## Highlights

Expand Down
19 changes: 14 additions & 5 deletions OnePassword.NET.Tests/Common/TestsBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,8 @@ public class TestsBase
private static readonly SemaphoreSlim SemaphoreSlim = new(1, 1);
private static readonly CancellationTokenSource TestCancellationTokenSource = new();
private static readonly CancellationTokenSource TearDownCancellationTokenSource = new();
private static readonly bool IsLinux = RuntimeInformation.IsOSPlatform(OSPlatform.Linux);
private static readonly Uri DownloadSource = IsLinux ?
new Uri("https://cache.agilebits.com/dist/1P/op2/pkg/v2.26.0/op_linux_amd64_v2.26.0.zip") :
new Uri("https://cache.agilebits.com/dist/1P/op2/pkg/v2.26.0/op_windows_amd64_v2.26.0.zip");
private static readonly string ExecutableName = IsLinux ? "op" : "op.exe";
private static readonly Uri DownloadSource = GetDownloadSource();
private static readonly string ExecutableName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "op.exe" : "op";
private static bool _initialSetupDone;

private protected static readonly CancellationTokenSource SetUpCancellationTokenSource = new();
Expand Down Expand Up @@ -146,6 +143,18 @@ private static string GetEnv(string name, string value)
return value;
}

private static Uri GetDownloadSource()
{
const string version = "v2.32.1";

if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
return new Uri($"https://cache.agilebits.com/dist/1P/op2/pkg/{version}/op_windows_amd64_{version}.zip");
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
return new Uri($"https://cache.agilebits.com/dist/1P/op2/pkg/{version}/op_darwin_amd64_{version}.zip");

return new Uri($"https://cache.agilebits.com/dist/1P/op2/pkg/{version}/op_linux_amd64_{version}.zip");
}

private protected static void MarkManagementUnsupported()
{
GroupManagementSupported = false;
Expand Down
181 changes: 171 additions & 10 deletions OnePassword.NET.Tests/OnePasswordManagerCommandTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using System.Globalization;
using System.IO.Compression;
using System.Reflection;
using System.Runtime.InteropServices;
using OnePassword.Common;
using OnePassword.Documents;
Expand All @@ -22,6 +24,15 @@ public void VersionIsTrimmed()
Assert.That(manager.Version, Is.EqualTo("2.32.1"));
}

[Test]
public void DefaultExecutableNameMatchesCurrentPlatform()
{
var method = typeof(OnePasswordManagerOptions).GetMethod("GetDefaultExecutableName", BindingFlags.NonPublic | BindingFlags.Static);

Assert.That(method, Is.Not.Null);
Assert.That(method!.Invoke(null, null), Is.EqualTo(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "op.exe" : "op"));
}

[Test]
public void ArchiveDocumentObjectOverloadUsesArchiveCommand()
{
Expand Down Expand Up @@ -66,6 +77,109 @@ public void ArchiveItemStringOverloadUsesArchiveCommand()
Assert.That(fakeCli.LastArguments, Does.StartWith("item delete item-id --vault vault-id --archive"));
}

[Test]
public void MoveItemStringOverloadUsesResolvedVaultIds()
{
using var fakeCli = new FakeCli();
var manager = fakeCli.CreateManager();

manager.MoveItem("item-id", "current-vault-id", "destination-vault-id");

Assert.Multiple(() =>
{
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]
public void SearchForDocumentCreatesMissingOutputDirectory()
{
using var fakeCli = new FakeCli();
var manager = fakeCli.CreateManager();
var outputDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName(), Path.GetRandomFileName());
var outputFilePath = Path.Combine(outputDirectory, "document.txt");

try
{
manager.SearchForDocument("document-id", outputFilePath, "vault-id");

Assert.Multiple(() =>
{
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
{
var rootDirectory = Directory.GetParent(outputDirectory)?.FullName;
if (rootDirectory is not null && Directory.Exists(rootDirectory))
Directory.Delete(rootDirectory, true);
}
}

[Test]
public void GetSecretUsesTrimmedReference()
{
using var fakeCli = new FakeCli();
var manager = fakeCli.CreateManager();

manager.GetSecret(" op://vault/item/field ");

Assert.That(fakeCli.LastArguments, Does.StartWith("read op://vault/item/field --no-newline"));
}

[Test]
public void SaveSecretUsesTrimmedReference()
{
using var fakeCli = new FakeCli();
var manager = fakeCli.CreateManager();
var outputPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());

try
{
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.Contain(outputPath));
}
finally
{
if (File.Exists(outputPath))
File.Delete(outputPath);
}
}

[Test]
public void RevokeGroupPermissionsUsesVaultGroupCommand()
{
using var fakeCli = new FakeCli();
var manager = fakeCli.CreateManager();

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"));
}

[Test]
public void UpdateExtractsCurrentPlatformExecutablePayload()
{
using var fakeCli = new FakeCli(updateVersionOutput: "2.33.0\n");
var manager = fakeCli.CreateManager();

var updated = manager.Update();

Assert.Multiple(() =>
{
Assert.That(updated, Is.True);
Assert.That(manager.Version, Is.EqualTo("2.33.0"));
});
}

[Test]
public void ShareItemWithoutEmailsOmitsEmailsFlag()
{
Expand All @@ -77,7 +191,9 @@ public void ShareItemWithoutEmailsOmitsEmailsFlag()
Assert.Multiple(() =>
{
Assert.That(result.Url, Is.EqualTo(new Uri("https://share.example/item")));
Assert.That(result.RawResponse, Is.EqualTo("https://share.example/item"));
Assert.That(result.ExpiresAt, Is.Null);
Assert.That(result.Recipients, Is.Empty);
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"));
});
Expand All @@ -93,7 +209,8 @@ public void ShareItemStringSingleEmailOverloadUsesEmailsFlag()

Assert.Multiple(() =>
{
Assert.That(result.RawResponse, Is.EqualTo("{}"));
Assert.That(result.Url, Is.Null);
Assert.That(result.Recipients, Is.Empty);
Assert.That(fakeCli.LastArguments, Does.Contain("--emails recipient@example.com"));
});
}
Expand Down Expand Up @@ -190,7 +307,6 @@ public void ShareItemParsesStructuredShareResult()
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));
Assert.That(result.RawResponse, Does.Contain("\"share_link\": \"https://share.example/item\""));
});
}

Expand All @@ -199,21 +315,33 @@ private sealed class FakeCli : IDisposable
private readonly string _argumentsPath;
private readonly string _directoryPath;
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 = "{}")
public FakeCli(string versionOutput = "2.32.1\n", string nextOutput = "{}", string? updateVersionOutput = null)
{
_directoryPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
_argumentsPath = Path.Combine(_directoryPath, "last-arguments.txt");
_nextOutputPath = Path.Combine(_directoryPath, "next-output.txt");
_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");

Directory.CreateDirectory(_directoryPath);
File.WriteAllText(_nextOutputPath, nextOutput);
File.WriteAllText(_versionOutputPath, versionOutput);
if (updateVersionOutput is not null)
{
File.WriteAllText(_updateMessagePath, $"Version {updateVersionOutput.Trim()} is now available.");
File.WriteAllText(_updatedVersionOutputPath, updateVersionOutput);
CreateUpdatePayload();
}

var executablePath = Path.Combine(_directoryPath, ExecutableName);
File.WriteAllText(executablePath, GetScript());
File.WriteAllText(executablePath, GetScript("version-output.txt"));
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
File.SetUnixFileMode(executablePath,
Expand Down Expand Up @@ -243,30 +371,63 @@ public void Dispose()
Directory.Delete(_directoryPath, true);
}

private static string GetScript()
private void CreateUpdatePayload()
{
var updatedExecutablePath = Path.Combine(_directoryPath, PackagedExecutableName);
File.WriteAllText(updatedExecutablePath, GetScript("updated-version-output.txt"));
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
File.SetUnixFileMode(updatedExecutablePath,
UnixFileMode.UserRead
| UnixFileMode.UserWrite
| UnixFileMode.UserExecute);
}

using var zipArchive = ZipFile.Open(_updatePayloadPath, ZipArchiveMode.Create);
zipArchive.CreateEntryFromFile(updatedExecutablePath, PackagedExecutableName);
File.Delete(updatedExecutablePath);
}

private static string GetScript(string versionOutputFileName)
{
return RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? """
@echo off
setlocal
> "%~dp0last-arguments.txt" echo %*
if "%~1"=="update" (
if exist "%~dp0update-payload.zip" copy /y "%~dp0update-payload.zip" "%~3\update-payload.zip" > nul
if exist "%~dp0update-output.txt" type "%~dp0update-output.txt"
exit /b 0
)
if "%~1"=="--version" (
type "%~dp0version-output.txt"
type "%~dp0VERSION_OUTPUT_PLACEHOLDER"
exit /b 0
)
type "%~dp0next-output.txt"
"""
""".Replace("VERSION_OUTPUT_PLACEHOLDER", versionOutputFileName)
: """
#!/bin/sh
script_dir=$(CDPATH= cd -- "$(dirname "$0")" && pwd)
printf '%s' "$*" > "$script_dir/last-arguments.txt"
if [ "$1" = "update" ]; then
if [ -f "$script_dir/update-payload.zip" ]; then
cp "$script_dir/update-payload.zip" "$3/update-payload.zip"
fi
if [ -f "$script_dir/update-output.txt" ]; then
cat "$script_dir/update-output.txt"
fi
exit 0
fi
if [ "$1" = "--version" ]; then
cat "$script_dir/version-output.txt"
cat "$script_dir/VERSION_OUTPUT_PLACEHOLDER"
exit 0
fi
cat "$script_dir/next-output.txt"
""";
""".Replace("VERSION_OUTPUT_PLACEHOLDER", versionOutputFileName);
}

private static string PackagedExecutableName => RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "op.exe" : "op";
}

private sealed class TestDocument(string id) : IDocument
Expand Down
8 changes: 4 additions & 4 deletions OnePassword.NET/IOnePasswordManager.Items.cs
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ public ImmutableList<Item> SearchForItems(string? vaultId = null, bool? includeA
/// <param name="viewOnce">Expires the link after a single view.</param>
/// <returns>The created share result.</returns>
/// <exception cref="ArgumentException">Thrown when there is an invalid argument.</exception>
public ItemShareResult ShareItem(IItem item, IVault vault, string emailAddress, TimeSpan? expiresIn = null, bool? viewOnce = null);
public ItemShare ShareItem(IItem item, IVault vault, string emailAddress, TimeSpan? expiresIn = null, bool? viewOnce = null);

/// <summary>Shares an item.</summary>
/// <param name="itemId">The ID of the item to share.</param>
Expand All @@ -163,7 +163,7 @@ public ImmutableList<Item> SearchForItems(string? vaultId = null, bool? includeA
/// <param name="viewOnce">Expires the link after a single view.</param>
/// <returns>The created share result.</returns>
/// <exception cref="ArgumentException">Thrown when there is an invalid argument.</exception>
public ItemShareResult ShareItem(string itemId, string vaultId, string emailAddress, TimeSpan? expiresIn = null, bool? viewOnce = null);
public ItemShare ShareItem(string itemId, string vaultId, string emailAddress, TimeSpan? expiresIn = null, bool? viewOnce = null);

/// <summary>Shares an item.</summary>
/// <param name="item">The item to share.</param>
Expand All @@ -173,7 +173,7 @@ public ImmutableList<Item> SearchForItems(string? vaultId = null, bool? includeA
/// <param name="viewOnce">Expires the link after a single view.</param>
/// <returns>The created share result.</returns>
/// <exception cref="ArgumentException">Thrown when there is an invalid argument.</exception>
public ItemShareResult ShareItem(IItem item, IVault vault, IReadOnlyCollection<string>? emailAddresses = null, TimeSpan? expiresIn = null, bool? viewOnce = null);
public ItemShare ShareItem(IItem item, IVault vault, IReadOnlyCollection<string>? emailAddresses = null, TimeSpan? expiresIn = null, bool? viewOnce = null);

/// <summary>Shares an item.</summary>
/// <param name="itemId">The ID of the item to share.</param>
Expand All @@ -183,5 +183,5 @@ public ImmutableList<Item> SearchForItems(string? vaultId = null, bool? includeA
/// <param name="viewOnce">Expires the link after a single view.</param>
/// <returns>The created share result.</returns>
/// <exception cref="ArgumentException">Thrown when there is an invalid argument.</exception>
public ItemShareResult ShareItem(string itemId, string vaultId, IReadOnlyCollection<string>? emailAddresses = null, TimeSpan? expiresIn = null, bool? viewOnce = null);
public ItemShare ShareItem(string itemId, string vaultId, IReadOnlyCollection<string>? emailAddresses = null, TimeSpan? expiresIn = null, bool? viewOnce = null);
}
2 changes: 1 addition & 1 deletion OnePassword.NET/IOnePasswordManagerOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ public interface IOnePasswordManagerOptions
/// <summary>The path to the 1Password CLI executable. Defaults to the current working directory.</summary>
public string Path { get; set; }

/// <summary>The name of the 1Password CLI executable. Defaults to 'op.exe'.</summary>
/// <summary>The name of the 1Password CLI executable. Defaults to 'op.exe' on Windows and 'op' on other platforms.</summary>
public string Executable { get; set; }

/// <summary>When <see langword="true" />, commands sent to the 1Password CLI executable are output to the console. Defaults to <see langword="false" />.</summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ namespace OnePassword.Items;
/// <summary>
/// Represents the result of sharing a 1Password item.
/// </summary>
public sealed class ItemShareResult
public sealed class ItemShare
{
/// <summary>
/// The generated share URL.
Expand All @@ -24,9 +24,4 @@ public sealed class ItemShareResult
/// Whether the share is view-once, when returned by the CLI.
/// </summary>
public bool? ViewOnce { get; internal set; }

/// <summary>
/// The raw CLI response used to build the result.
/// </summary>
public string RawResponse { get; internal set; } = "";
}
Loading
Loading