@botiverse/k-carrier 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.
Files changed (115) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +24 -0
  3. package/README.md +62 -0
  4. package/core/src/artifact/README.md +1 -0
  5. package/core/src/artifact/collectStream.ts +49 -0
  6. package/core/src/artifact/download.ts +300 -0
  7. package/core/src/artifact/errors.ts +24 -0
  8. package/core/src/artifact/partialPath.ts +18 -0
  9. package/core/src/artifact/source.ts +59 -0
  10. package/core/src/artifact/staticManifestSource.ts +185 -0
  11. package/core/src/artifact/swap.ts +21 -0
  12. package/core/src/clock.ts +22 -0
  13. package/core/src/converge/lifecycle.ts +114 -0
  14. package/core/src/converge/predicates.ts +57 -0
  15. package/core/src/converge/report.ts +38 -0
  16. package/core/src/createUpgrader.ts +300 -0
  17. package/core/src/drive/README.md +1 -0
  18. package/core/src/index.ts +17 -0
  19. package/core/src/invariants.ts +243 -0
  20. package/core/src/lifecycle/hostAdapter.ts +77 -0
  21. package/core/src/liveness.ts +80 -0
  22. package/core/src/platform/README.md +1 -0
  23. package/core/src/platform/index.ts +11 -0
  24. package/core/src/platform/ops.ts +53 -0
  25. package/core/src/platform/posix.ts +99 -0
  26. package/core/src/platform/windows.ts +73 -0
  27. package/core/src/policy/README.md +1 -0
  28. package/core/src/progress.ts +54 -0
  29. package/core/src/provenance/journal.ts +216 -0
  30. package/core/src/status/report.ts +76 -0
  31. package/core/src/status/reportStore.ts +67 -0
  32. package/core/src/txn/effects.ts +40 -0
  33. package/core/src/txn/engine.ts +275 -0
  34. package/core/src/txn/fileEffects.ts +129 -0
  35. package/core/src/txn/hostCallBudget.ts +28 -0
  36. package/core/src/txn/lock.ts +124 -0
  37. package/core/src/txn/state.ts +77 -0
  38. package/core/src/txn/transitions.ts +49 -0
  39. package/core/src/upgrade/outcome.ts +53 -0
  40. package/core/src/upgrade/retire.ts +44 -0
  41. package/core/src/upgrader.ts +124 -0
  42. package/docs/design-v1.md +246 -0
  43. package/docs/harness-design.md +170 -0
  44. package/docs/integration.md +326 -0
  45. package/docs/prior-art.md +150 -0
  46. package/docs/test-plan.md +100 -0
  47. package/harness/README.md +20 -0
  48. package/harness/src/adapter/probeChecks.ts +93 -0
  49. package/harness/src/adapter/releaseKnob.ts +65 -0
  50. package/harness/src/adapter/serviceChecks.ts +260 -0
  51. package/harness/src/artifact/checks.ts +232 -0
  52. package/harness/src/artifact/downloadHoles.ts +300 -0
  53. package/harness/src/artifact/downloadHolesMutations.ts +170 -0
  54. package/harness/src/artifact/m1.ts +204 -0
  55. package/harness/src/artifact/m1Resume.ts +141 -0
  56. package/harness/src/artifact/m3.ts +296 -0
  57. package/harness/src/artifact/m3Hosts.ts +241 -0
  58. package/harness/src/artifact/m4.ts +201 -0
  59. package/harness/src/artifact/m5.ts +239 -0
  60. package/harness/src/artifact/m6.ts +274 -0
  61. package/harness/src/artifact/m6Drive.ts +296 -0
  62. package/harness/src/artifact/m6DriveMutations.ts +70 -0
  63. package/harness/src/artifact/m6Mutations.ts +136 -0
  64. package/harness/src/artifact/m6Status.ts +224 -0
  65. package/harness/src/artifact-factory/checks.ts +68 -0
  66. package/harness/src/artifact-factory/demo.ts +70 -0
  67. package/harness/src/artifact-factory/factory.ts +134 -0
  68. package/harness/src/artifact-factory/run.ts +107 -0
  69. package/harness/src/blackbox.ts +284 -0
  70. package/harness/src/cli.ts +281 -0
  71. package/harness/src/crash/enumerate.ts +66 -0
  72. package/harness/src/crash/runner.ts +162 -0
  73. package/harness/src/examples/checks.ts +286 -0
  74. package/harness/src/fake-host/checks.ts +188 -0
  75. package/harness/src/fake-host/daemon-entry.ts +39 -0
  76. package/harness/src/fake-host/daemon.ts +159 -0
  77. package/harness/src/fake-host/inproc.ts +256 -0
  78. package/harness/src/fake-server/manifest.ts +68 -0
  79. package/harness/src/fake-server/server.ts +205 -0
  80. package/harness/src/fake-server/store.ts +208 -0
  81. package/harness/src/fixtures/minimal-adapter.ts +108 -0
  82. package/harness/src/fixtures/service-adapter.ts +187 -0
  83. package/harness/src/index.ts +11 -0
  84. package/harness/src/receipt.ts +83 -0
  85. package/harness/src/runner.ts +268 -0
  86. package/harness/src/scenario/processScan.ts +55 -0
  87. package/harness/src/scenario/sandbox.ts +259 -0
  88. package/harness/src/scenario/virtualClock.ts +56 -0
  89. package/harness/src/selfverify/selfVerify.ts +136 -0
  90. package/harness/src/sim/checks.ts +46 -0
  91. package/harness/src/sim/corpus.ts +36 -0
  92. package/harness/src/sim/effectRuntime.ts +113 -0
  93. package/harness/src/sim/error.ts +14 -0
  94. package/harness/src/sim/prng.ts +29 -0
  95. package/harness/src/sim/record.ts +76 -0
  96. package/harness/src/sim/run.ts +148 -0
  97. package/harness/src/sim/scheduler.ts +72 -0
  98. package/harness/src/sim/world.ts +284 -0
  99. package/harness/src/target.ts +86 -0
  100. package/harness/src/targetCheck.ts +47 -0
  101. package/harness/src/teeth/artifact.ts +215 -0
  102. package/harness/src/teeth/artifactDownload.ts +136 -0
  103. package/harness/src/teeth/artifactFactory.ts +41 -0
  104. package/harness/src/teeth/artifactM5.ts +68 -0
  105. package/harness/src/teeth/artifactM6.ts +264 -0
  106. package/harness/src/teeth/blackbox.ts +22 -0
  107. package/harness/src/teeth/checks.ts +278 -0
  108. package/harness/src/teeth/examples.ts +57 -0
  109. package/harness/src/teeth/fakeHost.ts +120 -0
  110. package/harness/src/teeth/index.ts +16 -0
  111. package/harness/src/teeth/m0.ts +135 -0
  112. package/harness/src/teeth/registry.ts +267 -0
  113. package/harness/src/teeth/selfCheck.ts +45 -0
  114. package/harness/src/teeth/sim.ts +61 -0
  115. package/package.json +43 -0
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Manifest model — the L0 static-file protocol (design-v1 §L0).
3
+ *
4
+ * A release is described by `manifest.json`: version + per-platform targets
5
+ * (artifact filename, sha256, size). The fake-server serves this plus the
6
+ * artifacts.
7
+ *
8
+ * The exact JSON shape here IS the wire contract the core artifact layer
9
+ * will consume; kept in the harness first so the harness is the first
10
+ * consumer (harness-design §0.3 dogfood rule).
11
+ */
12
+
13
+ import { createHash } from "node:crypto";
14
+
15
+ export const MANIFEST_FILE = "manifest.json";
16
+
17
+ /** One platform's binary target inside a manifest. */
18
+ export interface ManifestTarget {
19
+ /** Artifact filename, relative to the release root (served at /<file>). */
20
+ file: string;
21
+ sha256: string;
22
+ size: number;
23
+ }
24
+
25
+ export interface Manifest {
26
+ version: string;
27
+ /** Platform tag (e.g. "darwin-arm64") -> binary target. */
28
+ targets: Record<string, ManifestTarget>;
29
+ /** Track the release was published under (latest | alpha). Optional. */
30
+ /** Explicit opt-out of the signature chain (never a silent default). */
31
+ }
32
+
33
+ export function buildManifest(
34
+ version: string,
35
+ targets: Record<string, ManifestTarget>,
36
+ ): Manifest {
37
+ const manifest: Manifest = { version, targets };
38
+ return manifest;
39
+ }
40
+
41
+ /** sha256 hex of bytes (Node crypto; zero deps). */
42
+ export function sha256Hex(data: Uint8Array): string {
43
+ return createHash("sha256").update(data).digest("hex");
44
+ }
45
+
46
+ /**
47
+ * Numeric dotted version compare (1.2.3 < 1.10.0). Enough for the harness's
48
+ * own assertions; the core's anti-rollback policy will own the real rule.
49
+ * Returns -1 / 0 / 1.
50
+ */
51
+ export function compareVersions(a: string, b: string): number {
52
+ const pa = a.split(".").map((s) => {
53
+ const n = Math.trunc(Number(s));
54
+ return Number.isNaN(n) ? 0 : n;
55
+ });
56
+ const pb = b.split(".").map((s) => {
57
+ const n = Math.trunc(Number(s));
58
+ return Number.isNaN(n) ? 0 : n;
59
+ });
60
+ const len = Math.max(pa.length, pb.length);
61
+ for (let i = 0; i < len; i++) {
62
+ const da = pa[i] ?? 0;
63
+ const db = pb[i] ?? 0;
64
+ if (da < db) return -1;
65
+ if (da > db) return 1;
66
+ }
67
+ return 0;
68
+ }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * fake-server — local static publishing server (harness-design §1.2).
3
+ *
4
+ * A REAL HTTP static server (node:http, zero deps) that serves a release:
5
+ * manifest.json + artifacts. It is the
6
+ * black-box plane's publishing endpoint — DST's in-memory disk is a
7
+ * separate component (§1.45); this one serves real bytes over a
8
+ * real socket.
9
+ *
10
+ * Tamper API — the supply-chain teeth's whole point is "real tamper ->
11
+ * real reject": corruptByte / swapFiles / serveOlderVersion / dropFile all
12
+ * mutate what the client actually downloads, and the teeth verify that a
13
+ * real chain verifier rejects the result. No verification function is
14
+ * mocked anywhere. The on-disk release semantics live in ReleaseStore
15
+ * (store.ts); this file is the HTTP layer over it.
16
+ */
17
+ import { createServer, type Server, type IncomingMessage, type ServerResponse } from "node:http";
18
+ import { promises as fs } from "node:fs";
19
+ import * as path from "node:path";
20
+ import { ReleaseStore, type PublishReleaseSpec, type PublishedRelease } from "./store.ts";
21
+
22
+ export type { PublishReleaseSpec, PublishedRelease };
23
+
24
+ export interface FakeServerOptions {
25
+ /** Directory that will hold the release store (usually inside a sandbox). */
26
+ storeDir: string;
27
+ /** Port to bind; omit for an OS-assigned ephemeral port. */
28
+ port?: number;
29
+ /** Throttle artifact responses (interruptible-download tests). */
30
+ throttle?: { bytesPerTick: number; tickMs: number };
31
+ }
32
+
33
+ const CONTENT_TYPES: Record<string, string> = {
34
+ ".json": "application/json",
35
+ ".pub": "application/x-pem-file",
36
+ ".bin": "application/octet-stream",
37
+ ".sig": "application/octet-stream",
38
+ };
39
+
40
+ export class FakeServer {
41
+ /** The release store backing this server (the factory's publish target). */
42
+ readonly store: ReleaseStore;
43
+ private readonly requestedPort: number | undefined;
44
+ private readonly throttle: { bytesPerTick: number; tickMs: number } | undefined;
45
+ private server: Server | null = null;
46
+ private actualPort = 0;
47
+ /** Every request the server handled (for asserting Range flows). */
48
+ readonly requestLog: Array<{ method: string; url: string; range: string | null }> = [];
49
+
50
+ constructor(opts: FakeServerOptions) {
51
+ this.store = new ReleaseStore(opts.storeDir);
52
+ this.requestedPort = opts.port;
53
+ this.throttle = opts.throttle;
54
+ }
55
+
56
+ get url(): string {
57
+ if (!this.server) throw new Error("FakeServer not started");
58
+ return `http://127.0.0.1:${this.actualPort}`;
59
+ }
60
+
61
+ get port(): number {
62
+ return this.actualPort;
63
+ }
64
+
65
+ get active(): string | null {
66
+ return this.store.active;
67
+ }
68
+
69
+ async start(): Promise<void> {
70
+ if (this.server) return;
71
+ const server = createServer((req, res) => void this.handle(req, res));
72
+ this.server = server;
73
+ await new Promise<void>((resolve, reject) => {
74
+ server.once("error", reject);
75
+ server.listen(this.requestedPort ?? 0, "127.0.0.1", () => {
76
+ const addr = server.address();
77
+ if (addr && typeof addr === "object") this.actualPort = addr.port;
78
+ resolve();
79
+ });
80
+ });
81
+ }
82
+
83
+ async stop(): Promise<void> {
84
+ if (!this.server) return;
85
+ const server = this.server;
86
+ this.server = null;
87
+ await new Promise<void>((resolve) => {
88
+ server.close(() => {
89
+ resolve();
90
+ });
91
+ });
92
+ }
93
+
94
+ async publishRelease(spec: PublishReleaseSpec): Promise<PublishedRelease> {
95
+ this.requireStarted();
96
+ return this.store.publish(spec);
97
+ }
98
+
99
+ async corruptByte(file: string, offset: number): Promise<void> {
100
+ return this.store.corruptByte(file, offset);
101
+ }
102
+
103
+ /** Serve each file the other's bytes: both then fail their own sha256. */
104
+ async swapFiles(fileA: string, fileB: string): Promise<void> {
105
+ return this.store.swapFiles(fileA, fileB);
106
+ }
107
+
108
+ async serveOlderVersion(): Promise<string> {
109
+ return this.store.switchToOlderVersion();
110
+ }
111
+
112
+ async dropFile(file: string): Promise<void> {
113
+ return this.store.dropFile(file);
114
+ }
115
+
116
+ async restore(): Promise<void> {
117
+ return this.store.restore();
118
+ }
119
+
120
+ async readFile(file: string): Promise<Uint8Array> {
121
+ return this.store.readFile(file);
122
+ }
123
+
124
+ private requireStarted(): void {
125
+ if (!this.server) throw new Error("FakeServer not started");
126
+ }
127
+
128
+ private async handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
129
+ if (req.method !== "GET" && req.method !== "HEAD") {
130
+ res.writeHead(405, { "content-type": "text/plain" }).end("method not allowed");
131
+ return;
132
+ }
133
+ const raw = (req.url ?? "/").split("?")[0] ?? "/";
134
+ let file: string;
135
+ try {
136
+ file = decodeURIComponent(raw).replace(/^\/+/, "");
137
+ } catch {
138
+ res.writeHead(400, { "content-type": "text/plain" }).end("bad request");
139
+ return;
140
+ }
141
+ if (!file) {
142
+ res.writeHead(404, { "content-type": "text/plain" }).end("not found");
143
+ return;
144
+ }
145
+ let target: string;
146
+ try {
147
+ target = await this.store.resolveFile(file);
148
+ } catch {
149
+ res.writeHead(404, { "content-type": "text/plain" }).end("not found");
150
+ return;
151
+ }
152
+ this.requestLog.push({ method: req.method, url: file, range: req.headers.range ?? null });
153
+ try {
154
+ const data = await fs.readFile(target);
155
+ const ext = path.extname(file).toLowerCase();
156
+ const contentType = CONTENT_TYPES[ext] ?? "application/octet-stream";
157
+
158
+ // Range: bytes=N- (the single-range form the resumable download uses)
159
+ const rangeMatch = /^bytes=(\d+)-$/.exec(req.headers.range ?? "");
160
+ if (rangeMatch) {
161
+ const start = Number(rangeMatch[1]);
162
+ if (start >= data.length) {
163
+ res.writeHead(416, { "content-type": "text/plain" }).end("range not satisfiable");
164
+ return;
165
+ }
166
+ const slice = data.subarray(start);
167
+ res.writeHead(206, {
168
+ "content-type": contentType,
169
+ "content-range": `bytes ${start}-${data.length - 1}/${data.length}`,
170
+ "content-length": slice.length,
171
+ });
172
+ await this.writeBody(res, slice);
173
+ return;
174
+ }
175
+
176
+ res.writeHead(200, {
177
+ "content-type": contentType,
178
+ "content-length": data.length,
179
+ });
180
+ if (req.method === "GET") await this.writeBody(res, data);
181
+ else res.end();
182
+ } catch {
183
+ res.writeHead(404, { "content-type": "text/plain" }).end("not found");
184
+ }
185
+ }
186
+
187
+ /** Write the body, chunked and delayed when throttled (kill-window tests). */
188
+ private async writeBody(res: ServerResponse, data: Buffer): Promise<void> {
189
+ if (!this.throttle) {
190
+ res.end(data);
191
+ return;
192
+ }
193
+ const { bytesPerTick, tickMs } = this.throttle;
194
+ for (let off = 0; off < data.length; off += bytesPerTick) {
195
+ const chunk = data.subarray(off, off + bytesPerTick);
196
+ res.write(chunk);
197
+ if (off + bytesPerTick < data.length) {
198
+ await new Promise((r) => {
199
+ setTimeout(r, tickMs);
200
+ });
201
+ }
202
+ }
203
+ res.end();
204
+ }
205
+ }
@@ -0,0 +1,208 @@
1
+ /**
2
+ * ReleaseStore — on-disk release store behind the fake-server: publish
3
+ * (manifest + artifacts), pristine copies,
4
+ * file-level tamper ops and version switching. Pure filesystem semantics,
5
+ * no HTTP; the FakeServer in server.ts is the HTTP layer over this.
6
+ *
7
+ * Tamper ops mutate the served files on disk (a compromised server's files
8
+ * ARE what the client gets); restore() regenerates from pristine.
9
+ */
10
+ import { promises as fs } from "node:fs";
11
+ import * as path from "node:path";
12
+ import {
13
+ MANIFEST_FILE,
14
+ buildManifest,
15
+ sha256Hex,
16
+ compareVersions,
17
+ type Manifest,
18
+ } from "./manifest.ts";
19
+
20
+ export interface PublishReleaseSpec {
21
+ version: string;
22
+ /** Logical artifact name -> content; served at /<name>. */
23
+ artifacts: Record<string, string | Uint8Array>;
24
+ /** Platform tag for the manifest target. Default: "current". */
25
+ platform?: string;
26
+ /** Which artifact is the binary for `platform`. Default: the sole artifact. */
27
+ binary?: string;
28
+ /** Default true: the new release becomes the active (served) one. */
29
+ makeActive?: boolean;
30
+ /** chmod +x every artifact file (real binaries need to be runnable). */
31
+ executable?: boolean;
32
+ }
33
+
34
+ export interface PublishedRelease {
35
+ version: string;
36
+ /** All files served for this release (relative paths). */
37
+ files: string[];
38
+ manifest: Manifest;
39
+ }
40
+
41
+ export class ReleaseStore {
42
+ private readonly storeDir: string;
43
+ private activeVersion: string | null = null;
44
+ /** version -> file -> pristine bytes, for restore(). */
45
+ private readonly pristine = new Map<string, Map<string, Uint8Array>>();
46
+
47
+ constructor(storeDir: string) {
48
+ this.storeDir = storeDir;
49
+ }
50
+
51
+ get active(): string | null {
52
+ return this.activeVersion;
53
+ }
54
+
55
+ /** Whether a release with this version has been published. */
56
+ has(version: string): boolean {
57
+ return this.pristine.has(version);
58
+ }
59
+
60
+ /**
61
+ * Publish a release into the store: writes artifacts + manifest + the
62
+ * manifest, and makes it the active release (unless
63
+ * makeActive is false).
64
+ */
65
+ async publish(spec: PublishReleaseSpec): Promise<PublishedRelease> {
66
+ const version = spec.version;
67
+ if (this.pristine.has(version)) {
68
+ throw new Error(`release ${version} already published`);
69
+ }
70
+ const artifactNames = Object.keys(spec.artifacts);
71
+ if (artifactNames.length === 0) throw new Error("release has no artifacts");
72
+ const binary = spec.binary ?? (artifactNames.length === 1 ? artifactNames[0] : undefined);
73
+ if (!binary) throw new Error("multiple artifacts: specify which one is the platform binary");
74
+ if (!artifactNames.includes(binary)) throw new Error(`binary ${binary} not among artifacts`);
75
+
76
+ const releaseDir = this.releaseDir(version);
77
+ await fs.mkdir(releaseDir, { recursive: true });
78
+
79
+ const bytes = new Map<string, Uint8Array>();
80
+ const targets: Manifest["targets"] = {};
81
+ for (const name of artifactNames) {
82
+ const content = spec.artifacts[name];
83
+ if (content === undefined) throw new Error(`artifact ${name} has no content`);
84
+ const data = typeof content === "string" ? new TextEncoder().encode(content) : content;
85
+ bytes.set(name, data);
86
+ await fs.writeFile(path.join(releaseDir, name), data);
87
+ }
88
+ if (spec.executable) {
89
+ for (const name of artifactNames) {
90
+ await fs.chmod(path.join(releaseDir, name), 0o755);
91
+ }
92
+ }
93
+ const binaryData = bytes.get(binary);
94
+ if (!binaryData) throw new Error(`missing bytes for binary ${binary}`);
95
+ targets[spec.platform ?? "current"] = {
96
+ file: binary,
97
+ sha256: sha256Hex(binaryData),
98
+ size: binaryData.length,
99
+ };
100
+ const manifest = buildManifest(version, targets);
101
+ const manifestJson = new TextEncoder().encode(JSON.stringify(manifest, null, 2));
102
+
103
+ await fs.writeFile(path.join(releaseDir, MANIFEST_FILE), manifestJson);
104
+
105
+ // Pristine copy for restore().
106
+ const pristineFiles = new Map<string, Uint8Array>();
107
+ for (const name of artifactNames) {
108
+ const data = bytes.get(name);
109
+ if (data) pristineFiles.set(name, data);
110
+ }
111
+ pristineFiles.set(MANIFEST_FILE, manifestJson);
112
+ this.pristine.set(version, pristineFiles);
113
+
114
+ if (spec.makeActive !== false) this.activeVersion = version;
115
+ return { version, files: [...pristineFiles.keys()], manifest };
116
+ }
117
+
118
+ /**
119
+ * Flip one byte of a served file. offset is 0-based from the start of
120
+ * the file; the byte is XORed with 0xff (a genuine corruption, never a
121
+ * no-op unless applied twice to the same offset).
122
+ */
123
+ async corruptByte(file: string, offset: number): Promise<void> {
124
+ const target = await this.resolveFile(file);
125
+ const data = await fs.readFile(target);
126
+ if (offset < 0 || offset >= data.length) {
127
+ throw new RangeError(
128
+ `corruptByte: offset ${offset} out of range for ${file} (${data.length} bytes)`,
129
+ );
130
+ }
131
+ const corrupted = Buffer.from(data);
132
+ const current = corrupted[offset];
133
+ if (current === undefined) {
134
+ throw new RangeError(`corruptByte: offset ${offset} out of range for ${file}`);
135
+ }
136
+ corrupted[offset] = current ^ 0xff;
137
+ await fs.writeFile(target, corrupted);
138
+ }
139
+
140
+ /**
141
+ * Swap two files' contents: each file now holds the other's bytes, so a
142
+ * sha256 that was correct for one file is presented against the other.
143
+ */
144
+ async swapFiles(fileA: string, fileB: string): Promise<void> {
145
+ const a = await this.resolveFile(fileA);
146
+ const b = await this.resolveFile(fileB);
147
+ const dataA = await fs.readFile(a);
148
+ const dataB = await fs.readFile(b);
149
+ await fs.writeFile(a, dataB);
150
+ await fs.writeFile(b, dataA);
151
+ }
152
+
153
+ /**
154
+ * Switch the active release to the strictly-older one (downgrade
155
+ * attack). Returns the version now being served. Throws if no strictly
156
+ * older release is published.
157
+ */
158
+ async switchToOlderVersion(): Promise<string> {
159
+ const activeVersion = this.activeVersion;
160
+ if (!activeVersion) throw new Error("no active release");
161
+ const versions = [...this.pristine.keys()].filter((v) => compareVersions(v, activeVersion) < 0);
162
+ if (versions.length === 0) throw new Error("no older release published");
163
+ versions.sort(compareVersions);
164
+ const older = versions[0]; // oldest published
165
+ if (!older) throw new Error("no older release published");
166
+ this.activeVersion = older;
167
+ return older;
168
+ }
169
+
170
+ /** Stop serving a file: the client gets 404 for it from now on. */
171
+ async dropFile(file: string): Promise<void> {
172
+ await fs.unlink(await this.resolveFile(file));
173
+ }
174
+
175
+ /** Regenerate the active release from its pristine copy (undo tamper). */
176
+ async restore(): Promise<void> {
177
+ const activeVersion = this.activeVersion;
178
+ if (!activeVersion) throw new Error("no active release");
179
+ const pristine = this.pristine.get(activeVersion);
180
+ if (!pristine) throw new Error(`no pristine copy for ${activeVersion}`);
181
+ const releaseDir = this.releaseDir(activeVersion);
182
+ for (const [file, data] of pristine) {
183
+ await fs.writeFile(path.join(releaseDir, file), data);
184
+ }
185
+ }
186
+
187
+ /** Read a served file's current bytes (what a client would download). */
188
+ async readFile(file: string): Promise<Uint8Array> {
189
+ return new Uint8Array(await fs.readFile(await this.resolveFile(file)));
190
+ }
191
+
192
+ /** Absolute path of a served file; throws if missing or escaping root. */
193
+ async resolveFile(file: string): Promise<string> {
194
+ const activeVersion = this.activeVersion;
195
+ if (!activeVersion) throw new Error("no active release");
196
+ const releaseDir = this.releaseDir(activeVersion);
197
+ const resolved = path.resolve(releaseDir, `.${path.sep}${file}`);
198
+ if (resolved !== releaseDir && !resolved.startsWith(releaseDir + path.sep)) {
199
+ throw new Error(`path escapes release root: ${file}`);
200
+ }
201
+ await fs.access(resolved);
202
+ return resolved;
203
+ }
204
+
205
+ private releaseDir(version: string): string {
206
+ return path.join(this.storeDir, "releases", version);
207
+ }
208
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Minimal adopter adapter fixture — an INDEPENDENT HostAdapter + workload
3
+ * driver used to exercise `k-harness --adapter` (harness-design §1.7: the
4
+ * adopter contract subset runs against the adapter, not the fake host).
5
+ *
6
+ * It is intentionally NOT InprocFakeHost: a real adopter brings its own
7
+ * host semantics; this one is a from-scratch minimal implementation of the
8
+ * same contract surface (its own ledger bytes, its own startIds), so the
9
+ * adapter-mode tests prove the contract checks are host-agnostic.
10
+ *
11
+ * Default export contract: `(stateDir: string) => HostDriver`.
12
+ */
13
+ import { createHash } from "node:crypto";
14
+ import { promises as fs } from "node:fs";
15
+ import * as path from "node:path";
16
+ import type { ProcessEvidence, Slot } from "../../../core/src/lifecycle/hostAdapter.ts";
17
+ import type { HostDriver, LedgerState } from "../fake-host/inproc.ts";
18
+
19
+ const LEDGER = "adapter-ledger.bin";
20
+
21
+ export function createMinimalAdapter(stateDir: string): HostDriver {
22
+ let counter = 0;
23
+ let checksum: Uint8Array = createHash("sha256").update("minimal-adapter-v1").digest();
24
+ let runningSlot: Slot | null = null;
25
+ let parked = false;
26
+ let incarnation = 0;
27
+ let currentStartId: string | null = null;
28
+
29
+ const ledgerBytes = (): Uint8Array => {
30
+ const out = new Uint8Array(8 + 32);
31
+ new DataView(out.buffer).setBigUint64(0, BigInt(counter), false);
32
+ out.set(checksum, 8);
33
+ return out;
34
+ };
35
+
36
+ const writeLedger = async (): Promise<void> => {
37
+ await fs.mkdir(stateDir, { recursive: true });
38
+ await fs.writeFile(path.join(stateDir, LEDGER), ledgerBytes());
39
+ };
40
+
41
+ return {
42
+ async quiesce() {
43
+ if (parked) return;
44
+ if (!runningSlot) throw new Error("quiesce: no running slot");
45
+ parked = true;
46
+ await writeLedger();
47
+ },
48
+ async stop(slot: Slot) {
49
+ if (runningSlot !== slot) throw new Error(`stop: ${slot} is not the running slot`);
50
+ runningSlot = null;
51
+ },
52
+ async start(slot: Slot) {
53
+ if (runningSlot !== null) throw new Error(`start: ${runningSlot} already running`);
54
+ runningSlot = slot;
55
+ incarnation += 1;
56
+ currentStartId = `minimal-inc:${incarnation}`;
57
+ },
58
+ async healthProbe(): Promise<ProcessEvidence> {
59
+ if (!runningSlot) throw new Error("probe: no running slot");
60
+ if (!currentStartId) throw new Error("probe: no startId");
61
+ return {
62
+ version: runningSlot === "stable" ? "1.0.0" : "2.0.0",
63
+ pid: process.pid,
64
+ startId: currentStartId,
65
+ };
66
+ },
67
+ async resume() {
68
+ if (!parked) throw new Error("resume: not quiesced");
69
+ parked = false;
70
+ // restore in-memory state from the parked ledger file
71
+ const raw = new Uint8Array(await fs.readFile(path.join(stateDir, LEDGER)));
72
+ counter = Number(new DataView(raw.buffer, raw.byteOffset, raw.byteLength).getBigUint64(0, false));
73
+ checksum = raw.slice(8);
74
+ },
75
+ get running(): Slot | null {
76
+ return runningSlot;
77
+ },
78
+ get parked(): boolean {
79
+ return parked;
80
+ },
81
+ get startId(): string | null {
82
+ return currentStartId;
83
+ },
84
+ async doWork(n: number) {
85
+ if (parked) throw new Error("workload is parked (quiesced)");
86
+ if (!runningSlot) throw new Error("doWork: no running slot");
87
+ for (let i = 0; i < n; i++) {
88
+ counter += 1;
89
+ checksum = createHash("sha256").update(be64(counter)).update(checksum).digest();
90
+ }
91
+ await writeLedger();
92
+ },
93
+ async ledger(): Promise<Uint8Array> {
94
+ return new Uint8Array(await fs.readFile(path.join(stateDir, LEDGER)));
95
+ },
96
+ async ledgerState(): Promise<LedgerState> {
97
+ return { counter, checksum };
98
+ },
99
+ };
100
+ }
101
+
102
+ function be64(n: number): Uint8Array {
103
+ const out = new Uint8Array(8);
104
+ new DataView(out.buffer).setBigUint64(0, BigInt(n), false);
105
+ return out;
106
+ }
107
+
108
+ export default createMinimalAdapter;