@specific.dev/spectest 0.38.0 → 0.41.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/dist/components/k3s.js +1 -24
- package/dist/components/supabase.d.ts +87 -27
- package/dist/components/supabase.js +352 -69
- package/dist/daemon.d.ts +38 -0
- package/dist/daemon.js +405 -946
- package/dist/harness/build-context.d.ts +82 -0
- package/dist/harness/build-context.js +113 -0
- package/dist/harness/buildkit-progress.d.ts +37 -0
- package/dist/harness/buildkit-progress.js +66 -0
- package/dist/harness/container-run.d.ts +89 -0
- package/dist/harness/container-run.js +118 -0
- package/dist/harness/file-mounts.d.ts +91 -0
- package/dist/harness/file-mounts.js +119 -0
- package/dist/harness/hostmatch.d.ts +65 -0
- package/dist/harness/hostmatch.js +108 -0
- package/dist/harness/http-proxy.d.ts +62 -0
- package/dist/harness/http-proxy.js +104 -0
- package/dist/harness/ingress-table.d.ts +148 -0
- package/dist/harness/ingress-table.js +129 -0
- package/dist/harness/log-delta.d.ts +54 -0
- package/dist/harness/log-delta.js +83 -0
- package/dist/harness/main.d.ts +47 -0
- package/dist/harness/main.js +164 -0
- package/dist/harness/methods.d.ts +54 -0
- package/dist/harness/methods.js +65 -0
- package/dist/harness/names-registry.d.ts +63 -0
- package/dist/harness/names-registry.js +90 -0
- package/dist/harness/protocol.d.ts +88 -0
- package/dist/harness/protocol.js +96 -0
- package/dist/harness/ready-poll.d.ts +47 -0
- package/dist/harness/ready-poll.js +67 -0
- package/dist/harness/service-graph.d.ts +29 -0
- package/dist/harness/service-graph.js +92 -0
- package/dist/harness/volume-paths.d.ts +70 -0
- package/dist/harness/volume-paths.js +81 -0
- package/dist/index.d.ts +3 -3
- package/dist/ingress.d.ts +1 -1
- package/dist/inspect.d.ts +23 -0
- package/dist/inspect.js +65 -0
- package/dist/resolver.js +5 -8
- package/dist/vendor/rrweb-plugin-console-record.umd.js +521 -0
- package/dist/vendor/rrweb-record.min.js +5061 -0
- package/package.json +7 -1
- package/src/aws-sigv4.ts +218 -0
- package/src/browser.ts +2040 -0
- package/src/components/aws.ts +554 -0
- package/src/components/email.ts +398 -0
- package/src/components/expo.ts +167 -0
- package/src/components/index.ts +81 -0
- package/src/components/k3s.ts +2061 -0
- package/src/components/postgres.ts +132 -0
- package/src/components/replayFake.ts +1015 -0
- package/src/components/s3.ts +132 -0
- package/src/components/supabase.ts +1699 -0
- package/src/daemon.ts +5489 -0
- package/src/harness/build-context.test.ts +0 -0
- package/src/harness/build-context.ts +146 -0
- package/src/harness/buildkit-progress.test.ts +98 -0
- package/src/harness/buildkit-progress.ts +74 -0
- package/src/harness/container-run.test.ts +209 -0
- package/src/harness/container-run.ts +158 -0
- package/src/harness/file-mounts.test.ts +185 -0
- package/src/harness/file-mounts.ts +145 -0
- package/src/harness/hostmatch.test.ts +148 -0
- package/src/harness/hostmatch.ts +109 -0
- package/src/harness/http-proxy.test.ts +156 -0
- package/src/harness/http-proxy.ts +119 -0
- package/src/harness/ingress-rebind.test.ts +125 -0
- package/src/harness/ingress-table.test.ts +172 -0
- package/src/harness/ingress-table.ts +186 -0
- package/src/harness/log-delta.test.ts +125 -0
- package/src/harness/log-delta.ts +100 -0
- package/src/harness/main.test.ts +211 -0
- package/src/harness/main.ts +196 -0
- package/src/harness/methods.test.ts +63 -0
- package/src/harness/methods.ts +92 -0
- package/src/harness/names-registry.test.ts +137 -0
- package/src/harness/names-registry.ts +108 -0
- package/src/harness/protocol.test.ts +148 -0
- package/src/harness/protocol.ts +163 -0
- package/src/harness/ready-poll.test.ts +172 -0
- package/src/harness/ready-poll.ts +93 -0
- package/src/harness/service-graph.test.ts +97 -0
- package/src/harness/service-graph.ts +97 -0
- package/src/harness/volume-paths.test.ts +102 -0
- package/src/harness/volume-paths.ts +112 -0
- package/src/ids.ts +89 -0
- package/src/index.ts +2725 -0
- package/src/ingress.ts +305 -0
- package/src/inspect.ts +739 -0
- package/src/locator.ts +716 -0
- package/src/mobile.ts +133 -0
- package/src/record-secrets.ts +41 -0
- package/src/recorder.ts +846 -0
- package/src/redis.ts +202 -0
- package/src/replay-bundle.ts +108 -0
- package/src/resolver.ts +348 -0
- package/src/s3.ts +333 -0
- package/src/sql.ts +243 -0
- package/src/terminal.ts +740 -0
- package/src/url-match.ts +67 -0
- package/src/vendor/rrweb-plugin-console-record.umd.js +521 -0
- package/src/vendor/rrweb-record.min.js +5061 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
assertAbsolute,
|
|
5
|
+
certificateHostnames,
|
|
6
|
+
defaultKeyMode,
|
|
7
|
+
expandServiceToken,
|
|
8
|
+
isNoopChown,
|
|
9
|
+
mountFlag,
|
|
10
|
+
needsIdTables,
|
|
11
|
+
numericId,
|
|
12
|
+
resolveChownIds,
|
|
13
|
+
} from "./file-mounts";
|
|
14
|
+
|
|
15
|
+
describe("expandServiceToken", () => {
|
|
16
|
+
/** A component can't know the key a user will mount it under, so it
|
|
17
|
+
* writes the token and we fill in the chosen name. */
|
|
18
|
+
test("substitutes the service's map key", () => {
|
|
19
|
+
expect(expandServiceToken("mirror: {{SPECTEST_SERVICE}}.internal:5000", "cluster")).toBe(
|
|
20
|
+
"mirror: cluster.internal:5000",
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("substitutes every occurrence, not just the first", () => {
|
|
25
|
+
expect(expandServiceToken("{{SPECTEST_SERVICE}}/{{SPECTEST_SERVICE}}", "k3s")).toBe(
|
|
26
|
+
"k3s/k3s",
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("leaves content without the token untouched", () => {
|
|
31
|
+
expect(expandServiceToken("plain: true", "k3s")).toBe("plain: true");
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("numericId", () => {
|
|
36
|
+
test("reads a numeric id as itself", () => {
|
|
37
|
+
expect(numericId("1000")).toBe(1000);
|
|
38
|
+
expect(numericId("0")).toBe(0);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("a name is not an id", () => {
|
|
42
|
+
expect(numericId("postgres")).toBeUndefined();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/** Anything non-numeric must fall through to the image's tables rather
|
|
46
|
+
* than being coerced — `Number("12a")` is NaN, and a NaN uid would
|
|
47
|
+
* reach the syscall. */
|
|
48
|
+
test("a partly numeric value is a name, not an id", () => {
|
|
49
|
+
expect(numericId("12a")).toBeUndefined();
|
|
50
|
+
expect(numericId("")).toBeUndefined();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("absent stays absent", () => {
|
|
54
|
+
expect(numericId(undefined)).toBeUndefined();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("needsIdTables", () => {
|
|
59
|
+
/** Probing an image costs a docker create + cp, so two numeric ids
|
|
60
|
+
* should skip it entirely. */
|
|
61
|
+
test("numeric ids need no image probe", () => {
|
|
62
|
+
expect(needsIdTables("1000", "1000")).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("naming nobody needs no probe", () => {
|
|
66
|
+
expect(needsIdTables(undefined, undefined)).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("a name on either side needs the tables", () => {
|
|
70
|
+
expect(needsIdTables("postgres", undefined)).toBe(true);
|
|
71
|
+
expect(needsIdTables(undefined, "postgres")).toBe(true);
|
|
72
|
+
expect(needsIdTables("1000", "ssl-cert")).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("defaultKeyMode", () => {
|
|
77
|
+
test("an explicit mode always wins", () => {
|
|
78
|
+
expect(defaultKeyMode("0644", { uid: 70, gid: 70 })).toBe("0644");
|
|
79
|
+
expect(defaultKeyMode("0644", undefined)).toBe("0644");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
/** Reached only when an owner was named, so no environment that never
|
|
83
|
+
* mentioned ownership changes behaviour. */
|
|
84
|
+
test("no owner means no default, leaving existing environments alone", () => {
|
|
85
|
+
expect(defaultKeyMode(undefined, undefined)).toBeUndefined();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("naming a user gives the strictest mode that user can open", () => {
|
|
89
|
+
expect(defaultKeyMode(undefined, { uid: 70, gid: -1 })).toBe("0600");
|
|
90
|
+
expect(defaultKeyMode(undefined, { uid: 70, gid: 70 })).toBe("0600");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
/** With only a group named, 0600 would lock out the very reader that
|
|
94
|
+
* was just declared. */
|
|
95
|
+
test("naming only a group keeps the group able to read", () => {
|
|
96
|
+
expect(defaultKeyMode(undefined, { uid: -1, gid: 101 })).toBe("0640");
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("resolveChownIds", () => {
|
|
101
|
+
/** The landmine: Bun's fs.chown rejects -1 with EPERM where chown(2)
|
|
102
|
+
* and node both read it as "leave this one alone". Passing it straight
|
|
103
|
+
* through breaks exactly the common cases — a user with no group, and
|
|
104
|
+
* the reverse. */
|
|
105
|
+
test("an unchanged half is filled from the file's current owner", () => {
|
|
106
|
+
expect(resolveChownIds({ uid: 70, gid: -1 }, { uid: 0, gid: 0 })).toEqual({
|
|
107
|
+
uid: 70,
|
|
108
|
+
gid: 0,
|
|
109
|
+
});
|
|
110
|
+
expect(resolveChownIds({ uid: -1, gid: 101 }, { uid: 0, gid: 0 })).toEqual({
|
|
111
|
+
uid: 0,
|
|
112
|
+
gid: 101,
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("both halves specified are passed through", () => {
|
|
117
|
+
expect(resolveChownIds({ uid: 70, gid: 101 }, { uid: 0, gid: 0 })).toEqual({
|
|
118
|
+
uid: 70,
|
|
119
|
+
gid: 101,
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("uid 0 is a real owner, not an absent one", () => {
|
|
124
|
+
expect(resolveChownIds({ uid: 0, gid: 0 }, { uid: 70, gid: 70 })).toEqual({
|
|
125
|
+
uid: 0,
|
|
126
|
+
gid: 0,
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("neither half specified resolves to the current owner", () => {
|
|
131
|
+
expect(resolveChownIds({ uid: -1, gid: -1 }, { uid: 5, gid: 6 })).toEqual({
|
|
132
|
+
uid: 5,
|
|
133
|
+
gid: 6,
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("isNoopChown", () => {
|
|
139
|
+
test("recognises an owner that asks for nothing", () => {
|
|
140
|
+
expect(isNoopChown({ uid: -1, gid: -1 })).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("any named half is not a no-op", () => {
|
|
144
|
+
expect(isNoopChown({ uid: 0, gid: -1 })).toBe(false);
|
|
145
|
+
expect(isNoopChown({ uid: -1, gid: 0 })).toBe(false);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe("assertAbsolute", () => {
|
|
150
|
+
test("accepts an absolute path", () => {
|
|
151
|
+
expect(() => assertAbsolute("db", "file path", "/etc/postgresql/server.crt")).not.toThrow();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
/** Docker would resolve a relative path against the image's working
|
|
155
|
+
* directory, so the file would land somewhere the project never said. */
|
|
156
|
+
test("refuses a relative path, naming the service and the field", () => {
|
|
157
|
+
expect(() => assertAbsolute("db", "certificate keyPath", "certs/server.key")).toThrow(
|
|
158
|
+
/service "db": certificate keyPath "certs\/server.key" must be absolute/,
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("certificateHostnames", () => {
|
|
164
|
+
test("expands the service token in SANs", () => {
|
|
165
|
+
expect(certificateHostnames("api", 0, ["{{SPECTEST_SERVICE}}.internal"])).toEqual([
|
|
166
|
+
"api.internal",
|
|
167
|
+
]);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
/** A certificate with no SANs verifies against nothing; failing at
|
|
171
|
+
* mint time says so, where a TLS error at request time would not. */
|
|
172
|
+
test("refuses an entry with no hostnames", () => {
|
|
173
|
+
expect(() => certificateHostnames("api", 2, [])).toThrow(
|
|
174
|
+
/certificate entry 2 lists no hostnames/,
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe("mountFlag", () => {
|
|
180
|
+
test("mounts read-only", () => {
|
|
181
|
+
expect(mountFlag("/workspace/.spectest/files/db/x", "/etc/x.conf")).toBe(
|
|
182
|
+
"--volume=/workspace/.spectest/files/db/x:/etc/x.conf:ro",
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Staging `files` and `certificates` for bind-mounting into a container.
|
|
3
|
+
*
|
|
4
|
+
* Ported out of `daemon.ts` as part of the harness split. These are the
|
|
5
|
+
* *pre-entrypoint* injection point — the only way to put something in place
|
|
6
|
+
* before the container's own process starts, which is what a config file a
|
|
7
|
+
* daemon reads at startup, or a TLS key it refuses to run without, actually
|
|
8
|
+
* needs. `setup` and `helpers` both run too late.
|
|
9
|
+
*
|
|
10
|
+
* What lives here is the part that decides *what* is staged and with which
|
|
11
|
+
* permissions. Writing the bytes, minting the certificate and running chown
|
|
12
|
+
* stay with the caller — but every rule that has ever been reported as a bug
|
|
13
|
+
* is a pure function below, with a test.
|
|
14
|
+
*
|
|
15
|
+
* ## The permission rules, and why they are not obvious
|
|
16
|
+
*
|
|
17
|
+
* **A bind mount carries the host inode's mode and ownership straight
|
|
18
|
+
* through, and the harness writes as root.** So `mode` on its own is not
|
|
19
|
+
* enough: locking a file down to `0600` gives the container a file owned by
|
|
20
|
+
* root that whoever it runs as cannot open. Postgres refusing to start on
|
|
21
|
+
* its own TLS key is the canonical report. `user`/`group` exist to name the
|
|
22
|
+
* reader, and naming one is what makes a strict mode usable.
|
|
23
|
+
*
|
|
24
|
+
* **Declaring a reader also implies the mode.** A key whose owner is named
|
|
25
|
+
* gets the strictest mode that owner can still open — `0600` when a user is
|
|
26
|
+
* named, `0640` when only a group is. A server that checks (postgres, ssh)
|
|
27
|
+
* refuses a lax key, so defaulting to something permissive would just move
|
|
28
|
+
* the failure. This only applies when an owner was named, so no existing
|
|
29
|
+
* environment changes behaviour.
|
|
30
|
+
*
|
|
31
|
+
* **`-1` means "leave this half alone", and Bun will not accept it.**
|
|
32
|
+
* chown(2) and node both read `-1` as unchanged, which is how plain `chown`
|
|
33
|
+
* lets you set a user without touching the group. Bun's `fs.chown` rejects
|
|
34
|
+
* it with `EPERM` (measured on Bun 1.3.14), so the untouched half has to be
|
|
35
|
+
* filled in from the file's current owner before the call —
|
|
36
|
+
* {@link resolveChownIds} is that, kept separate so the rule is testable
|
|
37
|
+
* without a filesystem.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/** Numeric ids for a staged file. `-1` means "unchanged". */
|
|
41
|
+
export interface OwnerIds {
|
|
42
|
+
uid: number;
|
|
43
|
+
gid: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** `{{SPECTEST_SERVICE}}` → the service's map key.
|
|
47
|
+
*
|
|
48
|
+
* Lets a component author self-referential config without knowing the key
|
|
49
|
+
* the user will choose for it — k3s's `registries.yaml` pointing at
|
|
50
|
+
* `<key>.internal:5000` is the reason this exists. */
|
|
51
|
+
export function expandServiceToken(text: string, service: string): string {
|
|
52
|
+
return text.replaceAll("{{SPECTEST_SERVICE}}", service);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Parse a `user`/`group` value that may be a numeric id already. */
|
|
56
|
+
export function numericId(value: string | undefined): number | undefined {
|
|
57
|
+
return value !== undefined && /^[0-9]+$/.test(value) ? Number(value) : undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Does resolving this owner need the image's `/etc/passwd` + `/etc/group`?
|
|
62
|
+
*
|
|
63
|
+
* Probing an image costs a `docker create` + `docker cp`, so it is worth
|
|
64
|
+
* knowing that two numeric ids need no probe at all.
|
|
65
|
+
*/
|
|
66
|
+
export function needsIdTables(user?: string, group?: string): boolean {
|
|
67
|
+
return (
|
|
68
|
+
(user !== undefined && numericId(user) === undefined) ||
|
|
69
|
+
(group !== undefined && numericId(group) === undefined)
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The mode a certificate's key should get.
|
|
75
|
+
*
|
|
76
|
+
* `undefined` when no owner was named — the file keeps whatever the harness
|
|
77
|
+
* wrote, which is the pre-existing behaviour for every environment that
|
|
78
|
+
* never asked about ownership.
|
|
79
|
+
*/
|
|
80
|
+
export function defaultKeyMode(
|
|
81
|
+
explicit: string | undefined,
|
|
82
|
+
owner: OwnerIds | undefined,
|
|
83
|
+
): string | undefined {
|
|
84
|
+
if (explicit) return explicit;
|
|
85
|
+
if (!owner) return undefined;
|
|
86
|
+
// Only a group was named, so the group has to be able to read it.
|
|
87
|
+
return owner.uid === -1 ? "0640" : "0600";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Fill in the halves of a chown that were meant to be left alone.
|
|
92
|
+
*
|
|
93
|
+
* Takes the file's current ids rather than reading them, so the rule is a
|
|
94
|
+
* pure function. See the module header for why `-1` cannot simply be passed
|
|
95
|
+
* through under Bun.
|
|
96
|
+
*/
|
|
97
|
+
export function resolveChownIds(owner: OwnerIds, current: OwnerIds): OwnerIds {
|
|
98
|
+
return {
|
|
99
|
+
uid: owner.uid < 0 ? current.uid : owner.uid,
|
|
100
|
+
gid: owner.gid < 0 ? current.gid : owner.gid,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** True when the owner asks for no change at all, so the chown can be
|
|
105
|
+
* skipped entirely rather than resolved and re-applied. */
|
|
106
|
+
export function isNoopChown(owner: OwnerIds): boolean {
|
|
107
|
+
return owner.uid < 0 && owner.gid < 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Reject a container path that isn't absolute.
|
|
112
|
+
*
|
|
113
|
+
* A relative path would be resolved by docker against the container's
|
|
114
|
+
* working directory, so the file would land somewhere that depends on the
|
|
115
|
+
* image rather than where the project said — a mount that appears to work
|
|
116
|
+
* and puts the file in the wrong place is worse than one that refuses.
|
|
117
|
+
*/
|
|
118
|
+
export function assertAbsolute(service: string, label: string, p: string): void {
|
|
119
|
+
if (!p.startsWith("/")) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`service ${JSON.stringify(service)}: ${label} ${JSON.stringify(p)} must be absolute`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The hostnames a certificate entry covers, with the service token
|
|
127
|
+
* expanded and the empty case refused. */
|
|
128
|
+
export function certificateHostnames(
|
|
129
|
+
service: string,
|
|
130
|
+
index: number,
|
|
131
|
+
hostnames: readonly string[],
|
|
132
|
+
): string[] {
|
|
133
|
+
const expanded = hostnames.map((h) => expandServiceToken(h, service));
|
|
134
|
+
if (expanded.length === 0) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`service ${JSON.stringify(service)}: certificate entry ${index} lists no hostnames`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
return expanded;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** A read-only single-file bind mount. */
|
|
143
|
+
export function mountFlag(hostPath: string, containerPath: string): string {
|
|
144
|
+
return `--volume=${hostPath}:${containerPath}:ro`;
|
|
145
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
certCovers,
|
|
5
|
+
hostWithoutPort,
|
|
6
|
+
isWildcard,
|
|
7
|
+
matchRoute,
|
|
8
|
+
wildcardCoversHost,
|
|
9
|
+
wildcardSuffix,
|
|
10
|
+
} from "./hostmatch";
|
|
11
|
+
|
|
12
|
+
describe("wildcard shape", () => {
|
|
13
|
+
test("only `*.suffix` counts as a wildcard", () => {
|
|
14
|
+
expect(isWildcard("*.example.com")).toBe(true);
|
|
15
|
+
expect(isWildcard("api.example.com")).toBe(false);
|
|
16
|
+
// A bare `*` has no suffix to anchor on and is not a supported form.
|
|
17
|
+
expect(isWildcard("*")).toBe(false);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("the suffix keeps its leading dot, which is what anchors the match", () => {
|
|
21
|
+
expect(wildcardSuffix("*.example.com")).toBe(".example.com");
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("certificate coverage — one label only (RFC 6125)", () => {
|
|
26
|
+
test("covers exactly one label", () => {
|
|
27
|
+
expect(wildcardCoversHost("*.example.com", "api.example.com")).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
/** The rule the whole module exists to protect. Relaxing this to a plain
|
|
31
|
+
* suffix test makes certCovers claim a deep name is already served, so
|
|
32
|
+
* the runtime-TLS path skips minting the leaf it needs and the client
|
|
33
|
+
* gets a certificate it rejects. */
|
|
34
|
+
test("does NOT cover a deeper name", () => {
|
|
35
|
+
expect(wildcardCoversHost("*.example.com", "a.b.example.com")).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("does not cover the bare domain", () => {
|
|
39
|
+
expect(wildcardCoversHost("*.example.com", "example.com")).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("does not cover an unrelated domain that merely ends similarly", () => {
|
|
43
|
+
// "notexample.com" ends with "example.com" but not with ".example.com".
|
|
44
|
+
expect(wildcardCoversHost("*.example.com", "notexample.com")).toBe(false);
|
|
45
|
+
expect(wildcardCoversHost("*.example.com", "evil-example.com")).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("certCovers accepts an exact entry", () => {
|
|
49
|
+
expect(certCovers(["api.example.com"], "api.example.com")).toBe(true);
|
|
50
|
+
expect(certCovers(["api.example.com"], "other.example.com")).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("certCovers accepts a wildcard entry for one label", () => {
|
|
54
|
+
expect(certCovers(["*.example.com"], "api.example.com")).toBe(true);
|
|
55
|
+
expect(certCovers(["*.example.com"], "a.b.example.com")).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("certCovers finds a wildcard that is not the first entry", () => {
|
|
59
|
+
// Bun matches a wildcard serverName even when it isn't first in the
|
|
60
|
+
// tls array; our own lookup must not depend on ordering either.
|
|
61
|
+
const names = ["one.test", "two.test", "*.example.com"];
|
|
62
|
+
expect(certCovers(names, "api.example.com")).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("no configured certs covers nothing", () => {
|
|
66
|
+
expect(certCovers([], "api.example.com")).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("route matching — exact first, then longest suffix", () => {
|
|
71
|
+
test("an exact route beats a wildcard that also matches", () => {
|
|
72
|
+
const routes = new Map([
|
|
73
|
+
["*.example.com", "wild"],
|
|
74
|
+
["api.example.com", "exact"],
|
|
75
|
+
]);
|
|
76
|
+
expect(matchRoute(routes, "api.example.com")).toBe("exact");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("the longest matching wildcard wins", () => {
|
|
80
|
+
const routes = new Map([
|
|
81
|
+
["*.example.com", "short"],
|
|
82
|
+
["*.eu.example.com", "long"],
|
|
83
|
+
]);
|
|
84
|
+
expect(matchRoute(routes, "a.eu.example.com")).toBe("long");
|
|
85
|
+
expect(matchRoute(routes, "a.us.example.com")).toBe("short");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("insertion order does not decide the winner", () => {
|
|
89
|
+
const a = new Map([
|
|
90
|
+
["*.eu.example.com", "long"],
|
|
91
|
+
["*.example.com", "short"],
|
|
92
|
+
]);
|
|
93
|
+
const b = new Map([
|
|
94
|
+
["*.example.com", "short"],
|
|
95
|
+
["*.eu.example.com", "long"],
|
|
96
|
+
]);
|
|
97
|
+
expect(matchRoute(a, "x.eu.example.com")).toBe("long");
|
|
98
|
+
expect(matchRoute(b, "x.eu.example.com")).toBe("long");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
/** Unlike a certificate, a wildcard ROUTE matches any depth — this is the
|
|
102
|
+
* deliberate asymmetry: such a host reverse-proxies over http but needs
|
|
103
|
+
* its own cert for https. */
|
|
104
|
+
test("a wildcard route matches any depth of subdomain", () => {
|
|
105
|
+
const routes = new Map([["*.example.com", "wild"]]);
|
|
106
|
+
expect(matchRoute(routes, "a.b.c.example.com")).toBe("wild");
|
|
107
|
+
// …while the cert rule refuses the same name.
|
|
108
|
+
expect(certCovers(["*.example.com"], "a.b.c.example.com")).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("no match yields undefined rather than an arbitrary route", () => {
|
|
112
|
+
const routes = new Map([["*.example.com", "wild"]]);
|
|
113
|
+
expect(matchRoute(routes, "elsewhere.test")).toBeUndefined();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("a route stored as a falsy value is still returned", () => {
|
|
117
|
+
// `byHost.get()` returning 0 or "" must not be mistaken for a miss.
|
|
118
|
+
const routes = new Map<string, number>([["api.test", 0]]);
|
|
119
|
+
expect(matchRoute(routes, "api.test")).toBe(0);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("the bare domain does not match its own wildcard", () => {
|
|
123
|
+
const routes = new Map([["*.example.com", "wild"]]);
|
|
124
|
+
expect(matchRoute(routes, "example.com")).toBeUndefined();
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("Host header parsing", () => {
|
|
129
|
+
test("strips a port", () => {
|
|
130
|
+
expect(hostWithoutPort("api.example.com:8080")).toBe("api.example.com");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("leaves a portless host alone", () => {
|
|
134
|
+
expect(hostWithoutPort("api.example.com")).toBe("api.example.com");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("trims surrounding whitespace", () => {
|
|
138
|
+
expect(hostWithoutPort(" api.example.com:80 ")).toBe("api.example.com");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
/** A naive split on the last ':' mangles IPv6 literals; the brackets are
|
|
142
|
+
* what disambiguate address colons from the port separator. */
|
|
143
|
+
test("keeps an IPv6 literal intact", () => {
|
|
144
|
+
expect(hostWithoutPort("[::1]:8080")).toBe("[::1]");
|
|
145
|
+
expect(hostWithoutPort("[::1]")).toBe("[::1]");
|
|
146
|
+
expect(hostWithoutPort("[2001:db8::1]:443")).toBe("[2001:db8::1]");
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hostname matching for ingress: which route serves a request, and which
|
|
3
|
+
* certificate may serve a TLS handshake.
|
|
4
|
+
*
|
|
5
|
+
* Ported out of `daemon.ts` as part of the harness split. These are pure
|
|
6
|
+
* functions guarding two rules that are easy to state and easy to get
|
|
7
|
+
* subtly wrong, and until now they had no tests at all.
|
|
8
|
+
*
|
|
9
|
+
* ## The two rules
|
|
10
|
+
*
|
|
11
|
+
* **Precedence: exact first, then longest suffix.** A `*.example.com`
|
|
12
|
+
* route and an `api.example.com` route can both match `api.example.com`;
|
|
13
|
+
* the exact one wins. Among competing wildcards the longest suffix wins,
|
|
14
|
+
* so `*.eu.example.com` beats `*.example.com` for `a.eu.example.com`.
|
|
15
|
+
* The same ordering applies at all three layers — DNS in the resolver,
|
|
16
|
+
* routes here, and SNI in Bun's cert table.
|
|
17
|
+
*
|
|
18
|
+
* **Routes and certificates match differently, deliberately.** A wildcard
|
|
19
|
+
* *route* matches any depth of subdomain, but a wildcard *certificate*
|
|
20
|
+
* covers exactly one label — which is what RFC 6125 says and what every
|
|
21
|
+
* TLS client enforces. So `a.b.example.com` under `*.example.com` is
|
|
22
|
+
* reverse-proxied happily over http, and needs its own cert entry for
|
|
23
|
+
* https.
|
|
24
|
+
*
|
|
25
|
+
* That asymmetry is load-bearing, not an oversight. Loosening
|
|
26
|
+
* {@link wildcardCoversHost} to a plain suffix test would make
|
|
27
|
+
* {@link certCovers} report that a deep name is already covered, so the
|
|
28
|
+
* runtime-TLS path would skip minting the leaf that name actually needs
|
|
29
|
+
* and hand the client a certificate it rejects — a TLS failure at request
|
|
30
|
+
* time, far from the code that caused it.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
// Re-exported rather than redefined: `ingress.ts` owns the wildcard shape
|
|
34
|
+
// for the declarative lowering, and two copies of "what counts as a
|
|
35
|
+
// wildcard" is precisely the drift this port exists to remove.
|
|
36
|
+
export { isWildcard } from "../ingress";
|
|
37
|
+
import { isWildcard } from "../ingress";
|
|
38
|
+
|
|
39
|
+
/** `"*.example.com"` → `".example.com"`. */
|
|
40
|
+
export function wildcardSuffix(pattern: string): string {
|
|
41
|
+
return pattern.slice(1);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Does a wildcard *certificate* pattern cover `hostname`?
|
|
46
|
+
*
|
|
47
|
+
* One label only (RFC 6125): `*.example.com` covers `api.example.com` and
|
|
48
|
+
* NOT `a.b.example.com`. See the module header for why this must not be
|
|
49
|
+
* relaxed into a suffix test.
|
|
50
|
+
*/
|
|
51
|
+
export function wildcardCoversHost(pattern: string, hostname: string): boolean {
|
|
52
|
+
const suffix = wildcardSuffix(pattern);
|
|
53
|
+
if (!hostname.endsWith(suffix)) return false;
|
|
54
|
+
const label = hostname.slice(0, -suffix.length);
|
|
55
|
+
return label.length > 0 && !label.includes(".");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Is `hostname` already served by an exact or wildcard certificate?
|
|
60
|
+
*
|
|
61
|
+
* Takes the set of configured server names rather than reading module
|
|
62
|
+
* state, so the rule is testable on its own — the caller passes
|
|
63
|
+
* `HTTPS_CERT_BY_HOST.keys()`.
|
|
64
|
+
*/
|
|
65
|
+
export function certCovers(serverNames: Iterable<string>, hostname: string): boolean {
|
|
66
|
+
const names = [...serverNames];
|
|
67
|
+
if (names.includes(hostname)) return true;
|
|
68
|
+
return names.some((n) => isWildcard(n) && wildcardCoversHost(n, hostname));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Pick the route for `host`: exact match first, then the longest matching
|
|
73
|
+
* wildcard suffix.
|
|
74
|
+
*
|
|
75
|
+
* Unlike a certificate, a wildcard route matches any depth — `*.test`
|
|
76
|
+
* serves `a.b.test`.
|
|
77
|
+
*/
|
|
78
|
+
export function matchRoute<T>(byHost: ReadonlyMap<string, T>, host: string): T | undefined {
|
|
79
|
+
const exact = byHost.get(host);
|
|
80
|
+
if (exact !== undefined) return exact;
|
|
81
|
+
let best: T | undefined;
|
|
82
|
+
let bestLen = -1;
|
|
83
|
+
for (const [pattern, route] of byHost) {
|
|
84
|
+
if (!isWildcard(pattern)) continue;
|
|
85
|
+
const suffix = wildcardSuffix(pattern);
|
|
86
|
+
if (host.endsWith(suffix) && suffix.length > bestLen) {
|
|
87
|
+
best = route;
|
|
88
|
+
bestLen = suffix.length;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return best;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Strip the port from a `Host` header value.
|
|
96
|
+
*
|
|
97
|
+
* IPv6 literals arrive bracketed (`[::1]:8080`), so a naive split on the
|
|
98
|
+
* last `:` would mangle them; the brackets are what disambiguate.
|
|
99
|
+
*/
|
|
100
|
+
export function hostWithoutPort(hostHeader: string): string {
|
|
101
|
+
const h = hostHeader.trim();
|
|
102
|
+
if (h.startsWith("[")) {
|
|
103
|
+
const close = h.indexOf("]");
|
|
104
|
+
if (close !== -1) return h.slice(0, close + 1);
|
|
105
|
+
return h;
|
|
106
|
+
}
|
|
107
|
+
const colon = h.lastIndexOf(":");
|
|
108
|
+
return colon === -1 ? h : h.slice(0, colon);
|
|
109
|
+
}
|