@b00dea/cockpit 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +375 -0
- package/LICENSE +21 -0
- package/README.md +22 -2
- package/bin/cockpit.mjs +33 -0
- package/cockpit.schema.json +2574 -0
- package/dist/autonomy.d.ts +44 -0
- package/dist/autonomy.js +156 -0
- package/dist/bounded-read.d.ts +14 -0
- package/dist/bounded-read.js +42 -0
- package/dist/browser/commands.d.ts +62 -0
- package/dist/browser/commands.js +349 -0
- package/dist/browser/package-paths.d.ts +1 -0
- package/dist/browser/package-paths.js +6 -0
- package/dist/browser/proxy.d.ts +22 -0
- package/dist/browser/proxy.js +37 -0
- package/dist/browser/runtime.d.ts +117 -0
- package/dist/browser/runtime.js +183 -0
- package/dist/browser/surfaces.d.ts +141 -0
- package/dist/browser/surfaces.js +556 -0
- package/dist/browser/ui-gate.d.ts +15 -0
- package/dist/browser/ui-gate.js +18 -0
- package/dist/cli-guard.d.ts +5 -0
- package/dist/cli-guard.js +71 -0
- package/dist/cli-main.d.ts +139 -0
- package/dist/cli-main.js +1405 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +16 -0
- package/dist/command-table.d.ts +113 -0
- package/dist/command-table.js +512 -0
- package/dist/config/load.d.ts +171 -0
- package/dist/config/load.js +345 -0
- package/dist/config/sibling-toml.d.ts +48 -0
- package/dist/config/sibling-toml.js +160 -0
- package/dist/config/surface-tools-toml.d.ts +9 -0
- package/dist/config/surface-tools-toml.js +37 -0
- package/dist/config/verified-write.d.ts +43 -0
- package/dist/config/verified-write.js +89 -0
- package/dist/diagnostics.d.ts +26 -0
- package/dist/diagnostics.js +335 -0
- package/dist/emit.d.ts +22 -0
- package/dist/emit.js +43 -0
- package/dist/entrypoint-kinds.d.ts +3 -0
- package/dist/entrypoint-kinds.js +11 -0
- package/dist/environment/application/destructive.d.ts +6 -0
- package/dist/environment/application/destructive.js +425 -0
- package/dist/environment/application/doctor.d.ts +89 -0
- package/dist/environment/application/doctor.js +235 -0
- package/dist/environment/application/guard.d.ts +43 -0
- package/dist/environment/application/guard.js +622 -0
- package/dist/environment/application/health.d.ts +134 -0
- package/dist/environment/application/health.js +379 -0
- package/dist/environment/application/lifecycle-context.d.ts +228 -0
- package/dist/environment/application/lifecycle-context.js +154 -0
- package/dist/environment/application/lifecycle-state.d.ts +25 -0
- package/dist/environment/application/lifecycle-state.js +201 -0
- package/dist/environment/application/lifecycle.d.ts +17 -0
- package/dist/environment/application/lifecycle.js +729 -0
- package/dist/environment/application/logs.d.ts +45 -0
- package/dist/environment/application/logs.js +127 -0
- package/dist/environment/application/setup.d.ts +129 -0
- package/dist/environment/application/setup.js +741 -0
- package/dist/environment/application/shared.d.ts +11 -0
- package/dist/environment/application/shared.js +61 -0
- package/dist/environment/claims.d.ts +389 -0
- package/dist/environment/claims.js +1709 -0
- package/dist/environment/codex-hook-trust.d.ts +11 -0
- package/dist/environment/codex-hook-trust.js +185 -0
- package/dist/environment/compose-environment.d.ts +1 -0
- package/dist/environment/compose-environment.js +6 -0
- package/dist/environment/compose-ownership.d.ts +21 -0
- package/dist/environment/compose-ownership.js +117 -0
- package/dist/environment/compose-topology.d.ts +117 -0
- package/dist/environment/compose-topology.js +413 -0
- package/dist/environment/contract.d.ts +489 -0
- package/dist/environment/contract.js +761 -0
- package/dist/environment/declaration.d.ts +14 -0
- package/dist/environment/declaration.js +39 -0
- package/dist/environment/destructive-composition.d.ts +29 -0
- package/dist/environment/destructive-composition.js +333 -0
- package/dist/environment/docker-daemon.d.ts +30 -0
- package/dist/environment/docker-daemon.js +78 -0
- package/dist/environment/docker-endpoint.d.ts +10 -0
- package/dist/environment/docker-endpoint.js +19 -0
- package/dist/environment/env-schema-observation.d.ts +46 -0
- package/dist/environment/env-schema-observation.js +241 -0
- package/dist/environment/evidence.d.ts +136 -0
- package/dist/environment/evidence.js +437 -0
- package/dist/environment/executable.d.ts +26 -0
- package/dist/environment/executable.js +167 -0
- package/dist/environment/generated-application/authorities.d.ts +5 -0
- package/dist/environment/generated-application/authorities.js +61 -0
- package/dist/environment/generated-application/bootstrap-skill.d.ts +16 -0
- package/dist/environment/generated-application/bootstrap-skill.js +328 -0
- package/dist/environment/generated-application/check.d.ts +13 -0
- package/dist/environment/generated-application/check.js +189 -0
- package/dist/environment/generated-application/custom-surface.d.ts +26 -0
- package/dist/environment/generated-application/custom-surface.js +289 -0
- package/dist/environment/generated-application/prepare.d.ts +29 -0
- package/dist/environment/generated-application/prepare.js +276 -0
- package/dist/environment/generated-application/publication.d.ts +33 -0
- package/dist/environment/generated-application/publication.js +547 -0
- package/dist/environment/generated-application/shared.d.ts +484 -0
- package/dist/environment/generated-application/shared.js +349 -0
- package/dist/environment/generated-application/surface-observation.d.ts +4 -0
- package/dist/environment/generated-application/surface-observation.js +69 -0
- package/dist/environment/generated-application/surface-outputs.d.ts +71 -0
- package/dist/environment/generated-application/surface-outputs.js +389 -0
- package/dist/environment/generated-application/surfaces.d.ts +3 -0
- package/dist/environment/generated-application/surfaces.js +699 -0
- package/dist/environment/generated-application/sync.d.ts +6 -0
- package/dist/environment/generated-application/sync.js +109 -0
- package/dist/environment/git-observation.d.ts +183 -0
- package/dist/environment/git-observation.js +544 -0
- package/dist/environment/health-composition.d.ts +11 -0
- package/dist/environment/health-composition.js +628 -0
- package/dist/environment/host-observation.d.ts +56 -0
- package/dist/environment/host-observation.js +52 -0
- package/dist/environment/identity.d.ts +48 -0
- package/dist/environment/identity.js +85 -0
- package/dist/environment/init-application.d.ts +308 -0
- package/dist/environment/init-application.js +697 -0
- package/dist/environment/install-policy-observation.d.ts +71 -0
- package/dist/environment/install-policy-observation.js +284 -0
- package/dist/environment/install-policy.d.ts +48 -0
- package/dist/environment/install-policy.js +96 -0
- package/dist/environment/instance.d.ts +33 -0
- package/dist/environment/instance.js +122 -0
- package/dist/environment/instruction-pointer.d.ts +19 -0
- package/dist/environment/instruction-pointer.js +144 -0
- package/dist/environment/lifecycle-composition.d.ts +29 -0
- package/dist/environment/lifecycle-composition.js +675 -0
- package/dist/environment/lifecycle-stage.d.ts +115 -0
- package/dist/environment/lifecycle-stage.js +398 -0
- package/dist/environment/lifecycle.d.ts +75 -0
- package/dist/environment/lifecycle.js +354 -0
- package/dist/environment/logs.d.ts +132 -0
- package/dist/environment/logs.js +519 -0
- package/dist/environment/observations.d.ts +46 -0
- package/dist/environment/observations.js +344 -0
- package/dist/environment/operator-application.d.ts +50 -0
- package/dist/environment/operator-application.js +155 -0
- package/dist/environment/ports.d.ts +182 -0
- package/dist/environment/ports.js +1205 -0
- package/dist/environment/posture-observation.d.ts +68 -0
- package/dist/environment/posture-observation.js +253 -0
- package/dist/environment/read-composition.d.ts +27 -0
- package/dist/environment/read-composition.js +352 -0
- package/dist/environment/readiness-composition.d.ts +38 -0
- package/dist/environment/readiness-composition.js +130 -0
- package/dist/environment/reclaim.d.ts +333 -0
- package/dist/environment/reclaim.js +1835 -0
- package/dist/environment/repository-identity.d.ts +13 -0
- package/dist/environment/repository-identity.js +108 -0
- package/dist/environment/runtime-measurement-record.d.ts +38 -0
- package/dist/environment/runtime-measurement-record.js +111 -0
- package/dist/environment/runtime-measurement.d.ts +27 -0
- package/dist/environment/runtime-measurement.js +224 -0
- package/dist/environment/runtime-probe-residue.d.ts +43 -0
- package/dist/environment/runtime-probe-residue.js +148 -0
- package/dist/environment/runtime-probe.d.ts +72 -0
- package/dist/environment/runtime-probe.js +725 -0
- package/dist/environment/runtime.d.ts +505 -0
- package/dist/environment/runtime.js +2335 -0
- package/dist/environment/seed.d.ts +16 -0
- package/dist/environment/seed.js +43 -0
- package/dist/environment/selftest-application.d.ts +86 -0
- package/dist/environment/selftest-application.js +713 -0
- package/dist/environment/sensitive.d.ts +3 -0
- package/dist/environment/sensitive.js +48 -0
- package/dist/environment/setup-observation.d.ts +62 -0
- package/dist/environment/setup-observation.js +1473 -0
- package/dist/environment/setup.d.ts +43 -0
- package/dist/environment/setup.js +176 -0
- package/dist/environment/sibling-instructions.d.ts +33 -0
- package/dist/environment/sibling-instructions.js +224 -0
- package/dist/environment/sibling-registration.d.ts +45 -0
- package/dist/environment/sibling-registration.js +538 -0
- package/dist/environment/state.d.ts +371 -0
- package/dist/environment/state.js +2013 -0
- package/dist/environment/templates.d.ts +140 -0
- package/dist/environment/templates.js +685 -0
- package/dist/environment/tool-registration.d.ts +9 -0
- package/dist/environment/tool-registration.js +96 -0
- package/dist/environment/upgrade-application.d.ts +19 -0
- package/dist/environment/upgrade-application.js +175 -0
- package/dist/environment-exec/execute.d.ts +102 -0
- package/dist/environment-exec/execute.js +1175 -0
- package/dist/environment-exec/libexec-program.d.ts +6 -0
- package/dist/environment-exec/libexec-program.js +22 -0
- package/dist/environment-exec/verified-digest.d.ts +4 -0
- package/dist/environment-exec/verified-digest.js +16 -0
- package/dist/exact-keys.d.ts +7 -0
- package/dist/exact-keys.js +15 -0
- package/dist/fix-catalogue.d.ts +32 -0
- package/dist/fix-catalogue.js +60 -0
- package/dist/freshness.d.ts +29 -0
- package/dist/freshness.js +75 -0
- package/dist/generated-environment/adopt.d.ts +16 -0
- package/dist/generated-environment/adopt.js +275 -0
- package/dist/generated-environment/command.d.ts +47 -0
- package/dist/generated-environment/command.js +75 -0
- package/dist/generated-environment/transaction.d.ts +186 -0
- package/dist/generated-environment/transaction.js +1770 -0
- package/dist/grading/evaluate.d.ts +71 -0
- package/dist/grading/evaluate.js +403 -0
- package/dist/grading/grade.d.ts +33 -0
- package/dist/grading/grade.js +57 -0
- package/dist/guards/evaluator.d.ts +113 -0
- package/dist/guards/evaluator.js +1226 -0
- package/dist/guards/harness-adapter.d.ts +17 -0
- package/dist/guards/harness-adapter.js +57 -0
- package/dist/guards/host.d.ts +8 -0
- package/dist/guards/host.js +10 -0
- package/dist/guards/invocation-parser.d.ts +66 -0
- package/dist/guards/invocation-parser.js +595 -0
- package/dist/guards/local-identity.d.ts +40 -0
- package/dist/guards/local-identity.js +123 -0
- package/dist/guards/policy-pin.d.ts +13 -0
- package/dist/guards/policy-pin.js +175 -0
- package/dist/guards/protected-locations.d.ts +62 -0
- package/dist/guards/protected-locations.js +461 -0
- package/dist/guards/read-only.d.ts +37 -0
- package/dist/guards/read-only.js +315 -0
- package/dist/init/command.d.ts +40 -0
- package/dist/init/command.js +310 -0
- package/dist/init/derive.d.ts +51 -0
- package/dist/init/derive.js +715 -0
- package/dist/init/draft.d.ts +138 -0
- package/dist/init/draft.js +1205 -0
- package/dist/init/render.d.ts +35 -0
- package/dist/init/render.js +82 -0
- package/dist/init/scaffold.d.ts +53 -0
- package/dist/init/scaffold.js +121 -0
- package/dist/invocation.d.ts +13 -0
- package/dist/invocation.js +20 -0
- package/dist/package-metadata.d.ts +9 -0
- package/dist/package-metadata.js +19 -0
- package/dist/readiness.d.ts +53 -0
- package/dist/readiness.js +88 -0
- package/dist/references/named.d.ts +24 -0
- package/dist/references/named.js +27 -0
- package/dist/refusal.d.ts +15 -0
- package/dist/refusal.js +7 -0
- package/dist/registry-derivation.d.ts +31 -0
- package/dist/registry-derivation.js +32 -0
- package/dist/registry-resolve/command.d.ts +47 -0
- package/dist/registry-resolve/command.js +226 -0
- package/dist/registry-resolve/registry.d.ts +68 -0
- package/dist/registry-resolve/registry.js +292 -0
- package/dist/registry-resolve/render-shell.d.ts +18 -0
- package/dist/registry-resolve/render-shell.js +36 -0
- package/dist/repair.d.ts +29 -0
- package/dist/repair.js +8 -0
- package/dist/schema-json.d.ts +1 -0
- package/dist/schema-json.js +177 -0
- package/dist/schema.d.ts +1322 -0
- package/dist/schema.js +934 -0
- package/dist/setup/command.d.ts +4 -0
- package/dist/setup/command.js +4 -0
- package/dist/sha256.d.ts +1 -0
- package/dist/sha256.js +4 -0
- package/dist/surfaces/adoption.d.ts +41 -0
- package/dist/surfaces/adoption.js +217 -0
- package/dist/surfaces/consumer-instructions.d.ts +20 -0
- package/dist/surfaces/consumer-instructions.js +43 -0
- package/dist/surfaces/contract.d.ts +42 -0
- package/dist/surfaces/contract.js +169 -0
- package/dist/surfaces/core-declarations.d.ts +24 -0
- package/dist/surfaces/core-declarations.js +79 -0
- package/dist/surfaces/development-markers.d.ts +1 -0
- package/dist/surfaces/development-markers.js +18 -0
- package/dist/surfaces/generator-adapter.d.ts +71 -0
- package/dist/surfaces/generator-adapter.js +809 -0
- package/dist/surfaces/harness-registry.d.ts +180 -0
- package/dist/surfaces/harness-registry.js +117 -0
- package/dist/surfaces/mcp.d.ts +95 -0
- package/dist/surfaces/mcp.js +186 -0
- package/dist/surfaces/merge.d.ts +30 -0
- package/dist/surfaces/merge.js +105 -0
- package/dist/surfaces/ownership.d.ts +190 -0
- package/dist/surfaces/ownership.js +795 -0
- package/dist/surfaces/projection.d.ts +136 -0
- package/dist/surfaces/projection.js +681 -0
- package/dist/surfaces/render.d.ts +118 -0
- package/dist/surfaces/render.js +633 -0
- package/dist/surfaces/restrictions.d.ts +130 -0
- package/dist/surfaces/restrictions.js +447 -0
- package/dist/surfaces/selftest.d.ts +162 -0
- package/dist/surfaces/selftest.js +393 -0
- package/dist/surfaces/skill-contract.d.ts +85 -0
- package/dist/surfaces/skill-contract.js +449 -0
- package/dist/surfaces/sync.d.ts +57 -0
- package/dist/surfaces/sync.js +164 -0
- package/dist/team-metadata.d.ts +41 -0
- package/dist/team-metadata.js +512 -0
- package/dist/testing/gates.d.ts +91 -0
- package/dist/testing/gates.js +182 -0
- package/dist/testing/hooks.d.ts +59 -0
- package/dist/testing/hooks.js +139 -0
- package/dist/testing/verdict.d.ts +29 -0
- package/dist/testing/verdict.js +62 -0
- package/dist/work-orders/application.d.ts +99 -0
- package/dist/work-orders/application.js +383 -0
- package/dist/work-orders/composition.d.ts +124 -0
- package/dist/work-orders/composition.js +1010 -0
- package/dist/work-orders/contract.d.ts +61 -0
- package/dist/work-orders/contract.js +122 -0
- package/dist/work-orders/store.d.ts +158 -0
- package/dist/work-orders/store.js +197 -0
- package/docs/README.md +65 -0
- package/docs/architecture.md +136 -0
- package/docs/capability-matrix.md +50 -0
- package/docs/cli-reference.md +599 -0
- package/docs/command-list.md +51 -0
- package/docs/compose-patterns.md +99 -0
- package/docs/concepts.md +171 -0
- package/docs/configuration-essentials.md +172 -0
- package/docs/configuration-reference.md +621 -0
- package/docs/environment.md +760 -0
- package/docs/extending.md +43 -0
- package/docs/faq.md +111 -0
- package/docs/getting-started.md +608 -0
- package/docs/harness-surfaces.md +433 -0
- package/docs/joining-a-team.md +76 -0
- package/docs/release-policy.md +49 -0
- package/docs/safety-and-autonomy.md +419 -0
- package/docs/troubleshooting.md +361 -0
- package/docs/tutorial.md +170 -0
- package/docs/work-and-browser.md +254 -0
- package/libexec/browser-operation.cjs +186 -0
- package/libexec/fence-marker.cjs +101 -0
- package/libexec/fence-marker.d.cts +24 -0
- package/libexec/fenced-group-runner.cjs +77 -0
- package/libexec/fenced-supervisor.cjs +137 -0
- package/libexec/generated-filesystem.cjs +731 -0
- package/libexec/install-filesystem.cjs +225 -0
- package/libexec/rulesync-generator.NOTICE.md +1112 -0
- package/libexec/rulesync-generator.cjs +743 -0
- package/libexec/surface-generator-worker.cjs +266 -0
- package/libexec/surface-output-reader.cjs +99 -0
- package/libexec/surface-scratch-cleanup.cjs +14 -0
- package/libexec/validate-harness-evidence.d.mts +183 -0
- package/libexec/validate-harness-evidence.mjs +1312 -0
- package/package.json +74 -4
- package/surfaces/README.md +33 -0
- package/surfaces/agents/evaluator.agent.md +76 -0
- package/surfaces/guards/bash-guard.sh +48 -0
- package/surfaces/guards/claude.adapter.json +25 -0
- package/surfaces/guards/codex.adapter.json +24 -0
- package/surfaces/guards/opencode.adapter.json +25 -0
- package/surfaces/references/browser.md +63 -0
- package/surfaces/references/test-data-and-personas.md +84 -0
- package/surfaces/references/testing-tiers.md +11 -0
- package/surfaces/skills/cockpit-browser.skill.md +54 -0
- package/surfaces/skills/cockpit-explain.skill.md +32 -0
- package/surfaces/skills/cockpit-fix.skill.md +37 -0
- package/surfaces/skills/cockpit-setup.skill.md +39 -0
- package/surfaces/skills/cockpit-work.skill.md +34 -0
- package/surfaces/skills/collection.json +5 -0
- package/surfaces/skills/procedures/explain.md +61 -0
- package/surfaces/skills/procedures/fix.md +81 -0
- package/surfaces/skills/procedures/setup.md +379 -0
- package/surfaces/skills/procedures/work.md +105 -0
|
@@ -0,0 +1,1473 @@
|
|
|
1
|
+
import { exactKeys } from '../exact-keys.js';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { constants } from 'node:fs';
|
|
4
|
+
import { lstat, open, readFile, readdir, realpath } from 'node:fs/promises';
|
|
5
|
+
import { dirname, isAbsolute, normalize, relative, resolve, sep } from 'node:path';
|
|
6
|
+
import { isProxy } from 'node:util/types';
|
|
7
|
+
import { EnvironmentFile } from '../schema.js';
|
|
8
|
+
import { loadCockpitConfig } from '../config/load.js';
|
|
9
|
+
import { cockpitCommand, formatShellCommand } from '../fix-catalogue.js';
|
|
10
|
+
import { authenticatedExecutionOutcome, executeProbe } from '../environment-exec/execute.js';
|
|
11
|
+
import { hasErrorCode, RefusalError, ENVIRONMENT_CODES, errorCause, filesystemObjectIdentity, isCanonicalTimestamp, sameFilesystemObject, snapshotJson, } from './contract.js';
|
|
12
|
+
import { gitEnvironment } from './setup.js';
|
|
13
|
+
import { resolveGitExecutable, resolvedGitExecutable } from './executable.js';
|
|
14
|
+
import { checkoutId as deriveCheckoutId } from './identity.js';
|
|
15
|
+
import { cockpitId as deriveCockpitId } from './identity.js';
|
|
16
|
+
const OWNER = 'setup-observation';
|
|
17
|
+
const CHECKOUT_ID = /^checkout:[a-f0-9]{64}$/;
|
|
18
|
+
const SLUG = /^[a-z0-9][a-z0-9-]*$/;
|
|
19
|
+
const SHA256 = /^[a-f0-9]{64}$/;
|
|
20
|
+
const IGNORE_OBSERVATION_LIFETIME_MS = 60_000;
|
|
21
|
+
const SETUP_CHECKOUT_OBSERVATION_LIFETIME_MS = 60_000;
|
|
22
|
+
const CONFIGURATION_BYTE_LIMIT = 1_048_576;
|
|
23
|
+
const COCKPIT_NAME = /^[a-z0-9][a-z0-9-]*$/;
|
|
24
|
+
function isPromiseLike(value) {
|
|
25
|
+
if ((typeof value !== 'object' || value === null) && typeof value !== 'function')
|
|
26
|
+
return false;
|
|
27
|
+
return typeof Reflect.get(value, 'then') === 'function';
|
|
28
|
+
}
|
|
29
|
+
function directoryIdentity(stat) {
|
|
30
|
+
return {
|
|
31
|
+
dev: stat.dev.toString(),
|
|
32
|
+
ino: stat.ino.toString(),
|
|
33
|
+
birthtimeNs: stat.birthtimeNs.toString(),
|
|
34
|
+
mode: stat.mode.toString(),
|
|
35
|
+
mtimeNs: stat.mtimeNs.toString(),
|
|
36
|
+
ctimeNs: stat.ctimeNs.toString(),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function sameDirectoryIdentity(left, right) {
|
|
40
|
+
return sameDirectoryNode(left, right)
|
|
41
|
+
&& left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs;
|
|
42
|
+
}
|
|
43
|
+
function sameDirectoryNode(left, right) {
|
|
44
|
+
return left.dev === right.dev && left.ino === right.ino && left.birthtimeNs === right.birthtimeNs && left.mode === right.mode;
|
|
45
|
+
}
|
|
46
|
+
async function resolvedDirectory(path) {
|
|
47
|
+
try {
|
|
48
|
+
const before = await lstat(path, { bigint: true });
|
|
49
|
+
if (!before.isDirectory() || before.isSymbolicLink())
|
|
50
|
+
return undefined;
|
|
51
|
+
const canonicalPath = await realpath(path);
|
|
52
|
+
const after = await lstat(canonicalPath, { bigint: true });
|
|
53
|
+
if (canonicalPath !== path || !after.isDirectory() || after.isSymbolicLink())
|
|
54
|
+
return undefined;
|
|
55
|
+
return { canonicalPath, beforeIdentity: directoryIdentity(before), afterIdentity: directoryIdentity(after) };
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// Any lstat/realpath failure means the directory identity is not established; callers report the failed observation.
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
async function stableDirectory(path) {
|
|
63
|
+
const resolved = await resolvedDirectory(path);
|
|
64
|
+
return resolved && sameDirectoryIdentity(resolved.beforeIdentity, resolved.afterIdentity)
|
|
65
|
+
? { canonicalPath: resolved.canonicalPath, identity: resolved.afterIdentity }
|
|
66
|
+
: undefined;
|
|
67
|
+
}
|
|
68
|
+
async function stableDirectoryNode(path) {
|
|
69
|
+
const resolved = await resolvedDirectory(path);
|
|
70
|
+
return resolved && sameDirectoryNode(resolved.beforeIdentity, resolved.afterIdentity)
|
|
71
|
+
? { canonicalPath: resolved.canonicalPath, identity: resolved.afterIdentity }
|
|
72
|
+
: undefined;
|
|
73
|
+
}
|
|
74
|
+
function fileIdentity(stat) {
|
|
75
|
+
return { ...directoryIdentity(stat), size: stat.size.toString(), nlink: stat.nlink.toString() };
|
|
76
|
+
}
|
|
77
|
+
function sameFileIdentity(left, right) {
|
|
78
|
+
return sameDirectoryIdentity(left, right) && left.size === right.size && left.nlink === right.nlink;
|
|
79
|
+
}
|
|
80
|
+
async function stableFile(path) {
|
|
81
|
+
let handle;
|
|
82
|
+
try {
|
|
83
|
+
const before = await lstat(path, { bigint: true });
|
|
84
|
+
if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1n
|
|
85
|
+
|| before.size > BigInt(CONFIGURATION_BYTE_LIMIT))
|
|
86
|
+
return undefined;
|
|
87
|
+
if (await realpath(path) !== path)
|
|
88
|
+
return undefined;
|
|
89
|
+
handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
90
|
+
const opened = await handle.stat({ bigint: true });
|
|
91
|
+
const beforeIdentity = fileIdentity(before);
|
|
92
|
+
const openedIdentity = fileIdentity(opened);
|
|
93
|
+
if (!opened.isFile() || opened.nlink !== 1n || !sameFileIdentity(beforeIdentity, openedIdentity))
|
|
94
|
+
return undefined;
|
|
95
|
+
const bytes = await handle.readFile();
|
|
96
|
+
const after = await handle.stat({ bigint: true });
|
|
97
|
+
const linked = await lstat(path, { bigint: true });
|
|
98
|
+
const afterIdentity = fileIdentity(after);
|
|
99
|
+
const linkedIdentity = fileIdentity(linked);
|
|
100
|
+
if (after.nlink !== 1n || linked.nlink !== 1n || bytes.length !== Number(opened.size)
|
|
101
|
+
|| !sameFileIdentity(openedIdentity, afterIdentity)
|
|
102
|
+
|| !sameFileIdentity(openedIdentity, linkedIdentity))
|
|
103
|
+
return undefined;
|
|
104
|
+
return {
|
|
105
|
+
identity: openedIdentity,
|
|
106
|
+
sha256: createHash('sha256').update(bytes).digest('hex'),
|
|
107
|
+
byteLength: bytes.length,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// Any read failure means no file evidence; callers report the unavailable observation.
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
finally {
|
|
115
|
+
try {
|
|
116
|
+
await handle?.close();
|
|
117
|
+
}
|
|
118
|
+
catch { /* Unavailable evidence carries no file authority. */ }
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function validSource(value) {
|
|
122
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
123
|
+
return false;
|
|
124
|
+
const source = value;
|
|
125
|
+
return typeof source.path === 'string' && isAbsolute(source.path) && resolve(source.path) === source.path
|
|
126
|
+
&& !source.path.includes('\0') && Number.isSafeInteger(source.byteLength) && source.byteLength >= 0
|
|
127
|
+
&& typeof source.sha256 === 'string' && SHA256.test(source.sha256);
|
|
128
|
+
}
|
|
129
|
+
function snapshotIgnoreInput(value) {
|
|
130
|
+
const snapshot = snapshotJson(value);
|
|
131
|
+
if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot))
|
|
132
|
+
return undefined;
|
|
133
|
+
const input = snapshot;
|
|
134
|
+
const expectedKeys = [
|
|
135
|
+
'checkoutId', 'env', 'environmentFile', 'environmentFileIndex', 'gitExecutable', 'mainCheckoutPath', 'observedAt', 'siblingIndex', 'siblingRoot',
|
|
136
|
+
'siblingSlug', 'siblingsRoot', 'source',
|
|
137
|
+
];
|
|
138
|
+
const environmentFile = EnvironmentFile.safeParse(input.environmentFile);
|
|
139
|
+
const executable = resolvedGitExecutable(input.gitExecutable);
|
|
140
|
+
const environment = input.env;
|
|
141
|
+
if (!exactKeys(input, expectedKeys)
|
|
142
|
+
|| !validSource(input.source)
|
|
143
|
+
|| !Number.isSafeInteger(input.siblingIndex) || input.siblingIndex < 0
|
|
144
|
+
|| typeof input.siblingSlug !== 'string' || !SLUG.test(input.siblingSlug)
|
|
145
|
+
|| typeof input.mainCheckoutPath !== 'string' || !isAbsolute(input.mainCheckoutPath)
|
|
146
|
+
|| resolve(input.mainCheckoutPath) !== input.mainCheckoutPath || input.mainCheckoutPath.includes('\0')
|
|
147
|
+
|| typeof input.siblingsRoot !== 'string' || !isAbsolute(input.siblingsRoot)
|
|
148
|
+
|| resolve(input.siblingsRoot) !== input.siblingsRoot || input.siblingsRoot.includes('\0')
|
|
149
|
+
|| typeof input.siblingRoot !== 'string' || !isAbsolute(input.siblingRoot)
|
|
150
|
+
|| resolve(input.siblingRoot) !== input.siblingRoot || input.siblingRoot.includes('\0')
|
|
151
|
+
|| typeof input.checkoutId !== 'string' || !CHECKOUT_ID.test(input.checkoutId)
|
|
152
|
+
|| !Number.isSafeInteger(input.environmentFileIndex) || input.environmentFileIndex < 0
|
|
153
|
+
|| !environmentFile.success || !isCanonicalTimestamp(input.observedAt) || executable === undefined
|
|
154
|
+
|| environment === null || typeof environment !== 'object' || Array.isArray(environment)
|
|
155
|
+
|| Object.values(environment).some((entry) => typeof entry !== 'string'))
|
|
156
|
+
return undefined;
|
|
157
|
+
return {
|
|
158
|
+
source: { ...input.source },
|
|
159
|
+
siblingIndex: input.siblingIndex,
|
|
160
|
+
siblingSlug: input.siblingSlug,
|
|
161
|
+
mainCheckoutPath: input.mainCheckoutPath,
|
|
162
|
+
siblingsRoot: input.siblingsRoot,
|
|
163
|
+
siblingRoot: input.siblingRoot,
|
|
164
|
+
checkoutId: input.checkoutId,
|
|
165
|
+
environmentFileIndex: input.environmentFileIndex,
|
|
166
|
+
environmentFile: { ...environmentFile.data },
|
|
167
|
+
observedAt: input.observedAt,
|
|
168
|
+
env: { ...environment },
|
|
169
|
+
gitExecutable: executable,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
function ignoreProbeStep(id, input, args, filesystemChecks) {
|
|
173
|
+
return probeStep(id, [input.gitExecutable.canonicalPath, '-c', 'core.excludesFile=/dev/null', '-C', input.siblingRoot, ...args], input.siblingRoot, input.env, input.gitExecutable, filesystemChecks);
|
|
174
|
+
}
|
|
175
|
+
async function probeBoolean(step) {
|
|
176
|
+
const outcome = await authenticatedProbeOutcome(step);
|
|
177
|
+
if (outcome?.kind === 'complete')
|
|
178
|
+
return true;
|
|
179
|
+
return outcome?.kind === 'refusal'
|
|
180
|
+
&& outcome.code === ENVIRONMENT_CODES.EXECUTION_STEP_FAILED
|
|
181
|
+
&& outcome.execution.started
|
|
182
|
+
&& outcome.execution.exitCode === 1
|
|
183
|
+
&& outcome.execution.signal === null
|
|
184
|
+
? false
|
|
185
|
+
: undefined;
|
|
186
|
+
}
|
|
187
|
+
function gitDisplayedPath(path) {
|
|
188
|
+
return path.includes('"') ? `"${path.replaceAll('"', '\\"')}"` : path;
|
|
189
|
+
}
|
|
190
|
+
function ignoredByMatchedPattern(detail) {
|
|
191
|
+
if (detail === '::')
|
|
192
|
+
return false;
|
|
193
|
+
const delimiters = [...detail.matchAll(/:[1-9][0-9]*:/g)];
|
|
194
|
+
const delimiter = delimiters[0];
|
|
195
|
+
if (delimiters.length !== 1 || delimiter.index === undefined || delimiter.index === 0)
|
|
196
|
+
return undefined;
|
|
197
|
+
const pattern = detail.slice(delimiter.index + delimiter[0].length);
|
|
198
|
+
if (pattern.length === 0)
|
|
199
|
+
return undefined;
|
|
200
|
+
return !pattern.startsWith('!');
|
|
201
|
+
}
|
|
202
|
+
function ignoreStatusOutput(bytes, instance, template) {
|
|
203
|
+
try {
|
|
204
|
+
const decoded = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
205
|
+
if (!decoded.endsWith('\n') || decoded.includes('\0') || /\r/.test(decoded))
|
|
206
|
+
return undefined;
|
|
207
|
+
const lines = decoded.slice(0, -1).split('\n');
|
|
208
|
+
if (lines.length !== 2)
|
|
209
|
+
return undefined;
|
|
210
|
+
const statuses = [];
|
|
211
|
+
for (const [index, expectedPath] of [instance, template].entries()) {
|
|
212
|
+
const separator = lines[index].lastIndexOf('\t');
|
|
213
|
+
if (separator < 2 || lines[index].slice(separator + 1) !== gitDisplayedPath(expectedPath))
|
|
214
|
+
return undefined;
|
|
215
|
+
const detail = lines[index].slice(0, separator);
|
|
216
|
+
const ignored = ignoredByMatchedPattern(detail);
|
|
217
|
+
if (ignored === undefined)
|
|
218
|
+
return undefined;
|
|
219
|
+
statuses.push(ignored);
|
|
220
|
+
}
|
|
221
|
+
return { instanceIgnored: statuses[0], templateIgnored: statuses[1] };
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
// Unparseable ignore output yields no projection; callers report the failed observation.
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
async function observeIgnoreStatus(step, instance, template) {
|
|
229
|
+
const outcome = await authenticatedProbeOutcome(step);
|
|
230
|
+
if (!outcome || outcome.stderr.byteLength !== 0
|
|
231
|
+
|| outcome.execution.stderrObservedBytes !== 0
|
|
232
|
+
|| outcome.execution.stdoutObservedBytes !== outcome.stdout.byteLength)
|
|
233
|
+
return undefined;
|
|
234
|
+
const exitCode = outcome.kind === 'complete'
|
|
235
|
+
? 0
|
|
236
|
+
: outcome.code === ENVIRONMENT_CODES.EXECUTION_STEP_FAILED
|
|
237
|
+
&& outcome.execution.started
|
|
238
|
+
&& outcome.execution.exitCode === 1
|
|
239
|
+
&& outcome.execution.signal === null
|
|
240
|
+
? 1
|
|
241
|
+
: undefined;
|
|
242
|
+
if (exitCode === undefined)
|
|
243
|
+
return undefined;
|
|
244
|
+
const status = ignoreStatusOutput(outcome.stdout, instance, template);
|
|
245
|
+
if (!status || (status.instanceIgnored || status.templateIgnored) !== (exitCode === 0))
|
|
246
|
+
return undefined;
|
|
247
|
+
return status;
|
|
248
|
+
}
|
|
249
|
+
async function closedIdentity(path, identity) {
|
|
250
|
+
const dev = Number(identity.dev);
|
|
251
|
+
const ino = Number(identity.ino);
|
|
252
|
+
if (!Number.isSafeInteger(dev) || !Number.isSafeInteger(ino))
|
|
253
|
+
return undefined;
|
|
254
|
+
const parents = [];
|
|
255
|
+
let cursor = dirname(path);
|
|
256
|
+
while (true) {
|
|
257
|
+
parents.push(cursor);
|
|
258
|
+
const parent = dirname(cursor);
|
|
259
|
+
if (parent === cursor)
|
|
260
|
+
break;
|
|
261
|
+
if (parents.length >= 64)
|
|
262
|
+
return undefined;
|
|
263
|
+
cursor = parent;
|
|
264
|
+
}
|
|
265
|
+
const ancestors = [];
|
|
266
|
+
for (const ancestorPath of parents.reverse()) {
|
|
267
|
+
try {
|
|
268
|
+
const status = await lstat(ancestorPath, { bigint: true });
|
|
269
|
+
const ancestor = { path: ancestorPath, ...filesystemObjectIdentity(status) };
|
|
270
|
+
if (!status.isDirectory() || status.isSymbolicLink()
|
|
271
|
+
|| !Number.isSafeInteger(ancestor.dev) || !Number.isSafeInteger(ancestor.ino))
|
|
272
|
+
return undefined;
|
|
273
|
+
ancestors.push(ancestor);
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
// Any lstat failure means the ancestor identity is not established; callers report the failed observation.
|
|
277
|
+
return undefined;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return { dev, ino, birthtimeNs: identity.birthtimeNs, ancestors };
|
|
281
|
+
}
|
|
282
|
+
async function directoryCheck(path, identity) {
|
|
283
|
+
const value = await closedIdentity(path, identity);
|
|
284
|
+
return value ? { path, expected: 'directory', identity: value } : undefined;
|
|
285
|
+
}
|
|
286
|
+
async function boundedFileCheck(path) {
|
|
287
|
+
const file = await stableFile(path);
|
|
288
|
+
if (file) {
|
|
289
|
+
const identity = await closedIdentity(path, file.identity);
|
|
290
|
+
return identity ? {
|
|
291
|
+
path,
|
|
292
|
+
expected: 'file',
|
|
293
|
+
sha256: file.sha256,
|
|
294
|
+
maxBytes: CONFIGURATION_BYTE_LIMIT,
|
|
295
|
+
identity,
|
|
296
|
+
} : undefined;
|
|
297
|
+
}
|
|
298
|
+
try {
|
|
299
|
+
await lstat(path);
|
|
300
|
+
return undefined;
|
|
301
|
+
}
|
|
302
|
+
catch (error) {
|
|
303
|
+
return hasErrorCode(error, 'ENOENT')
|
|
304
|
+
? { path, expected: 'absent' }
|
|
305
|
+
: undefined;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
function ignoreFiles(root, declarations) {
|
|
309
|
+
const result = new Set();
|
|
310
|
+
for (const relativePath of [declarations.instance, declarations.template]) {
|
|
311
|
+
const absolute = resolve(root, relativePath);
|
|
312
|
+
if (!confined(root, absolute))
|
|
313
|
+
return undefined;
|
|
314
|
+
let cursor = dirname(absolute);
|
|
315
|
+
while (confined(root, cursor)) {
|
|
316
|
+
result.add(resolve(cursor, '.gitignore'));
|
|
317
|
+
if (cursor === root)
|
|
318
|
+
break;
|
|
319
|
+
cursor = dirname(cursor);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return [...result].sort();
|
|
323
|
+
}
|
|
324
|
+
async function ignoreRepositoryChecks(input, root) {
|
|
325
|
+
const rootCheck = await directoryCheck(root.canonicalPath, root.identity);
|
|
326
|
+
if (!rootCheck)
|
|
327
|
+
return undefined;
|
|
328
|
+
const identityStep = probeStep('setup:observe:environment-repository', [
|
|
329
|
+
input.gitExecutable.canonicalPath,
|
|
330
|
+
'-c',
|
|
331
|
+
'core.excludesFile=/dev/null',
|
|
332
|
+
'-C',
|
|
333
|
+
input.siblingRoot,
|
|
334
|
+
'rev-parse',
|
|
335
|
+
'--path-format=absolute',
|
|
336
|
+
'--show-toplevel',
|
|
337
|
+
'--absolute-git-dir',
|
|
338
|
+
'--git-common-dir',
|
|
339
|
+
], input.siblingRoot, input.env, input.gitExecutable, [rootCheck]);
|
|
340
|
+
const output = await authenticatedProbe(identityStep);
|
|
341
|
+
const lines = output && exactLines(output, 3);
|
|
342
|
+
if (!lines || !lines.every(canonicalAbsolutePath))
|
|
343
|
+
return undefined;
|
|
344
|
+
const [topLevel, gitDirectory, commonDirectory] = await Promise.all(lines.map(async (path) => {
|
|
345
|
+
try {
|
|
346
|
+
return await realpath(path);
|
|
347
|
+
}
|
|
348
|
+
catch { /* An unresolvable Git path yields no identity; the caller reports the failed observation. */
|
|
349
|
+
return undefined;
|
|
350
|
+
}
|
|
351
|
+
}));
|
|
352
|
+
if (topLevel !== root.canonicalPath || !gitDirectory || !commonDirectory)
|
|
353
|
+
return undefined;
|
|
354
|
+
const [git, common] = await Promise.all([stableDirectory(gitDirectory), stableDirectory(commonDirectory)]);
|
|
355
|
+
if (!git || !common)
|
|
356
|
+
return undefined;
|
|
357
|
+
const ignored = ignoreFiles(root.canonicalPath, input.environmentFile);
|
|
358
|
+
if (!ignored)
|
|
359
|
+
return undefined;
|
|
360
|
+
const dotGit = resolve(root.canonicalPath, '.git');
|
|
361
|
+
let administrativeFile;
|
|
362
|
+
try {
|
|
363
|
+
const status = await lstat(dotGit);
|
|
364
|
+
if (status.isFile() && !status.isSymbolicLink())
|
|
365
|
+
administrativeFile = dotGit;
|
|
366
|
+
else if (!status.isDirectory() || status.isSymbolicLink())
|
|
367
|
+
return undefined;
|
|
368
|
+
}
|
|
369
|
+
catch {
|
|
370
|
+
// A missing or unreadable .git entry yields no authority; the caller reports the failed observation.
|
|
371
|
+
return undefined;
|
|
372
|
+
}
|
|
373
|
+
const administrative = await worktreeAdministrativeAuthority(common.canonicalPath);
|
|
374
|
+
if (!administrative)
|
|
375
|
+
return undefined;
|
|
376
|
+
const worktreeConfig = resolve(git.canonicalPath, 'config.worktree');
|
|
377
|
+
const checks = await setupFilesystemChecks({
|
|
378
|
+
sourcePath: input.source.path,
|
|
379
|
+
directories: [root, git, common],
|
|
380
|
+
configurationPaths: [resolve(common.canonicalPath, 'config')],
|
|
381
|
+
administrativePaths: [
|
|
382
|
+
...(administrativeFile ? [administrativeFile] : []),
|
|
383
|
+
...administrative.paths,
|
|
384
|
+
],
|
|
385
|
+
optionalFilePaths: [
|
|
386
|
+
...administrative.optionalFiles,
|
|
387
|
+
worktreeConfig,
|
|
388
|
+
resolve(git.canonicalPath, 'index'),
|
|
389
|
+
resolve(common.canonicalPath, 'info', 'exclude'),
|
|
390
|
+
...ignored,
|
|
391
|
+
],
|
|
392
|
+
});
|
|
393
|
+
if (!checks)
|
|
394
|
+
return undefined;
|
|
395
|
+
const configurationInventory = await authenticatedProbe(ignoreProbeStep('setup:observe:environment-config-origins', input, ['config', '--show-origin', '--null', '--list'], checks));
|
|
396
|
+
return configurationInventory
|
|
397
|
+
&& await validConfigurationInventory(configurationInventory, root.canonicalPath, [
|
|
398
|
+
resolve(common.canonicalPath, 'config'),
|
|
399
|
+
worktreeConfig,
|
|
400
|
+
])
|
|
401
|
+
? checks
|
|
402
|
+
: undefined;
|
|
403
|
+
}
|
|
404
|
+
export async function observeEnvironmentIgnore(value, use) {
|
|
405
|
+
const input = snapshotIgnoreInput(value);
|
|
406
|
+
if (!input || typeof use !== 'function')
|
|
407
|
+
return undefined;
|
|
408
|
+
const observedAt = Date.parse(input.observedAt);
|
|
409
|
+
const issuedAt = Date.now();
|
|
410
|
+
if (observedAt > issuedAt || issuedAt - observedAt > IGNORE_OBSERVATION_LIFETIME_MS)
|
|
411
|
+
return undefined;
|
|
412
|
+
const root = await stableDirectory(input.siblingRoot);
|
|
413
|
+
if (!root || deriveCheckoutId({ checkoutPath: root.canonicalPath }) !== input.checkoutId)
|
|
414
|
+
return undefined;
|
|
415
|
+
const loaded = await loadCockpitConfig(input.source.path);
|
|
416
|
+
if (loaded.kind === 'refusal' || !sameSource(loaded.result.source, input.source))
|
|
417
|
+
return undefined;
|
|
418
|
+
const [mainCheckout, siblingsDirectory] = await Promise.all([
|
|
419
|
+
stableDirectory(input.mainCheckoutPath),
|
|
420
|
+
stableDirectory(input.siblingsRoot),
|
|
421
|
+
]);
|
|
422
|
+
if (!mainCheckout || !siblingsDirectory)
|
|
423
|
+
return undefined;
|
|
424
|
+
const sibling = loaded.result.config.sibling[input.siblingIndex];
|
|
425
|
+
const environmentFile = sibling?.environment_files?.[input.environmentFileIndex];
|
|
426
|
+
const expectedRoot = sibling ? resolve(input.siblingsRoot, sibling.dir) : undefined;
|
|
427
|
+
if (!sibling || sibling.slug !== input.siblingSlug || expectedRoot !== root.canonicalPath
|
|
428
|
+
|| resolve(dirname(loaded.result.source.path), loaded.result.config.cockpit.siblings_root) !== siblingsDirectory.canonicalPath
|
|
429
|
+
|| JSON.stringify(environmentFile) !== JSON.stringify(input.environmentFile))
|
|
430
|
+
return undefined;
|
|
431
|
+
const sanitizedInput = { ...input, env: gitEnvironment(input.env) };
|
|
432
|
+
const repositoryChecks = await ignoreRepositoryChecks(sanitizedInput, root);
|
|
433
|
+
if (!repositoryChecks)
|
|
434
|
+
return undefined;
|
|
435
|
+
const ignoreStatus = await observeIgnoreStatus(ignoreProbeStep('setup:observe:environment-ignore', sanitizedInput, [
|
|
436
|
+
'-c', 'core.quotePath=false',
|
|
437
|
+
'check-ignore', '--verbose', '--non-matching', '--no-index', '--',
|
|
438
|
+
input.environmentFile.instance, input.environmentFile.template,
|
|
439
|
+
], repositoryChecks), input.environmentFile.instance, input.environmentFile.template);
|
|
440
|
+
const templateTracked = await probeBoolean(ignoreProbeStep('setup:observe:environment-template-tracked', sanitizedInput, ['ls-files', '--error-unmatch', '--', input.environmentFile.template], repositoryChecks));
|
|
441
|
+
if (!ignoreStatus)
|
|
442
|
+
return undefined;
|
|
443
|
+
if (!ignoreStatus.instanceIgnored)
|
|
444
|
+
return {
|
|
445
|
+
kind: 'refusal', code: ENVIRONMENT_CODES.INSTANCE_NOT_IGNORED, path: input.environmentFile.instance,
|
|
446
|
+
};
|
|
447
|
+
if (templateTracked !== true)
|
|
448
|
+
return {
|
|
449
|
+
kind: 'refusal', code: ENVIRONMENT_CODES.TEMPLATE_NOT_TRACKED, path: input.environmentFile.template,
|
|
450
|
+
};
|
|
451
|
+
if (ignoreStatus.templateIgnored)
|
|
452
|
+
return {
|
|
453
|
+
kind: 'refusal', code: ENVIRONMENT_CODES.TEMPLATE_IGNORED, path: input.environmentFile.template,
|
|
454
|
+
};
|
|
455
|
+
const validThrough = observedAt + IGNORE_OBSERVATION_LIFETIME_MS;
|
|
456
|
+
const finalRoot = await authenticatedProbe(ignoreProbeStep('setup:observe:environment-final-authority', sanitizedInput, ['rev-parse', '--path-format=absolute', '--show-toplevel'], repositoryChecks));
|
|
457
|
+
if (!finalRoot || finalRoot.toString('utf8').trimEnd() !== root.canonicalPath
|
|
458
|
+
|| Date.now() > validThrough)
|
|
459
|
+
return undefined;
|
|
460
|
+
const result = use();
|
|
461
|
+
if (isPromiseLike(result)) {
|
|
462
|
+
void Promise.resolve(result).catch(() => undefined);
|
|
463
|
+
throw new RefusalError('environment ignore projection callback must be synchronous');
|
|
464
|
+
}
|
|
465
|
+
return result;
|
|
466
|
+
}
|
|
467
|
+
const issuedSetupCheckoutObservations = new WeakMap();
|
|
468
|
+
const issuedSetupCheckoutProjections = new WeakMap();
|
|
469
|
+
function setupCheckoutFailure(reason) {
|
|
470
|
+
return {
|
|
471
|
+
state: 'failed',
|
|
472
|
+
producer: OWNER,
|
|
473
|
+
code: ENVIRONMENT_CODES.SETUP_PATH_TYPE_INVALID,
|
|
474
|
+
reason,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
function snapshotSetupCheckoutInput(input) {
|
|
478
|
+
const snapshot = snapshotJson(input);
|
|
479
|
+
if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot))
|
|
480
|
+
return undefined;
|
|
481
|
+
const value = snapshot;
|
|
482
|
+
const expectedKeys = ['cockpitName', 'env', 'gitExecutable', 'observedAt', 'source'];
|
|
483
|
+
const executable = resolvedGitExecutable(value.gitExecutable);
|
|
484
|
+
if (!exactKeys(value, expectedKeys)
|
|
485
|
+
|| !validSource(value.source)
|
|
486
|
+
|| typeof value.cockpitName !== 'string' || !COCKPIT_NAME.test(value.cockpitName)
|
|
487
|
+
|| !isCanonicalTimestamp(value.observedAt)
|
|
488
|
+
|| !executable
|
|
489
|
+
|| value.env === null || typeof value.env !== 'object' || Array.isArray(value.env)
|
|
490
|
+
|| Object.entries(value.env).some(([name, entry]) => name.length === 0 || name.includes('\0') || name.includes('=')
|
|
491
|
+
|| typeof entry !== 'string' || entry.includes('\0')))
|
|
492
|
+
return undefined;
|
|
493
|
+
return {
|
|
494
|
+
source: { ...value.source },
|
|
495
|
+
cockpitName: value.cockpitName,
|
|
496
|
+
observedAt: value.observedAt,
|
|
497
|
+
env: { ...value.env },
|
|
498
|
+
gitExecutable: executable,
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
function exactLines(bytes, count) {
|
|
502
|
+
try {
|
|
503
|
+
const decoded = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
504
|
+
if (!decoded.endsWith('\n') || decoded.includes('\0') || /\r/.test(decoded))
|
|
505
|
+
return undefined;
|
|
506
|
+
const lines = decoded.slice(0, -1).split('\n');
|
|
507
|
+
return lines.length === count && lines.every((line) => line.length > 0) ? lines : undefined;
|
|
508
|
+
}
|
|
509
|
+
catch {
|
|
510
|
+
// Invalid UTF-8 is the only throw and means the Git output is malformed.
|
|
511
|
+
return undefined;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
function canonicalAbsolutePath(path) {
|
|
515
|
+
return isAbsolute(path) && normalize(path) === path && !/[\0\r\n]/.test(path);
|
|
516
|
+
}
|
|
517
|
+
function worktreeInventory(bytes) {
|
|
518
|
+
try {
|
|
519
|
+
const decoded = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
520
|
+
if (!decoded.endsWith('\0\0'))
|
|
521
|
+
return undefined;
|
|
522
|
+
const records = decoded.slice(0, -2).split('\0\0');
|
|
523
|
+
if (records.length === 0 || records.length > 4_096 || records.some((record) => record.length === 0))
|
|
524
|
+
return undefined;
|
|
525
|
+
const result = [];
|
|
526
|
+
const paths = new Set();
|
|
527
|
+
for (const record of records) {
|
|
528
|
+
const fields = record.split('\0');
|
|
529
|
+
const worktree = fields.shift();
|
|
530
|
+
if (!worktree?.startsWith('worktree '))
|
|
531
|
+
return undefined;
|
|
532
|
+
const path = worktree.slice('worktree '.length);
|
|
533
|
+
if (!canonicalAbsolutePath(path) || paths.has(path))
|
|
534
|
+
return undefined;
|
|
535
|
+
paths.add(path);
|
|
536
|
+
const kinds = new Set();
|
|
537
|
+
for (const field of fields) {
|
|
538
|
+
const separator = field.indexOf(' ');
|
|
539
|
+
const kind = separator === -1 ? field : field.slice(0, separator);
|
|
540
|
+
const detail = separator === -1 ? '' : field.slice(separator + 1);
|
|
541
|
+
if (kinds.has(kind) || !['HEAD', 'branch', 'bare', 'detached', 'locked', 'prunable'].includes(kind))
|
|
542
|
+
return undefined;
|
|
543
|
+
kinds.add(kind);
|
|
544
|
+
if (kind === 'HEAD' && !/^[a-f0-9]{40}(?:[a-f0-9]{24})?$/.test(detail))
|
|
545
|
+
return undefined;
|
|
546
|
+
if (kind === 'branch' && !validRefName(detail))
|
|
547
|
+
return undefined;
|
|
548
|
+
if ((kind === 'bare' || kind === 'detached') && detail !== '')
|
|
549
|
+
return undefined;
|
|
550
|
+
}
|
|
551
|
+
const modeCount = Number(kinds.has('branch')) + Number(kinds.has('bare')) + Number(kinds.has('detached'));
|
|
552
|
+
if (modeCount !== 1 || (kinds.has('bare') ? kinds.has('HEAD') || kinds.has('branch') || kinds.has('detached') : !kinds.has('HEAD'))) {
|
|
553
|
+
return undefined;
|
|
554
|
+
}
|
|
555
|
+
result.push({ path });
|
|
556
|
+
}
|
|
557
|
+
return result;
|
|
558
|
+
}
|
|
559
|
+
catch {
|
|
560
|
+
// A malformed worktree listing yields no administrative authority; callers report the failed observation.
|
|
561
|
+
return undefined;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
function validRefName(value) {
|
|
565
|
+
if (!value.startsWith('refs/') || value.endsWith('/') || value.endsWith('.') || value.includes('//')
|
|
566
|
+
|| value.includes('..') || value.includes('@{') || value === '@'
|
|
567
|
+
|| /[\x00-\x20\x7f~^:?*\\[]/.test(value))
|
|
568
|
+
return false;
|
|
569
|
+
return value.split('/').every((component) => component.length > 0 && component !== '.' && component !== '..'
|
|
570
|
+
&& !component.startsWith('.') && !component.endsWith('.lock'));
|
|
571
|
+
}
|
|
572
|
+
async function validConfigurationInventory(bytes, checkout, allowedPaths) {
|
|
573
|
+
let fields;
|
|
574
|
+
try {
|
|
575
|
+
const decoded = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
576
|
+
if (!decoded.endsWith('\0'))
|
|
577
|
+
return false;
|
|
578
|
+
fields = decoded.slice(0, -1).split('\0');
|
|
579
|
+
}
|
|
580
|
+
catch {
|
|
581
|
+
// Invalid UTF-8 is the only throw and means the Git configuration is malformed.
|
|
582
|
+
return false;
|
|
583
|
+
}
|
|
584
|
+
if (fields.length === 0 || fields.length % 2 !== 0 || fields.length > 8_192)
|
|
585
|
+
return false;
|
|
586
|
+
const allowed = new Set(allowedPaths);
|
|
587
|
+
for (let index = 0; index < fields.length; index += 2) {
|
|
588
|
+
const origin = fields[index];
|
|
589
|
+
const entry = fields[index + 1];
|
|
590
|
+
if (!origin || entry === undefined)
|
|
591
|
+
return false;
|
|
592
|
+
const separator = entry.indexOf('\n');
|
|
593
|
+
if (separator <= 0)
|
|
594
|
+
return false;
|
|
595
|
+
const key = entry.slice(0, separator);
|
|
596
|
+
const value = entry.slice(separator + 1);
|
|
597
|
+
if (key === 'include.path' || key.startsWith('includeif.'))
|
|
598
|
+
return false;
|
|
599
|
+
if (origin === 'command line:') {
|
|
600
|
+
if (key !== 'core.excludesfile' || value !== '/dev/null')
|
|
601
|
+
return false;
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
if (!origin.startsWith('file:'))
|
|
605
|
+
return false;
|
|
606
|
+
const path = origin.slice('file:'.length);
|
|
607
|
+
if (path.length === 0 || path.includes('\0'))
|
|
608
|
+
return false;
|
|
609
|
+
let canonical;
|
|
610
|
+
try {
|
|
611
|
+
canonical = await realpath(isAbsolute(path) ? path : resolve(checkout, path));
|
|
612
|
+
}
|
|
613
|
+
catch {
|
|
614
|
+
// An unresolvable origin path is not an allowed worktree; the caller returns false.
|
|
615
|
+
return false;
|
|
616
|
+
}
|
|
617
|
+
if (!allowed.has(canonical))
|
|
618
|
+
return false;
|
|
619
|
+
}
|
|
620
|
+
return true;
|
|
621
|
+
}
|
|
622
|
+
function sameRecordedObject(observed, recorded) {
|
|
623
|
+
return Number(observed.dev) === recorded.dev && Number(observed.ino) === recorded.ino && observed.birthtimeNs === recorded.birthtimeNs;
|
|
624
|
+
}
|
|
625
|
+
async function authorityFingerprint(checks) {
|
|
626
|
+
const result = [];
|
|
627
|
+
for (const check of checks) {
|
|
628
|
+
if (check.identity) {
|
|
629
|
+
for (const ancestor of check.identity.ancestors) {
|
|
630
|
+
try {
|
|
631
|
+
const status = await lstat(ancestor.path, { bigint: true });
|
|
632
|
+
if (!status.isDirectory() || status.isSymbolicLink() || !sameFilesystemObject(status, ancestor))
|
|
633
|
+
return undefined;
|
|
634
|
+
}
|
|
635
|
+
catch {
|
|
636
|
+
// Any lstat failure means the authority fingerprint cannot be established; callers report the failed observation.
|
|
637
|
+
return undefined;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
if (check.expected === 'absent') {
|
|
642
|
+
try {
|
|
643
|
+
await lstat(check.path);
|
|
644
|
+
return undefined;
|
|
645
|
+
}
|
|
646
|
+
catch (error) {
|
|
647
|
+
if (!hasErrorCode(error, 'ENOENT'))
|
|
648
|
+
return undefined;
|
|
649
|
+
result.push([check.path, 'absent']);
|
|
650
|
+
continue;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
if (check.expected === 'directory') {
|
|
654
|
+
const directory = await stableDirectory(check.path);
|
|
655
|
+
if (!directory || (check.identity && !sameRecordedObject(directory.identity, check.identity)))
|
|
656
|
+
return undefined;
|
|
657
|
+
result.push([check.path, 'directory', directory.identity]);
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
const file = await stableFile(check.path);
|
|
661
|
+
if (!file || (check.maxBytes !== undefined && file.byteLength > check.maxBytes)
|
|
662
|
+
|| (check.sha256 !== undefined && file.sha256 !== check.sha256)
|
|
663
|
+
|| (check.identity && !sameRecordedObject(file.identity, check.identity)))
|
|
664
|
+
return undefined;
|
|
665
|
+
result.push([check.path, 'file', file]);
|
|
666
|
+
}
|
|
667
|
+
return JSON.stringify(result);
|
|
668
|
+
}
|
|
669
|
+
async function authenticatedProbeOutcome(step) {
|
|
670
|
+
const before = await authorityFingerprint(step.filesystemChecks);
|
|
671
|
+
if (!before)
|
|
672
|
+
return undefined;
|
|
673
|
+
const outcome = authenticatedExecutionOutcome(await executeProbe(step), step);
|
|
674
|
+
const after = await authorityFingerprint(step.filesystemChecks);
|
|
675
|
+
return after === before ? outcome : undefined;
|
|
676
|
+
}
|
|
677
|
+
async function authenticatedProbe(step) {
|
|
678
|
+
const outcome = await authenticatedProbeOutcome(step);
|
|
679
|
+
return outcome?.kind === 'complete' ? outcome.stdout : undefined;
|
|
680
|
+
}
|
|
681
|
+
function setupProbeStep(id, executable, env, checkout, args, filesystemChecks = []) {
|
|
682
|
+
return probeStep(id, [executable.canonicalPath, '-c', 'core.excludesFile=/dev/null', '-C', checkout, ...args], checkout, env, executable, filesystemChecks);
|
|
683
|
+
}
|
|
684
|
+
async function canonicalDirectory(path) {
|
|
685
|
+
if (!canonicalAbsolutePath(path))
|
|
686
|
+
return undefined;
|
|
687
|
+
return stableDirectory(path);
|
|
688
|
+
}
|
|
689
|
+
async function setupFilesystemChecks(input) {
|
|
690
|
+
const checks = new Map();
|
|
691
|
+
const source = await boundedFileCheck(input.sourcePath);
|
|
692
|
+
if (!source)
|
|
693
|
+
return undefined;
|
|
694
|
+
checks.set(source.path, source);
|
|
695
|
+
for (const directory of input.directories) {
|
|
696
|
+
const check = await directoryCheck(directory.canonicalPath, directory.identity);
|
|
697
|
+
if (!check)
|
|
698
|
+
return undefined;
|
|
699
|
+
checks.set(check.path, check);
|
|
700
|
+
}
|
|
701
|
+
for (const path of input.configurationPaths ?? []) {
|
|
702
|
+
const check = await boundedFileCheck(path);
|
|
703
|
+
if (!check || check.expected !== 'file')
|
|
704
|
+
return undefined;
|
|
705
|
+
checks.set(check.path, check);
|
|
706
|
+
}
|
|
707
|
+
for (const path of input.administrativePaths ?? []) {
|
|
708
|
+
try {
|
|
709
|
+
const status = await lstat(path);
|
|
710
|
+
if (status.isDirectory() && !status.isSymbolicLink()) {
|
|
711
|
+
const directory = await stableDirectory(path);
|
|
712
|
+
if (!directory)
|
|
713
|
+
return undefined;
|
|
714
|
+
const check = await directoryCheck(directory.canonicalPath, directory.identity);
|
|
715
|
+
if (!check)
|
|
716
|
+
return undefined;
|
|
717
|
+
checks.set(check.path, check);
|
|
718
|
+
}
|
|
719
|
+
else if (status.isFile() && !status.isSymbolicLink()) {
|
|
720
|
+
const check = await boundedFileCheck(path);
|
|
721
|
+
if (!check || check.expected !== 'file')
|
|
722
|
+
return undefined;
|
|
723
|
+
checks.set(check.path, check);
|
|
724
|
+
}
|
|
725
|
+
else
|
|
726
|
+
return undefined;
|
|
727
|
+
}
|
|
728
|
+
catch {
|
|
729
|
+
// Any lstat failure means the check cannot be built; callers report the failed observation.
|
|
730
|
+
return undefined;
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
for (const path of input.optionalFilePaths ?? []) {
|
|
734
|
+
const check = await boundedFileCheck(path);
|
|
735
|
+
if (!check)
|
|
736
|
+
return undefined;
|
|
737
|
+
checks.set(check.path, check);
|
|
738
|
+
}
|
|
739
|
+
return [...checks.values()];
|
|
740
|
+
}
|
|
741
|
+
async function worktreeAdministrativeAuthority(commonDirectory) {
|
|
742
|
+
const root = resolve(commonDirectory, 'worktrees');
|
|
743
|
+
try {
|
|
744
|
+
const rootStatus = await lstat(root);
|
|
745
|
+
if (!rootStatus.isDirectory() || rootStatus.isSymbolicLink())
|
|
746
|
+
return undefined;
|
|
747
|
+
}
|
|
748
|
+
catch (error) {
|
|
749
|
+
return hasErrorCode(error, 'ENOENT')
|
|
750
|
+
? { paths: [], optionalFiles: [] }
|
|
751
|
+
: undefined;
|
|
752
|
+
}
|
|
753
|
+
let entries;
|
|
754
|
+
try {
|
|
755
|
+
entries = await readdir(root, { withFileTypes: true });
|
|
756
|
+
}
|
|
757
|
+
catch {
|
|
758
|
+
// An unreadable worktree directory yields no administrative authority; callers report the failed observation.
|
|
759
|
+
return undefined;
|
|
760
|
+
}
|
|
761
|
+
if (entries.length > 4_096 || entries.some((entry) => !entry.isDirectory() || entry.isSymbolicLink()))
|
|
762
|
+
return undefined;
|
|
763
|
+
const directories = entries.map((entry) => resolve(root, entry.name)).sort();
|
|
764
|
+
const optionalFiles = directories.flatMap((directory) => ['gitdir', 'HEAD', 'commondir', 'locked']
|
|
765
|
+
.map((name) => resolve(directory, name)));
|
|
766
|
+
return { paths: [root, ...directories], optionalFiles };
|
|
767
|
+
}
|
|
768
|
+
async function gitIdentity(id, checkout, input, filesystemChecks = []) {
|
|
769
|
+
const output = await authenticatedProbe(setupProbeStep(id, input.gitExecutable, input.env, checkout, [
|
|
770
|
+
'rev-parse', '--path-format=absolute', '--show-toplevel', '--absolute-git-dir', '--git-common-dir',
|
|
771
|
+
], filesystemChecks));
|
|
772
|
+
const lines = output && exactLines(output, 3);
|
|
773
|
+
if (!lines || !lines.every(canonicalAbsolutePath))
|
|
774
|
+
return undefined;
|
|
775
|
+
const [topLevel, gitDirectory, commonDirectory] = await Promise.all(lines.map(async (path) => {
|
|
776
|
+
try {
|
|
777
|
+
return await realpath(path);
|
|
778
|
+
}
|
|
779
|
+
catch { /* An unresolvable Git path yields no identity; the caller reports the failed observation. */
|
|
780
|
+
return undefined;
|
|
781
|
+
}
|
|
782
|
+
}));
|
|
783
|
+
return topLevel && gitDirectory && commonDirectory
|
|
784
|
+
? { topLevel, gitDirectory, commonDirectory }
|
|
785
|
+
: undefined;
|
|
786
|
+
}
|
|
787
|
+
function sameGitIdentity(left, right) {
|
|
788
|
+
return left.topLevel === right.topLevel && left.gitDirectory === right.gitDirectory
|
|
789
|
+
&& left.commonDirectory === right.commonDirectory;
|
|
790
|
+
}
|
|
791
|
+
export async function observeSetupCheckout(input) {
|
|
792
|
+
const supplied = snapshotSetupCheckoutInput(input);
|
|
793
|
+
if (!supplied)
|
|
794
|
+
return setupCheckoutFailure('setup checkout observation input is malformed');
|
|
795
|
+
const value = { ...supplied, env: gitEnvironment(supplied.env) };
|
|
796
|
+
const issuedAt = Date.now();
|
|
797
|
+
const observedAt = Date.parse(value.observedAt);
|
|
798
|
+
if (observedAt > issuedAt || issuedAt - observedAt > SETUP_CHECKOUT_OBSERVATION_LIFETIME_MS) {
|
|
799
|
+
return setupCheckoutFailure('setup checkout observation time is outside the freshness window');
|
|
800
|
+
}
|
|
801
|
+
const source = await stableFile(value.source.path);
|
|
802
|
+
if (!source || source.byteLength !== value.source.byteLength || source.sha256 !== value.source.sha256) {
|
|
803
|
+
return setupCheckoutFailure('configuration source does not match its declared identity');
|
|
804
|
+
}
|
|
805
|
+
const startingDirectory = await canonicalDirectory(dirname(value.source.path));
|
|
806
|
+
if (!startingDirectory)
|
|
807
|
+
return setupCheckoutFailure('configuration directory is unavailable');
|
|
808
|
+
const sourceChecks = await setupFilesystemChecks({
|
|
809
|
+
sourcePath: value.source.path,
|
|
810
|
+
directories: [startingDirectory],
|
|
811
|
+
});
|
|
812
|
+
if (!sourceChecks)
|
|
813
|
+
return setupCheckoutFailure('configuration source authority is unavailable');
|
|
814
|
+
const current = await gitIdentity('setup:observe:checkout-current', startingDirectory.canonicalPath, value, sourceChecks);
|
|
815
|
+
if (!current || !confined(current.topLevel, value.source.path)) {
|
|
816
|
+
return setupCheckoutFailure('Git could not authenticate the current checkout; setup requires a Git working tree to bound repository writes. '
|
|
817
|
+
+ `Inspect it with ${formatShellCommand(['git', '-C', startingDirectory.canonicalPath, 'rev-parse', '--show-toplevel'])}; `
|
|
818
|
+
+ `if this is a new directory, initialize it with ${formatShellCommand(['git', '-C', startingDirectory.canonicalPath, 'init'])}, `
|
|
819
|
+
+ `then retry ${cockpitCommand('setup', { config: value.source.path })}`);
|
|
820
|
+
}
|
|
821
|
+
const currentDirectories = await Promise.all([...new Set([current.topLevel, current.gitDirectory, current.commonDirectory])].map(canonicalDirectory));
|
|
822
|
+
if (currentDirectories.some((entry) => !entry))
|
|
823
|
+
return setupCheckoutFailure('current checkout identity paths are unavailable');
|
|
824
|
+
const currentAdministrative = await worktreeAdministrativeAuthority(current.commonDirectory);
|
|
825
|
+
if (!currentAdministrative)
|
|
826
|
+
return setupCheckoutFailure('worktree administrative authority is unavailable');
|
|
827
|
+
const currentWorktreeConfiguration = resolve(current.gitDirectory, 'config.worktree');
|
|
828
|
+
const currentChecks = await setupFilesystemChecks({
|
|
829
|
+
sourcePath: value.source.path,
|
|
830
|
+
directories: currentDirectories,
|
|
831
|
+
configurationPaths: [resolve(current.commonDirectory, 'config')],
|
|
832
|
+
administrativePaths: [resolve(current.topLevel, '.git'), ...currentAdministrative.paths],
|
|
833
|
+
optionalFilePaths: [...currentAdministrative.optionalFiles, currentWorktreeConfiguration],
|
|
834
|
+
});
|
|
835
|
+
if (!currentChecks)
|
|
836
|
+
return setupCheckoutFailure('current checkout authority is unavailable');
|
|
837
|
+
const configurationArguments = ['config', '--show-origin', '--null', '--list'];
|
|
838
|
+
const configurationInventory = await authenticatedProbe(setupProbeStep('setup:observe:checkout-config-origins', value.gitExecutable, value.env, current.topLevel, configurationArguments, currentChecks));
|
|
839
|
+
const allowedCurrentConfigurations = [resolve(current.commonDirectory, 'config'), currentWorktreeConfiguration];
|
|
840
|
+
if (!configurationInventory
|
|
841
|
+
|| !await validConfigurationInventory(configurationInventory, current.topLevel, allowedCurrentConfigurations)) {
|
|
842
|
+
return setupCheckoutFailure('Git configuration origins are unsupported');
|
|
843
|
+
}
|
|
844
|
+
const worktrees = await authenticatedProbe(setupProbeStep('setup:observe:checkout-worktrees', value.gitExecutable, value.env, current.topLevel, ['worktree', 'list', '--porcelain', '-z'], currentChecks));
|
|
845
|
+
const inventory = worktrees && worktreeInventory(worktrees);
|
|
846
|
+
if (!inventory)
|
|
847
|
+
return setupCheckoutFailure('Git returned an invalid worktree inventory');
|
|
848
|
+
const first = inventory[0];
|
|
849
|
+
if (!first)
|
|
850
|
+
return setupCheckoutFailure('Git returned an empty worktree inventory');
|
|
851
|
+
const directMembership = inventory.filter((record) => record.path === current.topLevel).length;
|
|
852
|
+
const separateMain = first.path === current.commonDirectory && current.gitDirectory === current.commonDirectory;
|
|
853
|
+
if (directMembership + Number(separateMain) !== 1) {
|
|
854
|
+
return setupCheckoutFailure('current checkout is not uniquely present in the worktree inventory');
|
|
855
|
+
}
|
|
856
|
+
const declaredMain = first.path;
|
|
857
|
+
const mainProbeRoot = separateMain ? current.topLevel : declaredMain;
|
|
858
|
+
// When the current checkout is the main one, probing its top level again would repeat the first answer.
|
|
859
|
+
const main = mainProbeRoot === current.topLevel ? current
|
|
860
|
+
: await gitIdentity('setup:observe:checkout-main', mainProbeRoot, value, currentChecks);
|
|
861
|
+
if (!main || main.topLevel !== mainProbeRoot || main.commonDirectory !== current.commonDirectory) {
|
|
862
|
+
return setupCheckoutFailure('Git could not authenticate the main checkout; setup requires its Git working-tree identity to bound repository writes. '
|
|
863
|
+
+ `Inspect it with ${formatShellCommand(['git', '-C', mainProbeRoot, 'rev-parse', '--show-toplevel'])}, `
|
|
864
|
+
+ `restore the intended checkout, then retry ${cockpitCommand('setup', { config: value.source.path })}`);
|
|
865
|
+
}
|
|
866
|
+
if (current.topLevel !== main.topLevel) {
|
|
867
|
+
const mainDotGit = await stableDirectory(resolve(main.topLevel, '.git'));
|
|
868
|
+
if (!mainDotGit || main.gitDirectory !== mainDotGit.canonicalPath || main.commonDirectory !== mainDotGit.canonicalPath) {
|
|
869
|
+
return setupCheckoutFailure('linked checkout uses an unsupported external Git directory');
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
const paths = [...new Set([
|
|
873
|
+
current.topLevel,
|
|
874
|
+
current.gitDirectory,
|
|
875
|
+
current.commonDirectory,
|
|
876
|
+
main.topLevel,
|
|
877
|
+
main.gitDirectory,
|
|
878
|
+
main.commonDirectory,
|
|
879
|
+
])];
|
|
880
|
+
const observedDirectories = await Promise.all(paths.map(canonicalDirectory));
|
|
881
|
+
if (observedDirectories.some((entry) => !entry))
|
|
882
|
+
return setupCheckoutFailure('checkout identity paths are unavailable');
|
|
883
|
+
const allAdministrative = await worktreeAdministrativeAuthority(current.commonDirectory);
|
|
884
|
+
if (!allAdministrative)
|
|
885
|
+
return setupCheckoutFailure('worktree administrative authority is unavailable');
|
|
886
|
+
const worktreeConfigurationPaths = [...new Set([
|
|
887
|
+
resolve(current.gitDirectory, 'config.worktree'), resolve(main.gitDirectory, 'config.worktree'),
|
|
888
|
+
])];
|
|
889
|
+
const allChecks = await setupFilesystemChecks({
|
|
890
|
+
sourcePath: value.source.path,
|
|
891
|
+
directories: observedDirectories,
|
|
892
|
+
configurationPaths: [resolve(current.commonDirectory, 'config'), resolve(main.commonDirectory, 'config')],
|
|
893
|
+
administrativePaths: [resolve(current.topLevel, '.git'), resolve(main.topLevel, '.git'), ...allAdministrative.paths],
|
|
894
|
+
optionalFilePaths: [...allAdministrative.optionalFiles, ...worktreeConfigurationPaths],
|
|
895
|
+
});
|
|
896
|
+
if (!allChecks)
|
|
897
|
+
return setupCheckoutFailure('checkout authority is unavailable');
|
|
898
|
+
if (main.topLevel !== current.topLevel) {
|
|
899
|
+
const mainConfigurationInventory = await authenticatedProbe(setupProbeStep('setup:observe:checkout-main-config-origins', value.gitExecutable, value.env, main.topLevel, configurationArguments, allChecks));
|
|
900
|
+
const allowedMainConfigurations = [
|
|
901
|
+
resolve(main.commonDirectory, 'config'), resolve(main.gitDirectory, 'config.worktree'),
|
|
902
|
+
];
|
|
903
|
+
if (!mainConfigurationInventory
|
|
904
|
+
|| !await validConfigurationInventory(mainConfigurationInventory, main.topLevel, allowedMainConfigurations)) {
|
|
905
|
+
return setupCheckoutFailure('main checkout Git configuration origins are unsupported');
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
const confirmedCurrent = await gitIdentity('setup:observe:checkout-current-confirm', current.topLevel, value, allChecks);
|
|
909
|
+
const confirmedMain = main.topLevel === current.topLevel ? confirmedCurrent
|
|
910
|
+
: await gitIdentity('setup:observe:checkout-main-confirm', main.topLevel, value, allChecks);
|
|
911
|
+
if (!confirmedCurrent || !confirmedMain || !sameGitIdentity(current, confirmedCurrent)
|
|
912
|
+
|| !sameGitIdentity(main, confirmedMain)) {
|
|
913
|
+
return setupCheckoutFailure('checkout identity changed during observation');
|
|
914
|
+
}
|
|
915
|
+
const administrativeDirectories = [];
|
|
916
|
+
const administrativeFiles = [];
|
|
917
|
+
for (const path of [...new Set([
|
|
918
|
+
resolve(current.topLevel, '.git'), resolve(main.topLevel, '.git'),
|
|
919
|
+
...allAdministrative.paths, ...allAdministrative.optionalFiles, ...worktreeConfigurationPaths,
|
|
920
|
+
])]) {
|
|
921
|
+
try {
|
|
922
|
+
const status = await lstat(path);
|
|
923
|
+
if (status.isDirectory() && !status.isSymbolicLink()) {
|
|
924
|
+
const directory = await stableDirectory(path);
|
|
925
|
+
if (!directory)
|
|
926
|
+
return setupCheckoutFailure('Git administrative entry is unavailable');
|
|
927
|
+
administrativeDirectories.push(directory);
|
|
928
|
+
}
|
|
929
|
+
else if (status.isFile() && !status.isSymbolicLink()) {
|
|
930
|
+
const file = await stableFile(path);
|
|
931
|
+
if (!file)
|
|
932
|
+
return setupCheckoutFailure('Git administrative entry is unavailable');
|
|
933
|
+
administrativeFiles.push([path, file]);
|
|
934
|
+
}
|
|
935
|
+
else
|
|
936
|
+
return setupCheckoutFailure('Git administrative entry is invalid');
|
|
937
|
+
}
|
|
938
|
+
catch (error) {
|
|
939
|
+
if ((allAdministrative.optionalFiles.includes(path) || worktreeConfigurationPaths.includes(path))
|
|
940
|
+
&& hasErrorCode(error, 'ENOENT'))
|
|
941
|
+
continue;
|
|
942
|
+
return setupCheckoutFailure('Git administrative entry is unavailable');
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
const directoryIdentities = Object.fromEntries([...observedDirectories.map((entry) => entry), ...administrativeDirectories]
|
|
946
|
+
.map((entry) => [entry.canonicalPath, entry.identity]));
|
|
947
|
+
const configurationPaths = [...new Set([
|
|
948
|
+
resolve(current.commonDirectory, 'config'),
|
|
949
|
+
resolve(main.commonDirectory, 'config'),
|
|
950
|
+
])];
|
|
951
|
+
const configurationFiles = await Promise.all(configurationPaths.map(stableFile));
|
|
952
|
+
if (configurationFiles.some((entry) => !entry))
|
|
953
|
+
return setupCheckoutFailure('Git configuration is unavailable');
|
|
954
|
+
const fileIdentities = Object.fromEntries([
|
|
955
|
+
...configurationPaths.map((path, index) => [path, configurationFiles[index]]),
|
|
956
|
+
...administrativeFiles,
|
|
957
|
+
]);
|
|
958
|
+
const validThrough = new Date(observedAt + SETUP_CHECKOUT_OBSERVATION_LIFETIME_MS).toISOString();
|
|
959
|
+
const binding = {
|
|
960
|
+
source: value.source,
|
|
961
|
+
cockpitName: value.cockpitName,
|
|
962
|
+
currentCheckoutPath: current.topLevel,
|
|
963
|
+
mainCheckoutPath: main.topLevel,
|
|
964
|
+
cockpitId: deriveCockpitId({ name: value.cockpitName, mainCheckoutPath: main.topLevel }),
|
|
965
|
+
checkoutId: deriveCheckoutId({ checkoutPath: current.topLevel }),
|
|
966
|
+
observedAt: value.observedAt,
|
|
967
|
+
validThrough,
|
|
968
|
+
sourceIdentity: source.identity,
|
|
969
|
+
directoryIdentities,
|
|
970
|
+
fileIdentities,
|
|
971
|
+
};
|
|
972
|
+
const observation = { state: 'available', producer: OWNER, observedAt: value.observedAt, validThrough };
|
|
973
|
+
issuedSetupCheckoutObservations.set(observation, binding);
|
|
974
|
+
return observation;
|
|
975
|
+
}
|
|
976
|
+
function sameSource(left, right) {
|
|
977
|
+
return left.path === right.path && left.byteLength === right.byteLength && left.sha256 === right.sha256;
|
|
978
|
+
}
|
|
979
|
+
function snapshotSetupCheckoutContext(value) {
|
|
980
|
+
const snapshot = snapshotJson(value);
|
|
981
|
+
if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot))
|
|
982
|
+
return undefined;
|
|
983
|
+
const context = snapshot;
|
|
984
|
+
const expectedKeys = ['cockpitName', 'evaluatedAt', 'source'];
|
|
985
|
+
return exactKeys(context, expectedKeys)
|
|
986
|
+
&& validSource(context.source)
|
|
987
|
+
&& typeof context.cockpitName === 'string' && COCKPIT_NAME.test(context.cockpitName)
|
|
988
|
+
&& Number.isSafeInteger(context.evaluatedAt)
|
|
989
|
+
? {
|
|
990
|
+
source: { ...context.source },
|
|
991
|
+
cockpitName: context.cockpitName,
|
|
992
|
+
evaluatedAt: context.evaluatedAt,
|
|
993
|
+
}
|
|
994
|
+
: undefined;
|
|
995
|
+
}
|
|
996
|
+
export async function withSetupCheckoutProjection(observation, expected, use) {
|
|
997
|
+
if (observation === null || typeof observation !== 'object')
|
|
998
|
+
return undefined;
|
|
999
|
+
const binding = issuedSetupCheckoutObservations.get(observation);
|
|
1000
|
+
issuedSetupCheckoutObservations.delete(observation);
|
|
1001
|
+
const context = snapshotSetupCheckoutContext(expected);
|
|
1002
|
+
const now = Date.now();
|
|
1003
|
+
if (!binding || !context || typeof use !== 'function'
|
|
1004
|
+
|| !sameSource(binding.source, context.source) || binding.cockpitName !== context.cockpitName
|
|
1005
|
+
|| context.evaluatedAt !== Date.parse(binding.observedAt) || context.evaluatedAt > now
|
|
1006
|
+
|| now > Date.parse(binding.validThrough))
|
|
1007
|
+
return undefined;
|
|
1008
|
+
if (!await checkoutBindingCurrent(binding))
|
|
1009
|
+
return undefined;
|
|
1010
|
+
const consumed = {
|
|
1011
|
+
source: binding.source,
|
|
1012
|
+
cockpitName: binding.cockpitName,
|
|
1013
|
+
currentCheckoutPath: binding.currentCheckoutPath,
|
|
1014
|
+
mainCheckoutPath: binding.mainCheckoutPath,
|
|
1015
|
+
cockpitId: binding.cockpitId,
|
|
1016
|
+
checkoutId: binding.checkoutId,
|
|
1017
|
+
observedAt: binding.observedAt,
|
|
1018
|
+
validThrough: binding.validThrough,
|
|
1019
|
+
};
|
|
1020
|
+
const projection = {};
|
|
1021
|
+
issuedSetupCheckoutProjections.set(projection, consumed);
|
|
1022
|
+
try {
|
|
1023
|
+
const result = use(projection);
|
|
1024
|
+
if (isPromiseLike(result)) {
|
|
1025
|
+
void Promise.resolve(result).catch(() => undefined);
|
|
1026
|
+
throw new RefusalError('setup checkout projection callback must be synchronous');
|
|
1027
|
+
}
|
|
1028
|
+
return result;
|
|
1029
|
+
}
|
|
1030
|
+
finally {
|
|
1031
|
+
issuedSetupCheckoutProjections.delete(projection);
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
async function checkoutBindingCurrent(binding, publication = false) {
|
|
1035
|
+
const source = await stableFile(binding.source.path);
|
|
1036
|
+
if (!source || !sameFileIdentity(source.identity, binding.sourceIdentity)
|
|
1037
|
+
|| source.byteLength !== binding.source.byteLength || source.sha256 !== binding.source.sha256)
|
|
1038
|
+
return false;
|
|
1039
|
+
for (const [path, identity] of Object.entries(binding.directoryIdentities)) {
|
|
1040
|
+
const current = await stableDirectory(path);
|
|
1041
|
+
// Publishing files changes checkout directory timestamps, but never its inode or Git administration.
|
|
1042
|
+
const matches = current && (publication && (path === binding.currentCheckoutPath || path === binding.mainCheckoutPath)
|
|
1043
|
+
? sameDirectoryNode(current.identity, identity) : sameDirectoryIdentity(current.identity, identity));
|
|
1044
|
+
if (!matches)
|
|
1045
|
+
return false;
|
|
1046
|
+
}
|
|
1047
|
+
for (const [path, identity] of Object.entries(binding.fileIdentities)) {
|
|
1048
|
+
const current = await stableFile(path);
|
|
1049
|
+
if (!current || !sameFileIdentity(current.identity, identity.identity)
|
|
1050
|
+
|| current.byteLength !== identity.byteLength || current.sha256 !== identity.sha256)
|
|
1051
|
+
return false;
|
|
1052
|
+
}
|
|
1053
|
+
return true;
|
|
1054
|
+
}
|
|
1055
|
+
export function consumeSetupCheckoutProjection(projection) {
|
|
1056
|
+
const binding = issuedSetupCheckoutProjections.get(projection);
|
|
1057
|
+
if (!binding)
|
|
1058
|
+
return undefined;
|
|
1059
|
+
issuedSetupCheckoutProjections.delete(projection);
|
|
1060
|
+
return binding;
|
|
1061
|
+
}
|
|
1062
|
+
const issuedGeneratedCheckoutIdentities = new WeakMap();
|
|
1063
|
+
function captureGeneratedCheckoutInput(input) {
|
|
1064
|
+
if (input === null || typeof input !== 'object' || Array.isArray(input) || !exactKeys(input, ['cockpitName', 'env', 'source']))
|
|
1065
|
+
return undefined;
|
|
1066
|
+
const { source: rawSource, cockpitName, env: rawEnvironment } = input;
|
|
1067
|
+
const source = snapshotJson(rawSource);
|
|
1068
|
+
if (!validSource(source) || typeof cockpitName !== 'string' || !COCKPIT_NAME.test(cockpitName)
|
|
1069
|
+
|| rawEnvironment === null || typeof rawEnvironment !== 'object' || Array.isArray(rawEnvironment)
|
|
1070
|
+
|| isProxy(rawEnvironment))
|
|
1071
|
+
return undefined;
|
|
1072
|
+
const environmentDescriptors = Object.getOwnPropertyDescriptors(rawEnvironment);
|
|
1073
|
+
const environmentKeys = Reflect.ownKeys(environmentDescriptors);
|
|
1074
|
+
if (environmentKeys.length > 4_096)
|
|
1075
|
+
return undefined;
|
|
1076
|
+
const env = {};
|
|
1077
|
+
for (const key of environmentKeys) {
|
|
1078
|
+
if (typeof key !== 'string')
|
|
1079
|
+
return undefined;
|
|
1080
|
+
if (key !== 'PATH')
|
|
1081
|
+
continue;
|
|
1082
|
+
const descriptor = environmentDescriptors[key];
|
|
1083
|
+
if (!descriptor.enumerable || !Object.hasOwn(descriptor, 'value')
|
|
1084
|
+
|| descriptor.value !== undefined && (typeof descriptor.value !== 'string'
|
|
1085
|
+
|| descriptor.value.includes('\0') || Buffer.byteLength(descriptor.value) > 65_536))
|
|
1086
|
+
return undefined;
|
|
1087
|
+
env[key] = descriptor.value;
|
|
1088
|
+
}
|
|
1089
|
+
return { source: { path: source.path, byteLength: source.byteLength, sha256: source.sha256 },
|
|
1090
|
+
cockpitName, env };
|
|
1091
|
+
}
|
|
1092
|
+
async function pathAbsent(path) {
|
|
1093
|
+
try {
|
|
1094
|
+
await lstat(path);
|
|
1095
|
+
return false;
|
|
1096
|
+
}
|
|
1097
|
+
catch (error) {
|
|
1098
|
+
return hasErrorCode(error, 'ENOENT');
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
export async function observeGeneratedCheckoutIdentity(input) {
|
|
1102
|
+
const value = captureGeneratedCheckoutInput(input);
|
|
1103
|
+
if (!value) {
|
|
1104
|
+
return setupCheckoutFailure('generated checkout identity input is malformed');
|
|
1105
|
+
}
|
|
1106
|
+
const source = value.source;
|
|
1107
|
+
const sourceFile = await stableFile(source.path);
|
|
1108
|
+
if (!sourceFile || sourceFile.sha256 !== source.sha256 || sourceFile.byteLength !== source.byteLength) {
|
|
1109
|
+
return setupCheckoutFailure('generated checkout configuration source is unavailable or changed');
|
|
1110
|
+
}
|
|
1111
|
+
const root = dirname(source.path);
|
|
1112
|
+
const directories = new Map();
|
|
1113
|
+
const absentGitEntries = [];
|
|
1114
|
+
let candidate = root;
|
|
1115
|
+
let hasCheckout = false;
|
|
1116
|
+
for (;;) {
|
|
1117
|
+
if (directories.size >= 256)
|
|
1118
|
+
return setupCheckoutFailure('generated checkout ancestor discovery exceeds its bound');
|
|
1119
|
+
const directory = await stableDirectoryNode(candidate);
|
|
1120
|
+
if (!directory)
|
|
1121
|
+
return setupCheckoutFailure('generated checkout ancestor cannot be authenticated');
|
|
1122
|
+
directories.set(candidate, directory.identity);
|
|
1123
|
+
const gitEntry = resolve(candidate, '.git');
|
|
1124
|
+
try {
|
|
1125
|
+
await lstat(gitEntry);
|
|
1126
|
+
hasCheckout = true;
|
|
1127
|
+
break;
|
|
1128
|
+
}
|
|
1129
|
+
catch (error) {
|
|
1130
|
+
if (!hasErrorCode(error, 'ENOENT'))
|
|
1131
|
+
return setupCheckoutFailure('generated checkout Git entry cannot be observed');
|
|
1132
|
+
}
|
|
1133
|
+
absentGitEntries.push(gitEntry);
|
|
1134
|
+
const parent = dirname(candidate);
|
|
1135
|
+
if (parent === candidate)
|
|
1136
|
+
break;
|
|
1137
|
+
candidate = parent;
|
|
1138
|
+
}
|
|
1139
|
+
const searchEnvironment = value.env;
|
|
1140
|
+
const observedAt = Date.now();
|
|
1141
|
+
if (!Number.isSafeInteger(observedAt))
|
|
1142
|
+
return setupCheckoutFailure('generated checkout observation time is invalid');
|
|
1143
|
+
let checkout;
|
|
1144
|
+
let consumed;
|
|
1145
|
+
if (hasCheckout) {
|
|
1146
|
+
const git = await resolveGitExecutable(searchEnvironment);
|
|
1147
|
+
if (git.state !== 'available')
|
|
1148
|
+
return setupCheckoutFailure(git.reason);
|
|
1149
|
+
const env = gitEnvironment(searchEnvironment);
|
|
1150
|
+
const observation = await observeSetupCheckout({ source, cockpitName: value.cockpitName,
|
|
1151
|
+
env, gitExecutable: git.value, observedAt: new Date(observedAt).toISOString() });
|
|
1152
|
+
if (observation.state === 'failed')
|
|
1153
|
+
return observation;
|
|
1154
|
+
checkout = issuedSetupCheckoutObservations.get(observation);
|
|
1155
|
+
consumed = await withSetupCheckoutProjection(observation, {
|
|
1156
|
+
source, cockpitName: value.cockpitName, evaluatedAt: observedAt,
|
|
1157
|
+
}, consumeSetupCheckoutProjection);
|
|
1158
|
+
if (!checkout || !consumed)
|
|
1159
|
+
return setupCheckoutFailure('generated checkout observation changed before consumption');
|
|
1160
|
+
}
|
|
1161
|
+
const identity = {
|
|
1162
|
+
state: 'available', kind: consumed ? 'checkout' : 'standalone',
|
|
1163
|
+
cockpitId: consumed?.cockpitId ?? deriveCockpitId({ name: value.cockpitName, mainCheckoutPath: root }),
|
|
1164
|
+
checkoutId: consumed?.checkoutId ?? deriveCheckoutId({ checkoutPath: root }),
|
|
1165
|
+
currentCheckoutPath: consumed?.currentCheckoutPath ?? root,
|
|
1166
|
+
mainCheckoutPath: consumed?.mainCheckoutPath ?? root,
|
|
1167
|
+
};
|
|
1168
|
+
const binding = { source, sourceIdentity: sourceFile.identity,
|
|
1169
|
+
cockpitName: value.cockpitName, env: searchEnvironment, directories, absentGitEntries,
|
|
1170
|
+
...(checkout ? { checkout } : {}), observedAt };
|
|
1171
|
+
const current = await generatedCheckoutBindingCurrent(binding);
|
|
1172
|
+
const completedAt = Date.now();
|
|
1173
|
+
if (!current || !Number.isSafeInteger(completedAt) || completedAt < observedAt
|
|
1174
|
+
|| completedAt - observedAt > SETUP_CHECKOUT_OBSERVATION_LIFETIME_MS) {
|
|
1175
|
+
return setupCheckoutFailure('generated checkout identity changed or expired during observation');
|
|
1176
|
+
}
|
|
1177
|
+
issuedGeneratedCheckoutIdentities.set(identity, binding);
|
|
1178
|
+
return identity;
|
|
1179
|
+
}
|
|
1180
|
+
async function generatedCheckoutBindingCurrent(binding) {
|
|
1181
|
+
const source = await stableFile(binding.source.path);
|
|
1182
|
+
if (!source || !sameFileIdentity(source.identity, binding.sourceIdentity)
|
|
1183
|
+
|| source.sha256 !== binding.source.sha256 || source.byteLength !== binding.source.byteLength)
|
|
1184
|
+
return false;
|
|
1185
|
+
for (const [path, expected] of binding.directories) {
|
|
1186
|
+
const current = await stableDirectoryNode(path);
|
|
1187
|
+
if (!current || !sameDirectoryNode(current.identity, expected))
|
|
1188
|
+
return false;
|
|
1189
|
+
}
|
|
1190
|
+
for (const path of binding.absentGitEntries)
|
|
1191
|
+
if (!await pathAbsent(path))
|
|
1192
|
+
return false;
|
|
1193
|
+
if (binding.checkout && !await checkoutBindingCurrent(binding.checkout, true))
|
|
1194
|
+
return false;
|
|
1195
|
+
return true;
|
|
1196
|
+
}
|
|
1197
|
+
export async function revalidateGeneratedCheckoutIdentity(identity) {
|
|
1198
|
+
const binding = identity && typeof identity === 'object' ? issuedGeneratedCheckoutIdentities.get(identity) : undefined;
|
|
1199
|
+
const now = Date.now();
|
|
1200
|
+
if (!binding || !Number.isSafeInteger(now) || now < binding.observedAt
|
|
1201
|
+
|| !await generatedCheckoutBindingCurrent(binding))
|
|
1202
|
+
return false;
|
|
1203
|
+
const completedAt = Date.now();
|
|
1204
|
+
if (!Number.isSafeInteger(completedAt) || completedAt < now)
|
|
1205
|
+
return false;
|
|
1206
|
+
if (completedAt - binding.observedAt > SETUP_CHECKOUT_OBSERVATION_LIFETIME_MS) {
|
|
1207
|
+
const refreshed = await observeGeneratedCheckoutIdentity({ source: binding.source, cockpitName: binding.cockpitName, env: binding.env });
|
|
1208
|
+
if (refreshed.state !== 'available' || refreshed.kind !== identity.kind
|
|
1209
|
+
|| refreshed.cockpitId !== identity.cockpitId || refreshed.checkoutId !== identity.checkoutId
|
|
1210
|
+
|| refreshed.mainCheckoutPath !== identity.mainCheckoutPath || refreshed.currentCheckoutPath !== identity.currentCheckoutPath)
|
|
1211
|
+
return false;
|
|
1212
|
+
const current = issuedGeneratedCheckoutIdentities.get(refreshed);
|
|
1213
|
+
const refreshedAt = Date.now();
|
|
1214
|
+
if (!current || !Number.isSafeInteger(refreshedAt) || refreshedAt < current.observedAt
|
|
1215
|
+
|| refreshedAt - current.observedAt > SETUP_CHECKOUT_OBSERVATION_LIFETIME_MS)
|
|
1216
|
+
return false;
|
|
1217
|
+
issuedGeneratedCheckoutIdentities.set(identity, current);
|
|
1218
|
+
issuedGeneratedCheckoutIdentities.delete(refreshed);
|
|
1219
|
+
}
|
|
1220
|
+
return true;
|
|
1221
|
+
}
|
|
1222
|
+
function confined(root, candidate) {
|
|
1223
|
+
const fromRoot = relative(root, candidate);
|
|
1224
|
+
return fromRoot !== '..' && !fromRoot.startsWith(`..${sep}`) && !isAbsolute(fromRoot);
|
|
1225
|
+
}
|
|
1226
|
+
function probeStep(id, argv, cwd, env, executable, filesystemChecks = []) {
|
|
1227
|
+
return {
|
|
1228
|
+
id,
|
|
1229
|
+
argv,
|
|
1230
|
+
argv0: executable.invocationPath,
|
|
1231
|
+
cwd,
|
|
1232
|
+
env,
|
|
1233
|
+
timeoutMs: 10_000,
|
|
1234
|
+
stdoutLimitBytes: 65_536,
|
|
1235
|
+
stderrLimitBytes: 65_536,
|
|
1236
|
+
paths: [cwd],
|
|
1237
|
+
ports: [],
|
|
1238
|
+
labels: {},
|
|
1239
|
+
expected: [],
|
|
1240
|
+
consumedFiles: [{ path: executable.canonicalPath, sha256: executable.sha256, maxBytes: 536_870_912, argvIndexes: [0], executable: true, executionMode: executable.executionMode }],
|
|
1241
|
+
filesystemChecks,
|
|
1242
|
+
compensation: [],
|
|
1243
|
+
};
|
|
1244
|
+
}
|
|
1245
|
+
function available(value) {
|
|
1246
|
+
return { state: 'available', value, observedAt: new Date().toISOString(), producer: OWNER };
|
|
1247
|
+
}
|
|
1248
|
+
function outsideRoot() {
|
|
1249
|
+
return {
|
|
1250
|
+
state: 'failed',
|
|
1251
|
+
code: ENVIRONMENT_CODES.SETUP_PATH_OUTSIDE_ROOT,
|
|
1252
|
+
reason: 'the checkout or its Git administrative directory is outside the declared siblings root',
|
|
1253
|
+
producer: OWNER,
|
|
1254
|
+
};
|
|
1255
|
+
}
|
|
1256
|
+
function failure(message, code = ENVIRONMENT_CODES.SETUP_PATH_TYPE_INVALID) {
|
|
1257
|
+
return { state: 'failed', code, reason: message, producer: OWNER };
|
|
1258
|
+
}
|
|
1259
|
+
function decodeLines(bytes) {
|
|
1260
|
+
try {
|
|
1261
|
+
const source = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
1262
|
+
return source.trimEnd().split(/\r?\n/);
|
|
1263
|
+
}
|
|
1264
|
+
catch {
|
|
1265
|
+
// Invalid UTF-8 is the only throw and means the file cannot be read as lines.
|
|
1266
|
+
return undefined;
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
async function pathChecks(target, siblingsRoot) {
|
|
1270
|
+
const checks = [];
|
|
1271
|
+
const addDirectoryCheck = async (path) => {
|
|
1272
|
+
const directory = await stableDirectory(path);
|
|
1273
|
+
if (!directory)
|
|
1274
|
+
return false;
|
|
1275
|
+
const check = await directoryCheck(directory.canonicalPath, directory.identity);
|
|
1276
|
+
if (!check)
|
|
1277
|
+
return false;
|
|
1278
|
+
checks.push(check);
|
|
1279
|
+
return true;
|
|
1280
|
+
};
|
|
1281
|
+
let current = siblingsRoot;
|
|
1282
|
+
try {
|
|
1283
|
+
const components = relative(siblingsRoot, target).split(sep).filter(Boolean);
|
|
1284
|
+
for (const component of components) {
|
|
1285
|
+
let stat;
|
|
1286
|
+
try {
|
|
1287
|
+
stat = await lstat(current);
|
|
1288
|
+
}
|
|
1289
|
+
catch (error) {
|
|
1290
|
+
if (hasErrorCode(error, 'ENOENT') && current !== siblingsRoot) {
|
|
1291
|
+
return {
|
|
1292
|
+
kind: 'observation',
|
|
1293
|
+
observation: available({
|
|
1294
|
+
kind: 'absent',
|
|
1295
|
+
missingParent: current,
|
|
1296
|
+
filesystemChecks: [...checks, { path: current, expected: 'absent' }],
|
|
1297
|
+
}),
|
|
1298
|
+
checks,
|
|
1299
|
+
};
|
|
1300
|
+
}
|
|
1301
|
+
throw error;
|
|
1302
|
+
}
|
|
1303
|
+
if (stat.isSymbolicLink()) {
|
|
1304
|
+
return { kind: 'observation', observation: available({ kind: 'symlink' }), checks };
|
|
1305
|
+
}
|
|
1306
|
+
if (!stat.isDirectory()) {
|
|
1307
|
+
return { kind: 'observation', observation: available({ kind: 'directory' }), checks };
|
|
1308
|
+
}
|
|
1309
|
+
if (!await addDirectoryCheck(current)) {
|
|
1310
|
+
return { kind: 'observation', observation: failure('a sibling path component changed during inspection'), checks };
|
|
1311
|
+
}
|
|
1312
|
+
current = resolve(current, component);
|
|
1313
|
+
}
|
|
1314
|
+
try {
|
|
1315
|
+
const stat = await lstat(target);
|
|
1316
|
+
if (stat.isSymbolicLink())
|
|
1317
|
+
return { kind: 'observation', observation: available({ kind: 'symlink' }), checks };
|
|
1318
|
+
if (!stat.isDirectory())
|
|
1319
|
+
return { kind: 'observation', observation: available({ kind: 'directory' }), checks };
|
|
1320
|
+
if (!await addDirectoryCheck(target)) {
|
|
1321
|
+
return { kind: 'observation', observation: failure('the sibling directory changed during inspection'), checks };
|
|
1322
|
+
}
|
|
1323
|
+
const dotGit = resolve(target, '.git');
|
|
1324
|
+
let dotGitStat;
|
|
1325
|
+
try {
|
|
1326
|
+
dotGitStat = await lstat(dotGit);
|
|
1327
|
+
}
|
|
1328
|
+
catch (error) {
|
|
1329
|
+
if (hasErrorCode(error, 'ENOENT')) {
|
|
1330
|
+
return {
|
|
1331
|
+
kind: 'observation',
|
|
1332
|
+
observation: available({ kind: 'directory', filesystemChecks: checks }),
|
|
1333
|
+
checks,
|
|
1334
|
+
};
|
|
1335
|
+
}
|
|
1336
|
+
return { kind: 'observation', observation: failure(`the .git entry could not be inspected: ${errorCause(error)}`), checks };
|
|
1337
|
+
}
|
|
1338
|
+
if (dotGitStat.isSymbolicLink())
|
|
1339
|
+
return { kind: 'observation', observation: available({ kind: 'symlink' }), checks };
|
|
1340
|
+
if (dotGitStat.isDirectory()) {
|
|
1341
|
+
if (!await addDirectoryCheck(dotGit)) {
|
|
1342
|
+
return { kind: 'observation', observation: failure('the Git administrative directory changed during inspection'), checks };
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
else if (dotGitStat.isFile()) {
|
|
1346
|
+
if (dotGitStat.nlink !== 1 || dotGitStat.size > CONFIGURATION_BYTE_LIMIT) {
|
|
1347
|
+
return { kind: 'observation', observation: failure('the .git entry is not a bounded private file'), checks };
|
|
1348
|
+
}
|
|
1349
|
+
const bytes = await readFile(dotGit);
|
|
1350
|
+
const after = await lstat(dotGit);
|
|
1351
|
+
if (after.nlink !== 1 || after.dev !== dotGitStat.dev || after.ino !== dotGitStat.ino
|
|
1352
|
+
|| after.size !== dotGitStat.size || after.mtimeMs !== dotGitStat.mtimeMs) {
|
|
1353
|
+
return { kind: 'observation', observation: failure('the .git entry changed during inspection'), checks };
|
|
1354
|
+
}
|
|
1355
|
+
let canonicalDotGit;
|
|
1356
|
+
try {
|
|
1357
|
+
canonicalDotGit = await realpath(dotGit);
|
|
1358
|
+
}
|
|
1359
|
+
catch (error) {
|
|
1360
|
+
return { kind: 'observation', observation: failure(`the .git entry changed during inspection: ${errorCause(error)}`), checks };
|
|
1361
|
+
}
|
|
1362
|
+
const check = await boundedFileCheck(canonicalDotGit);
|
|
1363
|
+
if (!check || check.expected !== 'file') {
|
|
1364
|
+
return { kind: 'observation', observation: failure('the .git entry changed during inspection'), checks };
|
|
1365
|
+
}
|
|
1366
|
+
checks.push(check);
|
|
1367
|
+
}
|
|
1368
|
+
else
|
|
1369
|
+
return { kind: 'observation', observation: failure('the .git entry has an invalid type'), checks };
|
|
1370
|
+
return { kind: 'continue', checks };
|
|
1371
|
+
}
|
|
1372
|
+
catch (error) {
|
|
1373
|
+
if (hasErrorCode(error, 'ENOENT'))
|
|
1374
|
+
return {
|
|
1375
|
+
kind: 'observation',
|
|
1376
|
+
observation: available({ kind: 'absent', filesystemChecks: [...checks, { path: target, expected: 'absent' }] }),
|
|
1377
|
+
checks,
|
|
1378
|
+
};
|
|
1379
|
+
return { kind: 'observation', observation: failure('the sibling path could not be inspected'), checks };
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
catch (error) {
|
|
1383
|
+
if (hasErrorCode(error, 'ENOENT')) {
|
|
1384
|
+
return {
|
|
1385
|
+
kind: 'observation',
|
|
1386
|
+
observation: failure('the siblings root is absent'),
|
|
1387
|
+
checks,
|
|
1388
|
+
};
|
|
1389
|
+
}
|
|
1390
|
+
return { kind: 'observation', observation: failure('the siblings root could not be inspected'), checks };
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
export async function observeSetupPath(target, siblingsRoot, env, gitExecutable) {
|
|
1394
|
+
const executable = resolvedGitExecutable(gitExecutable);
|
|
1395
|
+
if (executable === undefined)
|
|
1396
|
+
return failure('Git executable identity is unresolved');
|
|
1397
|
+
const inspected = await pathChecks(target, siblingsRoot);
|
|
1398
|
+
if (inspected.kind === 'observation')
|
|
1399
|
+
return inspected.observation;
|
|
1400
|
+
const identityStep = probeStep('setup:observe:identity', [executable.canonicalPath, '-C', target, 'rev-parse', '--path-format=absolute', '--show-toplevel', '--absolute-git-dir', '--git-common-dir'], siblingsRoot, env, executable, inspected.checks);
|
|
1401
|
+
const beforeIdentity = await authorityFingerprint(identityStep.filesystemChecks);
|
|
1402
|
+
if (!beforeIdentity)
|
|
1403
|
+
return failure('checkout identity changed during observation');
|
|
1404
|
+
const identityExecution = authenticatedExecutionOutcome(await executeProbe(identityStep), identityStep);
|
|
1405
|
+
const afterIdentity = await authorityFingerprint(identityStep.filesystemChecks);
|
|
1406
|
+
if (afterIdentity !== beforeIdentity)
|
|
1407
|
+
return failure('checkout identity changed during observation');
|
|
1408
|
+
if (!identityExecution || identityExecution.kind === 'refusal')
|
|
1409
|
+
return failure('Git could not identify the checkout');
|
|
1410
|
+
const lines = decodeLines(identityExecution.stdout);
|
|
1411
|
+
if (!lines || lines.length !== 3)
|
|
1412
|
+
return failure('Git returned invalid checkout identity');
|
|
1413
|
+
const [topLevel, gitDirectory, commonDirectory] = lines.map((path) => resolve(path));
|
|
1414
|
+
let canonicalRoot;
|
|
1415
|
+
let canonicalTarget;
|
|
1416
|
+
let canonicalGitDirectory;
|
|
1417
|
+
let canonicalCommonDirectory;
|
|
1418
|
+
try {
|
|
1419
|
+
canonicalRoot = await realpath(siblingsRoot);
|
|
1420
|
+
canonicalTarget = await realpath(target);
|
|
1421
|
+
canonicalGitDirectory = await realpath(gitDirectory);
|
|
1422
|
+
canonicalCommonDirectory = await realpath(commonDirectory);
|
|
1423
|
+
}
|
|
1424
|
+
catch (error) {
|
|
1425
|
+
return failure(`Git returned a path that could not be canonicalized: ${errorCause(error)}`);
|
|
1426
|
+
}
|
|
1427
|
+
if (resolve(topLevel) !== canonicalTarget ||
|
|
1428
|
+
!confined(canonicalRoot, canonicalGitDirectory) ||
|
|
1429
|
+
!confined(canonicalRoot, canonicalCommonDirectory))
|
|
1430
|
+
return outsideRoot();
|
|
1431
|
+
const identityDirectories = await Promise.all([...new Set([canonicalTarget, canonicalGitDirectory, canonicalCommonDirectory])].map(canonicalDirectory));
|
|
1432
|
+
if (identityDirectories.some((entry) => !entry))
|
|
1433
|
+
return failure('checkout identity paths are unavailable');
|
|
1434
|
+
const exactChecks = await Promise.all(identityDirectories.map((directory) => directoryCheck(directory.canonicalPath, directory.identity)));
|
|
1435
|
+
if (exactChecks.some((check) => !check))
|
|
1436
|
+
return failure('checkout identity paths changed during inspection');
|
|
1437
|
+
const repositoryChecks = [...new Map([
|
|
1438
|
+
...inspected.checks,
|
|
1439
|
+
...exactChecks,
|
|
1440
|
+
].map((check) => [check.path, check])).values()];
|
|
1441
|
+
const configPath = resolve(canonicalCommonDirectory, 'config');
|
|
1442
|
+
const worktreeConfigPath = resolve(canonicalGitDirectory, 'config.worktree');
|
|
1443
|
+
const configCheck = await boundedFileCheck(configPath);
|
|
1444
|
+
if (!configCheck || configCheck.expected !== 'file') {
|
|
1445
|
+
return failure('the Git configuration is not a stable private file');
|
|
1446
|
+
}
|
|
1447
|
+
const worktreeConfigCheck = await boundedFileCheck(worktreeConfigPath);
|
|
1448
|
+
if (!worktreeConfigCheck)
|
|
1449
|
+
return failure('the worktree Git configuration could not be inspected');
|
|
1450
|
+
repositoryChecks.push(configCheck, worktreeConfigCheck);
|
|
1451
|
+
const confirmedIdentity = await authenticatedProbe(probeStep('setup:observe:identity-confirm', [executable.canonicalPath, '-C', target, 'rev-parse', '--path-format=absolute', '--show-toplevel', '--absolute-git-dir', '--git-common-dir'], siblingsRoot, env, executable, repositoryChecks));
|
|
1452
|
+
const confirmedLines = confirmedIdentity && decodeLines(confirmedIdentity);
|
|
1453
|
+
if (!confirmedLines || confirmedLines.length !== 3
|
|
1454
|
+
|| confirmedLines.map((path) => resolve(path)).join('\0') !== [topLevel, gitDirectory, commonDirectory].join('\0')) {
|
|
1455
|
+
return failure('checkout identity changed during observation');
|
|
1456
|
+
}
|
|
1457
|
+
const configurationInventory = await authenticatedProbe(probeStep('setup:observe:config-origins', [executable.canonicalPath, '-c', 'core.excludesFile=/dev/null', '-C', target, 'config', '--show-origin', '--null', '--list'], siblingsRoot, env, executable, repositoryChecks));
|
|
1458
|
+
if (!configurationInventory || !await validConfigurationInventory(configurationInventory, canonicalTarget, [configPath, worktreeConfigPath]))
|
|
1459
|
+
return failure('Git configuration origins are unsupported');
|
|
1460
|
+
const remote = await authenticatedProbeOutcome(probeStep('setup:observe:origin', [executable.canonicalPath, '-C', target, 'config', '--get', 'remote.origin.url'], siblingsRoot, env, executable, repositoryChecks));
|
|
1461
|
+
if (!remote || (remote.kind === 'refusal' && remote.execution.exitCode !== 1)) {
|
|
1462
|
+
return failure('Git could not inspect the origin remote');
|
|
1463
|
+
}
|
|
1464
|
+
const remoteLines = remote.kind === 'complete' ? decodeLines(remote.stdout) : [];
|
|
1465
|
+
if (remote.kind === 'complete' && (!remoteLines || remoteLines.length !== 1)) {
|
|
1466
|
+
return failure('Git returned an invalid origin remote');
|
|
1467
|
+
}
|
|
1468
|
+
return available({
|
|
1469
|
+
kind: 'repository',
|
|
1470
|
+
remotes: remoteLines?.[0] ? { origin: remoteLines[0] } : {},
|
|
1471
|
+
filesystemChecks: repositoryChecks,
|
|
1472
|
+
});
|
|
1473
|
+
}
|