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
19 changes: 3 additions & 16 deletions src/Crypto/SealPipeline.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,13 @@ internal static class SealPipeline
/// </summary>
public static async Task<UploadResult> SealAndUploadAsync(
PostGuardConfig config,
HttpClient http,
EncryptInput input,
UploadOptions? uploadOptions,
CancellationToken ct = default)
{
var sealedBytes = await SealAsync(config, input, ct);
var sealedBytes = await SealAsync(config, http, input, ct);

using var http = CreateHttpClient(config);
var cryptify = new CryptifyClient(http, config.CryptifyUrl);
var uuid = await cryptify.UploadAsync(
sealedBytes, input.Recipients, uploadOptions?.Notify, ct);
Expand All @@ -34,14 +34,14 @@ public static async Task<UploadResult> SealAndUploadAsync(
/// </summary>
public static async Task<byte[]> SealAsync(
PostGuardConfig config,
HttpClient http,
EncryptInput input,
CancellationToken ct = default)
{
var apiKey = input.Sign is ApiKeySign ak
? ak.ApiKey
: throw new ArgumentException("Only ApiKey signing is supported");

using var http = CreateHttpClient(config);
var pkg = new PkgClient(http, config.PkgUrl);

// Fetch MPK and signing keys in parallel
Expand Down Expand Up @@ -92,17 +92,4 @@ internal static string BuildPolicyJson(IReadOnlyList<RecipientBuilder> recipient

return JsonSerializer.Serialize(policy);
}

private static HttpClient CreateHttpClient(PostGuardConfig config)
{
var http = new HttpClient();
if (config.Headers != null)
{
foreach (var (key, value) in config.Headers)
{
http.DefaultRequestHeaders.TryAddWithoutValidation(key, value);
}
}
return http;
}
}
48 changes: 46 additions & 2 deletions src/PostGuard.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,48 @@

namespace E4A.PostGuard;

public class PostGuard
public class PostGuard : IDisposable
{
private readonly PostGuardConfig _config;
private readonly HttpClient _http;
private readonly bool _ownsHttp;
private bool _disposed;

public PostGuard(PostGuardConfig config)
{
ArgumentNullException.ThrowIfNull(config);
config.Validate();
_config = config;

if (config.HttpClient is not null)
{
_http = config.HttpClient;
_ownsHttp = false;
}
else
{
// SocketsHttpHandler with a bounded PooledConnectionLifetime so a
// long-lived client still picks up DNS changes — recommended pattern
// for singleton HttpClient. See:
// https://learn.microsoft.com/en-us/dotnet/fundamentals/networking/http/httpclient-guidelines
var handler = new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
};
_http = new HttpClient(handler, disposeHandler: true);
if (config.Timeout is { } timeout)
{
_http.Timeout = timeout;
}
if (config.Headers is not null)
{
foreach (var (key, value) in config.Headers)
{
_http.DefaultRequestHeaders.TryAddWithoutValidation(key, value);
}
}
_ownsHttp = true;
}
}

/// <summary>
Expand All @@ -30,7 +63,18 @@ public PostGuard(PostGuardConfig config)
/// </summary>
public Sealed Encrypt(EncryptInput input)
{
return new Sealed(_config, input);
return new Sealed(_config, _http, input);
}

public void Dispose()
{
if (_disposed) return;
_disposed = true;
if (_ownsHttp)
{
_http.Dispose();
}
GC.SuppressFinalize(this);
}
}

Expand Down
16 changes: 16 additions & 0 deletions src/PostGuardConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,22 @@ public class PostGuardConfig
/// </summary>
public bool AllowInsecureUrls { get; init; }

/// <summary>
/// Optional caller-supplied <see cref="System.Net.Http.HttpClient"/>. When
/// set, the SDK reuses this client for all PKG and Cryptify calls and does
/// NOT dispose it — ownership stays with the caller (DI-friendly). When
/// null, <see cref="PostGuard"/> creates and owns a single long-lived client.
/// </summary>
public HttpClient? HttpClient { get; init; }

/// <summary>
/// Request timeout applied to the SDK-owned <see cref="System.Net.Http.HttpClient"/>.
/// Ignored when <see cref="HttpClient"/> is supplied (the caller owns the
/// timeout in that case). Defaults to <see cref="System.Net.Http.HttpClient"/>'s
/// own default of 100 seconds when null.
/// </summary>
public TimeSpan? Timeout { get; init; }

internal void Validate()
{
if (AllowInsecureUrls)
Expand Down
8 changes: 5 additions & 3 deletions src/Sealed.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ namespace E4A.PostGuard;
public class Sealed
{
private readonly PostGuardConfig _config;
private readonly HttpClient _http;
private readonly EncryptInput _input;

internal Sealed(PostGuardConfig config, EncryptInput input)
internal Sealed(PostGuardConfig config, HttpClient http, EncryptInput input)
{
_config = config;
_http = http;
_input = input;
}

Expand All @@ -28,7 +30,7 @@ public async Task<UploadResult> UploadAsync(
UploadOptions? options = null,
CancellationToken ct = default)
{
return await SealPipeline.SealAndUploadAsync(_config, _input, options, ct);
return await SealPipeline.SealAndUploadAsync(_config, _http, _input, options, ct);
}

/// <summary>
Expand All @@ -38,6 +40,6 @@ public async Task<UploadResult> UploadAsync(
/// <returns>The sealed (encrypted + signed) byte array.</returns>
public async Task<byte[]> ToBytesAsync(CancellationToken ct = default)
{
return await SealPipeline.SealAsync(_config, _input, ct);
return await SealPipeline.SealAsync(_config, _http, _input, ct);
}
}
51 changes: 51 additions & 0 deletions tests/E4A.PostGuard.Tests/PostGuardConfigTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -91,4 +91,55 @@ public void Ctor_NullConfig_Throws()
{
Assert.Throws<ArgumentNullException>(() => new PostGuard(null!));
}

[Fact]
public void Dispose_DoesNotDisposeInjectedHttpClient()
{
using var injected = new HttpClient();
var config = new PostGuardConfig
{
PkgUrl = ValidPkg,
CryptifyUrl = ValidCryptify,
HttpClient = injected,
};

var pg = new PostGuard(config);
pg.Dispose();

// If injected was disposed, this throws ObjectDisposedException.
injected.DefaultRequestHeaders.TryAddWithoutValidation("X-Test", "ok");
}

[Fact]
public void Dispose_DisposesOwnedHttpClient()
{
var config = new PostGuardConfig
{
PkgUrl = ValidPkg,
CryptifyUrl = ValidCryptify,
};

var pg = new PostGuard(config);
pg.Dispose();
// Calling Dispose twice must be safe.
pg.Dispose();
}

[Fact]
public void Ctor_AppliesTimeoutToOwnedClient()
{
var config = new PostGuardConfig
{
PkgUrl = ValidPkg,
CryptifyUrl = ValidCryptify,
Timeout = TimeSpan.FromSeconds(42),
};

using var pg = new PostGuard(config);

// No exception during construction; the Timeout property is applied
// internally — see PostGuard ctor. We can at least verify the SDK
// accepts the value without throwing.
Assert.NotNull(pg);
}
}