eui-ruby 0.1.0

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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 59195020fcb69b8505cacd7ea0e69a2b6e1b62aee5d643d12b23cf5e28d59c44
4
+ data.tar.gz: 4d5f6ab4418724e05f811cf421face93e14f80771d18d2b0489d40b3b1552610
5
+ SHA512:
6
+ metadata.gz: c916bad7e89041aa084bde702f29e5c53752b71d2ba6293dad7097b9017c1ef1ffadc4ad87f4303e705b297628bbc2de3b53203c4b1cd4963e6b14bcd853e912
7
+ data.tar.gz: 224ee331e613b7d5435f51c4a87b91f46295f1bbb82babb0ca9ef9b0b3d0513f8a02a3a97a8e79594666d8d7573175dbd3e03c7b9bb2f880f6b0f5c34b1a28de
data/CHANGELOG.md ADDED
@@ -0,0 +1,31 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ The first cut: enough to write an EUI application in Ruby and have the
6
+ reference client draw it.
7
+
8
+ - `EUI::Proto` — the wire format of `spec/02`: varints, the 64-byte style
9
+ record, nodes, values, handlers, ops, batches and every session frame,
10
+ checked against the byte vectors the spec pins.
11
+ - `EUI::View` — a view hash compiled into interned atoms, styles and
12
+ colours, and diffed against the tree the client holds: keyed children
13
+ reconcile by `MoveChild`, and one changed word is one `SetText`.
14
+ - `EUI::Session` / `EUI::Server` — the HTTP and WebSocket halves of
15
+ `spec/01`: manifest, content-addressed assets, and a session that
16
+ welcomes, mounts, patches, answers a ping and rebuilds on a resync.
17
+ - `EUI::Component` — state, handlers, and a view that is a function of the
18
+ state.
19
+ - `EUI::Blake3` — BLAKE3 in Ruby, because an asset is named by the hash of
20
+ its content.
21
+ - `EUI::Manifest` — the signed `EUIM` record, with an Ed25519 publisher key
22
+ kept on disk.
23
+ - `bench/` — the same application in Ruby and in Soli, and a driver that
24
+ measures both until the server goes quiet. Writing it found two of this
25
+ gem's own faults: a quadratic keyed reconciliation (a 10 000-row sort took
26
+ 17.7 s; a Fenwick tree made it 0.8 s) and a style record compiled once per
27
+ node rather than once per distinct style (a 50 000-node render went from
28
+ 2.9 s to 0.6 s).
29
+
30
+ Not yet: local handlers (`spec/07` bytecode), file transfers (`spec/01`
31
+ §6), session resume, and the windowed `list`'s `window` event.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Olivier Bonnaure
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,192 @@
1
+ # eui-ruby
2
+
3
+ EUI applications, written in Ruby.
4
+
5
+ [EUI](https://github.com/solisoft/eui) delivers an application interface over
6
+ HTTPS without HTML, CSS or JavaScript. The server sends a tree that is
7
+ **already resolved**, in a compact binary encoding; a native client applies
8
+ it, lays it out and draws it on the GPU. There is no tolerant parse at the
9
+ other end, no cascade to resolve, no script to run.
10
+
11
+ This gem is the server half: the wire format, the view encoder and its diff,
12
+ the session, the content-addressed asset store, the signed manifest, and a
13
+ component model in which a view is a hash and a handler changes state.
14
+
15
+ ```ruby
16
+ require "eui"
17
+
18
+ class Counter < EUI::Component
19
+ def mount(params)
20
+ super # the window, as it is right now
21
+ @count = 0
22
+ end
23
+
24
+ on("increment") { @count += 1 }
25
+
26
+ def render
27
+ column(gap: 5, align: "center", justify: "center",
28
+ width: "100%", height: "100%", bg: "surface.base") do
29
+ [text(@count.to_s, size: "4xl", weight: "bold"),
30
+ button("Increment", "increment")]
31
+ end
32
+ end
33
+ end
34
+
35
+ app = EUI::App.new(name: "Counter", app_id: "counter.example")
36
+ app.mount("counter", Counter)
37
+ app.run(port: 5099)
38
+ ```
39
+
40
+ ```
41
+ EUI_ALLOW_INSECURE_LOOPBACK=1 eui ws://127.0.0.1:5099/_eui/session/counter
42
+ ```
43
+
44
+ Pressing the button sends **one** `SetText`. Not a page, not a diffed DOM,
45
+ not a frame of JSON: the style records went once, at mount, and every later
46
+ render names them by id.
47
+
48
+ ## What a view is
49
+
50
+ A hash, and a pure function of the state. `EUI::DSL` — included in every
51
+ component — builds them, but there is nothing behind the helpers: print one
52
+ and you have the whole document.
53
+
54
+ ```ruby
55
+ {"k" => "box", "s" => {"display" => "column", "gap" => 4},
56
+ "c" => [{"k" => "text", "t" => "Hi", "s" => {"size" => "lg"}}]}
57
+ ```
58
+
59
+ - **`k`** one of the seventeen primitive kinds. Everything a person would
60
+ call a widget — button, dialog, table, date picker — is composed from
61
+ these on the server, which is why the catalogue grows without shipping a
62
+ new client.
63
+ - **`s`** a flat style hash in the spec's own vocabulary. An unknown key is
64
+ an error, not a key that does nothing.
65
+ - **`c`** children, **`t`** text, **`p`** props, **`on`** handlers,
66
+ **`key`** identity for reconciliation.
67
+
68
+ **[eui.solisoft.net/components](https://eui.solisoft.net/components) is the
69
+ reference for all of it** — every node key, all seventeen kinds, every style
70
+ key and all thirty-three colour roles, the event names, and the catalogue of
71
+ composed widgets, each one shown with the hash it returns. It is written
72
+ against Soli, and the vocabulary is the protocol's, so a style hash or a node
73
+ on that page is the same style hash and the same node here. The rest of the
74
+ site is worth the visit too: [the running demo](https://eui.solisoft.net/demo),
75
+ [the controls](https://eui.solisoft.net/controls) every widget is built from,
76
+ and [what is not there yet](https://eui.solisoft.net/gaps).
77
+
78
+ Colour is a **role** — `surface.raised`, `text.muted`, `danger.base` — never
79
+ a literal. The client resolves it against the viewer's theme, so the page is
80
+ right in dark mode *without this server ever learning which mode they are
81
+ in*. Sizes are scale indices, by index or by name: `size: "lg"`, `gap: 4`,
82
+ `radius: "md"`.
83
+
84
+ What this gem ships is the primitives plus a few composed widgets — `button`,
85
+ `card`, `field`, `divider`, `spacer`. Anything else in the catalogue is a
86
+ function that returns a hash, so it ports to Ruby by writing the same hash.
87
+
88
+ ```ruby
89
+ column(gap: 4, pad: 6, bg: "surface.base") do
90
+ [ text("Total", size: "sm", fg: "text.muted"),
91
+ text(format("%.2f", @total), size: "2xl", font: "mono"),
92
+ row(gap: 3) { [button("Save", "save"), button("Delete", "delete", tone: "danger")] },
93
+ divider,
94
+ keyed("row-#{id}", card { [text(name)] }) ]
95
+ end
96
+ ```
97
+
98
+ ## What a handler is
99
+
100
+ A block, or a method, named by the **view** rather than by the event kind —
101
+ `{"on" => {"wake" => "tick"}}` arrives as `tick`. It changes state and
102
+ returns; it never touches the tree.
103
+
104
+ ```ruby
105
+ on("pick") { |params| @selected = params["props"]["id"] }
106
+ ```
107
+
108
+ `params` carries `node`, `kind`, `payload` and `props` — the node's props as
109
+ the server last rendered them. That last one is what lets one handler serve
110
+ ten thousand rows: put the identifying value on the node, not in the
111
+ handler's name.
112
+
113
+ A handler that raises leaves the state unchanged and the screen right; it is
114
+ a line in the log, not the end of somebody's session. A **view** that cannot
115
+ be encoded is the other way round: it fails identically on every later
116
+ render, so the session ends with `Error 400` and the reason.
117
+
118
+ ## Running it
119
+
120
+ | | |
121
+ |---|---|
122
+ | `app.run(port: 5099)` | plain `ws://` on loopback, for development |
123
+ | `app.run(host: "0.0.0.0", port: 443, tls: {cert:, key:})` | TLS 1.3, which is the protocol's floor |
124
+
125
+ A release client refuses `ws://` outright; a debug one takes
126
+ `EUI_ALLOW_INSECURE_LOOPBACK=1`. `EUI_TRACE=1` on the server prints every
127
+ frame and every event a session sees, which is the first thing to reach for
128
+ when a click does nothing.
129
+
130
+ Three endpoints, and nothing else: `/.well-known/eui` (the signed manifest),
131
+ `/_eui/asset/<blake3-hex>` (content-addressed, immutable, served to anyone),
132
+ and the session itself.
133
+
134
+ ```ruby
135
+ app = EUI::App.new(name: "Books", app_id: "books.example",
136
+ key_path: "config/eui_publisher.pem",
137
+ capabilities: %w[net.open])
138
+ app.font("Space Grotesk", ["public/fonts/space-grotesk-400.ttf",
139
+ "public/fonts/space-grotesk-700.ttf"])
140
+ ```
141
+
142
+ The publisher key is generated on first use and kept: a client pins it
143
+ against `app_id` on first run and refuses a different one later. It belongs
144
+ to the application, not to a deployment, and it never belongs in a
145
+ repository.
146
+
147
+ ## What is here, and what is not
148
+
149
+ Implemented: the whole wire format of `spec/02` (checked against the byte
150
+ vectors that document pins), the session frames of `spec/01`, the view
151
+ encoder with its append-only tables, a keyed diff that reorders by
152
+ `MoveChild`, assets, fonts, notifications, `scroll_to` and `focus_to`, the
153
+ manifest, and BLAKE3 in Ruby because an asset is named by the hash of its
154
+ content.
155
+
156
+ Not yet:
157
+
158
+ - **Local handlers** (`spec/07`). A handler is a server round trip; the
159
+ bytecode a client runs for itself — the hover that answers without a
160
+ packet — is the next milestone.
161
+ - **File transfers** (`spec/01` §6): `Upload` and `Blob` are decoded and
162
+ dropped.
163
+ - **Session resume** (`spec/01` §4.1): a socket that breaks gets a fresh
164
+ session and a fresh `Mount`, which is a conforming answer and a worse one.
165
+ - The windowed `list`'s `window` event, and `scene` uniforms.
166
+
167
+ ## Tests
168
+
169
+ ```
170
+ rake test
171
+ # or, with nothing installed but Ruby itself:
172
+ ruby -Ilib -Itest -e 'Dir["test/**/*_test.rb"].each { |f| require File.expand_path(f) }'
173
+ ```
174
+
175
+ 61 of them, and the ones worth reading are `test/proto_test.rb` — the spec's
176
+ own §8 example, 150 bytes, byte for byte — and `test/session_test.rb`, which
177
+ runs a real application on a real socket and counts the ops a click costs.
178
+
179
+ ## Against the other four
180
+
181
+ The same application, written five times — in Soli, Ruby, Python, PHP and
182
+ JavaScript, node for node. [`bench/`](bench/) holds all five and the one
183
+ driver that measures them, each phase timed until the server goes quiet. At
184
+ 500 rows, one number changed: **9 bytes** out of every one of them, 12 ms in
185
+ Soli, 24 ms here, 9 ms in Node. The bytes being identical is the protocol's
186
+ claim; the rest ranks by JIT, and the memory column ranks what else is in the
187
+ process. [`bench/README.md`](bench/README.md) has the tables, the method and
188
+ the caveats.
189
+
190
+ ## Licence
191
+
192
+ MIT.
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The counter, as an EUI application in Ruby.
4
+ #
5
+ # ruby -Ilib examples/counter.rb
6
+ # EUI_ALLOW_INSECURE_LOOPBACK=1 eui ws://127.0.0.1:5099/_eui/session/counter
7
+ #
8
+ # What to look at: `render` is a pure function of `@count`, and pressing a
9
+ # button sends one `SetText` — not a page, not a diffed DOM, not a frame of
10
+ # JSON. The style records were sent once, at mount, and every later render
11
+ # references them by id.
12
+
13
+ require_relative '../lib/eui'
14
+
15
+ class Counter < EUI::Component
16
+ def mount(params)
17
+ super
18
+ @count = 0
19
+ end
20
+
21
+ on('increment') { @count += 1 }
22
+ on('decrement') { @count -= 1 }
23
+ on('reset') { @count = 0 }
24
+
25
+ def render
26
+ column(
27
+ display: 'column', justify: 'center', align: 'center', gap: 7,
28
+ bg: 'surface.base', width: '100%', height: '100%', pad: 8
29
+ ) do
30
+ [
31
+ text('COUNTER', size: 'sm', weight: 'semibold', fg: 'text.muted', font: 'mono'),
32
+ text(@count.to_s, size: '4xl', weight: 'bold', fg: tone),
33
+ row(gap: 4) do
34
+ [
35
+ button('−', 'decrement', tone: 'quiet', size: 'lg'),
36
+ button('Reset', 'reset', tone: 'quiet'),
37
+ button('+', 'increment', size: 'lg')
38
+ ]
39
+ end,
40
+ divider(width: 320),
41
+ text(footnote, size: 'xs', fg: 'text.muted', font: 'mono')
42
+ ]
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ # Colour by what the number *is*, so it reads as a legend rather than
49
+ # decoration — and reads right in either theme, because the client
50
+ # resolves the role and this server never learns which one they are in.
51
+ def tone
52
+ return 'danger.base' if @count.negative?
53
+ return 'text.muted' if @count.zero?
54
+
55
+ 'success.base'
56
+ end
57
+
58
+ def footnote
59
+ "#{width} × #{height} · #{@viewport['mode'] || 'light'}"
60
+ end
61
+ end
62
+
63
+ app = EUI::App.new(name: 'Counter', app_id: 'counter.eui-ruby')
64
+ app.mount('counter', Counter)
65
+ app.run(port: Integer(ENV.fetch('PORT', '5099')))
data/lib/eui/app.rb ADDED
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'assets'
4
+ require_relative 'manifest'
5
+ require_relative 'server'
6
+
7
+ module EUI
8
+ # An application: the components it serves, the assets they name, and the
9
+ # manifest that says who published it.
10
+ #
11
+ # app = EUI::App.new(name: "Counter", app_id: "counter.example")
12
+ # app.mount("counter", Counter)
13
+ # app.run(port: 5012)
14
+ #
15
+ # The first component mounted is the one a bare origin opens, because the
16
+ # protocol has one entry and a server here has many: `wss://host` has to
17
+ # mean something, and the first one in the file is what a person reading
18
+ # it top to bottom would say.
19
+ class App
20
+ attr_reader :components, :assets, :name, :app_id
21
+
22
+ def initialize(name:, app_id: nil, version: '0.1.0', root: Dir.pwd,
23
+ key_path: nil, capabilities: [], logger: nil)
24
+ @name = name
25
+ @app_id = app_id || name.downcase.gsub(/[^a-z0-9]+/, '-')
26
+ @version = version
27
+ @root = root
28
+ @assets = Assets.new(root: root)
29
+ @components = {}
30
+ @capabilities = capabilities
31
+ @key_path = key_path
32
+ @logger = logger
33
+ end
34
+
35
+ def mount(path, component_class)
36
+ name = path.to_s.delete_prefix('/')
37
+ @components[name] = component_class
38
+ @default ||= name
39
+ self
40
+ end
41
+
42
+ def component_for(name)
43
+ return @components[@default] if name.nil?
44
+
45
+ @components[name]
46
+ end
47
+
48
+ # The face an application draws in, bound to a font role and sent as
49
+ # content-addressed assets — so the window talks to no font service and
50
+ # opens no connection the session did not.
51
+ #
52
+ # Declaring one at boot is what takes the manifest's floor to EUI 4;
53
+ # calling it later falls back to `sans` for the sessions already open
54
+ # rather than ending them.
55
+ def font(family, paths)
56
+ hashes = Array(paths).map { |path| @assets.add_file(path) }
57
+ @fonts ||= {}
58
+ @fonts[family] = hashes
59
+ family
60
+ end
61
+
62
+ def fonts = @fonts || {}
63
+
64
+ # The signed record at `/.well-known/eui`. Without a key path there is
65
+ # no manifest, which a debug client on loopback accepts and a release
66
+ # client does not.
67
+ def manifest
68
+ return nil unless @key_path
69
+
70
+ @manifest ||= Manifest.new(
71
+ app_id: @app_id, name: @name, version: @version,
72
+ key: Manifest.publisher_key(@key_path),
73
+ entry: "/_eui/session/#{@default}",
74
+ capabilities: @capabilities,
75
+ protocol_min: 1, protocol_max: Proto::PROTOCOL_VERSION
76
+ )
77
+ end
78
+
79
+ def run(host: '127.0.0.1', port: 5012, tls: nil)
80
+ Server.new(self, host: host, port: port, tls: tls, logger: @logger).start
81
+ end
82
+ end
83
+ end
data/lib/eui/assets.rb ADDED
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'blake3'
4
+ require_relative 'errors'
5
+
6
+ module EUI
7
+ # The content-addressed store behind `/_eui/asset/<blake3-hex>`.
8
+ #
9
+ # An asset is named by the hash of its bytes, so the name *is* the
10
+ # content: the client recomputes it and discards a mismatch, a proxy may
11
+ # serve it to anyone, and `immutable` is always the right cache header. A
12
+ # file somebody uploaded is not an asset — that travels in the session
13
+ # (`spec/01-transport.md` §6), because it is one person's and not the
14
+ # same for everyone.
15
+ class Assets
16
+ TYPES = {
17
+ '.png' => 'image/png', '.jpg' => 'image/jpeg', '.jpeg' => 'image/jpeg',
18
+ '.gif' => 'image/gif', '.webp' => 'image/webp', '.svg' => 'image/svg+xml',
19
+ '.ttf' => 'font/ttf', '.otf' => 'font/otf', '.woff2' => 'font/woff2',
20
+ '.wav' => 'audio/wav', '.mp3' => 'audio/mpeg', '.ogg' => 'audio/ogg',
21
+ '.mp4' => 'video/mp4', '.wgsl' => 'text/wgsl'
22
+ }.freeze
23
+
24
+ Entry = Struct.new(:bytes, :content_type)
25
+
26
+ def initialize(root: Dir.pwd)
27
+ @root = File.expand_path(root)
28
+ @by_hash = {}
29
+ @by_path = {}
30
+ @lock = Mutex.new
31
+ end
32
+
33
+ # Take a file into the store and answer its hash. Cheap to call on
34
+ # every render: a path whose mtime and size have not moved is not read
35
+ # again.
36
+ def add_file(path)
37
+ full = File.expand_path(path, @root)
38
+ unless full.start_with?(@root + File::SEPARATOR) || full == @root
39
+ raise ViewError, "an asset must live under #{@root}, got #{path}"
40
+ end
41
+ raise ViewError, "no such asset: #{path}" unless File.file?(full)
42
+
43
+ stat = File.stat(full)
44
+ stamp = [stat.mtime.to_f, stat.size]
45
+ @lock.synchronize do
46
+ cached = @by_path[full]
47
+ return cached[1] if cached && cached[0] == stamp
48
+
49
+ bytes = File.binread(full)
50
+ hash = Blake3.digest(bytes)
51
+ @by_hash[hash] = Entry.new(bytes, TYPES.fetch(File.extname(full).downcase, 'application/octet-stream'))
52
+ @by_path[full] = [stamp, hash]
53
+ hash
54
+ end
55
+ end
56
+
57
+ # Bytes that have no file — a picture out of a database, a chart this
58
+ # process drew — reach a window the same way.
59
+ def add_bytes(bytes, content_type: 'application/octet-stream')
60
+ bytes = bytes.b
61
+ hash = Blake3.digest(bytes)
62
+ @lock.synchronize { @by_hash[hash] = Entry.new(bytes, content_type) }
63
+ hash
64
+ end
65
+
66
+ def fetch(hex)
67
+ return nil unless /\A[0-9a-f]{64}\z/.match?(hex)
68
+
69
+ @lock.synchronize { @by_hash[[hex].pack('H*')] }
70
+ end
71
+
72
+ def size = @lock.synchronize { @by_hash.size }
73
+
74
+ def self.hex(hash) = hash.unpack1('H*')
75
+ end
76
+ end
data/lib/eui/blake3.rb ADDED
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EUI
4
+ # BLAKE3, in Ruby, because an asset is named by the hash of its content
5
+ # and the client recomputes it (`spec/01-transport.md` §2.2).
6
+ #
7
+ # Only the plain hash: no keyed mode, no key derivation, no extendable
8
+ # output past 32 bytes. That is every use the protocol has for it — an
9
+ # asset's name — and each of the others is a footgun this library would
10
+ # rather not carry.
11
+ module Blake3
12
+ OUT_LEN = 32
13
+ BLOCK_LEN = 64
14
+ CHUNK_LEN = 1024
15
+
16
+ CHUNK_START = 1 << 0
17
+ CHUNK_END = 1 << 1
18
+ PARENT = 1 << 2
19
+ ROOT = 1 << 3
20
+
21
+ IV = [
22
+ 0x6A09E667, 0xBB67AE85, 0x3C6EF372, 0xA54FF53A,
23
+ 0x510E527F, 0x9B05688C, 0x1F83D9AB, 0x5BE0CD19
24
+ ].freeze
25
+
26
+ MSG_PERMUTATION = [2, 6, 3, 10, 7, 0, 4, 13, 1, 11, 12, 5, 9, 14, 15, 8].freeze
27
+
28
+ MASK = 0xFFFF_FFFF
29
+
30
+ class << self
31
+ def hexdigest(data) = digest(data).unpack1('H*')
32
+
33
+ def digest(data)
34
+ hasher = Hasher.new
35
+ hasher.update(data)
36
+ hasher.digest
37
+ end
38
+
39
+ def file(path)
40
+ hasher = Hasher.new
41
+ File.open(path, 'rb') do |io|
42
+ while (block = io.read(65_536))
43
+ hasher.update(block)
44
+ end
45
+ end
46
+ hasher.digest
47
+ end
48
+
49
+ def rotr(x, n) = ((x >> n) | (x << (32 - n))) & MASK
50
+
51
+ def g(state, a, b, c, d, mx, my)
52
+ state[a] = (state[a] + state[b] + mx) & MASK
53
+ state[d] = rotr(state[d] ^ state[a], 16)
54
+ state[c] = (state[c] + state[d]) & MASK
55
+ state[b] = rotr(state[b] ^ state[c], 12)
56
+ state[a] = (state[a] + state[b] + my) & MASK
57
+ state[d] = rotr(state[d] ^ state[a], 8)
58
+ state[c] = (state[c] + state[d]) & MASK
59
+ state[b] = rotr(state[b] ^ state[c], 7)
60
+ end
61
+
62
+ def round(state, m)
63
+ g(state, 0, 4, 8, 12, m[0], m[1])
64
+ g(state, 1, 5, 9, 13, m[2], m[3])
65
+ g(state, 2, 6, 10, 14, m[4], m[5])
66
+ g(state, 3, 7, 11, 15, m[6], m[7])
67
+ g(state, 0, 5, 10, 15, m[8], m[9])
68
+ g(state, 1, 6, 11, 12, m[10], m[11])
69
+ g(state, 2, 7, 8, 13, m[12], m[13])
70
+ g(state, 3, 4, 9, 14, m[14], m[15])
71
+ end
72
+
73
+ # The one primitive: a chaining value and a block in, sixteen words
74
+ # out. The first eight are the next chaining value; all sixteen are
75
+ # the root's output.
76
+ def compress(cv, block, counter, block_len, flags)
77
+ state = [
78
+ cv[0], cv[1], cv[2], cv[3], cv[4], cv[5], cv[6], cv[7],
79
+ IV[0], IV[1], IV[2], IV[3],
80
+ counter & MASK, (counter >> 32) & MASK, block_len, flags
81
+ ]
82
+ m = block
83
+ 7.times do |r|
84
+ round(state, m)
85
+ m = MSG_PERMUTATION.map { |i| m[i] } if r < 6
86
+ end
87
+ 8.times do |i|
88
+ state[i] ^= state[i + 8]
89
+ state[i + 8] ^= cv[i]
90
+ end
91
+ state
92
+ end
93
+
94
+ def words_of(block)
95
+ block = block.ljust(BLOCK_LEN, "\x00") if block.bytesize < BLOCK_LEN
96
+ block.unpack('V16')
97
+ end
98
+
99
+ def parent_output(left, right, flags)
100
+ Output.new(IV.dup, left + right, 0, BLOCK_LEN, PARENT | flags)
101
+ end
102
+
103
+ def parent_cv(left, right, flags) = parent_output(left, right, flags).chaining_value
104
+ end
105
+
106
+ # A node's output, before anybody has decided whether it is the root.
107
+ # Which it is changes the flags, and so changes the bytes: that is what
108
+ # keeps a chunk's hash from being a tree's hash.
109
+ Output = Struct.new(:input_cv, :block_words, :counter, :block_len, :flags) do
110
+ def chaining_value
111
+ Blake3.compress(input_cv, block_words, counter, block_len, flags)[0, 8]
112
+ end
113
+
114
+ def root_bytes(length = OUT_LEN)
115
+ out = +''
116
+ counter = 0
117
+ while out.bytesize < length
118
+ words = Blake3.compress(input_cv, block_words, counter, block_len, flags | ROOT)
119
+ out << words.pack('V16')
120
+ counter += 1
121
+ end
122
+ out.byteslice(0, length)
123
+ end
124
+ end
125
+
126
+ # One chunk of at most 1024 bytes, compressed a block at a time.
127
+ class ChunkState
128
+ attr_reader :chunk_counter
129
+
130
+ def initialize(key, chunk_counter, flags)
131
+ @cv = key.dup
132
+ @chunk_counter = chunk_counter
133
+ @block = +''
134
+ @block.force_encoding(Encoding::BINARY)
135
+ @blocks_compressed = 0
136
+ @flags = flags
137
+ end
138
+
139
+ def length = (BLOCK_LEN * @blocks_compressed) + @block.bytesize
140
+
141
+ def start_flag = @blocks_compressed.zero? ? CHUNK_START : 0
142
+
143
+ def update(input)
144
+ offset = 0
145
+ while offset < input.bytesize
146
+ if @block.bytesize == BLOCK_LEN
147
+ @cv = Blake3.compress(@cv, Blake3.words_of(@block), @chunk_counter, BLOCK_LEN, @flags | start_flag)[0, 8]
148
+ @blocks_compressed += 1
149
+ @block = +''
150
+ @block.force_encoding(Encoding::BINARY)
151
+ end
152
+ want = BLOCK_LEN - @block.bytesize
153
+ take = [want, input.bytesize - offset].min
154
+ @block << input.byteslice(offset, take)
155
+ offset += take
156
+ end
157
+ self
158
+ end
159
+
160
+ def output
161
+ Output.new(@cv, Blake3.words_of(@block), @chunk_counter, @block.bytesize, @flags | start_flag | CHUNK_END)
162
+ end
163
+ end
164
+
165
+ # The streaming hasher. Chunks are merged into a binary tree as they
166
+ # complete, so hashing a 200 MB file costs a stack of at most 54
167
+ # chaining values.
168
+ class Hasher
169
+ def initialize(key: IV.dup, flags: 0)
170
+ @key = key
171
+ @flags = flags
172
+ @chunk = ChunkState.new(key, 0, flags)
173
+ @stack = []
174
+ end
175
+
176
+ def update(input)
177
+ input = input.b
178
+ offset = 0
179
+ while offset < input.bytesize
180
+ if @chunk.length == CHUNK_LEN
181
+ add_chunk(@chunk.output.chaining_value, @chunk.chunk_counter + 1)
182
+ @chunk = ChunkState.new(@key, @chunk.chunk_counter + 1, @flags)
183
+ end
184
+ want = CHUNK_LEN - @chunk.length
185
+ take = [want, input.bytesize - offset].min
186
+ @chunk.update(input.byteslice(offset, take))
187
+ offset += take
188
+ end
189
+ self
190
+ end
191
+
192
+ def digest(length = OUT_LEN)
193
+ output = @chunk.output
194
+ @stack.reverse_each do |left|
195
+ output = Blake3.parent_output(left, output.chaining_value, @flags)
196
+ end
197
+ output.root_bytes(length)
198
+ end
199
+
200
+ def hexdigest(length = OUT_LEN) = digest(length).unpack1('H*')
201
+
202
+ private
203
+
204
+ # A chunk's chaining value joins the tree, merging with everything to
205
+ # its left that is now complete — which is what the low bits of the
206
+ # chunk count say.
207
+ def add_chunk(cv, total_chunks)
208
+ while (total_chunks & 1).zero?
209
+ cv = Blake3.parent_cv(@stack.pop, cv, @flags)
210
+ total_chunks >>= 1
211
+ end
212
+ @stack.push(cv)
213
+ end
214
+ end
215
+ end
216
+ end