ruby_everywhere 0.1.7 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "base64"
5
+ require "securerandom"
6
+ require_relative "blake2b"
7
+
8
+ module Everywhere
9
+ # Minisign-compatible Ed25519 signing, stdlib-only (OpenSSL + our Blake2b).
10
+ #
11
+ # This is the update-artifact signing scheme: `every updates keygen` mints a
12
+ # keypair, `every publish` signs each artifact, and the shell verifies with
13
+ # the `minisign-verify` Rust crate before installing an update. Files are
14
+ # byte-compatible with the reference minisign tool — keys made here sign with
15
+ # `minisign -S`, keys made there publish here, and every `.minisig` verifies
16
+ # with `minisign -V`.
17
+ #
18
+ # Formats (https://jedisct1.github.io/minisign/):
19
+ # public key "Ed" || key_id[8] || ed25519_pk[32], base64, comment line above
20
+ # secret key "Ed" || "Sc" || "B2" || salt[32] || opslimit[8 LE] ||
21
+ # memlimit[8 LE] || (key_id[8] || sk[64] || checksum[32]) XOR
22
+ # scrypt(passphrase); checksum = BLAKE2b-256("Ed"||key_id||sk)
23
+ # signature "ED" || key_id[8] || sig[64] over BLAKE2b-512(file) —
24
+ # prehashed mode, so signing never loads the artifact whole —
25
+ # plus a global sig over sig||trusted_comment
26
+ module Minisign
27
+ Error = Class.new(StandardError)
28
+ VerifyError = Class.new(Error)
29
+ PassphraseError = Class.new(Error)
30
+
31
+ UNTRUSTED = "signature from ruby_everywhere secret key"
32
+
33
+ # libsodium scryptsalsa208sha256 "interactive" cost (N=2^14, r=8, p=1 —
34
+ # 16 MB, milliseconds to derive). The reference tool defaults to
35
+ # "sensitive" (1 GB); both read whatever the key file declares, so the
36
+ # cheaper default costs no compatibility.
37
+ KDF_OPSLIMIT = 524_288
38
+ KDF_MEMLIMIT = 16_777_216
39
+
40
+ KeyPair = Struct.new(:public_key, :secret_key, :key_id_hex, keyword_init: true)
41
+
42
+ module_function
43
+
44
+ # ---- keygen --------------------------------------------------------------
45
+
46
+ def keygen(passphrase: "")
47
+ pkey = OpenSSL::PKey.generate_key("ED25519")
48
+ key_id = SecureRandom.bytes(8)
49
+ sk = pkey.raw_private_key + pkey.raw_public_key # 32-byte seed + 32-byte pk
50
+
51
+ checksum = Blake2b.digest("Ed#{key_id}#{sk}", 32)
52
+ salt = SecureRandom.bytes(32)
53
+ stream = kdf_stream(passphrase, salt, KDF_OPSLIMIT, KDF_MEMLIMIT)
54
+
55
+ secret_blob = "EdScB2#{salt}#{[KDF_OPSLIMIT, KDF_MEMLIMIT].pack("Q<Q<")}" \
56
+ "#{xor_bytes("#{key_id}#{sk}#{checksum}", stream)}"
57
+ public_blob = "Ed#{key_id}#{pkey.raw_public_key}"
58
+ id_hex = key_id_hex(key_id)
59
+
60
+ KeyPair.new(
61
+ public_key: "untrusted comment: minisign public key #{id_hex}\n#{Base64.strict_encode64(public_blob)}\n",
62
+ secret_key: "untrusted comment: minisign encrypted secret key\n#{Base64.strict_encode64(secret_blob)}\n",
63
+ key_id_hex: id_hex
64
+ )
65
+ end
66
+
67
+ # ---- signing -------------------------------------------------------------
68
+
69
+ # Returns the full .minisig content for `path`.
70
+ def sign_file(path, secret_key:, passphrase: "", trusted_comment: nil)
71
+ key_id, seed = decrypt_secret_key(secret_key, passphrase)
72
+ pkey = OpenSSL::PKey.new_raw_private_key("ED25519", seed)
73
+
74
+ signature = pkey.sign(nil, Blake2b.digest64_file(path))
75
+ trusted_comment ||= "timestamp:#{Time.now.to_i}\tfile:#{File.basename(path)}\thashed"
76
+ global = pkey.sign(nil, signature + trusted_comment.b)
77
+
78
+ "untrusted comment: #{UNTRUSTED}\n" \
79
+ "#{Base64.strict_encode64("ED#{key_id}#{signature}")}\n" \
80
+ "trusted comment: #{trusted_comment}\n" \
81
+ "#{Base64.strict_encode64(global)}\n"
82
+ end
83
+
84
+ # ---- verification --------------------------------------------------------
85
+
86
+ # `public_key` may be the two-line key file or just its base64 line.
87
+ # Raises VerifyError unless everything checks out.
88
+ def verify_file(path, signature:, public_key:)
89
+ alg, key_id, sig, trusted_comment, global = parse_signature(signature)
90
+ pk_key_id, pk = parse_public_key(public_key)
91
+
92
+ raise VerifyError, "signature key id #{key_id_hex(key_id)} doesn't match public key #{key_id_hex(pk_key_id)}" unless key_id == pk_key_id
93
+
94
+ pkey = OpenSSL::PKey.new_raw_public_key("ED25519", pk)
95
+ message = alg == "ED" ? Blake2b.digest64_file(path) : File.binread(path)
96
+ raise VerifyError, "file signature invalid for #{File.basename(path)}" unless pkey.verify(nil, sig, message)
97
+ raise VerifyError, "trusted comment signature invalid" unless pkey.verify(nil, global, sig + trusted_comment.b)
98
+
99
+ true
100
+ end
101
+
102
+ # The bare base64 line of a public key — what everywhere.yml stores and the
103
+ # shell's minisign-verify consumes.
104
+ def public_key_base64(content)
105
+ line = content.lines.map(&:strip).reject { |l| l.empty? || l.start_with?("untrusted comment:") }.first
106
+ raise Error, "no public key data found" unless line
107
+
108
+ blob = decode64(line, "public key")
109
+ raise Error, "unsupported public key format" unless blob.bytesize == 42 && blob.start_with?("Ed")
110
+
111
+ line
112
+ end
113
+
114
+ # ---- internals -----------------------------------------------------------
115
+
116
+ def parse_public_key(content)
117
+ blob = decode64(public_key_base64(content), "public key")
118
+ [blob.byteslice(2, 8), blob.byteslice(10, 32)]
119
+ end
120
+
121
+ def parse_signature(content)
122
+ lines = content.lines.map(&:chomp)
123
+ sig_b64 = lines.find { |l| !l.start_with?("untrusted comment:") && !l.strip.empty? }
124
+ trusted = lines.find { |l| l.start_with?("trusted comment: ") }&.delete_prefix("trusted comment: ")
125
+ global_b64 = lines[lines.index { |l| l.start_with?("trusted comment:") }.to_i + 1] if trusted
126
+ raise VerifyError, "malformed signature file" unless sig_b64 && trusted && global_b64
127
+
128
+ blob = decode64(sig_b64, "signature")
129
+ alg = blob.byteslice(0, 2)
130
+ raise VerifyError, "unsupported signature algorithm #{alg.inspect}" unless %w[ED Ed].include?(alg)
131
+ raise VerifyError, "malformed signature" unless blob.bytesize == 74
132
+
133
+ [alg, blob.byteslice(2, 8), blob.byteslice(10, 64), trusted, decode64(global_b64, "global signature")]
134
+ end
135
+
136
+ # -> [key_id, ed25519_seed]
137
+ def decrypt_secret_key(content, passphrase)
138
+ raise Error, "no secret key material given" if content.to_s.strip.empty?
139
+
140
+ line = content.lines.map(&:strip).reject { |l| l.empty? || l.start_with?("untrusted comment:") }.first
141
+ blob = decode64(line.to_s, "secret key")
142
+ kdf_alg = blob.byteslice(2, 2)
143
+ unless blob.bytesize == 158 && blob.start_with?("Ed") && ["Sc", "\x00\x00"].include?(kdf_alg) &&
144
+ blob.byteslice(4, 2) == "B2"
145
+ raise Error, "unsupported secret key format"
146
+ end
147
+
148
+ keynum = if kdf_alg == "Sc"
149
+ salt = blob.byteslice(6, 32)
150
+ opslimit, memlimit = blob.byteslice(38, 16).unpack("Q<Q<")
151
+ xor_bytes(blob.byteslice(54, 104), kdf_stream(passphrase, salt, opslimit, memlimit))
152
+ else
153
+ blob.byteslice(54, 104) # null KDF (minisign -W): stored in the clear
154
+ end
155
+
156
+ key_id = keynum.byteslice(0, 8)
157
+ sk = keynum.byteslice(8, 64)
158
+ checksum = keynum.byteslice(72, 32)
159
+ # Reference minisign writes an all-zero checksum for unencrypted (-W)
160
+ # keys and skips the check; a wrong checksum on an encrypted key means a
161
+ # wrong passphrase (scrypt stream decrypted to garbage).
162
+ unless (kdf_alg != "Sc" && checksum == ("\0" * 32).b) ||
163
+ OpenSSL.secure_compare(checksum, Blake2b.digest("Ed#{key_id}#{sk}", 32))
164
+ raise PassphraseError, "wrong passphrase (or corrupt secret key)"
165
+ end
166
+
167
+ [key_id, sk.byteslice(0, 32)]
168
+ end
169
+
170
+ def kdf_stream(passphrase, salt, opslimit, memlimit)
171
+ n, r, p = scrypt_params(opslimit, memlimit)
172
+ OpenSSL::KDF.scrypt(passphrase.to_s, salt: salt, N: n, r: r, p: p, length: 104)
173
+ end
174
+
175
+ # Port of libsodium's pickparams: how opslimit/memlimit (what the key file
176
+ # stores) map to scrypt's N/r/p.
177
+ def scrypt_params(opslimit, memlimit)
178
+ opslimit = 32_768 if opslimit < 32_768
179
+ r = 8
180
+ if opslimit < memlimit / 32
181
+ max_n = opslimit / (r * 4)
182
+ [1 << n_log2(max_n), r, 1]
183
+ else
184
+ max_n = memlimit / (r * 128)
185
+ n = 1 << n_log2(max_n)
186
+ max_rp = [(opslimit / 4) / n, 0x3fffffff].min
187
+ [n, r, [max_rp / r, 1].max]
188
+ end
189
+ end
190
+
191
+ def n_log2(max_n) = (1..62).find { |i| (1 << i) > max_n / 2 } || 63
192
+
193
+ def key_id_hex(key_id) = key_id.unpack1("Q<").to_s(16).upcase
194
+
195
+ def xor_bytes(a, b)
196
+ a.unpack("C*").zip(b.unpack("C*")).map { |x, y| x ^ y }.pack("C*")
197
+ end
198
+
199
+ def decode64(str, what)
200
+ Base64.strict_decode64(str.strip)
201
+ rescue ArgumentError
202
+ raise Error, "invalid base64 in #{what}"
203
+ end
204
+ end
205
+ end
@@ -0,0 +1,232 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "openssl"
5
+ require "uri"
6
+ require "time"
7
+
8
+ module Everywhere
9
+ # Minimal S3 client — just the four operations `every publish` needs
10
+ # (PUT / HEAD / GET / CopyObject), signed with AWS Signature V4, stdlib only.
11
+ #
12
+ # Deliberately not aws-sdk-s3: the gem keeps a near-zero runtime dependency
13
+ # footprint, and SigV4 is a stable, testable ~150 lines. Works against any
14
+ # S3-compatible endpoint — AWS, Cloudflare R2, DigitalOcean Spaces, MinIO.
15
+ #
16
+ # Addressing: virtual-host style for AWS proper, path style for custom
17
+ # endpoints (R2/Spaces/MinIO all accept it; MinIO requires it). Override
18
+ # with force_path_style.
19
+ class S3
20
+ Error = Class.new(StandardError)
21
+
22
+ ALGORITHM = "AWS4-HMAC-SHA256"
23
+ UNSIGNED = "UNSIGNED-PAYLOAD"
24
+
25
+ attr_reader :bucket
26
+
27
+ def initialize(bucket:, region: "auto", endpoint: nil, force_path_style: nil,
28
+ access_key_id:, secret_access_key:, session_token: nil)
29
+ @bucket = bucket
30
+ @region = region.to_s.empty? ? "auto" : region
31
+ @access_key_id = access_key_id
32
+ @secret_access_key = secret_access_key
33
+ @session_token = session_token
34
+
35
+ if endpoint && !endpoint.empty?
36
+ @endpoint = URI(endpoint)
37
+ @path_style = force_path_style.nil? ? true : force_path_style
38
+ else
39
+ @endpoint = URI("https://s3.#{aws_region}.amazonaws.com")
40
+ @path_style = force_path_style || false
41
+ end
42
+ raise Error, "endpoint must be http(s)" unless %w[http https].include?(@endpoint.scheme)
43
+ end
44
+
45
+ # body: String or IO (IO must respond to #size or be paired with size:).
46
+ # sha256: precomputed hex payload digest; IOs default to UNSIGNED-PAYLOAD.
47
+ def put_object(key, body, content_type: nil, cache_control: nil, sha256: nil, size: nil)
48
+ headers = {}
49
+ headers["content-type"] = content_type if content_type
50
+ headers["cache-control"] = cache_control if cache_control
51
+ if body.is_a?(String)
52
+ sha256 ||= OpenSSL::Digest::SHA256.hexdigest(body)
53
+ headers["content-length"] = body.bytesize.to_s
54
+ else
55
+ sha256 ||= UNSIGNED
56
+ headers["content-length"] = (size || body.size).to_s
57
+ end
58
+ request(:put, key, headers: headers, body: body, payload_sha256: sha256)
59
+ true
60
+ end
61
+
62
+ # -> { size:, etag: } or nil when the key doesn't exist.
63
+ def head_object(key)
64
+ res = request(:head, key, allow_404: true)
65
+ return nil if res.code == "404"
66
+
67
+ { size: res["content-length"]&.to_i, etag: res["etag"]&.delete('"') }
68
+ end
69
+
70
+ # -> body String, or nil when the key doesn't exist.
71
+ def get_object(key)
72
+ res = request(:get, key, allow_404: true)
73
+ res.code == "404" ? nil : res.body
74
+ end
75
+
76
+ # Server-side copy within the bucket (how the -latest alias is refreshed
77
+ # without re-uploading the artifact).
78
+ def copy_object(source_key, dest_key, content_type: nil, cache_control: nil)
79
+ headers = { "x-amz-copy-source" => "/#{@bucket}/#{uri_encode_path(source_key)}" }
80
+ if content_type || cache_control
81
+ headers["x-amz-metadata-directive"] = "REPLACE"
82
+ headers["content-type"] = content_type if content_type
83
+ headers["cache-control"] = cache_control if cache_control
84
+ end
85
+ res = request(:put, dest_key, headers: headers)
86
+ # CopyObject reports some failures inside a 200 body.
87
+ raise Error, "copy #{source_key} -> #{dest_key} failed: #{res.body}" if res.body.to_s.include?("<Error>")
88
+
89
+ true
90
+ end
91
+
92
+ # ---- request plumbing ----------------------------------------------------
93
+
94
+ private
95
+
96
+ def request(method, key, headers: {}, body: nil, payload_sha256: nil, allow_404: false, attempt: 1)
97
+ uri = object_uri(key)
98
+ payload_sha256 ||= OpenSSL::Digest::SHA256.hexdigest("")
99
+ all = headers.merge(Signer.headers(
100
+ method: method, uri: uri, headers: headers, payload_sha256: payload_sha256,
101
+ access_key_id: @access_key_id, secret_access_key: @secret_access_key,
102
+ session_token: @session_token, region: aws_region
103
+ ))
104
+
105
+ req = Net::HTTP.const_get(method.capitalize).new(uri.request_uri)
106
+ all.each { |k, v| req[k] = v }
107
+ if body.is_a?(String)
108
+ req.body = body
109
+ elsif body
110
+ req.body_stream = body
111
+ end
112
+
113
+ res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", read_timeout: 300) do |http|
114
+ http.request(req)
115
+ end
116
+
117
+ return res if res.is_a?(Net::HTTPSuccess) || (allow_404 && res.code == "404")
118
+
119
+ if retryable?(res) && attempt < 3 && body_rewindable?(body)
120
+ sleep(attempt)
121
+ body.rewind if body.respond_to?(:rewind) && !body.is_a?(String)
122
+ return request(method, key, headers: headers, body: body, payload_sha256: payload_sha256,
123
+ allow_404: allow_404, attempt: attempt + 1)
124
+ end
125
+
126
+ raise Error, "S3 #{method.upcase} #{key} failed (HTTP #{res.code}): #{error_detail(res)}"
127
+ rescue Errno::ECONNREFUSED, Errno::ECONNRESET, Net::OpenTimeout, Net::ReadTimeout, SocketError => e
128
+ raise Error, "S3 #{method.upcase} #{key} failed: #{e.message}" unless attempt < 3 && body_rewindable?(body)
129
+
130
+ sleep(attempt)
131
+ body.rewind if body.respond_to?(:rewind) && !body.is_a?(String)
132
+ request(method, key, headers: headers, body: body, payload_sha256: payload_sha256,
133
+ allow_404: allow_404, attempt: attempt + 1)
134
+ end
135
+
136
+ def retryable?(res) = res.code.to_i >= 500 || res.code == "429"
137
+
138
+ def body_rewindable?(body) = body.nil? || body.is_a?(String) || body.respond_to?(:rewind)
139
+
140
+ def error_detail(res)
141
+ code = res.body.to_s[%r{<Code>([^<]+)</Code>}, 1]
142
+ msg = res.body.to_s[%r{<Message>([^<]+)</Message>}, 1]
143
+ [code, msg].compact.join(": ").then { |s| s.empty? ? res.message : s }
144
+ end
145
+
146
+ def object_uri(key)
147
+ encoded = uri_encode_path(key)
148
+ if @path_style
149
+ URI("#{@endpoint.scheme}://#{@endpoint.host}:#{@endpoint.port}/#{@bucket}/#{encoded}")
150
+ else
151
+ host = @endpoint.host.start_with?("#{@bucket}.") ? @endpoint.host : "#{@bucket}.#{@endpoint.host}"
152
+ URI("#{@endpoint.scheme}://#{host}:#{@endpoint.port}/#{encoded}")
153
+ end
154
+ end
155
+
156
+ # RFC 3986 encoding per path segment ('/' preserved) — SigV4's canonical form.
157
+ def uri_encode_path(key)
158
+ key.split("/", -1).map { |seg| Signer.uri_encode(seg) }.join("/")
159
+ end
160
+
161
+ def aws_region = @region == "auto" ? "us-east-1" : @region
162
+
163
+ # Pure SigV4 header computation, separated for direct testing against the
164
+ # published AWS test vectors.
165
+ module Signer
166
+ module_function
167
+
168
+ def headers(method:, uri:, headers:, payload_sha256:, access_key_id:, secret_access_key:,
169
+ session_token: nil, region:, time: Time.now.utc)
170
+ amz_date = time.strftime("%Y%m%dT%H%M%SZ")
171
+ date = time.strftime("%Y%m%d")
172
+
173
+ signed = {
174
+ "host" => host_header(uri),
175
+ "x-amz-content-sha256" => payload_sha256,
176
+ "x-amz-date" => amz_date
177
+ }
178
+ signed["x-amz-security-token"] = session_token if session_token
179
+ # Sign every x-amz-* header we send (S3 requires it) plus the core set.
180
+ canonical_headers = headers.select { |k, _| k.downcase.start_with?("x-amz-") || %w[range content-type content-md5 cache-control date expires].include?(k.downcase) }
181
+ .transform_keys(&:downcase).merge(signed)
182
+
183
+ sorted = canonical_headers.sort.to_h
184
+ header_list = sorted.keys.join(";")
185
+ canonical_request = [
186
+ method.to_s.upcase,
187
+ uri.path.empty? ? "/" : uri.path,
188
+ canonical_query(uri.query),
189
+ sorted.map { |k, v| "#{k}:#{v.to_s.strip}\n" }.join,
190
+ header_list,
191
+ payload_sha256
192
+ ].join("\n")
193
+
194
+ scope = "#{date}/#{region}/s3/aws4_request"
195
+ string_to_sign = [
196
+ ALGORITHM, amz_date, scope,
197
+ OpenSSL::Digest::SHA256.hexdigest(canonical_request)
198
+ ].join("\n")
199
+
200
+ k = %W[AWS4#{secret_access_key} #{date} #{region} s3 aws4_request]
201
+ .reduce { |key, data| OpenSSL::HMAC.digest("SHA256", key, data) }
202
+ signature = OpenSSL::HMAC.hexdigest("SHA256", k, string_to_sign)
203
+
204
+ signed.merge(
205
+ "authorization" => "#{ALGORITHM} Credential=#{access_key_id}/#{scope}, " \
206
+ "SignedHeaders=#{header_list}, Signature=#{signature}"
207
+ )
208
+ end
209
+
210
+ # Canonical form: every param as key=value (empty value keeps the "="),
211
+ # sorted. Callers pass unencoded simple queries; we never need decoding.
212
+ def canonical_query(query)
213
+ return "" if query.nil? || query.empty?
214
+
215
+ query.split("&").map { |pair|
216
+ k, v = pair.split("=", 2)
217
+ "#{uri_encode(k)}=#{uri_encode(v || "")}"
218
+ }.sort.join("&")
219
+ end
220
+
221
+ def host_header(uri)
222
+ default_port = uri.scheme == "https" ? 443 : 80
223
+ uri.port == default_port ? uri.host : "#{uri.host}:#{uri.port}"
224
+ end
225
+
226
+ # RFC 3986 unreserved-only encoding (AWS canonical form).
227
+ def uri_encode(str)
228
+ str.to_s.gsub(/[^A-Za-z0-9\-._~]/) { |c| c.bytes.map { |b| format("%%%02X", b) }.join }
229
+ end
230
+ end
231
+ end
232
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module Everywhere
7
+ # The update-feed contract: bucket key layout + latest.json / index.json
8
+ # bodies. This is THE interface between `every publish`, the shell's Rust
9
+ # updater, and the Platform's release publishing — all three speak exactly
10
+ # these shapes, so keep changes here schema-versioned and additive.
11
+ #
12
+ # Layout (channel is a *release channel* — stable/beta — never the receipt's
13
+ # distribution_channel):
14
+ #
15
+ # {prefix?}/{channel}/{os}/{arch}/
16
+ # latest.json mutable pointer, written last, Cache-Control: no-cache
17
+ # index.json append-only publish history (rollback + stats source)
18
+ # My-App-1.2.0.zip immutable + .minisig sibling
19
+ # My-App-latest.zip convenience alias (CopyObject) for download links
20
+ #
21
+ # The shell only ever does:
22
+ # GET {updates.url}/{channel}/{os}/{arch}/latest.json?current={version}
23
+ # — a raw bucket/CDN or a dynamic Platform endpoint serve the identical JSON.
24
+ module UpdateManifest
25
+ SCHEMA = 1
26
+
27
+ IMMUTABLE_CACHE = "public, max-age=31536000, immutable"
28
+ MUTABLE_CACHE = "no-cache"
29
+
30
+ module_function
31
+
32
+ def dir(channel:, os:, arch:, prefix: nil)
33
+ [prefix, channel, os, arch].compact.reject(&:empty?).join("/")
34
+ end
35
+
36
+ def artifact_key(filename, **loc) = "#{dir(**loc)}/#{filename}"
37
+ def latest_key(**loc) = "#{dir(**loc)}/latest.json"
38
+ def index_key(**loc) = "#{dir(**loc)}/index.json"
39
+
40
+ # Where images embedded in release notes live: channel-scoped (shared by
41
+ # every os/arch target of the release), versioned so they're immutable and
42
+ # rollback-safe. notes_html rewrites embedded images to these URLs at
43
+ # publish time — signed/expiring storage URLs must never leak into a feed.
44
+ def notes_asset_key(filename, channel:, version:, prefix: nil)
45
+ "#{[ prefix, channel ].compact.reject(&:empty?).join("/")}/notes/#{version}/#{filename}"
46
+ end
47
+
48
+ # "My-App-1.2.0.zip" -> "My-App-latest.zip" (keeps the extension).
49
+ def latest_alias(filename, version)
50
+ aliased = filename.sub("-#{version}", "-latest")
51
+ aliased == filename ? nil : aliased
52
+ end
53
+
54
+ # One latest.json body (also an index.json entry), built from a build
55
+ # receipt (dist/release.json) + publish-time facts.
56
+ #
57
+ # Release notes ship in two forms: `notes` is the human-authored source
58
+ # (markdown; plain text is valid markdown), `notes_html` is pre-rendered
59
+ # sanitized-by-construction HTML the app can inject straight into a
60
+ # changelog modal via the bridge. Pass notes_html explicitly to override
61
+ # the default markdown rendering (the Platform does — its notes are
62
+ # authored rich).
63
+ def entry(receipt:, url:, signature:, channel:, notes: nil, notes_html: nil, published_at: Time.now.utc)
64
+ artifact = receipt.fetch("artifact")
65
+ sha256 = artifact.fetch("checksum").to_s.delete_prefix("sha256:")
66
+ notes_html ||= render_notes_html(notes)
67
+
68
+ {
69
+ "schema" => SCHEMA,
70
+ "app" => {
71
+ "name" => receipt.dig("app", "name"),
72
+ "bundle_id" => receipt.dig("app", "bundle_id")
73
+ },
74
+ "channel" => channel,
75
+ "os" => receipt.dig("target", "os"),
76
+ "arch" => receipt.dig("target", "arch"),
77
+ "version" => receipt.dig("app", "version"),
78
+ "published_at" => published_at.iso8601,
79
+ "notes" => notes,
80
+ "notes_html" => notes_html,
81
+ "url" => url,
82
+ "sha256" => sha256,
83
+ "size_bytes" => artifact.fetch("size_bytes"),
84
+ "signature" => signature,
85
+ "manifest_checksum" => receipt["manifest_checksum"]
86
+ }.compact
87
+ end
88
+
89
+ # index.json: append-only history, newest first. Re-publishing the same
90
+ # version replaces its entry (idempotent re-runs don't duplicate).
91
+ def index_append(existing_json, entry)
92
+ entries = existing_json ? JSON.parse(existing_json).fetch("entries", []) : []
93
+ entries = entries.reject { |e| e["version"] == entry["version"] }
94
+ JSON.pretty_generate({ "schema" => SCHEMA, "entries" => [entry] + entries }) << "\n"
95
+ end
96
+
97
+ def index_find(index_json, version)
98
+ return nil unless index_json
99
+
100
+ JSON.parse(index_json).fetch("entries", []).find { |e| e["version"] == version }
101
+ end
102
+
103
+ def to_json(entry) = JSON.pretty_generate(entry) << "\n"
104
+
105
+ # Markdown -> HTML for notes_html. Kramdown with GFM-ish niceties off the
106
+ # shelf; raw HTML in the source passes through (the feed is the app
107
+ # developer's own signed content — same trust as the artifact itself).
108
+ def render_notes_html(notes)
109
+ return nil if notes.nil? || notes.strip.empty?
110
+
111
+ require "kramdown"
112
+ Kramdown::Document.new(notes, auto_ids: false, entity_output: :as_char).to_html.strip
113
+ end
114
+ end
115
+ end
@@ -1,11 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Everywhere
4
- VERSION = "0.1.7"
4
+ VERSION = "0.1.8"
5
5
 
6
6
  # Version of the vendored @rubyeverywhere/bridge JS this gem ships. Tracks
7
7
  # bridge/package.json — bump it whenever lib/everywhere/javascript/bridge.js is
8
8
  # refreshed. `every install` stamps it into the vendored file so the build
9
9
  # receipt can record which bridge shipped; it versions independently of the CLI.
10
- BRIDGE_VERSION = "0.1.0"
10
+ BRIDGE_VERSION = "0.2.0"
11
11
  end