kern-sandbox 0.2.37 → 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 +110 -5
- 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
|
|
@@ -160,8 +160,13 @@ let PYC_SWEPT = false;
|
|
|
160
160
|
|
|
161
161
|
/** The host directory holding one bytecode cache per image: $XDG_CACHE_HOME, else ~/.cache. */
|
|
162
162
|
function pycRoot() {
|
|
163
|
-
|
|
164
|
-
|
|
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");
|
|
165
170
|
}
|
|
166
171
|
|
|
167
172
|
/** kern's own defaults, from `kern-oci/src/pull.rs`. Copied because they cross a process boundary. */
|
|
@@ -226,6 +231,87 @@ function ociCanonicalRef(image) {
|
|
|
226
231
|
* cheaply. A moved tag therefore yields bytecode that no longer validates, which CPython handles by
|
|
227
232
|
* recompiling: the cost of the imperfect key is a slow call, never a wrong one. Mirrors `_pyc_dir_for`.
|
|
228
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
|
+
|
|
229
315
|
function pycDirFor(image) {
|
|
230
316
|
// The full digest: the key is no longer load-bearing for correctness, and a truncation saved 48
|
|
231
317
|
// characters of path against two images sharing a cache directory.
|
|
@@ -500,6 +586,14 @@ function pycBuild(kernBin, image, dest, timeoutS) {
|
|
|
500
586
|
try {
|
|
501
587
|
// An image without python3 leaves no cache and no trace: the next session runs as before.
|
|
502
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
|
+
}
|
|
503
597
|
fs.renameSync(tmp, dest);
|
|
504
598
|
unlock();
|
|
505
599
|
done();
|
|
@@ -541,13 +635,20 @@ function pycStartSweep(root) {
|
|
|
541
635
|
}
|
|
542
636
|
|
|
543
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.
|
|
544
642
|
const inFlight = PYC_BUILDS.get(dest);
|
|
545
643
|
if (inFlight) return inFlight;
|
|
546
644
|
// `pycBuild` is already asynchronous (it spawns and returns), so there is nothing to defer. The
|
|
547
645
|
// promise is returned for the tests and dropped by `open()`: a caller's first call must not wait on
|
|
548
646
|
// a cache fill. A test that polled for the directory instead would be a timing race pretending to be
|
|
549
647
|
// an assertion, and it would also race the test's own teardown - which is how this was found.
|
|
550
|
-
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
|
+
});
|
|
551
652
|
PYC_BUILDS.set(dest, started);
|
|
552
653
|
return started;
|
|
553
654
|
}
|
|
@@ -2121,7 +2222,7 @@ class Sandbox {
|
|
|
2121
2222
|
if (pycMountAllowed(dest) && pycPathHasNoSymlink(dest)) {
|
|
2122
2223
|
// pycHasContent, not existsSync: an empty directory here is a cache that was swept out
|
|
2123
2224
|
// from under a mount and recreated by kern, and adopting it silences the feature for good.
|
|
2124
|
-
if (pycHasContent(dest)) {
|
|
2225
|
+
if (pycHasContent(dest) && pycSourceMatches(dest, this.image)) {
|
|
2125
2226
|
this._pycDir = dest;
|
|
2126
2227
|
// Records the ADOPTION for the sweep's least-recently-used order. Best effort: a cache on a
|
|
2127
2228
|
// read-only filesystem is still usable, it just cannot be aged.
|
|
@@ -4289,6 +4390,10 @@ module.exports = {
|
|
|
4289
4390
|
// The bytecode cache's internals, exported for its tests only: the mount flag and the atomic
|
|
4290
4391
|
// publish are security properties, and a test that cannot reach them cannot assert them.
|
|
4291
4392
|
_PYC_MOUNT: PYC_MOUNT,
|
|
4393
|
+
_PYC_SOURCE_ID: PYC_SOURCE_ID,
|
|
4394
|
+
_sanitizeRef: sanitizeRef,
|
|
4395
|
+
_SANITIZE_VECTORS: SANITIZE_VECTORS,
|
|
4396
|
+
_pycSourceId: pycSourceId,
|
|
4292
4397
|
// Exported for the test that proves a stale lock is swept: the marks decide what the sweep
|
|
4293
4398
|
// collects, and a lock left out of them disables an image's cache forever.
|
|
4294
4399
|
_PYC_DEBRIS_MARKS: PYC_DEBRIS_MARKS,
|
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",
|