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
9 changes: 9 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ The Coder workspace ships the net10.0 SDK but not the net8.0 runtime: `dotnet te
## Pinned pg-ffi native binaries
`.github/pg-ffi-version` holds one line: the exact `encryption4all/postguard` release tag both workflows download the native libraries from. Bump that file to move to a newer release; don't reintroduce a "newest `pg-ffi-*` release" lookup, it changes what ships without a commit. The Dobby App cannot push `.github/workflows/`, so workflow edits have to go to a maintainer as a patch in a PR comment.

## Tracked public API surface
`src/PublicAPI.Shipped.txt` and `src/PublicAPI.Unshipped.txt` list every public member of `E4A.PostGuard`, checked by Microsoft.CodeAnalysis.PublicApiAnalyzers during `dotnet build` (no separate CI step).

- Changing the public surface without updating the files fails the build. Add new members to `PublicAPI.Unshipped.txt`; record a removal there as `*REMOVED*<the exact shipped line>`.
- To regenerate a line, build and copy the signature out of the `RS0016` message, which prints it in the file's own format (`Namespace.Type.Member(args) -> ret`). IDEs offer this as a code fix on the diagnostic.
- Severity is raised through `<WarningsAsErrors>` in `src/E4A.PostGuard.csproj`, not `.editorconfig`. RS0017 and friends are reported against `PublicAPI.Shipped.txt`, and path-based `.editorconfig` severity does not reach additional files, so it silently stays a warning there.
- Both TFMs currently produce the same surface (no `#if` in `src/`), so one pair of files covers them. If a member ever becomes TFM-conditional the files have to be split per TFM.
- At release time, move the `PublicAPI.Unshipped.txt` entries into `PublicAPI.Shipped.txt` (applying `*REMOVED*` lines as deletions) and leave the unshipped file with just its `#nullable enable` header. release-please does not do this.

## API layout
- `src/Api/PkgClient.cs`: PKG sign-key + MPK fetch.
- `src/Api/CryptifyClient.cs`: chunked Cryptify upload (init / store-chunk / finalize).
Expand Down
18 changes: 18 additions & 0 deletions src/E4A.PostGuard.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@
<RepositoryType>git</RepositoryType>
<PackageTags>ibe;encryption;postguard;identity-based-encryption</PackageTags>
<PackageReadmeFile>README.md</PackageReadmeFile>

<!--
The public surface of this package is a contract, so PublicApiAnalyzers findings have to
break the build instead of scrolling past as warnings. WarningsAsErrors is used rather than
an .editorconfig severity because RS0017 and friends are reported against
PublicAPI.Shipped.txt, and path-based .editorconfig severity does not reach additional files.
-->
<WarningsAsErrors>$(WarningsAsErrors);RS0016;RS0017;RS0024;RS0025;RS0036;RS0037;RS0041;RS0048;RS0050</WarningsAsErrors>
</PropertyGroup>

<ItemGroup>
Expand All @@ -28,6 +36,16 @@
<InternalsVisibleTo Include="E4A.PostGuard.Tests"/>
</ItemGroup>

<!-- Public API surface tracking: every public member must be listed in PublicAPI.*.txt -->
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.PublicApiAnalyzers" Version="5.6.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<AdditionalFiles Include="PublicAPI.Shipped.txt"/>
<AdditionalFiles Include="PublicAPI.Unshipped.txt"/>
</ItemGroup>

<!-- Native pg-ffi libraries: pack into runtimes/ and copy to output at runtime -->
<ItemGroup>
<None Include="runtimes/linux-x64/native/**" Pack="true" PackagePath="runtimes/linux-x64/native" Link="runtimes/linux-x64/native/%(Filename)%(Extension)"/>
Expand Down
101 changes: 101 additions & 0 deletions src/PublicAPI.Shipped.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
#nullable enable
E4A.PostGuard.Exceptions.NetworkException
E4A.PostGuard.Exceptions.NetworkException.Body.get -> string!
E4A.PostGuard.Exceptions.NetworkException.NetworkException(int statusCode, string! body, string! url) -> void
E4A.PostGuard.Exceptions.NetworkException.StatusCode.get -> int
E4A.PostGuard.Exceptions.NetworkException.Url.get -> string!
E4A.PostGuard.Exceptions.PostGuardException
E4A.PostGuard.Exceptions.PostGuardException.PostGuardException(string! message) -> void
E4A.PostGuard.Exceptions.PostGuardException.PostGuardException(string! message, System.Exception! inner) -> void
E4A.PostGuard.Exceptions.SealException
E4A.PostGuard.Exceptions.SealException.SealException(string! message) -> void
E4A.PostGuard.Models.ApiKeySign
E4A.PostGuard.Models.ApiKeySign.ApiKey.get -> string!
E4A.PostGuard.Models.EncryptInput
E4A.PostGuard.Models.EncryptInput.EncryptInput() -> void
E4A.PostGuard.Models.EncryptInput.Files.get -> System.Collections.Generic.IReadOnlyList<E4A.PostGuard.Models.PgFile!>!
E4A.PostGuard.Models.EncryptInput.Files.init -> void
E4A.PostGuard.Models.EncryptInput.Recipients.get -> System.Collections.Generic.IReadOnlyList<E4A.PostGuard.Models.RecipientBuilder!>!
E4A.PostGuard.Models.EncryptInput.Recipients.init -> void
E4A.PostGuard.Models.EncryptInput.Sign.get -> E4A.PostGuard.Models.SignMethod!
E4A.PostGuard.Models.EncryptInput.Sign.init -> void
E4A.PostGuard.Models.NotifyOptions
E4A.PostGuard.Models.NotifyOptions.Language.get -> string!
E4A.PostGuard.Models.NotifyOptions.Language.init -> void
E4A.PostGuard.Models.NotifyOptions.Message.get -> string?
E4A.PostGuard.Models.NotifyOptions.Message.init -> void
E4A.PostGuard.Models.NotifyOptions.NotifyOptions() -> void
E4A.PostGuard.Models.NotifyOptions.Recipients.get -> bool
E4A.PostGuard.Models.NotifyOptions.Recipients.init -> void
E4A.PostGuard.Models.NotifyOptions.Sender.get -> bool
E4A.PostGuard.Models.NotifyOptions.Sender.init -> void
E4A.PostGuard.Models.PgFile
E4A.PostGuard.Models.PgFile.Content.get -> System.IO.Stream!
E4A.PostGuard.Models.PgFile.Content.init -> void
E4A.PostGuard.Models.PgFile.Deconstruct(out string! Name, out System.IO.Stream! Content) -> void
E4A.PostGuard.Models.PgFile.Name.get -> string!
E4A.PostGuard.Models.PgFile.Name.init -> void
E4A.PostGuard.Models.PgFile.PgFile(E4A.PostGuard.Models.PgFile! original) -> void
E4A.PostGuard.Models.PgFile.PgFile(string! Name, System.IO.Stream! Content) -> void
E4A.PostGuard.Models.RecipientBuilder
E4A.PostGuard.Models.RecipientBuilder.Email.get -> string!
E4A.PostGuard.Models.RecipientBuilder.ExtraAttribute(string! type, string! value) -> E4A.PostGuard.Models.RecipientBuilder!
E4A.PostGuard.Models.SignMethod
E4A.PostGuard.Models.UploadOptions
E4A.PostGuard.Models.UploadOptions.Notify.get -> E4A.PostGuard.Models.NotifyOptions?
E4A.PostGuard.Models.UploadOptions.Notify.init -> void
E4A.PostGuard.Models.UploadOptions.UploadOptions() -> void
E4A.PostGuard.Models.UploadResult
E4A.PostGuard.Models.UploadResult.Deconstruct(out string! Uuid) -> void
E4A.PostGuard.Models.UploadResult.UploadResult(E4A.PostGuard.Models.UploadResult! original) -> void
E4A.PostGuard.Models.UploadResult.UploadResult(string! Uuid) -> void
E4A.PostGuard.Models.UploadResult.Uuid.get -> string!
E4A.PostGuard.Models.UploadResult.Uuid.init -> void
E4A.PostGuard.PostGuard
E4A.PostGuard.PostGuard.Dispose() -> void
E4A.PostGuard.PostGuard.Encrypt(E4A.PostGuard.Models.EncryptInput! input) -> E4A.PostGuard.Sealed!
E4A.PostGuard.PostGuard.PostGuard(E4A.PostGuard.PostGuardConfig! config) -> void
E4A.PostGuard.PostGuard.Recipient.get -> E4A.PostGuard.RecipientBuilders!
E4A.PostGuard.PostGuard.Sign.get -> E4A.PostGuard.SignBuilders!
E4A.PostGuard.PostGuardConfig
E4A.PostGuard.PostGuardConfig.AllowInsecureUrls.get -> bool
E4A.PostGuard.PostGuardConfig.AllowInsecureUrls.init -> void
E4A.PostGuard.PostGuardConfig.CryptifyUrl.get -> string!
E4A.PostGuard.PostGuardConfig.CryptifyUrl.init -> void
E4A.PostGuard.PostGuardConfig.Headers.get -> System.Collections.Generic.Dictionary<string!, string!>?
E4A.PostGuard.PostGuardConfig.Headers.init -> void
E4A.PostGuard.PostGuardConfig.HttpClient.get -> System.Net.Http.HttpClient?
E4A.PostGuard.PostGuardConfig.HttpClient.init -> void
E4A.PostGuard.PostGuardConfig.PkgUrl.get -> string!
E4A.PostGuard.PostGuardConfig.PkgUrl.init -> void
E4A.PostGuard.PostGuardConfig.PostGuardConfig() -> void
E4A.PostGuard.PostGuardConfig.Timeout.get -> System.TimeSpan?
E4A.PostGuard.PostGuardConfig.Timeout.init -> void
E4A.PostGuard.RecipientBuilders
E4A.PostGuard.RecipientBuilders.Email(string! email) -> E4A.PostGuard.Models.RecipientBuilder!
E4A.PostGuard.RecipientBuilders.EmailDomain(string! email) -> E4A.PostGuard.Models.RecipientBuilder!
E4A.PostGuard.RecipientBuilders.RecipientBuilders() -> void
E4A.PostGuard.Sealed
E4A.PostGuard.Sealed.ToBytesAsync(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<byte[]!>!
E4A.PostGuard.Sealed.UploadAsync(E4A.PostGuard.Models.UploadOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<E4A.PostGuard.Models.UploadResult!>!
E4A.PostGuard.SignBuilders
E4A.PostGuard.SignBuilders.ApiKey(string! apiKey) -> E4A.PostGuard.Models.ApiKeySign!
E4A.PostGuard.SignBuilders.SignBuilders() -> void
override E4A.PostGuard.Models.PgFile.Equals(object? obj) -> bool
override E4A.PostGuard.Models.PgFile.GetHashCode() -> int
override E4A.PostGuard.Models.PgFile.ToString() -> string!
override E4A.PostGuard.Models.UploadResult.Equals(object? obj) -> bool
override E4A.PostGuard.Models.UploadResult.GetHashCode() -> int
override E4A.PostGuard.Models.UploadResult.ToString() -> string!
static E4A.PostGuard.Models.PgFile.operator !=(E4A.PostGuard.Models.PgFile? left, E4A.PostGuard.Models.PgFile? right) -> bool
static E4A.PostGuard.Models.PgFile.operator ==(E4A.PostGuard.Models.PgFile? left, E4A.PostGuard.Models.PgFile? right) -> bool
static E4A.PostGuard.Models.UploadResult.operator !=(E4A.PostGuard.Models.UploadResult? left, E4A.PostGuard.Models.UploadResult? right) -> bool
static E4A.PostGuard.Models.UploadResult.operator ==(E4A.PostGuard.Models.UploadResult? left, E4A.PostGuard.Models.UploadResult? right) -> bool
virtual E4A.PostGuard.Models.PgFile.<Clone>$() -> E4A.PostGuard.Models.PgFile!
virtual E4A.PostGuard.Models.PgFile.EqualityContract.get -> System.Type!
virtual E4A.PostGuard.Models.PgFile.Equals(E4A.PostGuard.Models.PgFile? other) -> bool
virtual E4A.PostGuard.Models.PgFile.PrintMembers(System.Text.StringBuilder! builder) -> bool
virtual E4A.PostGuard.Models.UploadResult.<Clone>$() -> E4A.PostGuard.Models.UploadResult!
virtual E4A.PostGuard.Models.UploadResult.EqualityContract.get -> System.Type!
virtual E4A.PostGuard.Models.UploadResult.Equals(E4A.PostGuard.Models.UploadResult? other) -> bool
virtual E4A.PostGuard.Models.UploadResult.PrintMembers(System.Text.StringBuilder! builder) -> bool
2 changes: 2 additions & 0 deletions src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#nullable enable
*REMOVED*E4A.PostGuard.Exceptions.NetworkException.Body.get -> string!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not introduced by this PR, and no change needed here, but this file is what makes it visible.

The *REMOVED* entry formally records that the pending release drops a public property. Release PR #38 is open and cuts E4A.PostGuard 0.5.1, a patch bump, because #43 landed as fix:. Publishing a patch that removes a public member breaks consumers on dotnet restore with nothing in the version number to signal it.

Worth deciding before #38 merges: re-cut as 0.6.0, or accept it as pre-1.0 latitude.

117 changes: 117 additions & 0 deletions tests/E4A.PostGuard.Tests/PublicApiTrackingTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
using System.Reflection;
using System.Xml.Linq;

namespace E4A.PostGuard.Tests;

/// <summary>
/// The public surface is tracked in <c>src/PublicAPI.Shipped.txt</c> and
/// <c>src/PublicAPI.Unshipped.txt</c> and guarded by Microsoft.CodeAnalysis.PublicApiAnalyzers.
/// The analyzer only guards what it is wired up to see, so these tests cover the wiring itself
/// and check the tracked types against the assembly that ships.
/// </summary>
public class PublicApiTrackingTests
{
[Fact]
public void EveryExportedType_IsDeclaredInThePublicApiFiles()
{
var declared = DeclaredEntries();
var declaredTypes = declared.Where(entry => !entry.Contains("->")).ToHashSet(StringComparer.Ordinal);

var exported = typeof(PostGuard).Assembly.GetExportedTypes()
.Select(DeclaredName)
.ToHashSet(StringComparer.Ordinal);

var undeclared = exported.Except(declaredTypes).Order(StringComparer.Ordinal).ToArray();
var stale = declaredTypes.Except(exported).Order(StringComparer.Ordinal).ToArray();

Assert.True(
undeclared.Length == 0,
$"public types missing from the PublicAPI files: {string.Join(", ", undeclared)}");
Assert.True(
stale.Length == 0,
$"PublicAPI files declare types the assembly no longer exports: {string.Join(", ", stale)}");
}

[Theory]
[InlineData("PublicAPI.Shipped.txt")]
[InlineData("PublicAPI.Unshipped.txt")]
public void ApiFile_TracksNullability(string fileName)
{
// Without the header the analyzer records every reference type as oblivious, so a
// nullable-annotation change would slip through unreviewed. Dropping it from the
// unshipped file still builds green, so nothing but this test catches that.
var lines = File.ReadAllLines(Path.Combine(RepoRoot(), "src", fileName));
Assert.Equal("#nullable enable", lines.FirstOrDefault());
}

[Fact]
public void Csproj_KeepsTheAnalyzerWiredUpAndFailingTheBuild()
{
// Parsed rather than substring-matched: reformatting an element, or moving
// WarningsAsErrors into a second PropertyGroup, leaves the wiring fully intact
// and must not red-light this test.
var csproj = XDocument.Load(Path.Combine(RepoRoot(), "src", "E4A.PostGuard.csproj"));

Assert.Contains("Microsoft.CodeAnalysis.PublicApiAnalyzers", Includes(csproj, "PackageReference"));
Assert.Contains("PublicAPI.Shipped.txt", Includes(csproj, "AdditionalFiles"));
Assert.Contains("PublicAPI.Unshipped.txt", Includes(csproj, "AdditionalFiles"));

// Left as warnings, an undeclared API change scrolls past in a green build.
var warningsAsErrors = string.Join(';', csproj.Descendants("WarningsAsErrors").Select(element => element.Value));
Assert.Contains("RS0016", warningsAsErrors, StringComparison.Ordinal);
Assert.Contains("RS0017", warningsAsErrors, StringComparison.Ordinal);
}

/// <summary>The <c>Include</c> attribute of every <paramref name="elementName"/> item in the project.</summary>
private static string[] Includes(XDocument csproj, string elementName) =>
csproj.Descendants(elementName)
.Select(element => (string?)element.Attribute("Include"))
.OfType<string>()
.ToArray();

/// <summary>Shipped plus unshipped additions, minus the entries marked <c>*REMOVED*</c>.</summary>
private static HashSet<string> DeclaredEntries()
{
const string removedPrefix = "*REMOVED*";
var root = Path.Combine(RepoRoot(), "src");

var entries = new[] { "PublicAPI.Shipped.txt", "PublicAPI.Unshipped.txt" }
.SelectMany(name => File.ReadAllLines(Path.Combine(root, name)))
.Select(line => line.Trim())
.Where(line => line.Length > 0 && !line.StartsWith('#'))
.ToArray();

var declared = entries
.Where(line => !line.StartsWith(removedPrefix, StringComparison.Ordinal))
.ToHashSet(StringComparer.Ordinal);

foreach (var removed in entries.Where(line => line.StartsWith(removedPrefix, StringComparison.Ordinal)))
{
declared.Remove(removed[removedPrefix.Length..]);
}

return declared;
}

/// <summary>The name a type is written under in the PublicAPI files.</summary>
private static string DeclaredName(Type type)
{
var name = type.FullName!.Replace('+', '.');
var arity = name.IndexOf('`');
return arity < 0
? name
: $"{name[..arity]}<{string.Join(", ", type.GetGenericArguments().Select(argument => argument.Name))}>";
}

private static string RepoRoot()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "E4A.PostGuard.slnx")))
{
dir = dir.Parent;
}

Assert.NotNull(dir);
return dir!.FullName;
}
}