@microck/canonfig 2.0.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 (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +263 -0
  3. package/dist/agent/agent-resolution.errors.js +42 -0
  4. package/dist/agent/agent-resolution.layer.js +204 -0
  5. package/dist/agent/agent-resolution.service.js +2259 -0
  6. package/dist/agent/agent-resolution.types.js +1 -0
  7. package/dist/agent/controlled-executor.js +704 -0
  8. package/dist/agent/harness-adapters.js +85 -0
  9. package/dist/cli/cli.js +618 -0
  10. package/dist/cli/exit-codes.js +28 -0
  11. package/dist/cli/follower-commands.js +3 -0
  12. package/dist/cli/render.js +56 -0
  13. package/dist/cli/source-commands.js +5 -0
  14. package/dist/domain/brand.js +29 -0
  15. package/dist/domain/identity.js +31 -0
  16. package/dist/domain/npm-package-spec.js +186 -0
  17. package/dist/domain/profile.js +950 -0
  18. package/dist/domain/recipe-versions.js +297 -0
  19. package/dist/domain/resource.js +259 -0
  20. package/dist/domain/synchronization.js +346 -0
  21. package/dist/enrollment/enrollment.errors.js +43 -0
  22. package/dist/enrollment/enrollment.layer.js +724 -0
  23. package/dist/enrollment/enrollment.service.js +3 -0
  24. package/dist/enrollment/enrollment.types.js +59 -0
  25. package/dist/enrollment/follower-client.js +585 -0
  26. package/dist/enrollment/source-server.js +313 -0
  27. package/dist/machine/linux.layer.js +1183 -0
  28. package/dist/machine/machine-state.errors.js +52 -0
  29. package/dist/machine/machine-state.service.js +3 -0
  30. package/dist/machine/machine-state.types.js +1 -0
  31. package/dist/machine/macos.layer.js +470 -0
  32. package/dist/machine/windows.layer.js +879 -0
  33. package/dist/profile/discovery.js +740 -0
  34. package/dist/profile/profile-catalog.errors.js +50 -0
  35. package/dist/profile/profile-catalog.layer.js +20 -0
  36. package/dist/profile/profile-catalog.service.js +7 -0
  37. package/dist/profile/profile-codec.js +153 -0
  38. package/dist/profile/publication.js +298 -0
  39. package/dist/profile/tool-catalog.js +384 -0
  40. package/dist/runtime/doctor.js +306 -0
  41. package/dist/runtime/layers.js +706 -0
  42. package/dist/runtime/main.js +38 -0
  43. package/dist/schedule/linux-schedule.js +24 -0
  44. package/dist/schedule/macos-schedule.js +25 -0
  45. package/dist/schedule/schedule-manager.errors.js +17 -0
  46. package/dist/schedule/schedule-manager.layer.js +205 -0
  47. package/dist/schedule/schedule-manager.service.js +3 -0
  48. package/dist/schedule/schedule-manager.types.js +114 -0
  49. package/dist/schedule/windows-schedule.js +25 -0
  50. package/dist/state/state-repository.errors.js +55 -0
  51. package/dist/state/state-repository.layer.js +1507 -0
  52. package/dist/state/state-repository.service.js +3 -0
  53. package/dist/state/state-repository.types.js +1 -0
  54. package/dist/state/state-schema.js +298 -0
  55. package/dist/synchronization/config-codec.js +97 -0
  56. package/dist/synchronization/executor.js +700 -0
  57. package/dist/synchronization/follower-orchestration.js +939 -0
  58. package/dist/synchronization/follower-sync-config.js +81 -0
  59. package/dist/synchronization/npm-artifact.js +670 -0
  60. package/dist/synchronization/planner.js +378 -0
  61. package/dist/synchronization/recovery.js +397 -0
  62. package/dist/synchronization/resource-executors.js +1198 -0
  63. package/dist/synchronization/resource-plans.js +645 -0
  64. package/dist/synchronization/synchronization.errors.js +102 -0
  65. package/dist/synchronization/synchronization.layer.js +97 -0
  66. package/dist/synchronization/synchronization.service.js +11 -0
  67. package/dist/synchronization/synchronization.types.js +1 -0
  68. package/package.json +66 -0
@@ -0,0 +1,1183 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ import { access, chmod, lstat, mkdir, open, readFile, readlink, realpath, rename, rmdir, rm, symlink, unlink, } from "node:fs/promises";
4
+ import { constants as filesystemConstants } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { basename, dirname, isAbsolute, join, normalize, relative, resolve, sep, } from "node:path";
7
+ import { Effect, Layer, Redacted, Schema } from "effect";
8
+ import { ContentDigest, CredentialReference, } from "../domain/brand.js";
9
+ import { CredentialStorageError, ExecutableNotFoundError, FileSizeLimitError, HumanActionRequiredError, InvalidMachinePathError, InvalidSchedulerJobError, MachineFilesystemError, ProcessOutputLimitError, ProcessStartError, ProcessTimeoutError, } from "./machine-state.errors.js";
10
+ import { MachineState } from "./machine-state.service.js";
11
+ const decode = Schema.decodeUnknownSync;
12
+ const defaultDirectoryMode = 0o700;
13
+ const defaultFileMode = 0o600;
14
+ class ProcessTimeoutSignal extends Error {
15
+ }
16
+ class ProcessOutputLimitSignal extends Error {
17
+ }
18
+ class ProcessStartSignal extends Error {
19
+ }
20
+ class CredentialCommandSignal extends Error {
21
+ }
22
+ const messageOf = (cause) => cause instanceof Error ? cause.message : String(cause);
23
+ const errorCode = (cause) => cause instanceof Error && "code" in cause
24
+ ? String(cause.code)
25
+ : undefined;
26
+ const filesystemError = (operation, path) => (cause) => new MachineFilesystemError({ operation, path, message: messageOf(cause) });
27
+ const promiseEffect = (operation, path, run) => Effect.tryPromise({
28
+ try: run,
29
+ catch: filesystemError(operation, path),
30
+ });
31
+ const objectKind = (metadata) => {
32
+ if (metadata.isSymbolicLink())
33
+ return "symlink";
34
+ if (metadata.isFile())
35
+ return "regular";
36
+ if (metadata.isDirectory())
37
+ return "directory";
38
+ return "special";
39
+ };
40
+ const sameFileIdentity = (left, right) => left.dev === right.dev && left.ino === right.ino;
41
+ const assertStableRegularHandle = async (path, handle, opened) => {
42
+ if (!opened.isFile()) {
43
+ throw new Error(`path is not a regular file: ${path}`);
44
+ }
45
+ const visible = await lstat(path);
46
+ if (!visible.isFile() || !sameFileIdentity(opened, visible)) {
47
+ throw new Error(`regular file target changed during read: ${path}`);
48
+ }
49
+ };
50
+ const regularFileBytes = async (path, maximumBytes) => {
51
+ const handle = await open(path, filesystemConstants.O_RDONLY
52
+ | filesystemConstants.O_NOFOLLOW
53
+ | filesystemConstants.O_NONBLOCK);
54
+ try {
55
+ const metadata = await handle.stat();
56
+ await assertStableRegularHandle(path, handle, metadata);
57
+ if (metadata.size > maximumBytes) {
58
+ throw new FileSizeLimitError({ path, maximumBytes });
59
+ }
60
+ const chunks = [];
61
+ let total = 0;
62
+ const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, Math.max(1, maximumBytes)));
63
+ while (true) {
64
+ const result = await handle.read(chunk, 0, chunk.byteLength, null);
65
+ if (result.bytesRead === 0)
66
+ break;
67
+ const bytes = Buffer.from(chunk.subarray(0, result.bytesRead));
68
+ total += bytes.byteLength;
69
+ if (total > maximumBytes) {
70
+ throw new FileSizeLimitError({ path, maximumBytes });
71
+ }
72
+ chunks.push(bytes);
73
+ }
74
+ await assertStableRegularHandle(path, handle, metadata);
75
+ return Buffer.concat(chunks, total);
76
+ }
77
+ finally {
78
+ await handle.close();
79
+ }
80
+ };
81
+ const regularFileDigest = async (path, maximumBytes) => {
82
+ const handle = await open(path, filesystemConstants.O_RDONLY
83
+ | filesystemConstants.O_NOFOLLOW
84
+ | filesystemConstants.O_NONBLOCK);
85
+ try {
86
+ const metadata = await handle.stat();
87
+ await assertStableRegularHandle(path, handle, metadata);
88
+ const hash = createHash("sha256");
89
+ const chunk = Buffer.allocUnsafe(64 * 1024);
90
+ let total = 0;
91
+ while (true) {
92
+ const result = await handle.read(chunk, 0, chunk.byteLength, null);
93
+ if (result.bytesRead === 0)
94
+ break;
95
+ const bytes = Buffer.from(chunk.subarray(0, result.bytesRead));
96
+ total += bytes.byteLength;
97
+ if (total > maximumBytes) {
98
+ throw new FileSizeLimitError({ path, maximumBytes });
99
+ }
100
+ hash.update(bytes);
101
+ }
102
+ await assertStableRegularHandle(path, handle, metadata);
103
+ return hash.digest("hex");
104
+ }
105
+ finally {
106
+ await handle.close();
107
+ }
108
+ };
109
+ const checkLinuxPath = (path) => {
110
+ if (path.platform !== "linux") {
111
+ return Effect.fail(new InvalidMachinePathError({
112
+ path: path.absolute,
113
+ message: `expected a Linux path, received ${path.platform}`,
114
+ }));
115
+ }
116
+ if (!isAbsolute(path.absolute) || path.absolute.includes("\0")) {
117
+ return Effect.fail(new InvalidMachinePathError({
118
+ path: path.absolute,
119
+ message: "a normalized absolute path without NUL bytes is required",
120
+ }));
121
+ }
122
+ return Effect.succeed(path.absolute);
123
+ };
124
+ const linuxPath = (absolute) => ({
125
+ platform: "linux",
126
+ absolute: normalize(absolute),
127
+ });
128
+ const sameFilesystemIdentity = (before, after) => before.dev === after.dev && before.ino === after.ino;
129
+ const isWithin = (root, candidate) => {
130
+ const remainder = relative(root, candidate);
131
+ return remainder === ""
132
+ || (!remainder.startsWith(`..${sep}`) && remainder !== ".." && !isAbsolute(remainder));
133
+ };
134
+ const environmentValue = (entries, name) => entries.find((entry) => entry.name === name)?.value;
135
+ const processEnvironmentEntries = () => Object.entries(process.env).flatMap(([name, value]) => value === undefined ? [] : [{ name, value }]);
136
+ const environmentObject = (base, additions, unset = [], unsetPrefixes = []) => {
137
+ const output = {};
138
+ const blocked = new Set(unset.map((name) => name.toLowerCase()));
139
+ const prefixes = unsetPrefixes.map((prefix) => prefix.toLowerCase());
140
+ for (const entry of base) {
141
+ const lower = entry.name.toLowerCase();
142
+ if (blocked.has(lower) || prefixes.some((prefix) => lower.startsWith(prefix)))
143
+ continue;
144
+ output[entry.name] = entry.value;
145
+ }
146
+ for (const entry of additions)
147
+ output[entry.name] = entry.value;
148
+ return output;
149
+ };
150
+ const runCredentialCommand = (executable, arguments_, environment, secret) => Effect.tryPromise({
151
+ try: (signal) => new Promise((resolveCommand, rejectCommand) => {
152
+ const child = spawn(executable, [...arguments_], {
153
+ env: environmentObject(environment, []),
154
+ shell: false,
155
+ stdio: ["pipe", "pipe", "pipe"],
156
+ });
157
+ const output = [];
158
+ let outputBytes = 0;
159
+ let failed = false;
160
+ const fail = () => {
161
+ if (failed)
162
+ return;
163
+ failed = true;
164
+ child.kill("SIGKILL");
165
+ };
166
+ child.stdout.on("data", (chunk) => {
167
+ outputBytes += chunk.byteLength;
168
+ if (outputBytes > 1024 * 1024) {
169
+ fail();
170
+ return;
171
+ }
172
+ output.push(chunk);
173
+ });
174
+ child.stderr.on("data", (chunk) => {
175
+ outputBytes += chunk.byteLength;
176
+ if (outputBytes > 1024 * 1024)
177
+ fail();
178
+ });
179
+ child.once("error", fail);
180
+ const timer = setTimeout(fail, 5_000);
181
+ const abort = () => {
182
+ child.kill("SIGKILL");
183
+ };
184
+ signal.addEventListener("abort", abort, { once: true });
185
+ child.once("close", (exitCode) => {
186
+ clearTimeout(timer);
187
+ signal.removeEventListener("abort", abort);
188
+ if (failed) {
189
+ rejectCommand(new CredentialCommandSignal());
190
+ return;
191
+ }
192
+ resolveCommand({
193
+ exitCode,
194
+ standardOutput: Buffer.concat(output),
195
+ });
196
+ });
197
+ if (secret === undefined) {
198
+ child.stdin.end();
199
+ }
200
+ else {
201
+ child.stdin.end(Redacted.value(secret));
202
+ }
203
+ }),
204
+ catch: () => new HumanActionRequiredError({
205
+ action: "unlock Linux credential storage",
206
+ recovery: "Start and unlock a Secret Service provider for this user session, then retry.",
207
+ }),
208
+ });
209
+ const makeTemporarySibling = (path) => join(dirname(path), `.${basename(path)}.canonfig-${randomBytes(12).toString("hex")}`);
210
+ const syncHandle = (handle) => handle.sync().then(() => undefined,
211
+ // Windows raises EPERM for fsync on some filesystems and directory
212
+ // handles; durability there relies on the rename, not the flush.
213
+ (cause) => {
214
+ if (process.platform === "win32"
215
+ && (cause.code === "EPERM" || cause.code === "EINVAL")) {
216
+ return undefined;
217
+ }
218
+ throw cause;
219
+ });
220
+ const atomicWriteFile = (path, content, mode) => promiseEffect("atomically write file", path, async () => {
221
+ await mkdir(dirname(path), { recursive: true, mode: defaultDirectoryMode });
222
+ const temporary = makeTemporarySibling(path);
223
+ let handle;
224
+ try {
225
+ handle = await open(temporary, "wx", mode);
226
+ await handle.writeFile(content);
227
+ await syncHandle(handle);
228
+ await handle.chmod(mode);
229
+ await handle.close();
230
+ handle = undefined;
231
+ await rename(temporary, path);
232
+ const directory = await open(dirname(path), "r");
233
+ try {
234
+ await syncHandle(directory);
235
+ }
236
+ finally {
237
+ await directory.close();
238
+ }
239
+ }
240
+ finally {
241
+ if (handle !== undefined)
242
+ await handle.close().catch(() => undefined);
243
+ await rm(temporary, { force: true }).catch(() => undefined);
244
+ }
245
+ });
246
+ const descriptorPath = (handle) => `/proc/self/fd/${handle.fd}`;
247
+ const portableSafeRootMutation = async (root, path, input, symlinkTarget, beforeMutation) => {
248
+ const rootHandle = await open(root, filesystemConstants.O_RDONLY
249
+ | filesystemConstants.O_DIRECTORY
250
+ | filesystemConstants.O_NOFOLLOW);
251
+ const rootIdentity = await rootHandle.stat();
252
+ const guard = join(dirname(root), `.${basename(root)}.canonfig-guard-${randomBytes(12).toString("hex")}`);
253
+ const heldRoot = join(guard, basename(root));
254
+ let held = false;
255
+ try {
256
+ await beforeMutation?.();
257
+ const visibleRoot = await lstat(root);
258
+ if (visibleRoot.isSymbolicLink()
259
+ || !sameFilesystemIdentity(rootIdentity, visibleRoot)) {
260
+ throw new Error("managed root identity changed before mutation");
261
+ }
262
+ await mkdir(guard, { mode: defaultDirectoryMode });
263
+ await rename(root, heldRoot);
264
+ held = true;
265
+ const isolatedRoot = await lstat(heldRoot);
266
+ if (isolatedRoot.isSymbolicLink()
267
+ || !sameFilesystemIdentity(rootIdentity, isolatedRoot)) {
268
+ throw new Error("managed root identity changed while isolating mutation");
269
+ }
270
+ const parts = relative(root, path).split(sep);
271
+ let parent = heldRoot;
272
+ for (const part of parts.slice(0, -1)) {
273
+ const candidate = join(parent, part);
274
+ try {
275
+ const ancestor = await lstat(candidate);
276
+ if (ancestor.isSymbolicLink() || !ancestor.isDirectory()) {
277
+ throw new Error(`managed ancestor is not a directory: ${candidate}`);
278
+ }
279
+ }
280
+ catch (cause) {
281
+ if (errorCode(cause) !== "ENOENT" || input.mutation.kind === "remove") {
282
+ if (errorCode(cause) === "ENOENT" && input.mutation.kind === "remove") {
283
+ return;
284
+ }
285
+ throw cause;
286
+ }
287
+ await mkdir(candidate, { mode: defaultDirectoryMode });
288
+ }
289
+ parent = candidate;
290
+ }
291
+ const name = parts.at(-1);
292
+ const target = join(parent, name);
293
+ if (input.mutation.kind === "remove") {
294
+ await rm(target, { force: true });
295
+ return;
296
+ }
297
+ const temporary = join(parent, `.${name}.canonfig-${randomBytes(12).toString("hex")}`);
298
+ try {
299
+ if (input.mutation.kind === "symlink") {
300
+ await symlink(symlinkTarget, temporary);
301
+ }
302
+ else {
303
+ const mode = input.mutation.mode ?? defaultFileMode;
304
+ const temporaryHandle = await open(temporary, "wx", mode);
305
+ try {
306
+ await temporaryHandle.writeFile(input.mutation.content);
307
+ await syncHandle(temporaryHandle);
308
+ await temporaryHandle.chmod(mode);
309
+ }
310
+ finally {
311
+ await temporaryHandle.close();
312
+ }
313
+ }
314
+ await rename(temporary, target);
315
+ const parentHandle = await open(parent, filesystemConstants.O_RDONLY
316
+ | filesystemConstants.O_DIRECTORY
317
+ | filesystemConstants.O_NOFOLLOW);
318
+ try {
319
+ await syncHandle(parentHandle);
320
+ }
321
+ finally {
322
+ await parentHandle.close();
323
+ }
324
+ }
325
+ finally {
326
+ await unlink(temporary).catch(() => undefined);
327
+ }
328
+ }
329
+ finally {
330
+ await rootHandle.close().catch(() => undefined);
331
+ if (held) {
332
+ await rename(heldRoot, root);
333
+ held = false;
334
+ }
335
+ if (!held) {
336
+ await rm(guard, { recursive: true, force: true }).catch(() => undefined);
337
+ }
338
+ }
339
+ };
340
+ const safeRootMutation = (input, beforeMutation, strategy = process.platform === "darwin"
341
+ ? "portable"
342
+ : "descriptor") => Effect.gen(function* () {
343
+ const root = yield* checkLinuxPath(input.root);
344
+ const path = yield* checkLinuxPath(input.path);
345
+ if (!isWithin(root, path) || path === root) {
346
+ return yield* new MachineFilesystemError({
347
+ operation: "mutate managed path",
348
+ path,
349
+ message: `path is not a descendant of managed root ${root}`,
350
+ });
351
+ }
352
+ const symlinkTarget = input.mutation.kind === "symlink"
353
+ ? yield* checkLinuxPath(input.mutation.target)
354
+ : undefined;
355
+ const remainder = relative(root, path);
356
+ const parts = remainder.split(sep);
357
+ yield* promiseEffect("mutate managed path", path, async () => {
358
+ if (strategy === "portable") {
359
+ await portableSafeRootMutation(root, path, input, symlinkTarget, beforeMutation);
360
+ return;
361
+ }
362
+ const handles = [];
363
+ try {
364
+ const rootHandle = await open(root, filesystemConstants.O_RDONLY
365
+ | filesystemConstants.O_DIRECTORY
366
+ | filesystemConstants.O_NOFOLLOW);
367
+ handles.push(rootHandle);
368
+ const rootIdentity = await rootHandle.stat();
369
+ await beforeMutation?.();
370
+ const visibleRoot = await lstat(root);
371
+ if (visibleRoot.isSymbolicLink()
372
+ || !sameFilesystemIdentity(rootIdentity, visibleRoot)) {
373
+ throw new Error("managed root identity changed before mutation");
374
+ }
375
+ let parent = rootHandle;
376
+ for (const part of parts.slice(0, -1)) {
377
+ const candidate = join(descriptorPath(parent), part);
378
+ let child;
379
+ try {
380
+ child = await open(candidate, filesystemConstants.O_RDONLY
381
+ | filesystemConstants.O_DIRECTORY
382
+ | filesystemConstants.O_NOFOLLOW);
383
+ }
384
+ catch (cause) {
385
+ if (errorCode(cause) !== "ENOENT" || input.mutation.kind === "remove") {
386
+ if (errorCode(cause) === "ENOENT" && input.mutation.kind === "remove") {
387
+ return;
388
+ }
389
+ throw cause;
390
+ }
391
+ await mkdir(candidate, { mode: defaultDirectoryMode });
392
+ child = await open(candidate, filesystemConstants.O_RDONLY
393
+ | filesystemConstants.O_DIRECTORY
394
+ | filesystemConstants.O_NOFOLLOW);
395
+ }
396
+ handles.push(child);
397
+ parent = child;
398
+ }
399
+ const name = parts.at(-1);
400
+ const target = join(descriptorPath(parent), name);
401
+ if (input.mutation.kind === "remove") {
402
+ await rm(target, { force: true });
403
+ await syncHandle(parent);
404
+ return;
405
+ }
406
+ const temporary = join(descriptorPath(parent), `.${name}.canonfig-${randomBytes(12).toString("hex")}`);
407
+ try {
408
+ if (input.mutation.kind === "symlink") {
409
+ await symlink(symlinkTarget, temporary);
410
+ }
411
+ else {
412
+ const mode = input.mutation.mode ?? defaultFileMode;
413
+ const temporaryHandle = await open(temporary, "wx", mode);
414
+ try {
415
+ await temporaryHandle.writeFile(input.mutation.content);
416
+ await syncHandle(temporaryHandle);
417
+ await temporaryHandle.chmod(mode);
418
+ }
419
+ finally {
420
+ await temporaryHandle.close();
421
+ }
422
+ }
423
+ await rename(temporary, target);
424
+ await syncHandle(parent);
425
+ }
426
+ finally {
427
+ await unlink(temporary).catch(() => undefined);
428
+ }
429
+ }
430
+ finally {
431
+ for (const handle of handles.reverse()) {
432
+ await handle.close().catch(() => undefined);
433
+ }
434
+ }
435
+ });
436
+ });
437
+ const normalizedInputPath = (input, home) => {
438
+ if (input.path.length === 0 || input.path.includes("\0")) {
439
+ return Effect.fail(new InvalidMachinePathError({
440
+ path: input.path,
441
+ message: "path must not be empty or contain NUL bytes",
442
+ }));
443
+ }
444
+ const expanded = input.path === "~"
445
+ ? home
446
+ : input.path.startsWith("~/")
447
+ ? join(home, input.path.slice(2))
448
+ : input.path;
449
+ if (isAbsolute(expanded))
450
+ return Effect.succeed(linuxPath(resolve(expanded)));
451
+ if (input.base !== undefined && input.base.platform !== "linux") {
452
+ return Effect.fail(new InvalidMachinePathError({
453
+ path: input.path,
454
+ message: `relative Linux paths cannot use a ${input.base.platform} base`,
455
+ }));
456
+ }
457
+ return Effect.succeed(linuxPath(resolve(input.base?.absolute ?? process.cwd(), expanded)));
458
+ };
459
+ const readBounded = (input) => Effect.gen(function* () {
460
+ const path = yield* checkLinuxPath(input.path);
461
+ if (!Number.isSafeInteger(input.maximumBytes) || input.maximumBytes < 0) {
462
+ return yield* new FileSizeLimitError({
463
+ path,
464
+ maximumBytes: input.maximumBytes,
465
+ });
466
+ }
467
+ return yield* Effect.tryPromise({
468
+ try: () => regularFileBytes(path, input.maximumBytes),
469
+ catch: (cause) => cause instanceof FileSizeLimitError
470
+ ? cause
471
+ : filesystemError("read file", path)(cause),
472
+ });
473
+ });
474
+ const digest = (input) => Effect.gen(function* () {
475
+ const path = yield* checkLinuxPath(input.path);
476
+ const maximumBytes = input.maximumBytes ?? Number.MAX_SAFE_INTEGER;
477
+ const value = yield* Effect.tryPromise({
478
+ try: () => regularFileDigest(path, maximumBytes),
479
+ catch: (cause) => cause instanceof FileSizeLimitError
480
+ ? cause
481
+ : filesystemError("digest file", path)(cause),
482
+ });
483
+ return {
484
+ algorithm: "sha256",
485
+ value: decode(ContentDigest)(value),
486
+ };
487
+ });
488
+ const runBoundedProcess = (invocation, baseEnvironment) => Effect.gen(function* () {
489
+ const executable = yield* checkLinuxPath(invocation.executable);
490
+ const workingDirectory = invocation.workingDirectory === undefined
491
+ ? undefined
492
+ : yield* checkLinuxPath(invocation.workingDirectory);
493
+ if (!Number.isSafeInteger(invocation.timeoutMilliseconds)
494
+ || invocation.timeoutMilliseconds <= 0) {
495
+ return yield* new ProcessTimeoutError({
496
+ executable,
497
+ timeoutMilliseconds: invocation.timeoutMilliseconds,
498
+ });
499
+ }
500
+ if (!Number.isSafeInteger(invocation.maximumOutputBytes)
501
+ || invocation.maximumOutputBytes < 0) {
502
+ return yield* new ProcessOutputLimitError({
503
+ executable,
504
+ maximumOutputBytes: invocation.maximumOutputBytes,
505
+ });
506
+ }
507
+ return yield* Effect.tryPromise({
508
+ try: (signal) => new Promise((resolveProcess, rejectProcess) => {
509
+ const output = [];
510
+ const errors = [];
511
+ let outputBytes = 0;
512
+ let failure;
513
+ const child = spawn(executable, [...invocation.arguments], {
514
+ cwd: workingDirectory,
515
+ env: environmentObject(baseEnvironment, invocation.environment ?? [], invocation.environmentUnset ?? [], invocation.environmentUnsetPrefixes ?? []),
516
+ shell: false,
517
+ stdio: ["ignore", "pipe", "pipe"],
518
+ });
519
+ const terminate = (reason) => {
520
+ if (failure !== undefined)
521
+ return;
522
+ failure = reason;
523
+ child.kill("SIGKILL");
524
+ };
525
+ const capture = (target) => (chunk) => {
526
+ outputBytes += chunk.byteLength;
527
+ if (outputBytes > invocation.maximumOutputBytes) {
528
+ terminate(new ProcessOutputLimitSignal());
529
+ return;
530
+ }
531
+ target.push(chunk);
532
+ };
533
+ child.stdout.on("data", capture(output));
534
+ child.stderr.on("data", capture(errors));
535
+ child.once("error", (cause) => {
536
+ failure = new ProcessStartSignal(messageOf(cause));
537
+ });
538
+ const timer = setTimeout(() => terminate(new ProcessTimeoutSignal()), invocation.timeoutMilliseconds);
539
+ const abort = () => {
540
+ child.kill("SIGKILL");
541
+ };
542
+ signal.addEventListener("abort", abort, { once: true });
543
+ child.once("close", (exitCode, childSignal) => {
544
+ clearTimeout(timer);
545
+ signal.removeEventListener("abort", abort);
546
+ if (failure !== undefined) {
547
+ rejectProcess(failure);
548
+ return;
549
+ }
550
+ resolveProcess({
551
+ exitCode,
552
+ signal: childSignal,
553
+ standardOutput: Buffer.concat(output),
554
+ standardError: Buffer.concat(errors),
555
+ });
556
+ });
557
+ }),
558
+ catch: (cause) => {
559
+ if (cause instanceof ProcessTimeoutSignal) {
560
+ return new ProcessTimeoutError({
561
+ executable,
562
+ timeoutMilliseconds: invocation.timeoutMilliseconds,
563
+ });
564
+ }
565
+ if (cause instanceof ProcessOutputLimitSignal) {
566
+ return new ProcessOutputLimitError({
567
+ executable,
568
+ maximumOutputBytes: invocation.maximumOutputBytes,
569
+ });
570
+ }
571
+ return new ProcessStartError({
572
+ executable,
573
+ message: messageOf(cause),
574
+ });
575
+ },
576
+ });
577
+ });
578
+ const schedulerExpression = (calendar) => {
579
+ const validTime = /^([01]\d|2[0-3]):[0-5]\d$/u;
580
+ const timezoneSuffix = () => {
581
+ if (calendar.timezone === undefined)
582
+ return Effect.succeed("");
583
+ if (calendar.timezone.trim() !== calendar.timezone
584
+ || calendar.timezone.length === 0
585
+ || /[\n\r\0]/u.test(calendar.timezone)) {
586
+ return Effect.fail(new InvalidSchedulerJobError({
587
+ field: "calendar.timezone",
588
+ message: "systemd calendar timezone must be a non-empty single-line name",
589
+ }));
590
+ }
591
+ try {
592
+ new Intl.DateTimeFormat("en-US", { timeZone: calendar.timezone }).format();
593
+ }
594
+ catch {
595
+ return Effect.fail(new InvalidSchedulerJobError({
596
+ field: "calendar.timezone",
597
+ message: `unsupported systemd calendar timezone: ${calendar.timezone}`,
598
+ }));
599
+ }
600
+ if (calendar.kind === "systemd-on-calendar"
601
+ && (calendar.expression.startsWith("@")
602
+ || /(?:^|\s)(?:UTC|GMT|[A-Za-z0-9._+-]+\/[A-Za-z0-9._+-]+)\s*$/u.test(calendar.expression))) {
603
+ return Effect.fail(new InvalidSchedulerJobError({
604
+ field: "calendar.timezone",
605
+ message: "custom systemd expressions with shortcuts or an existing timezone cannot add a named timezone",
606
+ }));
607
+ }
608
+ return Effect.succeed(` ${calendar.timezone}`);
609
+ };
610
+ if (calendar.kind === "systemd-on-calendar") {
611
+ if (calendar.expression.trim().length === 0
612
+ || /[\n\r\0]/u.test(calendar.expression)) {
613
+ return Effect.fail(new InvalidSchedulerJobError({
614
+ field: "calendar.expression",
615
+ message: "systemd calendar expression must be non-empty and single-line",
616
+ }));
617
+ }
618
+ return timezoneSuffix().pipe(Effect.map((timezone) => `${calendar.expression}${timezone}`));
619
+ }
620
+ if (!validTime.test(calendar.localTime)) {
621
+ return Effect.fail(new InvalidSchedulerJobError({
622
+ field: "calendar.localTime",
623
+ message: "local time must use 24-hour HH:mm format",
624
+ }));
625
+ }
626
+ const prefix = calendar.kind === "daily"
627
+ ? "*-*-*"
628
+ : `${calendar.weekdays.join(",")} *-*-*`;
629
+ return timezoneSuffix().pipe(Effect.map((timezone) => `${prefix} ${calendar.localTime}:00${timezone}`));
630
+ };
631
+ const systemdQuote = (value, field) => {
632
+ if (/[\n\r\0]/u.test(value)) {
633
+ return Effect.fail(new InvalidSchedulerJobError({
634
+ field,
635
+ message: "systemd command values must be single-line and contain no NUL bytes",
636
+ }));
637
+ }
638
+ return Effect.succeed(`"${value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"")}"`);
639
+ };
640
+ const renderSystemdJob = (job) => Effect.gen(function* () {
641
+ if (!/^[A-Za-z0-9][A-Za-z0-9_.@-]*$/u.test(job.name)) {
642
+ return yield* new InvalidSchedulerJobError({
643
+ field: "name",
644
+ message: "job name must be a portable systemd unit stem",
645
+ });
646
+ }
647
+ if (job.description.trim().length === 0 || /[\n\r\0]/u.test(job.description)) {
648
+ return yield* new InvalidSchedulerJobError({
649
+ field: "description",
650
+ message: "description must be non-empty and single-line",
651
+ });
652
+ }
653
+ const executable = yield* checkLinuxPath(job.executable);
654
+ const command = [
655
+ yield* systemdQuote(executable, "executable"),
656
+ ...yield* Effect.forEach(job.arguments, (argument, index) => systemdQuote(argument, `arguments[${index}]`)),
657
+ ].join(" ");
658
+ const calendar = yield* schedulerExpression(job.calendar);
659
+ const serviceName = `${job.name}.service`;
660
+ return {
661
+ platform: "linux",
662
+ mechanism: "systemd-user-timer",
663
+ serviceName,
664
+ service: [
665
+ "[Unit]",
666
+ `Description=${job.description}`,
667
+ "",
668
+ "[Service]",
669
+ "Type=oneshot",
670
+ `ExecStart=${command}`,
671
+ "",
672
+ ].join("\n"),
673
+ schedule: [
674
+ "[Unit]",
675
+ `Description=${job.description} schedule`,
676
+ "",
677
+ "[Timer]",
678
+ `OnCalendar=${calendar}`,
679
+ "Persistent=true",
680
+ `Unit=${serviceName}`,
681
+ "",
682
+ "[Install]",
683
+ "WantedBy=timers.target",
684
+ "",
685
+ ].join("\n"),
686
+ };
687
+ });
688
+ const systemdBackend = (home, environment) => {
689
+ const unitDirectory = join(home, ".config", "systemd", "user");
690
+ const systemctl = environmentValue(environment, "CANONFIG_SYSTEMCTL")
691
+ ?? "/usr/bin/systemctl";
692
+ const paths = (definition) => {
693
+ const timerName = definition.serviceName.endsWith(".service")
694
+ ? `${definition.serviceName.slice(0, -".service".length)}.timer`
695
+ : `${definition.serviceName}.timer`;
696
+ return {
697
+ service: join(unitDirectory, definition.serviceName),
698
+ timer: join(unitDirectory, timerName),
699
+ timerName,
700
+ };
701
+ };
702
+ const runSystemctl = (arguments_) => runBoundedProcess({
703
+ executable: linuxPath(systemctl),
704
+ arguments: ["--user", ...arguments_],
705
+ timeoutMilliseconds: 10_000,
706
+ maximumOutputBytes: 1024 * 1024,
707
+ }, environment);
708
+ const requireSuccess = (arguments_, action) => runSystemctl(arguments_).pipe(Effect.flatMap((result) => result.exitCode === 0
709
+ ? Effect.void
710
+ : Effect.fail(new HumanActionRequiredError({
711
+ action,
712
+ recovery: "Ensure the systemd user manager is running for this follower, then retry.",
713
+ }))));
714
+ const queryTimerState = (timerName, operation) => runSystemctl([operation, timerName]).pipe(Effect.flatMap((result) => {
715
+ const value = Buffer.from(result.standardOutput)
716
+ .toString("utf8")
717
+ .trim()
718
+ .toLowerCase();
719
+ const positive = operation === "is-enabled" ? "enabled" : "active";
720
+ const negative = operation === "is-enabled" ? "disabled" : "inactive";
721
+ if (value === positive && result.exitCode === 0)
722
+ return Effect.succeed(true);
723
+ // systemctl uses a non-zero exit status for these normal negative
724
+ // states. Accept only the explicit semantic state, never an arbitrary
725
+ // query failure or permission error.
726
+ if (value === negative)
727
+ return Effect.succeed(false);
728
+ return Effect.fail(new HumanActionRequiredError({
729
+ action: `inspect the systemd user timer (${operation})`,
730
+ recovery: "The systemd user manager returned an indeterminate scheduler state; ensure it is running and retry.",
731
+ }));
732
+ }));
733
+ const readUnit = (path) => Effect.tryPromise({
734
+ try: async () => {
735
+ try {
736
+ const [content, metadata] = await Promise.all([
737
+ readFile(path, "utf8"),
738
+ lstat(path),
739
+ ]);
740
+ return { content, mode: metadata.mode & 0o777 };
741
+ }
742
+ catch (cause) {
743
+ if (errorCode(cause) === "ENOENT")
744
+ return undefined;
745
+ throw cause;
746
+ }
747
+ },
748
+ catch: filesystemError("snapshot systemd user schedule", path),
749
+ });
750
+ return {
751
+ inspect: (expected) => Effect.gen(function* () {
752
+ const path = paths(expected);
753
+ const installed = yield* Effect.tryPromise({
754
+ try: async () => {
755
+ try {
756
+ const [service, timer] = await Promise.all([
757
+ readFile(path.service, "utf8"),
758
+ readFile(path.timer, "utf8"),
759
+ ]);
760
+ return {
761
+ installed: true,
762
+ matches: service === expected.service && timer === expected.schedule,
763
+ };
764
+ }
765
+ catch (cause) {
766
+ const code = cause instanceof Error && "code" in cause
767
+ ? String(cause.code)
768
+ : "";
769
+ if (code === "ENOENT")
770
+ return { installed: false, matches: false };
771
+ throw cause;
772
+ }
773
+ },
774
+ catch: filesystemError("inspect systemd user schedule", unitDirectory),
775
+ });
776
+ if (!installed.installed) {
777
+ return { installed: false, enabled: false, matches: false };
778
+ }
779
+ const enabled = yield* queryTimerState(path.timerName, "is-enabled");
780
+ return { ...installed, enabled };
781
+ }),
782
+ snapshot: (expected) => Effect.gen(function* () {
783
+ const path = paths(expected);
784
+ const service = yield* readUnit(path.service);
785
+ const timer = yield* readUnit(path.timer);
786
+ if (service === undefined && timer === undefined) {
787
+ return {
788
+ state: "absent",
789
+ platform: expected.platform,
790
+ mechanism: expected.mechanism,
791
+ serviceName: expected.serviceName,
792
+ };
793
+ }
794
+ const enabled = timer === undefined
795
+ ? false
796
+ : yield* queryTimerState(path.timerName, "is-enabled");
797
+ const active = timer === undefined
798
+ ? false
799
+ : yield* queryTimerState(path.timerName, "is-active");
800
+ return {
801
+ state: "present",
802
+ platform: expected.platform,
803
+ mechanism: expected.mechanism,
804
+ serviceName: expected.serviceName,
805
+ enabled,
806
+ active,
807
+ servicePresent: service !== undefined,
808
+ schedulePresent: timer !== undefined,
809
+ service: service?.content,
810
+ schedule: timer?.content,
811
+ serviceMode: service?.mode,
812
+ scheduleMode: timer?.mode,
813
+ };
814
+ }),
815
+ install: (definition) => Effect.gen(function* () {
816
+ const path = paths(definition);
817
+ yield* atomicWriteFile(path.service, new TextEncoder().encode(definition.service), 0o600);
818
+ yield* atomicWriteFile(path.timer, new TextEncoder().encode(definition.schedule), 0o600);
819
+ yield* requireSuccess(["daemon-reload"], "reload the systemd user manager");
820
+ yield* requireSuccess(["enable", "--now", path.timerName], "enable the Canonfig systemd user timer");
821
+ }),
822
+ remove: (definition) => Effect.gen(function* () {
823
+ const path = paths(definition);
824
+ yield* runSystemctl(["disable", "--now", path.timerName]).pipe(Effect.ignore);
825
+ yield* promiseEffect("remove systemd service", path.service, () => rm(path.service, { force: true }));
826
+ yield* promiseEffect("remove systemd timer", path.timer, () => rm(path.timer, { force: true }));
827
+ yield* requireSuccess(["daemon-reload"], "reload the systemd user manager");
828
+ }),
829
+ restore: (expected, snapshot) => Effect.gen(function* () {
830
+ const path = paths(expected);
831
+ yield* runSystemctl(["disable", "--now", path.timerName]).pipe(Effect.ignore);
832
+ if (snapshot.state === "absent") {
833
+ yield* promiseEffect("remove systemd service", path.service, () => rm(path.service, { force: true }));
834
+ yield* promiseEffect("remove systemd timer", path.timer, () => rm(path.timer, { force: true }));
835
+ yield* requireSuccess(["daemon-reload"], "reload the systemd user manager");
836
+ return;
837
+ }
838
+ if (snapshot.servicePresent) {
839
+ if (snapshot.service === undefined) {
840
+ return yield* new HumanActionRequiredError({
841
+ action: "restore the Canonfig systemd service",
842
+ recovery: "The captured systemd service contents were incomplete; inspect the user unit manually.",
843
+ });
844
+ }
845
+ yield* atomicWriteFile(path.service, new TextEncoder().encode(snapshot.service), snapshot.serviceMode ?? defaultFileMode);
846
+ }
847
+ else {
848
+ yield* promiseEffect("remove systemd service", path.service, () => rm(path.service, { force: true }));
849
+ }
850
+ if (snapshot.schedulePresent) {
851
+ if (snapshot.schedule === undefined) {
852
+ return yield* new HumanActionRequiredError({
853
+ action: "restore the Canonfig systemd timer",
854
+ recovery: "The captured systemd timer contents were incomplete; inspect the user unit manually.",
855
+ });
856
+ }
857
+ yield* atomicWriteFile(path.timer, new TextEncoder().encode(snapshot.schedule), snapshot.scheduleMode ?? defaultFileMode);
858
+ }
859
+ else {
860
+ yield* promiseEffect("remove systemd timer", path.timer, () => rm(path.timer, { force: true }));
861
+ }
862
+ yield* requireSuccess(["daemon-reload"], "reload the systemd user manager");
863
+ if (!snapshot.schedulePresent)
864
+ return;
865
+ yield* requireSuccess([snapshot.enabled ? "enable" : "disable", path.timerName], snapshot.enabled
866
+ ? "enable the restored Canonfig systemd user timer"
867
+ : "disable the restored Canonfig systemd user timer");
868
+ const active = snapshot.active ?? snapshot.enabled;
869
+ yield* requireSuccess([active ? "start" : "stop", path.timerName], active
870
+ ? "start the restored Canonfig systemd user timer"
871
+ : "stop the restored Canonfig systemd user timer");
872
+ }),
873
+ };
874
+ };
875
+ const localCredentialPath = (reference, root) => {
876
+ const prefix = "local-file:";
877
+ const value = String(reference);
878
+ if (!value.startsWith(prefix)) {
879
+ return Effect.fail(new CredentialStorageError({
880
+ operation: "resolve credential reference",
881
+ reference: value,
882
+ message: "credential reference is not owned by the local-file provider",
883
+ }));
884
+ }
885
+ const path = resolve(value.slice(prefix.length));
886
+ if (dirname(path) !== root) {
887
+ return Effect.fail(new CredentialStorageError({
888
+ operation: "resolve credential reference",
889
+ reference: value,
890
+ message: "credential reference is outside the configured credential directory",
891
+ }));
892
+ }
893
+ return Effect.succeed(path);
894
+ };
895
+ const discoverSecretTool = (environment) => Effect.promise(async () => {
896
+ const directories = (environmentValue(environment, "PATH") ?? "")
897
+ .split(":")
898
+ .filter((entry) => entry.length > 0);
899
+ for (const directory of directories) {
900
+ const candidate = join(directory, "secret-tool");
901
+ try {
902
+ await access(candidate, filesystemConstants.X_OK);
903
+ return candidate;
904
+ }
905
+ catch {
906
+ // Continue through the declared executable search path.
907
+ }
908
+ }
909
+ return undefined;
910
+ });
911
+ const secretServiceKey = (reference) => {
912
+ const prefix = "secret-service:";
913
+ const value = String(reference);
914
+ if (!value.startsWith(prefix) || value.length === prefix.length) {
915
+ return Effect.fail(new CredentialStorageError({
916
+ operation: "resolve credential reference",
917
+ reference: value,
918
+ message: "credential reference is not owned by the Secret Service provider",
919
+ }));
920
+ }
921
+ return Effect.succeed(value.slice(prefix.length));
922
+ };
923
+ export const linuxMachineStateLayer = (options = {}) => Layer.sync(MachineState, () => {
924
+ const environment = options.environment ?? processEnvironmentEntries();
925
+ const home = environmentValue(environment, "HOME") ?? homedir();
926
+ const credentialPolicy = options.credentialPolicy ?? { kind: "secure-store" };
927
+ const scheduler = options.schedulerBackend ?? systemdBackend(home, environment);
928
+ const localCredentialRoot = credentialPolicy.kind === "local-file"
929
+ ? resolve(credentialPolicy.path)
930
+ : undefined;
931
+ const secretServiceSession = credentialPolicy.kind === "secure-store"
932
+ && environmentValue(environment, "DBUS_SESSION_BUS_ADDRESS") !== undefined;
933
+ const normalizePath = Effect.fn("MachineState.normalizePath")(function* (input) {
934
+ return yield* normalizedInputPath(input, home);
935
+ });
936
+ const userDirectories = Effect.fn("MachineState.userDirectories")(() => Effect.succeed({
937
+ home: linuxPath(home),
938
+ config: linuxPath(environmentValue(environment, "XDG_CONFIG_HOME") ?? join(home, ".config")),
939
+ data: linuxPath(environmentValue(environment, "XDG_DATA_HOME") ?? join(home, ".local", "share")),
940
+ cache: linuxPath(environmentValue(environment, "XDG_CACHE_HOME") ?? join(home, ".cache")),
941
+ }));
942
+ const ensureDirectory = Effect.fn("MachineState.ensureDirectory")(function* (input) {
943
+ const path = yield* checkLinuxPath(input.path);
944
+ yield* promiseEffect("ensure directory", path, () => mkdir(path, { recursive: true, mode: input.mode ?? defaultDirectoryMode }).then(() => undefined));
945
+ });
946
+ const atomicWrite = Effect.fn("MachineState.atomicWrite")(function* (input) {
947
+ const path = yield* checkLinuxPath(input.path);
948
+ yield* atomicWriteFile(path, input.content, input.mode ?? defaultFileMode);
949
+ });
950
+ const replaceSymlink = Effect.fn("MachineState.replaceSymlink")(function* (input) {
951
+ const path = yield* checkLinuxPath(input.path);
952
+ const target = yield* checkLinuxPath(input.target);
953
+ yield* promiseEffect("replace symlink", path, async () => {
954
+ await mkdir(dirname(path), { recursive: true, mode: defaultDirectoryMode });
955
+ const temporary = makeTemporarySibling(path);
956
+ try {
957
+ await symlink(target, temporary);
958
+ await rename(temporary, path);
959
+ }
960
+ finally {
961
+ await unlink(temporary).catch(() => undefined);
962
+ }
963
+ });
964
+ });
965
+ const removeFile = Effect.fn("MachineState.removeFile")(function* (input) {
966
+ const path = yield* checkLinuxPath(input.path);
967
+ yield* promiseEffect("remove file", path, () => rm(path, { force: true }));
968
+ });
969
+ const removeEmptyDirectory = Effect.fn("MachineState.removeEmptyDirectory")(function* (input) {
970
+ const path = yield* checkLinuxPath(input.path);
971
+ yield* promiseEffect("remove empty directory", path, () => rmdir(path));
972
+ });
973
+ const validatePathWithinRoot = Effect.fn("MachineState.validatePathWithinRoot")(function* (input) {
974
+ const root = yield* checkLinuxPath(input.root);
975
+ const path = yield* checkLinuxPath(input.path);
976
+ if (!isWithin(root, path) || path === root) {
977
+ return yield* new MachineFilesystemError({
978
+ operation: "validate managed path containment",
979
+ path,
980
+ message: `path is not a descendant of managed root ${root}`,
981
+ });
982
+ }
983
+ yield* promiseEffect("validate managed path containment", path, async () => {
984
+ const rootBefore = await lstat(root);
985
+ const actualRoot = await realpath(root);
986
+ const rootAfter = await lstat(root);
987
+ if (!sameFilesystemIdentity(rootBefore, rootAfter)) {
988
+ throw new Error("managed root identity changed during validation");
989
+ }
990
+ const ancestors = [];
991
+ for (let ancestor = dirname(path);; ancestor = dirname(ancestor)) {
992
+ ancestors.push(ancestor);
993
+ if (ancestor === root)
994
+ break;
995
+ if (ancestor === dirname(ancestor)) {
996
+ throw new Error(`managed path ancestry did not reach root ${root}`);
997
+ }
998
+ }
999
+ ancestors.reverse();
1000
+ for (const ancestor of ancestors) {
1001
+ let before;
1002
+ try {
1003
+ before = await lstat(ancestor);
1004
+ }
1005
+ catch (cause) {
1006
+ if (errorCode(cause) === "ENOENT")
1007
+ break;
1008
+ throw cause;
1009
+ }
1010
+ const actualAncestor = await realpath(ancestor);
1011
+ const after = await lstat(ancestor);
1012
+ if (!sameFilesystemIdentity(before, after)) {
1013
+ throw new Error(`ancestor identity changed during validation: ${ancestor}`);
1014
+ }
1015
+ if (!isWithin(actualRoot, actualAncestor)) {
1016
+ throw new Error(`ancestor resolves outside managed root ${root}: ${ancestor}`);
1017
+ }
1018
+ }
1019
+ });
1020
+ });
1021
+ const mutateWithinRoot = Effect.fn("MachineState.mutateWithinRoot")(function* (input) {
1022
+ yield* safeRootMutation(input, options.beforeSafeRootMutation, options.safeRootMutationStrategy);
1023
+ });
1024
+ const readSymlink = Effect.fn("MachineState.readSymlink")(function* (machinePath) {
1025
+ const path = yield* checkLinuxPath(machinePath);
1026
+ const target = yield* promiseEffect("read symlink", path, () => readlink(path));
1027
+ return linuxPath(resolve(dirname(path), target));
1028
+ });
1029
+ const inspectPath = Effect.fn("MachineState.inspectPath")(function* (machinePath) {
1030
+ const path = yield* checkLinuxPath(machinePath);
1031
+ const metadata = yield* promiseEffect("inspect path", path, () => lstat(path));
1032
+ return { kind: objectKind(metadata) };
1033
+ });
1034
+ const setPermissions = Effect.fn("MachineState.setPermissions")(function* (input) {
1035
+ const path = yield* checkLinuxPath(input.path);
1036
+ yield* promiseEffect("set permissions", path, () => chmod(path, input.mode));
1037
+ });
1038
+ const permissions = Effect.fn("MachineState.permissions")(function* (machinePath) {
1039
+ const path = yield* checkLinuxPath(machinePath);
1040
+ const metadata = yield* promiseEffect("read permissions", path, () => lstat(path));
1041
+ const mode = metadata.mode & 0o7777;
1042
+ return { mode, executableByOwner: (mode & 0o100) !== 0 };
1043
+ });
1044
+ const findExecutable = Effect.fn("MachineState.findExecutable")(function* (query) {
1045
+ if (query.name.length === 0
1046
+ || query.name.includes("/")
1047
+ || query.name.includes("\0")) {
1048
+ return yield* new ExecutableNotFoundError({ name: query.name });
1049
+ }
1050
+ const search = query.searchPath === undefined
1051
+ ? (environmentValue(environment, "PATH") ?? "").split(":")
1052
+ .filter((entry) => entry.length > 0)
1053
+ .map(linuxPath)
1054
+ : query.searchPath;
1055
+ for (const directory of search) {
1056
+ const directoryPath = yield* checkLinuxPath(directory);
1057
+ const candidate = join(directoryPath, query.name);
1058
+ const available = yield* Effect.promise(() => access(candidate, filesystemConstants.X_OK)
1059
+ .then(() => true)
1060
+ .catch(() => false));
1061
+ if (available) {
1062
+ return { name: query.name, path: linuxPath(candidate) };
1063
+ }
1064
+ }
1065
+ return yield* new ExecutableNotFoundError({ name: query.name });
1066
+ });
1067
+ const credentialCapability = Effect.fn("MachineState.credentialCapability")(function* () {
1068
+ if (localCredentialRoot !== undefined) {
1069
+ return { kind: "local-file", path: linuxPath(localCredentialRoot) };
1070
+ }
1071
+ const secretTool = secretServiceSession
1072
+ ? yield* discoverSecretTool(environment)
1073
+ : undefined;
1074
+ return secretTool === undefined
1075
+ ? {
1076
+ kind: "unavailable",
1077
+ recovery: "Configure a Secret Service session for noninteractive access, or explicitly select the local-file credential policy.",
1078
+ }
1079
+ : { kind: "secure-noninteractive", provider: "secret-service" };
1080
+ });
1081
+ const requireSecretTool = Effect.fn("MachineState.requireSecretTool")(function* () {
1082
+ const secretTool = secretServiceSession
1083
+ ? yield* discoverSecretTool(environment)
1084
+ : undefined;
1085
+ if (secretTool !== undefined)
1086
+ return secretTool;
1087
+ return yield* new HumanActionRequiredError({
1088
+ action: "configure credential storage",
1089
+ recovery: "Install secret-tool and start an unlocked Secret Service provider for this user session, or explicitly select the local-file credential policy.",
1090
+ });
1091
+ });
1092
+ const storeCredential = Effect.fn("MachineState.storeCredential")(function* (input) {
1093
+ if (input.name.trim().length === 0) {
1094
+ return yield* new CredentialStorageError({
1095
+ operation: "store credential",
1096
+ reference: "local-file",
1097
+ message: "credential name must not be empty",
1098
+ });
1099
+ }
1100
+ const name = createHash("sha256").update(input.name).digest("hex");
1101
+ if (localCredentialRoot !== undefined) {
1102
+ const path = join(localCredentialRoot, `${name}.credential`);
1103
+ const bytes = new TextEncoder().encode(Redacted.value(input.value));
1104
+ yield* atomicWriteFile(path, bytes, defaultFileMode);
1105
+ return decode(CredentialReference)(`local-file:${path}`);
1106
+ }
1107
+ const secretTool = yield* requireSecretTool();
1108
+ const result = yield* runCredentialCommand(secretTool, ["store", "--label=Canonfig credential", "canonfig-key", name], environment, input.value);
1109
+ if (result.exitCode !== 0) {
1110
+ return yield* new HumanActionRequiredError({
1111
+ action: "unlock Linux credential storage",
1112
+ recovery: "Unlock the Secret Service collection for this user session, then retry.",
1113
+ });
1114
+ }
1115
+ return decode(CredentialReference)(`secret-service:${name}`);
1116
+ });
1117
+ const loadCredential = Effect.fn("MachineState.loadCredential")(function* (input) {
1118
+ if (localCredentialRoot !== undefined) {
1119
+ const path = yield* localCredentialPath(input.reference, localCredentialRoot);
1120
+ const content = yield* readBounded({
1121
+ path: linuxPath(path),
1122
+ maximumBytes: 1024 * 1024,
1123
+ });
1124
+ return Redacted.make(new TextDecoder().decode(content));
1125
+ }
1126
+ const key = yield* secretServiceKey(input.reference);
1127
+ const secretTool = yield* requireSecretTool();
1128
+ const result = yield* runCredentialCommand(secretTool, ["lookup", "canonfig-key", key], environment);
1129
+ if (result.exitCode !== 0) {
1130
+ return yield* new HumanActionRequiredError({
1131
+ action: "provide local credential",
1132
+ recovery: "Store the required credential in the unlocked Secret Service collection, then retry.",
1133
+ });
1134
+ }
1135
+ return Redacted.make(result.standardOutput.toString("utf8").replace(/\n$/u, ""));
1136
+ });
1137
+ const removeCredential = Effect.fn("MachineState.removeCredential")(function* (reference) {
1138
+ if (localCredentialRoot !== undefined) {
1139
+ const path = yield* localCredentialPath(reference, localCredentialRoot);
1140
+ yield* promiseEffect("remove credential", path, () => unlink(path));
1141
+ return;
1142
+ }
1143
+ const key = yield* secretServiceKey(reference);
1144
+ const secretTool = yield* requireSecretTool();
1145
+ const result = yield* runCredentialCommand(secretTool, ["clear", "canonfig-key", key], environment);
1146
+ if (result.exitCode !== 0) {
1147
+ return yield* new CredentialStorageError({
1148
+ operation: "remove credential",
1149
+ reference: String(reference),
1150
+ message: "Secret Service did not remove the credential",
1151
+ });
1152
+ }
1153
+ });
1154
+ return MachineState.of({
1155
+ normalizePath,
1156
+ userDirectories,
1157
+ ensureDirectory,
1158
+ atomicWrite,
1159
+ readFile: Effect.fn("MachineState.readFile")(readBounded),
1160
+ removeFile,
1161
+ removeEmptyDirectory,
1162
+ validatePathWithinRoot,
1163
+ mutateWithinRoot,
1164
+ replaceSymlink,
1165
+ readSymlink,
1166
+ inspectPath,
1167
+ setPermissions,
1168
+ permissions,
1169
+ findExecutable,
1170
+ runProcess: Effect.fn("MachineState.runProcess")((invocation) => runBoundedProcess(invocation, environment)),
1171
+ digestFile: Effect.fn("MachineState.digestFile")(digest),
1172
+ credentialCapability,
1173
+ storeCredential,
1174
+ loadCredential,
1175
+ removeCredential,
1176
+ renderSchedulerJob: Effect.fn("MachineState.renderSchedulerJob")(renderSystemdJob),
1177
+ inspectSchedulerJob: Effect.fn("MachineState.inspectSchedulerJob")(scheduler.inspect),
1178
+ snapshotSchedulerJob: Effect.fn("MachineState.snapshotSchedulerJob")(scheduler.snapshot),
1179
+ installSchedulerJob: Effect.fn("MachineState.installSchedulerJob")(scheduler.install),
1180
+ removeSchedulerJob: Effect.fn("MachineState.removeSchedulerJob")(scheduler.remove),
1181
+ restoreSchedulerJob: Effect.fn("MachineState.restoreSchedulerJob")(scheduler.restore),
1182
+ });
1183
+ });