// Copyright (C) 2026, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Reflection;
using System.Runtime.Versioning;
using System.Web;
using Duplicati.Library.Interface;
using Duplicati.Library.Utility;
namespace Duplicati.Library.SecretProvider;
///
/// Implementation of a secret provider that reads secrets from the Unix pass utility
///
[SupportedOSPlatform("linux")]
public class UnixPassProvider : ISecretProvider
{
///
/// The default pass command
///
private const string DefaultPassCommand = "pass";
///
public string Key => "pass";
///
public string DisplayName => Strings.UnixPassProvider.DisplayName;
///
public string Description => Strings.UnixPassProvider.Description;
///
/// Cached support detection for the pass utility, using a PATH scan.
/// This avoids re-scanning PATH multiple times across different instances.
///
private static readonly ConcurrentDictionary> _passSupportCache = new();
///
public Task IsSupported(CancellationToken cancellationToken)
{
var cmd = _config?.PassCommand ?? DefaultPassCommand;
if (string.IsNullOrWhiteSpace(cmd) || cmd.ContainsAny(Path.GetInvalidPathChars()) || cmd.Contains(Path.PathSeparator))
return Task.FromResult(false);
return Task.FromResult(_passSupportCache.GetOrAdd(cmd, pc => new Lazy(() => CheckPassSupport(pc))).Value);
}
///
public bool IsSetSupported => true;
///
public IList SupportedCommands
=> CommandLineArgumentMapper.MapArguments(new UnixPassProviderConfig())
.ToList();
///
/// The configuration for the secret provider; null if not initialized
///
private UnixPassProviderConfig? _config;
///
/// Checks whether the configured pass command is available by scanning PATH.
/// This works across platforms (Linux, macOS, Windows) and avoids spawning a process.
///
/// The pass command to check.
/// true if the command appears to be available; otherwise false.
private static bool CheckPassSupport(string passCommand)
{
if (string.IsNullOrWhiteSpace(passCommand))
return false;
try
{
// If the command contains a directory separator, treat it as a path and just check for existence.
if (passCommand.IndexOfAny(new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }) >= 0)
return File.Exists(passCommand);
var pathEnv = Environment.GetEnvironmentVariable("PATH");
if (string.IsNullOrWhiteSpace(pathEnv))
return false;
foreach (var dir in pathEnv.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries))
{
var d = dir.Trim();
if (string.IsNullOrEmpty(d))
continue;
var candidate = Path.Combine(d, passCommand);
if (File.Exists(candidate))
return true;
}
return false;
}
catch
{
return false;
}
}
///
/// The configuration for the Unix pass provider
///
private class UnixPassProviderConfig : ICommandLineArgumentMapper
{
///
/// The command to run to get a password
///
public string PassCommand { get; set; } = DefaultPassCommand;
///
/// Gets the command line argument description for a member
///
/// The name of the member
/// The command line argument description, or null if the member is not a command line argument
public static CommandLineArgumentDescriptionAttribute? GetCommandLineArgumentDescription(string name)
=> name switch
{
nameof(PassCommand) => new CommandLineArgumentDescriptionAttribute() { Name = "pass-command", Type = CommandLineArgument.ArgumentType.String, ShortDescription = Strings.UnixPassProvider.PassCommandDescriptionShort, LongDescription = Strings.UnixPassProvider.PassCommandDescriptionLong },
_ => null
};
///
CommandLineArgumentDescriptionAttribute? ICommandLineArgumentMapper.GetCommandLineArgumentDescription(MemberInfo mi)
=> GetCommandLineArgumentDescription(mi.Name);
}
///
public Task InitializeAsync(System.Uri config, CancellationToken cancellationToken)
{
var args = HttpUtility.ParseQueryString(config.Query);
_config = CommandLineArgumentMapper.ApplyArguments(new UnixPassProviderConfig(), args);
return Task.CompletedTask;
}
///
public async Task> ResolveSecretsAsync(IEnumerable keys, CancellationToken cancellationToken)
{
if (_config is null)
throw new InvalidOperationException("The UnixPassProvider has not been initialized");
var result = new Dictionary();
foreach (var key in keys)
{
var value = await GetString(key, _config, cancellationToken).ConfigureAwait(false);
result[key] = value;
}
return result;
}
///
public async Task SetSecretAsync(string key, string value, bool overwrite, CancellationToken cancellationToken)
{
if (_config is null)
throw new InvalidOperationException("The UnixPassProvider has not been initialized");
if (!overwrite && await SecretExistsAsync(key, _config, cancellationToken).ConfigureAwait(false))
throw new UserInformationException($"The key '{key}' already exists", "KeyAlreadyExists");
await InsertSecretAsync(key, value, _config, overwrite, cancellationToken).ConfigureAwait(false);
}
///
/// Gets a string from the pass utility
///
/// The name of the secret
/// The configuration for the Unix pass provider
/// The cancellation token
/// The secret
private static async Task GetString(string name, UnixPassProviderConfig config, CancellationToken cancellationToken)
{
var psi = new ProcessStartInfo(config.PassCommand)
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
psi.ArgumentList.Add("show");
psi.ArgumentList.Add(name);
using var process = Process.Start(psi);
if (process is null)
throw new InvalidOperationException("Failed to start pass");
using var _ = new ProcessDisposer(process);
using var ct = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
ct.CancelAfter(TimeSpan.FromSeconds(10));
var stdoutTask = process.StandardOutput.ReadToEndAsync(ct.Token);
var stderrTask = process.StandardError.ReadToEndAsync(ct.Token);
await Task.WhenAll(stdoutTask, stderrTask, process.WaitForExitAsync(ct.Token)).ConfigureAwait(false);
var output = await stdoutTask.ConfigureAwait(false);
var error = await stderrTask.ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(error))
throw new UserInformationException($"Error running pass: {error}", "PassError");
if (process.ExitCode != 0)
throw new UserInformationException($"pass failed with exit code {process.ExitCode}", "PassFailed");
if (string.IsNullOrWhiteSpace(output))
throw new UserInformationException("pass returned no output", "PassNoOutput");
return output.Trim();
}
///
/// Checks whether a secret exists in the pass utility
///
/// The name of the secret
/// The configuration for the Unix pass provider
/// The cancellation token
/// True if the secret exists; false otherwise
private static async Task SecretExistsAsync(string name, UnixPassProviderConfig config, CancellationToken cancellationToken)
{
var psi = new ProcessStartInfo(config.PassCommand)
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
psi.ArgumentList.Add("show");
psi.ArgumentList.Add(name);
using var process = Process.Start(psi);
if (process is null)
throw new InvalidOperationException("Failed to start pass");
using var _ = new ProcessDisposer(process);
using var ct = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
ct.CancelAfter(TimeSpan.FromSeconds(10));
var stdoutTask = process.StandardOutput.ReadToEndAsync(ct.Token);
var stderrTask = process.StandardError.ReadToEndAsync(ct.Token);
await Task.WhenAll(stdoutTask, stderrTask, process.WaitForExitAsync(ct.Token)).ConfigureAwait(false);
return process.ExitCode == 0;
}
///
/// Inserts a secret into the pass utility
///
/// The name of the secret
/// The value of the secret
/// The configuration for the Unix pass provider
/// Whether to overwrite an existing secret
/// The cancellation token
/// An awaitable task
private static async Task InsertSecretAsync(string name, string value, UnixPassProviderConfig config, bool overwrite, CancellationToken cancellationToken)
{
var psi = new ProcessStartInfo(config.PassCommand)
{
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
psi.ArgumentList.Add("insert");
if (overwrite)
psi.ArgumentList.Add("-f");
psi.ArgumentList.Add("--multiline");
psi.ArgumentList.Add(name);
using var process = Process.Start(psi);
if (process is null)
throw new InvalidOperationException("Failed to start pass");
using var _ = new ProcessDisposer(process);
using var ct = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
ct.CancelAfter(TimeSpan.FromSeconds(10));
var stdoutTask = process.StandardOutput.ReadToEndAsync(ct.Token);
var stderrTask = process.StandardError.ReadToEndAsync(ct.Token);
await process.StandardInput.WriteAsync(value.AsMemory(), ct.Token).ConfigureAwait(false);
await process.StandardInput.WriteAsync(Environment.NewLine.AsMemory(), ct.Token).ConfigureAwait(false);
await process.StandardInput.FlushAsync(ct.Token).ConfigureAwait(false);
process.StandardInput.Close();
await Task.WhenAll(stdoutTask, stderrTask, process.WaitForExitAsync(ct.Token)).ConfigureAwait(false);
if (process.ExitCode != 0)
{
var error = await stderrTask.ConfigureAwait(false);
throw new UserInformationException($"pass failed with exit code {process.ExitCode}: {error}", "PassInsertFailed");
}
}
}