fix(management): close the review findings in the pipe contract and the log's redaction (#51)

This commit was merged in pull request #51.
This commit is contained in:
james.maes committed 2026-11-11 06:22:57 +00:00
1 parent db45420b14
commit 4e01519556
50 files changed
+3657 -454

No files matched your search

+61
View File
@@ -2,6 +2,67 @@
## Unreleased
Version 2 of the management pipe: the contract's gaps are closed (2.0). The contract is a set of types that
nothing serves or sends yet, so none of the first seven items changes what the installed product does; they change
what the service and its clients will be able to build on. The last two are about logging.
- **Error messages.** An error carries a message that is printable and at most 512 UTF-16 units, however the reply
is built and however it is read; this used to hold only for replies built by two functions. A cut adds no mark
(a message could be 515 units, and an identity value 203), a value that is missing is absent instead of the words
"(missing)", and a failure with no message cannot be built.
- **Error codes.** A failure can be built only with a code from the contract's table and with exactly the details
that table names for the code. A client still reads a code it does not know.
- **The envelope.** A request or a reply is read only by the contract's two parsing functions and written only by
its writing function. The JSON serializer refuses both under the default options and under any options that do
not bring a converter of their own for the envelope; it used to refuse reading for the contract's own options
only. A request and a reply are built only by functions that check them: a request names its operation by a
well-formed name and a server by its full identifier, and a reply holds exactly one of a result and an error.
The text on the wire is unchanged. The parsing functions no longer throw
for a text that holds a surrogate without its partner, as itself or as an escape (`\ud800` on its own): it is
refused like anything else that is wrong in the same place.
- **Accounts.** The principal and the tenant are carried one way wherever a reply names an account (a snapshot,
the result of adding a server or signing in, the details of a changed identity): printable, at most 200 units,
left out when missing. The message beside an account is printable and at most 512 units, like an error's.
- **Server names and addresses.** A name is 1 to 64 characters of visible text after trimming, of which at least
one draws something (a name made only of spaces, combining marks and characters that draw nothing, such as
U+3164, is refused), without square brackets (ASCII or fullwidth), and not shaped like a server identifier or the
first eight or more characters of one. `servers.add` and
`servers.rename` refuse any other name as `invalid_request`. The rule is one function for the service and the
companion, it says which part a name breaks, and it proposes a name from a server's address that always passes
(the host; "Server" in front of a host that looks like an identifier). The rule for an address is beside it:
HTTPS, or plain HTTP for this computer itself, with no user name, query, fragment or path, and a host that has
an ASCII name and is not an IPv6 literal with a zone (the stored form of an address cannot hold the zone, so two
zones would be one server). The existing checks of an address are unchanged.
- **Adding a server, and switching the certificate exception on.** `servers.add` and `trust.set` carry the sign-in
provider's address that `servers.check` reported, for comparison only; leaving it out, or giving null, says that
the check named no provider, and text that names no address is refused as `invalid_request`. When the service
reads another provider from the server, or one where the check named none, or none where it named one, nothing
is stored or changed and the answer is the new error code `provider_changed`; the client checks the server
again.
- **Printer discovery.** The operation table marks `printers.discover` as single-flight: one discovery runs at a
time, and a request that arrives during it shares its result. The discovery itself is built with the pipe.
- **Log redaction recognises more shapes.** As with the two shapes below, this changes what a log line shows only
for a line that holds one of them, and no line the product writes today does. The named values,
whose value is replaced by `[REDACTED]` while the name stays, now include every name under which the product's
own requests and responses carry a secret. `enrollmentCode`, `deviceToken` and the `X-QQQ-Device-Token` header
were covered; added are a session response's `uuid`, `sessionUUID`, `sessionId`, the code verifier and the
ID-token hint in every spelling (`codeVerifier`, `code_verifier`, `code-verifier`; `idTokenHint`,
`id_token_hint`, `id-token-hint`), and `code` in a message that also names a code verifier. Each named value is
recognised as a member of a JSON object, as `name=value` or `name: value`, and now also with the equals sign
percent-encoded (`name%3Dvalue`). A name that is itself percent-encoded, the rest of a value after a comma, a
semicolon or a space, and a nonce are not covered. An `Authorization:` or `Proxy-Authorization:` header line
loses its whole value to the end of the line,
whatever its scheme; a line that began with `Authorization: Bearer` and went on with other text used to keep
that text. A callback's `code` and `state` are also redacted when the value is quoted, when the callback is
percent-encoded inside another address, and when its ampersands are written `&`. A cookie or authorization
header is also recognised when its value stands on the next line, under the names `HTTP_COOKIE` and
`HTTP_AUTHORIZATION`, with a no-break or zero-width space before the colon, and, for the authorization header,
with an equals sign in place of the colon. What is still not recognised, and what is hidden although it is no
secret, is listed in audit W41 and held by tests.
- **Service log.** The entries the service keeps in memory are stamped by the service's own clock, and a
per-server view of the log, which nothing uses yet, would prefix a server's lines with the first eight characters
of its identifier, never with its name.
One printer whose driver does not answer no longer stops jobs for the other printers (2.0). What such a printer
still affects is listed at the end of this entry; one case is not covered at all, a printer reached through a
print server that is switched off or unreachable. Until now the service asked Windows about every printer
+20 -15
View File
@@ -197,23 +197,23 @@ Found by the reviews of the legacy polling removal, the enrollment fix, the desi
| W30 | Nit | Code that only the removed legacy mode and the removed polling path reached still ships in Core and Windows: the bearer-token transport, the in-memory download and submit, the HTTP fallbacks for a missing realtime channel | Closed by pull request 35 (`b868e9e`): the bearer-token transport, the in-memory download and submit and the HTTP fallbacks are deleted; the API client is built only from a signed-in session |
| C13 | Low | A claim nested past 1,024 levels cannot be identified, so it is never refused and stops the lane. The server's 2,000-character cap on options cannot produce one; a depth cap on the server would be the durable fix | Open (server) |
Found by the retrospective reviews of 2026-11-10 and by the review of the printing seams step, or seen while verifying. Pull requests 28, 29, 35, 37 and 38 had merged with a final fix round their reviewer had not seen, and pull requests 30 and 31 with no review (ADR-0004, "About the process"). Review A covered pull requests 37, 38, 31 and 30; review B covered the last fix rounds of pull requests 29, 35 and 28; both read `release/2.0` at `c3efed4`. Neither found a blocker. The printing seams step merged afterwards as pull request 42 (`e2ca086`). The review of its first head found five pieces of older behavior (W44 to W46, W57 and W58); the step changed nothing that reaches Windows, so all five are in `release/2.0` as they were. Every citation below was read at `6f0237d` and holds at `e2ca086`. Pull requests 40 and 41 changed none of the source files cited by line. Pull request 42 rewrote `WindowsPrinters*.cs` and `PrinterWorkerHost.cs`, so the rows that cite them (W44, W46, W48, W57, W58) were given the line numbers of `e2ca086`, as the two `CHANGELOG.md` citations carry those of `077148f`, which pull request 42 did not move. Some of those rows have since been brought up to a later tree; the end of this paragraph says which. Severity is the reviewer's. "Not rated" marks an item that no reviewer has rated. Review A's findings about documents (its 14 to 21) are answered in ADR-0004, "Resolved after the retrospective reviews", and by the status corrections in this file. Review B's finding 8 is W26. C22, C23 and W49 to W55 are the nits left open by the reviews of pull requests 40 and 41, which merged afterwards with the reviewed head and the verdict in their descriptions. Those descriptions name no places; where a row gives one, it was found by reading the code. W56 to W58 and C24 were added in the fix round after the review of the change that wrote this table. W59 to W64 are what pull request 42 left open that had no row; C25 and W65 are two further places with the effect of W44. W66 to W69 were found while W44 was fixed, were not there before as rows, and no reviewer has rated them; each says whether it was tested or only read. W70 to W72 come from the review of that fix (no blocker, 3 should-fix, 9 nits): its should-fix S1 and its nits 2 and 9, with the reviewer's severity. That fix, the change that gave a printer backend three reads, moved lines in `IsolatedPrinterBackend.cs`, `WindowsPrinters.cs`, `WindowsPrinters.Files.cs`, `PrinterWorkerHost.cs` and the worker fixture's `Program.cs`. The open rows that cite those files by line (W45, W46, W48 and W57 to W61) therefore carry the line numbers of that change's tree, the head its review's fix round ended on, and no longer those of `e2ca086`; so do W70, W71 and W73. Only the numbers were changed in those rows. The closed rows W44 and C25, and W65, keep the numbers of `e2ca086`, at which they say they were read. C24 is not touched: its numbers were already behind `release/2.0` before that change. W73 comes from the confirmation review of that fix round (no blocker, 1 should-fix, 4 nits): its nit 1.
Found by the retrospective reviews of 2026-11-10 and by the review of the printing seams step, or seen while verifying. Pull requests 28, 29, 35, 37 and 38 had merged with a final fix round their reviewer had not seen, and pull requests 30 and 31 with no review (ADR-0004, "About the process"). Review A covered pull requests 37, 38, 31 and 30; review B covered the last fix rounds of pull requests 29, 35 and 28; both read `release/2.0` at `c3efed4`. Neither found a blocker. The printing seams step merged afterwards as pull request 42 (`e2ca086`). The review of its first head found five pieces of older behavior (W44 to W46, W57 and W58); the step changed nothing that reaches Windows, so all five are in `release/2.0` as they were. Every citation below was read at `6f0237d` and holds at `e2ca086`. Pull requests 40 and 41 changed none of the source files cited by line. Pull request 42 rewrote `WindowsPrinters*.cs` and `PrinterWorkerHost.cs`, so the rows that cite them (W44, W46, W48, W57, W58) were given the line numbers of `e2ca086`, as the two `CHANGELOG.md` citations carry those of `077148f`, which pull request 42 did not move. Some of those rows have since been brought up to a later tree; the end of this paragraph says which. Severity is the reviewer's. "Not rated" marks an item that no reviewer has rated. Review A's findings about documents (its 14 to 21) are answered in ADR-0004, "Resolved after the retrospective reviews", and by the status corrections in this file. Review B's finding 8 is W26. C22, C23 and W49 to W55 are the nits left open by the reviews of pull requests 40 and 41, which merged afterwards with the reviewed head and the verdict in their descriptions. Those descriptions name no places; where a row gives one, it was found by reading the code. W56 to W58 and C24 were added in the fix round after the review of the change that wrote this table. W59 to W64 are what pull request 42 left open that had no row; C25 and W65 are two further places with the effect of W44. W66 to W69 were found while W44 was fixed, were not there before as rows, and no reviewer has rated them; each says whether it was tested or only read. W70 to W72 come from the review of that fix (no blocker, 3 should-fix, 9 nits): its should-fix S1 and its nits 2 and 9, with the reviewer's severity. That fix, the change that gave a printer backend three reads, moved lines in `IsolatedPrinterBackend.cs`, `WindowsPrinters.cs`, `WindowsPrinters.Files.cs`, `PrinterWorkerHost.cs` and the worker fixture's `Program.cs`. The open rows that cite those files by line (W45, W46, W48 and W57 to W61) therefore carry the line numbers of that change's tree, the head its review's fix round ended on, and no longer those of `e2ca086`; so do W70, W71 and W73. Only the numbers were changed in those rows. The closed rows W44 and C25, and W65, keep the numbers of `e2ca086`, at which they say they were read. C24 is not touched: its numbers were already behind `release/2.0` before that change. W73 comes from the confirmation review of that fix round (no blocker, 1 should-fix, 4 nits): its nit 1. "The contract and redaction fixes that followed the retrospective review" in a status below is one change, merged as one pull request: the fixes to the version 2 pipe contract and to the log's redaction that closed W31 to W42, C14 and W56, together with the fix round after their own review. W74 and the rows after it were found by that review, which found no blocker; what it found in rows that already existed is recorded in their statuses.
| ID | Severity | Finding | Status |
| --- | --- | --- | --- |
| W31 | Should-fix | The bound and the printable form of an error's message are applied by the two `ManagementReply.Failure` factories and nowhere else. A reply built through the public record constructors or a `with` expression is written as given (`ManagementJson.cs:436-443`, `ManagementReply.cs:17`, `ManagementError.cs:17`), and `ManagementOperations.Check` returns a bare `ManagementError` that no factory takes (`ManagementOperations.cs:235-257`). A probe wrote, and read back whole, a message of 2,028 units holding U+202E and a newline. ADR-0004 says the wire enforces the form (review A, 1) | Open; to close before anything serves the contract |
| W32 | Should-fix | `Failure<TDetails>` accepts any details record for any code, and both factories accept any text as a code (`ManagementReply.cs:33-40`). Details written that way are then read by the client through the private constructor that stores what the wire carried (`IdentityChangedDetails.cs:23-28`), unbounded; `busy` can be sent with details although its table entry gives it none (review A, 2) | Open; to close before anything serves the contract |
| W33 | Should-fix | The four envelope types refuse the serializer only when it is given one of the contract's own two options objects: the refusing converters are added to those options and not declared on the types (`ManagementJson.cs:178-181`). With default or web options an envelope is read with no envelope check, and the test tries only the contract's options (`ManagementEnvelopeTests.cs:688`). ADR-0004, `ManagementJson.cs:26-27` and `docs/DEVELOPMENT.md:20` say the refusal cannot be walked around. Nothing in production reads an envelope yet (review A, 3) | Open; to close before anything serves the contract |
| W34 | Should-fix | Core's printable-text function was written for sentence fragments and is used as the form of typed wire members (`QqqCompatibilityProblem.cs:67-79`, called at `ManagementReply.cs:50` and `IdentityChangedDetails.cs:44`). A missing identity value and an empty message are sent as the text `(missing)`, which a client cannot tell from a value; a cut value carries a three-character mark, so a message can be 515 units and an identity value 203 where ADR-0004 says 512 and 200; and applying the function twice changes the text (review A, 4) | Open |
| W35 | Should-fix | The same identity values are treated two ways. `IdentityChangedDetails` cuts the principal and the tenant at 200 units and makes them printable; the snapshot's authentication status carries them as Core holds them (`AgentAuthenticationStatus.cs:9`, inside `ServerSnapshot`). Core admits identity values of up to 1,024 units and rejects only control characters (`QqqSessionClient.cs:930-933`), so U+202E and U+2028 pass, while `ManagementProtocol.cs:47-48` says the QQQ contract limits such a value to 200. The same question is open for the other text a server can influence in results: status, printer sync detail, last command and outcome, job titles and messages (review A, 5) | Open |
| W36 | Should-fix | `printers.discover` is a read (`ManagementOperations.cs:192`) and reads never wait, but it runs a driver discovery in a helper process, and nothing said whether concurrent requests share one. A hung driver and a client that retries could hold several pipe instances for 30 seconds each (review A, 6) | Open. Decided in ADR-0004 (single-flight); to be built with the pipe server |
| C14 | Nit | The printable-text function is a public static on an authentication record (`QqqCompatibilityProblem.cs:67`), and the pipe contract depends on that type for the form of its messages. Its default limit is an internal constant of 80 in a public signature, and its comment says every character that is not visible becomes a space although other space separators (U+00A0, U+3000) are kept (review A, 7) | Open |
| W37 | Nit | The walk that refuses a null inside a known list handles lists and records only (`ManagementJson.cs:384-393`); a dictionary-valued member and a nullable-struct member are not walked. No contract type has either today (review A, 8) | Open |
| W38 | Nit | Smaller gaps in the contract. `ManagementPayload.From` accepts a value that is not an object (`ManagementPayload.cs:33-35`), which `ParseReply` then reports as an incomplete reply. No test puts a repeated member through `ParseReply`, although it is refused. A reply with `ok: true`, a result and an error is accepted as a success and the error is dropped (review A, 9) | Open |
| W39 | Should-fix | The service log's per-server view takes any string as the server's name (`ServiceLog.cs:152-159`), and the contract takes any string as a name (`ServersAddArgs.cs:16`, `ServersRenameArgs.cs:9`). The activity log file redacts the joined line a second time, so a name holding a credential keyword hides that server's lines: a server named `Cookie:` is written as `[Service] [Cookie:[REDACTED]`. Square brackets in a name forge structure inside a line. The view's own character rule is a second, weaker copy of Core's (UTF-16 units, four categories, a cut at 64 that can split a surrogate pair), although its comment says it is Core's rule (`ServiceLog.cs:164-170`). Nothing calls the view yet (review A, 10) | Open. The name rule is decided in ADR-0004 ("Server names") and must be in force before the view is used |
| W40 | Should-fix | Redaction gaps (`AgentActivityLog.cs:122-156`): `id_token_hint=`, an `Authorization:` header of a scheme other than Bearer and `code_verifier=` pass unchanged, and so do bare `sessionUUID=` and `sessionId=`. The review traced that no log line can carry any of them today; they are values the product handles, and redaction is the second line of defense (review A, 11) | Open |
| W41 | Nit | Redaction edges (`AgentActivityLog.cs:144`, `:155`). Over-redaction: a line with "cookie:" as ordinary words loses the rest of the line, and `?state=QUEUED` or `?code=E1234` in a logged address loses its value. Under-redaction: a quoted callback value, a folded cookie header, a cookie header with other spacing, and a percent-encoded `code%3D`. No line the product writes today has these shapes (review A, 12) | Open |
| W42 | Nit | Three small items in the log. The runtime does not pass its time provider to `ServiceLog` (`AgentRuntime.cs:27`), so the ring's stamps read the system clock (the direction of W14). `ServiceLogTests.cs:626` uses a 200 ms real wait as an assertion, a timed wait of the kind W28 counts. The summary of `ServiceLog` says the ring is what the companion shows (`ServiceLog.cs:12-14`), which it is not (review A, 13) | Open |
| W31 | Should-fix | The bound and the printable form of an error's message are applied by the two `ManagementReply.Failure` factories and nowhere else. A reply built through the public record constructors or a `with` expression is written as given (`ManagementJson.cs:436-443`, `ManagementReply.cs:17`, `ManagementError.cs:17`), and `ManagementOperations.Check` returns a bare `ManagementError` that no factory takes (`ManagementOperations.cs:235-257`). A probe wrote, and read back whole, a message of 2,028 units holding U+202E and a newline. ADR-0004 says the wire enforces the form (review A, 1) | Closed by the contract and redaction fixes that followed the retrospective review (2026-11-10): the message is given its wire form in `ManagementError` itself, which has no member that can be set afterwards. The two factories, `ManagementOperations.Check` and the parser all build through it, for an error that is written and for one that is read, and `ManagementReply.Failure` takes an existing error, so the routing refusal needs no reply built by hand |
| W32 | Should-fix | `Failure<TDetails>` accepts any details record for any code, and both factories accept any text as a code (`ManagementReply.cs:33-40`). Details written that way are then read by the client through the private constructor that stores what the wire carried (`IdentityChangedDetails.cs:23-28`), unbounded; `busy` can be sent with details although its table entry gives it none (review A, 2) | Closed by the contract and redaction fixes that followed the retrospective review (2026-11-10): the service builds an error through the constructor, for a code that carries no details, or through `ManagementError.WithDetails`, which takes exactly the record the table names for the code. Text that is not in `ManagementErrorCode.All`, details for a code without any, and a missing or foreign details record are refused. Reading stays lenient, so a client still reads a code a newer service added |
| W33 | Should-fix | The four envelope types refuse the serializer only when it is given one of the contract's own two options objects: the refusing converters are added to those options and not declared on the types (`ManagementJson.cs:178-181`). With default or web options an envelope is read with no envelope check, and the test tries only the contract's options (`ManagementEnvelopeTests.cs:688`). ADR-0004, `ManagementJson.cs:26-27` and `docs/DEVELOPMENT.md:20` say the refusal cannot be walked around. Nothing in production reads an envelope yet (review A, 3) | Closed by the contract and redaction fixes that followed the retrospective review (2026-11-10): the four envelope types name a refusing converter in an attribute on the type, so the serializer neither reads nor writes them under the default options or any options object that does not bring a converter of its own for the type; `ManagementJson.Write` writes an envelope directly, as the same text as before. The test tries the default, web, indenting and case-insensitive options and the contract's own two. The refusal does not hold against options that carry a converter written for an envelope type, which the serializer asks before the type; that takes a converter written on purpose, nothing in the product has one, and a test holds where the refusal ends (review of the contract and redaction fixes, nit 4) |
| W34 | Should-fix | Core's printable-text function was written for sentence fragments and is used as the form of typed wire members (`QqqCompatibilityProblem.cs:67-79`, called at `ManagementReply.cs:50` and `IdentityChangedDetails.cs:44`). A missing identity value and an empty message are sent as the text `(missing)`, which a client cannot tell from a value; a cut value carries a three-character mark, so a message can be 515 units and an identity value 203 where ADR-0004 says 512 and 200; and applying the function twice changes the text (review A, 4) | Closed by the contract and redaction fixes that followed the retrospective review (2026-11-10): the contract has its own rule for text on the wire, `ManagementText` (ADR-0004, "Text on the wire"). A cut keeps 512 and 200 exactly and adds no mark; the rule applied twice changes nothing; a missing identity value is absent (the two members of `IdentityChangedDetails` may be null and are then left out); and an error with nothing to show cannot be built. A client still reads a reply whose message is empty |
| W35 | Should-fix | The same identity values are treated two ways. `IdentityChangedDetails` cuts the principal and the tenant at 200 units and makes them printable; the snapshot's authentication status carries them as Core holds them (`AgentAuthenticationStatus.cs:9`, inside `ServerSnapshot`). Core admits identity values of up to 1,024 units and rejects only control characters (`QqqSessionClient.cs:930-933`), so U+202E and U+2028 pass, while `ManagementProtocol.cs:47-48` says the QQQ contract limits such a value to 200. The same question is open for the other text a server can influence in results: status, printer sync detail, last command and outcome, job titles and messages (review A, 5) | Partly closed by the contract and redaction fixes that followed the retrospective review (2026-11-10): the snapshot, the result of `servers.add` and the result of `session.signIn` name the account through a record of the contract, `ServerAuthentication`, which gives the principal and the tenant the form `IdentityChangedDetails` gives them, when it is built and when it is read; a test fails if a reply names an account through any other record. Since the fix round after the review of those fixes, the message beside the account (`ServerAuthentication.Message`), which was held as given when built and when read, has the form of an error's message (printable, at most 512 units) in both directions. The comment that claimed a limit of 200 in the QQQ contract says what the session client admits. Open: Core still admits an identity value of up to 1,024 units and excludes only control characters, so the version 1 snapshot carries such a value as before; and the other text a server can influence in results is decided when the handlers are written |
| W36 | Should-fix | `printers.discover` is a read (`ManagementOperations.cs:192`) and reads never wait, but it runs a driver discovery in a helper process, and nothing said whether concurrent requests share one. A hung driver and a client that retries could hold several pipe instances for 30 seconds each (review A, 6) | Partly closed by the contract and redaction fixes that followed the retrospective review (2026-11-10): the operation table marks `printers.discover` as single-flight (`ManagementOperationInfo.SingleFlight`), and the comment on reads says what is true of them. Open: the discovery itself is made single-flight where the pipe server is built |
| C14 | Nit | The printable-text function is a public static on an authentication record (`QqqCompatibilityProblem.cs:67`), and the pipe contract depends on that type for the form of its messages. Its default limit is an internal constant of 80 in a public signature, and its comment says every character that is not visible becomes a space although other space separators (U+00A0, U+3000) are kept (review A, 7) | Closed by the contract and redaction fixes that followed the retrospective review (2026-11-10): which characters are shown is one definition in a neutral Core type, `ServerText.IsShown`; the pipe contract's rule is its own, `ManagementText` in the Management namespace; `QqqCompatibilityProblem.Printable` stays for Core's sentences, is internal again, and its comment says that other space separators are kept |
| W37 | Nit | The walk that refuses a null inside a known list handles lists and records only (`ManagementJson.cs:384-393`); a dictionary-valued member and a nullable-struct member are not walked. No contract type has either today (review A, 8) | Closed by the contract and redaction fixes that followed the retrospective review (2026-11-10): the walk follows the values of a dictionary, which are held to the rule a list's items are, and a value type that may be null; a test with records of its own holds both |
| W38 | Nit | Smaller gaps in the contract. `ManagementPayload.From` accepts a value that is not an object (`ManagementPayload.cs:33-35`), which `ParseReply` then reports as an incomplete reply. No test puts a repeated member through `ParseReply`, although it is refused. A reply with `ok: true`, a result and an error is accepted as a success and the error is dropped (review A, 9) | Partly closed by the contract and redaction fixes that followed the retrospective review (2026-11-10): `ManagementPayload.From` refuses a value that is not written as an object, and six cases put a repeated member through `ParseReply`. Open, as the review left it with no fix proposed: a reply with `ok: true`, a result and an error is read as a success and the error is dropped, and the reverse |
| W39 | Should-fix | The service log's per-server view takes any string as the server's name (`ServiceLog.cs:152-159`), and the contract takes any string as a name (`ServersAddArgs.cs:16`, `ServersRenameArgs.cs:9`). The activity log file redacts the joined line a second time, so a name holding a credential keyword hides that server's lines: a server named `Cookie:` is written as `[Service] [Cookie:[REDACTED]`. Square brackets in a name forge structure inside a line. The view's own character rule is a second, weaker copy of Core's (UTF-16 units, four categories, a cut at 64 that can split a surrogate pair), although its comment says it is Core's rule (`ServiceLog.cs:164-170`). Nothing calls the view yet (review A, 10) | Closed in the contract and the log by the contract and redaction fixes that followed the retrospective review (2026-11-10): `ServerName` is the rule ADR-0004 decided, as one function the service and the companion both call (`Check`, which says which part of the rule a name breaks); `ServersAddArgs` and `ServersRenameArgs` cannot be built with a name that breaks it, so a request that gives one is refused when its arguments are read; `ServerName.Propose` proposes a name from an address that always passes; and `ServiceLog.ForServer` takes a server's identifier and prefixes its first eight characters, so a name never stands in a log line. The fullwidth square brackets (U+FF3B, U+FF3D) are refused with the ASCII pair since the fix round after the review of those fixes: a person reads them as the same brackets. In the same round the rule gained one condition: a name must hold at least one character that draws something (C27). The rule for an address is beside it (`ServerAddress.TryRead`), held by test to what the session client accepts and refuses. Open: the upgrade and the wizard, which will call these, are not built; the session client's own check and the sign-in flow's copies of the address rule remain until their callers move; nothing calls the view yet |
| W40 | Should-fix | Redaction gaps (`AgentActivityLog.cs:122-156`): `id_token_hint=`, an `Authorization:` header of a scheme other than Bearer and `code_verifier=` pass unchanged, and so do bare `sessionUUID=` and `sessionId=`. The review traced that no log line can carry any of them today; they are values the product handles, and redaction is the second line of defense (review A, 11) | Closed by the contract and redaction fixes that followed the retrospective review (2026-11-10) for the shapes named here, and no further. Every name under which the product's own requests and responses carry a secret (read from the session client's sign-in, code exchange, session renewal and sign-out and from the API client's enrollment) is redacted as a named value: `enrollmentCode`; `deviceToken`, with the `X-QQQ-Device-Token` header; the session response's `uuid`; `sessionUUID`, `sessionId` and `sessionToken`; the code verifier and the ID-token hint in every spelling and letter case (`codeVerifier`, `code_verifier`, `code-verifier`; `idTokenHint`, `id_token_hint`, `id-token-hint`); and `code` in a message that also names a code verifier, which is the code-exchange request. Each is recognised as a member of a JSON object, as `name=value` or `name: value`, and with the equals sign percent-encoded (`name%3Dvalue`), anywhere in a line. An `Authorization` header line of any scheme loses its value to the end of the line. Not covered, and held as known behaviour by `ServiceLogTests`: a name that is itself percent-encoded, a JSON object percent-encoded as a whole, a name with no separator after it, the rest of a value after a comma, a semicolon or a space, and a nonce (`nonce`, `expectedNonce`), which is not a credential. The first fix for this row covered `code_verifier`, which the product never writes, and not `codeVerifier`, which it does (review of the contract and redaction fixes, S1) |
| W41 | Nit | Redaction edges (`AgentActivityLog.cs:144`, `:155`). Over-redaction: a line with "cookie:" as ordinary words loses the rest of the line, and `?state=QUEUED` or `?code=E1234` in a logged address loses its value. Under-redaction: a quoted callback value, a folded cookie header, a cookie header with other spacing, and a percent-encoded `code%3D`. No line the product writes today has these shapes (review A, 12) | Partly closed by the contract and redaction fixes that followed the retrospective review (2026-11-10). Redacted now: a quoted callback value; a percent-encoded callback (`%3Fcode%3D`, `%26state%3D`); a callback whose ampersands are written as HTML writes them (`&amp;code=`); the session cookie's two names in a folded or differently named header; a cookie or an authorization header whose value stands on the next line, the header's own line holding nothing after the name; an authorization header written with an equals sign; either header under the name a web server's environment gives it (`HTTP_AUTHORIZATION`, `HTTP_COOKIE`); and a no-break or zero-width space between a name and its separator. Open, and held as known behaviour by `ServiceLogTests`. Not redacted: a cookie of another name under a header name that only ends in "Cookie", with an equals sign in place of the colon, or as a member of a JSON object; the second line of a header whose value begins on the header's own line and is continued on the next; a callback value that opens a quote and never closes it; a value after an arrow (`=>`); what follows an escaped quote inside a quoted value; and the rest of a value after a comma, a semicolon or a space. Hidden although not a secret: a sentence with a colon after "cookie" or "authorization", and the line after one that ends there; "authorization" before an equals sign; `sessionId: 12`; the word `uuid` with a value; an ordinary `?state=` or `?code=` parameter; and every `code` with a value in a message that names a code verifier. No line the product writes has these shapes |
| W42 | Nit | Three small items in the log. The runtime does not pass its time provider to `ServiceLog` (`AgentRuntime.cs:27`), so the ring's stamps read the system clock (the direction of W14). `ServiceLogTests.cs:626` uses a 200 ms real wait as an assertion, a timed wait of the kind W28 counts. The summary of `ServiceLog` says the ring is what the companion shows (`ServiceLog.cs:12-14`), which it is not (review A, 13) | Closed by the contract and redaction fixes that followed the retrospective review (2026-11-10): the runtime hands its time provider to `ServiceLog`; the summary of `ServiceLog` says the ring is what the version 1 snapshot carries, not what the companion shows; the 200 ms wait stays, because it waits for something that must not happen, and its summary says so (it stays counted under W28) |
| U20 | Should-fix | The command line drops a byte-order mark in front of an enrollment code only when the console input code page is UTF-8. It strips U+FEFF (`CommandLine.cs:269`), but `Console.ReadLine` (`Program.cs:21`) decodes redirected input in the console code page and detects no mark, so under another code page (437 was tried) the three bytes arrive as three other characters, stay in front of the code, and the server rejects it. Seen with Windows PowerShell 5.1 when `$OutputEncoding` is UTF-8 on an OEM console. The single-use code is not consumed. `CHANGELOG.md:153` says the mark is dropped whether it comes from the variable or standard input, which is more than the code does (review B, 1) | Open |
| C15 | Should-fix | A claim that names its job can still be neither refused nor answered. A lone-surrogate escape (`\ud800`) in a title or in a member name makes the string reads outside the serializer throw `InvalidOperationException` (`PrintJobContract.cs:66`, `:219`; `ProtocolJson.cs:118`, `:134-137`). That is not a protocol violation and carries no stand-in for the job, so the lane stops and the server offers the same job again (`AgentRuntime.Pipeline.cs:100-104`). The review did not show that a submitter can produce it through the current server: by reading only, the server's serializer should not emit the escape. No test covers it, and there is no fuzz target for the claim parser (R2). `docs/security/threat-model.md:29` and `:33`, "Malformed job" in `docs/REALTIME.md` and `CHANGELOG.md:83` say more than the code does (review B, 2) | Open |
| C16 | Nit | The comment on the parse depth reads as if the cost were flat in depth (`ProtocolJson.cs:37-41`). The parse is not recursive, but its cost grows with depth: about 0.9 seconds and 63 MiB for a hostile 2 MiB response at 1,024 levels, which only the server can send (review B, 3) | Open |
@@ -238,7 +238,7 @@ Found by the retrospective reviews of 2026-11-10 and by the review of the printi
| W53 | Nit | One timeout test depends on the child process starting within six seconds (`WindowsTaskSchedulerTests.cs:80-83`); on a busy machine it fails for that and not for its reason. It is a timed wait of the kind W28 counts (review of pull request 41) | Open |
| W54 | Nit | Two sentences of the changelog entry for the branded shortcut disagree. The pull request does not say which; read at `077148f`, the opening says a file that is not ours no longer fails an install (`CHANGELOG.md:7`), and a later sentence says an install fails when a file that is not ours has the name the shortcut needs (`CHANGELOG.md:36-37`) (review of pull request 41) | Open |
| W55 | Nit | Two files keep two blank lines after their usings (`tests/Qqq.PrintAgent.Cli.Tests/AgentBrandingInstallerTests.cs`, `tests/Qqq.PrintAgent.WorkerFixture/Program.cs`) (review of pull request 41) | Open |
| W56 | Should-fix | `servers.add` has no argument for the sign-in provider's origin, and the comment on `ServersAddArgs` says the provider's address "is not an argument" (`ServersAddArgs.cs:9-16`). ADR-0004 now says the request carries the origin that `servers.check` reported, for comparison, and that a difference is refused; the error code of that refusal is not settled. No handler exists yet. The code table and the golden files are closed by test, so the code must be settled when the argument is added (review of this documentation change, 3) | Open |
| W56 | Should-fix | `servers.add` has no argument for the sign-in provider's origin, and the comment on `ServersAddArgs` says the provider's address "is not an argument" (`ServersAddArgs.cs:9-16`). ADR-0004 now says the request carries the origin that `servers.check` reported, for comparison, and that a difference is refused; the error code of that refusal is not settled. No handler exists yet. The code table and the golden files are closed by test, so the code must be settled when the argument is added (review of this documentation change, 3) | Closed in the contract by the contract and redaction fixes that followed the retrospective review (2026-11-10): `ServersAddArgs.ProviderOrigin` states the provider the check showed the operator, and `TrustSetArgs.ProviderOrigin` does the same for `trust.set`, which has the same race; `SignInProviderOrigin.Agrees` is the one rule for the comparison, in which an absent origin means that the check named no provider and is compared like any other value, so a server that names a provider only after the check is refused as one that names another; and the refusal has the code `provider_changed`, with no details, recorded in ADR-0004 with the two golden requests. The review of those fixes found two things in the rule, both closed in the fix round after it: text that names no origin was read as an argument and could then never agree, and is now refused as `invalid_request` when the arguments are read (a member left out and a null both state "none", by test); and two zones of one IPv6 literal agreed (C26). Open: the comparison is made where `servers.add` and `trust.set` are built |
| W57 | Nit | A parent watch that fails for any reason other than a cancelled wait or a parent that is not running leaves the print helper unwatched: the watch's task fails, the helper carries out its operation with nobody watching the service, and the failure is seen only when the task is awaited after the operation (`ParentProcessWatch.cs:27-43`, `PrinterWorkerHost.cs:76`, `:90-93`) (printing seams review, 2, second part; older behavior) | Open |
| W58 | Nit | A failing `SetStretchBltMode` is reported with a stale Windows error: its import has no `SetLastError` (`WindowsGdiPrinter.cs:150`), and the failure is turned into the thread's last error (`WindowsPrinters.cs:333`, `:342-348`) (printing seams review, section 1; older behavior) | Open |
| C24 | Nit | A lane's ticket can leak. When the receipt of a claimed job cannot be confirmed, the worker records FAILED and then lets the job go (`PrintWorker.cs:104-109`); if recording FAILED throws, which needs a journal failure, the release is skipped and the runtime's ticket for the job stays registered (`AgentRuntime.Pipeline.cs:164-174`, `:328-347`). A later offer of that job then fails on the registry's duplicate key (W25), and a later job for the same printer is linked behind a ticket that nothing completes (noted as finding 4 in the review of an unmerged branch about jobs in hand; older behavior; the second effect is by reading the registry, not from that review) | Open |
@@ -258,6 +258,11 @@ Found by the retrospective reviews of 2026-11-10 and by the review of the printi
| W71 | Nit | Unsharing a printer runs the full discovery although it needs no list. The pipe's `set-exposure` reads every queue before it changes anything (`AgentRuntime.cs:316`), and the store looks at that list only when a printer is being shared (`AgentStore.cs:280`). With one queue whose driver hangs or fails the request fails, so the operator cannot withdraw a printer locally, while jobs for it are still claimed and printed, as they are since W44 and C25 were closed. Leaving the list out when unsharing is not enough: the reply is a snapshot (`AgentRuntime.GetSnapshot`), which runs the full discovery again and fails (W67). That the request fails with the reason and changes nothing is tested (`AgentRuntimePrinterReadsTests.PipeRequestsThatReadTheInventoryFailWithTheReasonAndThePipeGoesOn`). Until this row it was recorded only inside W67 (review of the one-queue change, nit 2) | Open. Not changed in the fix round of that review |
| W72 | Nit | The one-queue change is tested in parts and not in one piece. No test goes from the service through `IsolatedPrinterBackend` and a helper process to the real `WindowsPrinters` over the fake Windows. The whole-pass test (`AgentRuntimePrinterReadsTests.JobIsClaimedRecheckedAndSpooledWhileAnotherQueuesDriverDoesNotAnswer`) runs the real runtime over the real `WindowsPrinters` and the fake Windows in one process, without a helper; the helper boundary is tested against the worker fixture, whose helper answers from a fake backend and not from `WindowsPrinters`. Two queues with one identifier are tested at the Windows level only (`WindowsPrintersThreeReadsTests.TwoQueuesWithOneIdentifierAreRefusedBeforeEitherIsRead`), not through the helper or the worker. A test in one piece was not added in the fix round, because it is not small: the fake Windows is part of the test assembly and not of the fixture's executable, and the list, the lookup and the print of one job are three helper processes, so the fake's state would have to be carried from one process to the next (review of the one-queue change, nit 9) | Open |
| W73 | Nit | A print helper's answer to the lookup whose entry names the queue asked for and has no formats is not refused in words of its own. The lookup checks the entry's identifier and returns the entry as it was answered (`IsolatedPrinterBackend.cs:63`, `:67`); the exposure filter in front of it then fails on the missing formats (`ExposedPrinterBackend.cs:68`). The job ends FAILED before SUBMITTING, and Windows is never asked to print, but the reason recorded is the platform's text for a missing value ("Object reference not set to an instance of an object."), where an entry that is the JSON word null is refused as "Printer worker returned a printer that was not asked for." Only an answer that is not the shipped helper's can look like this, because the shipped helper writes the formats of every queue it answers with. Pinned by two tests (`IsolatedPrinterBackendTests.AnswerWhoseEntryHasNoFormatsIsReturnedAsAnsweredAndTheExposureFilterFailsOnIt`, `PrintWorkerPrinterReadsTests.LookupThatAnswersWithAPrinterWithoutFormatsFailsTheJobBeforeSubmitting`) (confirmation review of the one-queue change, nit 1) | Open |
| W74 | Should-fix | `ManagementJson.ParseRequest` and `ParseReply` throw instead of answering. JSON's grammar lets a string hold the escape of a surrogate without its partner (`\ud800` on its own, six ASCII characters on the wire). In the operation, the server selector, a member's name, or an error's code or message it makes the parse throw `InvalidOperationException`, and text that holds such a surrogate itself makes it throw `ArgumentException`. The parser's summary says it tells apart everything that can be wrong, and "Text on the wire" says a surrogate without its partner becomes U+FFFD, which is true of a string and not of the escape. Older than the contract fixes. Any administrator's program can send it once the pipe is served. Since the envelope is written by hand, `ManagementJson.Write` of a payload read with such an escape in a value throws `InvalidOperationException` where the serializer threw `JsonException` (review of the contract and redaction fixes, S2) | Closed in the fix round after that review (2026-11-10). Neither function throws for anything a text holds. The escape is answered where it stands, by the distinction the parser already had for that place: a malformed operation, a malformed server, an incomplete reply for a code or a message (not "not JSON", which a client takes for a version 1 service), and not JSON for a member's name at any depth and for text that holds the surrogate itself. In a value of the arguments, a result or the details the envelope is read and the record is not, as before. `ManagementJson.Write` fails with `JsonException` again, on purpose. ADR-0004, "Text on the wire", and the comments say what holds for a string and what for the wire |
| C26 | Nit | The text of an origin has no place for an IPv6 zone. `QqqOrigin.Of` returns an origin that keeps the zone in the host it connects to and compares, so the session client's same-origin rule and the certificate exception's cover tell two zones apart; but the origin's address and authority as text do not hold it (`QqqOrigin.cs:39-40`). Whatever shows or stores an origin as text therefore gives `[fe80::1%25eth0]`, `[fe80::1%25eth1]` and `[fe80::1]` as one place (`QqqOrigin.ShownAuthority`, the text form of `QqqCertificateExceptionOrigins`). The contract compared providers by that text, so `SignInProviderOrigin.Agrees` agreed for two zones of one address, and `ServerAddress.TryRead` returned a zoned address without its zone. It is the one case the review found in which two different places compared equal (review of the contract and redaction fixes, nit 1) | Closed in the contract in the fix round after that review (2026-11-10): an IPv6 literal with a zone is refused as a server's address (`HostNotNameable`) and names no provider origin, so none is stored, compared or shown through version 2. Core is unchanged, because it compares the origins it holds with their zone and the rule that needed the fix is the contract's. Open in Core: the shown form of a zoned origin, for an address that reaches the session client another way (the version 1 sign-in) |
| W75 | Nit | Two ways around the contract's checks remained inside the assembly, outside what the fixes claimed. `ManagementError.FromWire` was internal and took a code, a message and details as given, so code in the service's own assembly could send text that is not a code, an empty message, or details for a code that carries none; only a comment said that the parser alone calls it. `ManagementReply` and `ManagementRequest` were positional records: a reply could be built with any version, with an error beside a result or with neither, and a request with any text as its operation or its server (review of the contract and redaction fixes, nit 5) | Closed in the fix round after that review (2026-11-10). A request and a reply have no public constructor and no member that can be set; they are built by `ManagementRequest.For` and by `ManagementReply.Success` and `Failure`, which the parser uses too. A request is always version 2, with an operation shaped like a name and a server named by a full identifier or not at all; a reply is always version 2, with exactly one of a result and an error. `ManagementOperations.Check` no longer asks about the version or the spelling of the identifier, because no request can have another. The parser's way into an error takes the error object as JSON and nothing else. The text on the wire is unchanged: no golden file changed. Left as it is: that way in is internal, so code in the assembly that builds a JSON object for the purpose can still have an error read from it; and the parser's two result records, `ManagementRequestParse` and `ManagementReplyParse`, can be built by hand, which nothing sends |
| W76 | Nit | A server's name may begin with a hyphen, and may be spelled like an option of the command line: `-h`, `--server` and `-` all pass `ServerName.Check`, which matches the rule ADR-0004 states. The name rule is about what a name is; nothing in it knows the command line. The command line is where it matters: a name given as the value of `--server`, or as the new name in a rename, must be taken as a value and not as an option (review of the contract and redaction fixes, nit 10, second part) | Open, for the step that builds the command line's server commands: it either takes such a name as a value however it is spelled, or the rule is changed to refuse a leading hyphen. A test holds that the rule accepts these names today, so the choice is made on purpose |
| C27 | Nit | Core's one definition of a shown character goes by Unicode category (`ServerText.cs:30-31`), and its comment says it is false for characters that hide text. By category some characters are shown that draw nothing: the Hangul fillers (U+3164, U+115F, U+1160, U+FFA0) are letters, the blank Braille pattern (U+2800) is a symbol, and the combining grapheme joiner (U+034F) and the variation selectors are marks. Each passes every rule built on the definition, so one of them can be a whole error message (the service's check that a message says something takes it for text), a whole identity value in a reply, or a whole value in the session client's problem details and log lines. Core's definition was not changed by the contract fixes (review of the contract and redaction fixes, nit 6) | Open for Core's definition, which that fix round did not change: a message and an identity value made only of such characters are still carried. Closed for a server's name, where it is a trap for the operator, by the contract's own rule: `ServerName.Check` refuses a name in which nothing draws (`ShowsNothing`), which is a name made only of those characters, of combining marks and of spaces |
### Repository
+1 -1
View File
@@ -17,7 +17,7 @@ and reconnect rules. The service never falls back from WSS to job/control pollin
The service owns the credential, journal and print operations. The UI is optional at runtime and sees only credential-free snapshots. Service IPC uses the `QqqPrintAgent` named pipe and accepts administrator clients. `snapshot` returns connection health, all locally discovered printers with exposure state, the latest 50 jobs and the latest 100 in-memory log entries. `set-exposure` persists a selected queue and returns a fresh snapshot. Legacy `status`, `printers` and `enroll` operations remain available.
That paragraph describes version 1 of the pipe, which the service still speaks. Version 2 ([ADR-0004](adr/0004-multi-server-runtime.md)) exists so far as a contract only, in `src/Qqq.PrintAgent.Windows/Management`: the envelope (`ManagementRequest`, `ManagementReply`, `ManagementError`, with arguments, results and details held as a `ManagementPayload` whose text form is fixed so none of them can land in a log line), the one serializer (`ManagementJson`), the error codes (`ManagementErrorCode`), the operation table (`ManagementOperations`: scope, kind, time limit, argument and result record for each of the 30 operations), one argument record per operation that takes arguments, the result records, and `ServerSelector`, which resolves the command line's `--server`. Nothing serves or sends it yet. Its wire form is held by golden files in `tests/Qqq.PrintAgent.Cli.Tests/Fixtures/management`: one request and one reply per operation, one failure per error code that carries details, and two further snapshots as a client will really see them (two healthy servers; a service whose stored data is not ready). A change to a record changes its golden file and the matching example in `ManagementSamples.cs` in the same commit; the tests fail when an operation or such a code has no golden file, or a file has no operation. Additions to a result are compatible (readers skip unknown members, and treat an unknown value of a vocabulary as unknown); a request with an unknown member is refused; a member repeated at any depth, a missing required member and a null inside a list the record knows are refused in both directions. An envelope is read only by `ManagementJson.ParseRequest` and `ManagementJson.ParseReply`; the envelope types refuse the serializer. Every error message is made printable and cut at 512 characters by the reply factories, with Core's `QqqCompatibilityProblem.Printable`.
That paragraph describes version 1 of the pipe, which the service still speaks. Version 2 ([ADR-0004](adr/0004-multi-server-runtime.md)) exists so far as a contract only, in `src/Qqq.PrintAgent.Windows/Management`: the envelope (`ManagementRequest`, `ManagementReply`, `ManagementError`, with arguments, results and details held as a `ManagementPayload` whose text form is fixed so none of them can land in a log line), the one serializer (`ManagementJson`), the error codes (`ManagementErrorCode`), the operation table (`ManagementOperations`: scope, kind, time limit, argument and result record for each of the 30 operations, and which of them is single-flight), one argument record per operation that takes arguments, the result records, and `ServerSelector`, which resolves the command line's `--server`. Nothing serves or sends it yet. Its wire form is held by golden files in `tests/Qqq.PrintAgent.Cli.Tests/Fixtures/management`: one request and one reply per operation, one failure per error code that carries details, and two further snapshots as a client will really see them (two healthy servers; a service whose stored data is not ready). A change to a record changes its golden file and the matching example in `ManagementSamples.cs` in the same commit; the tests fail when an operation or such a code has no golden file, or a file has no operation. Additions to a result are compatible (readers skip unknown members, and treat an unknown value of a vocabulary as unknown); a request with an unknown member is refused; a member repeated at any depth, a missing required member and a null inside a list the record knows are refused in both directions. An envelope is read only by `ManagementJson.ParseRequest` and `ManagementJson.ParseReply`, which answer everything a text can hold and never throw (a surrogate without its partner included, as itself or as the escape `\ud800`), and written only by `ManagementJson.Write`; a request and a reply are built only by `ManagementRequest.For` and by `ManagementReply.Success` and `Failure`, which the parser uses too, so a request always names its operation by a well-formed name and a server by a full identifier, and a reply always holds exactly one of a result and an error; the envelope types refuse the serializer in both directions, under the default options and under any options that do not bring a converter of their own for an envelope type. Every error message is made printable and cut at 512 UTF-16 units by `ManagementError` itself, however the error is built or read, and a failure is built only with a code from the table and the details record the table names for it. Text on the wire has the contract's own rule, `ManagementText` (a cut is exact and unmarked, a missing value is absent, the rule applied twice changes nothing); it shares one thing with Core, the definition of a shown character (`ServerText.IsShown`). An account is named in a reply only by `ServerAuthentication` and `IdentityChangedDetails`, which bound its identity values the same way (and `ServerAuthentication` its message, as an error's), and a server's name enters only through `ServerName`'s rule, which also proposes a name from an address; the rule for the address an operator types is `ServerAddress.TryRead`, and a caller builds the session client from the origin it returns, not from the typed text. `servers.add` and `trust.set` carry the provider's origin the check reported (`SignInProviderOrigin.Agrees` is the comparison, `provider_changed` its refusal; text that names no origin is refused as `invalid_request` when the arguments are read).
The cloud API lives in [qqq-print-protocol](https://git.kof22.com/QRun-IO/qqq-print-protocol). Its inventory is the complete currently exposed printer set. The server separately owns authorization/approval. Removing local exposure filters inventory and prevents new local submission to that queue. It cannot retract a job already handed to Windows.
+41 -16
View File
@@ -119,9 +119,10 @@ Requests and replies are JSON objects on the existing framing.
| `not_signed_in`, `not_paired` | The operation needs a session, or a pairing, that this server does not have. |
| `not_connected` | This server's connection is not ready for the action: its administrator disconnected this device, or the connection is still waiting for authorization or for the control state to be agreed. |
| `server_incompatible`, `certificate_untrusted` | The compatibility check failed; `details` carries its stages and, for the second, a description of the certificate that was presented. |
| `provider_changed` | `servers.add` and `trust.set` (when it switches the exception on): the sign-in provider the service reads from the server now is not the one `servers.check` reported, as the request states it. That is so when the service reads another provider, when it reads one where the check named none, and when it reads none where the check named one. Nothing was stored or changed. The client runs `servers.check` again and shows what it now reports; there are no details, because the check's result names the provider. |
| `server_unreachable` | The QQQ server could not be reached or did not answer in time (`session.signIn`, `servers.add`, `pairing.enroll` before the code is sent, `printers.testPrint`, `jobs.retry`, `jobs.cancel`). |
| `server_refused` | The QQQ server refused the action; `message` carries its reason. The wire enforces the form of that text, here and for every other code: before a message is sent, each control, format, line-separator, paragraph-separator, private-use and unassigned character in it becomes a space, and it is cut at 512 UTF-16 units, never inside a surrogate pair. It is the one definition Core uses for text a server supplied. |
| `sign_in_refused`, `bind_refused`, `identity_changed` | The server refused the session; refused to bind the pairing; or bound it for a different account than the approved one, in which case `details` names the approved principal and tenant. The only way to build those details puts each value through the same definition, cut at 200. |
| `server_refused` | The QQQ server refused the action; `message` carries its reason. The wire enforces the form of that text, here and for every other code: before a message is sent, each control, format, line-separator, paragraph-separator, private-use and unassigned character in it becomes a space, and it is cut at 512 UTF-16 units, never inside a surrogate pair. Which characters those are is the one definition Core uses for text a server supplied. The cut adds no mark, so no message is longer than 512 units, and a failure with no message at all cannot be built ("Text on the wire" under "Resolved after the retrospective reviews"). |
| `sign_in_refused`, `bind_refused`, `identity_changed` | The server refused the session; refused to bind the pairing; or bound it for a different account than the approved one, in which case `details` names the approved principal and tenant. The only way to build those details puts each value through the same definition, cut at 200; a value the pairing does not record is left out, never replaced by words. |
| `enrollment_refused`, `enrollment_unconfirmed` | The server refused the code; or the outcome is not known and `details` says what was left behind. |
| `printer_not_found`, `job_not_found`, `job_not_current` | No such printer or job for this server (a test print on a printer that is not shared with that server is `printer_not_found`); or the job belongs to an earlier pairing and is history. |
| `duplicate_risk_unconfirmed` | A retry could print twice and the request did not confirm that. |
@@ -176,7 +177,7 @@ Certificate exception:
- Pinning the accepted certificate's fingerprint was considered and rejected. The exception exists for self-signed certificates and private authorities such as a local Caddy root, and a private authority reissues its certificates on a short cycle; a pin would stop matching at each renewal and unattended printing would stop with it. The durable way to trust a private authority is to install its root in the Local Machine trusted root store, which needs no exception at all. The wizard and the Connection page say so wherever the exception is offered.
- When the trust stage fails, `servers.check` returns a description of the presented certificate (subject, issuer, expiry, fingerprint, and why Windows refused it), so the operator sees what the exception would accept. `trust.get` returns, for an existing server, the two origins the exception covers and the certificate from the last refusal the service remembers, with the address that presented it and when. It never probes: it contacts no server, and returns no certificate when the service has seen no refusal since it started.
- A server with the exception switched on says so in the snapshot, and the companion shows it on the server's node and its Connection page for as long as it is on, so a development setting is not forgotten.
- If a server later names a different sign-in provider, the exception does not follow it: sign-in fails as untrusted and the operator accepts the new origin or does not.
- If a server later names a different sign-in provider, the exception does not follow it: sign-in fails as untrusted and the operator accepts the new origin or does not. How the operator accepts it is under "Who names the sign-in provider" in "Resolved after the retrospective reviews": the Connection page checks the server, shows both origins, and sends `trust.set` with the provider's origin it showed.
- An exception carried over from 1.x covers the server's origin. The provider's origin is recorded from the first sign-in description the service reads after the upgrade, and logged. The companion's sign-in uses the same exception for the same two origins, from the snapshot, so the two cannot disagree about what is accepted.
- Core needs the per-origin check in place of the one switch. That closes the first half of audit finding C9; its second half, binding the discovery address to the issuer's origin, is separate Core work.
@@ -242,10 +243,10 @@ The contract was built as types and reviewed on 2026-11-10 before anything serve
- **What a reply must contain.** A reply that lacks a member its type requires, or has a null inside a list its type knows, is an unreadable reply and not a record with a hole in it; a member the reader does not know is still skipped, whatever it holds. A vocabulary value the reader does not know (a health, a stage, an outcome) is treated as unknown, never as a failure to read.
- **Dates.** A date on the wire is ISO 8601 with a trailing `Z` or an offset, and nothing else is read. The times inside the reused job, printer-catalog and control records are carried as the text the service stored; they are not converted.
- **The version of a reply.** A reply carries the version of the request it answers. The one exception is `unsupported_version`, which is answered in version 2 whatever version was asked for.
- **Text a server supplied.** An error's message and the approved account in the details of `identity_changed` can repeat what a server said. Both are made printable and cut by one definition, which Core owns and the contract calls, when the reply is built: 512 UTF-16 units for any message, 200 for each identity value. It is enforced where the reply is made, not asked of whoever writes a handler.
- **Text a server supplied.** An error's message and the approved account in the details of `identity_changed` can repeat what a server said. Both are made printable and cut by one definition, which Core owns and the contract calls, when the reply is built: 512 UTF-16 units for any message, 200 for each identity value. It is enforced where the reply is made, not asked of whoever writes a handler. *Made exact on 2026-11-10: see "Text on the wire" under "Resolved after the retrospective reviews". Core owns which characters are shown; the cut, and what stands for a missing value, are the contract's own rule; and "where the reply is made" is the error record and the two records that name an account, not the reply factories.*
- **A server that is being removed.** Reads answer; a repeated `servers.remove` succeeds with `removing` again; every other change is refused with `server_removing`.
- **The traffic meter while the stored data is not ready.** `transport.get` answers with an empty list instead of an error.
- **One way to read an envelope.** A request or a reply is read only by the contract's two parsing functions, which make the envelope checks. The envelope types refuse to be read by the serializer directly, so those checks cannot be walked around.
- **One way to read an envelope.** A request or a reply is read only by the contract's two parsing functions, which make the envelope checks. The envelope types refuse to be read by the serializer directly, so those checks cannot be walked around. *Since 2026-11-10 the refusal is declared on the types, so it holds for the default options and for any options object that does not bring a converter of its own for an envelope type (the serializer asks the options before the type, so a converter written on purpose for the type would be used; nothing in the product has one), and it covers writing too: an envelope is written only by the contract's own function, as one line.*
## Resolved after the retrospective reviews, 2026-11-10
@@ -281,7 +282,9 @@ They are settled here. Each settlement was taken on 2026-11-10 under the owner's
- When the certificate Windows refuses is the server's own, the provider is not known until the exception is accepted, because the sign-in description cannot be read before then. So the first warning names the server's address and says that the sign-in provider's address will be shown after the check. The re-check that follows returns the provider's origin, and the step then shows both addresses the exception covers, before the operator goes on.
- The service reads the sign-in description once in `servers.check` and again in `servers.add`, and a server whose certificate is unverified could name a different provider the second time. `servers.add` therefore carries the provider's origin that `servers.check` reported, for comparison only. The service still derives the origin from its own read and stores only that. When the origin it derives differs from the one the request carries, `servers.add` is refused and nothing is stored, so a stored exception never covers an origin the operator did not see. Comparing is not taking the value from the client.
- Which error code that refusal carries is not settled. It is chosen when the argument is added to the contract; no new code is assumed here.
- That refusal carries a code of its own, `provider_changed`, with no details. It was settled on 2026-11-10, under the standing instruction, when the plan for the wizard stage was written and the argument was added to the contract. The alternatives were weighed and not taken: `certificate_untrusted` would say that Windows refused a certificate when none was refused, and `invalid_request` could not be told from a bad name. (`server_incompatible` says a stage of the check failed, and here every stage passed; `server_refused` says the QQQ server refused, and here the service did.) The wizard needs no details: it returns to its first step, and the check it runs again shows the provider the server names now.
- The argument is `ServersAddArgs.ProviderOrigin`, and it states what the operator was shown by the check. An absent origin means "the check named no provider", and it is compared like any other value: if the service then reads a provider, the request is refused with `provider_changed`. (Decided on 2026-11-10 under the standing instruction, in place of an earlier wording of the same day under which an origin was compared only when it was given.) The reason: the comparison exists so that what the operator accepted is what the service uses, and a server that names no provider at the check and one at the add is exactly the case the operator has not seen. A caller that has not run a check cannot add a server that uses a provider; it must check first, which is what the wizard and the command line do. Nothing uses the contract yet, so no caller breaks. The comparison is one rule, which both operations call (`SignInProviderOrigin.Agrees`): none shown and none read agree; none shown and one read, one shown and none read, and one shown and another read are refused; the same one agrees. Two origins are compared in the one form an origin is kept in (scheme, the host's ASCII name and port), so one place written two ways is one place. That form has no place for the zone of an IPv6 literal (`[fe80::1%25eth0]`), so two zones of one address, or a zone and none, would compare as one place: an IPv6 literal with a zone therefore names no provider for this comparison and agrees with nothing, and the address rule refuses one as a server's address (`ServerAddress.TryRead`, as a host that cannot be named). On the wire, "the check named no provider" is the member left out or given as null; both are read as that statement. Text that names no origin (empty text, a word such as "none", a host with no scheme, a host that cannot be named) states nothing the check could have shown and could never agree, so it is not read and then answered `provider_changed` for ever: it is refused as `invalid_request` when the arguments are read, as a name that breaks the name rule is, for `servers.add` and for `trust.set` whichever way it switches.
- `trust.set` has the same race, and until now nothing said how an operator accepts a provider that a server names later ("Certificate exception" says only that the operator "accepts the new origin or does not"). Switching the exception on records the server's origin and the provider the server names, from the service's own read, which the operator has not seen. So the Connection page runs `servers.check` for the server first and shows both origins, and `trust.set` carries the provider's origin the operator was shown: `TrustSetArgs.ProviderOrigin`, compared by the same rule, an absent origin included, and refused with the same code, `provider_changed`, with nothing changed. A request that switches the exception off accepts nothing, so nothing is compared for it.
**When a scope becomes current.** In the decision of that name, "On open" becomes "when the server's connection starts", once, by the host. Finding a readable pairing whose row is not current means decrypting the pairing file and deriving its scope. Done at every open of a store, that is the derivation the design removed ("no reader ever has to derive a scope from a pairing file"), and it makes opening a store a writer again; stores are opened for every pass of a lane and for every command. A stop between the file write and the flag can only be seen at a start, so that is where it is repaired. The rest of the decision stands: an unreadable pairing never changes the current row.
@@ -291,10 +294,12 @@ They are settled here. Each settlement was taken on 2026-11-10 under the owner's
**Server names.** Neither record said what a server's name may be, and the name was to go in front of every log line of that server.
- The rule, enforced where a name enters, at `servers.add` and `servers.rename`, and refused there as `invalid_request`: 1 to 64 UTF-16 units after trimming; every code point one that Core's definition of printable text shows (the definition the pipe contract uses for text a server supplied); no square brackets; and not shaped like a server identifier or like the first eight or more characters of one, which is what `--server` takes as an identifier or a prefix.
- The rule, enforced where a name enters, at `servers.add` and `servers.rename`, and refused there as `invalid_request`: 1 to 64 UTF-16 units after trimming; every code point one that Core's definition of printable text shows (the definition the pipe contract uses for text a server supplied); no square brackets, which delimit the prefixes of a log line (the ASCII pair, and since the fix round of 2026-11-10 the fullwidth pair U+FF3B and U+FF3D, which a person reads as the same brackets); at least one code point that draws something, a letter, a digit, a punctuation mark or a symbol other than the few that draw nothing (added in the fix round of 2026-11-10: Core's definition goes by category and so shows the Hangul fillers, the blank Braille pattern and a combining mark on its own, and a name made only of those and of spaces would be a server the operator cannot see; the definition itself is unchanged, audit C27); and not shaped like a server identifier or like the first eight or more characters of one, which is what `--server` takes as an identifier or a prefix.
- Log lines carry the server's short identifier (the first eight characters of its identifier, which `--server` accepts), not its name. The reasoning is in finding 10 of the first retrospective review: the activity log file redacts each joined line a second time, so a name containing a credential keyword (`Cookie:`, `token:`, `password=`) hides that server's lines; square brackets in a name forge structure inside a line; and the service log's own check of a name is a second, weaker copy of Core's rule. A short identifier has none of these and survives a rename.
- The rule must be in force before any per-server log prefix is used. Nothing calls the service log's per-server view today (read at `077148f`).
- The upgrade and the wizard propose the host as the name. A host can break the rule (the brackets of an IPv6 literal, a host of eight or more hexadecimal digits). The code that proposes a name must produce one that passes; how is settled in the step that builds the registry.
- The upgrade and the wizard propose the host as the name. A host can break the rule (the brackets of an IPv6 literal, a host of eight or more hexadecimal digits). The code that proposes a name must produce one that passes. How was settled on 2026-11-10, with the plan for the wizard stage, and is in the contract beside the rule (`ServerName.Propose`): the proposal is the host by its ASCII name, without the brackets of an IPv6 literal and cut at 64 units; a host shaped like an identifier or its beginning gets the word "Server" in front; and an address with no host to name is proposed as "Server".
- The rule is one function in the contract, which the service and the companion both call (`ServerName.Check`), and it says which part of the rule a name breaks, so the companion can tell the operator before anything is sent. The service still refuses with `invalid_request` and no more.
- The address an operator types has one rule as well, beside the name's (`ServerAddress.TryRead`): HTTPS, or plain HTTP for this computer itself; no user information, query, fragment or path; and a host with an ASCII name, which is the form an origin is stored, compared and shown in. It is the rule the session client applies to the origin it is built for, plus the last condition. The session client's own check and the three copies in the sign-in flow of the version 1 companion are not removed by the change that added the rule; they go when their callers move to it.
**`printers.discover` is single-flight.** `printers.discover` is a read, and it runs a driver discovery, which starts a helper process. Without a rule, a hung driver and a client that retries could hold several of the sixteen pipe instances for 30 seconds each, with a helper process for each request, beside the host's own refresh. So the computer runs one discovery at a time: a `printers.discover` that arrives while a discovery is running, whoever started it, joins that discovery and gets its result. Each request keeps its own 30-second limit. (Finding 6 of the first retrospective review.)
@@ -322,20 +327,40 @@ Sentences in the sections above that were edited in place on 2026-11-10 to agree
- Under "When a server comes to exist": the provider's origin was added to what `servers.add` takes.
- Under "Certificate exception": the pointer to the two-stage warning was added.
- Under "Security consequences": "Staged bytes have a machine-wide ceiling, of which each server is guaranteed one eighth; the rest goes to whoever asks first." was replaced by the sentence that is there now.
- In the code table (later on 2026-11-10, with "Text on the wire" and the provider's code): the rows for `server_refused` and `identity_changed` gained their last sentences, about the cut without a mark, the failure with no message and the value that is left out; and the row for `provider_changed` was added.
- In ADR-0005: steps 1 and 2 of the wizard (the two-stage warning, the provider comparison, a pointer to the name rule) and the sentence about the name of the first server after an upgrade.
### Where the contract as built does less than this record says
### Text on the wire
The rows for `server_refused` and `identity_changed`, and "Text a server supplied" and "One way to read an envelope" under "Resolved while reviewing the pipe contract", say what the contract enforces. The types merged in pull request 37 do less in four places, which the first retrospective review showed with a probe. This record stays the design, the gaps are open findings in the audit, and they are to be closed before anything serves the contract:
Settled on 2026-11-10, when the contract's gaps below were closed, under the standing instruction. The first retrospective review found that the function Core wrote for pieces of sentences was being used as the form of typed wire members (its finding 4), and this record had left one question open: whether a limit includes the mark of a cut.
- The form and the bound of a message are applied by the two reply factories only; a reply built another way is written as given (audit W31).
- A details record is not tied to its code, and any text is accepted as a code (audit W32).
- The envelope types refuse the serializer only when it is given the contract's own options (audit W33).
- A cut value carries a three-character mark after the cut, so a message can be 515 units and an identity value 203 where this record says 512 and 200; and a missing value is sent as the text `(missing)` (audit W34). Whether the limit includes the mark is not settled.
- There is no mark. A value on the wire is cut at its limit exactly, never inside a surrogate pair, and nothing is added to say that it was cut: a message is at most 512 UTF-16 units and an identity value at most 200, as the code table says. A mark after the limit made the numbers in this record untrue (515 and 203); a mark inside the limit would put a decoration into an identity value, which is a typed member that a client may compare.
- Applying the rule to its own result changes nothing. The reader of a reply applies it again, so a client holds bounded, printable text even from a service that did not send it so, and holds the same text as a service that did.
- That is a statement about text. JSON's grammar also lets a string hold the escape of a surrogate without its partner (`\ud800` on its own). That is not text, and the rule never sees it, because it cannot be read into a string at all. It is refused where it stands; it is never made into U+FFFD and it never makes the parsing functions throw (settled on 2026-11-10 in the fix round after the review of these fixes, which found that they threw). In a request's `op` or `server` the request is refused as for any other malformed operation name or server. In a reply's `code` or `message` the reply is incomplete, as it is when either is not text; the alternative, "not JSON", is what a client takes for a version 1 service, which such a reply is not. In a member's name, at any depth, the text is refused as not JSON, in a request and in a reply, because the parse cannot establish that no member is repeated. In a value inside the arguments, a result or the details, the envelope is read and the record is not (`invalid_request` for arguments). `ManagementJson.Write` refuses a payload that holds one with the error it gave before these fixes. A surrogate without its partner inside a string that code already holds does become U+FFFD, in a message and in an identity value.
- A missing value is absent. The two members of the details of `identity_changed` may be left out, and so may the principal and the tenant wherever a reply names an account. No words such as "(missing)" are sent in their place: a client could not tell them from an account's name, and would have to match text to avoid showing them.
- A failure must say something. An error whose message is missing, empty or blank once it is printable cannot be built by the service. When a server refuses "with no reason" (ADR-0005), the handler says so in its own words. A client still reads a reply whose message is empty, so that it learns the code; the parsing functions refuse nothing for this.
- An account is carried one way in every reply. The snapshot, the result of `servers.add`, the result of `session.signIn` and the details of `identity_changed` give a principal and a tenant the same form and the same bound of 200. The session client itself admits an identity value of up to 1,024 units and excludes only control characters; what Core admits is not changed here (the first review's finding 5 names that as the other place the rule could live), so a value between the two bounds is carried cut. The message that stands beside an account (`ServerAuthentication.Message`) has the form of an error's message, printable and at most 512 units, when the record is built and when it is read (added in the fix round of 2026-11-10; it is the service's own wording today, and was held as given). The other text in results that a server can influence (a server's status, the printer sync detail, the last command and its outcome, job titles and messages) is decided when the handlers that produce it are written.
- Which characters are shown stays one definition, which Core owns (`ServerText.IsShown`) and the contract calls, as "Text a server supplied" says. The cut and the treatment of a missing value are the contract's own (`ManagementText`), because Core's function for sentences rightly marks a cut and names a missing value. The rule for a server's name uses the same definition of a shown character.
A fifth gap comes from this section's own decision, not from the review's probe:
### Where the contract as built did less than this record says
- `ServersAddArgs` has no argument for the sign-in provider's origin, and its comment says the provider's address "is not an argument" (`src/Qqq.PrintAgent.Windows/Management/ServersAddArgs.cs:9-16`). "Who names the sign-in provider" above now says `servers.add` carries it for comparison, and leaves the refusal's error code unsettled. Nothing is blocked today, because no handler exists. The code must be settled when the argument is added: the code table and the golden files are closed by test, and the wizard may not decide by matching text (audit W56).
The rows for `server_refused` and `identity_changed`, and "Text a server supplied" and "One way to read an envelope" under "Resolved while reviewing the pipe contract", say what the contract enforces. The types merged in pull request 37 did less in four places, which the first retrospective review showed with a probe. All four are closed in the contract (2026-11-10), and nothing serves the contract yet:
- The form and the bound of a message were applied by the two reply factories only. They are now applied in the error record itself, which has no member that can be set afterwards, for an error the service builds by any route and for one a client reads (audit W31).
- A details record was not tied to its code, and any text was accepted as a code. The service can now build an error only with a code from the table, and with exactly the details record the table names for it; a client still reads a code it does not know (audit W32).
- The envelope types refused the serializer only when it was given the contract's own options. The refusal is now declared on the types and holds for the default options and for every options object that brings no converter of its own for an envelope type, for reading and for writing (audit W33).
- A cut value carried a three-character mark after the cut, and a missing value was sent as the text `(missing)`. "Text on the wire" above settles both (audit W34).
- Found by the review of those fixes and closed in the round after it: a request and a reply could still be built member by member, with another version, with an error beside a result, or with any text as an operation or a server. Both are now built only by functions that check them, which the parser uses as well, so every request that exists is one the parser would read back and every reply holds exactly one of a result and an error (audit W75). The parsing functions also no longer throw for the escape of a surrogate without its partner (audit W74; "Text on the wire").
Three decisions of this section were also missing from the contract and are in it now. What each still needs is built with the pipe server and the registry, not with the contract:
- The server-name rule is a type of the contract (`ServerName`), and the arguments of `servers.add` and `servers.rename` cannot be built with a name that breaks it. The service log's per-server view takes a server's identifier and prefixes its first eight characters. The contract also proposes a name that passes (`ServerName.Propose`) and holds the rule for an address (`ServerAddress`); the upgrade and the wizard, which will call them, are not built (audit W39).
- The operation table marks `printers.discover` as single-flight. The discovery itself is made single-flight where the pipe server is built (audit W36).
- The provider's origin in `servers.add`, below.
A fifth gap came from this section's own decision, not from the review's probe, and is closed in the contract:
- `ServersAddArgs` had no argument for the sign-in provider's origin, and its comment said the provider's address "is not an argument". It now carries the origin `servers.check` reported, for comparison only, and so does `TrustSetArgs`; the refusal has the code `provider_changed`, which is in the code table above and in the contract's table (audit W56). No handler exists yet, so the comparison itself is made when `servers.add` and `trust.set` are built; the contract gives them one rule to make it with.
## Resolved while building the journal's schema version, 2026-11-10
@@ -15,7 +15,7 @@ Add server opens the wizard in the content area. A dialog cannot be resized by t
It has five steps, a step indicator, and Back, Next and Cancel. Enter moves forward and Escape cancels. Each step shows only what that step needs, and its own result; nothing from another step or another page appears in it.
1. **Server.** The address, and a name that defaults to the host (what a name may be is in ADR-0004, "Server names"; a host that breaks that rule is not proposed as it stands). Checking the server asks the service to run the compatibility check (`servers.check`), because the service is what will connect and it trusts certificates as LocalSystem, not as the signed-in user. The step shows the five stages in order (reaching the address, a trusted secure connection, a QQQ server, a printing extension it can print through, a sign-in method it implements), each passed, failed with the reason, or not reached. Next is enabled only when all five pass. A certificate failure shows the certificate that was presented (subject, issuer, expiry, fingerprint, and why Windows refused it) and offers two ways forward: install the authority's root certificate in this computer's trusted root store, which is the lasting fix and needs no exception; or switch on the development-only exception for this one server, with its warning. The warning comes in two stages, because the sign-in provider is known only from the server's sign-in description, which cannot be read while the server's own certificate is refused: the first warning names the server's address and says that the sign-in provider's address will be shown after the check; the re-check that follows returns the provider's address, and the step then shows both addresses the exception covers before the operator goes on. (An earlier wording had one warning name both addresses before the exception was accepted, which cannot be done in that case.) Nothing is stored yet: the choice travels with the wizard and is saved when the server is. An address that is already a server on this computer says so and offers to open it.
2. **Sign in.** The methods the server offers and no others. Browser sign-in shows that it is waiting, can be cancelled, and gives up after three minutes; password sign-in clears the password after every attempt. The sign-in runs in the companion, as the user. If the user's Windows account refuses a certificate that the service accepted, the step says that it is this account that refused it. When sign-in succeeds the wizard adds the server (`servers.add`), which stores the server, the certificate exception if one was accepted and the session together or not at all. The request carries the provider's address that the check reported, for comparison only: when the service's own read names another provider, the add is refused and nothing is stored. When it is the sign-in provider's certificate that is refused, this step shows it and offers the same two ways forward as step 1.
2. **Sign in.** The methods the server offers and no others. Browser sign-in shows that it is waiting, can be cancelled, and gives up after three minutes; password sign-in clears the password after every attempt. The sign-in runs in the companion, as the user. If the user's Windows account refuses a certificate that the service accepted, the step says that it is this account that refused it. When sign-in succeeds the wizard adds the server (`servers.add`), which stores the server, the certificate exception if one was accepted and the session together or not at all. The request carries the provider's address that the check reported, for comparison only: when the service's own read names another provider, or names one where the check named none (the request then carries no address, which says so), the add is refused with `provider_changed` and nothing is stored, and the wizard goes back to step 1 and checks the server again, so the operator sees the provider the server names now. When it is the sign-in provider's certificate that is refused, this step shows it and offers the same two ways forward as step 1.
3. **Pair this computer.** For a new server this is the enrollment code and where to create it. A refused code says so and can be tried again. When the outcome is not known (the reply was lost, or the service stopped answering), the step says exactly that, shows what the service reports was left behind, and offers to check again rather than to enroll again.
4. **Printers.** The Windows printers that can print unattended, none selected. Sharing can be skipped and done later.
5. **Done.** The server's name, who is signed in, how many printers are shared and that the server must approve them, and a button that opens the server.
@@ -3,7 +3,6 @@
* SPDX-License-Identifier: MIT
*/
using System.Globalization;
using System.Text;
namespace Qqq.PrintAgent.Core.Authentication;
@@ -55,16 +54,18 @@ public sealed record QqqCompatibilityProblem(QqqCompatibilityIssue Issue, string
/// <summary>
/// Makes server-supplied text safe to show. Anything that is not a visible character or a plain space becomes a
/// space: control characters, the line and paragraph separators (U+2028, U+2029) that end a line as surely as a
/// newline, the format characters that reorder or hide text (the bidirectional embeddings, overrides and isolates,
/// the zero-width characters), private-use and unassigned code points, and a surrogate without its partner. The
/// text is read as whole code points and cut after the limit (<see cref="ValueLimit"/> UTF-16 units unless the
/// caller names another), never in the middle of a surrogate pair, and reading stops there, so a value of any
/// length costs the same. A missing value is shown as such. The session client's log lines use it as well as the
/// problem details, because a log is read by an operator too.
/// Makes server-supplied text safe to show as part of a sentence: a problem's detail, or a line of the session
/// client's log, which an operator reads too. Every code point that <see cref="ServerText.IsShown"/> does not show
/// becomes a space: control characters, the line and paragraph separators, the format characters that reorder or
/// hide text, and private-use and unassigned code points. Other space separators (U+00A0, U+3000) are kept as they
/// are, and a surrogate without its partner is shown as U+FFFD. The text is read as whole code points and cut
/// after the limit (<see cref="ValueLimit"/> UTF-16 units unless the caller names another), never in the middle
/// of a surrogate pair, and reading stops there, so a value of any length costs the same. Because the result is a
/// piece of a sentence, a cut is marked with an ellipsis after the limit and a missing value is shown as the words
/// "(missing)". Neither suits a typed member of the management pipe, which has its own rule for that reason and
/// shares only <see cref="ServerText.IsShown"/> with this one.
/// </summary>
public static string Printable(string? value, int limit = ValueLimit)
internal static string Printable(string? value, int limit = ValueLimit)
{
if(string.IsNullOrEmpty(value))
{ return "(missing)"; }
@@ -73,23 +74,13 @@ public sealed record QqqCompatibilityProblem(QqqCompatibilityIssue Issue, string
{
if(shown.Length + rune.Utf16SequenceLength > limit)
{ return shown.Append(Cut).ToString(); }
shown.Append(IsShown(rune) ? rune.ToString() : " ");
shown.Append(ServerText.IsShown(rune) ? rune.ToString() : " ");
}
return shown.ToString();
}
/// <summary>
/// True for a code point that shows as itself and cannot change how the text around it is laid out. A surrogate
/// without its partner never arrives here as one: reading by code point has already replaced it with U+FFFD, which
/// is shown.
/// </summary>
private static bool IsShown(Rune rune) => Rune.GetUnicodeCategory(rune) is not (UnicodeCategory.Control or UnicodeCategory.Format
or UnicodeCategory.LineSeparator or UnicodeCategory.ParagraphSeparator or UnicodeCategory.PrivateUse or UnicodeCategory.OtherNotAssigned);
/// <summary>Joins a server-supplied list for display: each value printable and cut at the given limit, at most <see cref="ListLimit"/> of them, and a count of the rest, so neither one value nor the number of values can make a line unbounded.</summary>
internal static string PrintableList(IEnumerable<string?>? values, int limit = ValueLimit) => BoundedList((values ?? []).Select(value => Printable(value, limit)));
+32
View File
@@ -0,0 +1,32 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Globalization;
using System.Text;
namespace Qqq.PrintAgent.Core;
/// <summary>
/// The one definition of which characters are shown as they are when text that a server supplied, or that an operator
/// typed, is put on a screen, in a log or on the management pipe. It is a type of its own because several things
/// depend on it and none of them owns it: the session client's problem details and log lines
/// (<see cref="Authentication.QqqCompatibilityProblem"/>), the form of text on the management pipe, and the rule for
/// what a server's name may be. Each of those decides for itself how long the text may be and what stands in for a
/// missing value; none of them decides again which characters are safe.
/// </summary>
public static class ServerText
{
/// <summary>
/// True for a code point that shows as itself and cannot change how the text around it is laid out. False for
/// control characters, the line and paragraph separators (U+2028, U+2029) that end a line as surely as a newline,
/// the format characters that reorder or hide text (the bidirectional embeddings, overrides and isolates, the
/// zero-width characters, the invisible tag characters), and private-use and unassigned code points. Space
/// separators other than the plain space (the no-break space U+00A0, the ideographic space U+3000) are shown: they
/// are visible gaps and move nothing. A surrogate without its partner is not a code point and never arrives here
/// as one: reading text by code point replaces it with U+FFFD, which is shown.
/// </summary>
public static bool IsShown(Rune rune) => Rune.GetUnicodeCategory(rune) is not (UnicodeCategory.Control or UnicodeCategory.Format
or UnicodeCategory.LineSeparator or UnicodeCategory.ParagraphSeparator or UnicodeCategory.PrivateUse or UnicodeCategory.OtherNotAssigned);
}
+97 -11
View File
@@ -92,10 +92,17 @@ public sealed partial class AgentActivityLog : IAgentLog
/// <summary>
/// Redacts credential-shaped diagnostics; callers must still never log documents or credential payloads. What it
/// recognises: a bearer credential, a named secret written as name and value, a user name and password inside a web
/// address, the value of a cookie header line, and the one-time code and the state of a sign-in callback in the
/// query or fragment of an address. It is a second line of defense, not a parser: a secret in a shape it does not
/// know passes through.
/// recognises:
/// <list type="bullet">
/// <item>a bearer credential;</item>
/// <item>a named secret written as name and value (<see cref="NamedCredential"/>), also with the equals sign percent-encoded (<see cref="EncodedNamedCredential"/>);</item>
/// <item>a user name and password inside a web address;</item>
/// <item>the value of a cookie header line and of an authorization header line of any scheme, on the header's own line or, when that line holds nothing after the name, on the next;</item>
/// <item>the one-time code and the state of a sign-in callback in the query or fragment of an address, quoted or percent-encoded;</item>
/// <item>a member or parameter named <c>code</c> in a message that also names a code verifier, which is the code-exchange request (<see cref="ExchangedCode"/>).</item>
/// </list>
/// It is a second line of defense, not a parser: a secret in a shape it does not know passes through, and the
/// service log's tests record the shapes that are known to pass.
/// </summary>
public static string Redact(string message)
{
@@ -104,9 +111,14 @@ public sealed partial class AgentActivityLog : IAgentLog
string bounded = message.Length <= 8192 ? message : message[..8192] + " [truncated]";
string redacted = BearerCredential().Replace(bounded, "Bearer [REDACTED]");
redacted = NamedCredential().Replace(redacted, "${prefix}[REDACTED]");
redacted = EncodedNamedCredential().Replace(redacted, "${prefix}[REDACTED]");
redacted = UrlCredential().Replace(redacted, "${scheme}[REDACTED]@");
redacted = CookieHeader().Replace(redacted, "${prefix}[REDACTED]");
redacted = AuthorizationHeader().Replace(redacted, "${prefix}[REDACTED]");
redacted = CallbackParameter().Replace(redacted, "${prefix}[REDACTED]");
// The verifier's name is looked for in the message as it was given, not in the redacted text: a rule above
// may have hidden the name together with the value it stood in (a header's line, another secret's quotes).
redacted = CodeVerifierName().IsMatch(bounded) ? ExchangedCode().Replace(redacted, "${prefix}[REDACTED]") : redacted;
// Continuations cannot forge another timestamped AppKit log entry.
return redacted.Replace("\r", "", StringComparison.Ordinal).Replace("\n", "\n ", StringComparison.Ordinal);
}
@@ -118,18 +130,51 @@ public sealed partial class AgentActivityLog : IAgentLog
/// <summary>
/// The names of the named secrets, as the one alternation <see cref="NamedCredential"/> and
/// <see cref="EncodedNamedCredential"/> share; letter case is ignored. Beside the general words (token, password,
/// secret, API key, document content) it holds every name under which the product's own requests and responses
/// carry a secret, in the product's spelling and in the standard ones:
/// <list type="bullet">
/// <item><c>enrollmentCode</c>, the one-time code of the enrollment request, and <c>deviceToken</c>, the device's secret in the enrollment response, which the <c>X-QQQ-Device-Token</c> header also carries (that name ends in "token");</item>
/// <item><c>uuid</c>, the session identifier in the body of a session response, and <c>sessionUUID</c> and <c>sessionId</c>, the two names of the session cookie; <c>sessionToken</c>, the stored session, ends in "token";</item>
/// <item><c>codeVerifier</c>, the secret half of a browser sign-in in the code-exchange request, with <c>code_verifier</c> and <c>code-verifier</c>;</item>
/// <item><c>idTokenHint</c>, which the sign-out response carries, and <c>id_token_hint</c>, the parameter the identity provider's sign-out request carries it in, with <c>id-token-hint</c>.</item>
/// </list>
/// "uuid" is a name only at the start of a word or straight after a percent-encoded separator, so an identifier
/// whose name merely ends in it (a device's, a job's) is left alone.
/// </summary>
private const string SecretNames = """deviceToken|enrollment[_-]?code|access_token|refresh_token|id[_-]?token[_-]?hint|code[_-]?verifier|sessionUUID|sessionId|(?:\b|(?<=%3F|%26|%23))uuid|api[_-]?key|token|password|secret|contentBase64""";
/// <summary>Matches an HTTP bearer credential (the word Bearer and its token) so it can be replaced before anything is logged. The match has a one second limit so a crafted message cannot stall logging.</summary>
[GeneratedRegex("""\bBearer\s+[^\s,;"']+""", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture, 1000)]
private static partial Regex BearerCredential();
/// <summary>Matches a named secret such as a token, password, enrollment code, API key or document payload field written as name and value, keeping the name so the log still shows which field was hidden.</summary>
[GeneratedRegex("""(?<prefix>["']?(?:deviceToken|enrollmentCode|access_token|refresh_token|api[_-]?key|token|password|secret|contentBase64)["']?\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;&]+)""", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture, 1000)]
/// <summary>
/// Matches a named secret (<see cref="SecretNames"/>) written as name and value, keeping the name so the log still
/// shows which field was hidden: <c>name=value</c>, <c>name: value</c> and a member of a JSON object, with the
/// name and the value quoted or not. White space, a no-break space or a character that shows as nothing may stand
/// between the name and the separator. A name is redacted wherever it stands in a line, not only inside a header.
/// </summary>
[GeneratedRegex("""(?<prefix>["']?(?:""" + SecretNames + """)["']?[\s\p{Cf}]*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;&]+)""", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture, 1000)]
private static partial Regex NamedCredential();
/// <summary>
/// Matches a named secret (<see cref="SecretNames"/>) whose equals sign is percent-encoded (<c>%3D</c>), which is
/// how a parameter looks inside another address, keeping the name. The value ends at a percent-encoded separator
/// (<c>%26</c>, <c>%23</c>) as well as at a plain one, so the parameters after it stay readable.
/// </summary>
[GeneratedRegex("""(?<prefix>(?:""" + SecretNames + """)%3D)(?:(?!%26|%23)[^\s,;&"'<>])+""", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture, 1000)]
private static partial Regex EncodedNamedCredential();
/// <summary>Matches a user name and password embedded in a web address, keeping the scheme so the log still shows that a URL was involved.</summary>
[GeneratedRegex("""(?<scheme>https?://)[^/\s:@]+:[^/\s@]+@""", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture, 1000)]
private static partial Regex UrlCredential();
@@ -137,26 +182,67 @@ public sealed partial class AgentActivityLog : IAgentLog
/// <summary>
/// Matches the value of a <c>Cookie:</c> or <c>Set-Cookie:</c> header line, to the end of the line, keeping the header's
/// name. The whole value goes because a cookie header carries any number of name and value pairs and the session
/// cookie's name is the server's choice; whatever follows on the same line is hidden with it, which errs on the safe side.
/// Matches the value of a cookie header line, to the end of the line, keeping the header's name. The name is
/// "Cookie" where no letter or digit stands before it, so <c>Cookie:</c>, <c>Set-Cookie:</c> and
/// <c>HTTP_COOKIE:</c> are all one; white space, a no-break space or a character that shows as nothing may stand
/// before the colon. When the header's own line holds nothing after the colon, the value is the next line, which
/// is where a folded header puts it. The whole value goes because a cookie header carries any number of name and
/// value pairs and the session cookie's name is the server's choice; whatever follows on the same line is hidden
/// with it, which errs on the safe side.
/// </summary>
[GeneratedRegex("""(?<prefix>\b(?:Set-)?Cookie[ \t]*:[ \t]*)[^\r\n]+""", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture, 1000)]
[GeneratedRegex("""(?<prefix>(?<![A-Za-z0-9])Cookie[\s\p{Cf}-[\r\n]]*:[ \t]*(?:\r?\n[ \t]*)?)[^\r\n]+""", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture, 1000)]
private static partial Regex CookieHeader();
/// <summary>
/// Matches the value of an authorization header line, to the end of the line, keeping the header's name. The name
/// is "Authorization" where no letter or digit stands before it, so <c>Authorization:</c>,
/// <c>Proxy-Authorization:</c> and <c>HTTP_AUTHORIZATION:</c> are all one. The separator is a colon or an equals
/// sign, and white space, a no-break space, a character that shows as nothing or a quote may stand before it, so
/// the header as a member of a JSON object and in a list of <c>name=value</c> pairs is covered too. When the
/// header's own line holds nothing after the separator, the value is the next line, which is where a folded header
/// puts it. The whole value goes, whatever the scheme (Basic, Digest, Negotiate, a bare key), because the scheme
/// decides where the credential is and a new scheme must not need a new pattern; whatever follows on the same line
/// is hidden with it, which errs on the safe side, as it does for a cookie header.
/// </summary>
[GeneratedRegex("""(?<prefix>(?<![A-Za-z0-9])Authorization["']?[\s\p{Cf}-[\r\n]]*[:=][ \t]*(?:\r?\n[ \t]*)?)[^\r\n]+""", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture, 1000)]
private static partial Regex AuthorizationHeader();
/// <summary>
/// Matches the one-time authorization code and the state of a sign-in callback where they are parameters of an
/// address: <c>code=</c> or <c>state=</c> straight after the <c>?</c>, <c>&amp;</c> or <c>#</c> that starts a
/// parameter, keeping the name. The separator is required on purpose. "code" and "state" are ordinary words in a
/// log ("status code: 401", "exit code=1", "state=Paused"), so neither is a keyword on its own the way "token" is.
/// The value may be quoted, and the separator, the equals sign and the end of the value may be percent-encoded
/// (<c>%3F</c>, <c>%26</c>, <c>%23</c>, <c>%3D</c>), which is how a callback looks inside another address; the
/// ampersand may also be written the way HTML writes it (<c>&amp;amp;</c>).
/// </summary>
[GeneratedRegex("""(?<prefix>[?&#](?:code|state)=)[^\s&#"'<>]+""", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture, 1000)]
[GeneratedRegex("""(?<prefix>(?:[?&#]|&amp;|%3F|%26|%23)(?:code|state)(?:=|%3D))(?:"[^"\r\n]*"|'[^'\r\n]*'|(?:(?!%26|%23)[^\s&#"'<>])+)""", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture, 1000)]
private static partial Regex CallbackParameter();
/// <summary>Matches the name of a code verifier in any of its spellings (<c>codeVerifier</c>, <c>code_verifier</c>, <c>code-verifier</c>). A message that holds one is about a code exchange, which is what lets <see cref="ExchangedCode"/> treat "code" as a secret's name there.</summary>
[GeneratedRegex("""code[_-]?verifier""", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture, 1000)]
private static partial Regex CodeVerifierName();
/// <summary>
/// Matches a member or parameter named exactly <c>code</c> with its value, keeping the name: a member of a JSON
/// object, <c>code=value</c>, <c>code: value</c>, or with the equals sign percent-encoded. It is applied only to a
/// message that also names a code verifier (<see cref="CodeVerifierName"/>): the product's code-exchange request
/// carries the one-time authorization code as <c>code</c> beside <c>codeVerifier</c>, and outside that request
/// "code" is an ordinary word whose value an operator needs (an exit code, an error's code).
/// </summary>
[GeneratedRegex("""(?<prefix>["']?\bcode["']?(?:[\s\p{Cf}]*[:=]\s*|%3D))(?:"[^"\r\n]*"|'[^'\r\n]*'|(?:(?!%26|%23)[^\s,;&"'<>])+)""", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture, 1000)]
private static partial Regex ExchangedCode();
/// <summary>A shared exclusive file handle spans each append and retention pass, across processes and sessions.</summary>
private void Write(Action<string> append, string message)
{
+1 -1
View File
@@ -24,7 +24,7 @@ public sealed partial class AgentRuntime(string directory, Action<string> log, I
{
private readonly Lock _stateLock = new();
private readonly IPrinterBackend _printers = printerBackend ?? new WindowsPrinters();
private readonly ServiceLog _log = new(AgentActivityLog.Create(directory), log);
private readonly ServiceLog _log = new(AgentActivityLog.Create(directory), log, timeProvider);
private readonly SemaphoreSlim _claimsRequested = new(0, 1);
private readonly SemaphoreSlim _connectionGate = new(1, 1);
private DateTimeOffset? _lastServerContact;
@@ -4,42 +4,41 @@
*/
using System.Text.Json.Serialization;
using Qqq.PrintAgent.Core.Authentication;
namespace Qqq.PrintAgent.Windows.Management;
/// <summary>
/// The details of <see cref="ManagementErrorCode.IdentityChanged"/>: the account the pairing is approved for, so the
/// operator can be told which account to sign in as. Both values were supplied by the server when the pairing was
/// approved, so they are never sent as given. <see cref="For"/> is the only way to build this record in code, and it
/// puts both values through <see cref="QqqCompatibilityProblem.Printable"/>, the one definition Core uses for text a
/// server supplied, cut at <see cref="ManagementProtocol.MaximumIdentityCharacters"/>. The other constructor is
/// private and belongs to the serializer: it reads the two values a service wrote, which <see cref="For"/> had already
/// made printable.
/// approved, so they are never held as given. The record has one constructor, which gives each value the form of an
/// identity value on the wire (<see cref="ManagementText.Identity"/>: printable, and cut at
/// <see cref="ManagementProtocol.MaximumIdentityCharacters"/> exactly). <see cref="For"/> builds the record in code
/// through it, and the serializer reads the wire through it, so a client holds bounded values even when a service did
/// not send them so. A value the pairing does not record is absent: the member is null and is left out of the text,
/// and no words stand in for it, so a client never has to tell a missing account from one with a peculiar name.
/// </summary>
public sealed record IdentityChangedDetails
{
/// <summary>Takes the two values as they are written on the wire. Only the serializer calls it.</summary>
/// <summary>Takes the two values and gives each the form the wire carries. <see cref="For"/> and the serializer are its only callers.</summary>
[JsonConstructor]
private IdentityChangedDetails(string approvedPrincipalId, string approvedTenantId)
private IdentityChangedDetails(string? approvedPrincipalId, string? approvedTenantId)
{
ApprovedPrincipalId = approvedPrincipalId;
ApprovedTenantId = approvedTenantId;
ApprovedPrincipalId = ManagementText.Identity(approvedPrincipalId);
ApprovedTenantId = ManagementText.Identity(approvedTenantId);
}
/// <summary>The principal the pairing is approved for, printable and bounded.</summary>
public string ApprovedPrincipalId { get; }
/// <summary>The principal the pairing is approved for, printable and bounded, or null when the pairing records none.</summary>
public string? ApprovedPrincipalId { get; }
/// <summary>The tenant the pairing is approved for, printable and bounded.</summary>
public string ApprovedTenantId { get; }
/// <summary>The tenant the pairing is approved for, printable and bounded, or null when the pairing records none.</summary>
public string? ApprovedTenantId { get; }
/// <summary>Builds the details from the approved account as the pairing recorded it, making each value printable and cutting it at <see cref="ManagementProtocol.MaximumIdentityCharacters"/> UTF-16 units. A value that is missing is shown as such.</summary>
public static IdentityChangedDetails For(string? approvedPrincipalId, string? approvedTenantId)
=> new(QqqCompatibilityProblem.Printable(approvedPrincipalId, ManagementProtocol.MaximumIdentityCharacters), QqqCompatibilityProblem.Printable(approvedTenantId, ManagementProtocol.MaximumIdentityCharacters));
/// <summary>Builds the details from the approved account as the pairing recorded it. A value that is missing or has nothing to show stays absent.</summary>
public static IdentityChangedDetails For(string? approvedPrincipalId, string? approvedTenantId) => new(approvedPrincipalId, approvedTenantId);
}
@@ -19,7 +19,7 @@ public enum ManagementEnvelopeProblem
/// <summary>The text is longer than the contract allows; it was not parsed.</summary>
TooLarge,
/// <summary>The text is empty or is not JSON.</summary>
/// <summary>The text is empty or is not JSON. Text that holds a surrogate without its partner, as itself or as an escape in a member's name, is not JSON either: it cannot be read as text, so no member of it can be checked.</summary>
NotJson,
/// <summary>The text is JSON, but one of its objects has the same member twice: in the envelope, in the arguments or anywhere beneath them. Readers disagree about which of two values counts, so neither is taken.</summary>
@@ -40,15 +40,15 @@ public enum ManagementEnvelopeProblem
/// <summary>A request has no <c>op</c>, or one that is not text.</summary>
OperationMissing,
/// <summary>A request's <c>op</c> does not have the shape of an operation name: lower-case letters, optionally a dot and a camel-case word.</summary>
/// <summary>A request's <c>op</c> does not have the shape of an operation name: lower-case letters, optionally a dot and a camel-case word. A value that is not text at all (the escape of a surrogate without its partner) is one of these.</summary>
OperationMalformed,
/// <summary>A request's <c>server</c> is not a server identifier in its canonical form.</summary>
/// <summary>A request's <c>server</c> is not a server identifier in its canonical form; a value that is not text at all (the escape of a surrogate without its partner) is one of these.</summary>
ServerMalformed,
/// <summary>A request's <c>args</c> is not an object.</summary>
ArgumentsNotAnObject,
/// <summary>A reply lacks what its <c>ok</c> promises: a result object for a success, or an error with a code and a message for a failure; or it has no <c>ok</c>.</summary>
/// <summary>A reply lacks what its <c>ok</c> promises: a result object for a success, or an error with a code and a message for a failure, each of them text (the escape of a surrogate without its partner is not); or it has no <c>ok</c>.</summary>
ReplyIncomplete,
}
@@ -3,6 +3,9 @@
* SPDX-License-Identifier: MIT
*/
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Qqq.PrintAgent.Windows.Management;
/// <summary>
@@ -10,12 +13,122 @@ namespace Qqq.PrintAgent.Windows.Management;
/// message is for people and no client matches its text. A client that meets a code it does not know shows the message
/// and treats the failure as it would <see cref="ManagementErrorCode.InternalError"/>. Details are present only for the
/// codes whose table entry says so, and their type is fixed per code (<see cref="ManagementErrorCode.DetailsType"/>).
/// The service answers a failure through <see cref="ManagementReply.Failure(string, string)"/> or its sibling with
/// details, which make the message printable and cut it at <see cref="ManagementProtocol.MaximumMessageCharacters"/>,
/// so text a server supplied never reaches a client raw; this record only holds what it is given.
/// <para>
/// An error can be built in two ways, and both refuse a code that is not in the table: the constructor, for a code that
/// carries no details, and <see cref="WithDetails"/>, for a code that carries some, which takes the details as the
/// record the table names for that code and nothing else. So a failure cannot be sent under text that is not a code,
/// a code without details cannot be given any, and details cannot be written from an object that only looks like the
/// right record and skips what that record's own construction enforces. Only the parser builds an error otherwise, from
/// what the wire carried, because a client must be able to read a code a newer service added; that way in
/// (<see cref="FromWire"/>) takes the error object as JSON and nothing else, so no code can use it to turn loose text
/// into an error.
/// </para>
/// <para>
/// The message may repeat what a server said, so it is never held as given: every way of building an error ends in one
/// place that gives it the form of text on the wire (<see cref="ManagementText"/>: printable, and cut at
/// <see cref="ManagementProtocol.MaximumMessageCharacters"/> exactly), and nothing can change it afterwards, because
/// no member can be set once the error exists (a <c>with</c> expression can only copy it). That holds for an error the
/// service builds and for one a client reads from the wire, so text a server supplied reaches neither the wire nor a
/// client's screen raw. A reply whose code or message is not text at all, because it holds the escape of a surrogate
/// without its partner, is not read as a reply (<see cref="ManagementEnvelopeProblem.ReplyIncomplete"/>), so no error
/// is ever held with one. An error the service builds must also say something: a message with nothing to show is
/// refused, and no stand-in text is sent in its place. What a client reads is not refused for that, so that it still
/// learns the code.
/// </para>
/// </summary>
public sealed record ManagementError(string Code, string Message, ManagementPayload? Details = null)
[JsonConverter(typeof(ManagementJson.NotBySerializer<ManagementError>))]
public sealed record ManagementError
{
/// <summary>Builds an error with a code that carries no details. A code that is not in the table, or one whose table entry promises details, is refused, and so is a message with nothing to show.</summary>
public ManagementError(string code, string message) : this(Known(code, detailsType: null), ManagementText.Message(message), null)
{
}
/// <summary>Stores an error. The message is made printable and cut here, the only place an error's message is ever set; for a message that already has that form, this changes nothing.</summary>
private ManagementError(string code, string message, ManagementPayload? details)
{
Code = code;
Message = ManagementText.Bounded(message, ManagementProtocol.MaximumMessageCharacters);
Details = details;
}
/// <summary>What went wrong, as one of the <see cref="ManagementErrorCode"/> values. Clients decide by this.</summary>
public string Code { get; }
/// <summary>What went wrong, for people: printable, and at most <see cref="ManagementProtocol.MaximumMessageCharacters"/> UTF-16 units.</summary>
public string Message { get; }
/// <summary>The details the code's table entry promises, or null for a code that carries none.</summary>
public ManagementPayload? Details { get; }
/// <summary>
/// Builds an error with one of the codes that carry details, writing the details record the way the wire carries it.
/// The record must be exactly the type the table names for the code (<see cref="ManagementErrorCode.DetailsType"/>):
/// a code that is not in the table, a code that carries no details, another record and an object of no declared
/// type are all refused, and so is a message with nothing to show.
/// </summary>
public static ManagementError WithDetails<TDetails>(string code, string message, TDetails details)
where TDetails : class
{
ArgumentNullException.ThrowIfNull(details);
return new ManagementError(Known(code, typeof(TDetails)), ManagementText.Message(message), ManagementPayload.From(details));
}
/// <summary>
/// Reads an error object as a reply carried it, or returns null when it is not an object or lacks a code or a
/// message as text (a code or a message that holds a surrogate without its partner is not text). The code is held
/// as it came, known or not, with whatever details came with it; details of any kind but an object are dropped,
/// because every details type is an object. The message is still made printable and cut, so a service that did not
/// bound it cannot put raw text on a client's screen; an empty one is held as it came. This is the parser's way
/// in, and it is the only one that does not check the code against the table: it takes the JSON the wire carried
/// and not a code and a message as text, so a handler cannot reach for it to send a code of its own making.
/// </summary>
internal static ManagementError? FromWire(JsonElement error)
{
if(error.ValueKind != JsonValueKind.Object
|| !error.TryGetProperty("code", out JsonElement code) || code.ValueKind != JsonValueKind.String || ManagementJson.TextOf(code) is not { } codeText
|| !error.TryGetProperty("message", out JsonElement message) || message.ValueKind != JsonValueKind.String || ManagementJson.TextOf(message) is not { } messageText)
{
return null;
}
ManagementPayload? details = error.TryGetProperty("details", out JsonElement found) && found.ValueKind == JsonValueKind.Object ? ManagementPayload.FromElement(found) : null;
return new ManagementError(codeText, messageText, details);
}
/// <summary>Shows the code and the message, not the details, which are read through <see cref="ManagementJson"/> as their own type.</summary>
public override string ToString() => $"{Code}: {Message}";
/// <summary>
/// Returns the code when the service may send it with details of the given type (null for none), and refuses it
/// otherwise. The refusals are fixed text: what was given as a code is never repeated, because it need not be one.
/// </summary>
private static string Known(string code, Type? detailsType)
{
ArgumentNullException.ThrowIfNull(code);
if(!ManagementErrorCode.All.Contains(code, StringComparer.Ordinal))
{
throw new ArgumentException("This is not one of the management contract's error codes.", nameof(code));
}
if(ManagementErrorCode.DetailsType(code) != detailsType)
{
throw new ArgumentException(detailsType is null ? "This code carries details, and none were given." : "This code does not carry details of this type.", nameof(code));
}
return code;
}
}
@@ -86,12 +86,25 @@ public static class ManagementErrorCode
/// <summary>
/// <see cref="ManagementOperations.ServersAdd"/> and <see cref="ManagementOperations.TrustSet"/>: the sign-in
/// provider the service reads from the server now is not the one <see cref="ManagementOperations.ServersCheck"/>
/// reported, as the request states it (<see cref="SignInProviderOrigin.Agrees"/>). That is so when the service
/// reads another provider, when it reads one where the check named none, and when it reads none where the check
/// named one. Nothing was stored or changed, so a certificate exception does not come to cover a provider other
/// than the one the operator was shown. The client checks the server again and shows what the check now reports.
/// There are no details: the check's result names the provider.
/// </summary>
public const string ProviderChanged = "provider_changed";
/// <summary>The QQQ server could not be reached or did not answer in time, so the action was not carried out there.</summary>
public const string ServerUnreachable = "server_unreachable";
/// <summary>The QQQ server refused the action. The message carries its reason, which <see cref="ManagementReply.Failure(string, string)"/> makes printable and cuts at <see cref="ManagementProtocol.MaximumMessageCharacters"/> as it does every message, so what a server said cannot reach a client raw.</summary>
/// <summary>The QQQ server refused the action. The message carries its reason, which <see cref="ManagementError"/> makes printable and cuts at <see cref="ManagementProtocol.MaximumMessageCharacters"/> as it does every message, however the error is built, so what a server said cannot reach a client raw.</summary>
public const string ServerRefused = "server_refused";
@@ -106,7 +119,7 @@ public static class ManagementErrorCode
/// <summary>The server bound the pairing for a different account than the one approved for it. The details are an <see cref="IdentityChangedDetails"/> naming the approved account, printable and bounded by the only factory that builds them.</summary>
/// <summary>The server bound the pairing for a different account than the one approved for it. The details are an <see cref="IdentityChangedDetails"/> naming the approved account, printable and bounded by the record's one constructor.</summary>
public const string IdentityChanged = "identity_changed";
@@ -187,9 +200,9 @@ public static class ManagementErrorCode
public static IReadOnlyList<string> All { get; } =
[
UnsupportedVersion, UnknownOperation, InvalidRequest, ServerNotFound, ServerAmbiguous, ServerLimit, ServerExists, ServerRemoving, Busy, NotSignedIn, NotPaired,
NotConnected, ServerIncompatible, CertificateUntrusted, ServerUnreachable, ServerRefused, SignInRefused, BindRefused, IdentityChanged, EnrollmentRefused,
EnrollmentUnconfirmed, PrinterNotFound, JobNotFound, JobNotCurrent, DuplicateRiskUnconfirmed, CancelRefused, StateUnreadable, StateNewer, MigrationIncomplete,
UpdateInProgress, InternalError,
NotConnected, ServerIncompatible, CertificateUntrusted, ProviderChanged, ServerUnreachable, ServerRefused, SignInRefused, BindRefused, IdentityChanged,
EnrollmentRefused, EnrollmentUnconfirmed, PrinterNotFound, JobNotFound, JobNotCurrent, DuplicateRiskUnconfirmed, CancelRefused, StateUnreadable, StateNewer,
MigrationIncomplete, UpdateInProgress, InternalError,
];
@@ -3,9 +3,11 @@
* SPDX-License-Identifier: MIT
*/
using System.Buffers;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
@@ -24,7 +26,13 @@ namespace Qqq.PrintAgent.Windows.Management;
/// without breaking an older client; for the same reason a client treats a vocabulary value it does not know (a
/// health, a stage, an outcome) as unknown, never as a failure to read the reply. An envelope is read only by
/// <see cref="ParseRequest"/> and <see cref="ParseReply"/>: handed to the serializer directly, the envelope types
/// refuse to be read, so the checks those two make cannot be walked around.
/// refuse to be read, so the checks those two make cannot be walked around by accident. The refusal is declared on
/// the four envelope types themselves (<see cref="NotBySerializer{TEnvelope}"/>), so it holds for the default
/// options and for any options object that does not bring a converter of its own for one of these types. Such a
/// converter, placed in the options, takes precedence over a declaration on the type; it has to be written for the
/// purpose, and nothing in the product has one. The same types refuse to be written by the serializer, and an
/// envelope is written only by <see cref="Write(ManagementRequest)"/> and <see cref="Write(ManagementReply)"/>, so it
/// cannot leave in another form than the one line this contract defines (other options could indent it across lines).
/// </summary>
public static class ManagementJson
{
@@ -43,7 +51,7 @@ public static class ManagementJson
/// <summary>The options everything is written with, and replies are read with: a member the reader does not know is skipped.</summary>
/// <summary>The options every argument, result and details record is written with, and results and details are read with: a member the reader does not know is skipped. The envelope around them is written by <see cref="Write(ManagementRequest)"/> and <see cref="Write(ManagementReply)"/>, not through these.</summary>
public static JsonSerializerOptions Options { get; } = CreateOptions(skipUnknownMembers: true);
@@ -54,12 +62,20 @@ public static class ManagementJson
/// <summary>Writes a request as the one line the pipe carries.</summary>
public static string Write(ManagementRequest request) => JsonSerializer.Serialize(request, Options);
public static string Write(ManagementRequest request)
{
ArgumentNullException.ThrowIfNull(request);
return WriteLine(writer => WriteRequest(writer, request));
}
/// <summary>Writes a reply as the one line the pipe carries.</summary>
public static string Write(ManagementReply reply) => JsonSerializer.Serialize(reply, Options);
public static string Write(ManagementReply reply)
{
ArgumentNullException.ThrowIfNull(reply);
return WriteLine(writer => WriteReply(writer, reply));
}
@@ -69,6 +85,13 @@ public static class ManagementJson
/// members this one does not know. After that the envelope allows exactly <c>v</c>, <c>op</c>, <c>server</c> and
/// <c>args</c>. Whether the operation exists, and whether it should name a server, is not decided here but by
/// <see cref="ManagementOperations.Check"/>, and the arguments are read by <see cref="TryReadArguments"/>.
/// <para>
/// Nothing a text holds makes this throw. JSON's grammar lets a string hold the escape of a surrogate without its
/// partner (<c>\ud800</c> on its own), which is not text. It is answered like any other value that is wrong where
/// it stands: in <c>op</c> the operation is malformed, in <c>server</c> the server is, and in a member's name, at
/// any depth, the text is not JSON, because the parse cannot know that no member is repeated. In a value of the
/// arguments it leaves the envelope readable, and the arguments are then not read.
/// </para>
/// </summary>
public static ManagementRequestParse ParseRequest(string? text)
{
@@ -88,7 +111,9 @@ public static class ManagementJson
/// <summary>
/// Reads a reply's envelope. A success must carry a result object and a failure an error with a code and a message;
/// members this version does not know are skipped. The result and the details are read into their own types by
/// <see cref="TryReadResult"/> and <see cref="TryReadDetails"/>.
/// <see cref="TryReadResult"/> and <see cref="TryReadDetails"/>. Nothing a text holds makes this throw: a code or a
/// message that holds the escape of a surrogate without its partner is not text, so the reply is incomplete, and
/// such an escape in a member's name makes the text not JSON, as it does for a request.
/// </summary>
public static ManagementReplyParse ParseReply(string? text)
{
@@ -175,10 +200,6 @@ public static class ManagementJson
TypeInfoResolver = new DefaultJsonTypeInfoResolver { Modifiers = { LetNullableMembersBeAbsent } },
};
options.Converters.Add(new UtcDateTimeConverter());
options.Converters.Add(new WriteOnlyConverter<ManagementRequest>(WriteRequest, nameof(ParseRequest)));
options.Converters.Add(new WriteOnlyConverter<ManagementReply>(WriteReply, nameof(ParseReply)));
options.Converters.Add(new WriteOnlyConverter<ManagementError>(WriteError, nameof(ParseReply)));
options.Converters.Add(new WriteOnlyConverter<ManagementPayload>((writer, payload) => payload.ToElement().WriteTo(writer), nameof(ParseRequest) + " or " + nameof(ParseReply)));
options.MakeReadOnly();
return options;
}
@@ -238,6 +259,16 @@ public static class ManagementJson
{
return IsJsonWithARepeatedMember(text) ? ManagementEnvelopeProblem.RepeatedMember : ManagementEnvelopeProblem.NotJson;
}
catch(Exception exception) when(exception is InvalidOperationException or ArgumentException)
{
/////////////////////////////////////////////////////////////////////////////////////////
// The text holds a surrogate without its partner where the parse has to read it: as //
// itself, so the text cannot be carried as UTF-8 at all, or as an escape in a //
// member's name, which the parse must read to know that no member is repeated. The //
// parser reports neither as a JSON error. Nothing in such a text can be trusted. //
/////////////////////////////////////////////////////////////////////////////////////////
return ManagementEnvelopeProblem.NotJson;
}
if(parsed.RootElement.ValueKind != JsonValueKind.Object)
{
parsed.Dispose();
@@ -295,13 +326,13 @@ public static class ManagementJson
problem = member.Name switch
{
"v" => ManagementEnvelopeProblem.None,
"op" => kind == JsonValueKind.String ? ManagementEnvelopeProblem.None : ManagementEnvelopeProblem.OperationMissing,
"server" => kind == JsonValueKind.String && ManagementProtocol.IsServerId(member.Value.GetString()) ? ManagementEnvelopeProblem.None : ManagementEnvelopeProblem.ServerMalformed,
"op" => kind != JsonValueKind.String ? ManagementEnvelopeProblem.OperationMissing : TextOf(member.Value) is null ? ManagementEnvelopeProblem.OperationMalformed : ManagementEnvelopeProblem.None,
"server" => kind == JsonValueKind.String && ManagementProtocol.IsServerId(TextOf(member.Value)) ? ManagementEnvelopeProblem.None : ManagementEnvelopeProblem.ServerMalformed,
"args" => kind == JsonValueKind.Object ? ManagementEnvelopeProblem.None : ManagementEnvelopeProblem.ArgumentsNotAnObject,
_ => ManagementEnvelopeProblem.UnexpectedMember,
};
operation = member.NameEquals("op") ? member.Value.ToString() : operation;
server = member.NameEquals("server") ? member.Value.ToString() : server;
operation = member.NameEquals("op") ? TextOf(member.Value) : operation;
server = member.NameEquals("server") ? TextOf(member.Value) : server;
arguments = member.NameEquals("args") && kind == JsonValueKind.Object ? ManagementPayload.FromElement(member.Value) : arguments;
}
if(problem == ManagementEnvelopeProblem.None && operation is null)
@@ -313,13 +344,13 @@ public static class ManagementJson
problem = ManagementEnvelopeProblem.OperationMalformed;
}
return problem == ManagementEnvelopeProblem.None
? new ManagementRequestParse(new ManagementRequest(ManagementProtocol.Version, operation!, server, arguments), ManagementEnvelopeProblem.None)
? new ManagementRequestParse(ManagementRequest.For(operation!, server, arguments), ManagementEnvelopeProblem.None)
: new ManagementRequestParse(null, problem);
}
/// <summary>Reads the members of a reply object whose root is known to be an object.</summary>
/// <summary>Reads the members of a reply object whose root is known to be an object. The error is read by <see cref="ManagementError.FromWire"/>, and a reply is made by the same functions the service makes one with.</summary>
private static ManagementReplyParse ReadReply(JsonElement root)
{
ManagementEnvelopeProblem problem = CheckVersion(root);
@@ -328,11 +359,11 @@ public static class ManagementJson
return new ManagementReplyParse(null, problem);
}
bool hasResult = root.TryGetProperty("result", out JsonElement result) && result.ValueKind == JsonValueKind.Object;
ManagementError? error = root.TryGetProperty("error", out JsonElement failure) ? ReadError(failure) : null;
ManagementError? error = root.TryGetProperty("error", out JsonElement failure) ? ManagementError.FromWire(failure) : null;
ManagementReply? reply = root.TryGetProperty("ok", out JsonElement ok) switch
{
true when ok.ValueKind == JsonValueKind.True && hasResult => new ManagementReply(ManagementProtocol.Version, true, ManagementPayload.FromElement(result)),
true when ok.ValueKind == JsonValueKind.False && error is not null => new ManagementReply(ManagementProtocol.Version, false, Error: error),
true when ok.ValueKind == JsonValueKind.True && hasResult => ManagementReply.Success(ManagementPayload.FromElement(result)),
true when ok.ValueKind == JsonValueKind.False && error is not null => ManagementReply.Failure(error),
_ => null,
};
return new ManagementReplyParse(reply, reply is null ? ManagementEnvelopeProblem.ReplyIncomplete : ManagementEnvelopeProblem.None);
@@ -340,17 +371,22 @@ public static class ManagementJson
/// <summary>Reads an error object, or returns null when it lacks a code or a message as text. Details of any kind but an object are dropped, because every details type is an object.</summary>
private static ManagementError? ReadError(JsonElement error)
/// <summary>
/// The text of a JSON string, or null when it has none to give. JSON's grammar lets a string hold the escape of a
/// surrogate without its partner (<c>\ud800</c> on its own), which is not text: it cannot be turned into a string,
/// and the platform throws when asked to. The envelope's own strings are read through here, so such a value is
/// answered like any other value that is not what its member needs, and never thrown.
/// </summary>
internal static string? TextOf(JsonElement value)
{
if(error.ValueKind != JsonValueKind.Object
|| !error.TryGetProperty("code", out JsonElement code) || code.ValueKind != JsonValueKind.String
|| !error.TryGetProperty("message", out JsonElement message) || message.ValueKind != JsonValueKind.String)
try
{
return value.GetString();
}
catch(InvalidOperationException)
{
return null;
}
ManagementPayload? details = error.TryGetProperty("details", out JsonElement found) && found.ValueKind == JsonValueKind.Object ? ManagementPayload.FromElement(found) : null;
return new ManagementError(code.ToString(), message.ToString(), details);
}
@@ -379,14 +415,18 @@ public static class ManagementJson
/// True when a list the type knows, at any depth, has a null among its items. The element is walked beside the
/// type the serializer will read it into, so only members of the record are looked at: a member this version does
/// not know is skipped whole, as the serializer skips it, and what a later version puts in it (a list with a null
/// included) cannot make today's reader refuse the reply.
/// included) cannot make today's reader refuse the reply. The walk goes wherever a list can stand: through a
/// record's members, a list's items, the values of a dictionary (which are held to the same rule as a list's
/// items) and a value type that may be null. No record of the contract has a dictionary or such a value type
/// today; the walk follows them so that one added later is not a way around the rule.
/// </summary>
private static bool HasNullInAKnownList(JsonElement element, Type type, JsonSerializerOptions options)
{
JsonTypeInfo known = options.GetTypeInfo(type);
JsonTypeInfo known = options.GetTypeInfo(Nullable.GetUnderlyingType(type) ?? type);
return (known.Kind, element.ValueKind) switch
{
(JsonTypeInfoKind.Enumerable, JsonValueKind.Array) => element.EnumerateArray().Any(item => item.ValueKind == JsonValueKind.Null || HasNullInAKnownList(item, known.ElementType!, options)),
(JsonTypeInfoKind.Dictionary, JsonValueKind.Object) => element.EnumerateObject().Any(entry => entry.Value.ValueKind == JsonValueKind.Null || HasNullInAKnownList(entry.Value, known.ElementType!, options)),
(JsonTypeInfoKind.Object, JsonValueKind.Object) => element.EnumerateObject().Any(member => MemberType(known, member.Name) is { } memberType && HasNullInAKnownList(member.Value, memberType, options)),
_ => false,
};
@@ -399,6 +439,32 @@ public static class ManagementJson
/// <summary>
/// Writes one envelope as text. The envelope is written here and not by the serializer, so its form does not depend
/// on any options object: one line, never indented, with the escaping the serializer's own default gives, which is
/// also what the arguments, results and details inside it were written with. A payload that was read from the
/// wire can hold, in a value, the escape of a surrogate without its partner; that is not text and cannot be
/// written, and the failure is a <see cref="JsonException"/>, as it was when the serializer wrote the envelope.
/// </summary>
private static string WriteLine(Action<Utf8JsonWriter> write)
{
ArrayBufferWriter<byte> buffer = new();
using(Utf8JsonWriter writer = new(buffer))
{
try
{
write(writer);
}
catch(InvalidOperationException exception)
{
throw new JsonException("The envelope holds a value that is not text (a surrogate without its partner) and cannot be written.", exception);
}
}
return Encoding.UTF8.GetString(buffer.WrittenSpan);
}
/// <summary>Writes a request's envelope: the version, the operation, the server when one is named and the arguments when there are any.</summary>
private static void WriteRequest(Utf8JsonWriter writer, ManagementRequest request)
{
@@ -488,20 +554,30 @@ public static class ManagementJson
/// <summary>
/// Writes one of the envelope's types and refuses to read it. The serializer would read an envelope without any of
/// the checks <see cref="ParseRequest"/> and <see cref="ParseReply"/> make (another version, a malformed operation
/// name, a member the envelope does not have), so reading through it is closed and the refusal names the function
/// to use instead.
/// What the serializer does with one of the envelope's types: nothing. It would read an envelope without any of the
/// checks <see cref="ParseRequest"/> and <see cref="ParseReply"/> make (another version, a malformed operation name,
/// a member the envelope does not have), and with options of a caller's choosing it could write one indented across
/// lines, which the pipe's framing cannot carry. So both are closed, and the refusal names the functions to use
/// instead. Each envelope type names this converter in an attribute on the type itself, which is why the refusal
/// holds for the default options and for options built elsewhere, and not only for the two this contract builds.
/// It does not hold against options that carry a converter written for the envelope type: the serializer asks the
/// options before it asks the type.
/// </summary>
private sealed class WriteOnlyConverter<TEnvelope>(Action<Utf8JsonWriter, TEnvelope> write, string readWith) : JsonConverter<TEnvelope>
internal sealed class NotBySerializer<TEnvelope> : JsonConverter<TEnvelope>
{
/// <summary>Refuses, naming the function that reads this type with its checks.</summary>
public override TEnvelope Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
=> throw new NotSupportedException($"A {typeof(TEnvelope).Name} is not read by the serializer. Read it with {nameof(ManagementJson)}.{readWith}, which checks the envelope.");
/// <summary>Refuses to read.</summary>
public override TEnvelope Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => throw Refusal();
/// <summary>Writes the value the one way the wire carries it.</summary>
public override void Write(Utf8JsonWriter writer, TEnvelope value, JsonSerializerOptions options) => write(writer, value);
/// <summary>Refuses to write.</summary>
public override void Write(Utf8JsonWriter writer, TEnvelope value, JsonSerializerOptions options) => throw Refusal();
/// <summary>The refusal, naming the functions that read an envelope with its checks and the one that writes it.</summary>
private static NotSupportedException Refusal()
=> new($"A {typeof(TEnvelope).Name} is neither read nor written by the serializer. Read a request with {nameof(ManagementJson)}.{nameof(ParseRequest)} and a reply with "
+ $"{nameof(ManagementJson)}.{nameof(ParseReply)}, which check the envelope, and write either with {nameof(ManagementJson)}.{nameof(ManagementJson.Write)}.");
}
}
@@ -8,12 +8,19 @@ namespace Qqq.PrintAgent.Windows.Management;
/// <summary>
/// Everything the pipe needs to know about one operation before running it: its name on the wire, whether it names a
/// server, how it behaves beside others, how long the service lets it run, whether it still answers while the stored
/// data is not ready (written by a newer build, or an upgrade that could not finish), and the records its arguments
/// and its result are read into. The service and its clients both read the time limit from here, so they cannot drift
/// apart the way two hard-coded lists of operation names could.
/// data is not ready (written by a newer build, or an upgrade that could not finish), the records its arguments
/// and its result are read into, and whether it is single-flight. The service and its clients both read the time limit
/// from here, so they cannot drift apart the way two hard-coded lists of operation names could.
/// <para>
/// A single-flight operation does work of its own that must not be multiplied by the number of clients asking for it
/// (ADR-0004, "<c>printers.discover</c> is single-flight"): the computer runs it once at a time, and a request that
/// arrives while it is running, whoever started it, joins that run and is answered with its result. Each request
/// keeps its own time limit. Only an operation whose requests cannot differ can be shared this way, so a
/// single-flight operation is a read on the computer that takes no arguments.
/// </para>
/// </summary>
public sealed record ManagementOperationInfo(string Name, ManagementScope Scope, ManagementOperationKind Kind, TimeSpan ServerTimeLimit, bool AvailableWhenDegraded,
Type? ArgumentsType, Type ResultType)
Type? ArgumentsType, Type ResultType, bool SingleFlight = false)
{
/// <summary>
/// How long a client waits for the reply: the service's limit and a margin for the reply to arrive, so the client
@@ -8,7 +8,12 @@ namespace Qqq.PrintAgent.Windows.Management;
/// <summary>How an operation behaves beside others, which is what the pipe's concurrency rules are written in.</summary>
public enum ManagementOperationKind
{
/// <summary>Changes nothing and never waits for another operation.</summary>
/// <summary>
/// Changes nothing, and never waits for an operation that changes something. A read can still take time of its own,
/// and one kind of read waits for another read of the same operation: a read whose table entry is marked
/// <see cref="ManagementOperationInfo.SingleFlight"/> runs one at a time for the computer, and a request that
/// arrives while one is running joins it and gets its result.
/// </summary>
Read,
/// <summary>Changes something other than a server's session, pairing or trust.</summary>
@@ -35,7 +35,7 @@ public static class ManagementOperations
/// <summary>Stores a new server together with its certificate exception and its session, or nothing.</summary>
/// <summary>Stores a new server together with its certificate exception and its session, or nothing. The request states which sign-in provider the check showed the operator: the origin the check reported, or none when the member is left out. Nothing is stored and the answer is <see cref="ManagementErrorCode.ProviderChanged"/> when the provider the service reads is not that one, which includes reading one where the check named none and none where it named one.</summary>
public const string ServersAdd = "servers.add";
@@ -55,7 +55,15 @@ public static class ManagementOperations
/// <summary>Discovers this computer's Windows printers now and returns them with the time. A snapshot only reports the list the service last discovered; a client that needs a fresh one (the wizard's printer step, the command line's <c>printers</c>) asks here.</summary>
/// <summary>
/// Discovers this computer's Windows printers now and returns them with the time. A snapshot only reports the list
/// the service last discovered; a client that needs a fresh one (the wizard's printer step, the command line's
/// <c>printers</c>) asks here. It is a read, and the one single-flight operation
/// (<see cref="ManagementOperationInfo.SingleFlight"/>): a discovery loads printer drivers in a helper process, so the
/// computer runs one at a time, and a request that arrives while one is running, whether a client or the service's
/// own refresh started it, joins that discovery and gets its result. A hung driver and a client that retries then
/// cost one helper process, not one for each request.
/// </summary>
public const string PrintersDiscover = "printers.discover";
@@ -120,7 +128,7 @@ public static class ManagementOperations
/// <summary>Switches a server's certificate exception on or off.</summary>
/// <summary>Switches a server's certificate exception on or off. A request that switches it on states which sign-in provider the check showed the operator: the origin the check reported, or none when the member is left out. Nothing is changed and the answer is <see cref="ManagementErrorCode.ProviderChanged"/> when the provider the service reads is not that one, which includes reading one where the check named none and none where it named one. A request that switches it off accepts nothing, so nothing is compared.</summary>
public const string TrustSet = "trust.set";
@@ -189,7 +197,7 @@ public static class ManagementOperations
Computer(ServersReorder, ManagementOperationKind.Change, typeof(ServerListResult), typeof(ServersReorderArgs)),
Computer(ComputerGetProfile, ManagementOperationKind.Read, typeof(DeviceProfile)),
Computer(ComputerSetProfile, ManagementOperationKind.Change, typeof(DeviceProfile), typeof(ComputerSetProfileArgs)),
Computer(PrintersDiscover, ManagementOperationKind.Read, typeof(PrintersDiscoverResult)),
Computer(PrintersDiscover, ManagementOperationKind.Read, typeof(PrintersDiscoverResult), singleFlight: true),
Computer(LogsGetSettings, ManagementOperationKind.Read, typeof(LogSettings), degraded: true),
Computer(LogsSetSettings, ManagementOperationKind.Change, typeof(LogSettings), typeof(LogsSetSettingsArgs), degraded: true),
Computer(LogsClear, ManagementOperationKind.Change, typeof(LogsClearResult), degraded: true),
@@ -226,20 +234,18 @@ public static class ManagementOperations
/// <summary>
/// Decides whether a request whose envelope was read can be routed, and to which operation. It returns null when it
/// can, and otherwise the error to answer: the version is not this contract's; the operation does not exist; an
/// operation on the computer names a server; an operation on one server names none, or names it by anything but a
/// full identifier; or an operation that takes no arguments is given some (an empty object is accepted, because it
/// says the same as leaving the arguments out). It is pure, so the dispatcher and the tests share one rule. The
/// messages are fixed and never repeat what the request said.
/// can, and otherwise the error to answer: the operation does not exist; an operation on the computer names a
/// server; an operation on one server names none; or an operation that takes no arguments is given some (an empty
/// object is accepted, because it says the same as leaving the arguments out). It is pure, so the dispatcher and
/// the tests share one rule. The messages are fixed and never repeat what the request said. Three things are not
/// asked here because no request can be otherwise (<see cref="ManagementRequest"/>): its version is this
/// contract's, its operation is shaped like a name, and a server it names is named by a full identifier. Text
/// that breaks one of them is refused by the parser, which is where <see cref="ManagementErrorCode.UnsupportedVersion"/>
/// is answered.
/// </summary>
public static ManagementError? Check(ManagementRequest request, out ManagementOperationInfo? operation)
{
ArgumentNullException.ThrowIfNull(request);
operation = null;
if(request.V != ManagementProtocol.Version)
{
return new ManagementError(ManagementErrorCode.UnsupportedVersion, "This service speaks version 2 of the management protocol.");
}
if(!TryFind(request.Op, out operation))
{
return new ManagementError(ManagementErrorCode.UnknownOperation, "There is no operation with this name.");
@@ -248,7 +254,7 @@ public static class ManagementOperations
{
return new ManagementError(ManagementErrorCode.InvalidRequest, "This operation acts on the computer and must not name a server.");
}
if(operation.Scope == ManagementScope.Server && !ManagementProtocol.IsServerId(request.Server))
if(operation.Scope == ManagementScope.Server && request.Server is null)
{
return new ManagementError(ManagementErrorCode.InvalidRequest, "This operation acts on one server and must name it by its full identifier.");
}
@@ -259,8 +265,9 @@ public static class ManagementOperations
/// <summary>Describes an operation on the computer or the list of servers.</summary>
private static ManagementOperationInfo Computer(string name, ManagementOperationKind kind, Type result, Type? arguments = null, bool isLong = false, bool degraded = false)
=> new(name, ManagementScope.Computer, kind, isLong ? LongTimeLimit : OrdinaryTimeLimit, degraded, arguments, result);
private static ManagementOperationInfo Computer(string name, ManagementOperationKind kind, Type result, Type? arguments = null, bool isLong = false, bool degraded = false,
bool singleFlight = false)
=> new(name, ManagementScope.Computer, kind, isLong ? LongTimeLimit : OrdinaryTimeLimit, degraded, arguments, result, singleFlight);
@@ -4,6 +4,7 @@
*/
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Qqq.PrintAgent.Windows.Management;
@@ -14,6 +15,7 @@ namespace Qqq.PrintAgent.Windows.Management;
/// content when it is put into a log line or an interpolated string, and arguments can hold a session credential or an
/// enrollment code and one result holds the provider's sign-out request. The text form of a payload is fixed.
/// </summary>
[JsonConverter(typeof(ManagementJson.NotBySerializer<ManagementPayload>))]
public sealed class ManagementPayload
{
/// <summary>The JSON object, copied so it outlives the document it was read from. It is private so nothing prints it by reaching through the payload.</summary>
@@ -29,10 +31,10 @@ public sealed class ManagementPayload
/// <summary>Writes a record the way the wire carries it.</summary>
/// <summary>Writes a record the way the wire carries it. A value that is not written as a JSON object (a list, text, nothing at all) is refused, as <see cref="FromElement"/> refuses one: every argument, result and details record is an object, and a reply built around anything else would be unreadable to its client.</summary>
public static ManagementPayload From<TValue>(TValue value)
where TValue : class
=> new(JsonSerializer.SerializeToElement(value, ManagementJson.Options));
=> FromElement(JsonSerializer.SerializeToElement(value, ManagementJson.Options));
@@ -39,12 +39,18 @@ public static class ManagementProtocol
/// <summary>The most UTF-16 units of an error's message that are sent, whatever the code; a longer message is cut there and marked with an ellipsis. One bound for all, so no code's message can flood a log or a screen.</summary>
/// <summary>The most UTF-16 units of an error's message that are sent, whatever the code. A longer message is cut there, never inside a surrogate pair, and nothing is added to mark the cut, so the bound is exact (<see cref="ManagementText"/>). One bound for all, so no code's message can flood a log or a screen.</summary>
public const int MaximumMessageCharacters = 512;
/// <summary>The most UTF-16 units of an identity value (a principal, a tenant) that are sent: the 200 the QQQ contract lets such an identifier run to.</summary>
/// <summary>
/// The most UTF-16 units of an identity value (a principal, a tenant) that are sent, wherever a reply names one: in
/// <see cref="ServerAuthentication"/> and in <see cref="IdentityChangedDetails"/>. A longer value is cut there,
/// never inside a surrogate pair, with nothing added to mark the cut (<see cref="ManagementText"/>). 200 is this
/// contract's bound; the session client itself admits an identity value of up to 1,024 units, so a value between
/// the two is carried cut.
/// </summary>
public const int MaximumIdentityCharacters = 200;
@@ -3,49 +3,97 @@
* SPDX-License-Identifier: MIT
*/
using Qqq.PrintAgent.Core.Authentication;
using System.Text.Json.Serialization;
namespace Qqq.PrintAgent.Windows.Management;
/// <summary>
/// One reply on the management pipe: the contract version, whether the operation succeeded, and either its result or
/// its error. A reply carries the version of the request it answers, so a client never has to read a reply of another
/// version than it asked in. The result stays untyped here because its type depends on the operation;
/// version than it asked in; the one exception is <see cref="ManagementErrorCode.UnsupportedVersion"/>, which is
/// answered in this version whatever version was asked for. The result stays untyped here because its type depends on the operation;
/// <see cref="ManagementJson"/> reads it into the operation's own record. One result, that of a sign-out, carries the
/// identity provider's sign-out request, so neither the text form of a reply nor that of its result shows it.
/// <para>
/// A reply is built only by the <see cref="Success{TResult}"/> and <see cref="Failure(ManagementError)"/> functions,
/// which the parser uses too, and no member can be set afterwards. So every reply that exists is of this contract's
/// version and holds exactly one of the two: a success its result and no error, a failure its error and no result.
/// </para>
/// </summary>
public sealed record ManagementReply(int V, bool Ok, ManagementPayload? Result = null, ManagementError? Error = null)
[JsonConverter(typeof(ManagementJson.NotBySerializer<ManagementReply>))]
public sealed record ManagementReply
{
/// <summary>Stores a reply. Only the functions below call this, each with exactly one of the two.</summary>
private ManagementReply(ManagementPayload? result, ManagementError? error)
{
Result = result;
Error = error;
}
/// <summary>The contract version the reply is in. It is always this contract's: a reply of another version is not read, and none can be built.</summary>
public int V { get; } = ManagementProtocol.Version;
/// <summary>True when the operation succeeded, which is when the reply holds a result and no error.</summary>
public bool Ok => Error is null;
/// <summary>The operation's result as the JSON object the wire carries; null exactly when the operation failed.</summary>
public ManagementPayload? Result { get; }
/// <summary>Why the operation failed; null exactly when it succeeded.</summary>
public ManagementError? Error { get; }
/// <summary>Builds the reply to an operation that succeeded, writing its result record the way the wire carries it. An operation with nothing to report answers <see cref="NoResult"/>, so a success always has a result object.</summary>
public static ManagementReply Success<TResult>(TResult result)
where TResult : class
=> new(ManagementProtocol.Version, true, ManagementPayload.From(result));
=> Success(ManagementPayload.From(result));
/// <summary>Builds the reply to an operation that succeeded around a result that is already the JSON object the wire carries: what the parser does with a success it read.</summary>
public static ManagementReply Success(ManagementPayload result)
{
ArgumentNullException.ThrowIfNull(result);
return new ManagementReply(result, null);
}
/// <summary>
/// Builds the reply to an operation that failed with a code that carries no details. The message may repeat what a
/// server said (the reason of <see cref="ManagementErrorCode.ServerRefused"/>, for one), so it is never sent as given:
/// it goes through <see cref="QqqCompatibilityProblem.Printable"/>, the one definition Core uses for text a server
/// supplied, and is cut at <see cref="ManagementProtocol.MaximumMessageCharacters"/>. A control character, a line
/// separator or a character that reorders text therefore cannot reach a client's screen or log through any code.
/// server said (the reason of <see cref="ManagementErrorCode.ServerRefused"/>, for one); <see cref="ManagementError"/>
/// makes it printable and cuts it, here as for an error built any other way, and refuses a code that is not in the
/// table or that carries details.
/// </summary>
public static ManagementReply Failure(string code, string message) => new(ManagementProtocol.Version, false, Error: new ManagementError(code, Shown(message)));
public static ManagementReply Failure(string code, string message) => Failure(new ManagementError(code, message));
/// <summary>Builds the reply to an operation that failed with one of the codes that carry details, writing the details record the way the wire carries it. The message is made printable and cut exactly as in the other factory.</summary>
/// <summary>Builds the reply to an operation that failed with one of the codes that carry details, writing the details record the way the wire carries it. The record must be the one the table names for the code (<see cref="ManagementError.WithDetails"/>).</summary>
public static ManagementReply Failure<TDetails>(string code, string message, TDetails details)
where TDetails : class
=> new(ManagementProtocol.Version, false, Error: new ManagementError(code, Shown(message), ManagementPayload.From(details)));
=> Failure(ManagementError.WithDetails(code, message, details));
/// <summary>Builds the reply that carries an error that already exists, such as the refusal <see cref="ManagementOperations.Check"/> returns, so no failure has to be sent by building a reply member by member.</summary>
public static ManagementReply Failure(ManagementError error)
{
ArgumentNullException.ThrowIfNull(error);
return new ManagementReply(null, error);
}
/// <summary>Says whether the operation succeeded and, if not, with which code. The result is never shown, so logging a reply cannot reveal the provider's sign-out request.</summary>
public override string ToString() => Ok ? $"Management reply v{V} ok (result not shown)" : $"Management reply v{V} failed: {Error?.Code ?? "no error given"}";
/// <summary>An error's message as it is sent: printable, and at most <see cref="ManagementProtocol.MaximumMessageCharacters"/> UTF-16 units before the mark of a cut.</summary>
private static string Shown(string message) => QqqCompatibilityProblem.Printable(message, ManagementProtocol.MaximumMessageCharacters);
public override string ToString() => Error is null ? $"Management reply v{V} ok (result not shown)" : $"Management reply v{V} failed: {Error.Code}";
}
@@ -3,6 +3,8 @@
* SPDX-License-Identifier: MIT
*/
using System.Text.Json.Serialization;
namespace Qqq.PrintAgent.Windows.Management;
/// <summary>
@@ -11,18 +13,63 @@ namespace Qqq.PrintAgent.Windows.Management;
/// as. The arguments stay untyped here because their type depends on the operation; <see cref="ManagementJson"/> reads
/// them into the operation's own record. Arguments can hold a session credential or an enrollment code, so neither the
/// text form of a request nor that of its arguments shows them.
/// <para>
/// A request is built only by the <see cref="For(string, string)"/> functions, which the parser uses too, and no
/// member can be set afterwards. So every request that exists is one the parser would read back: it is of this
/// contract's version, its operation has the shape of an operation's name, and its server, when it names one, is a
/// full identifier in the one spelling the wire carries. Whether the operation exists, and whether it should name a
/// server, is still decided by <see cref="ManagementOperations.Check"/>.
/// </para>
/// </summary>
public sealed record ManagementRequest(int V, string Op, string? Server = null, ManagementPayload? Args = null)
[JsonConverter(typeof(ManagementJson.NotBySerializer<ManagementRequest>))]
public sealed record ManagementRequest
{
/// <summary>Builds a request of this contract's version without arguments: for an operation that takes none, or one whose arguments are all optional.</summary>
public static ManagementRequest For(string operation, string? server = null) => new(ManagementProtocol.Version, operation, server);
/// <summary>Stores a request after checking the operation's name and the server's identifier. The refusals are fixed text and never repeat what was given.</summary>
private ManagementRequest(string operation, string? server, ManagementPayload? arguments)
{
ArgumentNullException.ThrowIfNull(operation);
Op = ManagementJson.IsOperationName(operation) ? operation
: throw new ArgumentException("An operation's name is lower-case letters, optionally followed by one dot and a camel-case word, and at most 64 characters.", nameof(operation));
Server = server is null || ManagementProtocol.IsServerId(server) ? server : throw new ArgumentException("A request names a server by its full identifier, in lower case with hyphens.", nameof(server));
Args = arguments;
}
/// <summary>Builds a request of this contract's version, writing the operation's argument record the way the wire carries it.</summary>
/// <summary>The contract version the request is in. It is always this contract's: a request of another version is not read, and none can be built.</summary>
public int V { get; } = ManagementProtocol.Version;
/// <summary>The operation's name as the wire carries it. It has the shape of a name; whether such an operation exists is <see cref="ManagementOperations.Check"/>'s to say.</summary>
public string Op { get; }
/// <summary>The identifier of the server the operation acts on, or null for an operation on the computer or on the list of servers.</summary>
public string? Server { get; }
/// <summary>The operation's arguments as the JSON object the wire carries, or null when the request has none.</summary>
public ManagementPayload? Args { get; }
/// <summary>Builds a request without arguments: for an operation that takes none, or one whose arguments are all optional. A name that is not shaped like an operation's, or a server that is not a full identifier, is refused.</summary>
public static ManagementRequest For(string operation, string? server = null) => new(operation, server, null);
/// <summary>Builds a request, writing the operation's argument record the way the wire carries it. A name that is not shaped like an operation's, or a server that is not a full identifier, is refused.</summary>
public static ManagementRequest For<TArguments>(string operation, string? server, TArguments arguments)
where TArguments : class
=> new(ManagementProtocol.Version, operation, server, ManagementPayload.From(arguments));
=> new(operation, server, ManagementPayload.From(arguments));
/// <summary>Builds a request around arguments that are already the JSON object the wire carries, or around none: what the parser does with a request it read. The operation's name and the server are checked as for every request.</summary>
public static ManagementRequest For(string operation, string? server, ManagementPayload? arguments) => new(operation, server, arguments);
@@ -0,0 +1,70 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Text;
using Qqq.PrintAgent.Core;
namespace Qqq.PrintAgent.Windows.Management;
/// <summary>
/// The form of free text in a typed member of the management pipe: an error's message, and the identity values a
/// server supplied. Which characters are shown is Core's one definition (<see cref="ServerText.IsShown"/>); the rest of
/// the rule is the contract's own, because a wire member is a value and not a piece of a sentence:
/// <list type="bullet">
/// <item>Every code point Core's definition does not show becomes a space, and a surrogate without its partner becomes U+FFFD. That is about a string the service or a client already holds. On the wire such a surrogate can only be written as an escape (<c>\ud800</c> on its own), which is not text and never reaches this rule: <see cref="ManagementJson"/> does not read an envelope whose own strings hold one, nor a record that holds one in a value.</item>
/// <item>The text is cut at the limit exactly, never inside a surrogate pair, and nothing is added to say that it was cut. A value is at most the limit, whatever it held.</item>
/// <item>Applying the rule to its own result changes nothing, so the reader of a reply applies it again without harm.</item>
/// <item>No words stand in for a value that is missing. A member that may be absent stays absent, and a member that must say something refuses to be built from nothing.</item>
/// </list>
/// </summary>
internal static class ManagementText
{
/// <summary>
/// Makes text printable and cuts it at the limit, in UTF-16 units. A code point that would pass the limit is left
/// out whole and reading stops there, so the result is never longer than the limit, never ends in half a surrogate
/// pair, and costs the same for a value of any length.
/// </summary>
internal static string Bounded(string value, int limit)
{
StringBuilder shown = new(Math.Min(value.Length, limit));
foreach(Rune rune in value.EnumerateRunes())
{
if(shown.Length + rune.Utf16SequenceLength > limit)
{
break;
}
shown.Append(ServerText.IsShown(rune) ? rune.ToString() : " ");
}
return shown.ToString();
}
/// <summary>
/// An error's message as the wire carries it: printable and at most <see cref="ManagementProtocol.MaximumMessageCharacters"/>
/// UTF-16 units. A message with nothing to show (missing, empty, or only spaces once it is printable) is refused:
/// a failure with no words for the operator is a mistake in whoever built it, and no stand-in text is sent for it.
/// </summary>
internal static string Message(string message)
{
ArgumentNullException.ThrowIfNull(message);
string shown = Bounded(message, ManagementProtocol.MaximumMessageCharacters);
return string.IsNullOrWhiteSpace(shown) ? throw new ArgumentException("An error's message must say something.", nameof(message)) : shown;
}
/// <summary>
/// An identity value (a principal, a tenant) as the wire carries it, wherever the contract carries one: printable
/// and at most <see cref="ManagementProtocol.MaximumIdentityCharacters"/> UTF-16 units. A value that is missing, or
/// has nothing to show, is null, and the member is then left out of the text; it is never replaced by words a
/// client could take for an account's name.
/// </summary>
internal static string? Identity(string? value)
{
string? shown = value is null ? null : Bounded(value, ManagementProtocol.MaximumIdentityCharacters);
return string.IsNullOrWhiteSpace(shown) ? null : shown;
}
}
@@ -6,8 +6,9 @@
namespace Qqq.PrintAgent.Windows.Management;
/// <summary>
/// The result of <see cref="ManagementOperations.PrintersDiscover"/>: this computer's Windows printers as a discovery run
/// for this request found them, and when it finished. It is the same list and the same time the next snapshot reports
/// The result of <see cref="ManagementOperations.PrintersDiscover"/>: this computer's Windows printers as the discovery
/// this request started or joined found them, and when it finished. Requests that arrived while one discovery was
/// running all carry that discovery's list and time. It is the same list and the same time the next snapshot reports
/// in its computer part, so a client that asked here does not have to wait for one.
/// </summary>
public sealed record PrintersDiscoverResult(IReadOnlyList<ComputerPrinter> Printers, DateTimeOffset DiscoveredAt);
@@ -6,4 +6,4 @@
namespace Qqq.PrintAgent.Windows.Management;
/// <summary>The result of <see cref="ManagementOperations.ServersAdd"/>: the server as it was stored, with the identifier it was given, and who its session is signed in as.</summary>
public sealed record ServerAddResult(ServerSummary Server, AgentAuthenticationStatus Authentication);
public sealed record ServerAddResult(ServerSummary Server, ServerAuthentication Authentication);
@@ -0,0 +1,75 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Diagnostics.CodeAnalysis;
using Qqq.PrintAgent.Core.Authentication;
namespace Qqq.PrintAgent.Windows.Management;
/// <summary>
/// What a server's address may be: the one definition the service and the companion both call for the address an
/// operator types, where the rule used to be written out again wherever an address was read. It is the rule the session
/// client applies to the origin it is built for, so an address this accepts is one a session can be made for, and it
/// adds one thing the session client leaves to others: the host must have a plain ASCII name
/// (<see cref="QqqOrigin.Of"/>), because that name is what is stored, compared and shown. For the same reason an IPv6
/// literal with a zone (<c>[fe80::1%25eth0]</c>) is refused: the text of an origin has no place for the zone, so two
/// such addresses that differ only in it would be stored, compared and shown as one place. An address is accepted
/// when, after surrounding white space is removed:
/// <list type="bullet">
/// <item>it is an absolute address;</item>
/// <item>its scheme is HTTPS, or plain HTTP for this computer itself (a loopback host), which exists for development;</item>
/// <item>it carries no user information, no query and no fragment;</item>
/// <item>it has no path, since a server is its origin;</item>
/// <item>its host has an ASCII name, and is not an IPv6 literal with a zone.</item>
/// </list>
/// A caller that goes on to make a session for the server builds the session client from the origin this returns,
/// never from the text the operator typed: for a host written outside ASCII the two differ (the text keeps the
/// Unicode name, the origin has the name the connection is made to), and only the origin is what was judged, and what
/// is stored and shown.
/// </summary>
public static class ServerAddress
{
/// <summary>
/// Reads the address an operator typed. On success it returns the server's origin in the one form an origin is
/// kept in (scheme, the host's ASCII name and port, with the path "/"); otherwise it says what is wrong, reporting
/// the first problem in the order of <see cref="ServerAddressProblem"/>. Nothing is logged or thrown, whatever the
/// text holds.
/// </summary>
public static bool TryRead(string? text, [NotNullWhen(true)] out Uri? origin, out ServerAddressProblem problem)
{
Uri? address = null;
string trimmed = text?.Trim() ?? "";
problem = trimmed.Length == 0 ? ServerAddressProblem.Empty
: !Uri.TryCreate(trimmed, UriKind.Absolute, out address) ? ServerAddressProblem.NotAnAddress
: Judge(address);
origin = problem == ServerAddressProblem.None ? QqqOrigin.Of(address!) : null;
return origin is not null;
}
/// <summary>Says what is wrong with an absolute address as a server's address, or nothing.</summary>
private static ServerAddressProblem Judge(Uri address)
{
bool secure = string.Equals(address.Scheme, Uri.UriSchemeHttps, StringComparison.Ordinal);
bool thisComputer = string.Equals(address.Scheme, Uri.UriSchemeHttp, StringComparison.Ordinal) && address.IsLoopback;
return !secure && !thisComputer ? ServerAddressProblem.NotSecure
: address.UserInfo.Length != 0 ? ServerAddressProblem.HasUserInformation
: address.Query.Length != 0 || address.Fragment.Length != 0 ? ServerAddressProblem.HasQueryOrFragment
: !string.Equals(address.AbsolutePath, "/", StringComparison.Ordinal) ? ServerAddressProblem.HasPath
: QqqOrigin.Of(address) is not { } origin || HasZone(origin) ? ServerAddressProblem.HostNotNameable
: ServerAddressProblem.None;
}
/// <summary>
/// True when an origin's host is an IPv6 literal with a zone. The origin form keeps the zone in the host it
/// connects to and drops it from its text, which is the form the contract stores, compares and shows; an origin
/// like that cannot be named by its text. Only an IPv6 literal can hold a percent sign in the host of an origin
/// (<see cref="QqqOrigin.Of"/>).
/// </summary>
internal static bool HasZone(Uri origin) => origin.IdnHost.Contains('%', StringComparison.Ordinal);
}
@@ -0,0 +1,38 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace Qqq.PrintAgent.Windows.Management;
/// <summary>
/// What is wrong with a server's address, as <see cref="ServerAddress.TryRead"/> reports it, so the companion can tell
/// the operator what to correct. The values are in the order the rule is checked: an address that breaks it in several
/// ways is reported under the first.
/// </summary>
public enum ServerAddressProblem
{
/// <summary>Nothing: the address names a server's origin.</summary>
None,
/// <summary>The address is missing, empty or only white space.</summary>
Empty,
/// <summary>The text is not an absolute address: it has no scheme, or cannot be read as one.</summary>
NotAnAddress,
/// <summary>The address is not HTTPS. Plain HTTP is accepted for this computer itself only, for development.</summary>
NotSecure,
/// <summary>The address carries a user name or a password, which must never be part of a stored or logged address.</summary>
HasUserInformation,
/// <summary>The address has a query or a fragment.</summary>
HasQueryOrFragment,
/// <summary>The address has a path. A server is named by its origin: scheme, host and port.</summary>
HasPath,
/// <summary>The address's host cannot be named in the text of an origin: it has no plain ASCII name, so it cannot be compared or shown as the place a connection is really made to, or it is an IPv6 literal with a zone, which that text has no place for.</summary>
HostNotNameable,
}
@@ -0,0 +1,73 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Text.Json.Serialization;
namespace Qqq.PrintAgent.Windows.Management;
/// <summary>
/// Who is signed in to a server, as the pipe carries it: whether a session exists, the principal and the tenant it is
/// for, the text that explains the state, and whether printing is authorized. It carries no cookie, password or device
/// proof. The principal and the tenant are values the server supplied, so they are never held as given: the record has
/// one constructor, which gives each the form of an identity value on the wire (<see cref="ManagementText.Identity"/>:
/// printable, and cut at <see cref="ManagementProtocol.MaximumIdentityCharacters"/> exactly), the same form
/// <see cref="IdentityChangedDetails"/> gives the approved account. So one account reads the same wherever a reply
/// names it, in a snapshot, in the result of adding a server or signing in, and in the details of a changed identity.
/// <see cref="From"/> builds the record from the service's own status through that constructor, and the serializer
/// reads the wire through it, so a client holds bounded values even when a service did not send them so; a result
/// in which one of them is not text at all (the escape of a surrogate without its partner) is not read. A value that
/// is missing is absent and left out of the text. The message is the service's own wording today, and it is held to
/// the form of text on the wire all the same, by the same constructor: printable, and cut at
/// <see cref="ManagementProtocol.MaximumMessageCharacters"/> exactly, as an error's message is. So if a later handler
/// puts something a server said into it, that text cannot reach the wire or a client's screen raw.
/// </summary>
public sealed record ServerAuthentication
{
/// <summary>Takes the members as the service holds them or as the wire carried them, and gives the two identity values and the message their wire form. <see cref="From"/> and the serializer are its only callers.</summary>
[JsonConstructor]
private ServerAuthentication(bool signedIn, string? principalId, string? tenantId, string message, bool authorized = false)
{
ArgumentNullException.ThrowIfNull(message);
SignedIn = signedIn;
PrincipalId = ManagementText.Identity(principalId);
TenantId = ManagementText.Identity(tenantId);
Message = ManagementText.Bounded(message, ManagementProtocol.MaximumMessageCharacters);
Authorized = authorized;
}
/// <summary>True when this server has a session.</summary>
public bool SignedIn { get; }
/// <summary>The principal the session is for, printable and bounded, or null when there is none.</summary>
public string? PrincipalId { get; }
/// <summary>The tenant the session is for, printable and bounded, or null when there is none.</summary>
public string? TenantId { get; }
/// <summary>The text that explains the state to the operator: printable, and at most <see cref="ManagementProtocol.MaximumMessageCharacters"/> UTF-16 units. Today it is the service's own wording.</summary>
public string Message { get; }
/// <summary>True when the server has authorized this session to print.</summary>
public bool Authorized { get; }
/// <summary>Builds the record from the status the service keeps for a server, member by member.</summary>
public static ServerAuthentication From(AgentAuthenticationStatus status)
{
ArgumentNullException.ThrowIfNull(status);
return new ServerAuthentication(status.SignedIn, status.PrincipalId, status.TenantId, status.Message, status.Authorized);
}
}
@@ -0,0 +1,167 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Buffers;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Text;
using Qqq.PrintAgent.Core;
using Qqq.PrintAgent.Core.Authentication;
namespace Qqq.PrintAgent.Windows.Management;
/// <summary>
/// What a server's name may be (ADR-0004, "Server names"): the one definition the service and the companion both call.
/// The service enforces it where a name enters, in the arguments of <see cref="ManagementOperations.ServersAdd"/> and
/// <see cref="ManagementOperations.ServersRename"/>, and a name that breaks it is refused there with
/// <see cref="RefusalCode"/>; the companion asks <see cref="Check"/> while the operator types, so it can say what is
/// wrong before anything is sent. A name is accepted when, after surrounding white space is removed:
/// <list type="bullet">
/// <item>it has 1 to <see cref="MaximumCharacters"/> UTF-16 units;</item>
/// <item>every code point of it is one Core's definition of printable text shows (<see cref="ServerText.IsShown"/>), and no surrogate stands without its partner;</item>
/// <item>at least one code point of it draws something: a letter, a digit, a punctuation mark or a symbol, other than the few of those that draw nothing (the Hangul fillers and the blank Braille pattern). Core's definition shows those, and it shows a combining mark with nothing to sit on; a name made only of them and of spaces would be a server the operator cannot see in a list or tell from another;</item>
/// <item>it holds no square bracket, because square brackets are the delimiters of the service log's line prefixes (the marker of the service, a server's short identifier) and of other lines that put something in brackets, so a bracket in a name could forge that structure wherever a name is written into such a line. The fullwidth pair (U+FF3B, U+FF3D) is refused with the ASCII pair: it is not a delimiter to a program, but it reads as the same brackets to a person;</item>
/// <item>it is not shaped like a server identifier or like the first eight or more characters of one, in either letter case, because that is what the command line's <c>--server</c> takes as an identifier or a prefix (<see cref="ServerSelector"/>).</item>
/// </list>
/// The upgrade and the wizard propose a name from the server's address, and a host can break the rule (the brackets of
/// an IPv6 literal, a host of eight hexadecimal digits); <see cref="Propose"/> is how they get one that always passes.
/// </summary>
public static class ServerName
{
/// <summary>The most UTF-16 units a name may have, after surrounding white space is removed.</summary>
public const int MaximumCharacters = 64;
/// <summary>The code a request is refused with when it gives a name that breaks the rule.</summary>
public const string RefusalCode = ManagementErrorCode.InvalidRequest;
/// <summary>The square brackets a name may not hold: the ASCII pair, which the service log's prefixes are delimited by, and the fullwidth pair (U+FF3B, U+FF3D), which a person reads as the same brackets.</summary>
private static readonly SearchValues<char> s_squareBrackets = SearchValues.Create("[]\uFF3B\uFF3D");
/// <summary>How many characters a server identifier has in the one form the wire carries: 32 hexadecimal digits and four hyphens.</summary>
private const int IdentifierLength = 36;
/// <summary>What a proposed name is when the address gives nothing to name the server by, and what goes in front of a host that is shaped like an identifier.</summary>
private const string Fallback = "Server";
/// <summary>
/// Says what is wrong with a name, or <see cref="ServerNameProblem.None"/>. Surrounding white space is not part of
/// the name and is not judged. When a name breaks the rule in several ways the first of them in the order of
/// <see cref="ServerNameProblem"/> is reported, so the same name always gets the same answer.
/// </summary>
public static ServerNameProblem Check(string? name)
{
string trimmed = name?.Trim() ?? "";
return trimmed.Length == 0 ? ServerNameProblem.Empty
: trimmed.Length > MaximumCharacters ? ServerNameProblem.TooLong
: !IsPrintable(trimmed) ? ServerNameProblem.NotPrintable
: !trimmed.EnumerateRunes().Any(Draws) ? ServerNameProblem.ShowsNothing
: trimmed.AsSpan().ContainsAny(s_squareBrackets) ? ServerNameProblem.SquareBracket
: IsIdentifierOrItsBeginning(trimmed) ? ServerNameProblem.LikeAnIdentifier
: ServerNameProblem.None;
}
/// <summary>Decides whether a name may be a server's name, and returns it as it is stored: with surrounding white space removed and nothing else changed.</summary>
public static bool TryAccept(string? name, [NotNullWhen(true)] out string? accepted)
{
accepted = Check(name) == ServerNameProblem.None ? name!.Trim() : null;
return accepted is not null;
}
/// <summary>Returns the name as it is stored, or refuses one that breaks the rule. The refusal is fixed text and never repeats the name.</summary>
public static string Require(string? name)
=> TryAccept(name, out string? accepted) ? accepted : throw new ArgumentException("A server's name is 1 to 64 characters of visible text without square brackets, and is not shaped like a server identifier.", nameof(name));
/// <summary>
/// Proposes a name for the server at an address, and the proposal always passes the rule. It is the host, by the
/// ASCII name the connection is made to (<see cref="QqqOrigin.Of"/>), without the brackets of an IPv6 literal and
/// cut at <see cref="MaximumCharacters"/>. A host shaped like a server identifier or its beginning (eight
/// hexadecimal digits, for one) gets the word "Server" in front, which makes it a name and keeps the host readable;
/// an address that has no host to name is proposed as "Server" alone. The operator can change the proposal to any
/// name that passes.
/// </summary>
public static string Propose(Uri address)
{
ArgumentNullException.ThrowIfNull(address);
string host = (QqqOrigin.Of(address)?.Host ?? "").Trim('[', ']');
return TryAccept(Cut(host, MaximumCharacters), out string? proposed) ? proposed : Require(Fallback + " " + Cut(host, MaximumCharacters - Fallback.Length - 1));
}
/// <summary>The text, or its beginning when it is longer than the limit. A host's ASCII name holds no surrogate pair, so the cut cannot split one.</summary>
private static string Cut(string text, int limit) => text.Length > limit ? text[..limit] : text;
/// <summary>True when the text is made only of whole code points that are shown as themselves.</summary>
private static bool IsPrintable(string name)
{
for(int index = 0; index < name.Length;)
{
if(Rune.DecodeFromUtf16(name.AsSpan(index), out Rune rune, out int consumed) != OperationStatus.Done || !ServerText.IsShown(rune))
{
return false;
}
index += consumed;
}
return true;
}
/// <summary>
/// True for a code point that draws something on a screen, among the code points Core's definition shows. That
/// definition goes by category, and by category the Hangul fillers (U+115F, U+1160, U+3164, U+FFA0) are letters
/// and the blank Braille pattern (U+2800) is a symbol, though each draws nothing; a combining mark draws only
/// together with the character before it; and a space draws nothing by design. So a code point draws when it is
/// none of those. This is the contract's own addition for a name, where showing nothing is a trap for the
/// operator; Core's definition is not changed by it, and other text (a message, an identity value) is not held to
/// it. It names the code points known to draw nothing, and a font can always draw another as nothing.
/// </summary>
private static bool Draws(Rune rune)
=> rune.Value is not (0x115F or 0x1160 or 0x3164 or 0xFFA0 or 0x2800)
&& Rune.GetUnicodeCategory(rune) is not (UnicodeCategory.NonSpacingMark or UnicodeCategory.SpacingCombiningMark or UnicodeCategory.EnclosingMark or UnicodeCategory.SpaceSeparator);
/// <summary>
/// True when the text could be a server identifier or the first eight or more characters of one: hexadecimal digits
/// in either letter case with a hyphen in each place the identifier's one form has one. The command line compares a
/// <c>--server</c> value with identifiers without regard to letter case, so a name of this shape could select
/// another server than the one that bears it.
/// </summary>
private static bool IsIdentifierOrItsBeginning(string name)
{
if(name.Length is < ServerSelector.MinimumPrefixLength or > IdentifierLength)
{
return false;
}
for(int index = 0; index < name.Length; index++)
{
bool placeOfAHyphen = index is 8 or 13 or 18 or 23;
if(placeOfAHyphen ? name[index] != '-' : !char.IsAsciiHexDigit(name[index]))
{
return false;
}
}
return true;
}
}
@@ -0,0 +1,36 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace Qqq.PrintAgent.Windows.Management;
/// <summary>
/// What is wrong with a server's name, as <see cref="ServerName.Check"/> reports it, so the companion can tell the
/// operator which part of the rule the name breaks. The values are in the order the rule is checked: a name that breaks
/// it in several ways is reported under the first. The service does not send these; it refuses the request as an
/// invalid one, and the companion has already said why.
/// </summary>
public enum ServerNameProblem
{
/// <summary>Nothing: the name may be a server's name.</summary>
None,
/// <summary>The name is missing, empty or only white space.</summary>
Empty,
/// <summary>The name is longer than <see cref="ServerName.MaximumCharacters"/> UTF-16 units once surrounding white space is removed.</summary>
TooLong,
/// <summary>The name holds a character that is not shown as itself (a line break or another control character, a character that reorders or hides text, a private-use or unassigned one), or a surrogate without its partner.</summary>
NotPrintable,
/// <summary>Every character of the name is shown, yet the name shows nothing: it is made only of spaces, of combining marks with no character to sit on, and of characters that are letters or symbols by category and draw nothing (the Hangul fillers, the blank Braille pattern).</summary>
ShowsNothing,
/// <summary>The name holds a square bracket, ASCII or fullwidth (U+FF3B, U+FF3D).</summary>
SquareBracket,
/// <summary>The name is shaped like a server identifier or like the first eight or more characters of one, which the command line's <c>--server</c> would take for an identifier.</summary>
LikeAnIdentifier,
}
@@ -11,8 +11,10 @@ namespace Qqq.PrintAgent.Windows.Management;
/// by itself (and at its next start if it is stopped first), or finished. While it is in progress the server still answers
/// reads, and every change to it is refused with <see cref="ManagementErrorCode.ServerRemoving"/>, except a repeated
/// <see cref="ManagementOperations.ServersRemove"/>, which succeeds with <see cref="Removing"/> again so a client that
/// retried after a timeout is told what is true (<see cref="ManagementOperationInfo.AnsweredWhileRemoving"/>). A client
/// treats a state it does not know as still removing.
/// retried after a timeout is told what is true (<see cref="ManagementOperationInfo.AnsweredWhileRemoving"/>). Once
/// removal has finished the server no longer exists, so a <see cref="ManagementOperations.ServersRemove"/> repeated
/// after that is answered <see cref="ManagementErrorCode.ServerNotFound"/>, which a client that retried reads as
/// removed. A client treats a state it does not know as still removing.
/// </summary>
public sealed record ServerRemoveResult(string State)
{
@@ -8,7 +8,8 @@ namespace Qqq.PrintAgent.Windows.Management;
/// <summary>
/// Everything about one server that a client shows without asking again: its local identity, name, address and place in the
/// operator's order; its health and the text that explains it; the count of unreviewed failures that drives the health; who
/// is signed in and whether printing is authorized; whether this computer is paired with it and as which device; the
/// is signed in and whether printing is authorized (<see cref="ServerAuthentication"/>, which bounds the account's
/// identity values as every reply does); whether this computer is paired with it and as which device; the
/// transport and its traffic meter; when the server last answered this computer over any channel (the control state has
/// its own last contact, which is the control channel's alone); the administrator's desired state and last command; the
/// printers shared with it and the server's word on each; the certificate exception; whether its stored state cannot be
@@ -16,7 +17,7 @@ namespace Qqq.PrintAgent.Windows.Management;
/// nothing. A client treats a health it does not know as unknown, not as a failure to read the snapshot.
/// </summary>
public sealed record ServerSnapshot(string ServerId, string Name, string Address, int Position, string Health, string Status, int AttentionCount,
AgentAuthenticationStatus Authentication, bool Paired, string? DeviceId, AgentTransportSnapshot Transport, DateTimeOffset? LastContact, ServerControl Control,
ServerAuthentication Authentication, bool Paired, string? DeviceId, AgentTransportSnapshot Transport, DateTimeOffset? LastContact, ServerControl Control,
IReadOnlyList<ServerPrinter> Printers, PrinterServerSync PrinterSync, CertificateExceptionState CertificateException, bool StateUnreadable, bool Removing)
{
/// <summary>Health: connected and nothing needs the operator.</summary>
@@ -9,12 +9,75 @@ namespace Qqq.PrintAgent.Windows.Management;
/// <summary>
/// Arguments of <see cref="ManagementOperations.ServersAdd"/>: the address, the name, whether the operator accepted the
/// certificate exception, and the session from a sign-in the companion has completed. The sign-in provider's address is
/// not an argument: the service reads it from the server itself and never takes it from a client. The session credential
/// is a secret, so the text form of these arguments is fixed.
/// certificate exception, the session from a sign-in the companion has completed, and the sign-in provider's origin
/// that <see cref="ManagementOperations.ServersCheck"/> reported (<see cref="ServerCheckResult.ProviderOrigin"/>).
/// <para>
/// The provider's origin is carried for comparison only (ADR-0004, "Who names the sign-in provider"). The service still
/// reads the provider from the server itself and stores only what it read; it never takes the value from a client.
/// But it reads the sign-in description once for the check and again for the add, and a server whose certificate is
/// not verified could name another provider the second time, which a stored certificate exception would then cover
/// without the operator having seen it. So the request says which provider the operator was shown, the service
/// compares it with its own read (<see cref="ProviderAgrees"/>), and when they differ it stores nothing and answers
/// <see cref="ManagementErrorCode.ProviderChanged"/>. An absent origin states that the check named no provider, and
/// is compared like any other value: when the service then reads a provider, the request is refused. A client
/// therefore checks the server before it adds it. A member that is left out and one that is null both state that;
/// text that names no origin states nothing, and the record cannot be built with it, in code or from a request, so
/// such a request is refused as an invalid one when its arguments are read (<see cref="SignInProviderOrigin.Require"/>).
/// </para>
/// <para>
/// The name must be one a server may have (<see cref="ServerName"/>): the record cannot be built with another, in code
/// or from a request, so a request that gives one is refused as <see cref="ServerName.RefusalCode"/> when its
/// arguments are read. The session credential is a secret, so the text form of these arguments is fixed.
/// </para>
/// </summary>
public sealed record ServersAddArgs(string Address, string Name, bool AcceptCertificateException, QqqSessionCredential SessionCredential)
public sealed record ServersAddArgs
{
/// <summary>Takes the arguments. The name must pass <see cref="ServerName"/>'s rule and is held with surrounding white space removed. The provider's origin is the one the check reported, or null when it reported none; text that names no origin is refused.</summary>
public ServersAddArgs(string address, string name, bool acceptCertificateException, QqqSessionCredential sessionCredential, string? providerOrigin = null)
{
Address = address;
Name = ServerName.Require(name);
AcceptCertificateException = acceptCertificateException;
SessionCredential = sessionCredential;
ProviderOrigin = SignInProviderOrigin.Require(providerOrigin, nameof(providerOrigin));
}
/// <summary>The server's address.</summary>
public string Address { get; }
/// <summary>The server's name, as it is stored.</summary>
public string Name { get; }
/// <summary>True when the operator accepted the development-only certificate exception for this server.</summary>
public bool AcceptCertificateException { get; }
/// <summary>The session from a sign-in the companion has completed. It is a secret.</summary>
public QqqSessionCredential SessionCredential { get; }
/// <summary>The sign-in provider's origin that the check reported and the operator was shown, or null to state that the check reported none. It is compared, never stored.</summary>
public string? ProviderOrigin { get; }
/// <summary>
/// True when what this request says the operator was shown is what the service read from the server
/// (<see cref="SignInProviderOrigin.Agrees"/>): the same origin, or none on both sides. When it is false the
/// service stores nothing and answers <see cref="ManagementErrorCode.ProviderChanged"/>.
/// </summary>
public bool ProviderAgrees(string? readByTheService) => SignInProviderOrigin.Agrees(ProviderOrigin, readByTheService);
/// <summary>Returns a fixed label, so logging the arguments can never reveal the session credential.</summary>
public override string ToString() => "Arguments of servers.add (session credential not shown)";
}
@@ -5,5 +5,22 @@
namespace Qqq.PrintAgent.Windows.Management;
/// <summary>Arguments of <see cref="ManagementOperations.ServersRename"/>: the server's new name. Its address cannot be changed; a different address is a different server.</summary>
public sealed record ServersRenameArgs(string Name);
/// <summary>
/// Arguments of <see cref="ManagementOperations.ServersRename"/>: the server's new name. Its address cannot be changed; a
/// different address is a different server. The name must be one a server may have (<see cref="ServerName"/>): the
/// record cannot be built with another, in code or from a request, so a request that gives one is refused as
/// <see cref="ServerName.RefusalCode"/> when its arguments are read.
/// </summary>
public sealed record ServersRenameArgs
{
/// <summary>Takes the new name, which must pass <see cref="ServerName"/>'s rule, and holds it with surrounding white space removed.</summary>
public ServersRenameArgs(string name)
{
Name = ServerName.Require(name);
}
/// <summary>The server's new name, as it is stored.</summary>
public string Name { get; }
}
@@ -0,0 +1,62 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using Qqq.PrintAgent.Core.Authentication;
namespace Qqq.PrintAgent.Windows.Management;
/// <summary>
/// The one rule for comparing the sign-in provider a request says the operator was shown with the one the service read
/// from the server (ADR-0004, "Who names the sign-in provider"). <see cref="ManagementOperations.ServersAdd"/> and
/// <see cref="ManagementOperations.TrustSet"/> both store a certificate exception that covers the provider the server
/// names, and the service reads that name again when it stores, after the operator was shown what
/// <see cref="ManagementOperations.ServersCheck"/> reported. A server whose certificate is not verified could name
/// another provider the second time, or name one where it named none. The comparison exists so that what the operator
/// accepted is what the service uses: the request states what the check showed, the service compares, and when they
/// differ it stores nothing and answers <see cref="ManagementErrorCode.ProviderChanged"/>. The value is only ever
/// compared: what the service stores is its own read.
/// </summary>
internal static class SignInProviderOrigin
{
/// <summary>
/// True when what the operator was shown is what the service read. An origin that is absent states that the check
/// named no provider, and it is compared like any other value: none shown and none read agree, and none shown and
/// one read do not, because a provider that appears after the check is exactly the one the operator has not seen.
/// Two origins agree when they are the same origin in the one form an origin is kept and compared in
/// (<see cref="QqqOrigin.Of"/>: scheme, the host's ASCII name and port), so one place written two ways is one
/// place, and another host, port or scheme is another. Text that names no origin agrees with nothing. An IPv6
/// literal with a zone names none here, as it names no server's address (<see cref="ServerAddress.HasZone"/>):
/// the text two origins are compared by has no place for the zone, so two zones, or a zone and none, would
/// otherwise compare as one place.
/// </summary>
internal static bool Agrees(string? shown, string? readByTheService)
{
if(shown is null || readByTheService is null)
{
return shown is null && readByTheService is null;
}
return OriginOf(shown) is { } left && OriginOf(readByTheService) is { } right && string.Equals(left.AbsoluteUri, right.AbsoluteUri, StringComparison.Ordinal);
}
/// <summary>
/// Returns the origin a request carries, as it was given, or refuses text that names none. Null stays null: it
/// states that the check named no provider, and is a value like any other. Text that names no origin (empty text,
/// a word such as "none", an address with no host or with a scheme that is not a web one, a host that cannot be
/// named) states nothing the check could have shown, so it could never agree with what the service reads, and a
/// request that carries it would be answered <see cref="ManagementErrorCode.ProviderChanged"/> however often the
/// client checked again. It is a malformed argument, and is refused where the arguments are read, as a name that
/// breaks the name rule is. The refusal is fixed text and never repeats what was given.
/// </summary>
internal static string? Require(string? shown, string parameter)
=> shown is null || OriginOf(shown) is not null ? shown : throw new ArgumentException("The provider's origin is the origin the check reported, or is left out when the check named no provider.", parameter);
/// <summary>The origin a text names, in the one form an origin is compared in, or null when it names none.</summary>
private static Uri? OriginOf(string text)
=> Uri.TryCreate(text, UriKind.Absolute, out Uri? address) && QqqOrigin.Of(address) is { } origin && !ServerAddress.HasZone(origin) ? origin : null;
}
@@ -6,4 +6,4 @@
namespace Qqq.PrintAgent.Windows.Management;
/// <summary>The result of <see cref="ManagementOperations.SessionSignIn"/>: who is signed in, whether this computer is paired with the server, and whether printing is enabled. Signed in without printing enabled means a pairing or an approval is still needed, and the authentication message says which.</summary>
public sealed record SignInResult(AgentAuthenticationStatus Authentication, bool Paired, bool PrintingEnabled);
public sealed record SignInResult(ServerAuthentication Authentication, bool Paired, bool PrintingEnabled);
@@ -5,5 +5,35 @@
namespace Qqq.PrintAgent.Windows.Management;
/// <summary>Arguments of <see cref="ManagementOperations.TrustSet"/>: whether this server's development-only certificate exception is on. The addresses it covers are not arguments; the service records the server's own and the sign-in provider that server names.</summary>
public sealed record TrustSetArgs(bool AcceptCertificateException);
/// <summary>
/// Arguments of <see cref="ManagementOperations.TrustSet"/>: whether this server's development-only certificate
/// exception is on, and the sign-in provider's origin that <see cref="ManagementOperations.ServersCheck"/> reported
/// (<see cref="ServerCheckResult.ProviderOrigin"/>), which states what the operator was shown. The addresses the
/// exception covers are not taken from a client: the service records the server's own origin and the sign-in provider
/// that server names, from its own read. The provider's origin is carried for comparison only, as in
/// <see cref="ServersAddArgs"/> and for the same reason: the operator accepted the exception for what the check
/// showed, a server whose certificate is not verified could name another provider when the service reads it again,
/// and the exception must not come to cover an origin the operator did not see. An absent origin states that the
/// check named no provider, and is compared like any other value. When the service reads something else, nothing is
/// changed and the answer is <see cref="ManagementErrorCode.ProviderChanged"/>. A client therefore checks the server
/// before it switches the exception on. Switching it off accepts nothing, so there is nothing to compare. A member
/// that is left out and one that is null both state that the check named no provider; text that names no origin
/// states nothing, and the record cannot be built with it, in code or from a request, whichever way the switch goes,
/// so such a request is refused as an invalid one when its arguments are read
/// (<see cref="SignInProviderOrigin.Require"/>).
/// </summary>
public sealed record TrustSetArgs(bool AcceptCertificateException, string? ProviderOrigin = null)
{
/// <summary>The sign-in provider's origin that the check reported and the operator was shown, or null to state that the check reported none. It names an origin whenever it is given, and cannot be set once the arguments exist. It is compared, never stored.</summary>
public string? ProviderOrigin { get; } = SignInProviderOrigin.Require(ProviderOrigin, nameof(ProviderOrigin));
/// <summary>
/// True when the request may be carried out as far as the sign-in provider goes: it switches the exception off,
/// which accepts nothing, or what it says the operator was shown is what the service read from the server
/// (<see cref="SignInProviderOrigin.Agrees"/>). When it is false the service changes nothing and answers
/// <see cref="ManagementErrorCode.ProviderChanged"/>.
/// </summary>
public bool ProviderAgrees(string? readByTheService) => !AcceptCertificateException || SignInProviderOrigin.Agrees(ProviderOrigin, readByTheService);
}
+27 -41
View File
@@ -5,16 +5,19 @@
using System.Globalization;
using Qqq.PrintAgent.Core.Authentication;
using Qqq.PrintAgent.Windows.Management;
namespace Qqq.PrintAgent.Windows;
/// <summary>
/// The service's own log: one place that redacts, bounds and routes every line the service writes. A line goes to the
/// in-memory ring the companion shows (the newest 100 operational entries), to the shared activity log file with the
/// "[Service]" marker, and to the host's sink. Debug detail goes to the file only, and only while debug logging is on.
/// in-memory ring (the newest 100 operational entries), to the shared activity log file with the "[Service]" marker,
/// and to the host's sink. The ring is what version 1 of the pipe carries in its snapshot, which the command line
/// prints; the companion does not show it (its Logs page reads the activity log file), and ADR-0004 removes the ring
/// with version 1. Debug detail goes to the file only, and only while debug logging is on.
/// It is a type of its own, not part of the runtime, because it belongs to the computer and not to one server
/// connection: a connection is given a view of it through <see cref="ForServer"/> that puts that server's name in front
/// of each line and otherwise behaves exactly the same.
/// connection: a connection is given a view of it through <see cref="ForServer"/> that puts that server's short
/// identifier in front of each line and otherwise behaves exactly the same.
/// </summary>
internal sealed class ServiceLog
{
@@ -33,11 +36,6 @@ internal sealed class ServiceLog
/// <summary>The longest server name a prefixed view shows, in characters, so an operator's long name cannot use up a line.</summary>
internal const int ServerNameLimit = 64;
private readonly Sinks _sinks;
private readonly string _prefix;
@@ -71,7 +69,7 @@ internal sealed class ServiceLog
/// <summary>
/// Records a service message in the in-memory ring shown to the companion (newest 100) and in the shared activity log.
/// Records a service message in the in-memory ring (newest 100) and in the shared activity log.
/// The text is redacted first and capped at 1000 characters (4000 for debug detail), so credentials and document content
/// cannot reach either sink. DEBUG entries go only to the file, only while debug logging is on, and never displace the
/// ring's operational entries. A level other than ERROR, WARN or DEBUG is written to the file as information and kept
@@ -83,10 +81,9 @@ internal sealed class ServiceLog
if(debug && !_sinks.ActivityLog.IsDebugEnabled)
{ return; }
//////////////////////////////////////////////////////////////////////////////////
// the name in the prefix was redacted when the view was made, and is joined on //
// only here: redacted as one string with the message, a name ending in an open //
// quote would pair with the quote in front of a secret in the message, and the //
// secret itself would be left outside what is redacted //
// the prefix of a server's view is that server's short identifier, which holds //
// nothing to redact; it is joined on after the message is redacted, so the //
// line a view writes is the log's own line with eight digits in front of it //
//////////////////////////////////////////////////////////////////////////////////
string sanitized = _prefix + AgentActivityLog.Redact(message);
int cap = debug ? DebugLimit : LineLimit;
@@ -129,48 +126,37 @@ internal sealed class ServiceLog
/// <summary>A copy of the ring, oldest entry first, for the snapshot the companion reads.</summary>
/// <summary>A copy of the ring, oldest entry first, for the version 1 snapshot.</summary>
internal AgentLogEntry[] Entries() => _sinks.Entries();
/// <summary>Empties the ring, which is what clearing the logs does to the entries the companion shows; the files are cleared by the caller.</summary>
/// <summary>Empties the ring, which is what clearing the logs does to the entries the snapshot carries; the files are cleared by the caller.</summary>
internal void ClearEntries() => _sinks.Forget();
/// <summary>
/// A view of this log for one server's connection. Every line it writes starts with the server's name in square
/// brackets, so lines from several connections can be told apart; redaction, the caps (which then include the name),
/// the debug routing, the ring and the file are the same ones. The name is an operator's text, so it is made safe
/// here, once: a character that would end the line or reorder it becomes a space (see <see cref="StaysInItsLine"/>),
/// the name is redacted on its own, and it is cut at 64 characters. It is redacted apart from the lines it will
/// stand in front of because the redaction reads a quoted value up to the next quote: a name ending in an open quote,
/// redacted together with a line, would pair with the quote in front of a secret in that line and leave the secret
/// in the log. A name with nothing left to show is refused.
/// A view of this log for one server's connection. Every line it writes starts with the server's short identifier
/// in square brackets (the first eight characters of its identifier, which the command line's <c>--server</c>
/// accepts), so lines from several connections can be told apart; redaction, the caps (which then include the
/// prefix), the debug routing, the ring and the file are the same ones. The prefix is the identifier and not the
/// server's name (ADR-0004, "Server names"): the activity log file redacts each joined line a second time, so a name
/// holding a credential keyword would hide that server's lines, square brackets in a name would forge structure
/// inside a line, and a name changes when the server is renamed. Eight hexadecimal digits can do none of that, so
/// nothing here has to make an operator's text safe. Anything but a full identifier is refused.
/// </summary>
internal ServiceLog ForServer(string name)
internal ServiceLog ForServer(string serverId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
string plain = new string(name.Select(character => StaysInItsLine(character) ? character : ' ').ToArray()).Trim();
ArgumentException.ThrowIfNullOrWhiteSpace(plain, nameof(name));
string shown = AgentActivityLog.Redact(plain);
return new ServiceLog(_sinks, "[" + (shown.Length <= ServerNameLimit ? shown : shown[..ServerNameLimit]) + "] ");
if(!ManagementProtocol.IsServerId(serverId))
{
throw new ArgumentException("A server's view of the log is named by the server's full identifier.", nameof(serverId));
}
return new ServiceLog(_sinks, "[" + serverId[..ServerSelector.MinimumPrefixLength] + "] ");
}
/// <summary>
/// True for a character that shows as itself where it stands. False for the ones that let a name forge or disguise a
/// log line: control characters, the line and paragraph separators (U+2028, U+2029) that end a line as surely as a
/// newline, and the format characters, which include the bidirectional embeddings, overrides and isolates that make
/// text display in another order than it was written. It is the rule Core applies to text a server sends.
/// </summary>
private static bool StaysInItsLine(char character) => char.GetUnicodeCategory(character) is not (UnicodeCategory.Control
or UnicodeCategory.Format or UnicodeCategory.LineSeparator or UnicodeCategory.ParagraphSeparator);
/// <summary>What a log and every view made from it share: the file, the host's sink, and the ring with its lock and the clock that stamps its entries.</summary>
private sealed class Sinks(AgentActivityLog activityLog, Action<string> sink, TimeProvider clock)
{
@@ -3,6 +3,7 @@
* SPDX-License-Identifier: MIT
*/
using System.Globalization;
using System.IO.Pipes;
using System.Text;
using Qqq.PrintAgent.Core.Printing;
@@ -16,6 +17,28 @@ namespace Qqq.PrintAgent.Cli.Tests;
/// </summary>
public sealed class AgentRuntimeTests
{
/// <summary>
/// The entries the service keeps in memory are stamped by the runtime's own clock, like everything else the runtime
/// times. The runtime is given a clock that stands at a moment years from now and never moves, and every entry it
/// has written by the time it is connected carries exactly that moment; stamped by the system clock, as they were
/// before the runtime handed its clock to the log, none would.
/// </summary>
[Fact]
public async Task LogEntries_AreStampedByTheRuntimesClock()
{
DateTimeOffset moment = new(2031, 5, 6, 7, 8, 9, TimeSpan.Zero);
await using RealtimeRuntimeFixture fixture = new(timeProvider: new ManualTimeProvider(moment));
_ = await fixture.ConnectAsync();
await fixture.WaitAsync(() => fixture.Runtime.GetSnapshot().Logs.Count > 0);
IReadOnlyList<AgentLogEntry> entries = fixture.Runtime.GetSnapshot().Logs;
Assert.NotEmpty(entries);
Assert.All(entries, entry => Assert.Equal(moment.ToString("O", CultureInfo.InvariantCulture), entry.Timestamp));
}
/// <summary>
/// The first server request times out, which leaves the connection loop waiting out a 60-second backoff. A reconnect request
/// over the pipe wakes the loop at once, so the service connects long before that backoff (which is longer than the
@@ -8,6 +8,7 @@
"sessionCredential": {
"origin": "https://qqq.example.test",
"sessionToken": "CANARY-SESSION-COOKIE-7f3a"
}
},
"providerOrigin": "https://sign-in.example.test"
}
}
@@ -3,6 +3,7 @@
"op": "trust.set",
"server": "3f2b8c1e-5d4a-4f6b-9a7c-0e1d2c3b4a5f",
"args": {
"acceptCertificateException": true
"acceptCertificateException": true,
"providerOrigin": "https://sign-in.example.test"
}
}
File diff suppressed because it is too large. Load diff
@@ -65,7 +65,7 @@ public sealed class ManagementGoldenTests
Assert.Equal(operation.Scope == ManagementScope.Server, parse.Request.Server is not null);
Assert.Equal(operation.ArgumentsType is not null, parse.Request.Args is not null);
object? arguments = operation.ArgumentsType is null ? null : ReadTyped(nameof(ManagementJson.TryReadArguments), operation.ArgumentsType, parse.Request);
Assert.Equal(golden, ManagementJson.Write(parse.Request with { Args = arguments is null ? null : Payload(arguments, operation.ArgumentsType!) }));
Assert.Equal(golden, ManagementJson.Write(ManagementRequest.For(parse.Request.Op, parse.Request.Server, arguments is null ? null : Payload(arguments, operation.ArgumentsType!))));
}
@@ -77,8 +77,7 @@ public sealed class ManagementGoldenTests
{
Assert.True(ManagementOperations.TryFind(name, out ManagementOperationInfo? operation));
object? arguments = ManagementSamples.Arguments(name);
ManagementRequest example = new(ManagementProtocol.Version, name, ManagementSamples.ServerFor(operation),
arguments is null ? null : Payload(arguments, operation.ArgumentsType!));
var example = ManagementRequest.For(name, ManagementSamples.ServerFor(operation), arguments is null ? null : Payload(arguments, operation.ArgumentsType!));
Assert.Equal(arguments?.GetType(), operation.ArgumentsType);
Assert.Equal(Normalized(name + ".request.json"), ManagementJson.Write(example));
@@ -101,7 +100,7 @@ public sealed class ManagementGoldenTests
Assert.True(parse.Reply.Ok);
Assert.Null(parse.Reply.Error);
object result = ReadTyped(nameof(ManagementJson.TryReadResult), operation.ResultType, parse.Reply);
Assert.Equal(golden, ManagementJson.Write(parse.Reply with { Result = Payload(result, operation.ResultType) }));
Assert.Equal(golden, ManagementJson.Write(ManagementReply.Success(Payload(result, operation.ResultType))));
}
@@ -134,12 +133,11 @@ public sealed class ManagementGoldenTests
Assert.False(parse.Reply.Ok);
Assert.Null(parse.Reply.Result);
Assert.Equal(code, parse.Reply.Error!.Code);
Assert.Equal("Synthetic message for people.", parse.Reply.Error.Message);
Assert.Equal(ManagementSamples.FailureMessage, parse.Reply.Error.Message);
object details = ReadTyped(nameof(ManagementJson.TryReadDetails), detailsType, parse.Reply.Error);
ManagementError rewritten = parse.Reply.Error with { Details = Payload(details, detailsType) };
Assert.Equal(golden, ManagementJson.Write(parse.Reply with { Error = rewritten }));
Assert.Equal(golden, ManagementJson.Write(ManagementSamples.FailureWith(code, details)));
Assert.Equal(detailsType, ManagementSamples.Details(code)!.GetType());
Assert.Equal(golden, ManagementJson.Write(FailureOf(code, ManagementSamples.Details(code)!)));
Assert.Equal(golden, ManagementJson.Write(ManagementSamples.Failure(code)));
}
@@ -189,16 +187,7 @@ public sealed class ManagementGoldenTests
/// <summary>Builds a successful reply with <see cref="ManagementReply.Success"/> for a result whose type is known only at run time.</summary>
/// <summary>Builds a successful reply with <see cref="ManagementReply.Success{TResult}(TResult)"/> for a result whose type is known only at run time.</summary>
private static ManagementReply SuccessOf(object result)
=> (ManagementReply)typeof(ManagementReply).GetMethod(nameof(ManagementReply.Success))!.MakeGenericMethod(result.GetType()).Invoke(null, [result])!;
/// <summary>Builds a failed reply with details through the contract's own factory, for details whose type is known only at run time.</summary>
private static ManagementReply FailureOf(string code, object details)
{
MethodInfo factory = typeof(ManagementReply).GetMethods().Single(method => method is { Name: nameof(ManagementReply.Failure), IsGenericMethodDefinition: true });
return (ManagementReply)factory.MakeGenericMethod(details.GetType()).Invoke(null, [code, "Synthetic message for people.", details])!;
}
=> (ManagementReply)typeof(ManagementReply).GetMethods().Single(method => method.IsGenericMethodDefinition && string.Equals(method.Name, nameof(ManagementReply.Success), StringComparison.Ordinal)).MakeGenericMethod(result.GetType()).Invoke(null, [result])!;
}
@@ -178,10 +178,11 @@ public sealed class ManagementResultTests
/// <summary>
/// The approved account in the details of a changed identity came from the server, so the only way to build the
/// details makes both values printable and cuts each at 200 UTF-16 units, never inside a surrogate pair. The record
/// has no public constructor and no member that can be set afterwards, and it still reads back from the wire. The
/// characters are written as escapes here so this file holds none of them.
/// The approved account in the details of a changed identity came from the server, so the record's one constructor
/// makes both values printable and cuts each at 200 UTF-16 units exactly, never inside a surrogate pair and with
/// nothing added to mark the cut. The record has no public constructor and no member that can be set afterwards,
/// and it reads back from the wire as it was written. The characters are written as escapes here so this file
/// holds none of them.
/// </summary>
[Fact]
public void IdentityChangedDetails_AreBuiltOnlyPrintableAndBounded()
@@ -190,18 +191,148 @@ public sealed class ManagementResultTests
var hostile = IdentityChangedDetails.For("principal\u202E4\u0007\u2066x\u2069", "tenant\n2\u2028\u200B");
var tooLong = IdentityChangedDetails.For(new string('p', 300), new string('t', 199) + Pair + "tail");
var fitting = IdentityChangedDetails.For(new string('p', 198) + Pair, new string('t', 200));
var missing = IdentityChangedDetails.For(null, "");
ManagementReply reply = ManagementJson.ParseReply(ManagementJson.Write(ManagementReply.Failure(ManagementErrorCode.IdentityChanged, "Sign in as the approved account.", hostile))).Reply!;
Assert.Equal(("principal 4 x ", "tenant 2 "), (hostile.ApprovedPrincipalId, hostile.ApprovedTenantId));
Assert.Equal((new string('p', 200) + "...", new string('t', 199) + "..."), (tooLong.ApprovedPrincipalId, tooLong.ApprovedTenantId));
Assert.Equal((new string('p', 200), new string('t', 199)), (tooLong.ApprovedPrincipalId, tooLong.ApprovedTenantId));
Assert.Equal((new string('p', 198) + Pair, new string('t', 200)), (fitting.ApprovedPrincipalId, fitting.ApprovedTenantId));
Assert.Equal(("(missing)", "(missing)"), (missing.ApprovedPrincipalId, missing.ApprovedTenantId));
Assert.Equal(200, ManagementProtocol.MaximumIdentityCharacters);
Assert.Empty(typeof(IdentityChangedDetails).GetConstructors());
Assert.All(typeof(IdentityChangedDetails).GetProperties(), member => Assert.Null(member.SetMethod));
Assert.True(ManagementJson.TryReadDetails(reply.Error!, out IdentityChangedDetails? read));
Assert.Equal(hostile, read);
Assert.False(ManagementJson.TryReadDetails(new ManagementError(ManagementErrorCode.IdentityChanged, "Changed.", ManagementPayload.From(new NoResult())), out IdentityChangedDetails? _));
Assert.Equal(tooLong, IdentityChangedDetails.For(tooLong.ApprovedPrincipalId, tooLong.ApprovedTenantId));
}
/// <summary>
/// An approved account the pairing does not record is absent, not replaced by words: a value that is missing, empty
/// or has nothing to show once it is printable is null, is left out of the text, and reads back as null, so a client
/// never has to tell a missing account from one named "(missing)". An account that really has that name is carried
/// as it is.
/// </summary>
[Fact]
public void IdentityChangedDetails_LeaveOutAValueThatIsMissing()
{
var missing = IdentityChangedDetails.For(null, "");
var blank = IdentityChangedDetails.For(" ", "\u200B\n");
var half = IdentityChangedDetails.For("principal-4", null);
var literal = IdentityChangedDetails.For("(missing)", "tenant-2");
string written = ManagementJson.Write(ManagementReply.Failure(ManagementErrorCode.IdentityChanged, "Sign in as the approved account.", missing));
Assert.Equal((null, null), (missing.ApprovedPrincipalId, missing.ApprovedTenantId));
Assert.Equal(missing, blank);
Assert.Equal(("principal-4", null), (half.ApprovedPrincipalId, half.ApprovedTenantId));
Assert.Equal("(missing)", literal.ApprovedPrincipalId);
Assert.Equal("{\"v\":2,\"ok\":false,\"error\":{\"code\":\"identity_changed\",\"message\":\"Sign in as the approved account.\",\"details\":{}}}", written);
Assert.Contains("\"details\":{\"approvedPrincipalId\":\"principal-4\"}}", ManagementJson.Write(ManagementReply.Failure(ManagementErrorCode.IdentityChanged, "Sign in as the approved account.", half)), StringComparison.Ordinal);
Assert.True(ManagementJson.TryReadDetails(ManagementJson.ParseReply(written).Reply!.Error!, out IdentityChangedDetails? read));
Assert.Equal(missing, read);
}
/// <summary>
/// A client holds bounded, printable identity values even when a service did not send them so: the details are
/// read through the same constructor that builds them, so a principal of 2,028 units and a tenant with a character
/// that reorders text arrive cut and clean. A value of the wrong kind still makes the details unreadable.
/// </summary>
[Fact]
public void IdentityChangedDetails_ReadFromTheWire_AreBoundedToo()
{
string raw = "{\"v\":2,\"ok\":false,\"error\":{\"code\":\"identity_changed\",\"message\":\"Changed.\",\"details\":{\"approvedPrincipalId\":\"" + new string('p', 2028) + "\",\"approvedTenantId\":\"tenant\\u202E2\"}}}";
Assert.True(ManagementJson.TryReadDetails(ManagementJson.ParseReply(raw).Reply!.Error!, out IdentityChangedDetails? read));
Assert.Equal((new string('p', 200), "tenant 2"), (read.ApprovedPrincipalId, read.ApprovedTenantId));
Assert.False(ManagementJson.TryReadDetails(ManagementJson.ParseReply("{\"v\":2,\"ok\":false,\"error\":{\"code\":\"identity_changed\",\"message\":\"Changed.\",\"details\":{\"approvedPrincipalId\":7}}}").Reply!.Error!, out IdentityChangedDetails? _));
}
/// <summary>
/// The same account reads the same wherever a reply names it. The snapshot's authentication and the details of a
/// changed identity give a principal and a tenant one form: for values that are hostile, too long, cut beside a
/// surrogate pair, empty or missing, both records hold the same text, at most 200 UTF-16 units, and a missing value
/// is absent in both. The record has no public constructor and no member that can be set afterwards, and what it
/// writes reads back equal.
/// </summary>
[Theory]
[InlineData("principal-4", "tenant-2")]
[InlineData("principal\u202E4\u0007\u2066x\u2069", "tenant\n2\u2028\u200B")]
[InlineData("pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp-and-more", "tenant\uD83D\uDE00")]
[InlineData("", " ")]
[InlineData(null, null)]
[InlineData("(missing)", "\u200B")]
public void ServerAuthentication_GivesIdentityValuesTheFormTheIdentityChangedDetailsGiveThem(string? principal, string? tenant)
{
var authentication = ServerAuthentication.From(new AgentAuthenticationStatus(SignedIn: true, principal, tenant, "Signed in. Printing is enabled.", Authorized: true));
var details = IdentityChangedDetails.For(principal, tenant);
ManagementReply written = ManagementJson.ParseReply(ManagementJson.Write(ManagementReply.Success(authentication))).Reply!;
Assert.Equal((details.ApprovedPrincipalId, details.ApprovedTenantId), (authentication.PrincipalId, authentication.TenantId));
Assert.True((authentication.PrincipalId?.Length ?? 0) <= ManagementProtocol.MaximumIdentityCharacters);
Assert.Equal((true, "Signed in. Printing is enabled.", true), (authentication.SignedIn, authentication.Message, authentication.Authorized));
Assert.True(ManagementJson.TryReadResult(written, out ServerAuthentication? read));
Assert.Equal(authentication, read);
Assert.Empty(typeof(ServerAuthentication).GetConstructors());
Assert.All(typeof(ServerAuthentication).GetProperties(), member => Assert.Null(member.SetMethod));
}
/// <summary>
/// The message beside an account is held to the form of text on the wire, like the two identity values and like
/// an error's message: every character that is not shown becomes a space and the text is cut at 512 UTF-16 units
/// exactly, when the record is built from the service's status and when it is read from a reply of a service that
/// did not bound it. The result is the text an error would carry for the same message, and reading it again
/// changes nothing. A message that is already in that form is carried as it is, an empty one included.
/// </summary>
[Fact]
public void ServerAuthentication_GivesItsMessageTheFormOfTextOnTheWire_WhenBuiltAndWhenRead()
{
string hostile = "Signed in" + (char)0x202E + "as" + (char)0x000A + "another" + (char)0x2028 + "account " + new string('x', 2000);
string expected = new ManagementError(ManagementErrorCode.ServerRefused, hostile).Message;
string raw = "{\"v\":2,\"ok\":true,\"result\":{\"signedIn\":true,\"principalId\":\"principal-4\",\"tenantId\":\"tenant-2\",\"message\":" + JsonSerializer.Serialize(hostile) + ",\"authorized\":true}}";
var built = ServerAuthentication.From(new AgentAuthenticationStatus(SignedIn: true, "principal-4", "tenant-2", hostile, Authorized: true));
Assert.True(ManagementJson.TryReadResult(ManagementJson.ParseReply(raw).Reply!, out ServerAuthentication? read));
Assert.True(ManagementJson.TryReadResult(ManagementJson.ParseReply(ManagementJson.Write(ManagementReply.Success(built))).Reply!, out ServerAuthentication? readBack));
Assert.StartsWith("Signed in as another account xxx", expected, StringComparison.Ordinal);
Assert.Equal(ManagementProtocol.MaximumMessageCharacters, expected.Length);
Assert.Equal(expected, built.Message);
Assert.Equal(expected, read.Message);
Assert.Equal(built, read);
Assert.Equal(built, readBack);
Assert.Equal("Signed in.", ServerAuthentication.From(new AgentAuthenticationStatus(SignedIn: true, "principal-4", "tenant-2", "Signed in.")).Message);
Assert.Equal("", ServerAuthentication.From(new AgentAuthenticationStatus(SignedIn: false, PrincipalId: null, TenantId: null, Message: "")).Message);
Assert.Throws<ArgumentNullException>(() => ServerAuthentication.From(new AgentAuthenticationStatus(SignedIn: false, PrincipalId: null, TenantId: null, Message: null!)));
}
/// <summary>
/// A client holds bounded, printable identity values in a snapshot's authentication even when a service did not send
/// them so, because the wire is read through the constructor that builds the record. A signed-out status has neither
/// value and leaves both out of the text; the two flags are carried as given and the message in the form of text
/// on the wire (the test below), and a status without its message is unreadable.
/// </summary>
[Fact]
public void ServerAuthentication_ReadFromTheWire_IsBoundedToo()
{
string raw = "{\"v\":2,\"ok\":true,\"result\":{\"signedIn\":true,\"principalId\":\"" + new string('p', 1024) + "\",\"tenantId\":\"tenant\\u202E2\",\"message\":\"Signed in.\",\"authorized\":true}}";
var signedOut = ServerAuthentication.From(new AgentAuthenticationStatus(SignedIn: false, PrincipalId: null, TenantId: null, Message: "Sign in to start printing."));
Assert.True(ManagementJson.TryReadResult(ManagementJson.ParseReply(raw).Reply!, out ServerAuthentication? read));
Assert.Equal((true, new string('p', 200), "tenant 2", "Signed in.", true), (read.SignedIn, read.PrincipalId, read.TenantId, read.Message, read.Authorized));
Assert.Equal("{\"v\":2,\"ok\":true,\"result\":{\"signedIn\":false,\"message\":\"Sign in to start printing.\",\"authorized\":false}}", ManagementJson.Write(ManagementReply.Success(signedOut)));
Assert.True(ManagementJson.TryReadResult(ManagementJson.ParseReply("{\"v\":2,\"ok\":true,\"result\":{\"signedIn\":false,\"message\":\"Sign in to start printing.\"}}").Reply!, out ServerAuthentication? withoutTheFlag));
Assert.Equal(signedOut, withoutTheFlag);
Assert.False(ManagementJson.TryReadResult(ManagementJson.ParseReply("{\"v\":2,\"ok\":true,\"result\":{\"signedIn\":false,\"authorized\":false}}").Reply!, out ServerAuthentication? _));
Assert.Throws<ArgumentNullException>(() => ServerAuthentication.From(null!));
}
@@ -224,7 +355,7 @@ public sealed class ManagementResultTests
Assert.True(ManagementJson.TryReadResult(ManagementJson.ParseReply(before).Reply!, out ComputerSnapshot? read));
Assert.Null(read.PrintersDiscoveredAt);
Assert.Equal("{\"v\":2,\"ok\":true,\"result\":{\"printers\":[],\"discoveredAt\":\"2026-11-10T15:04:05.0000000Z\"}}", ManagementJson.Write(ManagementReply.Success(new PrintersDiscoverResult([], found))));
Assert.False(ManagementJson.TryReadResult(new ManagementReply(2, true, ManagementPayload.From(new ServerListResult([]))), out PrintersDiscoverResult? _));
Assert.False(ManagementJson.TryReadResult(ManagementReply.Success(new ServerListResult([])), out PrintersDiscoverResult? _));
}
@@ -4,6 +4,7 @@
*/
using System.Net.Security;
using System.Reflection;
using Qqq.PrintAgent.Core.Authentication;
using Qqq.PrintAgent.Core.Printing;
using Qqq.PrintAgent.Windows;
@@ -110,7 +111,7 @@ internal static class ManagementSamples
internal static object? Arguments(string operation) => operation switch
{
ManagementOperations.ServersCheck => new ServersCheckArgs(Address, AcceptCertificateException: true),
ManagementOperations.ServersAdd => new ServersAddArgs(Address, "Warehouse", AcceptCertificateException: true, SessionCredential),
ManagementOperations.ServersAdd => new ServersAddArgs(Address, "Warehouse", acceptCertificateException: true, SessionCredential, ProviderOrigin),
ManagementOperations.ServersReorder => new ServersReorderArgs([SecondServerId, ServerId]),
ManagementOperations.ComputerSetProfile => new ComputerSetProfileArgs(Computer.Profile),
ManagementOperations.LogsSetSettings => new LogsSetSettingsArgs(RetentionDays: 14, MaxFileMegabytes: 50, SegmentsPerDay: 5, DebugLogging: true),
@@ -118,7 +119,7 @@ internal static class ManagementSamples
ManagementOperations.SessionSignIn => new SessionSignInArgs(SessionCredential, SessionSignInArgs.AssociatePairing),
ManagementOperations.SessionSignOut => new SessionSignOutArgs("io.qrun.qqq-print-agent:/oauth/callback"),
ManagementOperations.PairingEnroll => new PairingEnrollArgs(EnrollmentCodeCanary),
ManagementOperations.TrustSet => new TrustSetArgs(AcceptCertificateException: true),
ManagementOperations.TrustSet => new TrustSetArgs(AcceptCertificateException: true, ProviderOrigin),
ManagementOperations.PrintersShare => new PrintersShareArgs("printer-a1", Shared: true),
ManagementOperations.PrintersSetProfile => new PrintersSetProfileArgs("printer-a1", new PrinterProfile("Shipping labels", "Thermal printer", "Dock 2")),
ManagementOperations.PrintersTestPrint => new PrintersTestPrintArgs("printer-a1", "PDF", "alignment"),
@@ -178,14 +179,33 @@ internal static class ManagementSamples
/// <summary>The message every example failure carries.</summary>
internal const string FailureMessage = "Synthetic message for people.";
/// <summary>The example failure of a code, built by the factory the service will use for it: the one that takes details for a code that carries them, with the example details, and the other for a code that carries none.</summary>
internal static ManagementReply Failure(string code) => Details(code) is { } details ? FailureWith(code, details) : ManagementReply.Failure(code, FailureMessage);
/// <summary>A failed reply built through the contract's own factory for details whose type is known only at run time. A refusal by the factory is thrown as itself, not wrapped by the reflection call.</summary>
internal static ManagementReply FailureWith(string code, object details)
{
MethodInfo factory = typeof(ManagementReply).GetMethods().Single(method => method is { Name: nameof(ManagementReply.Failure), IsGenericMethodDefinition: true });
return (ManagementReply)factory.MakeGenericMethod(details.GetType()).Invoke(null, BindingFlags.DoNotWrapExceptions, null, [code, FailureMessage, details], null)!;
}
/// <summary>A check in which every stage passed, for a server that offers both sign-in methods and is already a server here.</summary>
private static ServerCheckResult CheckPassed => ServerCheckResult.From(new QqqServerCompatibility([], null, QqqSignInMethods.Password | QqqSignInMethods.Browser), ProviderOrigin, ServerId);
/// <summary>Who is signed in, taken from the stored session's identity and never from its credential.</summary>
private static AgentAuthenticationStatus Authentication
=> new(SignedIn: true, StoredSession.Binding.PrincipalId, StoredSession.Binding.TenantId, "Signed in. Printing is enabled.", Authorized: true);
private static ServerAuthentication Authentication
=> ServerAuthentication.From(new AgentAuthenticationStatus(SignedIn: true, StoredSession.Binding.PrincipalId, StoredSession.Binding.TenantId, "Signed in. Printing is enabled.", Authorized: true));
@@ -247,14 +267,14 @@ internal static class ManagementSamples
new DeviceProfile("Packing station 7", "", "West warehouse")),
[
new ServerSnapshot(ServerId, "Warehouse", Address, 0, ServerSnapshot.Green, "Connected. Waiting for print jobs.", AttentionCount: 0,
new AgentAuthenticationStatus(SignedIn: true, "principal-4", "tenant-2", "Signed in. Printing is enabled.", Authorized: true), Paired: true, "device-17",
ServerAuthentication.From(new AgentAuthenticationStatus(SignedIn: true, "principal-4", "tenant-2", "Signed in. Printing is enabled.", Authorized: true)), Paired: true, "device-17",
new AgentTransportSnapshot("qqq-printing.v1", "CONNECTED", 20480, 5242880, 0, 0, s_instant.AddDays(-2)), s_instant.AddSeconds(-12),
new ServerControl("SUPPORTED", new DeviceDesiredState(3, DispatchPaused: false, "CONNECTED"), "2026-11-10T15:03:53.0000000Z"),
[new ServerPrinter("printer-a1", PrinterServerState.Approved, "Shipping labels")],
new PrinterServerSync(PrinterServerSync.Reported, "2026-11-10T15:03:00.0000000Z", Server: Address, Source: PrinterServerSync.RealtimeSource),
new CertificateExceptionState(Enabled: false, Address), StateUnreadable: false, Removing: false),
new ServerSnapshot(SecondServerId, "Test bench", "https://bench.example.test", 1, ServerSnapshot.Orange, "Signed in. Pair this computer to enable printing.", AttentionCount: 0,
new AgentAuthenticationStatus(SignedIn: true, "principal-9", "tenant-2", "Signed in. Pair this computer to enable printing.", Authorized: false), Paired: false, DeviceId: null,
ServerAuthentication.From(new AgentAuthenticationStatus(SignedIn: true, "principal-9", "tenant-2", "Signed in. Pair this computer to enable printing.", Authorized: false)), Paired: false, DeviceId: null,
Stopped, LastContact: null, new ServerControl("UNKNOWN", new DeviceDesiredState(0, DispatchPaused: false, "CONNECTED")), [],
PrinterServerSync.Initial, new CertificateExceptionState(Enabled: true, "https://bench.example.test", "https://bench.example.test"), StateUnreadable: false, Removing: false),
]);
@@ -25,7 +25,7 @@ public sealed class ManagementSecrecyTests
/// <summary>The records a reply may be made of besides the contract's own: existing snapshot records the contract reuses, each already free of credentials.</summary>
private static readonly Type[] s_reusedRecords =
[
typeof(JobPage), typeof(JobSnapshot), typeof(JobDetailsSnapshot), typeof(JobEventSnapshot), typeof(LogSettings), typeof(AgentAuthenticationStatus), typeof(AgentTransportSnapshot),
typeof(JobPage), typeof(JobSnapshot), typeof(JobDetailsSnapshot), typeof(JobEventSnapshot), typeof(LogSettings), typeof(AgentTransportSnapshot),
typeof(PrinterServerSync), typeof(DeviceProfile), typeof(DeviceDesiredState), typeof(PrinterMediaProfile),
];
@@ -68,6 +68,35 @@ public sealed class ManagementSecrecyTests
/// <summary>
/// An account's identity values are treated one way everywhere a reply names them. Every member of a result or of an
/// error's details that holds a principal or a tenant is declared by one of the two records that give such a value
/// its wire form in their one constructor, <see cref="ServerAuthentication"/> and <see cref="IdentityChangedDetails"/>;
/// the service's own status record, which holds the values as the session client read them, is in no reply. A new
/// result that named an account through another record would fail here.
/// </summary>
[Fact]
public void IdentityValues_AreCarriedOnlyByTheRecordsThatBoundThem()
{
HashSet<Type> visited = [];
foreach(Type root in ManagementOperations.All.Select(operation => operation.ResultType).Concat(ManagementErrorCode.All.Select(ManagementErrorCode.DetailsType).OfType<Type>()))
{
Walk(root, visited, []);
}
string[] holders = [.. visited.SelectMany(type => type.GetProperties(BindingFlags.Public | BindingFlags.Instance))
.Where(member => member.Name.Contains("Principal", StringComparison.OrdinalIgnoreCase) || member.Name.Contains("Tenant", StringComparison.OrdinalIgnoreCase))
.Select(member => member.DeclaringType!.Name + "." + member.Name).Order(StringComparer.Ordinal)];
Assert.Equal(["IdentityChangedDetails.ApprovedPrincipalId", "IdentityChangedDetails.ApprovedTenantId", "ServerAuthentication.PrincipalId", "ServerAuthentication.TenantId"], holders);
Assert.DoesNotContain(typeof(AgentAuthenticationStatus), visited);
Assert.Equal(typeof(ServerAuthentication), typeof(ServerSnapshot).GetProperty(nameof(ServerSnapshot.Authentication))!.PropertyType);
Assert.Equal(typeof(ServerAuthentication), typeof(ServerAddResult).GetProperty(nameof(ServerAddResult.Authentication))!.PropertyType);
Assert.Equal(typeof(ServerAuthentication), typeof(SignInResult).GetProperty(nameof(SignInResult.Authentication))!.PropertyType);
}
/// <summary>No argument record is used as a result and no result as an argument, so a credential that travels to the service in a request has no type in common with anything that travels back.</summary>
[Fact]
public void ArgumentRecords_AreNeverResults()
@@ -101,21 +130,19 @@ public sealed class ManagementSecrecyTests
foreach(ManagementOperationInfo operation in ManagementOperations.All)
{
object result = ManagementSamples.Result(operation.Name);
written.Add(ManagementJson.Write(new ManagementReply(ManagementProtocol.Version, true, Payload(result, operation.ResultType))));
written.Add(ManagementJson.Write(ManagementReply.Success(Payload(result, operation.ResultType))));
written.Add(result.ToString()!);
}
foreach(string code in ManagementErrorCode.All)
{
object? details = ManagementSamples.Details(code);
written.Add(ManagementJson.Write(new ManagementReply(ManagementProtocol.Version, false, Error: new ManagementError(code, "Synthetic message for people.",
details is null ? null : Payload(details, details.GetType())))));
written.Add(ManagementJson.Write(ManagementSamples.Failure(code)));
}
foreach(ServiceSnapshot further in ManagementSamples.FurtherSnapshots.Values)
{
written.Add(ManagementJson.Write(ManagementReply.Success(further)));
}
Assert.Equal(30 + 30 + 31 + 2, written.Count);
Assert.Equal(30 + 30 + 32 + 2, written.Count);
Assert.All(written, text => Assert.DoesNotContain(ManagementSamples.SessionCookieCanary, text, StringComparison.Ordinal));
Assert.All(written, text => Assert.DoesNotContain(ManagementSamples.DeviceTokenCanary, text, StringComparison.Ordinal));
Assert.All(written, text => Assert.DoesNotContain(ManagementSamples.EnrollmentCodeCanary, text, StringComparison.Ordinal));
@@ -142,7 +169,7 @@ public sealed class ManagementSecrecyTests
[Fact]
public void ProviderSignOutRequest_IsInTheSignOutReplyAndNowhereElse()
{
string[] carrying = [.. ManagementOperations.All.Where(operation => ManagementJson.Write(new ManagementReply(ManagementProtocol.Version, true,
string[] carrying = [.. ManagementOperations.All.Where(operation => ManagementJson.Write(ManagementReply.Success(
Payload(ManagementSamples.Result(operation.Name), operation.ResultType))).Contains(ManagementSamples.IdTokenHintCanary, StringComparison.Ordinal))
.Select(operation => operation.Name)];
@@ -157,7 +184,7 @@ public sealed class ManagementSecrecyTests
private static string RequestText(string operation)
{
object arguments = ManagementSamples.Arguments(operation)!;
return ManagementJson.Write(new ManagementRequest(ManagementProtocol.Version, operation, null, Payload(arguments, arguments.GetType())));
return ManagementJson.Write(ManagementRequest.For(operation, null, Payload(arguments, arguments.GetType())));
}
@@ -20,9 +20,9 @@ public sealed class ManagementTableTests
private static readonly string[] s_codesInTheAdr =
[
"unsupported_version", "unknown_operation", "invalid_request", "server_not_found", "server_ambiguous", "server_limit", "server_exists", "server_removing", "busy",
"not_signed_in", "not_paired", "not_connected", "server_incompatible", "certificate_untrusted", "server_unreachable", "server_refused", "sign_in_refused", "bind_refused",
"identity_changed", "enrollment_refused", "enrollment_unconfirmed", "printer_not_found", "job_not_found", "job_not_current", "duplicate_risk_unconfirmed", "cancel_refused",
"state_unreadable", "state_newer", "migration_incomplete", "update_in_progress", "internal_error",
"not_signed_in", "not_paired", "not_connected", "server_incompatible", "certificate_untrusted", "provider_changed", "server_unreachable", "server_refused", "sign_in_refused",
"bind_refused", "identity_changed", "enrollment_refused", "enrollment_unconfirmed", "printer_not_found", "job_not_found", "job_not_current", "duplicate_risk_unconfirmed",
"cancel_refused", "state_unreadable", "state_newer", "migration_incomplete", "update_in_progress", "internal_error",
];
@@ -52,7 +52,7 @@ public sealed class ManagementTableTests
string[] declared = Constants(typeof(ManagementErrorCode));
Assert.Equal(s_codesInTheAdr, ManagementErrorCode.All);
Assert.Equal(31, ManagementErrorCode.All.Count);
Assert.Equal(32, ManagementErrorCode.All.Count);
Assert.Equal(s_codesInTheAdr.Order(StringComparer.Ordinal), declared.Order(StringComparer.Ordinal));
Assert.All(ManagementErrorCode.All, code => Assert.Matches("^[a-z]+(_[a-z]+)*$", code));
}
@@ -69,6 +69,7 @@ public sealed class ManagementTableTests
Assert.Equal(typeof(IdentityChangedDetails), ManagementErrorCode.DetailsType(ManagementErrorCode.IdentityChanged));
Assert.Equal(typeof(CancelRefusedDetails), ManagementErrorCode.DetailsType(ManagementErrorCode.CancelRefused));
Assert.Equal(5, ManagementErrorCode.All.Count(code => ManagementErrorCode.DetailsType(code) is not null));
Assert.Null(ManagementErrorCode.DetailsType(ManagementErrorCode.ProviderChanged));
Assert.Null(ManagementErrorCode.DetailsType("code_added_later"));
}
@@ -119,6 +120,29 @@ public sealed class ManagementTableTests
/// <summary>
/// The table says which operation is single-flight, and it is the printer discovery alone: a read on the computer
/// with the ordinary time limit that takes no arguments, so every request for it asks the same thing and a request
/// that arrives while a discovery is running can join it and share its result. Nothing else is marked: the
/// snapshot never starts a discovery, and an operation that names a server, takes arguments or changes something
/// could not share one result among its requests, so the mark is only ever on a read on the computer without
/// arguments.
/// </summary>
[Fact]
public void Operations_PrinterDiscoveryIsTheOneReadThatJoinsARunInProgress()
{
Assert.True(ManagementOperations.TryFind(ManagementOperations.PrintersDiscover, out ManagementOperationInfo? discover));
Assert.Equal(["printers.discover"], Names(operation => operation.SingleFlight), StringComparer.Ordinal);
Assert.Equal((ManagementScope.Computer, ManagementOperationKind.Read, ManagementOperations.OrdinaryTimeLimit, false, null, typeof(PrintersDiscoverResult), true),
(discover.Scope, discover.Kind, discover.ServerTimeLimit, discover.AvailableWhenDegraded, discover.ArgumentsType, discover.ResultType, discover.SingleFlight));
Assert.All(ManagementOperations.All.Where(operation => operation.SingleFlight), operation =>
Assert.Equal((ManagementScope.Computer, ManagementOperationKind.Read, null), (operation.Scope, operation.Kind, operation.ArgumentsType)));
Assert.False(new ManagementOperationInfo("status", ManagementScope.Computer, ManagementOperationKind.Read, ManagementOperations.OrdinaryTimeLimit, false, null, typeof(NoResult)).SingleFlight);
}
/// <summary>The service gives an operation 30 seconds, and 120 to the ones that wait for a QQQ server: sign-in, sign-out and enrollment as in version 1, and checking and adding a server. A client waits a little longer than the service, 35 and 150 seconds as version 1 clients do, so it hears the service's own timeout.</summary>
[Fact]
public void Operations_TimeLimitsAreTodaysThirtySeconds_AndOneHundredTwentyForTheLongOnes()
@@ -0,0 +1,178 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Text;
using Qqq.PrintAgent.Core;
using Qqq.PrintAgent.Windows.Management;
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>
/// The contract's own rule for free text in a typed member of the wire. Which characters are shown is Core's one
/// definition; the rest is decided here because a wire member is a value and not a piece of a sentence: a cut keeps the
/// bound exactly and adds no mark, the rule applied twice changes nothing, a member that may be absent stays absent,
/// and a message that must say something is refused when it says nothing. The characters are written as code points or
/// escapes so this file holds none of them.
/// </summary>
public sealed class ManagementTextTests
{
/// <summary>A long emoji, as the two UTF-16 units of its surrogate pair.</summary>
private const string Pair = "\uD83D\uDE00";
/// <summary>
/// Every code point Core's definition does not show becomes one space, in a message and in an identity value alike:
/// a control character, the next-line control, the line and paragraph separators, the characters that reorder or
/// hide text, a private-use character in either plane, an invisible tag character and an unassigned code point.
/// </summary>
[Theory]
[InlineData(0x0000)]
[InlineData(0x0007)]
[InlineData(0x000A)]
[InlineData(0x001B)]
[InlineData(0x0085)]
[InlineData(0x2028)]
[InlineData(0x2029)]
[InlineData(0x202A)]
[InlineData(0x202E)]
[InlineData(0x2066)]
[InlineData(0x2069)]
[InlineData(0x200B)]
[InlineData(0x200E)]
[InlineData(0x061C)]
[InlineData(0xFEFF)]
[InlineData(0xE000)]
[InlineData(0xF0000)]
[InlineData(0xE0001)]
[InlineData(0x0378)]
public void ACodePointThatIsNotShown_BecomesOneSpace(int codePoint)
{
string text = "a" + char.ConvertFromUtf32(codePoint) + "b";
Assert.False(ServerText.IsShown(new Rune(codePoint)));
Assert.Equal("a b", ManagementText.Bounded(text, 16));
Assert.Equal("a b", ManagementText.Message(text));
Assert.Equal("a b", ManagementText.Identity(text));
}
/// <summary>Visible text in any script is kept as it is, and so are the space separators other than the plain space, which are visible gaps and move nothing, the replacement character and a character outside the first plane.</summary>
[Theory]
[InlineData(0x0020)]
[InlineData(0x0041)]
[InlineData(0x00E9)]
[InlineData(0x4E2D)]
[InlineData(0x00A0)]
[InlineData(0x2003)]
[InlineData(0x3000)]
[InlineData(0xFFFD)]
[InlineData(0x1F600)]
public void ACodePointThatIsShown_IsKept(int codePoint)
{
string text = "a" + char.ConvertFromUtf32(codePoint) + "b";
Assert.True(ServerText.IsShown(new Rune(codePoint)));
Assert.Equal(text, ManagementText.Bounded(text, 16));
Assert.Equal(text, ManagementText.Message(text));
Assert.Equal(text, ManagementText.Identity(text));
}
/// <summary>A surrogate without its partner is not a character; it is shown as the replacement character wherever it stands, and a whole pair is kept.</summary>
[Fact]
public void ASurrogateWithoutItsPartner_BecomesTheReplacementCharacter()
{
Assert.Equal("a\uFFFDb", ManagementText.Bounded("a\uD83Db", 16));
Assert.Equal("a\uFFFDb", ManagementText.Bounded("a\uDE00b", 16));
Assert.Equal("ab\uFFFD", ManagementText.Bounded("ab\uD83D", 16));
Assert.Equal("\uFFFD\uFFFD", ManagementText.Bounded("\uDE00\uD83D", 16));
Assert.Equal("a" + Pair + "b", ManagementText.Bounded("a" + Pair + "b", 16));
}
/// <summary>
/// Text is cut at the limit exactly. Nothing is added to mark the cut, so the result is never longer than the limit;
/// text of exactly the limit is kept whole; a surrogate pair that would straddle the limit is left out whole, and
/// reading stops there, so nothing after it is taken to fill the room it left.
/// </summary>
[Fact]
public void Text_IsCutAtTheLimitExactly_AndNeverInsideASurrogatePair()
{
Assert.Equal("abcde", ManagementText.Bounded("abcde", 5));
Assert.Equal("abcde", ManagementText.Bounded("abcdef", 5));
Assert.Equal("abcde", ManagementText.Bounded("abcde" + new string('x', 100000), 5));
Assert.Equal("abc" + Pair, ManagementText.Bounded("abc" + Pair + "tail", 5));
Assert.Equal("abcd", ManagementText.Bounded("abcd" + Pair + "tail", 5));
Assert.Equal("abcd ", ManagementText.Bounded("abcd\u202Etail", 5));
Assert.Equal("", ManagementText.Bounded("abc", 0));
Assert.Equal("", ManagementText.Bounded(Pair, 1));
Assert.Equal("", ManagementText.Bounded("", 5));
Assert.Equal(new string('m', 512), ManagementText.Message(new string('m', 600)));
Assert.Equal(new string('i', 200), ManagementText.Identity(new string('i', 300)));
Assert.Equal(new string('i', 199), ManagementText.Identity(new string('i', 199) + Pair));
}
/// <summary>
/// Applying the rule to its own result changes nothing, whatever the text held: characters that are not shown, lone
/// surrogates, a pair on the limit and across it, and text many times the limit. That is what lets the reader of a
/// reply apply the rule again to text the service already bounded.
/// </summary>
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(5)]
[InlineData(8)]
[InlineData(200)]
[InlineData(512)]
public void TheRuleAppliedTwice_ChangesNothing(int limit)
{
string[] texts =
[
"", " ", "plain", "a\u202Eb\nc\u2028d\u200Be", "\uD83D", "ab\uDE00", Pair, "abcd" + Pair, "abc" + Pair + "tail", new string('x', limit), new string('x', limit + 1),
new string('x', limit - 1) + Pair + "tail", new string('\u0007', limit + 3), string.Concat(Enumerable.Repeat(Pair, limit)), new string('x', 4000) + "...",
];
Assert.All(texts, text =>
{
string once = ManagementText.Bounded(text, limit);
Assert.Equal(once, ManagementText.Bounded(once, limit));
Assert.True(once.Length <= limit);
Assert.False(once.Length > 0 && char.IsHighSurrogate(once[^1]));
Assert.Equal(ManagementText.Identity(text), ManagementText.Identity(ManagementText.Identity(text)));
});
Assert.Equal(ManagementText.Message("refused\u202E " + new string('x', 600)), ManagementText.Message(ManagementText.Message("refused\u202E " + new string('x', 600))));
}
/// <summary>A message must say something: one that is missing, empty, blank, or blank once it is printable is refused, and the refusal does not repeat it. No words stand in for it.</summary>
[Fact]
public void AMessageWithNothingToShow_IsRefused()
{
Assert.Throws<ArgumentNullException>(() => ManagementText.Message(null!));
Assert.All(new[] { "", " ", "\t\n", "\u00A0", "\u202E\u200B", "\uE000" }, text => Assert.Throws<ArgumentException>(() => ManagementText.Message(text)));
Assert.Equal("The server refused.", ManagementText.Message("The server refused."));
Assert.Equal(" x ", ManagementText.Message("\nx\n"));
}
/// <summary>An identity value that is missing, empty, or has nothing to show once it is printable is null, never words in its place; any other value is carried, printable and bounded.</summary>
[Fact]
public void AnIdentityValueWithNothingToShow_IsAbsent()
{
Assert.Null(ManagementText.Identity(null));
Assert.All(new[] { "", " ", "\t\n", "\u00A0", "\u202E\u200B", "\uE000" }, text => Assert.Null(ManagementText.Identity(text)));
Assert.Equal("principal-4", ManagementText.Identity("principal-4"));
Assert.Equal("(missing)", ManagementText.Identity("(missing)"));
Assert.Equal(" tenant 2", ManagementText.Identity("\ttenant\u202E2"));
}
}
@@ -0,0 +1,213 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using Qqq.PrintAgent.Core.Authentication;
using Qqq.PrintAgent.Windows.Management;
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>
/// What a server's address may be: the one rule the service and the companion both call for the address an operator
/// types. It is held against the session client, which applies the same rule to the origin it is built for and still
/// has its own copy: every address the rule accepts is one a session client can be made for, with the same origin, and
/// every address the session client refuses the rule refuses. The rule refuses one thing more, a host with no ASCII
/// name. The characters are written as escapes so this file holds none of them.
/// </summary>
public sealed class ServerAddressTests
{
/// <summary>
/// An address is read when it is HTTPS, or plain HTTP for this computer itself, and names nothing but an origin. The
/// result is the origin in the one form an origin is kept in: lower-case scheme and host, the host by its ASCII
/// name, a default port left out, the path "/". White space around the text is not part of it. A session client
/// can be made for the result, and has it as its own origin.
/// </summary>
[Theory]
[InlineData("https://qqq.example.test", "https://qqq.example.test/")]
[InlineData("https://qqq.example.test/", "https://qqq.example.test/")]
[InlineData(" https://qqq.example.test/ \t", "https://qqq.example.test/")]
[InlineData("HTTPS://QQQ.Example.Test:443/", "https://qqq.example.test/")]
[InlineData("https://qqq.example.test:8443", "https://qqq.example.test:8443/")]
[InlineData("https://192.168.1.10/", "https://192.168.1.10/")]
[InlineData("https://[2001:db8::1]:8443/", "https://[2001:db8::1]:8443/")]
[InlineData("https://b\u00FCcher.example/", "https://xn--bcher-kva.example/")]
[InlineData("http://localhost:18581", "http://localhost:18581/")]
[InlineData("http://127.0.0.1:18581/", "http://127.0.0.1:18581/")]
[InlineData("http://[::1]:18581/", "http://[::1]:18581/")]
public void TryRead_ReadsAnOriginASessionCanBeMadeFor(string text, string expected)
{
Assert.True(ServerAddress.TryRead(text, out Uri? origin, out ServerAddressProblem problem));
Assert.Equal(ServerAddressProblem.None, problem);
Assert.Equal(expected, origin.AbsoluteUri);
Assert.Equal(origin, QqqOrigin.Of(origin));
Assert.Equal(origin, SessionClientOrigin(origin));
Assert.True(SessionClientAccepts(new Uri(text.Trim())));
}
/// <summary>Only HTTPS is a server's address. Plain HTTP is read for this computer itself (localhost and the loopback addresses), which exists for development, and for no other host; every other scheme is refused, and so is text that is no absolute address at all, or nothing.</summary>
[Theory]
[InlineData("https://qqq.example.test/", ServerAddressProblem.None)]
[InlineData("http://localhost/", ServerAddressProblem.None)]
[InlineData("http://127.0.0.1/", ServerAddressProblem.None)]
[InlineData("http://[::1]/", ServerAddressProblem.None)]
[InlineData("http://qqq.example.test/", ServerAddressProblem.NotSecure)]
[InlineData("http://192.168.1.10:8080/", ServerAddressProblem.NotSecure)]
[InlineData("http://localhost.example.test/", ServerAddressProblem.NotSecure)]
[InlineData("wss://qqq.example.test/", ServerAddressProblem.NotSecure)]
[InlineData("ws://localhost/", ServerAddressProblem.NotSecure)]
[InlineData("ftp://qqq.example.test/", ServerAddressProblem.NotSecure)]
[InlineData("file:///C:/qqq", ServerAddressProblem.NotSecure)]
[InlineData("localhost:18581", ServerAddressProblem.NotSecure)]
[InlineData("qqq.example.test", ServerAddressProblem.NotAnAddress)]
[InlineData("qqq.example.test/app", ServerAddressProblem.NotAnAddress)]
[InlineData("https://", ServerAddressProblem.NotAnAddress)]
[InlineData("not an address", ServerAddressProblem.NotAnAddress)]
[InlineData("", ServerAddressProblem.Empty)]
[InlineData(" ", ServerAddressProblem.Empty)]
[InlineData(null, ServerAddressProblem.Empty)]
public void TryRead_HttpsOnly_PlainHttpForThisComputerOnly(string? text, ServerAddressProblem expected)
{
bool read = ServerAddress.TryRead(text, out Uri? origin, out ServerAddressProblem problem);
Assert.Equal(expected, problem);
Assert.Equal(expected == ServerAddressProblem.None, read);
Assert.Equal(read, origin is not null);
}
/// <summary>
/// An address names an origin and nothing else. A user name or a password, which must never be stored or logged with
/// an address, a query, a fragment and a path are each refused and told apart; an address that is wrong in several
/// ways is reported under the first in the rule's order. Nothing comes back for a refused address.
/// </summary>
[Theory]
[InlineData("https://operator:synthetic-password@qqq.example.test/", ServerAddressProblem.HasUserInformation)]
[InlineData("https://operator@qqq.example.test/", ServerAddressProblem.HasUserInformation)]
[InlineData("https://qqq.example.test/?next=1", ServerAddressProblem.HasQueryOrFragment)]
[InlineData("https://qqq.example.test?next=1", ServerAddressProblem.HasQueryOrFragment)]
[InlineData("https://qqq.example.test/#top", ServerAddressProblem.HasQueryOrFragment)]
[InlineData("https://qqq.example.test/app", ServerAddressProblem.HasPath)]
[InlineData("https://qqq.example.test/qqq/v1/", ServerAddressProblem.HasPath)]
[InlineData("https://qqq.example.test//", ServerAddressProblem.HasPath)]
[InlineData("http://operator:synthetic-password@qqq.example.test/app?next=1#top", ServerAddressProblem.NotSecure)]
[InlineData("https://operator:synthetic-password@qqq.example.test/app?next=1#top", ServerAddressProblem.HasUserInformation)]
[InlineData("https://qqq.example.test/app?next=1#top", ServerAddressProblem.HasQueryOrFragment)]
public void TryRead_RefusesUserInformationQueryAndFragment(string text, ServerAddressProblem expected)
{
Assert.False(ServerAddress.TryRead(text, out Uri? origin, out ServerAddressProblem problem));
Assert.Equal(expected, problem);
Assert.Null(origin);
Assert.False(SessionClientAccepts(new Uri(text)));
}
/// <summary>
/// A host with no plain ASCII name is refused, because that name is what is stored, compared and shown: a host that
/// holds a line separator, and one the platform will not convert. This is the one thing the rule refuses that the
/// session client, which is handed an origin and leaves the naming to whoever stores it, does not.
/// </summary>
[Theory]
[InlineData("https://id\u2028.example.test/")]
[InlineData("https://-b\u00E4d-.example.test/")]
public void TryRead_RefusesAHostWithNoAsciiName(string text)
{
Assert.False(ServerAddress.TryRead(text, out Uri? origin, out ServerAddressProblem problem));
Assert.Equal(ServerAddressProblem.HostNotNameable, problem);
Assert.Null(origin);
Assert.Null(QqqOrigin.Of(new Uri(text)));
Assert.True(SessionClientAccepts(new Uri(text)));
}
/// <summary>
/// An IPv6 literal with a zone is refused as a host that cannot be named, with the zone written either way the
/// platform reads it, for a server's address and for this computer's own. The origin form keeps the zone in the
/// host it connects to and drops it from its text, so two addresses that differ only in the zone, or one with a
/// zone and one without, would be stored and shown as the same place; that is shown here, and it is why the rule
/// refuses them. The same literal without a zone is read. The session client, which is handed an origin and
/// compares hosts with their zone, accepts each of these.
/// </summary>
[Theory]
[InlineData("https://[fe80::1%25eth0]/")]
[InlineData("https://[fe80::1%25eth1]/")]
[InlineData("https://[fe80::1%eth0]")]
[InlineData("https://[fe80::1%2511]:8443/")]
[InlineData("http://[::1%251]:18581/")]
public void TryRead_RefusesAnIPv6LiteralWithAZone(string text)
{
Assert.False(ServerAddress.TryRead(text, out Uri? origin, out ServerAddressProblem problem));
Assert.Equal(ServerAddressProblem.HostNotNameable, problem);
Assert.Null(origin);
Uri withZone = QqqOrigin.Of(new Uri(text))!;
Assert.True(ServerAddress.HasZone(withZone));
Assert.DoesNotContain('%', withZone.AbsoluteUri);
Assert.True(ServerAddress.TryRead(withZone.AbsoluteUri, out Uri? withoutZone, out _));
Assert.False(ServerAddress.HasZone(withoutZone));
Assert.Equal(withZone.AbsoluteUri, withoutZone.AbsoluteUri);
Assert.True(SessionClientAccepts(new Uri(text)));
}
/// <summary>
/// The rule and the session client agree on every address in a table of the shapes an operator can type: the rule
/// reads an address exactly when a session client can be made for it, except for a host that cannot be named (one
/// with no ASCII name, or an IPv6 literal with a zone), which only the rule refuses. When both accept, the origin the rule returns is the origin the session client holds.
/// The values in the order they are checked are the rule's whole vocabulary.
/// </summary>
[Fact]
public void TheRule_AgreesWithWhatTheSessionClientAcceptsAndRefuses()
{
string[] table =
[
"https://qqq.example.test", "https://qqq.example.test/", "https://QQQ.example.test:443/", "https://qqq.example.test:8443/", "https://10.0.0.7/", "https://[::1]/",
"https://xn--bcher-kva.example/", "http://localhost/", "http://localhost:18581/", "http://127.0.0.1:8080/", "http://127.8.9.10/", "http://[::1]:8080/",
"http://qqq.example.test/", "http://10.0.0.7/", "http://localhost.example.test/", "wss://qqq.example.test/", "ws://localhost/", "ftp://qqq.example.test/",
"io.qrun.qqq-print-agent:/oauth/callback", "mailto:operator@example.test", "https://operator@qqq.example.test/", "https://operator:synthetic@qqq.example.test/",
"http://operator@localhost/", "https://qqq.example.test/?", "https://qqq.example.test/?a=1", "https://qqq.example.test/#", "https://qqq.example.test/#top",
"https://qqq.example.test/app", "https://qqq.example.test/app/", "https://qqq.example.test//", "http://localhost/app", "https://id\u2028.example.test/",
"https://[fe80::1]/", "https://[fe80::1%25eth0]/", "http://[::1%251]/",
];
Assert.All(table, text =>
{
Uri address = new(text);
bool read = ServerAddress.TryRead(text, out Uri? origin, out ServerAddressProblem problem);
Uri? made = SessionClientOrigin(address);
Assert.True(read == made is not null || (made is not null && problem == ServerAddressProblem.HostNotNameable), text);
Assert.True(!read || origin == made, text);
});
Assert.Equal([ServerAddressProblem.None, ServerAddressProblem.Empty, ServerAddressProblem.NotAnAddress, ServerAddressProblem.NotSecure, ServerAddressProblem.HasUserInformation,
ServerAddressProblem.HasQueryOrFragment, ServerAddressProblem.HasPath, ServerAddressProblem.HostNotNameable], Enum.GetValues<ServerAddressProblem>());
}
/// <summary>True when a session client can be made for an address.</summary>
private static bool SessionClientAccepts(Uri address) => SessionClientOrigin(address) is not null;
/// <summary>The origin of the session client made for an address the way the service and the companion make one, with plain HTTP allowed for this computer itself only, or null when the session client refuses the address. It contacts nothing.</summary>
private static Uri? SessionClientOrigin(Uri address)
{
try
{
return new QqqSessionClient(address, allowLoopbackHttp: address.IsLoopback).Origin;
}
catch(ArgumentException)
{
return null;
}
}
}
@@ -0,0 +1,396 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Text.Json;
using Qqq.PrintAgent.Windows.Management;
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>
/// What a server's name may be (ADR-0004, "Server names"): the one rule the service and the companion both call. It
/// says which part of the rule a name breaks, so the companion can tell the operator; the contract enforces it in the
/// arguments of <c>servers.add</c> and <c>servers.rename</c>, which cannot be built with a name that breaks it, so a
/// request that gives one is refused as an invalid request when its arguments are read; and it proposes a name from a
/// server's address that always passes. The characters are written as escapes so this file holds none of them.
/// </summary>
public sealed class ServerNameTests
{
/// <summary>The identifier the shape examples are cut from.</summary>
private const string Identifier = ManagementSamples.ServerId;
/// <summary>
/// A name of 1 to 64 UTF-16 units of visible text is accepted and held as given, in any script, with spaces inside
/// it, with punctuation other than square brackets (other brackets, fullwidth parentheses among them, are not
/// the log's delimiters), and with a character outside the first plane. Hexadecimal text is a name as long as it
/// could not be taken for an identifier: fewer than eight digits, digits that run on where an identifier has its
/// first hyphen, a space where the hyphen would be, and text longer than an identifier. A name may begin with a
/// hyphen, and may be spelled like an option of the command line; the rule is about what a name is, and the
/// command line, when it is built, has to take such a name as a value (recorded in the audit for that step).
/// </summary>
[Theory]
[InlineData("Warehouse")]
[InlineData("W")]
[InlineData("Warehouse east")]
[InlineData("print.example.com")]
[InlineData("print.example.com:8443")]
[InlineData("Caf\u00E9 \u4E2D\u6587")]
[InlineData("Dock\u00A02")]
[InlineData("Dock \uD83D\uDE00")]
[InlineData("Line (2) {west} <a> \"b\" 'c' #1 & co.")]
[InlineData("(missing)")]
[InlineData("3f2b8c1")]
[InlineData("deadbee")]
[InlineData("deadbeef1")]
[InlineData("deadbeefcafe")]
[InlineData("deadbeeg")]
[InlineData("3f2b8c1e 5d4a")]
[InlineData("3f2b8c1e_5d4a")]
[InlineData("3f2b8c1e-5d4a-4f6b-9a7c-0e1d2c3b4a5f0")]
[InlineData("Server 3f2b8c1e")]
[InlineData("Dock \uFF08east\uFF09")]
[InlineData("-")]
[InlineData("-h")]
[InlineData("--server")]
[InlineData("- Warehouse")]
public void AName_OfVisibleTextThatIsNotAnIdentifier_IsAccepted(string name)
{
Assert.Equal(ServerNameProblem.None, ServerName.Check(name));
Assert.True(ServerName.TryAccept(name, out string? accepted));
Assert.Equal(name, accepted);
Assert.Equal(name, ServerName.Require(name));
Assert.Equal(name, new ServersRenameArgs(name).Name);
Assert.Equal(name, new ServersAddArgs(ManagementSamples.Address, name, acceptCertificateException: false, ManagementSamples.SessionCredential).Name);
}
/// <summary>White space around a name is removed before it is judged and is not part of the stored name: a name of exactly 64 units stays one with spaces around it, and line breaks at its ends go with the spaces.</summary>
[Fact]
public void WhiteSpaceAroundAName_IsRemoved()
{
string longest = new('n', 64);
Assert.Equal(ServerNameProblem.None, ServerName.Check(" padded \t"));
Assert.Equal("padded", ServerName.Require(" padded \t"));
Assert.Equal("Warehouse east", ServerName.Require("\r\nWarehouse east\n"));
Assert.Equal(longest, ServerName.Require(" " + longest + " "));
Assert.Equal(longest, new ServersRenameArgs(" " + longest + " ").Name);
Assert.Equal("padded", new ServersAddArgs(ManagementSamples.Address, " padded ", acceptCertificateException: true, ManagementSamples.SessionCredential).Name);
Assert.Equal(64, ServerName.MaximumCharacters);
}
/// <summary>A name is refused when it is missing, empty or blank, or longer than 64 units once trimmed. The refusal is fixed text and never repeats the name.</summary>
[Theory]
[InlineData(null, ServerNameProblem.Empty)]
[InlineData("", ServerNameProblem.Empty)]
[InlineData(" ", ServerNameProblem.Empty)]
[InlineData("\t\r\n", ServerNameProblem.Empty)]
[InlineData("nnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn", ServerNameProblem.TooLong)]
[InlineData(" nnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn ", ServerNameProblem.TooLong)]
public void AName_ThatIsEmptyOrTooLong_IsRefused(string? name, ServerNameProblem problem)
{
Assert.Equal(problem, ServerName.Check(name));
Assert.False(ServerName.TryAccept(name, out string? accepted));
Assert.Null(accepted);
ArgumentException refusal = Assert.Throws<ArgumentException>(() => ServerName.Require(name));
Assert.True(string.IsNullOrWhiteSpace(name) || !refusal.Message.Contains(name.Trim(), StringComparison.Ordinal));
Assert.Throws<ArgumentException>(() => new ServersRenameArgs(name!));
Assert.Throws<ArgumentException>(() => new ServersAddArgs(ManagementSamples.Address, name!, acceptCertificateException: false, ManagementSamples.SessionCredential));
}
/// <summary>
/// A name is refused when it holds a code point that is not shown as itself, wherever in the name it stands: a line
/// break or another control character, a line or paragraph separator, a character that reorders or hides text, a
/// private-use character in either plane, an invisible tag character, or an unassigned code point. These are the
/// code points Core's one definition of printable text does not show. (At the very ends of a name the ones that are
/// white space go with the trimming; inside it nothing is removed and the name is refused.)
/// </summary>
[Theory]
[InlineData(0x0000)]
[InlineData(0x0009)]
[InlineData(0x000A)]
[InlineData(0x001B)]
[InlineData(0x0085)]
[InlineData(0x2028)]
[InlineData(0x2029)]
[InlineData(0x202A)]
[InlineData(0x202E)]
[InlineData(0x2066)]
[InlineData(0x2069)]
[InlineData(0x200B)]
[InlineData(0x200E)]
[InlineData(0xFEFF)]
[InlineData(0xE000)]
[InlineData(0xF0000)]
[InlineData(0xE0001)]
[InlineData(0x0378)]
public void AName_WithACodePointThatIsNotShown_IsRefused(int codePoint)
{
string character = char.ConvertFromUtf32(codePoint);
Assert.Equal(ServerNameProblem.NotPrintable, ServerName.Check("Ware" + character + "house"));
Assert.False(ServerName.TryAccept("Ware" + character + "house", out string? accepted));
Assert.Null(accepted);
Assert.False(ServerName.TryAccept(character + "W" + character + "W", out _));
Assert.False(ServerName.TryAccept("W" + character + "W" + character, out _));
Assert.Throws<ArgumentException>(() => new ServersRenameArgs("Ware" + character + "house"));
}
/// <summary>
/// A name must show something. Core's definition of printable text goes by category, and by category the Hangul
/// fillers are letters, the blank Braille pattern is a symbol and a combining mark is shown, though none of them
/// draws anything on its own; so does a space that is not the plain one. A name made only of those is refused as
/// one that shows nothing, however many of them it has and in whatever mixture, and the two argument records
/// cannot be built with it. One character that draws makes the same text a name: these characters are allowed in
/// a name, they just cannot be all of it.
/// </summary>
[Theory]
[InlineData(0x3164)]
[InlineData(0x115F)]
[InlineData(0x1160)]
[InlineData(0xFFA0)]
[InlineData(0x2800)]
[InlineData(0x034F)]
[InlineData(0x0301)]
[InlineData(0xFE0F)]
[InlineData(0x20DD)]
[InlineData(0x0903)]
public void AName_MadeOnlyOfCharactersThatDrawNothing_IsRefused(int codePoint)
{
string character = char.ConvertFromUtf32(codePoint);
string noBreakSpace = char.ConvertFromUtf32(0x00A0);
string filler = char.ConvertFromUtf32(0x3164);
Assert.Equal(ServerNameProblem.ShowsNothing, ServerName.Check(character));
Assert.Equal(ServerNameProblem.ShowsNothing, ServerName.Check(character + character + character));
Assert.Equal(ServerNameProblem.ShowsNothing, ServerName.Check(character + noBreakSpace + character));
Assert.Equal(ServerNameProblem.ShowsNothing, ServerName.Check(character + " " + filler));
Assert.Equal(ServerNameProblem.ShowsNothing, ServerName.Check(" " + character + " "));
Assert.False(ServerName.TryAccept(character, out string? accepted));
Assert.Null(accepted);
Assert.Throws<ArgumentException>(() => new ServersRenameArgs(character));
Assert.Throws<ArgumentException>(() => new ServersAddArgs(ManagementSamples.Address, character, acceptCertificateException: false, ManagementSamples.SessionCredential));
Assert.Equal(ServerNameProblem.None, ServerName.Check("W" + character));
Assert.Equal(ServerNameProblem.None, ServerName.Check(character + "2"));
Assert.Equal(ServerNameProblem.None, ServerName.Check(character + " - " + character));
}
/// <summary>A surrogate without its partner is not a character, so a name that holds one is refused, at its start, inside it and at its end; a whole pair is a character and is accepted.</summary>
[Fact]
public void AName_WithASurrogateWithoutItsPartner_IsRefused()
{
const char High = (char)0xD83D;
const char Low = (char)0xDE00;
Assert.Equal(ServerNameProblem.NotPrintable, ServerName.Check("Ware" + High + "house"));
Assert.Equal(ServerNameProblem.NotPrintable, ServerName.Check("Ware" + Low + "house"));
Assert.False(ServerName.TryAccept("Warehouse " + High, out _));
Assert.False(ServerName.TryAccept(Low + " Warehouse", out _));
Assert.False(ServerName.TryAccept("Ware" + High + "house", out _));
Assert.False(ServerName.TryAccept("Ware" + Low + High + "house", out _));
Assert.Throws<ArgumentException>(() => new ServersRenameArgs("Ware" + High + "house"));
Assert.True(ServerName.TryAccept("Ware" + High + Low + "house", out string? accepted));
Assert.Equal("Ware" + High + Low + "house", accepted);
}
/// <summary>
/// A square bracket anywhere in a name is refused. Square brackets delimit the prefixes of the service log's lines
/// (the service's marker, a server's short identifier), so a name written into a line that puts something in
/// brackets could forge that line's structure; the brackets of an IPv6 address are the case a proposed name must
/// avoid. The fullwidth pair (U+FF3B, U+FF3D) is refused with the ASCII pair, alone and mixed with it: a program
/// does not take it for a delimiter, but a person reading the line does.
/// </summary>
[Theory]
[InlineData("[")]
[InlineData("]")]
[InlineData("[::1]")]
[InlineData("https://[fe80::1]:8443")]
[InlineData("Warehouse [east]")]
[InlineData("a] [ERROR] forged [b")]
[InlineData("\uFF3B")]
[InlineData("\uFF3D")]
[InlineData("Warehouse \uFF3Beast\uFF3D")]
[InlineData("a\uFF3D \uFF3BERROR\uFF3D forged \uFF3Bb")]
[InlineData("Warehouse [east\uFF3D")]
public void AName_WithASquareBracket_IsRefused(string name)
{
Assert.Equal(ServerNameProblem.SquareBracket, ServerName.Check(name));
Assert.False(ServerName.TryAccept(name, out _));
Assert.Throws<ArgumentException>(() => new ServersRenameArgs(name));
}
/// <summary>
/// A name shaped like a server identifier, or like the first eight or more characters of one, is refused in either
/// letter case and with spaces around it, because the command line's <c>--server</c> takes exactly that as an
/// identifier or a prefix and compares without regard to letter case. Every beginning of an identifier from eight
/// characters up to the whole of it is refused; a beginning of fewer than eight is too short to select by and is
/// a name.
/// </summary>
[Fact]
public void AName_ShapedLikeAnIdentifierOrItsBeginning_IsRefused()
{
string[] shaped = ["deadbeef", "DEADBEEF", "DeadBeef", "00000000", "3f2b8c1e-", "3f2b8c1e-5d4a", "3F2B8C1E-5D4A-", " 3f2b8c1e ", Identifier, Identifier.ToUpperInvariant()];
Assert.All(shaped, name => Assert.Equal(ServerNameProblem.LikeAnIdentifier, ServerName.Check(name)));
Assert.All(shaped, name => Assert.False(ServerName.TryAccept(name, out _)));
Assert.All(Enumerable.Range(ServerSelector.MinimumPrefixLength, Identifier.Length - ServerSelector.MinimumPrefixLength + 1), length =>
{
Assert.False(ServerName.TryAccept(Identifier[..length], out _));
Assert.False(ServerName.TryAccept(Identifier[..length].ToUpperInvariant(), out _));
Assert.Throws<ArgumentException>(() => new ServersRenameArgs(Identifier[..length]));
});
Assert.All(Enumerable.Range(1, ServerSelector.MinimumPrefixLength - 1), length => Assert.True(ServerName.TryAccept(Identifier[..length], out _)));
Assert.Equal(36, Identifier.Length);
Assert.True(ManagementProtocol.IsServerId(Identifier));
}
/// <summary>
/// A name that breaks the rule in several ways is reported under the first of them in the rule's order (empty, too
/// long, not printable, showing nothing, a square bracket, shaped like an identifier), so the same name always
/// gets the same answer and the companion has one thing to say.
/// </summary>
[Fact]
public void AName_ThatBreaksTheRuleInSeveralWays_IsReportedUnderTheFirst()
{
Assert.Equal(ServerNameProblem.TooLong, ServerName.Check(new string('[', 65)));
Assert.Equal(ServerNameProblem.TooLong, ServerName.Check(new string('n', 60) + "\n[x]\n" + new string('n', 60)));
Assert.Equal(ServerNameProblem.NotPrintable, ServerName.Check("Ware\nhouse [east]"));
Assert.Equal(ServerNameProblem.NotPrintable, ServerName.Check(char.ConvertFromUtf32(0x3164) + "\n" + char.ConvertFromUtf32(0x3164)));
Assert.Equal(ServerNameProblem.TooLong, ServerName.Check(string.Concat(Enumerable.Repeat(char.ConvertFromUtf32(0x3164), 65))));
Assert.Equal(ServerNameProblem.SquareBracket, ServerName.Check("[deadbeef]"));
Assert.Equal(ServerNameProblem.LikeAnIdentifier, ServerName.Check(" deadbeef "));
Assert.Equal([ServerNameProblem.None, ServerNameProblem.Empty, ServerNameProblem.TooLong, ServerNameProblem.NotPrintable, ServerNameProblem.ShowsNothing, ServerNameProblem.SquareBracket, ServerNameProblem.LikeAnIdentifier],
Enum.GetValues<ServerNameProblem>());
}
/// <summary>
/// A name proposed from a server's address always passes the rule. It is the host by its ASCII name, without a port,
/// a path or anything else of the address; a host shaped like an identifier or its beginning gets "Server" in front;
/// a long host is cut at 64 units; and an address with no host to name is proposed as "Server".
/// </summary>
[Theory]
[InlineData("https://qqq.example.test/", "qqq.example.test")]
[InlineData("https://QQQ.Example.Test:8443/app?x=1#top", "qqq.example.test")]
[InlineData("https://operator:synthetic@qqq.example.test/", "qqq.example.test")]
[InlineData("http://localhost:18581/", "localhost")]
[InlineData("https://127.0.0.1/", "127.0.0.1")]
[InlineData("https://[2001:db8::8a2e:370:7334]/", "2001:db8::8a2e:370:7334")]
[InlineData("https://b\u00FCcher.example/", "xn--bcher-kva.example")]
[InlineData("https://deadbeef.example/", "deadbeef.example")]
[InlineData("https://deadbeef-cafe/", "Server deadbeef-cafe")]
[InlineData("https://DEADBEEF-CAFE-4F6B/", "Server deadbeef-cafe-4f6b")]
[InlineData("https://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.cccccccccccccccccccccccccccccc.example.test/", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.cc")]
[InlineData("wss://qqq.example.test/qqq/v1/qbit/printing/device/realtime", "qqq.example.test")]
[InlineData("io.qrun.qqq-print-agent:/oauth/callback", "Server")]
[InlineData("mailto:operator@example.test", "Server")]
[InlineData("custom://qqq.example.test/x", "Server")]
[InlineData("/qqq/v1/version", "Server")]
[InlineData("https://id\u2028.example.test/", "Server")]
public void Propose_EveryHostInTheTable_PassesCheck(string address, string expected)
{
string proposed = ServerName.Propose(new Uri(address, UriKind.RelativeOrAbsolute));
Assert.Equal(expected, proposed);
Assert.Equal(ServerNameProblem.None, ServerName.Check(proposed));
Assert.Equal(proposed, ServerName.Require(proposed));
Assert.Equal(proposed, new ServersRenameArgs(proposed).Name);
Assert.True(proposed.Length <= ServerName.MaximumCharacters);
}
/// <summary>The host of an IPv6 address is written in square brackets, which a name may not hold; the proposal is the address without them.</summary>
[Fact]
public void Propose_Ipv6Literal_HasNoBrackets_AndPasses()
{
string proposed = ServerName.Propose(new Uri("https://[::1]:8443/"));
Assert.Equal("::1", proposed);
Assert.Equal(ServerNameProblem.SquareBracket, ServerName.Check("[::1]"));
Assert.Equal(ServerNameProblem.None, ServerName.Check(proposed));
}
/// <summary>A host of eight hexadecimal digits is what the command line would take for the beginning of an identifier; the proposal puts "Server" in front, and a missing address is refused.</summary>
[Fact]
public void Propose_HostOfEightHexDigits_Passes()
{
string proposed = ServerName.Propose(new Uri("https://deadbeef/"));
Assert.Equal("Server deadbeef", proposed);
Assert.Equal(ServerNameProblem.LikeAnIdentifier, ServerName.Check("deadbeef"));
Assert.Equal(ServerNameProblem.None, ServerName.Check(proposed));
Assert.Throws<ArgumentNullException>(() => ServerName.Propose(null!));
}
/// <summary>
/// A request that gives a name that breaks the rule is refused when its arguments are read, for <c>servers.rename</c>
/// and for <c>servers.add</c>, which the dispatcher answers as an invalid request: the code the rule names. A request
/// with a good name is read, and the name arrives trimmed.
/// </summary>
[Theory]
[InlineData("Warehouse east", true)]
[InlineData(" Warehouse east ", true)]
[InlineData("", false)]
[InlineData(" ", false)]
[InlineData("[::1]", false)]
[InlineData("deadbeef", false)]
[InlineData("3f2b8c1e-5d4a-4f6b-9a7c-0e1d2c3b4a5f", false)]
[InlineData("Ware\u202Ehouse", false)]
[InlineData("Ware\nhouse", false)]
[InlineData("nnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn", false)]
public void ARequest_WithANameThatBreaksTheRule_IsRefusedWhenItsArgumentsAreRead(string name, bool accepted)
{
string quoted = JsonSerializer.Serialize(name);
ManagementRequest rename = ManagementJson.ParseRequest("{\"v\":2,\"op\":\"servers.rename\",\"server\":\"" + Identifier + "\",\"args\":{\"name\":" + quoted + "}}").Request!;
ManagementRequest add = ManagementJson.ParseRequest("{\"v\":2,\"op\":\"servers.add\",\"args\":{\"address\":\"https://qqq.example.test\",\"name\":" + quoted
+ ",\"acceptCertificateException\":false,\"sessionCredential\":{\"origin\":\"https://qqq.example.test\",\"sessionToken\":\"synthetic\"}}}").Request!;
Assert.Null(ManagementOperations.Check(rename, out _));
Assert.Null(ManagementOperations.Check(add, out _));
Assert.Equal(accepted, ManagementJson.TryReadArguments(rename, out ServersRenameArgs? renamed));
Assert.Equal(accepted, ManagementJson.TryReadArguments(add, out ServersAddArgs? added));
Assert.Equal(accepted ? "Warehouse east" : null, renamed?.Name);
Assert.Equal(accepted ? "Warehouse east" : null, added?.Name);
Assert.Equal(ManagementErrorCode.InvalidRequest, ServerName.RefusalCode);
}
/// <summary>The name in the two argument records cannot be changed once they exist, so the rule cannot be walked around with a <c>with</c> expression; and a request for either operation that leaves the name out is refused like one with a bad name.</summary>
[Fact]
public void TheNameInTheArguments_CannotBeSetAfterwardsOrLeftOut()
{
Assert.Null(typeof(ServersRenameArgs).GetProperty(nameof(ServersRenameArgs.Name))!.SetMethod);
Assert.All(typeof(ServersAddArgs).GetProperties(), member => Assert.Null(member.SetMethod));
Assert.Single(typeof(ServersRenameArgs).GetConstructors());
Assert.Single(typeof(ServersAddArgs).GetConstructors());
Assert.False(ManagementJson.TryReadArguments(ManagementJson.ParseRequest("{\"v\":2,\"op\":\"servers.rename\",\"server\":\"" + Identifier + "\",\"args\":{}}").Request!, out ServersRenameArgs? _));
Assert.False(ManagementJson.TryReadArguments(ManagementJson.ParseRequest("{\"v\":2,\"op\":\"servers.rename\",\"server\":\"" + Identifier + "\",\"args\":{\"name\":null}}").Request!, out ServersRenameArgs? _));
Assert.False(ManagementJson.TryReadArguments(ManagementJson.ParseRequest("{\"v\":2,\"op\":\"servers.rename\",\"server\":\"" + Identifier + "\",\"args\":{\"name\":\"Warehouse\",\"address\":\"https://other.example.test\"}}").Request!, out ServersRenameArgs? _));
Assert.Equal(new ServersRenameArgs("Warehouse"), new ServersRenameArgs(" Warehouse "));
}
}
+410 -119
View File
@@ -12,7 +12,7 @@ namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>
/// The service's log as a type of its own. It must write exactly what the runtime's own log method wrote before it was
/// moved here (the same redaction, caps, routing of debug detail, ring and file lines), and a view of it for one server
/// must add that server's name and change nothing else.
/// must add that server's short identifier and change nothing else.
/// </summary>
public sealed class ServiceLogTests : IDisposable
{
@@ -22,6 +22,16 @@ public sealed class ServiceLogTests : IDisposable
/// <summary>The identifier of the first server a view is made for; its lines start with "[3f2b8c1e] ".</summary>
private const string Production = "3f2b8c1e-5d4a-4f6b-9a7c-0e1d2c3b4a5f";
/// <summary>The identifier of the second server a view is made for; its lines start with "[9a1c0e2d] ".</summary>
private const string Staging = "9a1c0e2d-77b3-4c58-8f10-5e6d7c8b9a01";
/// <summary>Removes the two temporary state directories.</summary>
public void Dispose()
{
@@ -61,10 +71,11 @@ public sealed class ServiceLogTests : IDisposable
/// What the runtime's log method did before it became this type, transcribed from the runtime as it was: redact, cap at
/// 1000 characters (4000 for debug detail), write debug detail to the file only and only while it is on, and write
/// everything else to the file under its level with the "[Service]" marker. It is the reference the moved code is held to.
/// It redacts through the shared <see cref="AgentActivityLog.Redact"/>, as the runtime did, so it follows the two
/// patterns added to that method since (a cookie header line, and the code and state of a callback address) and is
/// otherwise the method as it was; <see cref="LinesWithNeitherNewPattern_AreRedactedAsBefore"/> holds the redaction
/// of every other kind of line to the text it gave before those patterns were added.
/// It redacts through the shared <see cref="AgentActivityLog.Redact"/>, as the runtime did, so it follows the
/// patterns added to that method since (a cookie header line, an authorization header line, the code and state of
/// a callback address, more named values and the code of a code exchange) and is otherwise the method as it was;
/// <see cref="LinesWithNoPatternAddedSince_AreRedactedAsBefore"/> holds the redaction of every other kind of line
/// to the text it gave before those patterns were added.
/// </summary>
private static string? WriteAsTheRuntimeDid(AgentActivityLog file, string level, string message)
{
@@ -150,7 +161,29 @@ public sealed class ServiceLogTests : IDisposable
/// and the file), with the redaction marker in its place: a bearer credential, a device token, an enrollment code, an
/// access or refresh token (including in the query of a callback address), a password, a secret, an API key, document
/// content, a user name and password inside an address, the value of a cookie header line, and the one-time code and
/// the state of a sign-in callback in the query or the fragment of an address.
/// the state of a sign-in callback in the query or the fragment of an address. So are the values the product handles
/// that were added later: the identity provider's ID-token hint, an authorization header of any scheme or of none,
/// the PKCE code verifier, the session cookie under either of the product's names wherever it stands (bare, in JSON,
/// on the continuation line of a folded header, under a header name that only ends in "Cookie"), and a callback's
/// code and state when the value is quoted or the callback is percent-encoded inside another address.
/// <para>
/// The rows from the code-exchange request on are the names under which the product's own requests and responses
/// carry a secret (the session client's sign-in, code exchange, session renewal and sign-out, and the API client's
/// enrollment), each as a member of a JSON object, as name and value, and with the equals sign percent-encoded:
/// <c>code</c> beside <c>codeVerifier</c> in the code-exchange request, <c>uuid</c> in a session response,
/// <c>sessionUUID</c>, <c>sessionId</c> and <c>sessionToken</c>, <c>enrollmentCode</c>, <c>deviceToken</c> and the
/// <c>X-QQQ-Device-Token</c> header, and <c>idTokenHint</c> in the sign-out response with the provider's
/// <c>id_token_hint</c>. The verifier and the hint are covered in every spelling and letter case.
/// </para>
/// <para>
/// The last rows are the edges of the header and callback patterns: a header whose value stands on the next
/// line, an authorization header written with an equals sign, the two headers under the names a web server's
/// environment gives them, a no-break space or a zero-width space between a name and its separator, and a
/// callback whose ampersands are written the way HTML writes them. After them stands the edge of the rule for a
/// code beside a code verifier: the code on a line of its own, and the verifier's name where an earlier rule hides
/// it with the value around it (on a cookie header's line, on an authorization header's line, inside the quoted
/// value of another named secret). The name is looked for in the message as it was given, so the code still goes.
/// </para>
/// </summary>
[Theory]
[InlineData("Authorization: Bearer synthetic-value-1", "synthetic-value-1")]
@@ -171,6 +204,82 @@ public sealed class ServiceLogTests : IDisposable
[InlineData("callback io.qrun.qqq-print-agent:/oauth/callback?code=synthetic-value-15&state=synthetic-value-16", "synthetic-value-16")]
[InlineData("returned to https://print.example.com/signed-in#state=synthetic-value-17&code=synthetic-value-18", "synthetic-value-17")]
[InlineData("returned to https://print.example.com/signed-in#state=synthetic-value-17&code=synthetic-value-18", "synthetic-value-18")]
[InlineData("GET https://id.example.com/logout?id_token_hint=synthetic-value-19&post_logout_redirect_uri=x&state=synthetic-state", "synthetic-value-19")]
[InlineData("Authorization: Basic synthetic-value-20", "synthetic-value-20")]
[InlineData("authorization: Digest username=\"operator\", response=\"synthetic-value-21\"", "synthetic-value-21")]
[InlineData("Proxy-Authorization: Negotiate synthetic-value-22", "synthetic-value-22")]
[InlineData("Authorization: synthetic-value-23", "synthetic-value-23")]
[InlineData("{\"Authorization\":\"Basic synthetic-value-24\"}", "synthetic-value-24")]
[InlineData("token request: grant_type=authorization_code&code_verifier=synthetic-value-25", "synthetic-value-25")]
[InlineData("sessionId=synthetic-value-26", "synthetic-value-26")]
[InlineData("sessionUUID=synthetic-value-27", "synthetic-value-27")]
[InlineData("{\"sessionUUID\":\"synthetic-value-28\"}", "synthetic-value-28")]
[InlineData("{\"Cookie\":\"sessionUUID=synthetic-value-29\"}", "synthetic-value-29")]
[InlineData("Cookie:\r\n sessionUUID=synthetic-value-30", "synthetic-value-30")]
[InlineData("SessionCookie: sessionUUID=synthetic-value-31", "synthetic-value-31")]
[InlineData("callback ?code=\"synthetic-value-32\"&state='synthetic-value-33'", "synthetic-value-32")]
[InlineData("callback ?code=\"synthetic-value-32\"&state='synthetic-value-33'", "synthetic-value-33")]
[InlineData("callback ?code%3Dsynthetic-value-34", "synthetic-value-34")]
[InlineData("redirect_uri=https%3A%2F%2Fapp.example.com%2Fcb%3Fcode%3Dsynthetic-value-35%26state%3Dsynthetic-value-36", "synthetic-value-35")]
[InlineData("redirect_uri=https%3A%2F%2Fapp.example.com%2Fcb%3Fcode%3Dsynthetic-value-35%26state%3Dsynthetic-value-36", "synthetic-value-36")]
[InlineData("POST /qqq/v1/manageSession body {\"code\":\"synthetic-value-37\",\"codeVerifier\":\"synthetic-value-38\",\"redirectUri\":\"io.qrun.qqq-print-agent:/oauth/callback\",\"authenticationFlow\":\"native\"}", "synthetic-value-37")]
[InlineData("POST /qqq/v1/manageSession body {\"code\":\"synthetic-value-37\",\"codeVerifier\":\"synthetic-value-38\",\"redirectUri\":\"io.qrun.qqq-print-agent:/oauth/callback\",\"authenticationFlow\":\"native\"}", "synthetic-value-38")]
[InlineData("codeVerifier=synthetic-value-39", "synthetic-value-39")]
[InlineData("CodeVerifier = synthetic-value-40", "synthetic-value-40")]
[InlineData("CODEVERIFIER: synthetic-value-41", "synthetic-value-41")]
[InlineData("code-verifier: synthetic-value-42", "synthetic-value-42")]
[InlineData("codeVerifier%3Dsynthetic-value-43%26redirectUri%3Dx", "synthetic-value-43")]
[InlineData("code_verifier%3Dsynthetic-value-44", "synthetic-value-44")]
[InlineData("code=synthetic-value-45&code_verifier=synthetic-value-46&grant_type=authorization_code", "synthetic-value-45")]
[InlineData("code=synthetic-value-45&code_verifier=synthetic-value-46&grant_type=authorization_code", "synthetic-value-46")]
[InlineData("code%3Dsynthetic-value-47%26code_verifier%3Dsynthetic-value-48", "synthetic-value-47")]
[InlineData("code%3Dsynthetic-value-47%26code_verifier%3Dsynthetic-value-48", "synthetic-value-48")]
[InlineData("{\"nativeLogoutData\":{\"issuer\":\"https://id.example.com\",\"idTokenHint\":\"synthetic-value-49\"}}", "synthetic-value-49")]
[InlineData("idTokenHint=synthetic-value-50", "synthetic-value-50")]
[InlineData("IdTokenHint = synthetic-value-51", "synthetic-value-51")]
[InlineData("id-token-hint=synthetic-value-52", "synthetic-value-52")]
[InlineData("id_token_hint%3Dsynthetic-value-53", "synthetic-value-53")]
[InlineData("redirect=https%3A%2F%2Fid.example.com%2Flogout%3Fid_token_hint%3Dsynthetic-value-54%26state%3Dsynthetic-state", "synthetic-value-54")]
[InlineData("idTokenHint%3Dsynthetic-value-55", "synthetic-value-55")]
[InlineData("session response {\"uuid\":\"synthetic-value-56\"}", "synthetic-value-56")]
[InlineData("uuid=synthetic-value-57", "synthetic-value-57")]
[InlineData("The session response's uuid: synthetic-value-58", "synthetic-value-58")]
[InlineData("next=https%3A%2F%2Fprint.example.com%2Fresume%3Fuuid%3Dsynthetic-value-59%26x%3D1", "synthetic-value-59")]
[InlineData("uuid%3Dsynthetic-value-60", "synthetic-value-60")]
[InlineData("sessionUUID%3Dsynthetic-value-61", "synthetic-value-61")]
[InlineData("sessionId%3Dsynthetic-value-62", "synthetic-value-62")]
[InlineData("{\"sessionId\":\"synthetic-value-63\"}", "synthetic-value-63")]
[InlineData("{\"origin\":\"https://print.example.com/\",\"sessionToken\":\"synthetic-value-64\"}", "synthetic-value-64")]
[InlineData("sessionToken=synthetic-value-65", "synthetic-value-65")]
[InlineData("sessionToken%3Dsynthetic-value-66", "synthetic-value-66")]
[InlineData("enrollmentCode%3Dsynthetic-value-67", "synthetic-value-67")]
[InlineData("enrollment_code=synthetic-value-68", "synthetic-value-68")]
[InlineData("{\"protocolVersion\":1,\"deviceId\":\"device-17\",\"deviceToken\":\"synthetic-value-69\"}", "synthetic-value-69")]
[InlineData("deviceToken%3Dsynthetic-value-70", "synthetic-value-70")]
[InlineData("X-QQQ-Device-Id: device-17, X-QQQ-Device-Token: synthetic-value-71", "synthetic-value-71")]
[InlineData("access_token%3Dsynthetic-value-72%26token_type%3Dbearer", "synthetic-value-72")]
[InlineData("{\"grant_type\":\"authorization_code\",\"code_verifier\":\"synthetic-value-90\"}", "synthetic-value-90")]
[InlineData("{\"id_token_hint\":\"synthetic-value-91\",\"post_logout_redirect_uri\":\"x\"}", "synthetic-value-91")]
[InlineData("Authorization:\r\n Basic synthetic-value-73", "synthetic-value-73")]
[InlineData("Authorization: \r\nBasic synthetic-value-74", "synthetic-value-74")]
[InlineData("Cookie:\r\n other=synthetic-value-75", "synthetic-value-75")]
[InlineData("Authorization=Basic synthetic-value-76", "synthetic-value-76")]
[InlineData("headers: Accept=*/*, Authorization=Basic synthetic-value-77", "synthetic-value-77")]
[InlineData("HTTP_AUTHORIZATION: Basic synthetic-value-78", "synthetic-value-78")]
[InlineData("HTTP_COOKIE: other=synthetic-value-79", "synthetic-value-79")]
[InlineData("Authorization\u00A0: Basic synthetic-value-80", "synthetic-value-80")]
[InlineData("Authorization\u200B: Basic synthetic-value-81", "synthetic-value-81")]
[InlineData("Cookie\u00A0: other=synthetic-value-82", "synthetic-value-82")]
[InlineData("Cookie\u200B: other=synthetic-value-83", "synthetic-value-83")]
[InlineData("sessionUUID\u200B=synthetic-value-84", "synthetic-value-84")]
[InlineData("code\u200B=synthetic-value-88&code_verifier\u00A0=synthetic-value-89", "synthetic-value-88")]
[InlineData("code\u200B=synthetic-value-88&code_verifier\u00A0=synthetic-value-89", "synthetic-value-89")]
[InlineData("\"Authorization\" => \"Basic synthetic-value-85\"", "synthetic-value-85")]
[InlineData("callback ?x=1&amp;code=synthetic-value-86&amp;state=synthetic-value-87", "synthetic-value-86")]
[InlineData("callback ?x=1&amp;code=synthetic-value-86&amp;state=synthetic-value-87", "synthetic-value-87")]
[InlineData("Cookie: a=b; codeVerifier=synthetic-value-92\ncode=synthetic-value-93", "synthetic-value-93")]
[InlineData("Authorization: Basic synthetic-value-94 {\"codeVerifier\":\"synthetic-value-95\"}\n{\"code\":\"synthetic-value-96\"}", "synthetic-value-96")]
[InlineData("token=\"codeVerifier\"\ncode=synthetic-value-97", "synthetic-value-97")]
public void Secrets_NeverReachTheRingTheSinkOrTheFile(string message, string secret)
{
(ServiceLog log, AgentActivityLog file) = Create();
@@ -218,10 +327,105 @@ public sealed class ServiceLogTests : IDisposable
/// <summary>
/// An authorization header line loses its whole value, to the end of that line and no further, whatever its scheme
/// and in either letter case, and keeps its name; so does the same header as a member of a JSON object. The ID-token
/// hint, the code verifier and the session cookie's two names lose their value and keep their name and everything
/// around it. A callback's code and state lose a quoted value with its quotes, and a percent-encoded callback loses
/// both values up to the encoded separator. A header whose own line holds nothing after its name loses the next
/// line, which is where its value then stands, and the lines after that are kept; an authorization header written
/// with an equals sign, or under the name a web server's environment gives it, is a header too; and a callback
/// whose ampersands are written as in HTML loses both values. Each text is the same in the ring, the host's sink
/// and the file.
/// </summary>
[Theory]
[InlineData("Authorization: Basic c3ludGhldGlj", "Authorization: [REDACTED]")]
[InlineData("authorization: Digest username=\"operator\", response=\"synthetic-response\"", "authorization: [REDACTED]")]
[InlineData("Proxy-Authorization:Negotiate synthetic-ticket", "Proxy-Authorization:[REDACTED]")]
[InlineData("Authorization: Bearer synthetic-bearer deviceToken=synthetic-device", "Authorization: [REDACTED]")]
[InlineData("Request headers:\nAccept: */*\nAuthorization: Basic c3ludGhldGlj\nUser-Agent: agent", "Request headers:\n Accept: */*\n Authorization: [REDACTED]\n User-Agent: agent")]
[InlineData("{\"Authorization\":\"Basic c3ludGhldGlj\"}", "{\"Authorization\":[REDACTED]")]
[InlineData("GET https://id.example.com/logout?id_token_hint=synthetic-hint&post_logout_redirect_uri=io.qrun.qqq-print-agent:/oauth/callback&state=synthetic-state",
"GET https://id.example.com/logout?id_token_hint=[REDACTED]&post_logout_redirect_uri=io.qrun.qqq-print-agent:/oauth/callback&state=[REDACTED]")]
[InlineData("token request: grant_type=authorization_code&code_verifier=synthetic-verifier&redirect_uri=x", "token request: grant_type=authorization_code&code_verifier=[REDACTED]&redirect_uri=x")]
[InlineData("sessionUUID=synthetic-session; Path=/", "sessionUUID=[REDACTED]; Path=/")]
[InlineData("The server refused sessionId=synthetic-session for this device", "The server refused sessionId=[REDACTED] for this device")]
[InlineData("{\"sessionUUID\":\"synthetic-session\",\"theme\":\"dark\"}", "{\"sessionUUID\":[REDACTED],\"theme\":\"dark\"}")]
[InlineData("callback ?code=\"synthetic-quoted\"&state='synthetic-quoted-2' received", "callback ?code=[REDACTED]&state=[REDACTED] received")]
[InlineData("callback ?code%3Dsynthetic-encoded%26state%3Dsynthetic-encoded-2 received", "callback ?code%3D[REDACTED]%26state%3D[REDACTED] received")]
[InlineData("redirect_uri=https%3A%2F%2Fapp.example.com%2Fcb%3FCODE%3dsynthetic-encoded%23top", "redirect_uri=https%3A%2F%2Fapp.example.com%2Fcb%3FCODE%3d[REDACTED]%23top")]
[InlineData("Authorization:\r\n Basic synthetic-ticket\r\nAccept: */*", "Authorization:\n [REDACTED]\n Accept: */*")]
[InlineData("Authorization: \r\nBasic synthetic-ticket", "Authorization: \n [REDACTED]")]
[InlineData("Cookie:\n\tother=synthetic-cookie; theme=dark\nAccept: */*", "Cookie:\n \t[REDACTED]\n Accept: */*")]
[InlineData("headers: Accept=*/*, Authorization=Basic synthetic-ticket", "headers: Accept=*/*, Authorization=[REDACTED]")]
[InlineData("HTTP_AUTHORIZATION: Basic synthetic-ticket", "HTTP_AUTHORIZATION: [REDACTED]")]
[InlineData("HTTP_COOKIE: other=synthetic-cookie", "HTTP_COOKIE: [REDACTED]")]
[InlineData("<a href=\"https://print.example.com/cb?session_state=kept&amp;code=synthetic-code&amp;state=synthetic-state\">", "<a href=\"https://print.example.com/cb?session_state=kept&amp;code=[REDACTED]&amp;state=[REDACTED]\">")]
public void AuthorizationHeadersAndTheValuesAddedLater_AreRedacted(string message, string expected)
{
(ServiceLog log, AgentActivityLog file) = Create();
log.Write("INFO", message);
Assert.Equal(expected, AgentActivityLog.Redact(message));
Assert.Equal(expected, Assert.Single(_sink));
Assert.Equal(expected, Assert.Single(log.Entries()).Message);
string written = File.ReadAllText(file.CurrentLogFilePath);
Assert.DoesNotContain("synthetic-", written, StringComparison.Ordinal);
Assert.DoesNotContain("c3ludGhldGlj", written, StringComparison.Ordinal);
Assert.Contains("[REDACTED]", written, StringComparison.Ordinal);
}
/// <summary>
/// The product's own request and response bodies, were one ever logged, lose every secret and keep the rest, with
/// each member's name left to show what was hidden: the code-exchange request (its <c>code</c> and its
/// <c>codeVerifier</c>, as JSON and as a form), a session response's <c>uuid</c>, the enrollment request's code and
/// the enrollment response's device token, the device's proof header, the sign-out response's <c>idTokenHint</c>,
/// and the provider's sign-out address percent-encoded inside another address. The nonce of the code-exchange
/// request stays; <see cref="WhatTheRedactionStillDoesNotRecognise_IsRecorded"/> says why. Each text is the same in
/// the ring and the host's sink; the file, which redacts the joined line a second time, holds no secret either.
/// </summary>
[Theory]
[InlineData("POST /qqq/v1/manageSession body {\"code\":\"synthetic-code\",\"codeVerifier\":\"synthetic-verifier\",\"redirectUri\":\"io.qrun.qqq-print-agent:/oauth/callback\",\"authenticationFlow\":\"native\",\"expectedNonce\":\"kept-nonce\"}",
"POST /qqq/v1/manageSession body {\"code\":[REDACTED],\"codeVerifier\":[REDACTED],\"redirectUri\":\"io.qrun.qqq-print-agent:/oauth/callback\",\"authenticationFlow\":\"native\",\"expectedNonce\":\"kept-nonce\"}")]
[InlineData("code=synthetic-code&code_verifier=synthetic-verifier&grant_type=authorization_code", "code=[REDACTED]&code_verifier=[REDACTED]&grant_type=authorization_code")]
[InlineData("code%3Dsynthetic-code%26code_verifier%3Dsynthetic-verifier%26grant_type%3Dauthorization_code", "code%3D[REDACTED]%26code_verifier%3D[REDACTED]%26grant_type%3Dauthorization_code")]
[InlineData("{\"codeVerifier\":\"synthetic-verifier\",\"code\":\"synthetic-code\"}", "{\"codeVerifier\":[REDACTED],\"code\":[REDACTED]}")]
[InlineData("session response {\"uuid\":\"synthetic-session\",\"values\":{}}", "session response {\"uuid\":[REDACTED],\"values\":{}}")]
[InlineData("{\"enrollmentCode\":\"synthetic-code\",\"deviceName\":\"Front desk\",\"agentVersion\":\"2.0.0\"}", "{\"enrollmentCode\":[REDACTED],\"deviceName\":\"Front desk\",\"agentVersion\":\"2.0.0\"}")]
[InlineData("{\"protocolVersion\":1,\"deviceId\":\"device-17\",\"deviceToken\":\"synthetic-token\",\"pollIntervalSeconds\":5}", "{\"protocolVersion\":1,\"deviceId\":\"device-17\",\"deviceToken\":[REDACTED],\"pollIntervalSeconds\":5}")]
[InlineData("X-QQQ-Device-Id: device-17, X-QQQ-Device-Token: synthetic-token", "X-QQQ-Device-Id: device-17, X-QQQ-Device-Token: [REDACTED]")]
[InlineData("{\"nativeLogoutData\":{\"issuer\":\"https://id.example.com\",\"endSessionEndpoint\":\"https://id.example.com/logout\",\"idTokenHint\":\"synthetic-hint\"}}",
"{\"nativeLogoutData\":{\"issuer\":\"https://id.example.com\",\"endSessionEndpoint\":\"https://id.example.com/logout\",\"idTokenHint\":[REDACTED]}}")]
[InlineData("next=https%3A%2F%2Fid.example.com%2Flogout%3Fid_token_hint%3Dsynthetic-hint%26post_logout_redirect_uri%3Dx%26state%3Dsynthetic-state",
"next=https%3A%2F%2Fid.example.com%2Flogout%3Fid_token_hint%3D[REDACTED]%26post_logout_redirect_uri%3Dx%26state%3D[REDACTED]")]
[InlineData("next=https%3A%2F%2Fprint.example.com%2Fresume%3Fuuid%3Dsynthetic-session%26x%3D1", "next=https%3A%2F%2Fprint.example.com%2Fresume%3Fuuid%3D[REDACTED]%26x%3D1")]
[InlineData("{\"origin\":\"https://print.example.com/\",\"sessionToken\":\"synthetic-session\"}", "{\"origin\":\"https://print.example.com/\",\"sessionToken\":[REDACTED]}")]
public void TheProductsOwnBodies_LoseEverySecretAndKeepTheRest(string message, string expected)
{
(ServiceLog log, AgentActivityLog file) = Create();
log.Write("INFO", message);
Assert.Equal(expected, AgentActivityLog.Redact(message));
Assert.Equal(expected, Assert.Single(_sink));
Assert.Equal(expected, Assert.Single(log.Entries()).Message);
string written = File.ReadAllText(file.CurrentLogFilePath);
Assert.DoesNotContain("synthetic-", written, StringComparison.Ordinal);
Assert.Contains("[REDACTED]", written, StringComparison.Ordinal);
}
/// <summary>
/// "code" and "state" are ordinary words in a log, so neither is a keyword on its own the way "token" is: only a
/// parameter of an address is redacted. Lines that report a status code, an exit code, an error code or a state, with
/// a colon, an equals sign or neither, and lines that only mention a cookie, are written exactly as they were given.
/// parameter of an address is redacted, and a <c>code</c> in a message that also names a code verifier. Lines that
/// report a status code, an exit code, an error code or a state, with a colon, an equals sign or neither, an
/// error object with its code, and lines that only mention a cookie, an authorization, a session, an ID token or a
/// code verifier without giving a value, are written exactly as they were given. So is an identifier whose name
/// only ends in "uuid".
/// </summary>
[Theory]
[InlineData("The server answered with status code: 401")]
@@ -236,6 +440,16 @@ public sealed class ServiceLogTests : IDisposable
[InlineData("Is the printer in a ready state? code 0")]
[InlineData("HTTP GET /qqq/v1/session: session cookie yes, device proof no")]
[InlineData("The server sent no session cookie; sign in again")]
[InlineData("Printing blocked: QQQ authorization is required.")]
[InlineData("Authorization required by the server; sign in again")]
[InlineData("Waiting for authorization (attempt 2 of 5)")]
[InlineData("Native grant: id token hint present yes, code verifier present yes")]
[InlineData("The session identifier changed; signing in again")]
[InlineData("Session established for print.example.com, session UUID present yes")]
[InlineData("GET /qqq/v1/operator/printers?limit=50&after=printer-a1 answered 200")]
[InlineData("The server answered {\"code\":\"busy\",\"message\":\"Another sign-in is running.\"}")]
[InlineData("Code exchange: code present yes, code verifier present yes")]
[InlineData("Printer deviceUuid=3f2b8c1e jobUuid: 9a1c0e2d")]
public void OrdinaryLinesAboutCodesStatesAndCookies_AreUntouched(string message)
{
(ServiceLog log, AgentActivityLog file) = Create();
@@ -251,39 +465,60 @@ public sealed class ServiceLogTests : IDisposable
/// <summary>
/// The redaction of a line that holds neither a cookie header nor a callback's code or state is, to the character,
/// what it was before those two patterns were added: each expected text here was produced by the method as it stood
/// before them. It is what lets the comparison with the runtime's old method keep its meaning.
/// The redaction of a line that holds none of the shapes added since (a cookie header, an authorization header, a
/// callback's code or state, an ID-token hint, a code verifier, the session's names, a percent-encoded equals
/// sign after a name) is, to the character,
/// what it was before they were added: each expected text here was produced by the method as it stood before them.
/// It is what lets the comparison with the runtime's old method keep its meaning. The line that used to stand here
/// with an "Authorization:" header in front is now hidden to its end, which the test above holds; the same
/// credentials without the header name are still redacted one by one.
/// </summary>
[Theory]
[InlineData("Print service started.", "Print service started.")]
[InlineData("Authorization: Bearer synthetic-bearer deviceToken=synthetic-device enrollmentCode=\"synthetic-code\"", "Authorization: Bearer [REDACTED] deviceToken=[REDACTED] enrollmentCode=[REDACTED]")]
[InlineData("Sent Bearer synthetic-bearer deviceToken=synthetic-device enrollmentCode=\"synthetic-code\"", "Sent Bearer [REDACTED] deviceToken=[REDACTED] enrollmentCode=[REDACTED]")]
[InlineData("https://operator:synthetic-password@print.example.com/ refused access_token=synthetic-access&refresh_token=synthetic-refresh", "https://[REDACTED]@print.example.com/ refused access_token=[REDACTED]&refresh_token=[REDACTED]")]
[InlineData("First line\r\n2026-11-10 12:00:00 [ERROR] forged entry", "First line\n 2026-11-10 12:00:00 [ERROR] forged entry")]
[InlineData("{\"enrollmentCode\":\"synthetic-code\",\"name\":\"Front desk\"}", "{\"enrollmentCode\":[REDACTED],\"name\":\"Front desk\"}")]
[InlineData("password='synthetic-secret' contentBase64=synthetic-document, api-key: synthetic-key; secret = synthetic-secret", "password=[REDACTED] contentBase64=[REDACTED], api-key: [REDACTED]; secret = [REDACTED]")]
[InlineData("[Session] GET /qqq/v1/metaData token=synthetic-debug-token", "[Session] GET /qqq/v1/metaData token=[REDACTED]")]
public void LinesWithNeitherNewPattern_AreRedactedAsBefore(string message, string expected)
public void LinesWithNoPatternAddedSince_AreRedactedAsBefore(string message, string expected)
=> Assert.Equal(expected, AgentActivityLog.Redact(message));
/// <summary>
/// What the redaction still does not recognise, recorded so that each is a known fact and not a surprise. A code or a
/// state that is not a parameter of an address (on its own, or as members of a JSON object), a PKCE verifier, a
/// session cookie outside a cookie header line (as a bare pair, or as a JSON member), and a Basic credential all pass
/// through unchanged. The redaction cannot help with these. They stay out of the log only for as long as nothing
/// writes them: the service is handed a session credential and not the callback, the session client logs an address
/// without its query (asserted below) and says only whether a cookie or a Basic credential was attached. This test
/// does not show that nothing writes them; it shows what would happen if something did.
/// state that is not a parameter of an address (on its own, or as members of a JSON object) where the message names
/// no code verifier, a cookie whose name is not one of the product's two when it stands under a header name that
/// only ends in "Cookie", with an equals sign in place of the colon, or as a JSON member, a client assertion, and
/// a secret whose name is percent-encoded itself or whose colon is, and a callback value that opens a quote and
/// never closes it, all pass through unchanged. The redaction
/// cannot help with these. They stay out of the log only for as long as nothing writes them: the service is handed
/// a session credential and not the callback, the session client logs an address without its query (asserted
/// below) and says only whether a cookie was attached. This test does not show that nothing writes them; it shows
/// what would happen if something did.
/// <para>
/// A nonce is on this list on purpose, under both of its names (<c>nonce</c> in the address the browser is sent
/// to, <c>expectedNonce</c> in the code-exchange request). It is not a credential: the client makes it up, sends it
/// through the browser's address bar in the open, and the server only checks that the ID token repeats it, so
/// reading it afterwards gives no access and nothing to replay. The code and the verifier that travel with it
/// are what must not be read, and those are redacted. "nonce" is also an ordinary word in a protocol log, which
/// a keyword would hide.
/// </para>
/// </summary>
[Theory]
[InlineData("code=synthetic-code state=synthetic-state")]
[InlineData("{\"code\":\"synthetic-code\",\"state\":\"synthetic-state\"}")]
[InlineData("io.qrun.qqq-print-agent:/oauth/callback?code_verifier=synthetic-verifier")]
[InlineData("sessionUUID=synthetic-session")]
[InlineData("{\"Cookie\":\"sessionUUID=synthetic-session\"}")]
[InlineData("Authorization: Basic c3ludGhldGlj")]
[InlineData("SessionCookie: other=synthetic-cookie")]
[InlineData("cookie = other=synthetic-cookie")]
[InlineData("{\"Cookie\":\"other=synthetic-cookie\"}")]
[InlineData("nonce=synthetic-nonce client_assertion=synthetic-assertion")]
[InlineData("{\"expectedNonce\":\"synthetic-nonce\"}")]
[InlineData("expectedNonce=synthetic-nonce")]
[InlineData("code%5Fverifier=synthetic-verifier")]
[InlineData("%7B%22idTokenHint%22%3A%22synthetic-hint%22%7D")]
[InlineData("callback ?code=\"synthetic-code and no closing quote")]
[InlineData("callback ?code='synthetic-code and no closing quote")]
public void WhatTheRedactionStillDoesNotRecognise_IsRecorded(string message)
{
(ServiceLog log, _) = Create();
@@ -297,6 +532,78 @@ public sealed class ServiceLogTests : IDisposable
/// <summary>
/// Where the redaction takes part of a secret and leaves the rest, recorded like the shapes above so that each is a
/// known fact. A named value ends at the first space, comma, semicolon or ampersand, so what follows one of those
/// survives; a quoted value ends at the first quote, so an escaped quote inside it ends it early; an arrow
/// (<c>=&gt;</c>) is read as an equals sign with the value "&gt;", and the real value stands after it; and a
/// header whose value begins on its own line and is continued on the next loses the first line only. Each
/// expected text holds the part that survives. Closing these would need a parser for each notation, and a longer
/// reach (a named value to the end of its line, a header over every indented line after it) would take lines an
/// operator needs, the header lines of a logged request among them.
/// </summary>
[Theory]
[InlineData("id_token_hint=synthetic-head,surviving-tail", "id_token_hint=[REDACTED],surviving-tail")]
[InlineData("sessionUUID=synthetic-head surviving-tail", "sessionUUID=[REDACTED] surviving-tail")]
[InlineData("deviceToken=synthetic-head;surviving-tail", "deviceToken=[REDACTED];surviving-tail")]
[InlineData("{\"sessionUUID\":\"synthetic-head\\\"surviving-tail\"}", "{\"sessionUUID\":[REDACTED]surviving-tail\"}")]
[InlineData("password='it\\'s surviving-tail'", "password=[REDACTED]s surviving-tail'")]
[InlineData("sessionUUID => surviving-value", "sessionUUID =[REDACTED] surviving-value")]
[InlineData("Authorization: Digest username=\"operator\",\r\n response=\"surviving-tail\"", "Authorization: [REDACTED]\n response=\"surviving-tail\"")]
[InlineData("Cookie: a=synthetic-head;\r\n\tother=surviving-tail", "Cookie: [REDACTED]\n \tother=surviving-tail")]
public void WhatSurvivesBesideARedactedValue_IsRecorded(string message, string expected)
{
(ServiceLog log, _) = Create();
log.Write("INFO", message);
Assert.Equal(expected, AgentActivityLog.Redact(message));
Assert.Equal(expected, Assert.Single(_sink));
Assert.Contains("surviving-", expected, StringComparison.Ordinal);
Assert.DoesNotContain("synthetic-head", expected, StringComparison.Ordinal);
}
/// <summary>
/// What the redaction hides although it is not a secret, recorded for the same reason. A header pattern takes the
/// rest of its line, so a sentence that happens to put a colon after the word "cookie" or "authorization" loses
/// what follows; a header whose name only ends in "-Cookie" is hidden like a cookie header; a parameter named
/// "state" or "code" in an ordinary address loses its value; the word "uuid" with a value after it is taken for
/// a session response's; in a message that names a code verifier every "code" with a value loses it; a line that
/// ends in "authorization:" or "cookie:" takes the next line with it, as a header whose value stands there
/// does; "authorization" before an equals sign is a header; and the session cookie's name before a colon loses
/// what follows even when that is a number. No line
/// the product writes has these shapes: the session client writes "session cookie yes" without a colon, and its
/// one address with a query is the printer list's. If such a line is ever written it must be worded differently,
/// or the pattern anchored; hiding too much is the side these patterns err on.
/// </summary>
[Theory]
[InlineData("HTTP GET /x: session cookie: yes, device proof: no, basic credential: no", "HTTP GET /x: session cookie: [REDACTED]")]
[InlineData("Proxy stripped the cookie: the request was refused with 407 by proxy.example.com", "Proxy stripped the cookie: [REDACTED]")]
[InlineData("X-Cookie: abc", "X-Cookie: [REDACTED]")]
[InlineData("Waiting for authorization: the administrator must approve this device", "Waiting for authorization: [REDACTED]")]
[InlineData("GET /qqq/v1/qbit/printing/jobs?state=QUEUED&printerId=printer-a1 answered 200", "GET /qqq/v1/qbit/printing/jobs?state=[REDACTED]&printerId=printer-a1 answered 200")]
[InlineData("GET /report?code=E1234&lang=en answered 500", "GET /report?code=[REDACTED]&lang=en answered 500")]
[InlineData("The job's uuid: 204 was not found", "The job's uuid: [REDACTED] was not found")]
[InlineData("Exchange failed with exit code=1 (codeVerifier present yes)", "Exchange failed with exit code=[REDACTED] (codeVerifier present yes)")]
[InlineData("sessionId: 12 established", "sessionId: [REDACTED] established")]
[InlineData("The server asks for authorization:\nsign in again\nand try once more", "The server asks for authorization:\n [REDACTED]\n and try once more")]
[InlineData("The proxy removed the cookie:\r\nthe request was refused", "The proxy removed the cookie:\n [REDACTED]")]
[InlineData("authorization=required for this device", "authorization=[REDACTED]")]
[InlineData("session_cookie: yes", "session_cookie: [REDACTED]")]
public void WhatTheRedactionHidesAlthoughItIsNotASecret_IsRecorded(string message, string expected)
{
(ServiceLog log, _) = Create();
log.Write("INFO", message);
Assert.Equal(expected, AgentActivityLog.Redact(message));
Assert.Equal(expected, Assert.Single(_sink));
}
/// <summary>An operational line is cut at 1000 characters after redaction, and a line of exactly that length is kept whole.</summary>
[Fact]
public void OperationalLine_IsCappedAtOneThousandCharacters()
@@ -341,7 +648,7 @@ public sealed class ServiceLogTests : IDisposable
/// <summary>Clearing the ring empties what the companion is shown and nothing else; later lines are kept again.</summary>
/// <summary>Clearing the ring empties what the snapshot carries and nothing else; later lines are kept again.</summary>
[Fact]
public void ClearingTheRing_EmptiesItAndLaterLinesAreKept()
{
@@ -375,7 +682,7 @@ public sealed class ServiceLogTests : IDisposable
/// <summary>
/// While debug logging is on, debug detail goes to the file only, marked as debug, redacted and cut at 4000 characters.
/// It never enters the ring or the host's sink, so it cannot displace the operational entries the companion shows.
/// It never enters the ring or the host's sink, so it cannot displace the operational entries the snapshot carries.
/// </summary>
[Fact]
public void DebugDetail_GoesToTheFileOnly_RedactedAndCappedAtFourThousand()
@@ -444,28 +751,28 @@ public sealed class ServiceLogTests : IDisposable
/// <summary>
/// A view for one server writes the same lines with that server's name in front, in the ring, the sink and the file.
/// It shares the ring and the file with the log it came from, so lines from the service and from each server appear
/// together in the order they were written.
/// A view for one server writes the same lines with that server's short identifier in front (the first eight
/// characters of its identifier), in the ring, the sink and the file. It shares the ring and the file with the log
/// it came from, so lines from the service and from each server appear together in the order they were written.
/// </summary>
[Fact]
public void ForServer_PrefixesTheServerNameAndSharesTheRingAndTheFile()
public void ForServer_PrefixesTheShortIdentifierAndSharesTheRingAndTheFile()
{
(ServiceLog log, AgentActivityLog file) = Create();
ServiceLog production = log.ForServer("Production");
ServiceLog staging = log.ForServer("Staging");
ServiceLog production = log.ForServer(Production);
ServiceLog staging = log.ForServer(Staging);
log.Write("INFO", "Print service started.");
production.Write("WARN", "Printing blocked.");
staging.Write("ERROR", "Local command failed.");
production.Write("INFO", "Signed in.");
string[] expected = ["Print service started.", "[Production] Printing blocked.", "[Staging] Local command failed.", "[Production] Signed in."];
string[] expected = ["Print service started.", "[3f2b8c1e] Printing blocked.", "[9a1c0e2d] Local command failed.", "[3f2b8c1e] Signed in."];
Assert.Equal(expected, _sink, StringComparer.Ordinal);
Assert.Equal(expected, log.Entries().Select(entry => entry.Message), StringComparer.Ordinal);
Assert.Equal(expected, staging.Entries().Select(entry => entry.Message), StringComparer.Ordinal);
Assert.Equal(["INFO", "WARN", "ERROR", "INFO"], production.Entries().Select(entry => entry.Level), StringComparer.Ordinal);
Assert.Equal([" [INFO] [Service] Print service started.", " [WARN] [Service] [Production] Printing blocked.", " [ERROR] [Service] [Staging] Local command failed.", " [INFO] [Service] [Production] Signed in."], Lines(file));
Assert.Equal([" [INFO] [Service] Print service started.", " [WARN] [Service] [3f2b8c1e] Printing blocked.", " [ERROR] [Service] [9a1c0e2d] Local command failed.", " [INFO] [Service] [3f2b8c1e] Signed in."], Lines(file));
staging.ClearEntries();
Assert.Empty(log.Entries());
}
@@ -473,9 +780,9 @@ public sealed class ServiceLogTests : IDisposable
/// <summary>
/// A server's view redacts exactly as the log does, and the caps do not grow by the name: the line is still at most
/// 1000 characters (4000 for debug detail) with the name counted in. Debug detail is still dropped while it is off
/// and goes to the file only while it is on, and a failure and the session client's progress carry the name too.
/// A server's view redacts exactly as the log does, and the caps do not grow by the prefix: the line is still at most
/// 1000 characters (4000 for debug detail) with the prefix counted in. Debug detail is still dropped while it is off
/// and goes to the file only while it is on, and a failure and the session client's progress carry the prefix too.
/// </summary>
[Theory]
[InlineData(false)]
@@ -483,9 +790,9 @@ public sealed class ServiceLogTests : IDisposable
public void ForServer_KeepsRedactionCapsAndDebugRouting(bool debug)
{
(ServiceLog log, AgentActivityLog file) = Create(debug);
ServiceLog server = log.ForServer("Production");
ServiceLog server = log.ForServer(Production);
server.Write("ERROR", "Authorization: Bearer synthetic-server-bearer enrollmentCode=synthetic-server-code");
server.Write("ERROR", "Sent Bearer synthetic-server-bearer enrollmentCode=synthetic-server-code");
server.Write("INFO", new string('p', 1500));
server.Write("DEBUG", new string('q', 4500));
server.Failure("Sign-in failed", new IOException("password=synthetic-server-password"));
@@ -494,111 +801,92 @@ public sealed class ServiceLogTests : IDisposable
Assert.Equal(debug, server.IsDebugEnabled);
Assert.Equal(
[
"[Production] Authorization: Bearer [REDACTED] enrollmentCode=[REDACTED]",
"[Production] " + new string('p', 1000 - "[Production] ".Length),
"[Production] Sign-in failed: IOException: password=[REDACTED]",
"[Production] [Session] Discovery started.",
"[3f2b8c1e] Sent Bearer [REDACTED] enrollmentCode=[REDACTED]",
"[3f2b8c1e] " + new string('p', 1000 - "[3f2b8c1e] ".Length),
"[3f2b8c1e] Sign-in failed: IOException: password=[REDACTED]",
"[3f2b8c1e] [Session] Discovery started.",
], _sink);
Assert.Equal(_sink, log.Entries().Select(entry => entry.Message), StringComparer.Ordinal);
List<string> lines = Lines(file);
Assert.Equal(debug ? 6 : 4, lines.Count(line => line.StartsWith(" [", StringComparison.Ordinal)));
Assert.Equal(debug, lines.Contains(" [INFO] [DEBUG] [Service] [Production] " + new string('q', 4000 - "[Production] ".Length), StringComparer.Ordinal));
Assert.Equal(debug, lines.Contains(" [INFO] [DEBUG] [Service] [3f2b8c1e] " + new string('q', 4000 - "[3f2b8c1e] ".Length), StringComparer.Ordinal));
Assert.DoesNotContain(lines, line => line.Contains("synthetic-server", StringComparison.Ordinal));
}
/// <summary>
/// A server's name is an operator's text, so it is made safe to stand at the front of a line: a line break or other
/// control character becomes a space (it cannot start a forged log entry), surrounding spaces go, and a long name is
/// cut at 64 characters. A name with nothing in it is refused.
/// A view is named by a server's full identifier and by nothing else. A server's name is refused, whatever it holds:
/// a credential keyword that would make the file's second redaction hide the server's lines, square brackets that
/// would forge structure inside a line, a line break that would start a forged entry. So are an identifier's
/// beginning, an identifier in another spelling, and nothing at all. The refusal does not repeat what was given, and
/// nothing is written.
/// </summary>
[Fact]
public void ForServer_MakesTheNameSafeToPrefix()
[Theory]
[InlineData("Production")]
[InlineData("Cookie:")]
[InlineData("Test Cookie: staging")]
[InlineData("Lab token:")]
[InlineData("password=")]
[InlineData("A token=\"")]
[InlineData("a] [ERROR] forged [b")]
[InlineData("Warehouse\r\n2026-11-10 12:00:00 [ERROR] forged")]
[InlineData("3f2b8c1e")]
[InlineData("3f2b8c1e-5d4a-4f6b-9a7c-0e1d2c3b4a5")]
[InlineData("3F2B8C1E-5D4A-4F6B-9A7C-0E1D2C3B4A5F")]
[InlineData("{3f2b8c1e-5d4a-4f6b-9a7c-0e1d2c3b4a5f}")]
[InlineData(" 3f2b8c1e-5d4a-4f6b-9a7c-0e1d2c3b4a5f")]
[InlineData("3f2b8c1e-5d4a-4f6b-9a7c-0e1d2c3b4a5f\n")]
[InlineData("")]
[InlineData(" ")]
[InlineData(null)]
public void ForServer_TakesAFullIdentifierAndNothingElse(string? value)
{
(ServiceLog log, AgentActivityLog file) = Create();
log.ForServer(" Warehouse\r\n2026-11-10 12:00:00 [ERROR] forged\t ").Write("INFO", "one");
log.ForServer(new string('n', 80)).Write("INFO", "two");
ArgumentException refusal = Assert.Throws<ArgumentException>(() => log.ForServer(value!));
Assert.Equal(["[Warehouse 2026-11-10 12:00:00 [ERROR] forged] one", "[" + new string('n', 64) + "] two"], _sink);
Assert.Equal(2, File.ReadAllLines(file.CurrentLogFilePath).Length);
Assert.Equal(64, ServiceLog.ServerNameLimit);
Assert.Throws<ArgumentException>(() => log.ForServer(" "));
Assert.Throws<ArgumentNullException>(() => log.ForServer(null!));
Assert.True(string.IsNullOrWhiteSpace(value) || !refusal.Message.Contains(value, StringComparison.Ordinal));
Assert.Empty(_sink);
Assert.Empty(log.Entries());
Assert.False(File.Exists(file.CurrentLogFilePath));
}
/// <summary>
/// A server's name cannot switch off the redaction of the line it stands in front of. The redaction reads a quoted
/// value up to the next quote, so a name ending in an open quote, redacted as one string with the line, pairs with
/// the quote in front of the password and leaves the password itself in the log. The name and the line are redacted
/// apart: the password is gone from the ring, the host's sink and the file, for an operational line, for debug detail
/// and for a failure, and a secret written into the name itself is gone as well.
/// The prefix of a server's view changes nothing about the line behind it, in the ring, the host's sink or the file.
/// Each message is written once through the log and once through a view: the view's line is the log's own line with
/// the short identifier in front, for a plain line, for lines that hold a secret in each quoting, and for a line
/// that ends in an open quote. In the file, which redacts every joined line a second time, the server's line keeps
/// its marker, its prefix and its text: a prefix made of an operator's name could lose all three there.
/// </summary>
[Theory]
[InlineData("A token=\"", "[A token=[REDACTED]] ")]
[InlineData("B secret='", "[B secret=[REDACTED]] ")]
[InlineData("C password=\"synthetic-name-secret", "[C password=[REDACTED]] ")]
public void ForServer_ANameCannotDefeatTheRedactionOfItsLine(string name, string prefix)
[InlineData("Print lane stopped: The printer is offline (job job-204).")]
[InlineData("Sign-in failed: password=\"synthetic-quoted\" rejected")]
[InlineData("Sign-in failed: token='synthetic-quoted' refused")]
[InlineData("Cookie: sessionUUID=synthetic-cookie; theme=dark")]
[InlineData("returned to io.qrun.qqq-print-agent:/oauth/callback?code=synthetic-code&state=synthetic-state")]
[InlineData("The server sent secret=\"")]
public void ForServer_ThePrefixChangesNothingAboutTheLineBehindIt(string message)
{
(ServiceLog log, AgentActivityLog file) = Create(debug: true);
ServiceLog server = log.ForServer(name);
ServiceLog server = log.ForServer(Production);
server.Write("ERROR", "Sign-in failed: password=\"hunter2\" rejected");
server.Write("DEBUG", "POST /session password=\"hunter2\" rejected");
server.Failure("Sign-in failed", new IOException("token=\"hunter2\" refused"));
log.Write("WARN", message);
server.Write("WARN", message);
log.Write("DEBUG", message);
server.Write("DEBUG", message);
Assert.Equal([prefix + "Sign-in failed: password=[REDACTED] rejected", prefix + "Sign-in failed: IOException: token=[REDACTED] refused"], _sink);
List<string> lines = Lines(file);
Assert.Equal([AgentActivityLog.Redact(message), "[3f2b8c1e] " + AgentActivityLog.Redact(message)], _sink);
Assert.Equal(_sink, log.Entries().Select(entry => entry.Message), StringComparer.Ordinal);
string written = File.ReadAllText(file.CurrentLogFilePath);
Assert.DoesNotContain("hunter2", written, StringComparison.Ordinal);
Assert.DoesNotContain("synthetic-name-secret", written, StringComparison.Ordinal);
Assert.Contains("POST /session password=[REDACTED] rejected", written, StringComparison.Ordinal);
Assert.Equal(4, File.ReadAllLines(file.CurrentLogFilePath).Count(line => line.Contains(" [Service] ", StringComparison.Ordinal)));
}
/// <summary>
/// A character in a server's name that would end the line or change the order it is shown in becomes a space, as a
/// control character does: the line and paragraph separators (U+2028, U+2029), the next-line control (U+0085), the
/// bidirectional embeddings, overrides and isolates with the characters that end them (U+202A to U+202E, U+2066 to
/// U+2069), the directional marks and the zero-width characters. None of them is a control character, so each used
/// to pass through, and a name could show a forged line or text in reverse.
/// </summary>
[Theory]
[InlineData(0x0085)]
[InlineData(0x2028)]
[InlineData(0x2029)]
[InlineData(0x202A)]
[InlineData(0x202B)]
[InlineData(0x202C)]
[InlineData(0x202D)]
[InlineData(0x202E)]
[InlineData(0x2066)]
[InlineData(0x2067)]
[InlineData(0x2068)]
[InlineData(0x2069)]
[InlineData(0x200E)]
[InlineData(0x200F)]
[InlineData(0x061C)]
[InlineData(0x200B)]
[InlineData(0xFEFF)]
public void ForServer_ACharacterThatEndsOrReordersALine_BecomesASpace(int codePoint)
{
(ServiceLog log, AgentActivityLog file) = Create();
char character = (char)codePoint;
log.ForServer("Ware" + character + "house" + character).Write("INFO", "one");
log.ForServer(character + "2026-11-10 12:00:00 [ERROR] forged" + character + character + "entry").Write("INFO", "two");
Assert.Equal(["[Ware house] one", "[2026-11-10 12:00:00 [ERROR] forged entry] two"], _sink);
Assert.Equal(_sink, log.Entries().Select(entry => entry.Message), StringComparer.Ordinal);
Assert.Equal([" [INFO] [Service] [Ware house] one", " [INFO] [Service] [2026-11-10 12:00:00 [ERROR] forged entry] two"], Lines(file));
Assert.DoesNotContain(character.ToString(), File.ReadAllText(file.CurrentLogFilePath), StringComparison.Ordinal);
Assert.Throws<ArgumentException>(() => log.ForServer(new string(character, 3)));
Assert.Equal(4, lines.Count);
Assert.Equal(lines[0].Replace(" [Service] ", " [Service] [3f2b8c1e] ", StringComparison.Ordinal), lines[1]);
Assert.Equal(lines[2].Replace(" [Service] ", " [Service] [3f2b8c1e] ", StringComparison.Ordinal), lines[3]);
Assert.StartsWith(" [WARN] [Service] [3f2b8c1e] ", lines[1], StringComparison.Ordinal);
Assert.StartsWith(" [INFO] [DEBUG] [Service] [3f2b8c1e] ", lines[3], StringComparison.Ordinal);
Assert.DoesNotContain(lines, line => line.Contains("synthetic-", StringComparison.Ordinal));
}
@@ -609,7 +897,10 @@ public sealed class ServiceLogTests : IDisposable
/// ring, so the second writer cannot enter the ring until the first has, and the earlier stamp is the earlier entry.
/// Stamped before that lock, the second writer would finish during the reading and stand in the ring in front of an
/// earlier stamp. The wait for the second writer is bounded, and with the stamp inside the lock it always runs out:
/// that the second writer could not finish is the thing being shown.
/// that the second writer could not finish is the thing being shown. It is a deliberate wait of 200 milliseconds of
/// real time for something that must not happen, the one kind of timed wait a test of an absence cannot avoid, and
/// it is counted with the other timed waits in the audit (W28). It cannot fail falsely: a slow machine only makes
/// the second writer later still.
/// </summary>
[Fact]
public void RingOrder_IsTimestampOrder_WhenASecondWriterArrivesDuringTheFirstStamp()
@@ -662,7 +953,7 @@ public sealed class ServiceLogTests : IDisposable
{
for(int index = 0; index < 25; index++)
{
(writer % 2 == 0 ? shared : shared.ForServer("Production")).Write("INFO", $"writer {writer} line {index}");
(writer % 2 == 0 ? shared : shared.ForServer(Production)).Write("INFO", $"writer {writer} line {index}");
}
})));
@@ -672,7 +963,7 @@ public sealed class ServiceLogTests : IDisposable
Assert.Equal(100, ring.Length);
Assert.Equal(100, ring.Distinct(StringComparer.Ordinal).Count());
Assert.All(ring, line => Assert.Contains(line, received, StringComparer.Ordinal));
Assert.Equal(ring, shared.ForServer("Production").Entries().Select(entry => entry.Message), StringComparer.Ordinal);
Assert.Equal(ring, shared.ForServer(Production).Entries().Select(entry => entry.Message), StringComparer.Ordinal);
Assert.Equal(Enumerable.Range(101, 100).Select(reading => SteppingClock.Reading(reading)), shared.Entries().Select(entry => entry.Timestamp), StringComparer.Ordinal);
Assert.Equal(200, Lines(file).Count);
}