@gajae-code/coding-agent 0.11.4 → 0.11.6
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 +23 -0
- package/dist/types/cli/notify-cli.d.ts +5 -1
- package/dist/types/config/settings-schema.d.ts +31 -1
- package/dist/types/export/html/template.generated.d.ts +1 -1
- package/dist/types/gjc-runtime/deep-interview-state.d.ts +28 -0
- package/dist/types/gjc-runtime/launch-tmux.d.ts +3 -0
- package/dist/types/gjc-runtime/managed-owner-admission.d.ts +42 -0
- package/dist/types/gjc-runtime/managed-owner-supervisor.d.ts +41 -0
- package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +9 -0
- package/dist/types/gjc-runtime/ultragoal-owner-loss-recovery.d.ts +94 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +1 -0
- package/dist/types/harness-control-plane/owner.d.ts +3 -1
- package/dist/types/internal-urls/local-protocol.d.ts +18 -0
- package/dist/types/main.d.ts +2 -2
- package/dist/types/modes/components/ansi-display-validator.d.ts +2 -0
- package/dist/types/modes/components/notifications-settings-editor.d.ts +4 -0
- package/dist/types/modes/components/session-observer-overlay.d.ts +3 -1
- package/dist/types/modes/components/settings-selector.d.ts +2 -0
- package/dist/types/modes/components/tool-transcript-format.d.ts +58 -0
- package/dist/types/modes/components/transcript-viewer-overlay.d.ts +3 -0
- package/dist/types/modes/controllers/goal-mode-controller.d.ts +8 -1
- package/dist/types/modes/controllers/selector-controller.d.ts +15 -0
- package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +3 -0
- package/dist/types/modes/shared/agent-wire/workflow-gate-types.d.ts +16 -0
- package/dist/types/plan-mode/approved-plan.d.ts +1 -0
- package/dist/types/runtime-mcp/manager.d.ts +2 -0
- package/dist/types/runtime-mcp/oauth-discovery.d.ts +12 -1
- package/dist/types/sdk/bus/chat-daemon-cli.d.ts +3 -1
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +42 -4
- package/dist/types/sdk/bus/config-commands.d.ts +8 -0
- package/dist/types/sdk/bus/config.d.ts +22 -0
- package/dist/types/sdk/bus/index.d.ts +162 -2
- package/dist/types/sdk/bus/notification-service.d.ts +71 -8
- package/dist/types/sdk/bus/session-control.d.ts +11 -8
- package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +5 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +11 -3
- package/dist/types/sdk/bus/telegram-daemon-control.d.ts +23 -4
- package/dist/types/sdk/bus/telegram-daemon.d.ts +214 -20
- package/dist/types/sdk/session.d.ts +16 -1
- package/dist/types/secrets/index.d.ts +4 -2
- package/dist/types/secrets/obfuscator.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +31 -2
- package/dist/types/session/artifacts.d.ts +30 -8
- package/dist/types/session/internal/managed-session-scope.d.ts +7 -0
- package/dist/types/session/internal/managed-session-storage.d.ts +63 -6
- package/dist/types/session/session-manager.d.ts +98 -18
- package/dist/types/session/session-storage.d.ts +27 -0
- package/dist/types/session/streaming-output.d.ts +4 -0
- package/dist/types/session/yield-queue.d.ts +8 -0
- package/dist/types/task/executor.d.ts +9 -0
- package/dist/types/tool-discovery/tool-index.d.ts +2 -0
- package/dist/types/tools/ask.d.ts +48 -0
- package/dist/types/tools/bash-skill-urls.d.ts +2 -2
- package/dist/types/tools/index.d.ts +4 -0
- package/package.json +9 -9
- package/scripts/generate-sdk-operation-inventory.ts +3 -0
- package/src/cli/notify-cli.ts +40 -18
- package/src/commands/sdk.ts +7 -2
- package/src/config/settings-schema.ts +31 -1
- package/src/daemon/runtime.ts +2 -2
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +70 -49
- package/src/defaults/gjc/skills/ralplan/SKILL.md +0 -6
- package/src/defaults/gjc/skills/team/SKILL.md +7 -9
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +0 -2
- package/src/eval/py/index.ts +2 -2
- package/src/export/html/template.generated.ts +1 -1
- package/src/extensibility/plugins/marketplace/manager.ts +94 -10
- package/src/gjc-runtime/deep-interview-recorder.ts +7 -0
- package/src/gjc-runtime/deep-interview-runtime.ts +10 -0
- package/src/gjc-runtime/deep-interview-state.ts +127 -3
- package/src/gjc-runtime/launch-tmux.ts +61 -6
- package/src/gjc-runtime/managed-owner-admission.ts +352 -0
- package/src/gjc-runtime/managed-owner-supervisor.ts +162 -0
- package/src/gjc-runtime/state-runtime.ts +38 -12
- package/src/gjc-runtime/team-runtime.ts +1 -1
- package/src/gjc-runtime/tmux-owner-isolation.ts +117 -0
- package/src/gjc-runtime/ultragoal-owner-loss-recovery.ts +328 -0
- package/src/gjc-runtime/ultragoal-runtime.ts +14 -0
- package/src/harness-control-plane/owner.ts +59 -3
- package/src/hindsight/client.ts +65 -2
- package/src/internal-urls/agent-protocol.ts +76 -5
- package/src/internal-urls/docs-index.generated.ts +8 -7
- package/src/internal-urls/local-protocol.ts +416 -18
- package/src/lsp/index.ts +4 -88
- package/src/main.ts +36 -12
- package/src/modes/acp/acp-agent.ts +8 -0
- package/src/modes/components/ansi-display-validator.ts +54 -0
- package/src/modes/components/notifications-settings-editor.ts +26 -2
- package/src/modes/components/session-observer-overlay.ts +40 -24
- package/src/modes/components/settings-selector.ts +3 -1
- package/src/modes/components/tool-transcript-format.ts +321 -0
- package/src/modes/components/transcript-viewer-overlay.ts +39 -17
- package/src/modes/controllers/command-controller.ts +15 -0
- package/src/modes/controllers/extension-ui-controller.ts +14 -2
- package/src/modes/controllers/goal-mode-controller.ts +20 -1
- package/src/modes/controllers/plan-mode-controller.ts +2 -0
- package/src/modes/controllers/runtime-mcp-command-controller.ts +3 -0
- package/src/modes/controllers/selector-controller.ts +70 -8
- package/src/modes/interactive-mode.ts +1 -0
- package/src/modes/runtime-init.ts +16 -2
- package/src/modes/shared/agent-wire/deep-interview-gate.ts +31 -1
- package/src/modes/shared/agent-wire/workflow-gate-broker.ts +17 -1
- package/src/modes/shared/agent-wire/workflow-gate-schema.ts +4 -2
- package/src/modes/shared/agent-wire/workflow-gate-types.ts +120 -3
- package/src/plan-mode/approved-plan.ts +3 -1
- package/src/prompts/agents/architect.md +1 -0
- package/src/prompts/agents/critic.md +1 -0
- package/src/prompts/system/custom-system-prompt.md +1 -1
- package/src/prompts/system/system-prompt.md +2 -1
- package/src/prompts/tools/lsp.md +3 -3
- package/src/runtime-mcp/manager.ts +16 -1
- package/src/runtime-mcp/oauth-discovery.ts +116 -26
- package/src/sdk/broker/session-index.ts +122 -64
- package/src/sdk/bus/chat-daemon-cli.ts +72 -10
- package/src/sdk/bus/chat-daemon-control.ts +538 -130
- package/src/sdk/bus/chat-daemon-runtime.ts +1 -0
- package/src/sdk/bus/config-commands.ts +22 -0
- package/src/sdk/bus/config.ts +37 -0
- package/src/sdk/bus/index.ts +388 -94
- package/src/sdk/bus/lifecycle-control-runtime.ts +53 -3
- package/src/sdk/bus/notification-service.ts +284 -62
- package/src/sdk/bus/recent-activity.ts +14 -5
- package/src/sdk/bus/session-control.ts +150 -44
- package/src/sdk/bus/telegram-daemon-cli.ts +20 -14
- package/src/sdk/bus/telegram-daemon-contract.ts +11 -3
- package/src/sdk/bus/telegram-daemon-control.ts +312 -76
- package/src/sdk/bus/telegram-daemon.ts +1916 -285
- package/src/sdk/host/control/dispatch.ts +14 -5
- package/src/sdk/protocol/operation-inventory.generated.json +29 -0
- package/src/sdk/session.ts +155 -27
- package/src/secrets/index.ts +53 -10
- package/src/secrets/obfuscator.ts +21 -18
- package/src/secrets/regex.ts +2 -0
- package/src/session/agent-session.ts +1447 -852
- package/src/session/artifacts.ts +188 -23
- package/src/session/context-estimation.ts +7 -2
- package/src/session/internal/managed-session-scope.ts +200 -38
- package/src/session/internal/managed-session-storage.ts +1152 -46
- package/src/session/session-manager.ts +1121 -148
- package/src/session/session-storage.ts +109 -11
- package/src/session/streaming-output.ts +6 -0
- package/src/session/yield-queue.ts +20 -0
- package/src/slash-commands/builtin-registry.ts +36 -0
- package/src/task/executor.ts +71 -26
- package/src/task/index.ts +63 -20
- package/src/tool-discovery/tool-index.ts +18 -0
- package/src/tools/ask-answer-registry.ts +15 -1
- package/src/tools/ask.ts +129 -32
- package/src/tools/bash-skill-urls.ts +14 -3
- package/src/tools/bash.ts +2 -2
- package/src/tools/index.ts +4 -0
- package/src/tools/plan-mode-guard.ts +1 -0
- package/src/tools/sqlite-reader.ts +3 -2
- package/src/tools/subagent.ts +2 -0
|
@@ -1,8 +1,21 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import * as fsp from "node:fs/promises";
|
|
4
4
|
import * as path from "node:path";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
applyOwnerOnlyFdSecurity,
|
|
7
|
+
applyOwnerOnlyPathSecurity,
|
|
8
|
+
exactRemoveDirectoryTree,
|
|
9
|
+
exactUnlink,
|
|
10
|
+
type NativeDirectoryTreeSnapshot,
|
|
11
|
+
type NativeOwnerOnlySecurityResult,
|
|
12
|
+
openRecoveryFsRoot,
|
|
13
|
+
type RecoveryFsRoot,
|
|
14
|
+
renameNoReplacePath,
|
|
15
|
+
snapshotDirectoryTree,
|
|
16
|
+
verifyOwnerOnlyFdSecurity,
|
|
17
|
+
verifyOwnerOnlyPathSecurity,
|
|
18
|
+
} from "@gajae-code/natives";
|
|
6
19
|
|
|
7
20
|
export const MANAGED_ARTIFACT_MAX_DEPTH = 32;
|
|
8
21
|
export const MANAGED_ARTIFACT_MAX_FILES = 10_000;
|
|
@@ -34,10 +47,121 @@ export interface ManagedStorageLock {
|
|
|
34
47
|
|
|
35
48
|
export interface ManagedFileSnapshot {
|
|
36
49
|
bytes: Buffer;
|
|
37
|
-
identity: { dev: bigint; ino: bigint; size: number; mtimeNs: bigint };
|
|
50
|
+
identity: { dev: bigint; ino: bigint; size: number; mtimeNs: bigint; ctimeNs: bigint; sha256: string };
|
|
38
51
|
}
|
|
39
52
|
|
|
40
|
-
|
|
53
|
+
const ACL_FAILURE_CODES = new Set(["acl_denied", "acl_io_error", "acl_present", "acl_malformed", "acl_unknown"]);
|
|
54
|
+
const ACL_CLEAR_EVIDENCE = new Set(["cleared", "already_absent", "unsupported", "not_run"]);
|
|
55
|
+
const GENERAL_FAILURE_CODES = new Set([
|
|
56
|
+
"acl_unavailable",
|
|
57
|
+
"acl_apply_failed",
|
|
58
|
+
"acl_verify_failed",
|
|
59
|
+
"not_found",
|
|
60
|
+
"not_directory",
|
|
61
|
+
"network_unsupported",
|
|
62
|
+
"reparse_point",
|
|
63
|
+
"identity_unavailable",
|
|
64
|
+
"identity_mismatch",
|
|
65
|
+
"owner_mismatch",
|
|
66
|
+
"mode_mismatch",
|
|
67
|
+
"io_error",
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
function hasOnlyKeys(value: Record<string, unknown>, allowed: readonly string[]): boolean {
|
|
71
|
+
return Object.keys(value).every(key => allowed.includes(key));
|
|
72
|
+
}
|
|
73
|
+
const ACL_QUERY_EVIDENCE = new Set(["absent", "unsupported"]);
|
|
74
|
+
|
|
75
|
+
export function validateNativeSecurityResult(
|
|
76
|
+
value: unknown,
|
|
77
|
+
operation: "apply" | "verify",
|
|
78
|
+
kind: "directory" | "file",
|
|
79
|
+
): NativeOwnerOnlySecurityResult {
|
|
80
|
+
if (!value || typeof value !== "object" || typeof (value as { ok?: unknown }).ok !== "boolean") {
|
|
81
|
+
throw new Error("Malformed owner-only security result");
|
|
82
|
+
}
|
|
83
|
+
const result = value as Record<string, unknown>;
|
|
84
|
+
if (result.ok === false) {
|
|
85
|
+
if (typeof result.code !== "string") throw new Error("Malformed owner-only security failure");
|
|
86
|
+
if (!ACL_FAILURE_CODES.has(result.code) && !GENERAL_FAILURE_CODES.has(result.code)) {
|
|
87
|
+
throw new Error("Unknown owner-only security failure code");
|
|
88
|
+
}
|
|
89
|
+
if (ACL_FAILURE_CODES.has(result.code)) {
|
|
90
|
+
if (
|
|
91
|
+
(result.operation !== "clear" && result.operation !== "query") ||
|
|
92
|
+
(result.attribute !== "access" && result.attribute !== "default")
|
|
93
|
+
) {
|
|
94
|
+
throw new Error("Malformed ACL security failure evidence");
|
|
95
|
+
}
|
|
96
|
+
} else if (result.operation !== undefined || result.attribute !== undefined) {
|
|
97
|
+
throw new Error("Unexpected ACL fields on owner-only security failure");
|
|
98
|
+
}
|
|
99
|
+
if (!hasOnlyKeys(result, ["ok", "code", "operation", "attribute"])) {
|
|
100
|
+
throw new Error("Unexpected owner-only security failure fields");
|
|
101
|
+
}
|
|
102
|
+
return value as NativeOwnerOnlySecurityResult;
|
|
103
|
+
}
|
|
104
|
+
if (process.platform !== "linux") {
|
|
105
|
+
if (!hasOnlyKeys(result, ["ok"])) throw new Error("Malformed non-Linux security success");
|
|
106
|
+
return value as NativeOwnerOnlySecurityResult;
|
|
107
|
+
}
|
|
108
|
+
if (result.platform !== "linux" || result.kind !== kind || result.protocol !== operation) {
|
|
109
|
+
throw new Error("Malformed Linux security success envelope");
|
|
110
|
+
}
|
|
111
|
+
if (result.code !== undefined || result.operation !== undefined || result.attribute !== undefined) {
|
|
112
|
+
throw new Error("Unexpected failure fields on Linux security success");
|
|
113
|
+
}
|
|
114
|
+
if (!hasOnlyKeys(result, ["ok", "platform", "kind", "protocol", "aclEvidence"])) {
|
|
115
|
+
throw new Error("Unexpected Linux security success fields");
|
|
116
|
+
}
|
|
117
|
+
const evidence = result.aclEvidence;
|
|
118
|
+
if (!evidence || typeof evidence !== "object") throw new Error("Missing Linux ACL evidence");
|
|
119
|
+
const record = evidence as Record<string, unknown>;
|
|
120
|
+
const validateAttribute = (candidate: unknown): void => {
|
|
121
|
+
if (!candidate || typeof candidate !== "object") throw new Error("Malformed Linux ACL attribute evidence");
|
|
122
|
+
const attribute = candidate as Record<string, unknown>;
|
|
123
|
+
if (!hasOnlyKeys(attribute, ["clear", "query"])) throw new Error("Unexpected Linux ACL evidence fields");
|
|
124
|
+
if (!ACL_CLEAR_EVIDENCE.has(String(attribute.clear)) || !ACL_QUERY_EVIDENCE.has(String(attribute.query))) {
|
|
125
|
+
throw new Error("Unknown Linux ACL attribute evidence");
|
|
126
|
+
}
|
|
127
|
+
if (operation === "verify" && attribute.clear !== "not_run") {
|
|
128
|
+
throw new Error("Verify result unexpectedly reports ACL mutation");
|
|
129
|
+
}
|
|
130
|
+
if (operation === "apply" && attribute.clear === "not_run") {
|
|
131
|
+
throw new Error("Apply result omitted ACL mutation evidence");
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
validateAttribute(record.access);
|
|
135
|
+
if (kind === "directory") validateAttribute(record.default);
|
|
136
|
+
else if (record.default !== undefined) throw new Error("File result unexpectedly carries default ACL evidence");
|
|
137
|
+
if (!hasOnlyKeys(record, kind === "directory" ? ["access", "default"] : ["access"])) {
|
|
138
|
+
throw new Error("Unexpected Linux ACL evidence attributes");
|
|
139
|
+
}
|
|
140
|
+
return value as NativeOwnerOnlySecurityResult;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
type NativeSecurity = NativeOwnerOnlySecurityResult;
|
|
144
|
+
type RetainedManagedReplacer = {
|
|
145
|
+
replaceManaged(
|
|
146
|
+
relativePath: string,
|
|
147
|
+
bytes: Uint8Array,
|
|
148
|
+
expectedDev: string,
|
|
149
|
+
expectedIno: string,
|
|
150
|
+
expectedSize: string,
|
|
151
|
+
expectedMtimeNs: string,
|
|
152
|
+
expectedCtimeNs: string,
|
|
153
|
+
expectedSha256: string,
|
|
154
|
+
): { ok: boolean; code?: string };
|
|
155
|
+
removeManaged(
|
|
156
|
+
relativePath: string,
|
|
157
|
+
expectedDev: string,
|
|
158
|
+
expectedIno: string,
|
|
159
|
+
expectedSize: string,
|
|
160
|
+
expectedMtimeNs: string,
|
|
161
|
+
expectedCtimeNs: string,
|
|
162
|
+
expectedSha256: string,
|
|
163
|
+
): { ok: boolean; code?: string };
|
|
164
|
+
};
|
|
41
165
|
type LockRecord = {
|
|
42
166
|
attemptId: string;
|
|
43
167
|
pid: number;
|
|
@@ -48,6 +172,80 @@ type LockRecord = {
|
|
|
48
172
|
leaseExpiresAt: number;
|
|
49
173
|
};
|
|
50
174
|
|
|
175
|
+
/** Captured configured-root authority for managed paths only. */
|
|
176
|
+
export interface ManagedDirectoryRoot {
|
|
177
|
+
readonly canonicalPath: string;
|
|
178
|
+
readonly dev: bigint;
|
|
179
|
+
readonly ino: bigint;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function managedDirectoryRoot(configuredRoot: string): ManagedDirectoryRoot {
|
|
183
|
+
const canonicalPath = fs.realpathSync.native(configuredRoot);
|
|
184
|
+
const stat = fs.lstatSync(canonicalPath, { bigint: true });
|
|
185
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`Unsafe managed root: ${configuredRoot}`);
|
|
186
|
+
return Object.freeze({ canonicalPath, dev: stat.dev, ino: stat.ino });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function retainManagedDirectoryAuthority(
|
|
190
|
+
root: ManagedDirectoryRoot,
|
|
191
|
+
directory: string,
|
|
192
|
+
expected?: { dev: bigint; ino: bigint },
|
|
193
|
+
): RecoveryFsRoot | undefined {
|
|
194
|
+
assertManagedDirectoryRoot(root);
|
|
195
|
+
managedRelativePath(root, directory);
|
|
196
|
+
const resolved = path.resolve(directory);
|
|
197
|
+
if (process.platform !== "linux") return undefined;
|
|
198
|
+
const named = fs.lstatSync(resolved, { bigint: true });
|
|
199
|
+
if (!named.isDirectory() || named.isSymbolicLink()) throw new Error("Managed directory authority is unavailable");
|
|
200
|
+
if (expected && (named.dev !== expected.dev || named.ino !== expected.ino))
|
|
201
|
+
throw new Error("Managed directory identity changed before retention");
|
|
202
|
+
const rootAuthority = openRecoveryFsRoot(root.canonicalPath);
|
|
203
|
+
try {
|
|
204
|
+
const retainedRoot = rootAuthority.identity();
|
|
205
|
+
if (
|
|
206
|
+
!retainedRoot.ok ||
|
|
207
|
+
!retainedRoot.identity ||
|
|
208
|
+
retainedRoot.identity.dev !== root.dev.toString() ||
|
|
209
|
+
retainedRoot.identity.ino !== root.ino.toString()
|
|
210
|
+
)
|
|
211
|
+
throw new Error("Managed root authority changed");
|
|
212
|
+
const relative = path.relative(root.canonicalPath, resolved).split(path.sep).join("/");
|
|
213
|
+
return rootAuthority.retainManagedDirectory(relative, named.dev.toString(), named.ino.toString());
|
|
214
|
+
} finally {
|
|
215
|
+
rootAuthority.close();
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function ensureDirectoryComponent(pathname: string): void {
|
|
220
|
+
try {
|
|
221
|
+
fs.mkdirSync(pathname, { mode: 0o700 });
|
|
222
|
+
} catch (error) {
|
|
223
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
224
|
+
}
|
|
225
|
+
assertSafeDirectory(pathname);
|
|
226
|
+
secure(pathname, "directory");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function assertManagedDirectoryRoot(root: ManagedDirectoryRoot): void {
|
|
230
|
+
const named = fs.lstatSync(root.canonicalPath, { bigint: true });
|
|
231
|
+
if (!named.isDirectory() || named.isSymbolicLink() || named.dev !== root.dev || named.ino !== root.ino) {
|
|
232
|
+
throw new Error(`Managed root authority changed: ${root.canonicalPath}`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Verify the configured root without rewriting its ownership, mode, or ACLs. */
|
|
237
|
+
function ensureManagedRoot(root: ManagedDirectoryRoot): void {
|
|
238
|
+
assertManagedDirectoryRoot(root);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function managedRelativePath(root: ManagedDirectoryRoot, pathname: string): readonly string[] {
|
|
242
|
+
const relative = path.relative(root.canonicalPath, path.resolve(pathname));
|
|
243
|
+
if (relative === "") return [];
|
|
244
|
+
if (path.isAbsolute(relative) || relative.split(path.sep).includes(".."))
|
|
245
|
+
throw new Error(`Managed path escapes configured root: ${pathname}`);
|
|
246
|
+
return relative.split(path.sep);
|
|
247
|
+
}
|
|
248
|
+
|
|
51
249
|
const PROCESS_START_ID = randomUUID();
|
|
52
250
|
|
|
53
251
|
function securityError(pathname: string, result: NativeSecurity): Error {
|
|
@@ -59,12 +257,719 @@ function securityError(pathname: string, result: NativeSecurity): Error {
|
|
|
59
257
|
}
|
|
60
258
|
|
|
61
259
|
function secure(pathname: string, kind: "directory" | "file"): void {
|
|
62
|
-
const applied = applyOwnerOnlyPathSecurity(pathname, kind)
|
|
260
|
+
const applied = validateNativeSecurityResult(applyOwnerOnlyPathSecurity(pathname, kind), "apply", kind);
|
|
63
261
|
if (!applied.ok) throw securityError(pathname, applied);
|
|
64
|
-
const verified = verifyOwnerOnlyPathSecurity(pathname, kind)
|
|
262
|
+
const verified = validateNativeSecurityResult(verifyOwnerOnlyPathSecurity(pathname, kind), "verify", kind);
|
|
65
263
|
if (!verified.ok) throw securityError(pathname, verified);
|
|
66
264
|
}
|
|
67
265
|
|
|
266
|
+
/** Internal retained-root capability for one managed descendant subtree. */
|
|
267
|
+
export class ManagedSessionDescendantStore {
|
|
268
|
+
readonly #root: ManagedDirectoryRoot;
|
|
269
|
+
readonly #baseDir: string;
|
|
270
|
+
readonly #authority: RecoveryFsRoot | undefined;
|
|
271
|
+
readonly #authorityBaseDir: string;
|
|
272
|
+
readonly #subtreeRoot: ManagedDirectoryRoot;
|
|
273
|
+
|
|
274
|
+
constructor(
|
|
275
|
+
root: ManagedDirectoryRoot,
|
|
276
|
+
baseDir: string,
|
|
277
|
+
retained?: { authority: RecoveryFsRoot; authorityBaseDir: string },
|
|
278
|
+
) {
|
|
279
|
+
assertManagedDirectoryRoot(root);
|
|
280
|
+
managedRelativePath(root, baseDir);
|
|
281
|
+
this.#root = root;
|
|
282
|
+
this.#baseDir = path.resolve(baseDir);
|
|
283
|
+
this.#authorityBaseDir = retained?.authorityBaseDir ?? this.#baseDir;
|
|
284
|
+
if (retained) {
|
|
285
|
+
const relative = path.relative(retained.authorityBaseDir, this.#baseDir).split(path.sep).join("/");
|
|
286
|
+
const captured = retained.authority.snapshotManagedTree(relative);
|
|
287
|
+
if (!captured.ok || !captured.snapshot)
|
|
288
|
+
throw new Error(captured.code ?? "Managed subtree identity unavailable");
|
|
289
|
+
const subtreeStat = fs.lstatSync(this.#baseDir, { bigint: true });
|
|
290
|
+
if (
|
|
291
|
+
captured.snapshot.rootDev !== subtreeStat.dev.toString() ||
|
|
292
|
+
captured.snapshot.rootIno !== subtreeStat.ino.toString()
|
|
293
|
+
)
|
|
294
|
+
throw new Error("Managed descendant root identity changed");
|
|
295
|
+
this.#subtreeRoot = Object.freeze({
|
|
296
|
+
canonicalPath: this.#baseDir,
|
|
297
|
+
dev: subtreeStat.dev,
|
|
298
|
+
ino: subtreeStat.ino,
|
|
299
|
+
});
|
|
300
|
+
this.#authority = retained.authority;
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
ensureManagedDirectory(this.#baseDir, root);
|
|
304
|
+
const subtreeStat = fs.lstatSync(this.#baseDir, { bigint: true });
|
|
305
|
+
this.#subtreeRoot = Object.freeze({ canonicalPath: this.#baseDir, dev: subtreeStat.dev, ino: subtreeStat.ino });
|
|
306
|
+
if (process.platform === "linux") {
|
|
307
|
+
const before = fs.lstatSync(this.#baseDir, { bigint: true });
|
|
308
|
+
const authority = openRecoveryFsRoot(this.#baseDir);
|
|
309
|
+
const retained = authority.identity();
|
|
310
|
+
if (
|
|
311
|
+
!retained.ok ||
|
|
312
|
+
!retained.identity ||
|
|
313
|
+
retained.identity.dev !== before.dev.toString() ||
|
|
314
|
+
retained.identity.ino !== before.ino.toString()
|
|
315
|
+
) {
|
|
316
|
+
authority.close();
|
|
317
|
+
throw new Error("Managed descendant root identity changed");
|
|
318
|
+
}
|
|
319
|
+
this.#authority = authority;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
get dir(): string {
|
|
324
|
+
return this.#baseDir;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
get rootAuthority(): ManagedDirectoryRoot {
|
|
328
|
+
return this.#root;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
get subtreeRootAuthority(): ManagedDirectoryRoot {
|
|
332
|
+
return this.#subtreeRoot;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
deriveSubtree(relativePath: string): ManagedSessionDescendantStore {
|
|
336
|
+
const child = this.ensureDirectory(relativePath);
|
|
337
|
+
const resolved = this.#resolve(relativePath);
|
|
338
|
+
if (!this.#authority) return new ManagedSessionDescendantStore(this.#root, resolved);
|
|
339
|
+
const retainedChild = this.#authority.retainManagedDirectory(
|
|
340
|
+
this.#relative(resolved),
|
|
341
|
+
child.dev.toString(),
|
|
342
|
+
child.ino.toString(),
|
|
343
|
+
);
|
|
344
|
+
return new ManagedSessionDescendantStore(this.#root, resolved, {
|
|
345
|
+
authority: retainedChild,
|
|
346
|
+
authorityBaseDir: resolved,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
retainAuthority(): RecoveryFsRoot | undefined {
|
|
351
|
+
if (!this.#authority) return undefined;
|
|
352
|
+
return this.#authority.retainManagedDirectory(
|
|
353
|
+
"",
|
|
354
|
+
this.#subtreeRoot.dev.toString(),
|
|
355
|
+
this.#subtreeRoot.ino.toString(),
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
assertBound(): void {
|
|
360
|
+
this.#assertBound();
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
verifyRootSecurity(): void {
|
|
364
|
+
this.#assertBound();
|
|
365
|
+
if (this.#authority) {
|
|
366
|
+
if (this.#authorityBaseDir === this.#baseDir) {
|
|
367
|
+
const verified = this.#authority.verifyOwnerOnlyDirectory();
|
|
368
|
+
if (!verified.ok) throw new Error(verified.code ?? "acl_verify_failed");
|
|
369
|
+
} else {
|
|
370
|
+
this.#assertBound();
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
} else {
|
|
374
|
+
const verified = validateNativeSecurityResult(
|
|
375
|
+
verifyOwnerOnlyPathSecurity(this.#baseDir, "directory"),
|
|
376
|
+
"verify",
|
|
377
|
+
"directory",
|
|
378
|
+
);
|
|
379
|
+
if (!verified.ok) throw securityError(this.#baseDir, verified);
|
|
380
|
+
}
|
|
381
|
+
this.#assertBound();
|
|
382
|
+
}
|
|
383
|
+
#assertBound(): void {
|
|
384
|
+
if (!this.#authority) return;
|
|
385
|
+
const named = fs.lstatSync(this.#baseDir, { bigint: true });
|
|
386
|
+
const retained =
|
|
387
|
+
this.#authorityBaseDir === this.#baseDir
|
|
388
|
+
? this.#authority.identity()
|
|
389
|
+
: (() => {
|
|
390
|
+
const relative = path.relative(this.#authorityBaseDir, this.#baseDir).split(path.sep).join("/");
|
|
391
|
+
const captured = this.#authority?.snapshotManagedTree(relative);
|
|
392
|
+
return captured?.ok && captured.snapshot
|
|
393
|
+
? { ok: true, identity: { dev: captured.snapshot.rootDev, ino: captured.snapshot.rootIno } }
|
|
394
|
+
: { ok: false, identity: undefined };
|
|
395
|
+
})();
|
|
396
|
+
if (
|
|
397
|
+
!retained.ok ||
|
|
398
|
+
!retained.identity ||
|
|
399
|
+
!named.isDirectory() ||
|
|
400
|
+
named.isSymbolicLink() ||
|
|
401
|
+
retained.identity.dev !== named.dev.toString() ||
|
|
402
|
+
retained.identity.dev !== this.#subtreeRoot.dev.toString() ||
|
|
403
|
+
retained.identity.ino !== this.#subtreeRoot.ino.toString() ||
|
|
404
|
+
retained.identity.ino !== named.ino.toString()
|
|
405
|
+
) {
|
|
406
|
+
throw new Error("Managed descendant root binding changed");
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
ensureDirectory(relativePath = ""): ManagedDirectoryRoot {
|
|
411
|
+
this.#assertBound();
|
|
412
|
+
if (this.#authority) {
|
|
413
|
+
const relative = this.#relative(this.#resolve(relativePath));
|
|
414
|
+
if (relative === "") return this.#subtreeRoot;
|
|
415
|
+
const ensured = this.#authority.ensureManagedDirectory(relative);
|
|
416
|
+
if (!ensured.ok || !ensured.identity) throw new Error(ensured.code ?? "managed_directory_create_failed");
|
|
417
|
+
this.#assertBound();
|
|
418
|
+
return Object.freeze({
|
|
419
|
+
canonicalPath: this.#resolve(relativePath),
|
|
420
|
+
dev: BigInt(ensured.identity.dev),
|
|
421
|
+
ino: BigInt(ensured.identity.ino),
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
ensureManagedDirectory(this.#resolve(relativePath), this.#root);
|
|
425
|
+
const named = fs.lstatSync(this.#resolve(relativePath), { bigint: true });
|
|
426
|
+
return Object.freeze({ canonicalPath: this.#resolve(relativePath), dev: named.dev, ino: named.ino });
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async publishNoReplace(relativePath: string, bytes: Uint8Array): Promise<void> {
|
|
430
|
+
const resolved = this.#resolve(relativePath);
|
|
431
|
+
if (this.#authority) {
|
|
432
|
+
this.#assertBound();
|
|
433
|
+
this.#publishRetainedNoReplace(this.#relative(resolved), bytes);
|
|
434
|
+
this.#assertBound();
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
await publishManagedFileNoReplace(resolved, bytes, undefined, this.#root);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
publishNoReplaceSync(relativePath: string, bytes: Uint8Array): void {
|
|
441
|
+
const resolved = this.#resolve(relativePath);
|
|
442
|
+
if (!this.#authority) {
|
|
443
|
+
publishManagedFileNoReplaceSync(resolved, bytes, this.#root);
|
|
444
|
+
this.#assertBound();
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
this.#assertBound();
|
|
448
|
+
this.#publishRetainedNoReplace(this.#relative(resolved), bytes);
|
|
449
|
+
this.#assertBound();
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async replace(relativePath: string, bytes: Uint8Array): Promise<void> {
|
|
453
|
+
this.#assertBound();
|
|
454
|
+
const resolved = this.#resolve(relativePath);
|
|
455
|
+
if (!this.#authority) {
|
|
456
|
+
await replaceManagedFile(resolved, bytes, this.#subtreeRoot);
|
|
457
|
+
this.#assertBound();
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
const relative = this.#relative(resolved);
|
|
461
|
+
this.#replaceRetained(relative, bytes);
|
|
462
|
+
this.#assertBound();
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
replaceSync(relativePath: string, bytes: Uint8Array): void {
|
|
466
|
+
this.#assertBound();
|
|
467
|
+
const resolved = this.#resolve(relativePath);
|
|
468
|
+
if (!this.#authority) {
|
|
469
|
+
replaceManagedFileSync(resolved, bytes, this.#subtreeRoot);
|
|
470
|
+
this.#assertBound();
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
this.#replaceRetained(this.#relative(resolved), bytes);
|
|
474
|
+
this.#assertBound();
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
appendSync(relativePath: string, bytes: Uint8Array): void {
|
|
478
|
+
this.#assertBound();
|
|
479
|
+
const resolved = this.#resolve(relativePath);
|
|
480
|
+
const existing = this.readExpected(relativePath);
|
|
481
|
+
if (!existing) throw new Error("managed_append_missing");
|
|
482
|
+
if (this.#authority) {
|
|
483
|
+
const appended = this.#authority.appendManaged(
|
|
484
|
+
this.#relative(resolved),
|
|
485
|
+
bytes,
|
|
486
|
+
existing.identity.dev.toString(),
|
|
487
|
+
existing.identity.ino.toString(),
|
|
488
|
+
existing.identity.size.toString(),
|
|
489
|
+
existing.identity.mtimeNs.toString(),
|
|
490
|
+
existing.identity.ctimeNs.toString(),
|
|
491
|
+
existing.identity.sha256,
|
|
492
|
+
);
|
|
493
|
+
if (!appended.ok) throw new Error(appended.code ?? "managed_append_failed");
|
|
494
|
+
this.#assertBound();
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
let fd: number | undefined;
|
|
498
|
+
try {
|
|
499
|
+
fd = fs.openSync(resolved, fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_NOFOLLOW);
|
|
500
|
+
const before = identity(fs.fstatSync(fd, { bigint: true }));
|
|
501
|
+
if (!sameIdentity(before, existing.identity)) throw new Error("identity_mismatch");
|
|
502
|
+
secureFileDescriptor(resolved, fd, "verify");
|
|
503
|
+
let offset = 0;
|
|
504
|
+
while (offset < bytes.byteLength) offset += fs.writeSync(fd, bytes, offset, bytes.byteLength - offset);
|
|
505
|
+
fs.fsyncSync(fd);
|
|
506
|
+
secureFileDescriptor(resolved, fd, "verify");
|
|
507
|
+
const after = identity(fs.fstatSync(fd, { bigint: true }));
|
|
508
|
+
const named = identity(fs.lstatSync(resolved, { bigint: true }));
|
|
509
|
+
if (!sameIdentity(after, named) || after.dev !== before.dev || after.ino !== before.ino)
|
|
510
|
+
throw new Error("identity_mismatch");
|
|
511
|
+
} finally {
|
|
512
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
513
|
+
}
|
|
514
|
+
this.#assertBound();
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
#relative(resolved: string): string {
|
|
518
|
+
return path.relative(this.#authorityBaseDir, resolved).split(path.sep).join("/");
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
#publishRetainedNoReplace(relative: string, bytes: Uint8Array): void {
|
|
522
|
+
if (!this.#authority) throw new Error("Managed descendant authority is unavailable");
|
|
523
|
+
const separator = relative.lastIndexOf("/");
|
|
524
|
+
const parent = separator < 0 ? "" : relative.slice(0, separator);
|
|
525
|
+
const temporaryName = `.gjc-publish-${process.pid}-${randomUUID()}`;
|
|
526
|
+
const temporary = parent ? `${parent}/${temporaryName}` : temporaryName;
|
|
527
|
+
const created = this.#authority.createManaged(temporary, bytes);
|
|
528
|
+
if (!created.ok) throw new Error(created.code ?? "managed_publish_failed");
|
|
529
|
+
if (!created.identity) throw new Error("managed_publish_identity_unavailable");
|
|
530
|
+
let captured: ReturnType<RecoveryFsRoot["readManaged"]> | undefined;
|
|
531
|
+
try {
|
|
532
|
+
captured = this.#authority.readManaged(temporary);
|
|
533
|
+
if (!captured.ok || !captured.identity || !captured.data)
|
|
534
|
+
throw new Error(captured.code ?? "managed_publish_identity_unavailable");
|
|
535
|
+
const expectedDigest = createHash("sha256").update(bytes).digest("hex");
|
|
536
|
+
const digest = captured.identity.sha256 ?? createHash("sha256").update(captured.data).digest("hex");
|
|
537
|
+
if (
|
|
538
|
+
captured.identity.dev !== created.identity.dev ||
|
|
539
|
+
captured.identity.ino !== created.identity.ino ||
|
|
540
|
+
captured.identity.size !== created.identity.size ||
|
|
541
|
+
captured.identity.mtimeNs !== created.identity.mtimeNs ||
|
|
542
|
+
captured.identity.ctimeNs !== created.identity.ctimeNs ||
|
|
543
|
+
digest !== expectedDigest
|
|
544
|
+
)
|
|
545
|
+
throw new Error("managed_publish_identity_mismatch");
|
|
546
|
+
const synced = this.#authority.fsyncExpected(
|
|
547
|
+
temporary,
|
|
548
|
+
false,
|
|
549
|
+
captured.identity.dev,
|
|
550
|
+
captured.identity.ino,
|
|
551
|
+
captured.identity.size,
|
|
552
|
+
captured.identity.mtimeNs,
|
|
553
|
+
digest,
|
|
554
|
+
);
|
|
555
|
+
if (!synced.ok) throw new Error(synced.code ?? "managed_publish_fsync_failed");
|
|
556
|
+
const published = this.#authority.renameManagedFileNoReplace(
|
|
557
|
+
temporary,
|
|
558
|
+
relative,
|
|
559
|
+
captured.identity.dev,
|
|
560
|
+
captured.identity.ino,
|
|
561
|
+
captured.identity.size,
|
|
562
|
+
captured.identity.mtimeNs,
|
|
563
|
+
captured.identity.ctimeNs,
|
|
564
|
+
digest,
|
|
565
|
+
);
|
|
566
|
+
if (!published.ok)
|
|
567
|
+
throw new Error(
|
|
568
|
+
published.code === "already_exists"
|
|
569
|
+
? "destination_conflict"
|
|
570
|
+
: (published.code ?? "managed_publish_failed"),
|
|
571
|
+
);
|
|
572
|
+
} catch (error) {
|
|
573
|
+
if (captured?.ok && captured.identity && captured.data) {
|
|
574
|
+
const digest = captured.identity.sha256 ?? createHash("sha256").update(captured.data).digest("hex");
|
|
575
|
+
const installed = this.#authority.readManaged(relative);
|
|
576
|
+
if (
|
|
577
|
+
installed.ok &&
|
|
578
|
+
installed.identity &&
|
|
579
|
+
installed.data &&
|
|
580
|
+
installed.identity.dev === captured.identity.dev &&
|
|
581
|
+
installed.identity.ino === captured.identity.ino &&
|
|
582
|
+
(installed.identity.sha256 ?? createHash("sha256").update(installed.data).digest("hex")) === digest
|
|
583
|
+
) {
|
|
584
|
+
const removed = this.#authority.removeManaged(
|
|
585
|
+
relative,
|
|
586
|
+
installed.identity.dev,
|
|
587
|
+
installed.identity.ino,
|
|
588
|
+
installed.identity.size,
|
|
589
|
+
installed.identity.mtimeNs,
|
|
590
|
+
installed.identity.ctimeNs,
|
|
591
|
+
digest,
|
|
592
|
+
);
|
|
593
|
+
if (!removed.ok) throw new Error(removed.code ?? "managed_publish_reconcile_failed");
|
|
594
|
+
}
|
|
595
|
+
const staged = this.#authority.readManaged(temporary);
|
|
596
|
+
if (
|
|
597
|
+
staged.ok &&
|
|
598
|
+
staged.identity &&
|
|
599
|
+
staged.data &&
|
|
600
|
+
staged.identity.dev === captured.identity.dev &&
|
|
601
|
+
staged.identity.ino === captured.identity.ino
|
|
602
|
+
) {
|
|
603
|
+
const removed = this.#authority.removeManaged(
|
|
604
|
+
temporary,
|
|
605
|
+
staged.identity.dev,
|
|
606
|
+
staged.identity.ino,
|
|
607
|
+
staged.identity.size,
|
|
608
|
+
staged.identity.mtimeNs,
|
|
609
|
+
staged.identity.ctimeNs,
|
|
610
|
+
staged.identity.sha256 ?? createHash("sha256").update(staged.data).digest("hex"),
|
|
611
|
+
);
|
|
612
|
+
if (!removed.ok) throw new Error(removed.code ?? "managed_publish_reconcile_failed");
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
throw error;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
#replaceRetained(relative: string, bytes: Uint8Array): void {
|
|
620
|
+
if (!this.#authority) throw new Error("Managed descendant authority is unavailable");
|
|
621
|
+
this.#assertBound();
|
|
622
|
+
const existing = this.#authority.readManaged(relative);
|
|
623
|
+
if (!existing.ok) {
|
|
624
|
+
if (existing.code !== "not_found") throw new Error(existing.code ?? "managed_replace_failed");
|
|
625
|
+
this.#publishRetainedNoReplace(relative, bytes);
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
if (!existing.identity || !existing.data) throw new Error("Managed descendant identity is unavailable");
|
|
629
|
+
const replaced = (this.#authority as RecoveryFsRoot & RetainedManagedReplacer).replaceManaged(
|
|
630
|
+
relative,
|
|
631
|
+
bytes,
|
|
632
|
+
existing.identity.dev,
|
|
633
|
+
existing.identity.ino,
|
|
634
|
+
existing.identity.size,
|
|
635
|
+
existing.identity.mtimeNs,
|
|
636
|
+
existing.identity.ctimeNs,
|
|
637
|
+
existing.identity.sha256 ?? createHash("sha256").update(existing.data).digest("hex"),
|
|
638
|
+
);
|
|
639
|
+
if (!replaced.ok) throw new Error(replaced.code ?? "managed_replace_failed");
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** Read an exact managed file without exposing its pathname as authority. */
|
|
643
|
+
readExpected(relativePath: string): ManagedFileSnapshot | null {
|
|
644
|
+
this.#assertBound();
|
|
645
|
+
const relative = this.#relative(this.#resolve(relativePath));
|
|
646
|
+
if (!this.#authority) {
|
|
647
|
+
try {
|
|
648
|
+
return captureManagedFileNoFollow(this.#resolve(relativePath));
|
|
649
|
+
} catch (error) {
|
|
650
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
651
|
+
throw error;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
const read = this.#authority.readManaged(relative);
|
|
655
|
+
if (!read.ok) {
|
|
656
|
+
if (read.code === "not_found") return null;
|
|
657
|
+
throw new Error(read.code ?? "managed_read_failed");
|
|
658
|
+
}
|
|
659
|
+
if (!read.data || !read.identity) throw new Error("Managed descendant identity is unavailable");
|
|
660
|
+
return {
|
|
661
|
+
bytes: Buffer.from(read.data),
|
|
662
|
+
identity: {
|
|
663
|
+
dev: BigInt(read.identity.dev),
|
|
664
|
+
ino: BigInt(read.identity.ino),
|
|
665
|
+
size: Number(read.identity.size),
|
|
666
|
+
mtimeNs: BigInt(read.identity.mtimeNs),
|
|
667
|
+
ctimeNs: BigInt(read.identity.ctimeNs),
|
|
668
|
+
sha256: read.identity.sha256 ?? createHash("sha256").update(read.data).digest("hex"),
|
|
669
|
+
},
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/** Remove an exact captured file without reopening its pathname as authority. */
|
|
674
|
+
removeExpected(relativePath: string, expected: ManagedFileSnapshot): void {
|
|
675
|
+
this.#assertBound();
|
|
676
|
+
if (!this.#authority) {
|
|
677
|
+
const removed = exactUnlink(this.#resolve(relativePath), {
|
|
678
|
+
dev: expected.identity.dev,
|
|
679
|
+
ino: expected.identity.ino,
|
|
680
|
+
size: BigInt(expected.identity.size),
|
|
681
|
+
mtimeNs: expected.identity.mtimeNs,
|
|
682
|
+
sha256: expected.identity.sha256,
|
|
683
|
+
quarantineName: `.gjc-remove-${process.pid}-${randomUUID()}`,
|
|
684
|
+
});
|
|
685
|
+
if (!removed.ok) throw new Error(removed.code ?? "managed_remove_failed");
|
|
686
|
+
this.#assertBound();
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
const removed = (this.#authority as RecoveryFsRoot & RetainedManagedReplacer).removeManaged(
|
|
690
|
+
this.#relative(this.#resolve(relativePath)),
|
|
691
|
+
expected.identity.dev.toString(),
|
|
692
|
+
expected.identity.ino.toString(),
|
|
693
|
+
expected.identity.size.toString(),
|
|
694
|
+
expected.identity.mtimeNs.toString(),
|
|
695
|
+
expected.identity.ctimeNs.toString(),
|
|
696
|
+
expected.identity.sha256,
|
|
697
|
+
);
|
|
698
|
+
if (!removed.ok) throw new Error(removed.code ?? "managed_remove_failed");
|
|
699
|
+
this.#assertBound();
|
|
700
|
+
}
|
|
701
|
+
/** Read and remove one managed descendant through retained authority. */
|
|
702
|
+
async consume(relativePath: string): Promise<Uint8Array | null> {
|
|
703
|
+
this.#assertBound();
|
|
704
|
+
const resolved = this.#resolve(relativePath);
|
|
705
|
+
if (this.#authority) {
|
|
706
|
+
const relative = this.#relative(resolved);
|
|
707
|
+
const existing = this.#authority.readManaged(relative);
|
|
708
|
+
if (!existing.ok) {
|
|
709
|
+
if (existing.code === "not_found") return null;
|
|
710
|
+
throw new Error(existing.code ?? "managed_read_failed");
|
|
711
|
+
}
|
|
712
|
+
if (!existing.identity || !existing.data) throw new Error("Managed descendant identity is unavailable");
|
|
713
|
+
const removed = (this.#authority as RecoveryFsRoot & RetainedManagedReplacer).removeManaged(
|
|
714
|
+
relative,
|
|
715
|
+
existing.identity.dev,
|
|
716
|
+
existing.identity.ino,
|
|
717
|
+
existing.identity.size,
|
|
718
|
+
existing.identity.mtimeNs,
|
|
719
|
+
existing.identity.ctimeNs,
|
|
720
|
+
existing.identity.sha256 ?? createHash("sha256").update(existing.data).digest("hex"),
|
|
721
|
+
);
|
|
722
|
+
if (!removed.ok) throw new Error(removed.code ?? "managed_consume_failed");
|
|
723
|
+
this.#assertBound();
|
|
724
|
+
return existing.data;
|
|
725
|
+
}
|
|
726
|
+
let snapshot: ManagedFileSnapshot;
|
|
727
|
+
try {
|
|
728
|
+
snapshot = captureManagedFileNoFollow(resolved);
|
|
729
|
+
} catch (error) {
|
|
730
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
731
|
+
throw error;
|
|
732
|
+
}
|
|
733
|
+
await unlinkManagedFileVerified(resolved, snapshot.identity);
|
|
734
|
+
this.#assertBound();
|
|
735
|
+
return snapshot.bytes;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/** Remove one managed descendant through retained authority when it exists. */
|
|
739
|
+
async remove(relativePath: string): Promise<void> {
|
|
740
|
+
await this.consume(relativePath);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/** Capture a complete descendant tree through this retained root. */
|
|
744
|
+
captureTree(relativePath: string): NativeDirectoryTreeSnapshot {
|
|
745
|
+
this.#assertBound();
|
|
746
|
+
const relative = this.#relative(this.#resolve(relativePath));
|
|
747
|
+
if (this.#authority) {
|
|
748
|
+
const captured = this.#authority.snapshotManagedTree(relative);
|
|
749
|
+
if (!captured.ok || !captured.snapshot) throw new Error(captured.code ?? "unsafe_artifacts");
|
|
750
|
+
return captured.snapshot;
|
|
751
|
+
}
|
|
752
|
+
const captured = snapshotDirectoryTree(this.#resolve(relativePath));
|
|
753
|
+
if (!captured.ok || !captured.snapshot) throw new Error(captured.code ?? "unsafe_artifacts");
|
|
754
|
+
return captured.snapshot;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/** Copy an exact captured tree into an absent managed destination. */
|
|
758
|
+
async importTree(
|
|
759
|
+
sourceRelativePath: string,
|
|
760
|
+
destinationRelativePath: string,
|
|
761
|
+
snapshot: NativeDirectoryTreeSnapshot,
|
|
762
|
+
): Promise<void> {
|
|
763
|
+
const actual = this.captureTree(sourceRelativePath);
|
|
764
|
+
if (JSON.stringify(actual) !== JSON.stringify(snapshot)) throw new Error("artifact_source_changed");
|
|
765
|
+
this.ensureDirectory(destinationRelativePath);
|
|
766
|
+
for (const entry of snapshot.entries) {
|
|
767
|
+
if (entry.relativePath === "") continue;
|
|
768
|
+
const target = path.posix.join(destinationRelativePath.replaceAll(path.sep, "/"), entry.relativePath);
|
|
769
|
+
if (entry.kind === "directory") this.ensureDirectory(target);
|
|
770
|
+
else {
|
|
771
|
+
if (!this.#authority) throw new Error("managed_storage_unsupported");
|
|
772
|
+
const read = this.#authority.readManaged(
|
|
773
|
+
this.#relative(
|
|
774
|
+
this.#resolve(path.posix.join(sourceRelativePath.replaceAll(path.sep, "/"), entry.relativePath)),
|
|
775
|
+
),
|
|
776
|
+
);
|
|
777
|
+
if (
|
|
778
|
+
!read.ok ||
|
|
779
|
+
!read.data ||
|
|
780
|
+
!read.identity ||
|
|
781
|
+
read.identity.dev !== entry.dev ||
|
|
782
|
+
read.identity.ino !== entry.ino ||
|
|
783
|
+
read.identity.size !== entry.size ||
|
|
784
|
+
read.identity.mtimeNs !== entry.mtimeNs ||
|
|
785
|
+
createHash("sha256").update(read.data).digest("hex") !== entry.sha256
|
|
786
|
+
)
|
|
787
|
+
throw new Error("artifact_source_changed");
|
|
788
|
+
await this.publishNoReplace(target, read.data);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
const imported = this.captureTree(destinationRelativePath);
|
|
792
|
+
const comparable = (tree: NativeDirectoryTreeSnapshot) =>
|
|
793
|
+
tree.entries.map(entry => ({
|
|
794
|
+
relativePath: entry.relativePath,
|
|
795
|
+
kind: entry.kind,
|
|
796
|
+
size: entry.size,
|
|
797
|
+
sha256: entry.sha256,
|
|
798
|
+
}));
|
|
799
|
+
if (JSON.stringify(comparable(imported)) !== JSON.stringify(comparable(snapshot)))
|
|
800
|
+
throw new Error("artifact_destination_mismatch");
|
|
801
|
+
this.fsyncTree();
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
moveTreeNoReplace(
|
|
805
|
+
sourceRelativePath: string,
|
|
806
|
+
destinationRelativePath: string,
|
|
807
|
+
expected: NativeDirectoryTreeSnapshot,
|
|
808
|
+
): void {
|
|
809
|
+
this.#assertBound();
|
|
810
|
+
if (!this.#authority) {
|
|
811
|
+
const source = this.#resolve(sourceRelativePath);
|
|
812
|
+
const destination = this.#resolve(destinationRelativePath);
|
|
813
|
+
if (fs.existsSync(destination)) throw new Error("already_exists");
|
|
814
|
+
let destinationSnapshot: NativeDirectoryTreeSnapshot | undefined;
|
|
815
|
+
try {
|
|
816
|
+
this.ensureDirectory(destinationRelativePath);
|
|
817
|
+
destinationSnapshot = this.captureTree(destinationRelativePath);
|
|
818
|
+
for (const entry of expected.entries) {
|
|
819
|
+
if (entry.relativePath === "") continue;
|
|
820
|
+
const target = path.posix.join(destinationRelativePath.replaceAll(path.sep, "/"), entry.relativePath);
|
|
821
|
+
if (entry.kind === "directory") {
|
|
822
|
+
this.ensureDirectory(target);
|
|
823
|
+
destinationSnapshot = this.captureTree(destinationRelativePath);
|
|
824
|
+
continue;
|
|
825
|
+
}
|
|
826
|
+
const sourceEntry = this.readExpected(
|
|
827
|
+
path.posix.join(sourceRelativePath.replaceAll(path.sep, "/"), entry.relativePath),
|
|
828
|
+
);
|
|
829
|
+
if (
|
|
830
|
+
!sourceEntry ||
|
|
831
|
+
sourceEntry.identity.dev.toString() !== entry.dev ||
|
|
832
|
+
sourceEntry.identity.ino.toString() !== entry.ino ||
|
|
833
|
+
sourceEntry.identity.size.toString() !== entry.size ||
|
|
834
|
+
sourceEntry.identity.mtimeNs.toString() !== entry.mtimeNs ||
|
|
835
|
+
sourceEntry.identity.sha256 !== entry.sha256
|
|
836
|
+
)
|
|
837
|
+
throw new Error("artifact_source_changed");
|
|
838
|
+
this.publishNoReplaceSync(target, sourceEntry.bytes);
|
|
839
|
+
destinationSnapshot = this.captureTree(destinationRelativePath);
|
|
840
|
+
}
|
|
841
|
+
destinationSnapshot = this.captureTree(destinationRelativePath);
|
|
842
|
+
const comparable = (tree: NativeDirectoryTreeSnapshot) =>
|
|
843
|
+
tree.entries.map(entry => ({
|
|
844
|
+
relativePath: entry.relativePath,
|
|
845
|
+
kind: entry.kind,
|
|
846
|
+
size: entry.size,
|
|
847
|
+
sha256: entry.sha256,
|
|
848
|
+
}));
|
|
849
|
+
if (JSON.stringify(comparable(destinationSnapshot)) !== JSON.stringify(comparable(expected)))
|
|
850
|
+
throw new Error("artifact_destination_mismatch");
|
|
851
|
+
const terminalSource = snapshotDirectoryTree(source);
|
|
852
|
+
if (
|
|
853
|
+
!terminalSource.ok ||
|
|
854
|
+
!terminalSource.snapshot ||
|
|
855
|
+
JSON.stringify(terminalSource.snapshot) !== JSON.stringify(expected)
|
|
856
|
+
)
|
|
857
|
+
throw new Error("artifact_source_changed");
|
|
858
|
+
const retired = exactRemoveDirectoryTree(source, expected);
|
|
859
|
+
if (!retired.ok) throw new Error(retired.code ?? "managed_remove_failed");
|
|
860
|
+
} catch (error) {
|
|
861
|
+
if (destinationSnapshot) {
|
|
862
|
+
const removed = exactRemoveDirectoryTree(destination, destinationSnapshot);
|
|
863
|
+
if (!removed.ok) throw new Error(removed.code ?? "managed_publish_reconcile_failed");
|
|
864
|
+
}
|
|
865
|
+
throw error;
|
|
866
|
+
}
|
|
867
|
+
this.#assertBound();
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
const moved = this.#authority.renameManagedTreeNoReplace(
|
|
871
|
+
this.#relative(this.#resolve(sourceRelativePath)),
|
|
872
|
+
this.#relative(this.#resolve(destinationRelativePath)),
|
|
873
|
+
expected,
|
|
874
|
+
);
|
|
875
|
+
if (!moved.ok) {
|
|
876
|
+
try {
|
|
877
|
+
const installed = this.captureTree(destinationRelativePath);
|
|
878
|
+
const comparable = (tree: NativeDirectoryTreeSnapshot) =>
|
|
879
|
+
tree.entries.map(entry => ({
|
|
880
|
+
relativePath: entry.relativePath,
|
|
881
|
+
kind: entry.kind,
|
|
882
|
+
size: entry.size,
|
|
883
|
+
sha256: entry.sha256,
|
|
884
|
+
}));
|
|
885
|
+
if (JSON.stringify(comparable(installed)) !== JSON.stringify(comparable(expected)))
|
|
886
|
+
throw new Error("managed_publish_reconcile_failed");
|
|
887
|
+
this.removeTreeExpected(destinationRelativePath, installed);
|
|
888
|
+
} catch (error) {
|
|
889
|
+
if (!(error instanceof Error) || error.message !== "not_found") throw error;
|
|
890
|
+
}
|
|
891
|
+
throw new Error(moved.code ?? "managed_move_failed");
|
|
892
|
+
}
|
|
893
|
+
this.#assertBound();
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
removeTreeExpected(relativePath: string, expected: NativeDirectoryTreeSnapshot): void {
|
|
897
|
+
this.#assertBound();
|
|
898
|
+
if (!this.#authority) {
|
|
899
|
+
const removed = exactRemoveDirectoryTree(this.#resolve(relativePath), expected);
|
|
900
|
+
if (!removed.ok) throw new Error(removed.code ?? "managed_remove_failed");
|
|
901
|
+
this.#assertBound();
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
const removed = this.#authority.removeManagedTree(this.#relative(this.#resolve(relativePath)), expected);
|
|
905
|
+
if (!removed.ok) throw new Error(removed.code ?? "managed_remove_failed");
|
|
906
|
+
this.#assertBound();
|
|
907
|
+
}
|
|
908
|
+
fsyncTree(): NativeDirectoryTreeSnapshot {
|
|
909
|
+
this.#assertBound();
|
|
910
|
+
if (!this.#authority) return fsyncManagedArtifactTree(this.#baseDir);
|
|
911
|
+
const baseRelative = this.#relative(this.#baseDir);
|
|
912
|
+
const before = this.#authority.snapshotManagedTree(baseRelative);
|
|
913
|
+
if (!before.ok || !before.snapshot) throw new Error(before.code ?? "unsafe_artifacts");
|
|
914
|
+
const entries = [...before.snapshot.entries].sort((left, right) => {
|
|
915
|
+
const leftDirectory = left.kind === "directory";
|
|
916
|
+
const rightDirectory = right.kind === "directory";
|
|
917
|
+
if (leftDirectory !== rightDirectory) return leftDirectory ? 1 : -1;
|
|
918
|
+
return right.relativePath.split("/").length - left.relativePath.split("/").length;
|
|
919
|
+
});
|
|
920
|
+
for (const entry of entries) {
|
|
921
|
+
const retainedPath = entry.relativePath ? path.posix.join(baseRelative, entry.relativePath) : baseRelative;
|
|
922
|
+
const synced = this.#authority.fsyncExpected(
|
|
923
|
+
retainedPath,
|
|
924
|
+
entry.kind === "directory",
|
|
925
|
+
entry.dev,
|
|
926
|
+
entry.ino,
|
|
927
|
+
entry.size,
|
|
928
|
+
entry.mtimeNs,
|
|
929
|
+
entry.sha256,
|
|
930
|
+
);
|
|
931
|
+
if (!synced.ok) throw new Error(synced.code ?? "fsync_failed");
|
|
932
|
+
}
|
|
933
|
+
const after = this.#authority.snapshotManagedTree(baseRelative);
|
|
934
|
+
if (!after.ok || !after.snapshot || JSON.stringify(after.snapshot) !== JSON.stringify(before.snapshot)) {
|
|
935
|
+
throw new Error("artifact_tree_changed_during_fsync");
|
|
936
|
+
}
|
|
937
|
+
this.#assertBound();
|
|
938
|
+
return after.snapshot;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
#resolve(relativePath: string): string {
|
|
942
|
+
if (path.isAbsolute(relativePath) || relativePath.split(/[\\/]/).includes("..")) {
|
|
943
|
+
throw new Error("Managed descendant path escapes retained store");
|
|
944
|
+
}
|
|
945
|
+
const resolved = path.resolve(this.#baseDir, relativePath);
|
|
946
|
+
if (resolved !== this.#baseDir && !resolved.startsWith(`${this.#baseDir}${path.sep}`)) {
|
|
947
|
+
throw new Error("Managed descendant path escapes retained store");
|
|
948
|
+
}
|
|
949
|
+
assertManagedDirectoryRoot(this.#root);
|
|
950
|
+
return resolved;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
function secureFileDescriptor(pathname: string, fd: number, operation: "apply" | "verify"): void {
|
|
955
|
+
if (process.platform !== "linux") {
|
|
956
|
+
if (operation === "apply") secure(pathname, "file");
|
|
957
|
+
else {
|
|
958
|
+
const verified = validateNativeSecurityResult(verifyOwnerOnlyPathSecurity(pathname, "file"), "verify", "file");
|
|
959
|
+
if (!verified.ok) throw securityError(pathname, verified);
|
|
960
|
+
}
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
const result = validateNativeSecurityResult(
|
|
964
|
+
operation === "apply"
|
|
965
|
+
? applyOwnerOnlyFdSecurity(pathname, "file", fd)
|
|
966
|
+
: verifyOwnerOnlyFdSecurity(pathname, "file", fd),
|
|
967
|
+
operation,
|
|
968
|
+
"file",
|
|
969
|
+
);
|
|
970
|
+
if (!result.ok) throw securityError(pathname, result);
|
|
971
|
+
}
|
|
972
|
+
|
|
68
973
|
function assertSafeDirectory(pathname: string): void {
|
|
69
974
|
const stat = fs.lstatSync(pathname);
|
|
70
975
|
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`Unsafe managed directory: ${pathname}`);
|
|
@@ -92,13 +997,24 @@ function bootId(): string | undefined {
|
|
|
92
997
|
}
|
|
93
998
|
}
|
|
94
999
|
|
|
95
|
-
function identity(stat: fs.BigIntStats): ManagedFileSnapshot["identity"] {
|
|
96
|
-
return {
|
|
1000
|
+
function identity(stat: fs.BigIntStats, sha256 = ""): ManagedFileSnapshot["identity"] {
|
|
1001
|
+
return {
|
|
1002
|
+
dev: stat.dev,
|
|
1003
|
+
ino: stat.ino,
|
|
1004
|
+
size: Number(stat.size),
|
|
1005
|
+
mtimeNs: stat.mtimeNs,
|
|
1006
|
+
ctimeNs: stat.ctimeNs,
|
|
1007
|
+
sha256,
|
|
1008
|
+
};
|
|
97
1009
|
}
|
|
98
1010
|
|
|
99
1011
|
function sameIdentity(left: ManagedFileSnapshot["identity"], right: ManagedFileSnapshot["identity"]): boolean {
|
|
100
1012
|
return (
|
|
101
|
-
left.dev === right.dev &&
|
|
1013
|
+
left.dev === right.dev &&
|
|
1014
|
+
left.ino === right.ino &&
|
|
1015
|
+
left.size === right.size &&
|
|
1016
|
+
left.mtimeNs === right.mtimeNs &&
|
|
1017
|
+
left.ctimeNs === right.ctimeNs
|
|
102
1018
|
);
|
|
103
1019
|
}
|
|
104
1020
|
|
|
@@ -144,19 +1060,46 @@ function sameFileIdentity(left: fs.BigIntStats, right: fs.BigIntStats): boolean
|
|
|
144
1060
|
}
|
|
145
1061
|
|
|
146
1062
|
/** Create a managed directory and fail closed unless its owner-only mode/ACL verifies. */
|
|
147
|
-
export function ensureManagedDirectory(pathname: string): void {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
1063
|
+
export function ensureManagedDirectory(pathname: string, root?: ManagedDirectoryRoot): void {
|
|
1064
|
+
if (!root) {
|
|
1065
|
+
fs.mkdirSync(pathname, { recursive: true, mode: 0o700 });
|
|
1066
|
+
assertSafeDirectory(pathname);
|
|
1067
|
+
secure(pathname, "directory");
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
ensureManagedRoot(root);
|
|
1071
|
+
let current = root.canonicalPath;
|
|
1072
|
+
for (const component of managedRelativePath(root, pathname)) {
|
|
1073
|
+
// Re-inspect the captured root or already-secured descendant before every
|
|
1074
|
+
// descent; a replaced component cannot grant authority to the next one.
|
|
1075
|
+
if (current === root.canonicalPath) assertManagedDirectoryRoot(root);
|
|
1076
|
+
else {
|
|
1077
|
+
assertSafeDirectory(current);
|
|
1078
|
+
secure(current, "directory");
|
|
1079
|
+
}
|
|
1080
|
+
current = path.join(current, component);
|
|
1081
|
+
ensureDirectoryComponent(current);
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/** Captures a bounded prefix from one no-follow descriptor and rechecks the pathname before use. */
|
|
1086
|
+
export function captureManagedFilePrefixNoFollow(pathname: string, maxBytes: number): ManagedFileSnapshot {
|
|
1087
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) throw new Error("invalid_capture_limit");
|
|
1088
|
+
return captureManagedFileNoFollowLimit(pathname, maxBytes);
|
|
151
1089
|
}
|
|
152
1090
|
|
|
153
1091
|
/** Captures header/hash/copy input from one no-follow descriptor and rechecks the pathname before use. */
|
|
154
1092
|
export function captureManagedFileNoFollow(pathname: string): ManagedFileSnapshot {
|
|
1093
|
+
return captureManagedFileNoFollowLimit(pathname);
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
function captureManagedFileNoFollowLimit(pathname: string, maxBytes?: number): ManagedFileSnapshot {
|
|
155
1097
|
const fd = fs.openSync(pathname, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
156
1098
|
try {
|
|
157
1099
|
const before = fs.fstatSync(fd, { bigint: true });
|
|
158
1100
|
if (!before.isFile() || before.nlink > 1) throw new Error("source_changed");
|
|
159
|
-
const
|
|
1101
|
+
const captureSize = maxBytes === undefined ? Number(before.size) : Math.min(Number(before.size), maxBytes);
|
|
1102
|
+
const bytes = Buffer.alloc(captureSize);
|
|
160
1103
|
let offset = 0;
|
|
161
1104
|
while (offset < bytes.byteLength) {
|
|
162
1105
|
const count = fs.readSync(fd, bytes, offset, bytes.byteLength - offset, offset);
|
|
@@ -168,7 +1111,7 @@ export function captureManagedFileNoFollow(pathname: string): ManagedFileSnapsho
|
|
|
168
1111
|
const named = fs.lstatSync(pathname, { bigint: true });
|
|
169
1112
|
if (!named.isFile() || named.isSymbolicLink() || !sameIdentity(identity(before), identity(named)))
|
|
170
1113
|
throw new Error("source_changed");
|
|
171
|
-
return { bytes, identity: identity(before) };
|
|
1114
|
+
return { bytes, identity: identity(before, createHash("sha256").update(bytes).digest("hex")) };
|
|
172
1115
|
} finally {
|
|
173
1116
|
fs.closeSync(fd);
|
|
174
1117
|
}
|
|
@@ -179,11 +1122,16 @@ export async function publishManagedFileNoReplace(
|
|
|
179
1122
|
destination: string,
|
|
180
1123
|
bytes: Uint8Array,
|
|
181
1124
|
assertOwned?: () => void,
|
|
1125
|
+
root?: ManagedDirectoryRoot,
|
|
182
1126
|
): Promise<void> {
|
|
183
1127
|
const parent = path.dirname(destination);
|
|
184
|
-
ensureManagedDirectory(parent);
|
|
1128
|
+
ensureManagedDirectory(parent, root);
|
|
185
1129
|
const staging = path.join(parent, `.${path.basename(destination)}.${randomUUID()}.staging`);
|
|
186
1130
|
let fd: number | undefined;
|
|
1131
|
+
let stagingIdentity: { dev: bigint; ino: bigint } | undefined;
|
|
1132
|
+
let stagingSnapshot: ManagedFileSnapshot | undefined;
|
|
1133
|
+
let committed = false;
|
|
1134
|
+
let failure: unknown;
|
|
187
1135
|
try {
|
|
188
1136
|
assertOwned?.();
|
|
189
1137
|
fd = fs.openSync(
|
|
@@ -191,82 +1139,231 @@ export async function publishManagedFileNoReplace(
|
|
|
191
1139
|
fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | fs.constants.O_NOFOLLOW,
|
|
192
1140
|
0o600,
|
|
193
1141
|
);
|
|
1142
|
+
secureFileDescriptor(staging, fd, "apply");
|
|
194
1143
|
let offset = 0;
|
|
195
1144
|
while (offset < bytes.byteLength) offset += fs.writeSync(fd, bytes, offset, bytes.byteLength - offset);
|
|
196
1145
|
fs.fsyncSync(fd);
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
1146
|
+
secureFileDescriptor(staging, fd, "verify");
|
|
1147
|
+
const staged = fs.fstatSync(fd, { bigint: true });
|
|
1148
|
+
stagingIdentity = { dev: staged.dev, ino: staged.ino };
|
|
1149
|
+
stagingSnapshot = {
|
|
1150
|
+
bytes: Buffer.from(bytes),
|
|
1151
|
+
identity: identity(staged, createHash("sha256").update(bytes).digest("hex")),
|
|
1152
|
+
};
|
|
200
1153
|
assertOwned?.();
|
|
1154
|
+
|
|
201
1155
|
const published = renameNoReplacePath(staging, destination);
|
|
202
1156
|
if (!published.ok) {
|
|
203
1157
|
if (published.code === "quarantine_collision") throw new Error("destination_conflict");
|
|
204
1158
|
throw new Error("durability_failed");
|
|
205
1159
|
}
|
|
206
|
-
|
|
1160
|
+
const named = fs.lstatSync(destination, { bigint: true });
|
|
1161
|
+
if (
|
|
1162
|
+
!named.isFile() ||
|
|
1163
|
+
named.isSymbolicLink() ||
|
|
1164
|
+
named.dev !== stagingIdentity.dev ||
|
|
1165
|
+
named.ino !== stagingIdentity.ino
|
|
1166
|
+
) {
|
|
1167
|
+
throw new Error("destination_identity_changed");
|
|
1168
|
+
}
|
|
1169
|
+
secureFileDescriptor(destination, fd, "verify");
|
|
1170
|
+
fs.closeSync(fd);
|
|
1171
|
+
fd = undefined;
|
|
1172
|
+
|
|
207
1173
|
fsyncDirectory(parent);
|
|
1174
|
+
committed = true;
|
|
1175
|
+
} catch (error) {
|
|
1176
|
+
failure = error;
|
|
208
1177
|
} finally {
|
|
209
1178
|
if (fd !== undefined) fs.closeSync(fd);
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
1179
|
+
if (!committed && stagingSnapshot) {
|
|
1180
|
+
try {
|
|
1181
|
+
const installed = captureManagedFileNoFollow(destination);
|
|
1182
|
+
if (
|
|
1183
|
+
installed.identity.dev === stagingSnapshot.identity.dev &&
|
|
1184
|
+
installed.identity.ino === stagingSnapshot.identity.ino &&
|
|
1185
|
+
installed.identity.sha256 === stagingSnapshot.identity.sha256
|
|
1186
|
+
)
|
|
1187
|
+
await unlinkManagedFileVerified(destination, installed.identity);
|
|
1188
|
+
} catch (error) {
|
|
1189
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") failure ??= error;
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
if (stagingIdentity) {
|
|
1193
|
+
await fsp
|
|
1194
|
+
.lstat(staging, { bigint: true })
|
|
1195
|
+
.then(stat => {
|
|
1196
|
+
if (stat.dev === stagingIdentity?.dev && stat.ino === stagingIdentity.ino) return fsp.unlink(staging);
|
|
1197
|
+
throw new Error("staging_identity_changed");
|
|
1198
|
+
})
|
|
1199
|
+
.catch(error => {
|
|
1200
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") failure ??= error;
|
|
1201
|
+
});
|
|
1202
|
+
}
|
|
213
1203
|
}
|
|
1204
|
+
if (failure !== undefined) throw failure;
|
|
214
1205
|
}
|
|
215
1206
|
|
|
216
|
-
export function publishManagedFileNoReplaceSync(
|
|
1207
|
+
export function publishManagedFileNoReplaceSync(
|
|
1208
|
+
destination: string,
|
|
1209
|
+
bytes: Uint8Array,
|
|
1210
|
+
root?: ManagedDirectoryRoot,
|
|
1211
|
+
): void {
|
|
217
1212
|
const parent = path.dirname(destination);
|
|
218
|
-
ensureManagedDirectory(parent);
|
|
1213
|
+
ensureManagedDirectory(parent, root);
|
|
219
1214
|
const staging = path.join(parent, `.${path.basename(destination)}.${randomUUID()}.staging`);
|
|
1215
|
+
let fd: number | undefined;
|
|
1216
|
+
let stagingIdentity: { dev: bigint; ino: bigint } | undefined;
|
|
1217
|
+
let stagingSnapshot: ManagedFileSnapshot | undefined;
|
|
1218
|
+
let committed = false;
|
|
220
1219
|
let failure: unknown;
|
|
221
1220
|
try {
|
|
222
|
-
|
|
1221
|
+
fd = fs.openSync(
|
|
223
1222
|
staging,
|
|
224
1223
|
fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | fs.constants.O_NOFOLLOW,
|
|
225
1224
|
0o600,
|
|
226
1225
|
);
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
}
|
|
234
|
-
|
|
1226
|
+
secureFileDescriptor(staging, fd, "apply");
|
|
1227
|
+
let offset = 0;
|
|
1228
|
+
while (offset < bytes.byteLength) offset += fs.writeSync(fd, bytes, offset, bytes.byteLength - offset);
|
|
1229
|
+
fs.fsyncSync(fd);
|
|
1230
|
+
secureFileDescriptor(staging, fd, "verify");
|
|
1231
|
+
const staged = fs.fstatSync(fd, { bigint: true });
|
|
1232
|
+
stagingIdentity = { dev: staged.dev, ino: staged.ino };
|
|
1233
|
+
stagingSnapshot = {
|
|
1234
|
+
bytes: Buffer.from(bytes),
|
|
1235
|
+
identity: identity(staged, createHash("sha256").update(bytes).digest("hex")),
|
|
1236
|
+
};
|
|
1237
|
+
|
|
235
1238
|
const published = renameNoReplacePath(staging, destination);
|
|
236
1239
|
if (!published.ok) {
|
|
237
1240
|
if (published.code === "quarantine_collision") throw new Error("destination_conflict");
|
|
238
1241
|
throw new Error("durability_failed");
|
|
239
1242
|
}
|
|
240
|
-
|
|
1243
|
+
const named = fs.lstatSync(destination, { bigint: true });
|
|
1244
|
+
if (!named.isFile() || named.isSymbolicLink() || named.dev !== staged.dev || named.ino !== staged.ino) {
|
|
1245
|
+
throw new Error("destination_identity_changed");
|
|
1246
|
+
}
|
|
1247
|
+
secureFileDescriptor(destination, fd, "verify");
|
|
1248
|
+
fs.closeSync(fd);
|
|
1249
|
+
fd = undefined;
|
|
241
1250
|
fsyncDirectory(parent);
|
|
1251
|
+
committed = true;
|
|
242
1252
|
} catch (error) {
|
|
243
1253
|
failure = error;
|
|
1254
|
+
} finally {
|
|
1255
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
244
1256
|
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
1257
|
+
if (stagingIdentity) {
|
|
1258
|
+
if (!committed && stagingSnapshot) {
|
|
1259
|
+
try {
|
|
1260
|
+
const installed = captureManagedFileNoFollow(destination);
|
|
1261
|
+
if (
|
|
1262
|
+
installed.identity.dev === stagingSnapshot.identity.dev &&
|
|
1263
|
+
installed.identity.ino === stagingSnapshot.identity.ino &&
|
|
1264
|
+
installed.identity.sha256 === stagingSnapshot.identity.sha256
|
|
1265
|
+
) {
|
|
1266
|
+
const removed = exactUnlink(destination, {
|
|
1267
|
+
dev: installed.identity.dev,
|
|
1268
|
+
ino: installed.identity.ino,
|
|
1269
|
+
size: BigInt(installed.identity.size),
|
|
1270
|
+
mtimeNs: installed.identity.mtimeNs,
|
|
1271
|
+
sha256: installed.identity.sha256,
|
|
1272
|
+
quarantineName: `.gjc-remove-${process.pid}-${randomUUID()}`,
|
|
1273
|
+
});
|
|
1274
|
+
if (!removed.ok) failure ??= new Error(removed.code ?? "managed_publish_reconcile_failed");
|
|
1275
|
+
}
|
|
1276
|
+
} catch (error) {
|
|
1277
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") failure ??= error;
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
try {
|
|
1281
|
+
const named = fs.lstatSync(staging, { bigint: true });
|
|
1282
|
+
if (named.dev !== stagingIdentity.dev || named.ino !== stagingIdentity.ino) {
|
|
1283
|
+
throw new Error("staging_identity_changed");
|
|
1284
|
+
}
|
|
1285
|
+
fs.unlinkSync(staging);
|
|
1286
|
+
} catch (error) {
|
|
1287
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") failure ??= error;
|
|
1288
|
+
}
|
|
249
1289
|
}
|
|
250
1290
|
if (failure !== undefined) throw failure;
|
|
251
1291
|
}
|
|
252
1292
|
|
|
1293
|
+
/** Replace one managed regular file while retaining the secured staging fd through publication. */
|
|
1294
|
+
export function replaceManagedFileSync(destination: string, bytes: Uint8Array, root: ManagedDirectoryRoot): void {
|
|
1295
|
+
const parent = path.dirname(destination);
|
|
1296
|
+
ensureManagedDirectory(parent, root);
|
|
1297
|
+
const staging = path.join(parent, `.${path.basename(destination)}.${randomUUID()}.replacement`);
|
|
1298
|
+
let fd: number | undefined;
|
|
1299
|
+
let stagedIdentity: { dev: bigint; ino: bigint } | undefined;
|
|
1300
|
+
try {
|
|
1301
|
+
fd = fs.openSync(
|
|
1302
|
+
staging,
|
|
1303
|
+
fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | fs.constants.O_NOFOLLOW,
|
|
1304
|
+
0o600,
|
|
1305
|
+
);
|
|
1306
|
+
secureFileDescriptor(staging, fd, "apply");
|
|
1307
|
+
let offset = 0;
|
|
1308
|
+
while (offset < bytes.byteLength) offset += fs.writeSync(fd, bytes, offset, bytes.byteLength - offset);
|
|
1309
|
+
fs.fsyncSync(fd);
|
|
1310
|
+
secureFileDescriptor(staging, fd, "verify");
|
|
1311
|
+
const staged = fs.fstatSync(fd, { bigint: true });
|
|
1312
|
+
stagedIdentity = { dev: staged.dev, ino: staged.ino };
|
|
1313
|
+
assertManagedDirectoryRoot(root);
|
|
1314
|
+
fs.renameSync(staging, destination);
|
|
1315
|
+
assertManagedDirectoryRoot(root);
|
|
1316
|
+
const named = fs.lstatSync(destination, { bigint: true });
|
|
1317
|
+
if (!named.isFile() || named.isSymbolicLink() || named.dev !== staged.dev || named.ino !== staged.ino) {
|
|
1318
|
+
throw new Error("destination_identity_changed");
|
|
1319
|
+
}
|
|
1320
|
+
secureFileDescriptor(destination, fd, "verify");
|
|
1321
|
+
fs.closeSync(fd);
|
|
1322
|
+
fd = undefined;
|
|
1323
|
+
fsyncDirectory(parent);
|
|
1324
|
+
} finally {
|
|
1325
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
1326
|
+
if (stagedIdentity) {
|
|
1327
|
+
try {
|
|
1328
|
+
const named = fs.lstatSync(staging, { bigint: true });
|
|
1329
|
+
if (named.dev === stagedIdentity.dev && named.ino === stagedIdentity.ino) fs.unlinkSync(staging);
|
|
1330
|
+
} catch {
|
|
1331
|
+
// Never delete an object whose staging identity is no longer proven.
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
export async function replaceManagedFile(
|
|
1338
|
+
destination: string,
|
|
1339
|
+
bytes: Uint8Array,
|
|
1340
|
+
root: ManagedDirectoryRoot,
|
|
1341
|
+
): Promise<void> {
|
|
1342
|
+
replaceManagedFileSync(destination, bytes, root);
|
|
1343
|
+
}
|
|
1344
|
+
|
|
253
1345
|
/** Copy the exact bytes captured from one no-follow source descriptor. */
|
|
254
1346
|
export async function copyManagedFileNoReplace(
|
|
255
1347
|
source: string,
|
|
256
1348
|
destination: string,
|
|
257
1349
|
snapshot = captureManagedFileNoFollow(source),
|
|
1350
|
+
root?: ManagedDirectoryRoot,
|
|
258
1351
|
): Promise<void> {
|
|
259
1352
|
const named = captureManagedFileNoFollow(source);
|
|
260
1353
|
if (!sameIdentity(snapshot.identity, named.identity) || !snapshot.bytes.equals(named.bytes))
|
|
261
1354
|
throw new Error("source_changed");
|
|
262
|
-
await publishManagedFileNoReplace(destination, snapshot.bytes);
|
|
1355
|
+
await publishManagedFileNoReplace(destination, snapshot.bytes, undefined, root);
|
|
263
1356
|
const destinationSnapshot = captureManagedFileNoFollow(destination);
|
|
264
1357
|
if (!destinationSnapshot.bytes.equals(snapshot.bytes)) throw new Error("durability_failed");
|
|
265
1358
|
}
|
|
266
1359
|
|
|
267
1360
|
/** Acquire a lease lock with bounded wait, heartbeats, conservative stale reclaim, and fencing. */
|
|
268
|
-
export async function acquireManagedLock(
|
|
269
|
-
|
|
1361
|
+
export async function acquireManagedLock(
|
|
1362
|
+
locksDirectory: string,
|
|
1363
|
+
name: string,
|
|
1364
|
+
root?: ManagedDirectoryRoot,
|
|
1365
|
+
): Promise<ManagedStorageLock> {
|
|
1366
|
+
ensureManagedDirectory(locksDirectory, root);
|
|
270
1367
|
const lockPath = path.join(locksDirectory, `${name}.lock`);
|
|
271
1368
|
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
272
1369
|
let staleObservedAt: number | undefined;
|
|
@@ -289,9 +1386,9 @@ export async function acquireManagedLock(locksDirectory: string, name: string):
|
|
|
289
1386
|
0o600,
|
|
290
1387
|
);
|
|
291
1388
|
try {
|
|
1389
|
+
secureFileDescriptor(lockPath, fd, "apply");
|
|
292
1390
|
fs.writeFileSync(fd, `${JSON.stringify(record)}\n`);
|
|
293
1391
|
fs.fsyncSync(fd);
|
|
294
|
-
secure(lockPath, "file");
|
|
295
1392
|
fsyncDirectory(locksDirectory);
|
|
296
1393
|
} catch (error) {
|
|
297
1394
|
fs.closeSync(fd);
|
|
@@ -301,7 +1398,10 @@ export async function acquireManagedLock(locksDirectory: string, name: string):
|
|
|
301
1398
|
let released = false;
|
|
302
1399
|
let descriptorClosed = false;
|
|
303
1400
|
const closeDescriptor = (): void => {
|
|
304
|
-
if (
|
|
1401
|
+
if (descriptorClosed) return;
|
|
1402
|
+
try {
|
|
1403
|
+
secureFileDescriptor(lockPath, fd, "verify");
|
|
1404
|
+
} finally {
|
|
305
1405
|
fs.closeSync(fd);
|
|
306
1406
|
descriptorClosed = true;
|
|
307
1407
|
}
|
|
@@ -406,7 +1506,9 @@ export function validateManagedArtifactTree(root: string): void {
|
|
|
406
1506
|
}
|
|
407
1507
|
|
|
408
1508
|
/** Flush a copied managed artifact tree, including empty directories, before publishing its receipt. */
|
|
409
|
-
export function fsyncManagedArtifactTree(root: string):
|
|
1509
|
+
export function fsyncManagedArtifactTree(root: string): NativeDirectoryTreeSnapshot {
|
|
1510
|
+
const before = snapshotDirectoryTree(root);
|
|
1511
|
+
if (!before.ok || !before.snapshot) throw new Error(before.code ?? "unsafe_artifacts");
|
|
410
1512
|
const visit = (pathname: string): void => {
|
|
411
1513
|
const stat = fs.lstatSync(pathname);
|
|
412
1514
|
if (stat.isSymbolicLink()) throw new Error("unsafe_artifacts");
|
|
@@ -425,7 +1527,11 @@ export function fsyncManagedArtifactTree(root: string): void {
|
|
|
425
1527
|
};
|
|
426
1528
|
validateManagedArtifactTree(root);
|
|
427
1529
|
visit(root);
|
|
428
|
-
|
|
1530
|
+
const after = snapshotDirectoryTree(root);
|
|
1531
|
+
if (!after.ok || !after.snapshot || JSON.stringify(after.snapshot) !== JSON.stringify(before.snapshot)) {
|
|
1532
|
+
throw new Error("artifact_tree_changed_during_fsync");
|
|
1533
|
+
}
|
|
1534
|
+
return after.snapshot;
|
|
429
1535
|
}
|
|
430
1536
|
|
|
431
1537
|
export async function publishManagedTombstone(
|