Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@

## Unreleased

- Add `fetch-sibling-asset` to `rivet/distribution` and the
`raco rivet sign-manifest` subcommand. The sibling helper checks the HTTP
status before saving, enforces the declared Content-Length against the
byte limit, verifies an optional expected SHA-256, and streams under the
same connect and idle deadlines as the manifest fetch — replacing the
private per-product sidecar downloads that silently accepted 404 pages.
The subcommand builds a manifest from explicitly named artifacts, signs
it with the distribution signer, verifies the written bytes with the
public half before publishing (deleting the output on failure), and
optionally emits `.sha256` sidecars next to each artifact, so products
keep only their asset-naming conventions.

- Extend the family boundary to Commerce: online payment orchestration and
credential delivery remain optional product/service concerns and never
become Rivet dependencies.
Expand Down
18 changes: 18 additions & 0 deletions rivet-cli/main.rkt
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@
" raco rivet release --development exercise release flow without platform production signing\n"
" raco rivet release --without-updates release without an update manifest or update key\n"
" raco rivet manifest-compose <inputs...> [--output <file>]\n"
" raco rivet sign-manifest --application-id <id> --version <v> --build <n>\n"
" --channel <stable|beta|dev> --base-url <url>\n"
" --key-id <id> --artifact <platform>:<arch>:<installer>:<file> ...\n"
" [--private-key <der>] [--public-key <der>] [--output <file>]\n"
" [--sidecars] [--minimum-version <v>] [--previous-version <v>]\n"
" [--published-at <ts>] [--rollout <0-100>] [--rollback-allowed]\n"
" compose a verified family update manifest\n"
" raco rivet compliance generate SBOM/notices and run the license audit\n"
" raco rivet verify re-verify the current packaged artifact\n"
Expand Down Expand Up @@ -211,6 +217,18 @@
#:private-key-path private-key-path
#:key-id key-id))
(say "family update manifest: ~a" compose-output)]
[(cons "sign-manifest" rest)
;; Build the release update manifest from named artifacts, sign it
;; with the distribution signer, and (with a public key available)
;; verify the written bytes before publishing. Products keep only
;; their asset-naming conventions.
(define parse-sign-manifest-arguments
(command "sign-manifest" 'parse-sign-manifest-arguments))
(define-values (artifacts options)
(parse-sign-manifest-arguments rest))
(define sign-manifest! (command "sign-manifest" 'sign-manifest!))
(define output (sign-manifest! artifacts options))
(say "signed update manifest: ~a" output)]
[(list "verify")
(define verify-project-package! (command "verify" 'verify-project-package!))
(define output (verify-project-package! (current-project!)))
Expand Down
184 changes: 184 additions & 0 deletions rivet-cli/sign-manifest.rkt
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
#lang racket/base

;; Builds the release update manifest from a dist directory, signs it with
;; the distribution signer, and refuses to publish anything unsigned or
;; unverified. Products keep only their asset-naming conventions: each
;; artifact is named explicitly on the command line, and the manifest URL is
;; the base URL joined with the file name.

(require racket/file
racket/match
racket/path
racket/string
"../rivet/distribution/crypto.rkt"
"../rivet/distribution/manifest.rkt")

(provide sign-manifest!
parse-sign-manifest-arguments
(struct-out requested-artifact))

(define installer-symbols '(zip msi deb rpm appimage))

(struct requested-artifact (platform architecture installer path) #:transparent)

(define (parse-artifact-spec who spec)
(define parts (string-split spec ":"))
(unless (= (length parts) 4)
(raise-arguments-error who
"--artifact expects <platform>:<arch>:<installer>:<file>"
"value" spec))
(define platform (string->symbol (list-ref parts 0)))
(define architecture (string->symbol (list-ref parts 1)))
(define installer (string->symbol (list-ref parts 2)))
(unless (memq platform '(windows macos linux))
(raise-arguments-error who "artifact platform must be windows, macos, or linux"
"value" spec))
(unless (memq architecture '(x64 arm64))
(raise-arguments-error who "artifact architecture must be x64 or arm64"
"value" spec))
(unless (memq installer installer-symbols)
(raise-arguments-error
who
"artifact installer must be zip, msi, deb, rpm, or appimage"
"value" spec))
(requested-artifact platform architecture installer (list-ref parts 3)))

(define (parse-sign-manifest-arguments arguments)
(let loop ([remaining arguments]
[artifacts '()]
[options (hasheq)])
(define (with key tail value)
(if (hash-has-key? options key)
(raise-arguments-error 'sign-manifest
(format "~a may be specified only once" key))
(loop tail artifacts (hash-set options key value))))
(match remaining
['()
(values (reverse artifacts) options)]
[(list* "--application-id" value tail) (with 'application-id tail value)]
[(list* "--version" value tail) (with 'version tail value)]
[(list* "--build" value tail)
(define number (string->number value))
(unless number
(raise-arguments-error 'sign-manifest "--build requires an integer" "value" value))
(loop tail artifacts (hash-set options 'build number))]
[(list* "--channel" value tail)
(unless (memq (string->symbol value) '(stable beta dev))
(raise-arguments-error 'sign-manifest
"--channel must be stable, beta, or dev"
"value" value))
(with 'channel tail (string->symbol value))]
[(list* "--base-url" value tail)
(unless (and (string? value) (regexp-match? #px"^https://" value))
(raise-arguments-error 'sign-manifest
"--base-url must be an HTTPS URL"
"value" value))
(with 'base-url tail value)]
[(list* "--key-id" value tail) (with 'key-id tail value)]
[(list* "--private-key" value tail) (with 'private-key tail value)]
[(list* "--public-key" value tail) (with 'public-key tail value)]
[(list* "--output" value tail) (with 'output tail value)]
[(list* "--minimum-version" value tail) (with 'minimum-version tail value)]
[(list* "--previous-version" value tail) (with 'previous-version tail value)]
[(list* "--published-at" value tail) (with 'published-at tail value)]
[(list* "--rollout" value tail)
(define number (string->number value))
(unless (and number (integer? number) (>= number 0) (<= number 100))
(raise-arguments-error 'sign-manifest
"--rollout requires an integer between 0 and 100"
"value" value))
(loop tail artifacts (hash-set options 'rollout number))]
[(list "--rollback-allowed") (loop artifacts (hash-set options 'rollback-allowed? #t))]
[(list "--sidecars") (loop artifacts (hash-set options 'sidecars? #t))]
[(list* "--artifact" spec tail)
(loop tail (cons (parse-artifact-spec 'sign-manifest spec) artifacts) options)]
[(cons argument _)
(raise-arguments-error 'sign-manifest "unknown argument" "argument" argument)])))

(define (require-option who options key)
(or (hash-ref options key #f)
(raise-arguments-error who
(format "~a is required" key))))

(define (join-url base-url file-name)
(format "~a/~a"
(string-trim base-url "/" #:right? #t)
file-name))

(define (write-sidecar! artifact-path)
(define sidecar (path-add-extension artifact-path #".sha256"))
(call-with-output-file sidecar
#:exists 'truncate/replace
(lambda (out)
(fprintf out "~a ~a\n"
(sha256-file/hex artifact-path)
(path->string (file-name-from-path artifact-path)))))
sidecar)

(define (sign-manifest! artifacts options)
(define application-id (require-option 'sign-manifest options 'application-id))
(define version (require-option 'sign-manifest options 'version))
(define build (require-option 'sign-manifest options 'build))
(define channel (require-option 'sign-manifest options 'channel))
(define base-url (require-option 'sign-manifest options 'base-url))
(define key-id (require-option 'sign-manifest options 'key-id))
(define output
(string->path
(or (hash-ref options 'output #f)
"dist/update-manifest.json")))
(when (null? artifacts)
(raise-arguments-error 'sign-manifest
"at least one --artifact is required"))
(define private-key-path
(or (hash-ref options 'private-key #f)
(getenv "RIVET_UPDATE_PRIVATE_KEY")
(raise-arguments-error 'sign-manifest
"a private key is required: pass --private-key or set RIVET_UPDATE_PRIVATE_KEY")))
(define private-key (read-ed25519-private-key private-key-path))
(define manifest
(update-manifest
application-id version build channel
(or (hash-ref options 'published-at #f)
(number->string (current-seconds)))
(hash-ref options 'minimum-version "0.0.0")
(hash-ref options 'previous-version #f)
(hash-ref options 'rollback-allowed? #f)
(hash-ref options 'rollout 100)
(for/list ([artifact (in-list artifacts)])
(define path (string->path (requested-artifact-path artifact)))
(unless (file-exists? path)
(raise-arguments-error 'sign-manifest
"artifact file does not exist"
"file" path))
(update-artifact
(requested-artifact-platform artifact)
(requested-artifact-architecture artifact)
(join-url base-url (path->string (file-name-from-path path)))
(sha256-file/hex path)
(file-size path)
(requested-artifact-installer artifact)
'()))))
(make-parent-directory* output)
(call-with-output-file output
#:exists 'truncate/replace
(lambda (out) (write-signed-manifest manifest private-key key-id out)))
;; Fail-closed publish: read the written file back and verify the exact
;; bytes with the public half when one is available. A failed verification
;; deletes the output so an unverified feed cannot be published by accident.
(define public-key-path
(or (hash-ref options 'public-key #f)
(getenv "RIVET_UPDATE_PUBLIC_KEY")))
(when public-key-path
(with-handlers ([exn:fail?
(lambda (error)
(when (file-exists? output) (delete-file output))
(raise error))])
(call-with-input-file output
(lambda (in)
(verify-signed-manifest in
(read-ed25519-public-key public-key-path)
#:key-id key-id)))))
(when (hash-ref options 'sidecars? #f)
(for ([artifact (in-list artifacts)])
(write-sidecar! (string->path (requested-artifact-path artifact)))))
output)
81 changes: 81 additions & 0 deletions rivet/distribution/updater.rkt
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
(struct-out update-candidate)
(struct-out install-plan)
fetch-update-manifest
fetch-sibling-asset
select-update
download-update
verify-update-artifact!
Expand Down Expand Up @@ -179,6 +180,86 @@
#:key-id key-id))
(lambda () (close-input-port in))))))

;; Release sidecars and sibling artifacts (sha256 files, extra installers)
;; live next to the update feed on the same host. Every private copy of
;; this helper skipped the HTTP status check, so a 404 page was saved as
;; the payload and only failed later with a misleading checksum error.
;; This shared helper fails fast instead: non-2xx statuses name the URL and
;; status, Content-Length mismatches are rejected, and the download runs
;; under the same deadlines as the manifest fetch.
(define (fetch-sibling-asset url destination
#:sha256 [expected-sha256 #f]
#:maximum-bytes [maximum-bytes (* 1024 1024 1024)]
#:on-progress [on-progress void]
#:connect-timeout-seconds
[connect-timeout-seconds
update-fetch-connect-timeout-seconds]
#:idle-timeout-seconds
[idle-timeout-seconds
update-fetch-idle-timeout-seconds])
(unless (and (string? url) (regexp-match? #px"^https://" url))
(raise-argument-error 'fetch-sibling-asset "HTTPS URL string" url))
(unless (real? maximum-bytes)
(raise-argument-error 'fetch-sibling-asset "real?" maximum-bytes))
(call-with-fetch-deadline
'fetch-sibling-asset connect-timeout-seconds idle-timeout-seconds
(lambda ()
(define-values (response in)
(get-pure-port/headers
(string->url url)
'("User-Agent: Rivet-Updater/1")
#:redirections update-fetch-redirections))
(dynamic-wind
void
(lambda ()
;; The status line is the first response header: "HTTP/1.1 200 OK".
(define status-line (read-line in 'return))
(define status
(let ([match (regexp-match #rx"^HTTP/[0-9.]+ ([0-9]{3})" status-line)])
(and match (string->number (list-ref match 1)))))
(unless (and status (<= 200 status 299))
(error 'fetch-sibling-asset
"sibling asset request failed: HTTP ~a for ~a"
(or status "unknown") url))
;; Consume the header block; only Content-Length is load-bearing.
(define content-length
(let loop ()
(define line (read-line in 'return))
(cond
[(or (eof-object? line) (string=? line "")) #f]
[(regexp-match #rx"(?i:^content-length:[ ]*([0-9]+)[ ]*$)" line)
=> (lambda (match) (string->number (list-ref match 1)))]
[else (loop)])))
(when (exact-positive-integer? content-length)
(when (> content-length maximum-bytes)
(error 'fetch-sibling-asset
"sibling asset declares ~a bytes beyond the ~a byte limit"
content-length maximum-bytes)))
(make-parent-directory* destination)
(define temporary (path-add-extension destination #".partial"))
(when (file-exists? temporary) (delete-file temporary))
(call-with-partial-cleanup
temporary
(lambda ()
(call-with-output-file temporary
#:exists 'truncate/replace
#:mode 'binary
(lambda (out)
(copy-limited! in out maximum-bytes
#:who 'fetch-sibling-asset
#:on-progress on-progress
#:stall-seconds idle-timeout-seconds)))
(when expected-sha256
(define actual (sha256-file/hex temporary))
(unless (string=? actual expected-sha256)
(error 'fetch-sibling-asset
"sibling asset SHA-256 does not match the expected value"
"expected" expected-sha256
"actual" actual)))
(rename-file-or-directory temporary destination #t)
destination)))
(lambda () (close-input-port in))))))

(define install-kinds '(portable msi appimage package-manager))

(define (artifact-matches-install-kind? artifact install-kind)
Expand Down
Loading
Loading