Skip to content

ecash stage 3a: the issuer sidecar becomes real (wizard, counters, retirement, producer) - #969

Open
maxy-player wants to merge 10 commits into
MakePrisms:mainfrom
maxy-player:feat/ecash-mutual-credit-s3
Open

ecash stage 3a: the issuer sidecar becomes real (wizard, counters, retirement, producer)#969
maxy-player wants to merge 10 commits into
MakePrisms:mainfrom
maxy-player:feat/ecash-mutual-credit-s3

Conversation

@maxy-player

@maxy-player maxy-player commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Stage 3a — the issuer sidecar becomes real

The issuer_mint protocol tag (docs/protocol-v1.md §4.2) shipped in stage 1 and
nothing in production had ever produced one: at the base below, all five
with_issuer_mint call sites are tests. This is the producer. A seat stands up its own
Cashu mint, reads its own counters out of it, and tells the truth about them on the wire
— including when it has nothing to say.

This branch STACKS on #962 and #966, so it shows their commits too

Measured at write time with git ls-remote and git log upstream/main..HEAD:

It needs the same rebase over #964
that #966 needs.
#964 dissolves
the loopback-http:// fence into a shape rule and makes accepted_mints membership the
one gate; that rebase will collapse the class-aware predicate this PR adds at two sites in
wallet_ops.rs into whatever #964 leaves standing. I did not pre-empt it.

The two-seat loop is STAGE 3b, deferred by maxie's ruling

A real job paid in issuer credit is deliberately out of scope, not an oversight: at this
base only the Own marker admits (mint_class.rs:79-81), so a counterparty that reads
this seat's issuer_mint tag records it Declared and home::mint_allowed refuses a
loopback http:// URL outright — a second seat cannot hold or return these tokens at
this base
. No fixture pretends otherwise. It lands on top of #964.

What's here

maxplayer issuer init — local files only. No relay, no wallet, no network. It does
not install cdk-mintd, does not spawn it and does not supervise it: it writes files and
prints the exact command.

  • <home>/mint-seed, mode 0600, a fresh BIP39 mnemonic — never printed, never logged,
    never in an error message.
    An existing one is kept and said so, the same shape as
    the seat key: a lost mint seed is lost money-shaped state.
  • An existing seed is VALIDATED, not merely kept. The first cut of this PR took
    path.exists() as the whole answer, which meant the 0600 promise above only ever
    covered a seed this code had itself created — a 0644 file, or a symlink, left at that
    path by an earlier hand was adopted silently and init still reported success. Fixed in
    1d7869f: anything that is not a regular file is refused by name, symlink included (the
    check is symlink_metadata, because plain metadata reports the target's type and
    would let a link through), and on Unix an over-broad mode is narrowed to 0600 in place
    set_permissions never opens the file, so not a byte is read or rewritten. A mode
    already tighter than 0600 is left alone.
  • a22c554 corrects the mask that narrowing used. 1d7869f read the mode with
    & 0o7777 and then tested it against 0o177, and those two masks disagree:
    0o4600 & 0o177 == 0, so a regular seed file carrying setuid scored clean, the branch
    was skipped, and the bit survived while the code reported the file narrowed. Its
    regression could not see it either — it asserted mode & 0o777 == 0o600, and
    0o4600 & 0o777 == 0o600 passes. One mistake made twice: a mask narrower than the value
    being judged. The disallowed set is now spelled const DISALLOWED: u32 = 0o7000 | 0o177
    — the special bits (setuid, setgid, sticky) as well as everything below owner read+write
    — so mode & DISALLOWED != 0 is exactly "not 0600 or tighter". Every mode assertion
    in the tests now masks 0o7777, never 0o777.
    Masking the read down to 0o777
    would have hidden the bit rather than cleared it; the check has to see what
    set_permissions is about to overwrite. No residue is left un-narrowed on Unix —
    0o7777 is the whole mode word set_permissions writes, and every bit in it outside
    0o600 is in DISALLOWED. On non-Unix targets the branch does not compile and this PR
    makes no mode claim at all. Three regressions cover these two bullets, and each was run
    against the body it fixes: they FAIL there and PASS here.
  • e0a36d5 is what makes the bolded sentence above true. At a22c554 one file-mode
    assertion still masked 0o777the_seed_is_owner_only_and_never_leaves_its_file, which
    reads a real seed file's mode. Nothing was wrong with the assertion (that seed is created
    fresh by init in the same test, so no special bit can be present) but it was weaker than
    the sentence claimed, and a completeness sentence is only worth what its weakest member is.
    Re-derived at this head over the whole file: seven file-mode assertions in mod tests,
    all masking 0o7777, and zero masking 0o777.
    The one remaining live 0o777 is
    assert_eq!(0o4600 & 0o777, 0o600, …), which is arithmetic demonstrating the old trap so
    it cannot be reopened quietly — not an assertion about any file's mode.
  • <home>/mint/mintd-config.toml with no mnemonic key at all — the seed reaches the
    mint by --seed-file. It carries the four [ln] bounds that are not optional in
    0.17.2 even though the shipped example.config.toml comments them out (that example
    does not parse). listen_host = "127.0.0.1"; a bare ::1 is refused with a message
    that names the brackets, because cdk builds its bind address by string concatenation and
    the unbracketed form aborts the process at startup.
  • config.toml: issuer_mint, plus the URL appended to accepted_mints and to
    extra_mints, idempotently. The third key is not in the original scope — see
    "one deviation" below.

maxplayer issuer status — url, issued, redeemed, outstanding, retired, last_seen
and the work dir. Counters come from the mint's own sqlite opened read-only (mode=ro
enforced by the handle, not intended by the caller); there is no API for them — the
management RPC's 22 methods retire nothing. retired is the seat's own durable count
in <home>/mint/retired.jsonl, because the mint burns a proof without recording who
presented it. A test asserts retired <= redeemed and outstanding == issued - redeemed
with both sides printed.

maxplayer issuer issue / retire. Retirement's mechanism was measured, not
assumed
— see below.

The producer. heartbeat_for_state gains the advertisement as a required
Option<IssuerMintAd>, for exactly the reason admission is required: a caller that could
omit it would publish a seat whose silence is indistinguishable from a seat too old to
speak. Both publish sites pass it, including the terminal beat — a seat leaving the market
still owes what it issued.

The negative is the load-bearing half

Five cases yield an ABSENT tag, a beat that still publishes, and a seat that stays on
the market: no issuer_mint; a sidecar that is down; a sqlite that will not open; a
ledger line that will not parse; a URL missing from accepted_mints. Each is proved
separately.

"Down" needed a liveness check, not a file read. A killed cdk-mintd leaves its
sqlite fully readable, so a beat built from the file alone would state outstanding = N
with last_seen = now for a mint that died a week ago. advertisement asks the mint
(GET /v1/info, 2 s) before it reads a counter.

Retirement — what deliverable 0 actually proved

retired is a protocol-required counter and nothing in this tree could burn one. The
obvious route is closed on purpose: wallet melt at an issuer mint is refused by stage 2,
and that refusal stays — retirement is not a melt on the wallet surface.

Measured 4 Sep against cdk-mintd 0.17.2 (fake wallet), captured strings in the worker's
evidence package:

  1. A proof CAN be burned with no second mint and no Lightning. A NUT-05 melt moved all
    18 input proofs to state='SPENT' — 100 sat redeemed — while
    sum(blind_signature.amount) stayed at 100. No new signature. Outstanding 100 → 0.
  2. The melt completes against an arbitrary well-formed bolt11 the mint never issued,
    with nothing paying it.
    The invoice was generated in-process with a fresh random key
    and payment hash; the mint's mint_quote table held only its own, different invoice.
    Payment successful: state=PAID, amount=100, fee_paid=0 — and the preimage is
    empty
    . The fake wallet never asks whether it issued the invoice.

So retire builds a burn instrument: a bolt11 for the amount, signed by an ephemeral
key discarded on the next line, naming a payment hash nobody holds a preimage for. Nothing
can route it and nothing will settle it. It is honest for a mutual-credit issuer — the
issuer destroys its own IOU — and the mint's "payment" is decoration, as the 3 Sep
prove-out already recorded.

Two things found along the way, both noted in code:

  • cdk-fake-wallet 0.17.2 reports total_spent = amount + 1 unconditionally, so the
    mint logs an "Over paid … Fee was too high" line and returns no change. The recorded
    retirement is the melt quote's amount, never that inflated figure.
  • No swap can burn: verify_transaction_balanced requires outputs == inputs - fee
    exactly and rejects an empty output set. Melt is the only burn.

send/receive admit the seat's OWN mint, and nothing else new

Two sites, and only these two, still called the class-blind home::mint_allowed. They now
call mint_class::mint_admitted with IssuerMints::none().with_own(config.issuer_mint())
— built from this seat's config alone, so only the Own marker admits and a
counterparty's signed declaration widens nothing. wallet_ops.rs's melt fence is not
one of them. Proved with controls: the same loopback stub, configured and reachable but
undeclared, is still RealMintDisallowed.

The live proof

crates/maxplayer-core/tests/issuer_sidecar_live.rs,
#[ignore = "needs a local cdk-mintd 0.17.2"]. One seat, one sidecar, no counterparty:
init → sidecar up from the written config and --seed-file → mint 100 → outstanding == 100
→ the beat carries ["issuer_mint", url, 100, 0, ts] read off the event → retire 40 →
issued unchanged, outstanding == 60, the beat carries retired == 40 → sidecar killed
no tag, and the seat still advertises. Run green:

after issue: issued=100 redeemed=0 outstanding=100
after retire: issued=100 redeemed=40 outstanding=60 retired=40
test one_seat_issues_advertises_retires_and_falls_silent_when_its_sidecar_dies ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

One deviation from the written scope, named rather than hidden

The wizard writes three config keys, not two. extra_mints is the third, and without
it deliverables 0/2/4 cannot work: wallet_ops::configured_mints is accepted_mints[0]
plus extra_mints — it does not read the rest of accepted_mints — so an issuer URL
appended at accepted_mints[1] never reaches open_wallet_async, and the seat cannot open
a wallet at the mint it issues from. extra_mints is the existing operator-visible opt-in
list maxplayer wallet mints add already writes. Reversible in one line if that is the
wrong call.

Also worth stating: wallet melt's issuer-specific message is unreachable for a
loopback issuer mint, because the class-blind real-mint fence above it refuses every
http:// URL first. The refusal stands; only its wording differs. Nothing was changed
there.

Checks, measured at e0a36d5e129b02ef84f90af5c85dc0d28e925b04

Every figure below was re-run at that exact commit with the tree clean
(git status --porcelain empty, printed into each log as command output). None is carried
forward from the earlier head.

declared command result
cargo build --locked rc=0
cargo test -p maxplayer-core --locked --offline 386 passed / 0 failed
cargo test -p maxplayer-core --features acp --locked --offline 435 passed / 0 failed / 1 ignored, then 1 passed
cargo test -p maxplayer-core --features wallet --locked --offline first run RED — 1340 passed / 1 failed / 2 ignored (the flake below); rerun unchanged: 1341 passed / 0 failed / 2 ignored, then 5, 1, 1, 1, 2
cargo test -p maxplayer --locked --offline 147, 2, 3, 3, 6 — all passed
npm --prefix web/network test 18 passed / 0 failed, rc=0
npm --prefix web/app test NOT RUN — rc=127, tsc: command not found

Buyer-only (cargo check -p maxplayer --no-default-features --locked --offline) is rc=0.

The web/app row cannot run here: web/app/node_modules is absent and npm ci needs the
network, which is why checks.toml declares it as prepare and not as a command. This
branch touches zero files under web/ (see the diffstat of
fc67661..e0a36d5), so that row is unrun, not red. web/network does run offline —
it declares no npm dependencies — and it passes; the earlier revision of this body called
both npm rows unrun, which was needlessly true of that one.

The ignored live test is not in those totals by design. Run on its own against a real
cdk-mintd 0.17.2 at this head: 1 passed / 0 failed, printing
after issue: issued=100 redeemed=0 outstanding=100 then
after retire: issued=100 redeemed=40 outstanding=60 retired=40.

The flake recurred at this head, and the red run is reported rather than replaced.
credential_proxy::tests::a_declared_over_cap_body_is_refused_before_the_upstream_sees_it
failed the first wallet sweep at e0a36d5credential_proxy.rs:3520, reqwest error
ConnectionReset (OS code 54) writing the body to its own loopback test server on
127.0.0.1, 1340 passed / 1 failed. Run alone at the same head it passes; the identical
declared command rerun immediately afterwards is 1341 / 0 / 2 with exit 0. Both logs are
kept: the red one is not deleted or edited.

Measured, not assumed: git diff --stat fc67661..e0a36d5 -- crates/maxplayer-core/src/credential_proxy.rs
is empty and git log --oneline over the same range and path lists no commits — this
branch does not touch that file.
Its earlier history on this PR is the same test failing
once at 17b4283 and passing alone, on re-run, and in the full sweeps at 1d7869f and
a22c554. It has not been bisected to base, so "pre-existing" is an inference from the
diff and the error shape, not a measurement I have made.

⚠️ This one is a real hazard for whoever merges: the money-path row can go red without a
code change. It is not mine to fix under this brief, and I have not touched it.

⛔ No merge, no tag, no release, no push to upstream, no rebase. Bob merges.

🤖 Generated with Claude Code

w-ecash-mutual-credit and others added 7 commits September 2, 2026 15:58
Stage 1 of the ecash mutual-credit build.

One new OPTIONAL tag on the seat announcement,
`["issuer_mint", url, cap, outstanding, retired, last_seen]`, carrying the
seat's own mint URL and its issuance counters. Emitted from `HeartbeatDraft`
(`with_issuer_mint`), parsed into `ParsedHeartbeat.issuer_mint`. Absent or
malformed reads as unstated, never a rejection. Announcement only, never on
the kind-3402 claim (protocol-v1 §4.2 "Issuer mint"). No version bump: a
reader MUST ignore unrecognised tags (§2.1).

No production publish path sets it yet; stage 3 wires live counters.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Owner decision (Bob, 2 Sep, "lets keep it simple for now - no wrapper, no
limit"): nothing enforces a ceiling on an issuer's outstanding tokens, so
the tag no longer declares one. The tag is positional and shrinks to four
values: `["issuer_mint", url, outstanding, retired, last_seen]`. Both
index tables (the doc's position table and the malformed-shape test's
field table) are renumbered: outstanding 3->2, retired 4->3, last_seen 5->4.

`IssuerMintAd` loses `cap_sats`; serializer, parser destructure, the
wire-shape fixture and the arity labels follow. The counters stay: they
are the only trust signal an operator reads before extending credit.
…the tag

The tag shrank to four values when `cap` was removed; one module-doc
sentence at the top of the section still said five. Doc only.
…stage 2)

The buyer wallet learns a mint CLASS (docs/protocol-v1.md §4.2 "Issuer
mint"): Lightning, or ISSUER — a seat's own Cashu mint whose tokens are an
IOU for that seat's work and have no Lightning route in or out.

Markers, each recorded with WHO said it (`mint_class::IssuerMarker`):
- Own: this seat's own mint, from a new optional `issuer_mint` config key.
- Info: a mint whose NUT-06 info lists no bolt11 method under NUT-04/05,
  learned by a bounded GET /v1/info per accepted mint at accept.
- Declared: the seller's own kind-30340 `issuer_mint` tag, read off its
  announcement at accept by (author, kind, d).

Rules:
- The real-mint fence admits an Own/Info issuer mint whatever
  `allow_real_mints` says (it carries no sats). A Declared mint does NOT
  widen the fence: a seller's signed word must not open the buyer's fence to
  any mint it names. Every marker refuses the hop.
- `plan_payment` refuses a Lightning hop INTO or OUT OF any issuer mint
  with the plain reason "you hold none of this seller's currency"; an
  issuer entry is never a hop target. Direct payment at one is unchanged.
- `select_source_mint` prefers the buyer's OWN mint when the seller lists it.
- The hop executor asks both mints their class before any leg (run_hop
  gate + plan_quotes guard); `wallet fund` / `wallet melt` refuse at a mint
  whose info lists no bolt11. An unreachable info read is UNKNOWN, i.e.
  Lightning, so reachability errors keep their existing labels.
- The class knowledge is SEALED into the accept-bind (`issuer_mints`, with
  markers) and re-derived at pay; legacy binds read as none known.

Unit stays `sat`; the three sat gates (gateway.rs parse_offer,
payment_wallet.rs terms_for_offer + seller receive) are untouched.
`complete_mint_async` opened the wallet and went straight to `poll_and_mint`
(`check_mint_quote`, then `wallet.mint`) with no issuer-class check — the
guard sat only on fund-begin and melt. Insert the same
`refuse_lightning_op_at_issuer(&wallet, "wallet fund", &mint_url)` after the
wallet opens, before any quote call; nothing else in the function changes.

NEGATIVE test, offline: a same-process mint stub that answers `/v1/info`
(and the keysets refresh cdk makes alongside it) and RECORDS every request
path. At a stub whose NUT-04/NUT-05 list no bolt11 (Issuer), completion
returns `WalletOpsError::IssuerMint` and the mint sees only the info read —
no `check_mint_quote`, no `wallet.mint`. Control: the identical call at a
bolt11-listing stub passes the guard and fails later inside `poll_and_mint`
as an ordinary Wallet error, so the gate is the class, not the stub.
…ape)

A mint is an issuer mint because an operator said so — this seat's own
config (`IssuerMarker::Own`) or the counterparty's kind-30340 ad
(`IssuerMarker::Declared`) — and never because its /v1/info document
listed no bolt11 method. Owner's ruling, 3 Sep 2026 ("lets do 2").

Dies: `class_from_info`, `IssuerMarker::Info`, `IssuerMints::from_urls`,
`probe_issuer_mints`, and the three production network reads that
existed only to obtain a class — the probe in mint_class.rs,
`CdkHopEffects::class_of` in crossmint_hop.rs (`load_mint_info`), and
`refuse_lightning_op_at_issuer` in wallet_ops.rs (`load_mint_info`).

Replaces: `IssuerMarker::admits` is `matches!(self, Self::Own)`; nothing
else about admission changes. `CdkHopEffects::open` takes the caller's
`IssuerMints` and fixes both legs' classes there — the bind's seal on
the pay path, this seat's own config on the recovery sweep — and
`HopEffects::mint_classes` returns them. `wallet_ops`'s guard reads own
config and runs before the wallet opens. Accept builds
`IssuerMints::none().with_own(..).with_declared(..)`.

Kept: `IssuerMintSeal` and accept-time sealing. A seal written under the
retired `info` marker still loads: `#[serde(alias = "info")]` reads it
as `Declared` — the hop it refused it still refuses, it admits nothing,
and it re-serializes as `declared`.

Tests: 3 removed (a_mint_listing_no_bolt11_method_is_an_issuer_mint,
bolt11_on_either_table_reads_as_lightning,
every_marker_refuses_but_only_own_and_info_admit), 4 added
(every_marker_refuses_but_only_own_admits,
a_legacy_info_seal_still_reads_as_a_declaration_and_admits_nothing,
an_undeclared_mint_is_fenced_exactly_as_before_whatever_it_is,
crossmint_hop_plan_quotes_refuses_a_declared_issuer_leg_without_asking_the_mint);
the wallet_ops check-H test now asserts the declared mint receives NO
request at all, not even /v1/info, while its stub would answer as a
Lightning mint if asked.

`grep -rnw -E 'class_from_info|load_mint_info|get_mint_info'` under
crates/: 17 hits in 4 files at 3f13a1c, 1 at this commit (doctor.rs:93,
the reachability probe, which obtains no class).
…ters, retirement, producer)

The `issuer_mint` protocol tag (§4.2) shipped in stage 1 and nothing in
production had ever produced one. This is the producer.

`maxplayer issuer init` writes the sidecar's files and nothing else: a fresh
BIP39 mnemonic at `<home>/mint-seed` mode 0600 (kept, never overwritten, if one
exists), `<home>/mint/mintd-config.toml` with NO `mnemonic` key — the seed
reaches cdk-mintd by `--seed-file` — and `issuer_mint` / `accepted_mints` /
`extra_mints` in config.toml. It does not install, spawn or supervise the mint;
it prints the command.

`issuer status` reads the counters out of the mint's own sqlite, opened
read-only: issued = sum(blind_signature.amount), redeemed = sum(proof.amount
where state='SPENT'), outstanding = issued - redeemed. `retired` cannot come
from there — the mint burns a proof without recording who presented it — so it
is the seat's own durable count in `<home>/mint/retired.jsonl`, gated by
retired <= redeemed.

`issuer retire` burns. Measured against cdk-mintd 0.17.2 on 4 Sep: a NUT-05 melt
of a well-formed bolt11 the mint never issued, that nothing pays, moves every
input proof to SPENT and signs NO new blind signature — 18 proofs / 100 sat
burned with sum(blind_signature.amount) unchanged. No second mint, no Lightning.
`wallet melt`'s stage-2 refusal is untouched: retirement is not a melt on the
wallet surface.

`heartbeat_for_state` and `retraction_for_state` take the advertisement as a
REQUIRED `Option<IssuerMintAd>`, for the reason `admission` is required: a caller
that could omit it would publish a seat whose silence is indistinguishable from
one too old to speak. Both publish sites pass it.

The negative is the load-bearing half. No issuer mint, a sidecar that is DOWN, a
sqlite that will not open, a ledger line that will not parse, or a URL outside
`accepted_mints` all yield an ABSENT tag — the beat still publishes and the seat
stays on the market. Liveness is a `/v1/info` GET, not a file read: a dead mint's
sqlite still reads, and publishing its stale counters with `last_seen = now`
would be the wrong number §6 forbids.

`send_async` and `receive_async` get the class-aware fence the rest of the
product already has, so the seat can hold the currency it issues. Only the `Own`
marker admits; a counterparty's declaration widens nothing.

Out of scope by maxie's ruling: the two-seat loop (stage 3b, after MakePrisms#964).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Sep 4, 2026

Copy link
Copy Markdown

Someone is attempting to deploy a commit to the MakePrisms Team on Vercel.

A member of the Team first needs to authorize it.

w-ecash-s3a-issuer-sidecar added 3 commits September 4, 2026 06:10
`ensure_seed` took `path.exists()` as the whole answer, so the 0600 promise
only ever covered a seed this seat created. A file some earlier hand left at
0644, or a symlink pointing somewhere the seat does not own, was adopted
silently and the wizard still reported success.

Now an existing path is checked before it is kept:

- Anything that is not a regular file is refused by name, symlink included.
  The check is `symlink_metadata`, which does not follow links — plain
  `metadata` reports the TARGET's type and would let a link through. A symlink
  is refused even when its target is a fine 0600 file, because the seat cannot
  promise the mode of a path it does not own.
- On Unix a mode with any bit beyond owner read+write (`mode & 0o177`) is
  narrowed to 0600 in place. `set_permissions` does not open the file, so no
  byte is read or rewritten. A mode already tighter than 0600 is left alone.

The phrase stays unread, unreturned, unlogged: the added errors name the path
and the file type and never open the file.

Two regressions, both proved load-bearing against the previous body (they FAIL
on it, PASS here):

- `an_existing_over_readable_seed_is_narrowed_to_0600_without_touching_its_bytes`
  plants a 0644 non-mnemonic file, then asserts the bytes and the mtime are
  unchanged and the final mode is 0600.
- `a_seed_path_that_is_not_a_regular_file_is_refused` covers the symlink and
  the directory branches and checks nothing was written through the link.

`init_keeps_an_existing_seed_and_is_idempotent` is untouched — it reuses the
already-safe file the first init wrote, which is exactly why it could not catch
this.
The existing-seed narrowing read the mode with `& 0o7777` and then tested it
against `0o177`. Those masks disagree: `0o4600 & 0o177 == 0`, so a regular seed
file carrying setuid scored clean, the branch was skipped, and the bit survived
on money-shaped state while the code reported the file narrowed to 0600.

The regression could not see it either. It asserted `mode & 0o777 == 0o600`,
and `0o4600 & 0o777 == 0o600` passes. Both mistakes are one mistake — a mask
narrower than the value being judged.

- The disallowed set is now spelled `const DISALLOWED: u32 = 0o7000 | 0o177`:
  the special bits (setuid, setgid, sticky) as well as everything below owner
  read+write. `0600` is the one mode with neither, so `mode & DISALLOWED != 0`
  is exactly "not 0600 or tighter". Masking the read down to `0o777` instead
  would have hidden the bit rather than cleared it — the check has to see what
  `set_permissions` is about to overwrite.
- Both mode regressions now assert `& 0o7777`, never `& 0o777`.
- `an_existing_setuid_seed_has_its_special_bits_cleared` plants a regular file
  at `04600` and asserts bytes unchanged, mtime unchanged, and
  `mode & 0o7777 == 0o600`. It also asserts the two properties of the old code
  that made this invisible, so the hole cannot be reopened quietly.
- The `ensure_seed` doc comment now describes the mask the code actually uses.

Everything already passing is unchanged: symlink refusal through
`symlink_metadata`, refusal by name and file type, no byte read or rewritten, a
mode already tighter than 0600 left alone, and
`init_keeps_an_existing_seed_and_is_idempotent` untouched.

Proved load-bearing against the previous body: the new test FAILS there, leaving
the mode at decimal 2432 (0o4600) against an expected 384 (0o600), and passes
here. The other 14 tests pass in both arms.
`the_seed_is_owner_only_and_never_leaves_its_file` read a real seed file's mode
and masked it with `0o777` before asserting `0o600`. Every other mode assertion
in this module moved to `0o7777` with the mask fix; this one was missed.

No production hole follows and the assertion was never wrong: that seed is
created fresh by `init` in the same test, so no special bit can be present and
the two masks cannot disagree there. It was weaker than it claimed to be, which
is the entire defect — a file-mode assertion masks the whole mode word so the
special bits are witnessed rather than discarded.

`:1081` is deliberately left at `0o777`. It is arithmetic about the OLD mask
(`0o4600 & 0o777 == 0o600`), asserted so the trap cannot be reopened quietly,
not an assertion about any file's mode.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant