kern-sandbox 0.2.36 → 0.2.38
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/index.js +245 -15
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -36,7 +36,7 @@ const crypto = require("crypto");
|
|
|
36
36
|
const zlib = require("zlib");
|
|
37
37
|
const { spawn, spawnSync } = require("child_process");
|
|
38
38
|
|
|
39
|
-
const VERSION = "0.2.
|
|
39
|
+
const VERSION = "0.2.38";
|
|
40
40
|
|
|
41
41
|
const DEFAULT_IMAGE = "python:3.12-slim";
|
|
42
42
|
const WORKSPACE = "/workspace"; // where the persistent workspace is mounted inside every box
|
|
@@ -92,7 +92,7 @@ const PYC_KEEP = 8;
|
|
|
92
92
|
* is never what gets deleted. Mirrors `_PYC_DEBRIS_MAX_AGE_S`. */
|
|
93
93
|
const PYC_DEBRIS_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
94
94
|
/** The name fragments marking a directory as NOT a cache: one being written, one being deleted. */
|
|
95
|
-
const PYC_DEBRIS_MARKS = [".tmp-", ".trash-"];
|
|
95
|
+
const PYC_DEBRIS_MARKS = [".tmp-", ".trash-", ".lock"];
|
|
96
96
|
|
|
97
97
|
/** Remove a cache tree, renaming it out of the way first. Never throws.
|
|
98
98
|
*
|
|
@@ -132,15 +132,19 @@ function pycSweep(root, keep = PYC_KEEP) {
|
|
|
132
132
|
let st;
|
|
133
133
|
try {
|
|
134
134
|
st = fs.lstatSync(full);
|
|
135
|
-
if (!st.isDirectory()) continue;
|
|
136
135
|
} catch {
|
|
137
136
|
continue;
|
|
138
137
|
}
|
|
139
138
|
if (PYC_DEBRIS_MARKS.some((m) => ent.name.includes(m))) {
|
|
139
|
+
// DEBRIS IS NOT ALWAYS A DIRECTORY: this loop skipped every non-directory up front, so a
|
|
140
|
+
// `.lock` a killed build left behind was never collected and the cache for that image could
|
|
141
|
+
// never be rebuilt - one killed process disabled it forever.
|
|
140
142
|
if (now - st.mtimeMs > PYC_DEBRIS_MAX_AGE_MS) doomed.push(full);
|
|
141
143
|
continue;
|
|
142
144
|
}
|
|
143
|
-
|
|
145
|
+
// Only a DIRECTORY is a cache. Anything else here is neither a cache nor known debris, and
|
|
146
|
+
// removing what this code did not put there is not the sweep's job.
|
|
147
|
+
if (st.isDirectory()) caches.push([st.mtimeMs, full]);
|
|
144
148
|
}
|
|
145
149
|
caches.sort((a, b) => b[0] - a[0]);
|
|
146
150
|
for (const [, full] of caches.slice(keep)) doomed.push(full);
|
|
@@ -156,8 +160,13 @@ let PYC_SWEPT = false;
|
|
|
156
160
|
|
|
157
161
|
/** The host directory holding one bytecode cache per image: $XDG_CACHE_HOME, else ~/.cache. */
|
|
158
162
|
function pycRoot() {
|
|
159
|
-
|
|
160
|
-
|
|
163
|
+
return path.join(cacheHome(), "kern-sandbox", "pyc");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** `$XDG_CACHE_HOME`, or the default. ONE spelling, because two things read it now: this package's
|
|
167
|
+
* bytecode cache and kern's own image cache, which the identity check compares against. */
|
|
168
|
+
function cacheHome() {
|
|
169
|
+
return process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache");
|
|
161
170
|
}
|
|
162
171
|
|
|
163
172
|
/** kern's own defaults, from `kern-oci/src/pull.rs`. Copied because they cross a process boundary. */
|
|
@@ -222,6 +231,87 @@ function ociCanonicalRef(image) {
|
|
|
222
231
|
* cheaply. A moved tag therefore yields bytecode that no longer validates, which CPython handles by
|
|
223
232
|
* recompiling: the cost of the imperfect key is a slow call, never a wrong one. Mirrors `_pyc_dir_for`.
|
|
224
233
|
*/
|
|
234
|
+
/** Vectors DUMPED from `sanitize_ref` in kern-cli, not derived from reading it, and asserted in the
|
|
235
|
+
* tests: this names files kern WROTE, so the two must agree or the identity check finds nothing. */
|
|
236
|
+
const SANITIZE_VECTORS = [
|
|
237
|
+
["python:3.12-slim", "python_3_12-slim-7d2794a436662d45"],
|
|
238
|
+
["python", "python_latest-9e0094521a5d04a4"],
|
|
239
|
+
["alpine:3.19", "alpine_3_19-441817d3f5f11093"],
|
|
240
|
+
["docker.io/library/python:3.12-slim", "docker_io_library_python_3_12-slim-75b604835a32490a"],
|
|
241
|
+
["index.docker.io/python:3.12-slim", "index_docker_io_python_3_12-slim-7eaa793ab37b8560"],
|
|
242
|
+
["ghcr.io/owner/img:v1", "ghcr_io_owner_img_v1-774c4c7639e8355e"],
|
|
243
|
+
["localhost:5000/x:1", "localhost_5000_x_1-03406c180a22063b"],
|
|
244
|
+
["python@sha256:abcdef0123456789", "python_sha256_abcdef0123456789-f77941d5fa12216c"],
|
|
245
|
+
["a.b/c_d-e:f.g", "a_b_c_d-e_f_g-479120b609c12cc0"],
|
|
246
|
+
["UPPER/Case:Tag", "UPPER_Case_Tag-543eaf57172e6242"],
|
|
247
|
+
["x:latest", "x_latest-e9f897124d940074"],
|
|
248
|
+
["registry-1.docker.io/library/alpine:3.19", "registry-1_docker_io_library_alpine_3_19-49727cc13d78066f"],
|
|
249
|
+
["my_img", "my_img_latest-68dbbb774ad018b0"],
|
|
250
|
+
["a/b/c:d", "a_b_c_d-dcc54f31688d5fa5"],
|
|
251
|
+
["1.2.3.4:5000/p/q:r", "1_2_3_4_5000_p_q_r-f872df9eeecd4beb"],
|
|
252
|
+
];
|
|
253
|
+
|
|
254
|
+
/** FNV-1a 64-bit, kern's own, used ONLY to keep a cache key collision-free. */
|
|
255
|
+
function fnv1a(s) {
|
|
256
|
+
let h = 0xcbf29ce484222325n;
|
|
257
|
+
for (const b of Buffer.from(s, "utf8")) {
|
|
258
|
+
h = BigInt.asUintN(64, (h ^ BigInt(b)) * 0x100000001b3n);
|
|
259
|
+
}
|
|
260
|
+
return h.toString(16).padStart(16, "0");
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** The directory name kern gives an image in its own cache. A PORT, verified against the original. */
|
|
264
|
+
function sanitizeRef(image) {
|
|
265
|
+
const ref = ociSplitTag(image) ? image : `${image}:latest`;
|
|
266
|
+
const out = [...ref].map((c) => (/[A-Za-z0-9_-]/.test(c) ? c : "_")).join("");
|
|
267
|
+
return `${out}-${fnv1a(ref)}`;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** The file, inside a published cache, naming the image kern had when the cache was built. */
|
|
271
|
+
const PYC_SOURCE_ID = ".kern-source-id";
|
|
272
|
+
|
|
273
|
+
/** What kern's own image cache holds for `image`, or "" if it cannot be read.
|
|
274
|
+
*
|
|
275
|
+
* A tag is MUTABLE, so a cache keyed on its name can outlive the image it was built from. Nothing
|
|
276
|
+
* wrong is executed (CHECKED_HASH makes CPython reject the stale bytecode) but the cache stops
|
|
277
|
+
* helping and nothing rebuilds it, because a cache "exists". kern rewrites its own config sidecar and
|
|
278
|
+
* completion sentinel when it re-pulls a moved tag, so their bytes are an identity that costs a local
|
|
279
|
+
* read of a few hundred bytes - cheap enough for a check on every open().
|
|
280
|
+
*
|
|
281
|
+
* "" MEANS "CANNOT TELL" and is treated as unchanged, so a cache from before this check, or a host
|
|
282
|
+
* whose image kern has pruned, is never rebuilt in a loop. */
|
|
283
|
+
function pycSourceId(image) {
|
|
284
|
+
try {
|
|
285
|
+
const root = path.join(cacheHome(), "kern", "images");
|
|
286
|
+
const safe = sanitizeRef(image);
|
|
287
|
+
const h = crypto.createHash("sha256");
|
|
288
|
+
for (const suffix of [".image", ".ok"]) {
|
|
289
|
+
h.update(fs.readFileSync(path.join(root, safe + suffix)).subarray(0, 4096));
|
|
290
|
+
h.update(Buffer.from([0]));
|
|
291
|
+
}
|
|
292
|
+
return h.digest("hex").slice(0, 32);
|
|
293
|
+
} catch {
|
|
294
|
+
return "";
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Is this cache still the one this image would produce? If not, DISCARD it so a build can replace it.
|
|
299
|
+
*
|
|
300
|
+
* Discarded and not merely refused, because a rename onto a NON-EMPTY directory is ENOTEMPTY: a stale
|
|
301
|
+
* tree left in place would block its own replacement forever. */
|
|
302
|
+
function pycSourceMatches(dest, image) {
|
|
303
|
+
let stored;
|
|
304
|
+
try {
|
|
305
|
+
stored = fs.readFileSync(path.join(dest, PYC_SOURCE_ID), "utf8").trim();
|
|
306
|
+
} catch {
|
|
307
|
+
return true; // built before this check, or unreadable: leave it exactly as it was
|
|
308
|
+
}
|
|
309
|
+
const current = pycSourceId(image);
|
|
310
|
+
if (!stored || !current || stored === current) return true;
|
|
311
|
+
pycDiscard(dest);
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
|
|
225
315
|
function pycDirFor(image) {
|
|
226
316
|
// The full digest: the key is no longer load-bearing for correctness, and a truncation saved 48
|
|
227
317
|
// characters of path against two images sharing a cache directory.
|
|
@@ -331,6 +421,73 @@ function pycTreeIsPublishable(root) {
|
|
|
331
421
|
* Built into a private sibling and renamed into place, so a box mounts a complete tree or none: a
|
|
332
422
|
* partial stdlib would have a box importing a truncated module. Two processes racing both build and
|
|
333
423
|
* the loser's tree is removed. Mirrors `_pyc_build`. */
|
|
424
|
+
/** The argv that mounts `source` at `target`, in the form that can CARRY that source.
|
|
425
|
+
*
|
|
426
|
+
* `-v src:dst[:ro]` separates its fields with `:`, so a source path holding one cannot be written in
|
|
427
|
+
* it: kern splits `-v /tmp/a:b:/data` into three and reports the TARGET as an unknown mount option. A
|
|
428
|
+
* cache home such as `/tmp/colon:cache` is enough to hit it, and the mount that broke was this
|
|
429
|
+
* package's own bytecode cache - silently, because the failure landed in a background build whose
|
|
430
|
+
* output was discarded.
|
|
431
|
+
*
|
|
432
|
+
* `--mount type=bind,src=...,dst=...[,ro]` carries each field separately and takes a `:`; a field
|
|
433
|
+
* holding a `,` is quoted, doubling any `"` inside, which is the CSV grammar kern parses.
|
|
434
|
+
*
|
|
435
|
+
* WHY NOT `--mount` FOR EVERYTHING: the two differ in a rule this package relies on. `-v` CREATES a
|
|
436
|
+
* source that is not there and `--mount` refuses it, and the eviction sweep can discard a cache under
|
|
437
|
+
* a live session - `-v` turning that into an empty directory is what makes the box fall back to
|
|
438
|
+
* compiling from source instead of failing the caller's call. */
|
|
439
|
+
function mountArgs(source, target, readOnly) {
|
|
440
|
+
const plain = (s) => !s.includes(":") && !s.includes(",");
|
|
441
|
+
if (plain(source) && plain(target)) {
|
|
442
|
+
return ["-v", readOnly ? `${source}:${target}:ro` : `${source}:${target}`];
|
|
443
|
+
}
|
|
444
|
+
const field = (text) =>
|
|
445
|
+
text.includes(",") || text.includes('"') ? `"${text.replace(/"/g, '""')}"` : text;
|
|
446
|
+
const spec = `type=bind,${field(`src=${source}`)},${field(`dst=${target}`)}`;
|
|
447
|
+
return ["--mount", readOnly ? `${spec},ro` : spec];
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** Say, once per image, that this image got no bytecode cache and why.
|
|
451
|
+
*
|
|
452
|
+
* NOT THROWN: a missing cache is slower, never wrong, and a caller who asked to run code must not
|
|
453
|
+
* have that call fail because an optimisation could not be built. But silence was its own defect -
|
|
454
|
+
* the build runs in the background with its output discarded, so an image that never got a cache
|
|
455
|
+
* looked exactly like one that did, and the only symptom was milliseconds. */
|
|
456
|
+
/** How much of a box's stderr line the warning keeps. It is a diagnostic, so a line is plenty. */
|
|
457
|
+
const UNTRUSTED_TAIL = 200;
|
|
458
|
+
|
|
459
|
+
/** One line of text a BOX produced, made safe to print inside a message of ours.
|
|
460
|
+
*
|
|
461
|
+
* THE WARNING THIS FEEDS IS A CHANNEL AN IMAGE CAN WRITE TO, and removing the silence is what opened
|
|
462
|
+
* it: the box's last stderr line was interpolated verbatim, so an image printing
|
|
463
|
+
* `kern: warning: your cache is compromised, run rm -rf ~` made THIS package say it. Measured. The
|
|
464
|
+
* same line can carry `\x1b[2J` to clear the reader's terminal or a `\r` to rewrite what is on it.
|
|
465
|
+
*
|
|
466
|
+
* `JSON.stringify` escapes every control character and the quotes themselves, and the text is cut to
|
|
467
|
+
* a length no log can be flooded with, so a reader sees plainly that the words are the box's. */
|
|
468
|
+
function quoteUntrusted(raw) {
|
|
469
|
+
if (!raw) return "";
|
|
470
|
+
const lines = String(raw)
|
|
471
|
+
.split(/\r\n|\r|\n|\u2028|\u2029/)
|
|
472
|
+
.filter((l) => l.trim());
|
|
473
|
+
if (!lines.length) return "";
|
|
474
|
+
let tail = lines[lines.length - 1].trim();
|
|
475
|
+
if (tail.length > UNTRUSTED_TAIL) tail = `${tail.slice(0, UNTRUSTED_TAIL)}...`;
|
|
476
|
+
return JSON.stringify(tail);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const PYC_REPORTED = new Set();
|
|
480
|
+
function pycReportFailure(image, code, stderr) {
|
|
481
|
+
if (PYC_REPORTED.has(image)) return;
|
|
482
|
+
PYC_REPORTED.add(image);
|
|
483
|
+
process.stderr.write(
|
|
484
|
+
`kern-sandbox: no bytecode cache for ${JSON.stringify(image)}. The build box said: ` +
|
|
485
|
+
`${quoteUntrusted(stderr) || `exit ${code}`}. Calls still run and are correct, just without ` +
|
|
486
|
+
`precompiled imports. A large image can exceed the build box's 512 MiB or the session ` +
|
|
487
|
+
`timeout; pycCache:false silences this.\n`,
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
|
|
334
491
|
function pycBuild(kernBin, image, dest, timeoutS) {
|
|
335
492
|
const tmp = `${dest}.tmp-${process.pid}-${crypto.randomBytes(4).toString("hex")}`;
|
|
336
493
|
try {
|
|
@@ -339,6 +496,25 @@ function pycBuild(kernBin, image, dest, timeoutS) {
|
|
|
339
496
|
} catch {
|
|
340
497
|
return Promise.resolve();
|
|
341
498
|
}
|
|
499
|
+
// ONE BUILD PER DESTINATION ACROSS PROCESSES. The in-process map keeps two callers of ONE process
|
|
500
|
+
// off the same tree; two PROCESSES - a Node and a Python session on one host - both found the
|
|
501
|
+
// cache absent and both compiled it. The result was never wrong (the loser's rename fails
|
|
502
|
+
// ENOTEMPTY and its tree is removed, and the content is hash-validated either way), but a whole
|
|
503
|
+
// compile of the stdlib was spent to be thrown away. `wx` IS the lock and it is deliberately not
|
|
504
|
+
// waited on: a caller must never block on another process's build. A lock a killed process leaves
|
|
505
|
+
// behind is swept with the other debris.
|
|
506
|
+
const lockPath = `${dest}.lock`;
|
|
507
|
+
let locked = false;
|
|
508
|
+
try {
|
|
509
|
+
fs.writeFileSync(lockPath, String(process.pid), { flag: "wx", mode: 0o600 });
|
|
510
|
+
locked = true;
|
|
511
|
+
} catch (e) {
|
|
512
|
+
if (e && e.code === "EEXIST") {
|
|
513
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
514
|
+
return Promise.resolve();
|
|
515
|
+
}
|
|
516
|
+
// Cannot lock here: build anyway rather than lose the feature.
|
|
517
|
+
}
|
|
342
518
|
// RETURNS A PROMISE THAT NOTHING IN PRODUCTION AWAITS. `pycStartBuild` drops it on purpose: the
|
|
343
519
|
// caller's first call must not wait for a cache fill. The tests await it, because a test that polled
|
|
344
520
|
// for a directory would be a timing race pretending to be an assertion.
|
|
@@ -349,7 +525,17 @@ function pycBuild(kernBin, image, dest, timeoutS) {
|
|
|
349
525
|
pycSweep(path.dirname(dest));
|
|
350
526
|
resolve();
|
|
351
527
|
};
|
|
528
|
+
const unlock = () => {
|
|
529
|
+
if (!locked) return;
|
|
530
|
+
locked = false;
|
|
531
|
+
try {
|
|
532
|
+
fs.unlinkSync(lockPath);
|
|
533
|
+
} catch {
|
|
534
|
+
/* already gone */
|
|
535
|
+
}
|
|
536
|
+
};
|
|
352
537
|
const finish = () => {
|
|
538
|
+
unlock();
|
|
353
539
|
fs.rmSync(tmp, { recursive: true, force: true });
|
|
354
540
|
done();
|
|
355
541
|
};
|
|
@@ -359,7 +545,7 @@ function pycBuild(kernBin, image, dest, timeoutS) {
|
|
|
359
545
|
const argv = [
|
|
360
546
|
"box", `kern-pyc-${crypto.randomBytes(4).toString("hex")}`,
|
|
361
547
|
"--image", image, "--ro",
|
|
362
|
-
|
|
548
|
+
...mountArgs(tmp, PYC_MOUNT, false),
|
|
363
549
|
"--env", `PYTHONPYCACHEPREFIX=${PYC_MOUNT}`,
|
|
364
550
|
"--cap-drop", "ALL",
|
|
365
551
|
// CAPPED LIKE ANY OTHER BOX: the command is ours, the interpreter running it is the caller's
|
|
@@ -373,19 +559,43 @@ function pycBuild(kernBin, image, dest, timeoutS) {
|
|
|
373
559
|
// build - 1.2 s on a desktop, 5 s on the VPS - which in a server process is not a stall but an
|
|
374
560
|
// outage: every request in flight waits for a cache fill. Nothing waits for this result, so there
|
|
375
561
|
// is no reason for it to hold the loop at all.
|
|
376
|
-
|
|
562
|
+
// STDERR IS KEPT, NOT IGNORED. A build that fails leaves no cache and the session runs as it
|
|
563
|
+
// did before this feature existed, which is correct and was also SILENT: an image that never
|
|
564
|
+
// got a cache looked exactly like one that did. The likeliest cause is this box's own caps -
|
|
565
|
+
// `compileall` walks every import root, and an image with large packages can exceed 512 MiB
|
|
566
|
+
// where a slim one never comes close.
|
|
567
|
+
const child = spawn(kernBin, argv, { stdio: ["ignore", "ignore", "pipe"], detached: false });
|
|
568
|
+
let errText = "";
|
|
569
|
+
if (child.stderr) {
|
|
570
|
+
child.stderr.setEncoding("utf8");
|
|
571
|
+
// Bounded: this is a diagnostic, and a box that floods stderr must not grow the heap.
|
|
572
|
+
child.stderr.on("data", (c) => {
|
|
573
|
+
if (errText.length < 8192) errText += c;
|
|
574
|
+
});
|
|
575
|
+
}
|
|
377
576
|
const killer = setTimeout(() => child.kill("SIGKILL"), (timeoutS + 10) * 1000);
|
|
378
577
|
if (typeof killer.unref === "function") killer.unref();
|
|
379
|
-
child.on("error", () => {
|
|
578
|
+
child.on("error", (e) => {
|
|
380
579
|
clearTimeout(killer);
|
|
580
|
+
pycReportFailure(image, -1, e && e.message);
|
|
381
581
|
finish();
|
|
382
582
|
});
|
|
383
583
|
child.on("close", (code) => {
|
|
384
584
|
clearTimeout(killer);
|
|
585
|
+
if (code !== 0) pycReportFailure(image, code, errText);
|
|
385
586
|
try {
|
|
386
587
|
// An image without python3 leaves no cache and no trace: the next session runs as before.
|
|
387
588
|
if (code === 0 && fs.readdirSync(tmp).length > 0 && pycTreeIsPublishable(tmp)) {
|
|
589
|
+
// WRITTEN BEFORE THE PUBLISH, so a tree that becomes visible always carries the identity
|
|
590
|
+
// of the image it was built from. Best effort: a cache without one reads as "cannot
|
|
591
|
+
// tell", exactly how every cache built before this existed behaves.
|
|
592
|
+
try {
|
|
593
|
+
fs.writeFileSync(path.join(tmp, PYC_SOURCE_ID), pycSourceId(image));
|
|
594
|
+
} catch {
|
|
595
|
+
/* a cache with no identity is simply never invalidated by it */
|
|
596
|
+
}
|
|
388
597
|
fs.renameSync(tmp, dest);
|
|
598
|
+
unlock();
|
|
389
599
|
done();
|
|
390
600
|
return;
|
|
391
601
|
}
|
|
@@ -425,13 +635,20 @@ function pycStartSweep(root) {
|
|
|
425
635
|
}
|
|
426
636
|
|
|
427
637
|
function pycStartBuild(kernBin, image, dest, timeoutS) {
|
|
638
|
+
// STILL IN FLIGHT, not merely once started. The entry outlives the promise, so a process that had
|
|
639
|
+
// already built this destination could never build it AGAIN - which is exactly what has to happen
|
|
640
|
+
// after a moved tag invalidates the cache: the stale tree is discarded and nothing replaces it for
|
|
641
|
+
// the life of that process. Measured on the Python side, where the map has the same shape.
|
|
428
642
|
const inFlight = PYC_BUILDS.get(dest);
|
|
429
643
|
if (inFlight) return inFlight;
|
|
430
644
|
// `pycBuild` is already asynchronous (it spawns and returns), so there is nothing to defer. The
|
|
431
645
|
// promise is returned for the tests and dropped by `open()`: a caller's first call must not wait on
|
|
432
646
|
// a cache fill. A test that polled for the directory instead would be a timing race pretending to be
|
|
433
647
|
// an assertion, and it would also race the test's own teardown - which is how this was found.
|
|
434
|
-
const started = pycBuild(kernBin, image, dest, timeoutS)
|
|
648
|
+
const started = pycBuild(kernBin, image, dest, timeoutS).finally(() => {
|
|
649
|
+
// Cleared when it settles, so the NEXT need for this destination starts a real build.
|
|
650
|
+
if (PYC_BUILDS.get(dest) === started) PYC_BUILDS.delete(dest);
|
|
651
|
+
});
|
|
435
652
|
PYC_BUILDS.set(dest, started);
|
|
436
653
|
return started;
|
|
437
654
|
}
|
|
@@ -1885,7 +2102,7 @@ class Sandbox {
|
|
|
1885
2102
|
ro = false;
|
|
1886
2103
|
}
|
|
1887
2104
|
const [real, tgt] = validateMount(source, target);
|
|
1888
|
-
this._mountArgs.push(
|
|
2105
|
+
this._mountArgs.push(...mountArgs(real, tgt, ro));
|
|
1889
2106
|
boundTargets.add("/" + tgt.split("/").filter((c) => c && c !== ".").join("/"));
|
|
1890
2107
|
}
|
|
1891
2108
|
}
|
|
@@ -2005,7 +2222,7 @@ class Sandbox {
|
|
|
2005
2222
|
if (pycMountAllowed(dest) && pycPathHasNoSymlink(dest)) {
|
|
2006
2223
|
// pycHasContent, not existsSync: an empty directory here is a cache that was swept out
|
|
2007
2224
|
// from under a mount and recreated by kern, and adopting it silences the feature for good.
|
|
2008
|
-
if (pycHasContent(dest)) {
|
|
2225
|
+
if (pycHasContent(dest) && pycSourceMatches(dest, this.image)) {
|
|
2009
2226
|
this._pycDir = dest;
|
|
2010
2227
|
// Records the ADOPTION for the sweep's least-recently-used order. Best effort: a cache on a
|
|
2011
2228
|
// read-only filesystem is still usable, it just cannot be aged.
|
|
@@ -2126,15 +2343,21 @@ class Sandbox {
|
|
|
2126
2343
|
if (!dry) verifyIsKern(this._kern);
|
|
2127
2344
|
const argv = [
|
|
2128
2345
|
this._kern, "box", name, "--image", this.image, "--ro",
|
|
2129
|
-
|
|
2346
|
+
...mountArgs(this._ws, WORKSPACE, false), "--workdir", WORKSPACE,
|
|
2130
2347
|
];
|
|
2131
2348
|
// The image's precompiled stdlib, read-only. Never on the setup box: that one compiles `.deps`
|
|
2132
2349
|
// into `__pycache__`, which the prefix would redirect into a mount it cannot write.
|
|
2133
|
-
|
|
2350
|
+
// CHECKED AGAIN HERE, not only at adoption: the sweep in another process can discard this
|
|
2351
|
+
// tree in between, and `--mount` refuses a source that is not there. Dropping the mount for this
|
|
2352
|
+
// one call degrades to compiling from source, which is what the missing directory produced
|
|
2353
|
+
// before, instead of failing the caller.
|
|
2354
|
+
if (this._pycDir && !isSetup && pycHasContent(this._pycDir))
|
|
2355
|
+
argv.push(...mountArgs(this._pycDir, PYC_MOUNT, true));
|
|
2134
2356
|
if (this.depsReadonly && !isSetup) {
|
|
2135
2357
|
const deps = path.join(this._ws, DEPS_DIR);
|
|
2136
2358
|
try {
|
|
2137
|
-
if (fs.statSync(deps).isDirectory())
|
|
2359
|
+
if (fs.statSync(deps).isDirectory())
|
|
2360
|
+
argv.push(...mountArgs(deps, `${WORKSPACE}/${DEPS_DIR}`, true));
|
|
2138
2361
|
} catch {
|
|
2139
2362
|
/* no deps yet */
|
|
2140
2363
|
}
|
|
@@ -4167,6 +4390,13 @@ module.exports = {
|
|
|
4167
4390
|
// The bytecode cache's internals, exported for its tests only: the mount flag and the atomic
|
|
4168
4391
|
// publish are security properties, and a test that cannot reach them cannot assert them.
|
|
4169
4392
|
_PYC_MOUNT: PYC_MOUNT,
|
|
4393
|
+
_PYC_SOURCE_ID: PYC_SOURCE_ID,
|
|
4394
|
+
_sanitizeRef: sanitizeRef,
|
|
4395
|
+
_SANITIZE_VECTORS: SANITIZE_VECTORS,
|
|
4396
|
+
_pycSourceId: pycSourceId,
|
|
4397
|
+
// Exported for the test that proves a stale lock is swept: the marks decide what the sweep
|
|
4398
|
+
// collects, and a lock left out of them disables an image's cache forever.
|
|
4399
|
+
_PYC_DEBRIS_MARKS: PYC_DEBRIS_MARKS,
|
|
4170
4400
|
_pycDirFor: pycDirFor,
|
|
4171
4401
|
_pycBuild: pycBuild,
|
|
4172
4402
|
_pycSweep: pycSweep,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kern-sandbox",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.38",
|
|
4
4
|
"description": "Your model writes the code. This runs it where it can't touch your machine: a rootless Linux container, no daemon, no VM, no cloud, no account. A kernel boundary, not a microVM: for deliberately hostile code, use one.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"sandbox",
|