@specific.dev/spectest 0.39.0 → 0.43.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.
Files changed (105) hide show
  1. package/dist/browser.d.ts +21 -8
  2. package/dist/browser.js +78 -36
  3. package/dist/components/supabase.d.ts +87 -27
  4. package/dist/components/supabase.js +352 -69
  5. package/dist/daemon.d.ts +38 -0
  6. package/dist/daemon.js +464 -987
  7. package/dist/harness/build-context.d.ts +82 -0
  8. package/dist/harness/build-context.js +113 -0
  9. package/dist/harness/buildkit-progress.d.ts +37 -0
  10. package/dist/harness/buildkit-progress.js +66 -0
  11. package/dist/harness/container-run.d.ts +89 -0
  12. package/dist/harness/container-run.js +118 -0
  13. package/dist/harness/file-mounts.d.ts +91 -0
  14. package/dist/harness/file-mounts.js +119 -0
  15. package/dist/harness/hostmatch.d.ts +65 -0
  16. package/dist/harness/hostmatch.js +108 -0
  17. package/dist/harness/http-proxy.d.ts +62 -0
  18. package/dist/harness/http-proxy.js +104 -0
  19. package/dist/harness/ingress-table.d.ts +148 -0
  20. package/dist/harness/ingress-table.js +129 -0
  21. package/dist/harness/log-delta.d.ts +54 -0
  22. package/dist/harness/log-delta.js +83 -0
  23. package/dist/harness/main.d.ts +47 -0
  24. package/dist/harness/main.js +164 -0
  25. package/dist/harness/methods.d.ts +54 -0
  26. package/dist/harness/methods.js +65 -0
  27. package/dist/harness/names-registry.d.ts +63 -0
  28. package/dist/harness/names-registry.js +90 -0
  29. package/dist/harness/protocol.d.ts +88 -0
  30. package/dist/harness/protocol.js +96 -0
  31. package/dist/harness/ready-poll.d.ts +47 -0
  32. package/dist/harness/ready-poll.js +67 -0
  33. package/dist/harness/service-graph.d.ts +29 -0
  34. package/dist/harness/service-graph.js +92 -0
  35. package/dist/harness/volume-paths.d.ts +70 -0
  36. package/dist/harness/volume-paths.js +81 -0
  37. package/dist/index.d.ts +58 -16
  38. package/dist/ingress.d.ts +1 -1
  39. package/dist/mobile.d.ts +9 -5
  40. package/dist/mobile.js +7 -6
  41. package/dist/recorder.d.ts +10 -0
  42. package/dist/resolver.js +5 -8
  43. package/dist/vendor/rrweb-plugin-console-record.umd.js +521 -0
  44. package/dist/vendor/rrweb-record.min.js +5061 -0
  45. package/package.json +7 -1
  46. package/src/aws-sigv4.ts +218 -0
  47. package/src/browser.ts +2095 -0
  48. package/src/components/aws.ts +554 -0
  49. package/src/components/email.ts +398 -0
  50. package/src/components/expo.ts +167 -0
  51. package/src/components/index.ts +81 -0
  52. package/src/components/k3s.ts +2061 -0
  53. package/src/components/postgres.ts +132 -0
  54. package/src/components/replayFake.ts +1015 -0
  55. package/src/components/s3.ts +132 -0
  56. package/src/components/supabase.ts +1699 -0
  57. package/src/daemon.ts +5537 -0
  58. package/src/harness/build-context.test.ts +0 -0
  59. package/src/harness/build-context.ts +146 -0
  60. package/src/harness/buildkit-progress.test.ts +98 -0
  61. package/src/harness/buildkit-progress.ts +74 -0
  62. package/src/harness/container-run.test.ts +209 -0
  63. package/src/harness/container-run.ts +158 -0
  64. package/src/harness/file-mounts.test.ts +185 -0
  65. package/src/harness/file-mounts.ts +145 -0
  66. package/src/harness/hostmatch.test.ts +148 -0
  67. package/src/harness/hostmatch.ts +109 -0
  68. package/src/harness/http-proxy.test.ts +156 -0
  69. package/src/harness/http-proxy.ts +119 -0
  70. package/src/harness/ingress-rebind.test.ts +125 -0
  71. package/src/harness/ingress-table.test.ts +172 -0
  72. package/src/harness/ingress-table.ts +186 -0
  73. package/src/harness/log-delta.test.ts +125 -0
  74. package/src/harness/log-delta.ts +100 -0
  75. package/src/harness/main.test.ts +211 -0
  76. package/src/harness/main.ts +196 -0
  77. package/src/harness/methods.test.ts +63 -0
  78. package/src/harness/methods.ts +92 -0
  79. package/src/harness/names-registry.test.ts +137 -0
  80. package/src/harness/names-registry.ts +108 -0
  81. package/src/harness/protocol.test.ts +148 -0
  82. package/src/harness/protocol.ts +163 -0
  83. package/src/harness/ready-poll.test.ts +172 -0
  84. package/src/harness/ready-poll.ts +93 -0
  85. package/src/harness/service-graph.test.ts +97 -0
  86. package/src/harness/service-graph.ts +97 -0
  87. package/src/harness/volume-paths.test.ts +102 -0
  88. package/src/harness/volume-paths.ts +112 -0
  89. package/src/ids.ts +89 -0
  90. package/src/index.ts +2767 -0
  91. package/src/ingress.ts +305 -0
  92. package/src/inspect.ts +739 -0
  93. package/src/locator.ts +716 -0
  94. package/src/mobile.ts +138 -0
  95. package/src/record-secrets.ts +41 -0
  96. package/src/recorder.ts +856 -0
  97. package/src/redis.ts +202 -0
  98. package/src/replay-bundle.ts +108 -0
  99. package/src/resolver.ts +348 -0
  100. package/src/s3.ts +333 -0
  101. package/src/sql.ts +243 -0
  102. package/src/terminal.ts +740 -0
  103. package/src/url-match.ts +67 -0
  104. package/src/vendor/rrweb-plugin-console-record.umd.js +521 -0
  105. 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
+ }