Commite387ab5bRecorded24 Sep 2026Repositorysigil

Verify the registry's toolchain index chain in-process

Message

(sigil cli manager registry) checks the chain behind sigil cli upgrade/use: registry.json under a baked root, the toolchain index under a toolchain-role key it lists, exact registry/path/ts/seq bindings in every trusted comment, seq rollback per root key, contract version keys, and the binary's sha256 and size against the signed entry.

Each step returns ok, unreachable (the registry did not serve it; the caller may fall back) or refused (it served something that failed a check; the caller must stop). Only the real root is trusted by default in every build; a dev or verify build uses its compiled-in fixture root only with SIGIL_TOOLCHAIN_TEST_ROOT=1.

The tests use real staging and production documents for the positive cases and a seed-derived minisign signer for every attack case.

Changed
 packages/sigil-cli/src/sigil/cli/manager/registry.sgl                 | 680 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 packages/sigil-cli/test/test-registry-bridge.sgl                      | 556 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 test/fixtures/registry-toolchain/production/registry.json             |  16 +++
 test/fixtures/registry-toolchain/production/registry.json.minisig     |   4 +
 test/fixtures/registry-toolchain/staging/registry.json                |  38 +++++++
 test/fixtures/registry-toolchain/staging/registry.json.minisig        |   4 +
 test/fixtures/registry-toolchain/staging/toolchain-index.json         |  20 ++++
 test/fixtures/registry-toolchain/staging/toolchain-index.json.minisig |   4 +
 8 files changed, 1322 insertions(+)
Diff
packages/sigil-cli/src/sigil/cli/manager/registry.sgladded
@@ -0,0 +1,680 @@
+1
;;; (sigil cli manager registry) - Verified toolchain index for `sigil cli`
+2
;;;
+3
;;; `sigil cli upgrade` and `sigil cli use` read the toolchain release
+4
;;; catalogue from the Sigil package registry (registry contract v1 §4.3)
+5
;;; and download binaries by content hash (§3). Everything they install
+6
;;; comes through one chain of checks, done in-process with
+7
;;; (sigil crypto minisign), so no `minisign` program is needed:
+8
;;;
+9
;;; 1. `/v1/meta/registry.json` must verify under a ROOT ANCHOR baked into
+10
;;; this build (`registry-root-anchors`, from (sigil registry trust)).
+11
;;; No other key can sign it, and nothing at runtime can add an anchor.
+12
;;; 2. registry.json names the operational keys. `/v1/toolchain/index.json`
+13
;;; must verify under a key it lists with the `toolchain` role.
+14
;;; 3. Each signature's trusted comment must bind the canonical registry
+15
;;; (`registry=pkg.usesigil.org`), the exact document path, a
+16
;;; timestamp, and a `seq` equal to the document's own `seq` (§6.2).
+17
;;; 4. `seq` may never go backwards: the highest seq seen for each
+18
;;; (root key, path) is remembered, and a lower one is refused.
+19
;;; 5. The binary comes from `/v1/cas/sha256/<hex>`; its sha256 and size
+20
;;; must equal the index entry's `source` and `size`.
+21
;;;
+22
;;; Every step reports one of three outcomes, and the difference between
+23
;;; the last two is the security property of this module:
+24
;;;
+25
;;; (ok . value) the step passed.
+26
;;; (unreachable . why) the registry did NOT SERVE what was asked for:
+27
;;; no connection, an HTTP error, or a 404. The
+28
;;; caller may fall back to Codeberg, with a
+29
;;; warning; that is no weaker than the pre-registry
+30
;;; path, which was HTTPS to Codeberg.
+31
;;; (refused . why) the registry SERVED something that failed
+32
;;; verification. The caller must stop. Falling back
+33
;;; here would let anyone who can tamper with the
+34
;;; registry's answers route every client to a
+35
;;; weaker source, silently.
+36
;;;
+37
;;; The one exception to "404 means unreachable" is a signature file: a
+38
;;; document the registry serves without its `.minisig` is refused.
+39
;;;
+40
;;; The registry base URL is client configuration: `SIGIL_REGISTRY`,
+41
;;; default `https://pkg.usesigil.org` (read by (sigil deps registry)).
+42
;;; Pointing it at a mirror or at staging changes where bytes come from,
+43
;;; never what verifies them. Only the real root verifies, in every build,
+44
;;; unless SIGIL_TOOLCHAIN_TEST_ROOT=1 opts a dev or verify build into its
+45
;;; compiled-in test root (see `toolchain-trust-anchors`).
+46
+47
(define-library (sigil cli manager registry)
+48
(import (sigil core)
+49
(sigil list)
+50
(sigil io)
+51
(sigil fs)
+52
(sigil path)
+53
(sigil string)
+54
(sigil json)
+55
(sigil http)
+56
(sigil crypto minisign)
+57
(sigil store hash)
+58
(only (sigil cli manager) make-directory-recursive)
+59
(only (sigil crypto) random-bytes)
+60
(only (sigil process) getenv)
+61
(only (sigil registry trust)
+62
registry-root-anchors baked-registry-root-public-key)
+63
(only (sigil deps registry)
+64
registry-base-url valid-cas-ref? cas-ref-hex cas-url))
+65
(export canonical-registry-host
+66
registry-json-path
+67
toolchain-index-path
+68
toolchain-registry-base-url
+69
toolchain-trust-anchors
+70
toolchain-trust-anchors-for
+71
valid-version-key?
+72
;; Pure verification (bytes in, tagged result out)
+73
parse-trusted-comment
+74
check-trusted-comment
+75
verify-registry-metadata
+76
verify-toolchain-index
+77
;; Fetch + verify the whole chain through an injectable fetcher
+78
load-verified-toolchain-index
+79
http-registry-fetcher
+80
;; Rollback state
+81
read-seen-seqs
+82
seen-seq
+83
record-seen-seq
+84
record-seen-seq!
+85
;; Index accessors
+86
toolchain-latest
+87
toolchain-version-entry
+88
toolchain-entry-platform
+89
toolchain-entry-platforms
+90
platform-source
+91
platform-size
+92
;; The downloaded binary
+93
verify-artifact-file
+94
download-toolchain-artifact)
+95
+96
(begin
+97
+98
;; ============================================================
+99
;; CONSTANTS AND CONFIGURATION
+100
;; ============================================================
+101
+102
;;; The origin every signature binds to (contract §6.2, §8.3). Mirrors
+103
;;; and staging serve documents signed for this name, so it is NOT
+104
;;; derived from the configured base URL.
+105
(define canonical-registry-host "pkg.usesigil.org")
+106
+107
(define registry-json-path "/v1/meta/registry.json")
+108
(define toolchain-index-path "/v1/toolchain/index.json")
+109
+110
;;; Where the registry is fetched from: `SIGIL_REGISTRY`, else
+111
;;; production. This is the switch between staging and production.
+112
(define (toolchain-registry-base-url) (registry-base-url))
+113
+114
;;; The root keys `cli upgrade`/`use` accept for registry.json.
+115
;;;
+116
;;; By default ONLY the real root, in every build. Dev and verify builds
+117
;;; also compile in the test fixture root (the `sigil-test-anchor`
+118
;;; feature, (sigil registry trust)), whose secret key is committed to
+119
;;; the repository. For registry metadata that is harmless; for
+120
;;; installing an executable it would let anyone holding that public
+121
;;; secret replace a developer's sigil. So the fixture root is used
+122
;;; here only when SIGIL_TOOLCHAIN_TEST_ROOT=1 is set explicitly, which
+123
;;; is how the staging registry (fixture-signed) is exercised.
+124
;;;
+125
;;; This switch can only NARROW trust: with it set, the anchors are
+126
;;; still the build's compiled-in set, which in a release build is the
+127
;;; real root alone. Nothing at runtime can add an anchor.
+128
(define (toolchain-trust-anchors-for test-root?)
+129
(if test-root?
+130
registry-root-anchors
+131
(list baked-registry-root-public-key)))
+132
+133
(define (toolchain-trust-anchors)
+134
(toolchain-trust-anchors-for
+135
(equal? (getenv "SIGIL_TOOLCHAIN_TEST_ROOT") "1")))
+136
+137
;;; Contract §1.6 version key: normalized SemVer, no prefix, no
+138
;;; leading zeros, no pre-release. Version strings end up in file
+139
;;; paths (`versions/<v>/sigil`), so nothing else is accepted.
+140
(define (valid-version-key? v)
+141
(and (string? v)
+142
(let ((parts (string-split v ".")))
+143
(and (= (length parts) 3)
+144
(every (lambda (p)
+145
(and (digits-only? p)
+146
(or (string=? p "0")
+147
(not (char=? (string-ref p 0) #\0)))))
+148
parts)))))
+149
+150
;; ============================================================
+151
;; OUTCOMES
+152
;; ============================================================
+153
+154
;; Inside the pure verifiers a refusal is raised as a tagged pair and
+155
;; caught once at the boundary, which keeps each check a single line.
+156
(define (refuse message)
+157
(raise (cons 'registry-refusal message)))
+158
+159
(define (registry-refusal? e)
+160
(and (pair? e) (eq? (car e) 'registry-refusal)))
+161
+162
(define-syntax with-refusals
+163
(syntax-rules ()
+164
((_ body ...)
+165
(guard (e ((registry-refusal? e) (cons 'refused (cdr e))))
+166
body ...))))
+167
+168
;; ============================================================
+169
;; SMALL VALIDATORS
+170
;; ============================================================
+171
+172
(define (digit-char? c) (and (char>=? c #\0) (char<=? c #\9)))
+173
+174
(define (digits-only? s)
+175
(and (string? s) (> (string-length s) 0)
+176
(every digit-char? (string->list s))))
+177
+178
(define (positive-integer? n) (and (integer? n) (> n 0)))
+179
+180
(define (uppercase-hex-id? value)
+181
(and (string? value) (= (string-length value) 16)
+182
(every (lambda (c)
+183
(or (digit-char? c) (and (char>=? c #\A) (char<=? c #\F))))
+184
(string->list value))))
+185
+186
(define (json-list value what)
+187
(unless (array? value) (refuse (string-append what " is not a JSON array")))
+188
(array->list value))
+189
+190
(define (decode-json-document bytes what)
+191
(let ((doc (guard (e (else #f)) (json-decode (utf8->string bytes)))))
+192
(unless (dict? doc) (refuse (string-append what " is not a JSON object")))
+193
doc))
+194
+195
(define (parse-signature-file bytes what)
+196
(or (guard (e (else #f)) (minisign-parse-signature bytes))
+197
(refuse (string-append what ".minisig is not a valid minisign signature file"))))
+198
+199
(define (parse-public-key text)
+200
(and (string? text)
+201
(guard (e (else #f)) (minisign-parse-public-key text))))
+202
+203
;; ============================================================
+204
;; TRUSTED COMMENTS (contract §6.2)
+205
;; ============================================================
+206
+207
;;; Split an authenticated trusted comment into an alist of k=v pairs.
+208
;;; Returns #f when a key appears twice: the grammar is space-separated
+209
;;; pairs, and a repeated key has no single meaning.
+210
(define (parse-trusted-comment comment)
+211
(let loop ((words (filter (lambda (w) (not (string=? w "")))
+212
(string-split comment " ")))
+213
(acc '()))
+214
(if (null? words)
+215
(reverse acc)
+216
(let* ((word (car words))
+217
(eq (string-index word (lambda (c) (char=? c #\=)))))
+218
(if (not eq)
+219
(loop (cdr words) acc) ; unknown shapes are tolerated
+220
(let ((key (substring word 0 eq))
+221
(value (substring word (+ eq 1) (string-length word))))
+222
(if (assoc key acc)
+223
#f
+224
(loop (cdr words) (cons (cons key value) acc)))))))))
+225
+226
;;; Check the bindings a verified trusted comment must carry for
+227
;;; CONTRACT-PATH. Returns the comment's seq; raises a refusal otherwise.
+228
(define (comment-seq comment contract-path)
+229
(let ((fields (parse-trusted-comment comment)))
+230
(unless fields
+231
(refuse (string-append "the signature for " contract-path
+232
" repeats a trusted-comment field")))
+233
(let ((registry (assoc-ref fields "registry"))
+234
(path (assoc-ref fields "path"))
+235
(seq (assoc-ref fields "seq"))
+236
(ts (assoc-ref fields "ts")))
+237
(unless (and registry (string=? registry canonical-registry-host))
+238
(refuse (string-append "the signature for " contract-path
+239
" is bound to registry "
+240
(or registry "(none)") ", not "
+241
canonical-registry-host)))
+242
(unless (and path (string=? path contract-path))
+243
(refuse (string-append "the signature for " contract-path
+244
" is bound to path " (or path "(none)"))))
+245
(unless (and ts (not (string=? ts "")))
+246
(refuse (string-append "the signature for " contract-path
+247
" has no ts= timestamp")))
+248
(unless (and (digits-only? seq) (positive-integer? (string->number seq)))
+249
(refuse (string-append "the signature for " contract-path
+250
" has no valid seq=")))
+251
(string->number seq))))
+252
+253
(define (assoc-ref alist key)
+254
(let ((pair (assoc key alist)))
+255
(and pair (cdr pair))))
+256
+257
;;; Exported form of the comment check, as a tagged result.
+258
(define (check-trusted-comment comment contract-path)
+259
(with-refusals (cons 'ok (comment-seq comment contract-path))))
+260
+261
;;; The document's own `seq` must equal the signed comment's, and
+262
;;; neither may be below MINIMUM-SEQ (the highest seen before).
+263
(define (check-document-seq doc signed-seq minimum-seq contract-path)
+264
(let ((doc-seq (dict-ref doc seq: #f)))
+265
(unless (positive-integer? doc-seq)
+266
(refuse (string-append contract-path " has no valid seq")))
+267
(unless (= doc-seq signed-seq)
+268
(refuse (string-append contract-path " says seq "
+269
(number->string doc-seq)
+270
" but its signature says seq "
+271
(number->string signed-seq))))
+272
(when (< doc-seq minimum-seq)
+273
(refuse (string-append contract-path " went backwards: seq "
+274
(number->string doc-seq)
+275
" is older than seq "
+276
(number->string minimum-seq)
+277
" already seen from this registry"
+278
" (a stale mirror, or a rollback)")))
+279
doc-seq))
+280
+281
;; ============================================================
+282
;; STEP 1: registry.json UNDER A ROOT ANCHOR
+283
;; ============================================================
+284
+285
(define (validate-key key)
+286
(unless (dict? key) (refuse "registry.json lists a key that is not an object"))
+287
(unless (uppercase-hex-id? (dict-ref key id: #f))
+288
(refuse "registry.json lists a key with an invalid id"))
+289
(unless (equal? (dict-ref key algorithm: #f) "minisign-ed25519")
+290
(refuse "registry.json lists a key with an unsupported algorithm"))
+291
(json-list (dict-ref key roles: #f) "a key's roles")
+292
(unless (string? (dict-ref key public-key: #f))
+293
(refuse "registry.json lists a key with no public-key"))
+294
(let ((status (dict-ref key status: #f)))
+295
(unless (member status '("active" "retired" "revoked"))
+296
(refuse "registry.json lists a key with an unsupported status"))
+297
(when (and (string=? status "retired")
+298
(not (positive-integer? (dict-ref key status-seq: #f))))
+299
(refuse "registry.json lists a retired key without status-seq")))
+300
key)
+301
+302
(define (has-role? key role)
+303
(and (member role (array->list (dict-ref key roles: #f))) #t))
+304
+305
(define (duplicate? values)
+306
(and (pair? values)
+307
(or (member (car values) (cdr values)) (duplicate? (cdr values)))))
+308
+309
;; (root-key-id . trusted-comment) for the first anchor that verifies.
+310
(define (first-verifying-anchor anchors sig doc-bytes)
+311
(and (pair? anchors)
+312
(let* ((key (parse-public-key (car anchors)))
+313
(comment (and key (minisign-verify-prehashed key sig doc-bytes))))
+314
(if comment
+315
(cons (minisign-public-key-id key) comment)
+316
(first-verifying-anchor (cdr anchors) sig doc-bytes)))))
+317
+318
;;; Verify registry.json bytes and their signature under ANCHORS (a
+319
;;; list of minisign public-key strings). MINIMUM-SEQ is a number, or a
+320
;;; procedure from the verifying root's key id to a number (rollback
+321
;;; memory is kept per root, see `read-seen-seqs`). Returns
+322
;;; (ok . (doc keys seq root-id)) keys = the validated key objects
+323
;;; (refused . message)
+324
(define (verify-registry-metadata doc-bytes sig-bytes anchors minimum-seq)
+325
(with-refusals
+326
(unless (and (list? anchors) (pair? anchors))
+327
(refuse "this build has no registry root anchor"))
+328
(let* ((sig (parse-signature-file sig-bytes "registry.json"))
+329
(verified (first-verifying-anchor anchors sig doc-bytes)))
+330
(unless verified
+331
(refuse "registry.json is not signed by a root key this build trusts"))
+332
(let* ((root-id (car verified))
+333
(comment (cdr verified))
+334
(minimum-seq (if (procedure? minimum-seq) (minimum-seq root-id) minimum-seq))
+335
(signed-seq (comment-seq comment registry-json-path))
+336
(doc (decode-json-document doc-bytes "registry.json")))
+337
(unless (equal? (dict-ref doc schema: #f) 1)
+338
(refuse "registry.json has an unsupported schema; a newer sigil may be needed"))
+339
(unless (equal? (dict-ref doc protocol: #f) 1)
+340
(refuse "registry.json has an unsupported protocol; a newer sigil may be needed"))
+341
(unless (equal? (dict-ref doc registry: #f) canonical-registry-host)
+342
(refuse (string-append "registry.json names a registry other than "
+343
canonical-registry-host)))
+344
(let* ((seq (check-document-seq doc signed-seq minimum-seq
+345
registry-json-path))
+346
(keys (map validate-key
+347
(json-list (dict-ref doc keys: #f) "registry.json keys"))))
+348
(when (duplicate? (map (lambda (k) (dict-ref k id: #f)) keys))
+349
(refuse "registry.json lists the same key id twice"))
+350
(cons 'ok (list doc keys seq root-id)))))))
+351
+352
;; ============================================================
+353
;; STEP 2: THE TOOLCHAIN INDEX UNDER A TOOLCHAIN-ROLE KEY
+354
;; ============================================================
+355
+356
;; The comment of the first toolchain-role key whose signature verifies
+357
;; and whose status allows it; raises on a revoked signer.
+358
(define (accepted-toolchain-comment keys sig doc-bytes)
+359
(let loop ((rest keys))
+360
(and (pair? rest)
+361
(let ((key (car rest)))
+362
(if (not (has-role? key "toolchain"))
+363
(loop (cdr rest))
+364
(let ((public (parse-public-key (dict-ref key public-key: #f))))
+365
(unless public
+366
(refuse (string-append "registry.json lists toolchain key "
+367
(dict-ref key id: "") " with an unreadable public-key")))
+368
(unless (string=? (minisign-public-key-id public) (dict-ref key id: ""))
+369
(refuse (string-append "registry.json lists toolchain key "
+370
(dict-ref key id: "")
+371
" whose public-key has id "
+372
(minisign-public-key-id public))))
+373
(let ((comment (minisign-verify-prehashed public sig doc-bytes))
+374
(status (dict-ref key status: #f)))
+375
(cond
+376
((not comment) (loop (cdr rest)))
+377
((string=? status "revoked")
+378
(refuse (string-append "the toolchain index is signed by revoked key "
+379
(dict-ref key id: ""))))
+380
((string=? status "active") comment)
+381
;; A retired key still verifies what it signed while it
+382
;; was current: documents below its status-seq.
+383
((string=? status "retired")
+384
(let ((fields (parse-trusted-comment comment)))
+385
(if (and fields
+386
(digits-only? (assoc-ref fields "seq"))
+387
(< (string->number (assoc-ref fields "seq"))
+388
(dict-ref key status-seq: 0)))
+389
comment
+390
(loop (cdr rest)))))
+391
(else (loop (cdr rest)))))))))))
+392
+393
(define (validate-platform platform-id entry)
+394
(unless (dict? entry)
+395
(refuse (string-append "toolchain index platform " platform-id " is not an object")))
+396
(unless (valid-cas-ref? (dict-ref entry source: #f))
+397
(refuse (string-append "toolchain index platform " platform-id
+398
" has an invalid source")))
+399
(unless (positive-integer? (dict-ref entry size: #f))
+400
(refuse (string-append "toolchain index platform " platform-id
+401
" has an invalid size")))
+402
(unless (string? (dict-ref entry artifact: #f))
+403
(refuse (string-append "toolchain index platform " platform-id
+404
" has no artifact name"))))
+405
+406
(define (validate-release version entry)
+407
(unless (dict? entry)
+408
(refuse (string-append "toolchain index release " version " is not an object")))
+409
(let ((platforms (dict-ref entry platforms: #f)))
+410
(unless (dict? platforms)
+411
(refuse (string-append "toolchain index release " version " has no platforms")))
+412
(for-each (lambda (k)
+413
(validate-platform (keyword->string k) (dict-ref platforms k #f)))
+414
(dict-keys platforms))))
+415
+416
;;; Verify toolchain index bytes and their signature under the
+417
;;; toolchain-role keys of a verified registry.json (KEYS). Returns
+418
;;; (ok . (doc seq))
+419
;;; (refused . message)
+420
(define (verify-toolchain-index doc-bytes sig-bytes keys minimum-seq)
+421
(with-refusals
+422
(let* ((sig (parse-signature-file sig-bytes "the toolchain index"))
+423
(comment (accepted-toolchain-comment keys sig doc-bytes)))
+424
(unless comment
+425
(refuse "the toolchain index is not signed by any toolchain key in registry.json"))
+426
(let* ((signed-seq (comment-seq comment toolchain-index-path))
+427
(doc (decode-json-document doc-bytes "the toolchain index")))
+428
(unless (equal? (dict-ref doc schema: #f) 1)
+429
(refuse "the toolchain index has an unsupported schema; a newer sigil may be needed"))
+430
(unless (equal? (dict-ref doc name: #f) "sigil")
+431
(refuse "the toolchain index is not for sigil"))
+432
(let ((seq (check-document-seq doc signed-seq minimum-seq
+433
toolchain-index-path))
+434
(versions (dict-ref doc versions: #f))
+435
(latest (dict-ref doc latest: #f)))
+436
(unless (dict? versions)
+437
(refuse "the toolchain index has no versions"))
+438
(unless (and (string? latest)
+439
(dict-ref versions (string->keyword latest) #f))
+440
(refuse "the toolchain index's latest is not one of its versions"))
+441
(for-each (lambda (k)
+442
(let ((version (keyword->string k)))
+443
(unless (valid-version-key? version)
+444
(refuse (string-append "the toolchain index has an invalid version key: "
+445
version)))
+446
(validate-release version (dict-ref versions k #f))))
+447
(dict-keys versions))
+448
(cons 'ok (list doc seq)))))))
+449
+450
;; ============================================================
+451
;; INDEX ACCESSORS (on a verified index)
+452
;; ============================================================
+453
+454
(define (toolchain-latest doc) (dict-ref doc latest: #f))
+455
+456
;;; The release entry for VERSION, or #f when the index does not carry
+457
;;; that version. A missing version is not a refusal: the registry
+458
;;; starts at the release published at the signing ceremony, and older
+459
;;; releases live only on Codeberg.
+460
(define (toolchain-version-entry doc version)
+461
(and (string? version)
+462
(dict-ref (dict-ref doc versions: #{}) (string->keyword version) #f)))
+463
+464
(define (toolchain-entry-platforms entry)
+465
(map keyword->string (dict-keys (dict-ref entry platforms: #{}))))
+466
+467
(define (toolchain-entry-platform entry platform)
+468
(dict-ref (dict-ref entry platforms: #{}) (string->keyword platform) #f))
+469
+470
(define (platform-source p) (dict-ref p source: #f))
+471
(define (platform-size p) (dict-ref p size: #f))
+472
+473
;; ============================================================
+474
;; ROLLBACK STATE: the highest seq seen per (root key, path)
+475
;; ============================================================
+476
;;
+477
;; A text file, one `<root-key-id> <path> <seq>` per line. Keyed by the
+478
;; root key that verified registry.json, not by URL: a mirror serving
+479
;; production's documents shares production's memory (the same signed
+480
;; seqs), while staging, signed by the fixture root, keeps its own
+481
;; (staging registry.json is at seq 122 while production's is at 1).
+482
;; A line that does not parse is ignored: the file is local state, and
+483
;; a damaged one costs only rollback memory, never trust. To reset it,
+484
;; delete the file.
+485
+486
(define (read-seen-seqs file)
+487
(if (not (file-exists? file))
+488
'()
+489
(let ((text (guard (e (else "")) (read-file-string file))))
+490
(filter-map
+491
(lambda (line)
+492
(let ((parts (filter (lambda (w) (not (string=? w "")))
+493
(string-split (string-trim line) " "))))
+494
(and (= (length parts) 3)
+495
(digits-only? (caddr parts))
+496
(cons (cons (car parts) (cadr parts))
+497
(string->number (caddr parts))))))
+498
(string-split text "\n")))))
+499

Showing the first 500 of 681 diff lines for this file. This diff is INCOMPLETE; read the file or clone the repository for the rest.

packages/sigil-cli/test/test-registry-bridge.sgladded
@@ -0,0 +1,556 @@
+1
;;; Tests for the verified toolchain index behind `sigil cli upgrade`/`use`
+2
;;; ((sigil cli manager registry)).
+3
;;;
+4
;;; Two kinds of evidence:
+5
;;;
+6
;;; * REAL BYTES. test/fixtures/registry-toolchain/ holds registry.json and
+7
;;; the toolchain index as served by the staging registry (signed with
+8
;;; the `minisign` program, fixture root DC8855833C274BE8), and
+9
;;; production's registry.json (the real root, C45E1150D2D3F55A). They
+10
;;; prove the in-process verifier accepts what minisign produced.
+11
;;;
+12
;;; * THROWAWAY KEYS. Every attack case needs a document signed by the
+13
;;; wrong key, or with the wrong binding, which no real key holder will
+14
;;; produce. `sign` below writes minisign signatures with Ed25519 keys
+15
;;; derived from fixed seeds, so no key file exists anywhere. The
+16
;;; happy-path case over those keys is the control that the signer
+17
;;; itself is right: without it, every refusal below could be the signer
+18
;;; failing rather than the check firing.
+19
;;;
+20
;;; Each attack case asserts the outcome TAG ('refused, never 'unreachable)
+21
;;; and a message fragment that only the intended check produces.
+22
+23
(import (sigil test)
+24
(sigil core)
+25
(sigil io)
+26
(sigil fs)
+27
(sigil path)
+28
(sigil string)
+29
(sigil process)
+30
(sigil crypto)
+31
(sigil crypto minisign)
+32
(sigil cli manager registry))
+33
+34
;; ============================================================
+35
;; Fixtures
+36
;; ============================================================
+37
+38
(define fixture-dir "test/fixtures/registry-toolchain")
+39
(define (fixture . parts) (read-file-bytes (apply path-join fixture-dir parts)))
+40
+41
(define fixture-root "RWToSyc8g1WI3P0eYKcMcKGJCkje2w1LLPKLrnbJKu1UMXY0y9/QGsHO")
+42
(define production-root "RWRa9dPSUBFexBbLdzZIfuAmuCYL736UeHC7IbdAOIYpgGIyDmWRaQHY")
+43
+44
;; ============================================================
+45
;; A minisign signer over seed-derived keys (tests only)
+46
;; ============================================================
+47
+48
(define (bv-append . parts)
+49
(let* ((total (apply + (map bytevector-length parts)))
+50
(out (make-bytevector total 0)))
+51
(let loop ((parts parts) (at 0))
+52
(if (null? parts)
+53
out
+54
(begin
+55
(bytevector-copy! out at (car parts))
+56
(loop (cdr parts) (+ at (bytevector-length (car parts)))))))))
+57
+58
;; A key is (seed key-id-bytes public-key-bytes).
+59
(define (make-key n)
+60
(let ((seed (make-bytevector 32 n)))
+61
(list seed (make-bytevector 8 (+ n 100)) (ed25519-public-key seed))))
+62
+63
(define (key-public k)
+64
(base64-encode (bv-append (bytevector 69 100) (cadr k) (caddr k))))
+65
+66
(define (key-id k)
+67
(minisign-public-key-id (minisign-parse-public-key (key-public k))))
+68
+69
;; A `.minisig` over BYTES with trusted comment COMMENT, prehashed ("ED").
+70
(define (sign k bytes comment)
+71
(let* ((sig (ed25519-sign (car k) (blake2b-512 bytes)))
+72
(global (ed25519-sign (car k) (bv-append sig (string->utf8 comment)))))
+73
(string->utf8
+74
(string-append "untrusted comment: test signature\n"
+75
(base64-encode (bv-append (bytevector 69 68) (cadr k) sig)) "\n"
+76
"trusted comment: " comment "\n"
+77
(base64-encode global) "\n"))))
+78
+79
(define (comment path seq)
+80
(string-append "registry=pkg.usesigil.org path=" path
+81
" seq=" (number->string seq) " ts=2026-09-24T00:00:00Z"))
+82
+83
(define root (make-key 1))
+84
(define toolchain (make-key 2))
+85
(define index-key (make-key 3))
+86
(define stranger (make-key 4))
+87
(define revoked (make-key 5))
+88
(define retired (make-key 6))
+89
+90
(define (key-json k roles status . extra)
+91
(string-append "{\"id\":\"" (key-id k) "\",\"algorithm\":\"minisign-ed25519\","
+92
"\"roles\":[" (string-join (map (lambda (r) (string-append "\"" r "\"")) roles) ",")
+93
"],\"public-key\":\"" (key-public k) "\",\"status\":\"" status "\""
+94
(apply string-append extra) "}"))
+95
+96
(define standard-keys
+97
(list (key-json root '("root") "active")
+98
(key-json toolchain '("toolchain") "active")
+99
(key-json index-key '("index") "active")))
+100
+101
(define (registry-bytes seq keys)
+102
(string->utf8
+103
(string-append "{\"schema\":1,\"protocol\":1,\"registry\":\"pkg.usesigil.org\","
+104
"\"seq\":" (number->string seq) ",\"updated\":\"2026-09-24T00:00:00Z\","
+105
"\"keys\":[" (string-join keys ",") "],\"log\":null,\"reproducibility\":null}")))
+106
+107
(define (index-bytes seq . opts)
+108
(let ((schema (if (pair? opts) (car opts) 1)))
+109
(string->utf8
+110
(string-append
+111
"{\"schema\":" (number->string schema) ",\"name\":\"sigil\",\"latest\":\"0.22.7\","
+112
"\"seq\":" (number->string seq) ",\"updated\":\"2026-09-24T00:00:00Z\","
+113
"\"versions\":{\"0.22.7\":{\"published\":\"2026-09-24T00:00:00Z\",\"seq\":"
+114
(number->string seq) ",\"platforms\":{\"linux-amd64-static\":{\"artifact\":"
+115
"\"sigil-linux-amd64-static\",\"size\":3,\"source\":\"sha256:"
+116
"039058c6f2c0cb492c533b0a4d14ef77cc0f78abccced5287d84a1a2011cfb81\"}}}}}"))))
+117
+118
;; A registry: an alist from path to fetch outcome, served by `fetcher`.
+119
(define (serve reg reg-sig idx idx-sig)
+120
(list (cons "/v1/meta/registry.json" (cons 'ok reg))
+121
(cons "/v1/meta/registry.json.minisig" (cons 'ok reg-sig))
+122
(cons "/v1/toolchain/index.json" (cons 'ok idx))
+123
(cons "/v1/toolchain/index.json.minisig" (cons 'ok idx-sig))))
+124
+125
(define (fetcher site)
+126
(lambda (path)
+127
(let ((entry (assoc path site)))
+128
(if entry (cdr entry) (cons 'missing (string-append path " 404"))))))
+129
+130
(define (with-path site path outcome)
+131
(cons (cons path outcome) (filter (lambda (e) (not (string=? (car e) path))) site)))
+132
+133
(define good-reg (registry-bytes 10 standard-keys))
+134
(define good-reg-sig (sign root good-reg (comment "/v1/meta/registry.json" 10)))
+135
(define good-idx (index-bytes 700))
+136
(define good-idx-sig (sign toolchain good-idx (comment "/v1/toolchain/index.json" 700)))
+137
(define good-site (serve good-reg good-reg-sig good-idx good-idx-sig))
+138
+139
;; Rollback memory is keyed by the root key that verified registry.json.
+140
(define scope (key-id root))
+141
+142
;; Every (root-id path seq) the loader asked to record, newest first.
+143
(define recorded (list))
+144
(define (record! root-id path seq) (set! recorded (cons (list root-id path seq) recorded)))
+145
+146
(define (load-site site . opts)
+147
(let ((seen (if (pair? opts) (car opts) '())))
+148
(load-verified-toolchain-index (fetcher site) (list (key-public root)) seen record!)))
+149
+150
(define (flip bytes at)
+151
(let ((copy (bytevector-copy bytes)))
+152
(bytevector-u8-set! copy at (modulo (+ 1 (bytevector-u8-ref copy at)) 256))
+153
copy))
+154
+155
(define (assert-refused result fragment)
+156
(assert-equal 'refused (car result))
+157
(unless (string-contains? (cdr result) fragment)
+158
(error (string-append "refusal message should mention: " fragment
+159
" -- got: " (if (string? (cdr result)) (cdr result) "?")))))
+160
+161
;; ============================================================
+162
;; Real bytes, signed by the minisign program
+163
;; ============================================================
+164
+165
(test-group "real registry documents"
+166
(test "staging registry.json verifies under the fixture root"
+167
(let ((r (verify-registry-metadata (fixture "staging" "registry.json")
+168
(fixture "staging" "registry.json.minisig")
+169
(list fixture-root) 0)))
+170
(assert-equal 'ok (car r))
+171
(assert-equal 122 (caddr (cdr r)))))
+172
+173
(test "staging toolchain index verifies under staging's toolchain key"
+174
(let* ((meta (verify-registry-metadata (fixture "staging" "registry.json")
+175
(fixture "staging" "registry.json.minisig")
+176
(list fixture-root) 0))
+177
(r (verify-toolchain-index (fixture "staging" "toolchain-index.json")
+178
(fixture "staging" "toolchain-index.json.minisig")
+179
(cadr (cdr meta)) 0)))
+180
(assert-equal 'ok (car r))
+181
(assert-equal "0.22.4" (toolchain-latest (cadr r)))
+182
(assert-equal 631 (caddr r))))
+183
+184
(test "production registry.json verifies under the production root"
+185
(let ((r (verify-registry-metadata (fixture "production" "registry.json")
+186
(fixture "production" "registry.json.minisig")
+187
(list production-root) 0)))
+188
(assert-equal 'ok (car r))
+189
(assert-equal 1 (caddr (cdr r)))))
+190
+191
(test "a build that trusts only the production root refuses staging"
+192
(assert-refused (verify-registry-metadata (fixture "staging" "registry.json")
+193
(fixture "staging" "registry.json.minisig")
+194
(list production-root) 0)
+195
"not signed by a root key this build trusts"))
+196
+197
(test "by default even this dev build trusts only the real root for upgrades"
+198
(assert-refused (verify-registry-metadata (fixture "staging" "registry.json")
+199
(fixture "staging" "registry.json.minisig")
+200
(toolchain-trust-anchors-for #f) 0)
+201
"not signed by a root key this build trusts"))
+202
+203
(test "with the test-root switch, this dev build accepts staging (the fixture root is compiled in)"
+204
(assert-equal 'ok (car (verify-registry-metadata
+205
(fixture "staging" "registry.json")
+206
(fixture "staging" "registry.json.minisig")
+207
(toolchain-trust-anchors-for #t) 0)))))
+208
+209
(test-group "version keys (contract §1.6)"
+210
(test "release versions are valid"
+211
(assert-true (valid-version-key? "0.22.7"))
+212
(assert-true (valid-version-key? "10.0.0")))
+213
(test "anything else is not"
+214
(for-each (lambda (v) (assert-false (valid-version-key? v)))
+215
(list "../bin" "0.22" "0.22.7.1" "v0.22.7" "0.22.07" "0.22.7-rc1" "" "0..7" "a.b.c"))))
+216
+217
;; ============================================================
+218
;; The chain, over throwaway keys
+219
;; ============================================================
+220
+221
(test-group "the chain accepts a correctly signed registry"
+222
(test "control: the test signer's documents verify end to end"
+223
(let ((r (load-site good-site)))
+224
(assert-equal 'ok (car r))
+225
(assert-equal "0.22.7" (toolchain-latest (cadr r)))
+226
(assert-equal 10 (caddr r))
+227
(assert-equal 700 (cadddr r))))
+228
+229
(test "seq equal to the highest seen is accepted"
+230
(let ((seen (record-seen-seq
+231
(record-seen-seq '() scope "/v1/meta/registry.json" 10)
+232
scope "/v1/toolchain/index.json" 700)))
+233
(assert-equal 'ok (car (load-site good-site seen))))))
+234
+235
(test-group "tampering is refused, never treated as an outage"
+236
(test "a tampered toolchain index"
+237
(assert-refused (load-site (with-path good-site "/v1/toolchain/index.json"
+238
(cons 'ok (flip good-idx 40))))
+239
"not signed by any toolchain key"))
+240
+241
(test "a tampered registry.json"
+242
(assert-refused (load-site (with-path good-site "/v1/meta/registry.json"
+243
(cons 'ok (flip good-reg 30))))
+244
"not signed by a root key"))
+245
+246
(test "an index signed by a key registry.json does not list"
+247
(assert-refused (load-site (with-path good-site "/v1/toolchain/index.json.minisig"
+248
(cons 'ok (sign stranger good-idx
+249
(comment "/v1/toolchain/index.json" 700)))))
+250
"not signed by any toolchain key"))
+251
+252
(test "registry.json signed by the toolchain key (only the root may sign it)"
+253
(assert-refused (load-site (with-path good-site "/v1/meta/registry.json.minisig"
+254
(cons 'ok (sign toolchain good-reg
+255
(comment "/v1/meta/registry.json" 10)))))
+256
"not signed by a root key"))
+257
+258
(test "an index signed by the root key (root is not a toolchain key)"
+259
(assert-refused (load-site (with-path good-site "/v1/toolchain/index.json.minisig"
+260
(cons 'ok (sign root good-idx
+261
(comment "/v1/toolchain/index.json" 700)))))
+262
"not signed by any toolchain key"))
+263
+264
(test "an index signed by the index-role key"
+265
(assert-refused (load-site (with-path good-site "/v1/toolchain/index.json.minisig"
+266
(cons 'ok (sign index-key good-idx
+267
(comment "/v1/toolchain/index.json" 700)))))
+268
"not signed by any toolchain key"))
+269
+270
(test "a signature bound to another path (cross-path replay)"
+271
(assert-refused (load-site (with-path good-site "/v1/toolchain/index.json.minisig"
+272
(cons 'ok (sign toolchain good-idx
+273
(comment "/v1/meta/registry.json" 700)))))
+274
"is bound to path"))
+275
+276
(test "a signature bound to another registry"
+277
(assert-refused (load-site (with-path good-site "/v1/toolchain/index.json.minisig"
+278
(cons 'ok (sign toolchain good-idx
+279
"registry=evil.example path=/v1/toolchain/index.json seq=700 ts=2026-09-24T00:00:00Z"))))
+280
"is bound to registry"))
+281
+282
(test "a signature without ts="
+283
(assert-refused (load-site (with-path good-site "/v1/toolchain/index.json.minisig"
+284
(cons 'ok (sign toolchain good-idx
+285
"registry=pkg.usesigil.org path=/v1/toolchain/index.json seq=700"))))
+286
"has no ts="))
+287
+288
(test "a signature with a repeated field"
+289
(assert-refused (load-site (with-path good-site "/v1/toolchain/index.json.minisig"
+290
(cons 'ok (sign toolchain good-idx
+291
(string-append (comment "/v1/toolchain/index.json" 700)
+292
" path=/v1/toolchain/index.json")))))
+293
"repeats a trusted-comment field"))
+294
+295
(test "a signed seq that differs from the document's seq"
+296
(assert-refused (load-site (with-path good-site "/v1/toolchain/index.json.minisig"
+297
(cons 'ok (sign toolchain good-idx
+298
(comment "/v1/toolchain/index.json" 701)))))
+299
"but its signature says seq"))
+300
+301
(test "seq=0 is not a valid seq"
+302
(let* ((idx (index-bytes 0)))
+303
(assert-refused (load-site (serve good-reg good-reg-sig idx
+304
(sign toolchain idx (comment "/v1/toolchain/index.json" 0))))
+305
"has no valid seq="))))
+306
+307
;; An index made of RAW json text, correctly signed, for the structure checks.
+308
(define (signed-index-site json)
+309
(let ((idx (string->utf8 json)))
+310
(serve good-reg good-reg-sig idx
+311
(sign toolchain idx (comment "/v1/toolchain/index.json" 700)))))
+312
+313
(define (index-json latest version source)
+314
(string-append
+315
"{\"schema\":1,\"name\":\"sigil\",\"latest\":\"" latest "\",\"seq\":700,"
+316
"\"updated\":\"2026-09-24T00:00:00Z\",\"versions\":{\"" version "\":{"
+317
"\"published\":\"2026-09-24T00:00:00Z\",\"seq\":700,\"platforms\":{"
+318
"\"linux-amd64\":{\"artifact\":\"sigil-linux-amd64\",\"size\":3,\"source\":\""
+319
source "\"}}}}}"))
+320
+321
(define good-source
+322
"sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad")
+323
+324
(define (index-sig-with text)
+325
(with-path good-site "/v1/toolchain/index.json.minisig"
+326
(cons 'ok (sign toolchain good-idx text))))
+327
+328
(test-group "bindings are exact matches"
+329
(test "a path with a suffix"
+330
(assert-refused (load-site (index-sig-with
+331
"registry=pkg.usesigil.org path=/v1/toolchain/index.json.evil seq=700 ts=2026-09-24T00:00:00Z"))
+332
"is bound to path"))
+333
+334
(test "a registry with a suffix"
+335
(assert-refused (load-site (index-sig-with
+336
"registry=pkg.usesigil.org.evil path=/v1/toolchain/index.json seq=700 ts=2026-09-24T00:00:00Z"))
+337
"is bound to registry"))
+338
+339
(test "an empty ts="
+340
(assert-refused (load-site (index-sig-with
+341
"registry=pkg.usesigil.org path=/v1/toolchain/index.json seq=700 ts="))
+342
"has no ts="))
+343
+344
(test "a seq with a sign"
+345
(assert-refused (load-site (index-sig-with
+346
"registry=pkg.usesigil.org path=/v1/toolchain/index.json seq=+700 ts=2026-09-24T00:00:00Z"))
+347
"has no valid seq="))
+348
+349
(test "a seq with trailing junk"
+350
(assert-refused (load-site (index-sig-with
+351
"registry=pkg.usesigil.org path=/v1/toolchain/index.json seq=700a ts=2026-09-24T00:00:00Z"))
+352
"has no valid seq=")))
+353
+354
(test-group "structure of signed documents"
+355
(test "control: a hand-written index with the same shape verifies"
+356
(assert-equal 'ok (car (load-site (signed-index-site (index-json "0.22.7" "0.22.7" good-source))))))
+357
+358
(test "a version key that is not X.Y.Z (it would become a directory name)"
+359
(assert-refused (load-site (signed-index-site (index-json "../bin" "../bin" good-source)))
+360
"invalid version key"))
+361
+362
(test "latest that is not one of the versions"
+363
(assert-refused (load-site (signed-index-site (index-json "0.22.8" "0.22.7" good-source)))
+364
"latest is not one of its versions"))
+365
+366
(test "a platform whose source is not a CAS ref"
+367
(assert-refused (load-site (signed-index-site (index-json "0.22.7" "0.22.7" "sha256:XYZ")))
+368
"has an invalid source"))
+369
+370
(test "registry.json naming another registry"
+371
(let ((reg (string->utf8 (string-replace (utf8->string good-reg)
+372
"\"registry\":\"pkg.usesigil.org\""
+373
"\"registry\":\"evil.example\""))))
+374
(assert-refused (load-site (serve reg (sign root reg (comment "/v1/meta/registry.json" 10))
+375
good-idx good-idx-sig))
+376
"names a registry other than"))))
+377
+378
(test-group "signature fetches"
+379
(test "registry.json served without its signature is refused"
+380
(assert-refused (load-site (with-path good-site "/v1/meta/registry.json.minisig"
+381
(cons 'missing "404")))
+382
"without its signature"))
+383
+384
(test "a signature that fails with a server error is an outage, not a refusal"
+385
(assert-equal 'unreachable
+386
(car (load-site (with-path good-site "/v1/toolchain/index.json.minisig"
+387
(cons 'unreachable "HTTP 503")))))))
+388
+389
(test-group "rollback"
+390
(test "a toolchain index older than one already seen"
+391
(assert-refused (load-site good-site (record-seen-seq '() scope "/v1/toolchain/index.json" 701))
+392
"went backwards"))
+393
+394
(test "a registry.json older than one already seen"
+395
(assert-refused (load-site good-site (record-seen-seq '() scope "/v1/meta/registry.json" 11))
+396
"went backwards"))
+397
+398
(test "seqs seen under another root do not apply"
+399
(assert-equal 'ok (car (load-site good-site
+400
(record-seen-seq '() (key-id stranger)
+401
"/v1/toolchain/index.json" 9999)))))
+402
+403
(test "both verified seqs are recorded, under the verifying root"
+404
(set! recorded (list))
+405
(load-site good-site)
+406
(assert-true (and (member (list scope "/v1/meta/registry.json" 10) recorded) #t))
+407
(assert-true (and (member (list scope "/v1/toolchain/index.json" 700) recorded) #t)))
+408
+409
(test "a verified registry.json is recorded even when the index is then missing"
+410
(set! recorded (list))
+411
(let ((r (load-site (with-path good-site "/v1/toolchain/index.json" (cons 'missing "404")))))
+412
(assert-equal 'unreachable (car r))
+413
(assert-true (and (member (list scope "/v1/meta/registry.json" 10) recorded) #t))))
+414
+415
(test "nothing is recorded for a registry.json that fails verification"
+416
(set! recorded (list))
+417
(load-site (with-path good-site "/v1/meta/registry.json" (cons 'ok (flip good-reg 30))))
+418
(assert-equal '() recorded)))
+419
+420
(test-group "key status in registry.json"
+421
(test "an index signed by a revoked toolchain key is refused as hostile"
+422
(let* ((reg (registry-bytes 10 (list (key-json root '("root") "active")
+423
(key-json revoked '("toolchain") "revoked"))))
+424
(site (serve reg (sign root reg (comment "/v1/meta/registry.json" 10))
+425
good-idx (sign revoked good-idx (comment "/v1/toolchain/index.json" 700)))))
+426
(assert-refused (load-site site) "revoked key")))
+427
+428
(test "a retired key still verifies documents below its status-seq"
+429
(let* ((reg (registry-bytes 10 (list (key-json root '("root") "active")
+430
(key-json retired '("toolchain") "retired"
+431
",\"status-seq\":701"))))
+432
(site (serve reg (sign root reg (comment "/v1/meta/registry.json" 10))
+433
good-idx (sign retired good-idx (comment "/v1/toolchain/index.json" 700)))))
+434
(assert-equal 'ok (car (load-site site)))))
+435
+436
(test "a retired key does not verify documents at or after its status-seq"
+437
(let* ((reg (registry-bytes 10 (list (key-json root '("root") "active")
+438
(key-json retired '("toolchain") "retired"
+439
",\"status-seq\":700"))))
+440
(site (serve reg (sign root reg (comment "/v1/meta/registry.json" 10))
+441
good-idx (sign retired good-idx (comment "/v1/toolchain/index.json" 700)))))
+442
(assert-refused (load-site site) "not signed by any toolchain key")))
+443
+444
(test "a key whose id does not match its public key"
+445
(let* ((bad (string-append "{\"id\":\"0000000000000000\",\"algorithm\":\"minisign-ed25519\","
+446
"\"roles\":[\"toolchain\"],\"public-key\":\"" (key-public toolchain)
+447
"\",\"status\":\"active\"}"))
+448
(reg (registry-bytes 10 (list (key-json root '("root") "active") bad)))
+449
(site (serve reg (sign root reg (comment "/v1/meta/registry.json" 10))
+450
good-idx good-idx-sig)))
+451
(assert-refused (load-site site) "whose public-key has id"))))
+452
+453
(test-group "documents this client does not understand"
+454
(test "an unsupported toolchain index schema"
+455
(let ((idx (index-bytes 700 2)))
+456
(assert-refused (load-site (serve good-reg good-reg-sig idx
+457
(sign toolchain idx (comment "/v1/toolchain/index.json" 700))))
+458
"unsupported schema"))))
+459
+460
;; ============================================================
+461
;; What falls back to Codeberg, and what does not
+462
;; ============================================================
+463
+464
(test-group "the registry did not serve it: unreachable, so the caller may fall back"
+465
(test "registry.json cannot be fetched"
+466
(let ((r (load-site (with-path good-site "/v1/meta/registry.json"
+467
(cons 'unreachable "could not connect")))))
+468
(assert-equal 'unreachable (car r))))
+469
+470
(test "no toolchain index yet (HTTP 404, production before the ceremony)"
+471
(let ((r (load-site (with-path good-site "/v1/toolchain/index.json"
+472
(cons 'missing "404")))))
+473
(assert-equal 'unreachable (car r))))
+474
+475
(test "a server error on the toolchain index"
+476
(let ((r (load-site (with-path good-site "/v1/toolchain/index.json"
+477
(cons 'unreachable "HTTP 503")))))
+478
(assert-equal 'unreachable (car r)))))
+479
+480
(test-group "the registry served something wrong: refused, no fallback"
+481
(test "a served document whose signature is 404"
+482
(assert-refused (load-site (with-path good-site "/v1/toolchain/index.json.minisig"
+483
(cons 'missing "404")))
+484
"without its signature"))
+485
+486
(test "a signature file that is not minisign"
+487
(assert-refused (load-site (with-path good-site "/v1/meta/registry.json.minisig"
+488
(cons 'ok (string->utf8 "not a signature\n"))))
+489
"not a valid minisign signature file")))
+490
+491
(test-group "a verified index that lacks what was asked for is not a refusal"
+492
(test "a version the index does not carry"
+493
(let ((r (load-site good-site)))
+494
(assert-equal 'ok (car r))
+495
(assert-false (toolchain-version-entry (cadr r) "0.21.0"))
+496
(assert-true (toolchain-version-entry (cadr r) "0.22.7"))))
+497
+498
(test "a platform the release does not carry"
+499
(let* ((r (load-site good-site))

Showing the first 500 of 557 diff lines for this file. This diff is INCOMPLETE; read the file or clone the repository for the rest.

test/fixtures/registry-toolchain/production/registry.jsonadded
@@ -0,0 +1,16 @@
+1
{
+2
"schema": 1,
+3
"protocol": 1,
+4
"registry": "pkg.usesigil.org",
+5
"seq": 1,
+6
"updated": "2026-07-30T08:04:54Z",
+7
"keys": [
+8
{"id":"C45E1150D2D3F55A","algorithm":"minisign-ed25519","roles":["root"],"public-key":"RWRa9dPSUBFexBbLdzZIfuAmuCYL736UeHC7IbdAOIYpgGIyDmWRaQHY","status":"active"},
+9
{"id":"21CC9BE316C6E0FD","algorithm":"minisign-ed25519","roles":["index"],"public-key":"RWT94MYW45vMIbIdQurIMk46IDZQMiHzlP7VwYQPVNztxJwE23FkHuR1","status":"active"},
+10
{"id":"7F92B426060F2F06","algorithm":"minisign-ed25519","roles":["toolchain"],"public-key":"RWQGLw8GJrSSfzudcy/vnMgUzoEUvVdmZxyzA20AJ6gSusjQk3eb3aBU","status":"active"},
+11
{"id":"DB1A828BD034F595","algorithm":"minisign-ed25519","roles":["substitutes"],"public-key":"RWSV9TTQi4Ia2zvGgBaclHH8wIC4c/E1249XreikJ8g0U4/z+vsM77p2","status":"active"},
+12
{"id":"989EF54F5E93C736","algorithm":"minisign-ed25519","roles":["channels"],"public-key":"RWQ2x5NeT/WemGsI3aV/G8gnrLevQftaIYIC2d06FPcoSV7n1hcan0j0","status":"active"}
+13
],
+14
"log": null,
+15
"reproducibility": null
+16
}
test/fixtures/registry-toolchain/production/registry.json.minisigadded
@@ -0,0 +1,4 @@
+1
untrusted comment: signature from minisign secret key
+2
RURa9dPSUBFexGJGC6m+fr4URX3C6/14b7cv85FbTgui4dCOAROj/xA0Vy4Sn5uXbBlsOW9PBYQNz+bZ2fvQPr4O5q//+bkLkQw=
+3
trusted comment: registry=pkg.usesigil.org path=/v1/meta/registry.json seq=1 ts=2026-07-30T08:04:54Z
+4
CL7XNWmV5Klo6bIHE8x2IXF18LwqHfZP2rynmoSPrRzDCLJtlGfw+MLx7cQGYSZo/Lk4cA2OGMFA/O1QzYBYCw==
test/fixtures/registry-toolchain/staging/registry.jsonadded
@@ -0,0 +1,38 @@
+1
{
+2
"schema": 1,
+3
"protocol": 1,
+4
"registry": "pkg.usesigil.org",
+5
"seq": 122,
+6
"updated": "2026-09-22T00:00:00Z",
+7
"keys": [
+8
{
+9
"id": "DC8855833C274BE8",
+10
"algorithm": "minisign-ed25519",
+11
"roles": [
+12
"root"
+13
],
+14
"public-key": "RWToSyc8g1WI3P0eYKcMcKGJCkje2w1LLPKLrnbJKu1UMXY0y9/QGsHO",
+15
"status": "active"
+16
},
+17
{
+18
"id": "6186763CEB9B738F",
+19
"algorithm": "minisign-ed25519",
+20
"roles": [
+21
"index"
+22
],
+23
"public-key": "RWSPc5vrPHaGYfNFlEtvl2FPQ/iPK0im4+CER95fgmuJwf0hGOHeZuIs",
+24
"status": "active"
+25
},
+26
{
+27
"id": "DCE57B7A08FEB1A8",
+28
"algorithm": "minisign-ed25519",
+29
"roles": [
+30
"toolchain"
+31
],
+32
"public-key": "RWSosf4Ienvl3EATeQ2PgA00Bf9tVtadrS8Wlu3P7mvwiFw+uOxNrYg/",
+33
"status": "active"
+34
}
+35
],
+36
"log": null,
+37
"reproducibility": null
+38
}
test/fixtures/registry-toolchain/staging/registry.json.minisigadded
@@ -0,0 +1,4 @@
+1
untrusted comment: signature from minisign secret key
+2
RUToSyc8g1WI3J6Uv+Y6oemr/XQOIodkISYXgTp6MnhguKAHCwEPhPZe5VR4qcNrZAdirqXrxjhb0NC0stWVXf8JPK7KEgBbiwE=
+3
trusted comment: registry=pkg.usesigil.org path=/v1/meta/registry.json seq=122 ts=2026-09-22T00:00:00Z
+4
t6k5ZFiNO/1D0RERH2cVUP1fhjbTAl+cOD/cxSCNIUh9eClug5S2whddvxRNEp5w4RZ2taLx1JCasLtiYbrmAg==
test/fixtures/registry-toolchain/staging/toolchain-index.jsonadded
@@ -0,0 +1,20 @@
+1
{
+2
"latest": "0.22.4",
+3
"name": "sigil",
+4
"schema": 1,
+5
"seq": 631,
+6
"updated": "2026-09-22T07:38:30Z",
+7
"versions": {
+8
"0.22.4": {
+9
"platforms": {
+10
"linux-amd64-static": {
+11
"artifact": "sigil-linux-amd64-static",
+12
"size": 11546562,
+13
"source": "sha256:a7e0902c4e9e7dcce0a88b4ff50559e526607fdc4fd31d5aad5321cf5469dca0"
+14
}
+15
},
+16
"published": "2026-09-22T07:38:30Z",
+17
"seq": 631
+18
}
+19
}
+20
}
test/fixtures/registry-toolchain/staging/toolchain-index.json.minisigadded
@@ -0,0 +1,4 @@
+1
untrusted comment: signature from minisign secret key
+2
RUSosf4Ienvl3EBn5bvbTNEj1Y0FbAV1TJKPcoimEqxMWNxg8btGW+lH9+v/r6b5TENTh+jH5OKSgj2xMIhJ2N8iPKZ5IF8figg=
+3
trusted comment: registry=pkg.usesigil.org path=/v1/toolchain/index.json seq=631 ts=2026-09-22T07:38:30Z
+4
ROH1NJjpdd7DvbvYhNU8ELRZsayMtt4LH5BSlx5uAfe9gxENXJImTXVC+gQTObGW+tdE79l6wtOjF5cYZpJwCQ==