browser-broker 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.
- package/.env.example +173 -0
- package/LICENSE +21 -0
- package/README.md +374 -0
- package/RELEASES.md +97 -0
- package/dist/package.json +58 -0
- package/dist/src/adapter/conformance/case.js +1 -0
- package/dist/src/adapter/conformance/cases.js +429 -0
- package/dist/src/adapter/conformance/discovery.js +156 -0
- package/dist/src/adapter/conformance/driver.js +1 -0
- package/dist/src/adapter/conformance/drivers.js +36 -0
- package/dist/src/adapter/conformance/run.js +224 -0
- package/dist/src/adapter/conformance/service-subject.js +165 -0
- package/dist/src/adapter/contract.js +24 -0
- package/dist/src/adapter/operations.js +114 -0
- package/dist/src/adapter/service-seam.js +1 -0
- package/dist/src/artifacts/names.js +229 -0
- package/dist/src/artifacts/store.js +174 -0
- package/dist/src/bin/broker-tool.js +63 -0
- package/dist/src/bin/broker.js +111 -0
- package/dist/src/browser/adoption.js +143 -0
- package/dist/src/browser/automation-probe.js +113 -0
- package/dist/src/browser/conformance/case.js +1 -0
- package/dist/src/browser/conformance/cases.js +192 -0
- package/dist/src/browser/conformance/run.js +102 -0
- package/dist/src/browser/conformance/subjects.js +19 -0
- package/dist/src/browser/discovery.js +226 -0
- package/dist/src/browser/driver.js +195 -0
- package/dist/src/browser/fake.js +585 -0
- package/dist/src/browser/launch.js +504 -0
- package/dist/src/browser/real.js +1425 -0
- package/dist/src/browser/setup.js +161 -0
- package/dist/src/capture/accounting.js +59 -0
- package/dist/src/capture/image.js +112 -0
- package/dist/src/capture/ladder.js +72 -0
- package/dist/src/capture/legibility.js +195 -0
- package/dist/src/capture/pipeline.js +153 -0
- package/dist/src/capture/tiers.js +166 -0
- package/dist/src/cli/adapter.js +233 -0
- package/dist/src/cli/commands.js +270 -0
- package/dist/src/cli/conformance-driver.js +119 -0
- package/dist/src/cli/diffs.js +122 -0
- package/dist/src/cli/image.js +274 -0
- package/dist/src/cli/index.js +895 -0
- package/dist/src/cli/login-command.js +401 -0
- package/dist/src/cli/operations-commands.js +186 -0
- package/dist/src/cli/reconcile-command.js +137 -0
- package/dist/src/cli/sign-in.js +134 -0
- package/dist/src/cli/telemetry.js +222 -0
- package/dist/src/config/environment.js +446 -0
- package/dist/src/diff/artifact-path.js +77 -0
- package/dist/src/diff/crops.js +102 -0
- package/dist/src/diff/geometry.js +122 -0
- package/dist/src/diff/image.js +132 -0
- package/dist/src/diff/mask.js +46 -0
- package/dist/src/diff/regions.js +263 -0
- package/dist/src/diff/settings.js +135 -0
- package/dist/src/doctor/checks.js +588 -0
- package/dist/src/doctor/report.js +152 -0
- package/dist/src/doctor/session.js +161 -0
- package/dist/src/errors.js +36 -0
- package/dist/src/feedback/read.js +119 -0
- package/dist/src/feedback/record.js +199 -0
- package/dist/src/operations/addresses.js +175 -0
- package/dist/src/operations/derive.js +109 -0
- package/dist/src/operations/ledger.js +194 -0
- package/dist/src/operations/status.js +197 -0
- package/dist/src/operations/telemetry.js +280 -0
- package/dist/src/report/document.js +419 -0
- package/dist/src/report/escape.js +68 -0
- package/dist/src/report/snapshot.js +97 -0
- package/dist/src/service/arbitration.js +537 -0
- package/dist/src/service/artifacts.js +85 -0
- package/dist/src/service/bridge.js +577 -0
- package/dist/src/service/broker.js +120 -0
- package/dist/src/service/browser-session.js +269 -0
- package/dist/src/service/capacity.js +62 -0
- package/dist/src/service/capture-seam.js +83 -0
- package/dist/src/service/capture-store.js +91 -0
- package/dist/src/service/comparison-store.js +101 -0
- package/dist/src/service/comparison.js +173 -0
- package/dist/src/service/events.js +93 -0
- package/dist/src/service/keys.js +68 -0
- package/dist/src/service/leases.js +147 -0
- package/dist/src/service/nudge.js +66 -0
- package/dist/src/service/operations/claim.js +692 -0
- package/dist/src/service/operations/give-back.js +131 -0
- package/dist/src/service/operations/pages.js +771 -0
- package/dist/src/service/operations/sign-in.js +915 -0
- package/dist/src/service/operations/status.js +62 -0
- package/dist/src/service/ownership.js +93 -0
- package/dist/src/service/pages.js +616 -0
- package/dist/src/service/pending-seeds.js +20 -0
- package/dist/src/service/queue.js +233 -0
- package/dist/src/service/reconcile.js +220 -0
- package/dist/src/service/refusals.js +262 -0
- package/dist/src/service/runtime.js +131 -0
- package/dist/src/service/signin-recovery.js +148 -0
- package/dist/src/service/storage-seed.js +239 -0
- package/dist/src/service/tabs.js +123 -0
- package/dist/src/store/budget.js +99 -0
- package/dist/src/store/location.js +42 -0
- package/dist/src/store/network-path.js +182 -0
- package/dist/src/store/network-volume.js +92 -0
- package/dist/src/store/open.js +226 -0
- package/dist/src/store/schema/step-001-initial.js +523 -0
- package/dist/src/store/schema/step-002-tab-budget.js +53 -0
- package/dist/src/store/schema/step-003-queue-order.js +110 -0
- package/dist/src/store/schema/step-004-tab-never-opened.js +100 -0
- package/dist/src/store/schema/step-005-storage-seed-event.js +90 -0
- package/dist/src/store/schema/step-006-signin-events.js +104 -0
- package/dist/src/store/schema/step-007-signin-without-process.js +92 -0
- package/dist/src/store/schema/step-008-signin-owner.js +76 -0
- package/dist/src/store/schema/step-009-named-browsers.js +138 -0
- package/dist/src/store/schema/step-010-signin-request.js +135 -0
- package/dist/src/store/schema/step.js +172 -0
- package/dist/src/store/schema/steps.js +58 -0
- package/dist/src/store/transaction.js +37 -0
- package/dist/src/tool/adapter.js +90 -0
- package/dist/src/tool/conformance-driver.js +184 -0
- package/dist/src/tool/protocol.js +310 -0
- package/dist/src/tool/session.js +351 -0
- package/dist/src/tool/tools.js +310 -0
- package/package.json +58 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turning things a caller supplies into things that are safe to be a file
|
|
3
|
+
* name — and nothing else. No filesystem call happens in this file.
|
|
4
|
+
*
|
|
5
|
+
* ── Why the rules are strict here rather than at the point of writing ───
|
|
6
|
+
*
|
|
7
|
+
* `SCHEMA.md` §1.7a states the reasoning and it is the reason this module is
|
|
8
|
+
* separate from the one that writes files: **a file name travels further
|
|
9
|
+
* than a database column does.** A column is read by things that were
|
|
10
|
+
* written to read it. A name ends up in log lines, in terminal output
|
|
11
|
+
* somebody screenshots, in error messages, in a shell history, in the title
|
|
12
|
+
* bar of whatever opened the image. It leaks by default, and it leaks to
|
|
13
|
+
* places nobody enumerated.
|
|
14
|
+
*
|
|
15
|
+
* Four rules follow from that, and §1.7a puts them in an order that matters:
|
|
16
|
+
*
|
|
17
|
+
* 1. **Query strings are stripped entirely, before anything else.** A query
|
|
18
|
+
* string is where identifiers, tokens, search terms and session material
|
|
19
|
+
* live — the part of an address most likely to carry something that should
|
|
20
|
+
* never have been written down, and the part least likely to help anybody
|
|
21
|
+
* tell two files apart. It is stripped first so that no later rule can be
|
|
22
|
+
* the thing that happened to remove it: a collapse rule that turned `?` and
|
|
23
|
+
* `=` into hyphens would leave the token in the name, spelled differently.
|
|
24
|
+
* 2. **Safe characters only**, so the name is the same on every filesystem and
|
|
25
|
+
* survives being pasted anywhere.
|
|
26
|
+
* 3. **Truncated to a bounded length**, so a deep path does not produce a name
|
|
27
|
+
* that is unusable or unprintable. What truncation loses is recoverable
|
|
28
|
+
* from `captures.url` (§1.7); nothing depends on the name being complete.
|
|
29
|
+
* 4. **Never interpreted as a path.** The derivation produces **one path
|
|
30
|
+
* segment**, and the separators that would make it more than one are not in
|
|
31
|
+
* the safe set.
|
|
32
|
+
*
|
|
33
|
+
* **A label a caller supplies is subject to rules two, three and four as
|
|
34
|
+
* well** (§1.7a). It is a label, not a location.
|
|
35
|
+
*
|
|
36
|
+
* ── What this module structurally guarantees, and what it does not ──────
|
|
37
|
+
*
|
|
38
|
+
* **Structural.** Everything returned by {@link slugFromUrl} and
|
|
39
|
+
* {@link sanitiseLabel} is drawn from {@link SAFE_CHARACTERS} — the functions
|
|
40
|
+
* build their output by testing each character against that set rather than by
|
|
41
|
+
* removing a list of bad ones, so a character nobody thought of is excluded by
|
|
42
|
+
* default rather than included by default. Neither separator, `.` and `:` are
|
|
43
|
+
* outside the set, so no return value can be a traversal, an absolute path, a
|
|
44
|
+
* drive-qualified path, or more than one segment.
|
|
45
|
+
*
|
|
46
|
+
* **Not structural, and worth saying rather than implying.** Nothing here can
|
|
47
|
+
* stop a caller putting a secret in the *path* of an address, and the path is
|
|
48
|
+
* kept: only the query string is dropped. §1.7a's rule is about where secrets
|
|
49
|
+
* overwhelmingly live, not a claim that an address is safe once it is stripped.
|
|
50
|
+
* A service that wanted that guarantee would have to drop the address
|
|
51
|
+
* entirely, which would defeat what the slug is for.
|
|
52
|
+
*/
|
|
53
|
+
/**
|
|
54
|
+
* The characters a derived name may contain. Lower-case letters, digits, and
|
|
55
|
+
* the hyphen — nothing else, and in particular neither separator, no dot, no
|
|
56
|
+
* colon and no tilde.
|
|
57
|
+
*
|
|
58
|
+
* An allowlist rather than a denylist, because a denylist has to be right
|
|
59
|
+
* about every filesystem's rules on every platform forever, and this has to be
|
|
60
|
+
* right once.
|
|
61
|
+
*/
|
|
62
|
+
const SAFE_CHARACTERS = /^[a-z0-9-]$/;
|
|
63
|
+
/**
|
|
64
|
+
* How long a derived part may be.
|
|
65
|
+
*
|
|
66
|
+
* A bound rather than a limit anybody has to remember: §1.7a asks for
|
|
67
|
+
* truncation so a deep path cannot produce an unprintable name, and the
|
|
68
|
+
* number matters less than there being one. Chosen so that the five parts
|
|
69
|
+
* §1.7a's shape assembles — slug, label, width, when, identifier — stay well
|
|
70
|
+
* inside the shortest path-component limit in common use.
|
|
71
|
+
*/
|
|
72
|
+
export const MAXIMUM_PART_LENGTH = 48;
|
|
73
|
+
/** What a name is when every character in it was unsafe. */
|
|
74
|
+
const EMPTY_PART = 'unnamed';
|
|
75
|
+
/**
|
|
76
|
+
* Reduce a string to the safe set, collapse runs, and truncate.
|
|
77
|
+
*
|
|
78
|
+
* **Runs collapse to one hyphen and the ends are trimmed**, so the result
|
|
79
|
+
* never begins or ends with a separator-looking character and never contains a
|
|
80
|
+
* run of them. That is presentation rather than safety — the safety is that
|
|
81
|
+
* nothing outside {@link SAFE_CHARACTERS} survives at all — but a name of
|
|
82
|
+
* forty hyphens is a name that tells a reader nothing, and truncating it would
|
|
83
|
+
* spend the whole budget on nothing.
|
|
84
|
+
*
|
|
85
|
+
* **Never empty.** A string that reduces to nothing returns {@link EMPTY_PART}
|
|
86
|
+
* rather than an empty string, because an empty part would collapse the shape
|
|
87
|
+
* §1.7a describes into one with a doubled separator and make two different
|
|
88
|
+
* captures produce the same name.
|
|
89
|
+
*/
|
|
90
|
+
function reduce(value) {
|
|
91
|
+
const characters = [];
|
|
92
|
+
for (const character of value.toLowerCase()) {
|
|
93
|
+
if (SAFE_CHARACTERS.test(character)) {
|
|
94
|
+
characters.push(character);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
// Anything outside the set becomes a separator rather than disappearing,
|
|
98
|
+
// so two distinguishable inputs do not collapse into one name.
|
|
99
|
+
if (characters.length > 0 && characters[characters.length - 1] !== '-') {
|
|
100
|
+
characters.push('-');
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const collapsed = characters.join('').replace(/^-+|-+$/g, '');
|
|
104
|
+
if (collapsed === '')
|
|
105
|
+
return EMPTY_PART;
|
|
106
|
+
return collapsed.slice(0, MAXIMUM_PART_LENGTH).replace(/-+$/g, '') || EMPTY_PART;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Strip a query string, and a fragment, from an address — textually, before
|
|
110
|
+
* anything parses it.
|
|
111
|
+
*
|
|
112
|
+
* **Textual and first, on purpose.** Doing it by parsing would mean handing
|
|
113
|
+
* the whole address including its query to a parser, and the value of the rule
|
|
114
|
+
* is that the query is gone before anything else touches it. `?` opens a query
|
|
115
|
+
* and `#` opens a fragment; whichever comes first ends the part that is kept.
|
|
116
|
+
*
|
|
117
|
+
* A fragment is stripped alongside the query although §1.7a names only the
|
|
118
|
+
* query. It is the same class of material — client-side state a caller chose,
|
|
119
|
+
* frequently carrying identifiers — and keeping it would put it in a file
|
|
120
|
+
* name for no benefit, since it never distinguishes two pages the server
|
|
121
|
+
* served.
|
|
122
|
+
*/
|
|
123
|
+
function withoutQuery(address) {
|
|
124
|
+
const end = Math.min(...['?', '#'].map((mark) => {
|
|
125
|
+
const at = address.indexOf(mark);
|
|
126
|
+
return at === -1 ? address.length : at;
|
|
127
|
+
}));
|
|
128
|
+
return address.slice(0, end);
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* The page slug: host and path, query stripped first, reduced to one safe
|
|
132
|
+
* segment.
|
|
133
|
+
*
|
|
134
|
+
* **Derived, never supplied** (§1.7a). A caller's own label describes what it
|
|
135
|
+
* was *doing*; the address describes what it was *looking at*, and only the
|
|
136
|
+
* second is reliably distinct between two pieces of work that happen to be
|
|
137
|
+
* described the same way.
|
|
138
|
+
*
|
|
139
|
+
* **An address this cannot parse still produces a name.** A capture is never
|
|
140
|
+
* refused for the sake of its file name — the whole shape of §3.11 is that the
|
|
141
|
+
* only refusals are argument mistakes — so an unparseable address falls back to
|
|
142
|
+
* reducing the stripped text directly. It is a worse name and it is still a
|
|
143
|
+
* name.
|
|
144
|
+
*
|
|
145
|
+
* ── TWO INDEPENDENT MECHANISMS KEEP THE QUERY OUT, and saying so matters ──
|
|
146
|
+
*
|
|
147
|
+
* Worth stating exactly, because a reader who assumes only one is at work will
|
|
148
|
+
* draw the wrong conclusion from a change to either:
|
|
149
|
+
*
|
|
150
|
+
* 1. **On the parseable path, taking `hostname` and `pathname` excludes the
|
|
151
|
+
* query by construction.** Neither property contains it, so even handed a
|
|
152
|
+
* full address the parser would not put a query in the name.
|
|
153
|
+
* 2. **{@link withoutQuery} strips it textually first**, which is what covers
|
|
154
|
+
* the **fallback path** — where there is no parser to be structural about
|
|
155
|
+
* it, and where the whole address would otherwise be reduced verbatim.
|
|
156
|
+
*
|
|
157
|
+
* So the strip is not redundant: it is the *only* mechanism on the branch that
|
|
158
|
+
* handles an address this cannot parse, and that is exactly the branch where an
|
|
159
|
+
* odd address ends up. Removing it leaks a token from any unparseable address.
|
|
160
|
+
* Belt and braces on one path, sole defence on the other.
|
|
161
|
+
*/
|
|
162
|
+
export function slugFromUrl(address) {
|
|
163
|
+
if (address === undefined || address.trim() === '')
|
|
164
|
+
return EMPTY_PART;
|
|
165
|
+
const stripped = withoutQuery(address);
|
|
166
|
+
try {
|
|
167
|
+
const parsed = new URL(stripped);
|
|
168
|
+
// Host and path only. Not the scheme, not any credentials the address
|
|
169
|
+
// carries in front of the host, and not the port — none of which help
|
|
170
|
+
// anybody tell two pictures apart, and the first of which is the single
|
|
171
|
+
// most sensitive thing an address can hold.
|
|
172
|
+
return reduce(`${parsed.hostname}${parsed.pathname}`);
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
return reduce(stripped);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* A caller's own label, made safe.
|
|
180
|
+
*
|
|
181
|
+
* Rules two, three and four of §1.7a apply here as they do to the slug: safe
|
|
182
|
+
* characters, bounded, and never a path. Rule one does not, because a label is
|
|
183
|
+
* not an address and has no query string to strip — but note that a label
|
|
184
|
+
* containing `?` is reduced by the same safe-character pass, so a caller that
|
|
185
|
+
* pasted an address into the label field does not smuggle one through.
|
|
186
|
+
*/
|
|
187
|
+
export function sanitiseLabel(label) {
|
|
188
|
+
if (label === undefined || label.trim() === '')
|
|
189
|
+
return EMPTY_PART;
|
|
190
|
+
return reduce(label);
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* A sortable stamp for the moment a capture was taken.
|
|
194
|
+
*
|
|
195
|
+
* Digits only, most significant first, so a directory listing sorts into time
|
|
196
|
+
* order within a page and a view — which is the fourth of the five sorting
|
|
197
|
+
* levels §1.7a's shape is chosen for. Derived from the instant's own text
|
|
198
|
+
* rather than formatted by hand so that it is the same on every platform.
|
|
199
|
+
*/
|
|
200
|
+
export function stampFromInstant(when) {
|
|
201
|
+
return when
|
|
202
|
+
.toISOString()
|
|
203
|
+
.replace(/[^0-9]/g, '')
|
|
204
|
+
.slice(0, 14);
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Assemble the five parts §1.7a specifies, in the order it specifies them:
|
|
208
|
+
* `<page-slug>-<view-label>-<width>-<when>-<id>.png`.
|
|
209
|
+
*
|
|
210
|
+
* That order is what makes a directory listing readable: all the pictures of
|
|
211
|
+
* one page together, then within a page all the pictures of one view, then the
|
|
212
|
+
* widths, then the sequence in time, then an identifier that guarantees
|
|
213
|
+
* uniqueness without anybody coordinating.
|
|
214
|
+
*
|
|
215
|
+
* **Every variable part goes through the rules above**, including the
|
|
216
|
+
* identifier — which is generated rather than supplied and would pass anyway,
|
|
217
|
+
* but passing it through means there is no part of this name that a future
|
|
218
|
+
* change could make caller-controlled without also making it safe.
|
|
219
|
+
*/
|
|
220
|
+
export function captureFileName(parts) {
|
|
221
|
+
const width = reduce(String(Math.trunc(parts.viewportWidth)));
|
|
222
|
+
return ([
|
|
223
|
+
slugFromUrl(parts.url),
|
|
224
|
+
sanitiseLabel(parts.label),
|
|
225
|
+
width,
|
|
226
|
+
stampFromInstant(parts.takenAt),
|
|
227
|
+
reduce(parts.id),
|
|
228
|
+
].join('-') + '.png');
|
|
229
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { BrokerError } from "../errors.js";
|
|
4
|
+
/**
|
|
5
|
+
* Every kind, in the order §1.7a lists them.
|
|
6
|
+
*
|
|
7
|
+
* **Named entries, not a derived list.** A test that walks this array and
|
|
8
|
+
* asserts each directory exists is a test that goes green when an entry is
|
|
9
|
+
* deleted, because it would then be walking a shorter list. So the test that
|
|
10
|
+
* matters names each folder outright and this constant is for code that
|
|
11
|
+
* genuinely needs to iterate.
|
|
12
|
+
*/
|
|
13
|
+
export const ARTIFACT_KINDS = [
|
|
14
|
+
'images',
|
|
15
|
+
'snapshots',
|
|
16
|
+
'console',
|
|
17
|
+
'network',
|
|
18
|
+
'downloads',
|
|
19
|
+
];
|
|
20
|
+
/**
|
|
21
|
+
* A path segment that identifies a lease, made safe.
|
|
22
|
+
*
|
|
23
|
+
* A claim identifier is generated by this service and is already safe, so this
|
|
24
|
+
* looks redundant — and it is not, for the reason rule two above gives: the
|
|
25
|
+
* value arriving here is the one thing in the assembled path that came from
|
|
26
|
+
* outside this module, and a check that only holds while an upstream promise
|
|
27
|
+
* holds is a check that fails silently the day the promise changes.
|
|
28
|
+
*/
|
|
29
|
+
function safeSegment(value, what) {
|
|
30
|
+
const reduced = value.toLowerCase().replace(/[^a-z0-9-]/g, '');
|
|
31
|
+
if (reduced === '' || reduced !== value.toLowerCase()) {
|
|
32
|
+
throw new BrokerError('artifact.no_request_path', `${what} is not usable as a directory name. Expected letters, digits and hyphens; found ${JSON.stringify(value)}.`);
|
|
33
|
+
}
|
|
34
|
+
return reduced;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Is this path absolute in **either** namespace?
|
|
38
|
+
*
|
|
39
|
+
* ── Why the platform's own answer is the wrong one ──────────────────────
|
|
40
|
+
*
|
|
41
|
+
* `path.isAbsolute` asks the namespace the host process happens to be running
|
|
42
|
+
* in, and the two namespaces disagree about what an absolute path even is. On
|
|
43
|
+
* a platform whose separator is the forward slash, `C:` followed by a
|
|
44
|
+
* backslash is not a root — it is a perfectly legal **relative filename that
|
|
45
|
+
* contains backslashes**, so the refusal never fires and the path is joined
|
|
46
|
+
* onto the root as one long oddly-named file. The same string refuses on the
|
|
47
|
+
* other platform. Identical input, opposite outcome, from the same code.
|
|
48
|
+
*
|
|
49
|
+
* That is not a hypothetical, and this codebase already answers it in one
|
|
50
|
+
* place: `store/network-path.ts` reads its share root with
|
|
51
|
+
* {@link path.win32.parse} **unconditionally**, on every platform, for
|
|
52
|
+
* exactly this reason. This follows that approach rather than inventing a
|
|
53
|
+
* second one, so both guards recognise both spellings the same way.
|
|
54
|
+
*
|
|
55
|
+
* ── Both checks, and honesty about what the second one adds ─────────────
|
|
56
|
+
*
|
|
57
|
+
* **The two are not independently load-bearing, and pretending otherwise
|
|
58
|
+
* would mislead the next reader.** The drive-letter parser also reports a
|
|
59
|
+
* root for a leading forward slash, so deleting the first check changes no
|
|
60
|
+
* outcome any test observes — deleting the *second* is what turns the guard
|
|
61
|
+
* off for a drive-qualified name and a share.
|
|
62
|
+
*
|
|
63
|
+
* The first check stays because this rule is "absolute in either namespace"
|
|
64
|
+
* and one line per namespace is how that reads as code. Leaning on one
|
|
65
|
+
* parser's incidental coverage of the other's case would make the rule
|
|
66
|
+
* correct by coincidence rather than by statement.
|
|
67
|
+
*
|
|
68
|
+
* A guard that only fires on the platform it was written on is worse than no
|
|
69
|
+
* guard, because it reports a protection that does not exist.
|
|
70
|
+
*/
|
|
71
|
+
function isAbsoluteInEitherNamespace(target) {
|
|
72
|
+
// POSIX: a leading forward slash. Named for its own sake — see above.
|
|
73
|
+
if (path.posix.isAbsolute(target)) {
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
// The other namespace: a drive letter followed by a colon and a separator,
|
|
77
|
+
// in either separator spelling; a two-separator share prefix naming a host
|
|
78
|
+
// and a share; and a bare leading backslash. Reading the parsed root covers
|
|
79
|
+
// every one of those spellings without this file having to enumerate them
|
|
80
|
+
// as patterns of its own.
|
|
81
|
+
return path.win32.parse(target).root !== '';
|
|
82
|
+
}
|
|
83
|
+
export class ArtifactStore {
|
|
84
|
+
#root;
|
|
85
|
+
/**
|
|
86
|
+
* @param root `BROKER_ARTIFACTS_ROOT`, already resolved by the environment
|
|
87
|
+
* snapshot. Taken as a string rather than read from the environment here,
|
|
88
|
+
* so that this class has no configuration of its own and a test does not
|
|
89
|
+
* have to arrange a process environment to use one.
|
|
90
|
+
*/
|
|
91
|
+
constructor(root) {
|
|
92
|
+
this.#root = path.resolve(root);
|
|
93
|
+
}
|
|
94
|
+
/** Where the tree is rooted. Read by nothing that stores it. */
|
|
95
|
+
get root() {
|
|
96
|
+
return this.#root;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The directory for one lease's artefacts of one kind, created if absent.
|
|
100
|
+
*
|
|
101
|
+
* Created lazily rather than at claim time: a lease that takes no captures
|
|
102
|
+
* should not leave an empty tree behind, and there is no moment in a
|
|
103
|
+
* daemonless service at which a setup step could have run instead.
|
|
104
|
+
*/
|
|
105
|
+
directoryFor(claimId, kind) {
|
|
106
|
+
const directory = path.join(this.#root, 'claims', safeSegment(claimId, 'A claim identifier'), kind);
|
|
107
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
108
|
+
return directory;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Write one file into a lease's tree, and report where it went **relative to
|
|
112
|
+
* the root**.
|
|
113
|
+
*
|
|
114
|
+
* The refusal below is the second of this file's two rules, and it asks two
|
|
115
|
+
* questions rather than one. It compares the resolved destination against
|
|
116
|
+
* the resolved root, which catches a name that climbs out through `..`; and
|
|
117
|
+
* it asks {@link isAbsoluteInEitherNamespace} whether the supplied name was
|
|
118
|
+
* absolute in **either** namespace, which is what catches the spellings the
|
|
119
|
+
* host platform does not read as roots. Both are needed — see that
|
|
120
|
+
* function for why one platform's answer is not the answer.
|
|
121
|
+
*/
|
|
122
|
+
write(claimId, kind, fileName, contents) {
|
|
123
|
+
const directory = this.directoryFor(claimId, kind);
|
|
124
|
+
const absolutePath = path.resolve(directory, fileName);
|
|
125
|
+
const relative = path.relative(this.#root, absolutePath);
|
|
126
|
+
// `..` at the front means the destination climbed out of the root; an
|
|
127
|
+
// absolute answer means it was never under it at all. Either is a bug in
|
|
128
|
+
// whatever composed the name, and neither may be written.
|
|
129
|
+
//
|
|
130
|
+
// **The supplied name is tested as well as the computed result**, and that
|
|
131
|
+
// is not belt and braces — it is the only check that catches a name
|
|
132
|
+
// absolute in the *other* namespace. Such a name is a legal relative
|
|
133
|
+
// filename to `path.resolve` here, so it resolves quietly under the root
|
|
134
|
+
// and the computed answer is relative and clean. Nothing downstream can
|
|
135
|
+
// notice; the name itself has to be asked.
|
|
136
|
+
if (relative.startsWith('..') ||
|
|
137
|
+
isAbsoluteInEitherNamespace(relative) ||
|
|
138
|
+
isAbsoluteInEitherNamespace(fileName)) {
|
|
139
|
+
throw new BrokerError('artifact.no_request_path', `A capture would have been written outside the artifact root. Refusing: ${JSON.stringify(fileName)} does not resolve under it.`);
|
|
140
|
+
}
|
|
141
|
+
fs.writeFileSync(absolutePath, contents);
|
|
142
|
+
return {
|
|
143
|
+
// Stored with forward slashes whatever platform wrote it, so a row
|
|
144
|
+
// written on one machine reads on another. A separator is presentation;
|
|
145
|
+
// making it part of the stored value would reintroduce, one layer down,
|
|
146
|
+
// exactly the machine-specificity rule one exists to remove.
|
|
147
|
+
relativePath: relative.split(path.sep).join('/'),
|
|
148
|
+
absolutePath,
|
|
149
|
+
bytes: contents.byteLength,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Turn a recorded relative path back into a location on this machine.
|
|
154
|
+
*
|
|
155
|
+
* **This is the only way bytes are ever served** (§1.9, `artifact.no_request_path`):
|
|
156
|
+
* a recorded path resolves under the root or nothing is served, so a caller
|
|
157
|
+
* never supplies a filesystem path and traversal has no input to arrive
|
|
158
|
+
* through. The refusal is the same one `write` makes, from the other
|
|
159
|
+
* direction, and it is deliberately duplicated rather than shared through a
|
|
160
|
+
* flag — the two callers are asking different questions and a shared
|
|
161
|
+
* parameterised helper would be one edit away from answering only one of
|
|
162
|
+
* them.
|
|
163
|
+
*/
|
|
164
|
+
resolve(relativePath) {
|
|
165
|
+
const absolutePath = path.resolve(this.#root, relativePath);
|
|
166
|
+
const relative = path.relative(this.#root, absolutePath);
|
|
167
|
+
if (relative.startsWith('..') ||
|
|
168
|
+
isAbsoluteInEitherNamespace(relative) ||
|
|
169
|
+
isAbsoluteInEitherNamespace(relativePath)) {
|
|
170
|
+
throw new BrokerError('artifact.no_request_path', `A recorded path does not resolve under the artifact root. Refusing to serve ${JSON.stringify(relativePath)}.`);
|
|
171
|
+
}
|
|
172
|
+
return absolutePath;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { serviceUnavailable } from "../cli/index.js";
|
|
3
|
+
import { BrokerError } from "../errors.js";
|
|
4
|
+
import { createRuntime } from "../service/runtime.js";
|
|
5
|
+
import { linesFrom, serveSession } from "../tool/session.js";
|
|
6
|
+
/**
|
|
7
|
+
* The executable shim for the tool surface: **the process a caller spawns.**
|
|
8
|
+
*
|
|
9
|
+
* `MILESTONES.md` #27: the service is spawned by its caller, serves that
|
|
10
|
+
* session, and exits with it. There is no port to bind, no daemon to start
|
|
11
|
+
* and nothing to leave running — this file reads standard input until it
|
|
12
|
+
* ends, and then the process has nothing left to do.
|
|
13
|
+
*
|
|
14
|
+
* ── Why the loop is not in this file ────────────────────────────────────
|
|
15
|
+
*
|
|
16
|
+
* Everything above the streams lives in `session.ts` and is importable, so
|
|
17
|
+
* the parity matrix drives the real handler in process rather than by
|
|
18
|
+
* spawning something per case. `MILESTONES.md` reserves spawning for the case
|
|
19
|
+
* where the process boundary is itself the thing under test — which is what
|
|
20
|
+
* this file's own smoke test does, and it is the only thing that does.
|
|
21
|
+
*
|
|
22
|
+
* ── The service it serves ───────────────────────────────────────────────
|
|
23
|
+
*
|
|
24
|
+
* The real one, built the same way the command line builds its own
|
|
25
|
+
* (`service/runtime.ts`), differing only in the adapter it records — §1.6
|
|
26
|
+
* keeps one row per decision and one column saying which door it came in
|
|
27
|
+
* through, and this door is `tool-stdio`.
|
|
28
|
+
*
|
|
29
|
+
* {@link serviceUnavailable} covers the one case where a service cannot be
|
|
30
|
+
* built at all: opening the store is what can fail, and it fails with a rule
|
|
31
|
+
* attached. Refusing every operation by name is the right answer there — a
|
|
32
|
+
* caller that spawned this gets an honest refusal and the whole wire path
|
|
33
|
+
* stays real — and it is reached only in that case, because a surface that
|
|
34
|
+
* refused everything unconditionally would be unusable rather than degraded.
|
|
35
|
+
*
|
|
36
|
+
* **Human text never goes to standard output**, because standard output is
|
|
37
|
+
* the protocol stream and one stray line would corrupt the framing for every
|
|
38
|
+
* message after it. The startup refusal below therefore goes to the error
|
|
39
|
+
* stream, and the session still serves — a caller mid-conversation gets
|
|
40
|
+
* refusals it can read rather than a process that vanished.
|
|
41
|
+
*/
|
|
42
|
+
let runtime;
|
|
43
|
+
try {
|
|
44
|
+
runtime = await createRuntime({ adapter: 'tool-stdio' });
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
if (!(error instanceof BrokerError)) {
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
process.stderr.write(`refused (${error.rule}): ${error.message}\n`);
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
await serveSession(linesFrom(process.stdin), {
|
|
54
|
+
service: runtime?.service ?? serviceUnavailable(),
|
|
55
|
+
streams: {
|
|
56
|
+
write: (line) => process.stdout.write(`${line}\n`),
|
|
57
|
+
log: (line) => process.stderr.write(`${line}\n`),
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
runtime?.close();
|
|
63
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { run } from "../cli/index.js";
|
|
3
|
+
import { createRuntime } from "../service/runtime.js";
|
|
4
|
+
import { BrokerError } from "../errors.js";
|
|
5
|
+
/**
|
|
6
|
+
* The executable entry point. Argument vector in, exit code out.
|
|
7
|
+
*
|
|
8
|
+
* ── What this file adds, and why it is the only thing it adds ───────────
|
|
9
|
+
*
|
|
10
|
+
* The dispatcher stays importable and stays free of any decision, so the
|
|
11
|
+
* command line can be driven in process (`MILESTONES.md`). What it does not
|
|
12
|
+
* do is build itself a service — `RunOptions.service` is injected precisely
|
|
13
|
+
* so that a conformance driver can hand it one, and a dispatcher that
|
|
14
|
+
* constructed its own could only be tested by spawning.
|
|
15
|
+
*
|
|
16
|
+
* That leaves somebody to build the real one, and this is that somebody: the
|
|
17
|
+
* shipped executable, which is the only caller whose service should be the
|
|
18
|
+
* real one by default.
|
|
19
|
+
*
|
|
20
|
+
* ── The fallback is kept, and narrowed to what it was for ───────────────
|
|
21
|
+
*
|
|
22
|
+
* `serviceUnavailable()` remains the answer when a service genuinely cannot
|
|
23
|
+
* be built, and it is now reached by the path that name describes rather than
|
|
24
|
+
* by every path. Building one means opening a store, and a store can
|
|
25
|
+
* legitimately refuse to open — a location on a network filesystem, a
|
|
26
|
+
* malformed configuration variable, a directory nothing may write to. Those
|
|
27
|
+
* refusals are the service *declining*, and they already have their own
|
|
28
|
+
* sentences naming the rule that refused, so they are reported as themselves
|
|
29
|
+
* rather than flattened into "not built".
|
|
30
|
+
*/
|
|
31
|
+
const argv = process.argv.slice(2);
|
|
32
|
+
/**
|
|
33
|
+
* Commands that must answer without a service, and therefore without a store.
|
|
34
|
+
*
|
|
35
|
+
* `doctor` is the one that matters most and the reason this list is not
|
|
36
|
+
* empty: a store that does not exist yet is the state it is most useful in,
|
|
37
|
+
* and `cli/index.ts` goes to some length not to create one while answering. A
|
|
38
|
+
* binary that built a runtime first would create the store *before* the
|
|
39
|
+
* command ran and hand `doctor` a fault it had itself caused — the exact
|
|
40
|
+
* failure that file's comment describes, reintroduced one layer up.
|
|
41
|
+
*
|
|
42
|
+
* **The bare spawn is here for a subtler reason, and it is the one this list
|
|
43
|
+
* exists to get right.** With no arguments the dispatcher opens the store,
|
|
44
|
+
* steps it and reports what stepping did — that report is the whole of what
|
|
45
|
+
* the command does. Building a runtime first would step the schema, so by the
|
|
46
|
+
* time the command ran there would be nothing left to step, and a spawn
|
|
47
|
+
* against a store that did not exist a moment ago would truthfully say
|
|
48
|
+
* "already at version 5, nothing to do". The command would still be honest;
|
|
49
|
+
* it would simply have been robbed of the thing it reports. Stepping is done
|
|
50
|
+
* once per spawn, by whoever is going to speak about it.
|
|
51
|
+
*
|
|
52
|
+
* `--help` and `--version` are here for the ordinary reason: neither reads
|
|
53
|
+
* anything, and opening a database to print a version string would make the
|
|
54
|
+
* two commands most likely to be run on a broken installation the two most
|
|
55
|
+
* likely to fail on one.
|
|
56
|
+
*/
|
|
57
|
+
function needsNoService(words) {
|
|
58
|
+
if (words.includes('--help') || words.includes('-h'))
|
|
59
|
+
return true;
|
|
60
|
+
if (words.includes('--version') || words.includes('-v'))
|
|
61
|
+
return true;
|
|
62
|
+
// Only `--json` can accompany a bare spawn; anything else is a command.
|
|
63
|
+
if (words.every((word) => word.startsWith('-')))
|
|
64
|
+
return true;
|
|
65
|
+
return words[0] === 'doctor';
|
|
66
|
+
}
|
|
67
|
+
if (needsNoService(argv)) {
|
|
68
|
+
process.exitCode = await run(argv);
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
let runtime;
|
|
72
|
+
try {
|
|
73
|
+
runtime = await createRuntime({ adapter: 'cli' });
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
if (error instanceof BrokerError) {
|
|
77
|
+
// The store declined to open. Reported with the rule that refused it,
|
|
78
|
+
// in the same shape every other refusal takes, rather than as a stack
|
|
79
|
+
// trace or as a claim that the service was never built.
|
|
80
|
+
process.stderr.write(`refused (${error.rule}): ${error.message}\n`);
|
|
81
|
+
process.exitCode = 4;
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (runtime !== undefined) {
|
|
88
|
+
try {
|
|
89
|
+
process.exitCode = await run(argv, {
|
|
90
|
+
service: runtime.service,
|
|
91
|
+
// `broker login` is performed by a person and is not one of the ten
|
|
92
|
+
// operations the flat seam carries, so it reaches the service through
|
|
93
|
+
// the typed interface. Supplied here rather than constructed in the
|
|
94
|
+
// dispatcher for the reason `RunOptions.service` gives: a dispatcher
|
|
95
|
+
// that built its own service could only be tested by spawning.
|
|
96
|
+
broker: runtime.broker,
|
|
97
|
+
store: runtime.store,
|
|
98
|
+
environment: runtime.environment,
|
|
99
|
+
// `broker reconcile` asks a live browser what it has open (§2.6,
|
|
100
|
+
// §4.3). It is the runtime's own provider rather than one built in
|
|
101
|
+
// the dispatcher, so this process decides adoption once — and it is
|
|
102
|
+
// passed here, from the shipped executable, because a command reached
|
|
103
|
+
// only by a test is a command that does not exist.
|
|
104
|
+
session: runtime.session,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
runtime.close();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|