diff --git a/CLAUDE.md b/CLAUDE.md index afbe431..24b2d4e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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*`. +- 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 `` 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). diff --git a/src/E4A.PostGuard.csproj b/src/E4A.PostGuard.csproj index 5eedadf..8d61866 100644 --- a/src/E4A.PostGuard.csproj +++ b/src/E4A.PostGuard.csproj @@ -18,6 +18,14 @@ git ibe;encryption;postguard;identity-based-encryption README.md + + + $(WarningsAsErrors);RS0016;RS0017;RS0024;RS0025;RS0036;RS0037;RS0041;RS0048;RS0050 @@ -28,6 +36,16 @@ + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + diff --git a/src/PublicAPI.Shipped.txt b/src/PublicAPI.Shipped.txt new file mode 100644 index 0000000..5840fe5 --- /dev/null +++ b/src/PublicAPI.Shipped.txt @@ -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.EncryptInput.Files.init -> void +E4A.PostGuard.Models.EncryptInput.Recipients.get -> System.Collections.Generic.IReadOnlyList! +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? +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! +E4A.PostGuard.Sealed.UploadAsync(E4A.PostGuard.Models.UploadOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +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.$() -> 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.$() -> 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 diff --git a/src/PublicAPI.Unshipped.txt b/src/PublicAPI.Unshipped.txt new file mode 100644 index 0000000..0dfd81c --- /dev/null +++ b/src/PublicAPI.Unshipped.txt @@ -0,0 +1,2 @@ +#nullable enable +*REMOVED*E4A.PostGuard.Exceptions.NetworkException.Body.get -> string! diff --git a/tests/E4A.PostGuard.Tests/PublicApiTrackingTests.cs b/tests/E4A.PostGuard.Tests/PublicApiTrackingTests.cs new file mode 100644 index 0000000..a8c1ed1 --- /dev/null +++ b/tests/E4A.PostGuard.Tests/PublicApiTrackingTests.cs @@ -0,0 +1,117 @@ +using System.Reflection; +using System.Xml.Linq; + +namespace E4A.PostGuard.Tests; + +/// +/// The public surface is tracked in src/PublicAPI.Shipped.txt and +/// src/PublicAPI.Unshipped.txt 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. +/// +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); + } + + /// The Include attribute of every item in the project. + private static string[] Includes(XDocument csproj, string elementName) => + csproj.Descendants(elementName) + .Select(element => (string?)element.Attribute("Include")) + .OfType() + .ToArray(); + + /// Shipped plus unshipped additions, minus the entries marked *REMOVED*. + private static HashSet 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; + } + + /// The name a type is written under in the PublicAPI files. + 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; + } +}