pi-oracle 0.7.10 → 0.7.12
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/README.md +13 -13
- package/docs/ORACLE_DESIGN.md +6 -5
- package/docs/ORACLE_ISOLATED_PI_VALIDATION.md +1 -1
- package/docs/platform-smoke.md +2 -2
- package/extensions/oracle/lib/archive.ts +723 -0
- package/extensions/oracle/lib/config.ts +16 -7
- package/extensions/oracle/lib/jobs.ts +2 -1
- package/extensions/oracle/lib/provider-capabilities.ts +41 -0
- package/extensions/oracle/lib/runtime.ts +9 -5
- package/extensions/oracle/lib/tools.ts +10 -576
- package/package.json +3 -3
- package/platform-smoke.config.mjs +1 -1
- package/scripts/oracle-real-smoke.mjs +4 -2
- package/scripts/platform-smoke/targets.mjs +1 -1
- package/scripts/platform-smoke.mjs +1 -1
|
@@ -0,0 +1,723 @@
|
|
|
1
|
+
// Purpose: Build provider-ready oracle context archives from selected project paths.
|
|
2
|
+
// Responsibilities: Expand archive inputs with default exclusions, apply whole-repo size pruning, and write .tar.zst or .tar.gz files safely.
|
|
3
|
+
// Scope: Archive construction only; tool orchestration, job admission, and worker execution live in sibling modules.
|
|
4
|
+
// Usage: Imported by oracle_submit and sanity tests to keep archive behavior provider-aware and regression-testable.
|
|
5
|
+
// Invariants/Assumptions: Archive entries are project-relative paths already validated through resolveArchiveInputs, and archive subprocesses must not inherit browser safe-storage secrets.
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import { once } from "node:events";
|
|
8
|
+
import { createReadStream, createWriteStream } from "node:fs";
|
|
9
|
+
import { lstat, mkdtemp, readdir, readlink, rm, stat, writeFile } from "node:fs/promises";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { basename, dirname, join, posix } from "node:path";
|
|
12
|
+
import { pipeline } from "node:stream/promises";
|
|
13
|
+
import { createGzip } from "node:zlib";
|
|
14
|
+
import { sweetCookieSafeStoragePasswordScrubbedEnv } from "../shared/browser-profile-helpers.mjs";
|
|
15
|
+
import { resolveOracleProviderArchivePlan, type OracleArchiveFormat } from "./provider-capabilities.js";
|
|
16
|
+
import { resolveArchiveInputs, sha256File } from "./jobs.js";
|
|
17
|
+
|
|
18
|
+
const ARCHIVE_COMMAND_TIMEOUT_MS = 120_000;
|
|
19
|
+
const ARCHIVE_COMMAND_KILL_GRACE_MS = 2_000;
|
|
20
|
+
const ARCHIVE_PIPE_FAILURE_ERROR_CODES = new Set(["EPIPE", "ERR_STREAM_DESTROYED"]);
|
|
21
|
+
const DEFAULT_ARCHIVE_PLAN = resolveOracleProviderArchivePlan("chatgpt");
|
|
22
|
+
const DEFAULT_ARCHIVE_FORMAT = DEFAULT_ARCHIVE_PLAN.archiveFormat;
|
|
23
|
+
const DEFAULT_MAX_ARCHIVE_BYTES = DEFAULT_ARCHIVE_PLAN.maxArchiveBytes;
|
|
24
|
+
|
|
25
|
+
const DEFAULT_ARCHIVE_EXCLUDED_DIR_NAMES_ANYWHERE = new Set([
|
|
26
|
+
".git",
|
|
27
|
+
".hg",
|
|
28
|
+
".svn",
|
|
29
|
+
".pi",
|
|
30
|
+
".oracle-context",
|
|
31
|
+
".cursor",
|
|
32
|
+
".artifacts",
|
|
33
|
+
".crabbox",
|
|
34
|
+
"node_modules",
|
|
35
|
+
"target",
|
|
36
|
+
".venv",
|
|
37
|
+
"venv",
|
|
38
|
+
"__pycache__",
|
|
39
|
+
".pytest_cache",
|
|
40
|
+
".mypy_cache",
|
|
41
|
+
".ruff_cache",
|
|
42
|
+
".tox",
|
|
43
|
+
".nox",
|
|
44
|
+
".hypothesis",
|
|
45
|
+
".next",
|
|
46
|
+
".nuxt",
|
|
47
|
+
".svelte-kit",
|
|
48
|
+
".turbo",
|
|
49
|
+
".parcel-cache",
|
|
50
|
+
".cache",
|
|
51
|
+
".gradle",
|
|
52
|
+
".terraform",
|
|
53
|
+
"DerivedData",
|
|
54
|
+
".build",
|
|
55
|
+
".pnpm-store",
|
|
56
|
+
".serverless",
|
|
57
|
+
".aws-sam",
|
|
58
|
+
"secrets",
|
|
59
|
+
".secrets",
|
|
60
|
+
]);
|
|
61
|
+
const DEFAULT_ARCHIVE_EXCLUDED_DIR_NAMES_AT_REPO_ROOT = new Set(["coverage", "htmlcov", "tmp", "temp", ".tmp", "dist", "build", "out"]);
|
|
62
|
+
const DEFAULT_ARCHIVE_EXCLUDED_FILES = new Set([
|
|
63
|
+
".coverage",
|
|
64
|
+
".DS_Store",
|
|
65
|
+
".env",
|
|
66
|
+
".netrc",
|
|
67
|
+
".npmrc",
|
|
68
|
+
".pypirc",
|
|
69
|
+
".scratchpad.md",
|
|
70
|
+
"Thumbs.db",
|
|
71
|
+
"id_dsa",
|
|
72
|
+
"id_ecdsa",
|
|
73
|
+
"id_ed25519",
|
|
74
|
+
"id_rsa",
|
|
75
|
+
]);
|
|
76
|
+
const DEFAULT_ARCHIVE_EXCLUDED_SUFFIXES = [".db", ".key", ".p12", ".pfx", ".pyc", ".pyd", ".pyo", ".pem", ".sqlite", ".sqlite3", ".tsbuildinfo", ".tfstate"];
|
|
77
|
+
const DEFAULT_ARCHIVE_EXCLUDED_SUBSTRINGS = [".tfstate."];
|
|
78
|
+
const DEFAULT_ARCHIVE_EXCLUDED_ENV_ALLOWLIST = new Set([".env.dist", ".env.example", ".env.sample", ".env.template"]);
|
|
79
|
+
const DEFAULT_ARCHIVE_EXCLUDED_PATH_SEQUENCES = [[".yarn", "cache"]] as const;
|
|
80
|
+
const ADAPTIVE_ARCHIVE_PRUNE_DIR_NAMES_ANYWHERE = new Set(["build", "dist", "out", "coverage", "htmlcov", "tmp", "temp", ".tmp"]);
|
|
81
|
+
const ADAPTIVE_ARCHIVE_PRUNE_PROTECTED_ANCESTOR_DIR_NAMES = new Set(["src", "source", "sources", "lib"]);
|
|
82
|
+
|
|
83
|
+
export type ArchiveSizeBreakdownRow = { relativePath: string; bytes: number };
|
|
84
|
+
|
|
85
|
+
export type ArchiveCreationResult = {
|
|
86
|
+
sha256: string;
|
|
87
|
+
archiveBytes: number;
|
|
88
|
+
initialArchiveBytes?: number;
|
|
89
|
+
autoPrunedPrefixes: ArchiveSizeBreakdownRow[];
|
|
90
|
+
includedEntries: string[];
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
function appendArchiveEntries(target: string[], source: Iterable<string>): void {
|
|
94
|
+
for (const entry of source) target.push(entry);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function getErrorCode(error: unknown): string | undefined {
|
|
98
|
+
return error && typeof error === "object" && "code" in error && typeof error.code === "string"
|
|
99
|
+
? error.code
|
|
100
|
+
: undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function mergeArchiveEntryGroups(groups: Iterable<Iterable<string>>): string[] {
|
|
104
|
+
const merged: string[] = [];
|
|
105
|
+
for (const group of groups) appendArchiveEntries(merged, group);
|
|
106
|
+
return merged;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function mergeArchiveEntryGroupsForTesting(groups: Iterable<Iterable<string>>): string[] {
|
|
110
|
+
return mergeArchiveEntryGroups(groups);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function pathContainsSequence(relativePath: string, sequence: readonly string[]): boolean {
|
|
114
|
+
const segments = relativePath.split("/").filter(Boolean);
|
|
115
|
+
if (sequence.length === 0 || segments.length < sequence.length) return false;
|
|
116
|
+
for (let index = 0; index <= segments.length - sequence.length; index += 1) {
|
|
117
|
+
if (sequence.every((segment, offset) => segments[index + offset] === segment)) return true;
|
|
118
|
+
}
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function getRelativeDepth(relativePath: string): number {
|
|
123
|
+
return relativePath.split("/").filter(Boolean).length;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function formatBytes(bytes: number): string {
|
|
127
|
+
return `${(bytes / (1024 * 1024)).toFixed(2)} MiB`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function formatDirectoryLabel(relativePath: string): string {
|
|
131
|
+
return relativePath.endsWith("/") ? relativePath : `${relativePath}/`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function summarizeByKey(
|
|
135
|
+
entrySizes: ArchiveSizeBreakdownRow[],
|
|
136
|
+
keyForEntry: (relativePath: string) => string | undefined,
|
|
137
|
+
limit = 7,
|
|
138
|
+
): ArchiveSizeBreakdownRow[] {
|
|
139
|
+
const totals = new Map<string, number>();
|
|
140
|
+
for (const entry of entrySizes) {
|
|
141
|
+
const key = keyForEntry(entry.relativePath);
|
|
142
|
+
if (!key) continue;
|
|
143
|
+
totals.set(key, (totals.get(key) ?? 0) + entry.bytes);
|
|
144
|
+
}
|
|
145
|
+
return [...totals.entries()]
|
|
146
|
+
.map(([relativePath, bytes]) => ({ relativePath, bytes }))
|
|
147
|
+
.sort((left, right) => right.bytes - left.bytes || left.relativePath.localeCompare(right.relativePath))
|
|
148
|
+
.slice(0, limit);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function summarizeTopLevelIncludedPaths(entrySizes: ArchiveSizeBreakdownRow[]): ArchiveSizeBreakdownRow[] {
|
|
152
|
+
return summarizeByKey(entrySizes, (relativePath) => {
|
|
153
|
+
const [topLevel, ...rest] = relativePath.split("/").filter(Boolean);
|
|
154
|
+
if (!topLevel) return undefined;
|
|
155
|
+
return rest.length > 0 ? `${topLevel}/` : topLevel;
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function getAdaptivePrunePrefix(relativePath: string): string | undefined {
|
|
160
|
+
const segments = relativePath.split("/").filter(Boolean);
|
|
161
|
+
for (let index = 0; index < segments.length - 1; index += 1) {
|
|
162
|
+
const name = segments[index];
|
|
163
|
+
if (!ADAPTIVE_ARCHIVE_PRUNE_DIR_NAMES_ANYWHERE.has(name)) continue;
|
|
164
|
+
const ancestors = segments.slice(0, index);
|
|
165
|
+
if (ancestors.some((segment) => ADAPTIVE_ARCHIVE_PRUNE_PROTECTED_ANCESTOR_DIR_NAMES.has(segment))) continue;
|
|
166
|
+
return segments.slice(0, index + 1).join("/");
|
|
167
|
+
}
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function summarizeAdaptivePruneCandidates(
|
|
172
|
+
entrySizes: ArchiveSizeBreakdownRow[],
|
|
173
|
+
minimumBytes = 0,
|
|
174
|
+
): ArchiveSizeBreakdownRow[] {
|
|
175
|
+
return summarizeByKey(entrySizes, getAdaptivePrunePrefix, Number.POSITIVE_INFINITY).filter((entry) => entry.bytes >= minimumBytes);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function pruneEntriesByPrefix(entries: string[], prefix: string): string[] {
|
|
179
|
+
return entries.filter((entry) => entry !== prefix && !entry.startsWith(`${prefix}/`));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function shouldExcludeArchivePath(relativePath: string, isDirectory: boolean, options?: { forceInclude?: boolean }): boolean {
|
|
183
|
+
const normalized = posix.normalize(relativePath).replace(/^\.\//, "");
|
|
184
|
+
if (!normalized || normalized === ".") return false;
|
|
185
|
+
if (options?.forceInclude) return false;
|
|
186
|
+
const name = basename(normalized);
|
|
187
|
+
if (DEFAULT_ARCHIVE_EXCLUDED_PATH_SEQUENCES.some((sequence) => pathContainsSequence(normalized, sequence))) return true;
|
|
188
|
+
if (isDirectory) {
|
|
189
|
+
if (DEFAULT_ARCHIVE_EXCLUDED_DIR_NAMES_ANYWHERE.has(name)) return true;
|
|
190
|
+
if (getRelativeDepth(normalized) === 1 && DEFAULT_ARCHIVE_EXCLUDED_DIR_NAMES_AT_REPO_ROOT.has(name)) return true;
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
if (DEFAULT_ARCHIVE_EXCLUDED_FILES.has(name)) return true;
|
|
194
|
+
if (name.startsWith(".env.") && !DEFAULT_ARCHIVE_EXCLUDED_ENV_ALLOWLIST.has(name)) return true;
|
|
195
|
+
if (DEFAULT_ARCHIVE_EXCLUDED_SUFFIXES.some((suffix) => name.endsWith(suffix))) return true;
|
|
196
|
+
if (DEFAULT_ARCHIVE_EXCLUDED_SUBSTRINGS.some((needle) => name.includes(needle))) return true;
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function isSymlinkToDirectory(path: string): Promise<boolean> {
|
|
201
|
+
try {
|
|
202
|
+
return (await stat(path)).isDirectory();
|
|
203
|
+
} catch {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function shouldExcludeArchiveChild(
|
|
209
|
+
absolutePath: string,
|
|
210
|
+
relativePath: string,
|
|
211
|
+
child: { isDirectory(): boolean; isSymbolicLink(): boolean },
|
|
212
|
+
options?: { forceInclude?: boolean },
|
|
213
|
+
): Promise<boolean> {
|
|
214
|
+
const isDirectoryLike = child.isDirectory() || (child.isSymbolicLink() && await isSymlinkToDirectory(absolutePath));
|
|
215
|
+
return shouldExcludeArchivePath(relativePath, isDirectoryLike, options);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function expandArchiveEntries(cwd: string, relativePath: string, options?: { forceIncludeSubtree?: boolean }): Promise<string[]> {
|
|
219
|
+
const normalized = posix.normalize(relativePath).replace(/^\.\//, "");
|
|
220
|
+
if (normalized === ".") {
|
|
221
|
+
const children = await readdir(cwd, { withFileTypes: true });
|
|
222
|
+
const results: string[] = [];
|
|
223
|
+
for (const child of children.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
224
|
+
const childRelative = child.name;
|
|
225
|
+
if (await shouldExcludeArchiveChild(join(cwd, childRelative), childRelative, child)) continue;
|
|
226
|
+
if (child.isDirectory()) appendArchiveEntries(results, await expandArchiveEntries(cwd, childRelative));
|
|
227
|
+
else results.push(childRelative);
|
|
228
|
+
}
|
|
229
|
+
return results;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const absolute = join(cwd, normalized);
|
|
233
|
+
const entry = await lstat(absolute);
|
|
234
|
+
if (!entry.isDirectory()) return [normalized];
|
|
235
|
+
if (shouldExcludeArchivePath(normalized, true, { forceInclude: options?.forceIncludeSubtree })) return [];
|
|
236
|
+
|
|
237
|
+
const children = await readdir(absolute, { withFileTypes: true });
|
|
238
|
+
const results: string[] = [];
|
|
239
|
+
for (const child of children.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
240
|
+
const childRelative = posix.join(normalized, child.name);
|
|
241
|
+
if (await shouldExcludeArchiveChild(join(cwd, childRelative), childRelative, child, { forceInclude: options?.forceIncludeSubtree })) continue;
|
|
242
|
+
if (child.isDirectory()) appendArchiveEntries(results, await expandArchiveEntries(cwd, childRelative, { forceIncludeSubtree: options?.forceIncludeSubtree }));
|
|
243
|
+
else results.push(childRelative);
|
|
244
|
+
}
|
|
245
|
+
return results;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function resolveExpandedArchiveEntriesFromInputs(
|
|
249
|
+
cwd: string,
|
|
250
|
+
entries: Array<{ absolute: string; relative: string }>,
|
|
251
|
+
): Promise<string[]> {
|
|
252
|
+
const expandedGroups = await Promise.all(entries.map(async (entry) => {
|
|
253
|
+
const statEntry = await lstat(entry.absolute);
|
|
254
|
+
const forceIncludeSubtree = statEntry.isDirectory() && entry.relative !== "." && shouldExcludeArchivePath(entry.relative, true);
|
|
255
|
+
return expandArchiveEntries(cwd, entry.relative, { forceIncludeSubtree });
|
|
256
|
+
}));
|
|
257
|
+
return Array.from(new Set(mergeArchiveEntryGroups(expandedGroups))).sort();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export async function resolveExpandedArchiveEntries(cwd: string, files: string[]): Promise<string[]> {
|
|
261
|
+
return resolveExpandedArchiveEntriesFromInputs(cwd, resolveArchiveInputs(cwd, files));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function isWholeRepoArchiveSelection(entries: Array<{ absolute: string; relative: string }>): boolean {
|
|
265
|
+
return entries.length === 1 && entries[0]?.relative === ".";
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function measureArchiveEntrySizes(cwd: string, entries: string[]): Promise<ArchiveSizeBreakdownRow[]> {
|
|
269
|
+
return Promise.all(entries.map(async (relativePath) => ({ relativePath, bytes: (await lstat(join(cwd, relativePath))).size })));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function formatArchiveOversizeError(args: {
|
|
273
|
+
archiveBytes: number;
|
|
274
|
+
maxBytes: number;
|
|
275
|
+
entrySizes: ArchiveSizeBreakdownRow[];
|
|
276
|
+
autoPrunedPrefixes: ArchiveSizeBreakdownRow[];
|
|
277
|
+
adaptivePruneMinBytes?: number;
|
|
278
|
+
}): string {
|
|
279
|
+
const topLevel = summarizeTopLevelIncludedPaths(args.entrySizes);
|
|
280
|
+
const adaptiveCandidates = summarizeAdaptivePruneCandidates(args.entrySizes, args.adaptivePruneMinBytes).slice(0, 7);
|
|
281
|
+
return [
|
|
282
|
+
`Oracle archive exceeds provider upload limit (${formatBytes(args.maxBytes)}) after default exclusions${args.autoPrunedPrefixes.length > 0 ? " and automatic generic generated-output-dir pruning" : ""}.`,
|
|
283
|
+
`The local archive measured ${formatBytes(args.archiveBytes)} (${args.archiveBytes} bytes), so submission stopped before dispatch.`,
|
|
284
|
+
args.autoPrunedPrefixes.length > 0 ? "Automatically pruned generic generated-output paths before failing:" : undefined,
|
|
285
|
+
...args.autoPrunedPrefixes.map((entry) => `- ${formatDirectoryLabel(entry.relativePath)} — ${formatBytes(entry.bytes)}`),
|
|
286
|
+
topLevel.length > 0 ? "Approx top-level included sizes:" : undefined,
|
|
287
|
+
...topLevel.map((entry) => `- ${entry.relativePath} — ${formatBytes(entry.bytes)}`),
|
|
288
|
+
adaptiveCandidates.length > 0 ? "Largest remaining generic generated-output-dir candidates:" : undefined,
|
|
289
|
+
...adaptiveCandidates.map((entry) => `- ${formatDirectoryLabel(entry.relativePath)} — ${formatBytes(entry.bytes)}`),
|
|
290
|
+
"Recommended retry order: (1) remove the largest obviously irrelevant/generated/history/export content, (2) if it still does not fit, keep only the directly relevant subtrees plus adjacent docs/tests/config, (3) if it still does not fit, explain what was cut before asking the user.",
|
|
291
|
+
]
|
|
292
|
+
.filter(Boolean)
|
|
293
|
+
.join("\n");
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function writeOctal(value: number, width: number): Buffer {
|
|
297
|
+
const text = Math.max(0, Math.floor(value)).toString(8).slice(-(width - 1)).padStart(width - 1, "0") + "\0";
|
|
298
|
+
return Buffer.from(text, "ascii");
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function writeTarName(header: Buffer, name: string): void {
|
|
302
|
+
const normalized = name.replaceAll("\\", "/");
|
|
303
|
+
const nameBytes = Buffer.byteLength(normalized);
|
|
304
|
+
if (nameBytes <= 100) {
|
|
305
|
+
header.write(normalized, 0, 100, "utf8");
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
const parts = normalized.split("/");
|
|
309
|
+
const fileName = parts.pop() || "";
|
|
310
|
+
const prefix = parts.join("/");
|
|
311
|
+
if (Buffer.byteLength(fileName) > 100 || Buffer.byteLength(prefix) > 155) {
|
|
312
|
+
throw new Error(`archive path is too long for portable tar header: ${normalized}`);
|
|
313
|
+
}
|
|
314
|
+
header.write(fileName, 0, 100, "utf8");
|
|
315
|
+
header.write(prefix, 345, 155, "utf8");
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function buildTarHeader(name: string, options: { mode: number; size: number; mtimeMs: number; type: "file" | "directory" | "symlink"; linkName?: string }): Buffer {
|
|
319
|
+
const header = Buffer.alloc(512);
|
|
320
|
+
writeTarName(header, options.type === "directory" && !name.endsWith("/") ? `${name}/` : name);
|
|
321
|
+
writeOctal(options.mode & 0o7777, 8).copy(header, 100);
|
|
322
|
+
writeOctal(0, 8).copy(header, 108);
|
|
323
|
+
writeOctal(0, 8).copy(header, 116);
|
|
324
|
+
writeOctal(options.size, 12).copy(header, 124);
|
|
325
|
+
writeOctal(Math.floor(options.mtimeMs / 1000), 12).copy(header, 136);
|
|
326
|
+
Buffer.from(" ", "ascii").copy(header, 148);
|
|
327
|
+
header[156] = options.type === "directory" ? 53 : options.type === "symlink" ? 50 : 48;
|
|
328
|
+
if (options.linkName) header.write(options.linkName.replaceAll("\\", "/"), 157, 100, "utf8");
|
|
329
|
+
header.write("ustar", 257, 6, "ascii");
|
|
330
|
+
header.write("00", 263, 2, "ascii");
|
|
331
|
+
let checksum = 0;
|
|
332
|
+
for (const byte of header) checksum += byte;
|
|
333
|
+
const checksumText = checksum.toString(8).padStart(6, "0");
|
|
334
|
+
header.write(`${checksumText}\0 `, 148, 8, "ascii");
|
|
335
|
+
return header;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function writeChunk(stream: NodeJS.WritableStream, chunk: Buffer): Promise<void> {
|
|
339
|
+
if (!stream.write(chunk)) await once(stream, "drain");
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function formatArchiveTimeoutMessage(commandTimeoutMs?: number): string {
|
|
343
|
+
return `Oracle archive subprocess timed out after ${commandTimeoutMs ?? ARCHIVE_COMMAND_TIMEOUT_MS}ms`;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function throwIfArchiveTimeoutAborted(timeout?: AbortSignal): void {
|
|
347
|
+
if (timeout?.aborted) throw new Error(formatArchiveTimeoutMessage());
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async function writePortableTarArchiveToStream(cwd: string, entries: string[], stream: NodeJS.WritableStream, timeout?: AbortSignal): Promise<void> {
|
|
351
|
+
for (const entry of entries) {
|
|
352
|
+
throwIfArchiveTimeoutAborted(timeout);
|
|
353
|
+
const normalizedEntry = entry.replaceAll("\\", "/");
|
|
354
|
+
const absolutePath = join(cwd, normalizedEntry);
|
|
355
|
+
const info = await lstat(absolutePath);
|
|
356
|
+
if (info.isSymbolicLink()) {
|
|
357
|
+
await writeChunk(stream, buildTarHeader(normalizedEntry, { mode: info.mode, size: 0, mtimeMs: info.mtimeMs, type: "symlink", linkName: await readlink(absolutePath) }));
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
if (info.isDirectory()) {
|
|
361
|
+
await writeChunk(stream, buildTarHeader(normalizedEntry, { mode: info.mode, size: 0, mtimeMs: info.mtimeMs, type: "directory" }));
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
if (!info.isFile()) continue;
|
|
365
|
+
await writeChunk(stream, buildTarHeader(normalizedEntry, { mode: info.mode, size: info.size, mtimeMs: info.mtimeMs, type: "file" }));
|
|
366
|
+
for await (const chunk of createReadStream(absolutePath)) {
|
|
367
|
+
throwIfArchiveTimeoutAborted(timeout);
|
|
368
|
+
await writeChunk(stream, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
369
|
+
}
|
|
370
|
+
const padding = info.size % 512 === 0 ? 0 : 512 - (info.size % 512);
|
|
371
|
+
if (padding > 0) await writeChunk(stream, Buffer.alloc(padding));
|
|
372
|
+
}
|
|
373
|
+
throwIfArchiveTimeoutAborted(timeout);
|
|
374
|
+
await writeChunk(stream, Buffer.alloc(1024));
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async function writeWindowsTarArchiveToZstd(cwd: string, entries: string[], archivePath: string, timeout: AbortSignal): Promise<void> {
|
|
378
|
+
const scrubbedEnv = sweetCookieSafeStoragePasswordScrubbedEnv(process.env);
|
|
379
|
+
const zstd = spawn("zstd", ["-19", "-T0", "-f", "-o", archivePath], {
|
|
380
|
+
cwd,
|
|
381
|
+
env: scrubbedEnv,
|
|
382
|
+
stdio: ["pipe", "ignore", "pipe"],
|
|
383
|
+
signal: timeout,
|
|
384
|
+
});
|
|
385
|
+
let stderr = "";
|
|
386
|
+
zstd.stderr?.on("data", (chunk) => {
|
|
387
|
+
stderr += String(chunk);
|
|
388
|
+
});
|
|
389
|
+
try {
|
|
390
|
+
await writePortableTarArchiveToStream(cwd, entries, zstd.stdin, timeout);
|
|
391
|
+
zstd.stdin.end();
|
|
392
|
+
} catch (error) {
|
|
393
|
+
zstd.stdin.destroy();
|
|
394
|
+
zstd.kill();
|
|
395
|
+
throw error;
|
|
396
|
+
}
|
|
397
|
+
const code = await new Promise<number | null>((resolve, reject) => {
|
|
398
|
+
zstd.once("error", reject);
|
|
399
|
+
zstd.once("close", resolve);
|
|
400
|
+
});
|
|
401
|
+
if (code !== 0) throw new Error(`zstd archive compression failed with status ${code}: ${stderr.trim()}`);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async function writeWindowsTarArchiveToGzip(cwd: string, entries: string[], archivePath: string, timeout: AbortSignal): Promise<void> {
|
|
405
|
+
const gzip = createGzip({ level: 9 });
|
|
406
|
+
const output = createWriteStream(archivePath, { mode: 0o600 });
|
|
407
|
+
const abort = () => gzip.destroy(new Error(formatArchiveTimeoutMessage()));
|
|
408
|
+
timeout.addEventListener("abort", abort, { once: true });
|
|
409
|
+
const completion = pipeline(gzip, output);
|
|
410
|
+
try {
|
|
411
|
+
await writePortableTarArchiveToStream(cwd, entries, gzip, timeout);
|
|
412
|
+
gzip.end();
|
|
413
|
+
await completion;
|
|
414
|
+
} catch (error) {
|
|
415
|
+
gzip.destroy();
|
|
416
|
+
output.destroy();
|
|
417
|
+
await completion.catch(() => undefined);
|
|
418
|
+
throw error;
|
|
419
|
+
} finally {
|
|
420
|
+
timeout.removeEventListener("abort", abort);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
type ArchiveCompressionTarget = {
|
|
425
|
+
name: string;
|
|
426
|
+
input: NodeJS.WritableStream;
|
|
427
|
+
done: Promise<number | null | undefined>;
|
|
428
|
+
pipe?: NodeJS.ReadableStream;
|
|
429
|
+
terminate: () => void;
|
|
430
|
+
kill: () => void;
|
|
431
|
+
unpipe: (tarStdout: NodeJS.ReadableStream) => void;
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
function createGzipCompressionTarget(archivePath: string): ArchiveCompressionTarget {
|
|
435
|
+
const gzip = createGzip({ level: 9 });
|
|
436
|
+
const output = createWriteStream(archivePath, { mode: 0o600 });
|
|
437
|
+
return {
|
|
438
|
+
name: "gzip",
|
|
439
|
+
input: gzip,
|
|
440
|
+
done: pipeline(gzip, output).then(() => undefined),
|
|
441
|
+
terminate: () => {
|
|
442
|
+
gzip.destroy();
|
|
443
|
+
output.destroy();
|
|
444
|
+
},
|
|
445
|
+
kill: () => {
|
|
446
|
+
gzip.destroy();
|
|
447
|
+
output.destroy();
|
|
448
|
+
},
|
|
449
|
+
unpipe: (tarStdout) => tarStdout.unpipe(gzip),
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function createZstdCompressionTarget(archivePath: string, env: NodeJS.ProcessEnv): ArchiveCompressionTarget {
|
|
454
|
+
const zstd = spawn(process.env.PI_ORACLE_TEST_ZSTD_BIN ?? "zstd", ["-19", "-T0", "-f", "-o", archivePath], {
|
|
455
|
+
env,
|
|
456
|
+
stdio: ["pipe", "ignore", "pipe"],
|
|
457
|
+
});
|
|
458
|
+
return {
|
|
459
|
+
name: "zstd",
|
|
460
|
+
input: zstd.stdin,
|
|
461
|
+
pipe: zstd.stderr,
|
|
462
|
+
done: new Promise<number | null>((resolve, reject) => {
|
|
463
|
+
zstd.once("error", reject);
|
|
464
|
+
zstd.once("close", resolve);
|
|
465
|
+
}),
|
|
466
|
+
terminate: () => zstd.kill("SIGTERM"),
|
|
467
|
+
kill: () => zstd.kill("SIGKILL"),
|
|
468
|
+
unpipe: (tarStdout) => tarStdout.unpipe(zstd.stdin),
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async function writeNonWindowsTarArchiveFile(
|
|
473
|
+
cwd: string,
|
|
474
|
+
archivePath: string,
|
|
475
|
+
listPath: string,
|
|
476
|
+
createCompressionTarget: (env: NodeJS.ProcessEnv) => ArchiveCompressionTarget,
|
|
477
|
+
options?: { commandTimeoutMs?: number },
|
|
478
|
+
): Promise<number> {
|
|
479
|
+
await new Promise<void>((resolvePromise, rejectPromise) => {
|
|
480
|
+
const scrubbedEnv = sweetCookieSafeStoragePasswordScrubbedEnv();
|
|
481
|
+
const tarArgs = ["--null", "-cf", "-", "-C", cwd, "-T", basename(listPath)];
|
|
482
|
+
const tar = spawn(process.env.PI_ORACLE_TEST_TAR_BIN ?? "tar", tarArgs, {
|
|
483
|
+
cwd: dirname(listPath),
|
|
484
|
+
env: scrubbedEnv,
|
|
485
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
486
|
+
});
|
|
487
|
+
const target = createCompressionTarget(scrubbedEnv);
|
|
488
|
+
|
|
489
|
+
let stderr = "";
|
|
490
|
+
let settled = false;
|
|
491
|
+
let timedOut = false;
|
|
492
|
+
let targetDone = false;
|
|
493
|
+
let targetCode: number | null | undefined;
|
|
494
|
+
let targetError: Error | undefined;
|
|
495
|
+
let timeout: NodeJS.Timeout | undefined;
|
|
496
|
+
let killGraceTimer: NodeJS.Timeout | undefined;
|
|
497
|
+
let tarCode: number | null | undefined;
|
|
498
|
+
|
|
499
|
+
const commandTimeoutMs = options?.commandTimeoutMs ?? ARCHIVE_COMMAND_TIMEOUT_MS;
|
|
500
|
+
const clearTimers = () => {
|
|
501
|
+
if (timeout) clearTimeout(timeout);
|
|
502
|
+
if (killGraceTimer) clearTimeout(killGraceTimer);
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
const terminateChildren = () => {
|
|
506
|
+
tar.kill("SIGTERM");
|
|
507
|
+
target.terminate();
|
|
508
|
+
killGraceTimer = setTimeout(() => {
|
|
509
|
+
tar.kill("SIGKILL");
|
|
510
|
+
target.kill();
|
|
511
|
+
}, ARCHIVE_COMMAND_KILL_GRACE_MS);
|
|
512
|
+
killGraceTimer.unref?.();
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
const rejectOnce = (error: Error) => {
|
|
516
|
+
if (settled) return;
|
|
517
|
+
settled = true;
|
|
518
|
+
clearTimers();
|
|
519
|
+
terminateChildren();
|
|
520
|
+
rejectPromise(error);
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
const finish = () => {
|
|
524
|
+
if (settled || tarCode === undefined || !targetDone) return;
|
|
525
|
+
settled = true;
|
|
526
|
+
clearTimers();
|
|
527
|
+
if (timedOut) {
|
|
528
|
+
rejectPromise(new Error(stderr || formatArchiveTimeoutMessage(commandTimeoutMs)));
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
if (targetError) {
|
|
532
|
+
rejectPromise(targetError);
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
if (tarCode === 0 && (targetCode === undefined || targetCode === 0)) {
|
|
536
|
+
resolvePromise();
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
const compressionStatus = targetCode === undefined ? "" : `, ${target.name}=${targetCode}`;
|
|
540
|
+
rejectPromise(new Error(stderr || `archive command failed (tar=${tarCode}${compressionStatus})`));
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
const handlePipeError = (error: unknown) => {
|
|
544
|
+
const normalized = error instanceof Error ? error : new Error(String(error));
|
|
545
|
+
if (ARCHIVE_PIPE_FAILURE_ERROR_CODES.has(getErrorCode(normalized) ?? "")) {
|
|
546
|
+
stderr = `${stderr}${stderr ? "\n" : ""}${normalized.message}`;
|
|
547
|
+
target.unpipe(tar.stdout);
|
|
548
|
+
terminateChildren();
|
|
549
|
+
finish();
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
rejectOnce(normalized);
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
if (commandTimeoutMs > 0) {
|
|
556
|
+
timeout = setTimeout(() => {
|
|
557
|
+
timedOut = true;
|
|
558
|
+
stderr = `${stderr}${stderr ? "\n" : ""}${formatArchiveTimeoutMessage(commandTimeoutMs)}`;
|
|
559
|
+
terminateChildren();
|
|
560
|
+
}, commandTimeoutMs);
|
|
561
|
+
timeout.unref?.();
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
tar.stderr.on("data", (data) => {
|
|
565
|
+
stderr += String(data);
|
|
566
|
+
});
|
|
567
|
+
target.pipe?.on("data", (data) => {
|
|
568
|
+
stderr += String(data);
|
|
569
|
+
});
|
|
570
|
+
tar.on("error", (error) => rejectOnce(error instanceof Error ? error : new Error(String(error))));
|
|
571
|
+
tar.stdout.on("error", handlePipeError);
|
|
572
|
+
target.input.on("error", handlePipeError);
|
|
573
|
+
tar.on("close", (code) => {
|
|
574
|
+
tarCode = code;
|
|
575
|
+
finish();
|
|
576
|
+
});
|
|
577
|
+
target.done.then(
|
|
578
|
+
(code) => {
|
|
579
|
+
targetCode = code;
|
|
580
|
+
targetDone = true;
|
|
581
|
+
finish();
|
|
582
|
+
},
|
|
583
|
+
(error) => {
|
|
584
|
+
targetError = error instanceof Error ? error : new Error(String(error));
|
|
585
|
+
targetDone = true;
|
|
586
|
+
finish();
|
|
587
|
+
},
|
|
588
|
+
);
|
|
589
|
+
tar.stdout.pipe(target.input);
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
return (await stat(archivePath)).size;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
async function writeTarGzipArchiveFile(
|
|
596
|
+
cwd: string,
|
|
597
|
+
entries: string[],
|
|
598
|
+
archivePath: string,
|
|
599
|
+
listPath: string,
|
|
600
|
+
options?: { commandTimeoutMs?: number },
|
|
601
|
+
): Promise<number> {
|
|
602
|
+
if (process.platform === "win32") {
|
|
603
|
+
const timeoutController = new AbortController();
|
|
604
|
+
const timeout = setTimeout(() => timeoutController.abort(), options?.commandTimeoutMs ?? ARCHIVE_COMMAND_TIMEOUT_MS);
|
|
605
|
+
try {
|
|
606
|
+
await writeWindowsTarArchiveToGzip(cwd, entries, archivePath, timeoutController.signal);
|
|
607
|
+
return (await stat(archivePath)).size;
|
|
608
|
+
} catch (error) {
|
|
609
|
+
if (timeoutController.signal.aborted) {
|
|
610
|
+
throw new Error(formatArchiveTimeoutMessage(options?.commandTimeoutMs));
|
|
611
|
+
}
|
|
612
|
+
throw error;
|
|
613
|
+
} finally {
|
|
614
|
+
clearTimeout(timeout);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
return writeNonWindowsTarArchiveFile(cwd, archivePath, listPath, () => createGzipCompressionTarget(archivePath), options);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
async function writeZstdArchiveFile(
|
|
622
|
+
cwd: string,
|
|
623
|
+
entries: string[],
|
|
624
|
+
archivePath: string,
|
|
625
|
+
listPath: string,
|
|
626
|
+
options?: { commandTimeoutMs?: number },
|
|
627
|
+
): Promise<number> {
|
|
628
|
+
if (process.platform === "win32") {
|
|
629
|
+
const timeoutController = new AbortController();
|
|
630
|
+
const timeout = setTimeout(() => timeoutController.abort(), options?.commandTimeoutMs ?? ARCHIVE_COMMAND_TIMEOUT_MS);
|
|
631
|
+
try {
|
|
632
|
+
await writeWindowsTarArchiveToZstd(cwd, entries, archivePath, timeoutController.signal);
|
|
633
|
+
return (await stat(archivePath)).size;
|
|
634
|
+
} catch (error) {
|
|
635
|
+
if (timeoutController.signal.aborted) {
|
|
636
|
+
throw new Error(formatArchiveTimeoutMessage(options?.commandTimeoutMs));
|
|
637
|
+
}
|
|
638
|
+
throw error;
|
|
639
|
+
} finally {
|
|
640
|
+
clearTimeout(timeout);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
return writeNonWindowsTarArchiveFile(cwd, archivePath, listPath, (env) => createZstdCompressionTarget(archivePath, env), options);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
async function writeArchiveFile(
|
|
648
|
+
cwd: string,
|
|
649
|
+
entries: string[],
|
|
650
|
+
archivePath: string,
|
|
651
|
+
listPath: string,
|
|
652
|
+
options?: { commandTimeoutMs?: number; archiveFormat?: OracleArchiveFormat },
|
|
653
|
+
): Promise<number> {
|
|
654
|
+
await writeFile(listPath, Buffer.from(`${entries.join("\0")}\0`), { mode: 0o600 });
|
|
655
|
+
await rm(archivePath, { force: true }).catch(() => undefined);
|
|
656
|
+
|
|
657
|
+
const archiveFormat = options?.archiveFormat ?? DEFAULT_ARCHIVE_FORMAT;
|
|
658
|
+
return archiveFormat === "tar.gz"
|
|
659
|
+
? writeTarGzipArchiveFile(cwd, entries, archivePath, listPath, options)
|
|
660
|
+
: writeZstdArchiveFile(cwd, entries, archivePath, listPath, options);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
export async function createArchiveForTesting(
|
|
664
|
+
cwd: string,
|
|
665
|
+
files: string[],
|
|
666
|
+
archivePath: string,
|
|
667
|
+
options?: { maxBytes?: number; adaptivePruneMinBytes?: number; commandTimeoutMs?: number; archiveFormat?: OracleArchiveFormat },
|
|
668
|
+
): Promise<ArchiveCreationResult> {
|
|
669
|
+
const archiveInputs = resolveArchiveInputs(cwd, files);
|
|
670
|
+
const wholeRepoSelection = isWholeRepoArchiveSelection(archiveInputs);
|
|
671
|
+
let expandedEntries = await resolveExpandedArchiveEntriesFromInputs(cwd, archiveInputs);
|
|
672
|
+
if (expandedEntries.length === 0) {
|
|
673
|
+
throw new Error("Oracle archive inputs are empty after default exclusions");
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const listDir = await mkdtemp(join(tmpdir(), "oracle-filelist-"));
|
|
677
|
+
const listPath = join(listDir, "files.list");
|
|
678
|
+
const maxBytes = options?.maxBytes ?? DEFAULT_MAX_ARCHIVE_BYTES;
|
|
679
|
+
const adaptivePruneMinBytes = options?.adaptivePruneMinBytes ?? 0;
|
|
680
|
+
const autoPrunedPrefixes: ArchiveSizeBreakdownRow[] = [];
|
|
681
|
+
let initialArchiveBytes: number | undefined;
|
|
682
|
+
|
|
683
|
+
try {
|
|
684
|
+
while (true) {
|
|
685
|
+
if (expandedEntries.length === 0) {
|
|
686
|
+
throw new Error("Oracle archive inputs are empty after default exclusions and automatic size pruning");
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const archiveBytes = await writeArchiveFile(cwd, expandedEntries, archivePath, listPath, { commandTimeoutMs: options?.commandTimeoutMs, archiveFormat: options?.archiveFormat });
|
|
690
|
+
if (archiveBytes <= maxBytes) {
|
|
691
|
+
return {
|
|
692
|
+
sha256: await sha256File(archivePath),
|
|
693
|
+
archiveBytes,
|
|
694
|
+
initialArchiveBytes,
|
|
695
|
+
autoPrunedPrefixes,
|
|
696
|
+
includedEntries: [...expandedEntries],
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
if (initialArchiveBytes === undefined) initialArchiveBytes = archiveBytes;
|
|
701
|
+
const entrySizes = await measureArchiveEntrySizes(cwd, expandedEntries);
|
|
702
|
+
if (!wholeRepoSelection) {
|
|
703
|
+
throw new Error(formatArchiveOversizeError({ archiveBytes, maxBytes, entrySizes, autoPrunedPrefixes, adaptivePruneMinBytes }));
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
const nextCandidate = summarizeAdaptivePruneCandidates(entrySizes, adaptivePruneMinBytes).find(
|
|
707
|
+
(entry) => !autoPrunedPrefixes.some((pruned) => pruned.relativePath === entry.relativePath),
|
|
708
|
+
);
|
|
709
|
+
if (!nextCandidate) {
|
|
710
|
+
throw new Error(formatArchiveOversizeError({ archiveBytes, maxBytes, entrySizes, autoPrunedPrefixes, adaptivePruneMinBytes }));
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
autoPrunedPrefixes.push(nextCandidate);
|
|
714
|
+
expandedEntries = pruneEntriesByPrefix(expandedEntries, nextCandidate.relativePath);
|
|
715
|
+
}
|
|
716
|
+
} finally {
|
|
717
|
+
await rm(listDir, { recursive: true, force: true }).catch(() => undefined);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
export async function createArchive(cwd: string, files: string[], archivePath: string, maxBytes = DEFAULT_MAX_ARCHIVE_BYTES, archiveFormat: OracleArchiveFormat = DEFAULT_ARCHIVE_FORMAT): Promise<ArchiveCreationResult> {
|
|
722
|
+
return createArchiveForTesting(cwd, files, archivePath, { maxBytes, archiveFormat });
|
|
723
|
+
}
|