kern-sandbox 0.2.37 → 0.2.39

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.
Files changed (2) hide show
  1. package/index.js +129 -5
  2. 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.37";
39
+ const VERSION = "0.2.39";
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
- const base = process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache");
164
- return path.join(base, "kern-sandbox", "pyc");
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,100 @@ 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
+ // THE CONFIG, which changes when ENTRYPOINT/ENV/WORKDIR/USER do.
289
+ h.update(fs.readFileSync(path.join(root, `${safe}.image`)).subarray(0, 4096));
290
+ // THE SENTINEL'S STAMP, NOT ITS BYTES. `.ok` holds the REFERENCE, so its contents are the tag and
291
+ // never move when the tag does: hashing them missed the commonest case there is, a rebuilt rootfs
292
+ // under an unchanged config. Its mtime and length are what kern ITSELF uses to decide an image's
293
+ // content changed, because a re-pull rewrites the sentinel last. Measured: a real re-pull leaves
294
+ // the config byte-identical and moves this.
295
+ const st = fs.statSync(path.join(root, `${safe}.ok`), { bigint: true });
296
+ h.update(`${st.mtimeNs}:${st.size}`);
297
+ // AND THE LAYER MANIFEST FOR A BUILT IMAGE, which names its layers by content. A pulled image has
298
+ // none, and that absence is part of the identity: an image that stops being layered is not the
299
+ // same image.
300
+ try {
301
+ h.update(fs.readFileSync(path.join(root, `${safe}.layers`)).subarray(0, 8192));
302
+ } catch {
303
+ h.update("\0no-layers");
304
+ }
305
+ return h.digest("hex").slice(0, 32);
306
+ } catch {
307
+ return "";
308
+ }
309
+ }
310
+
311
+ /** Is this cache still the one this image would produce? If not, DISCARD it so a build can replace it.
312
+ *
313
+ * Discarded and not merely refused, because a rename onto a NON-EMPTY directory is ENOTEMPTY: a stale
314
+ * tree left in place would block its own replacement forever. */
315
+ function pycSourceMatches(dest, image) {
316
+ let stored;
317
+ try {
318
+ stored = fs.readFileSync(path.join(dest, PYC_SOURCE_ID), "utf8").trim();
319
+ } catch {
320
+ return true; // built before this check, or unreadable: leave it exactly as it was
321
+ }
322
+ const current = pycSourceId(image);
323
+ if (!stored || !current || stored === current) return true;
324
+ pycDiscard(dest);
325
+ return false;
326
+ }
327
+
229
328
  function pycDirFor(image) {
230
329
  // The full digest: the key is no longer load-bearing for correctness, and a truncation saved 48
231
330
  // characters of path against two images sharing a cache directory.
@@ -500,6 +599,14 @@ function pycBuild(kernBin, image, dest, timeoutS) {
500
599
  try {
501
600
  // An image without python3 leaves no cache and no trace: the next session runs as before.
502
601
  if (code === 0 && fs.readdirSync(tmp).length > 0 && pycTreeIsPublishable(tmp)) {
602
+ // WRITTEN BEFORE THE PUBLISH, so a tree that becomes visible always carries the identity
603
+ // of the image it was built from. Best effort: a cache without one reads as "cannot
604
+ // tell", exactly how every cache built before this existed behaves.
605
+ try {
606
+ fs.writeFileSync(path.join(tmp, PYC_SOURCE_ID), pycSourceId(image));
607
+ } catch {
608
+ /* a cache with no identity is simply never invalidated by it */
609
+ }
503
610
  fs.renameSync(tmp, dest);
504
611
  unlock();
505
612
  done();
@@ -541,13 +648,20 @@ function pycStartSweep(root) {
541
648
  }
542
649
 
543
650
  function pycStartBuild(kernBin, image, dest, timeoutS) {
651
+ // STILL IN FLIGHT, not merely once started. The entry outlives the promise, so a process that had
652
+ // already built this destination could never build it AGAIN - which is exactly what has to happen
653
+ // after a moved tag invalidates the cache: the stale tree is discarded and nothing replaces it for
654
+ // the life of that process. Measured on the Python side, where the map has the same shape.
544
655
  const inFlight = PYC_BUILDS.get(dest);
545
656
  if (inFlight) return inFlight;
546
657
  // `pycBuild` is already asynchronous (it spawns and returns), so there is nothing to defer. The
547
658
  // promise is returned for the tests and dropped by `open()`: a caller's first call must not wait on
548
659
  // a cache fill. A test that polled for the directory instead would be a timing race pretending to be
549
660
  // 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);
661
+ const started = pycBuild(kernBin, image, dest, timeoutS).finally(() => {
662
+ // Cleared when it settles, so the NEXT need for this destination starts a real build.
663
+ if (PYC_BUILDS.get(dest) === started) PYC_BUILDS.delete(dest);
664
+ });
551
665
  PYC_BUILDS.set(dest, started);
552
666
  return started;
553
667
  }
@@ -2121,7 +2235,7 @@ class Sandbox {
2121
2235
  if (pycMountAllowed(dest) && pycPathHasNoSymlink(dest)) {
2122
2236
  // pycHasContent, not existsSync: an empty directory here is a cache that was swept out
2123
2237
  // from under a mount and recreated by kern, and adopting it silences the feature for good.
2124
- if (pycHasContent(dest)) {
2238
+ if (pycHasContent(dest) && pycSourceMatches(dest, this.image)) {
2125
2239
  this._pycDir = dest;
2126
2240
  // Records the ADOPTION for the sweep's least-recently-used order. Best effort: a cache on a
2127
2241
  // read-only filesystem is still usable, it just cannot be aged.
@@ -4289,6 +4403,10 @@ module.exports = {
4289
4403
  // The bytecode cache's internals, exported for its tests only: the mount flag and the atomic
4290
4404
  // publish are security properties, and a test that cannot reach them cannot assert them.
4291
4405
  _PYC_MOUNT: PYC_MOUNT,
4406
+ _PYC_SOURCE_ID: PYC_SOURCE_ID,
4407
+ _sanitizeRef: sanitizeRef,
4408
+ _SANITIZE_VECTORS: SANITIZE_VECTORS,
4409
+ _pycSourceId: pycSourceId,
4292
4410
  // Exported for the test that proves a stale lock is swept: the marks decide what the sweep
4293
4411
  // collects, and a lock left out of them disables an image's cache forever.
4294
4412
  _PYC_DEBRIS_MARKS: PYC_DEBRIS_MARKS,
@@ -4306,5 +4424,11 @@ module.exports = {
4306
4424
  _ociCanonicalRef: ociCanonicalRef,
4307
4425
  _PYC_BUILD_CODE: PYC_BUILD_CODE,
4308
4426
  _pycStartBuild: pycStartBuild,
4427
+ /** Await every build still in flight. FOR TESTS, and it removes a real race rather than masking
4428
+ * one: a test that removes its temp cache home while a background build is still writing into it
4429
+ * fails in `rimraf` with ENOTEMPTY, which is a teardown ordering bug and reads like a product
4430
+ * defect. Measured under the gate, where the machine is busy enough for the build to outlive the
4431
+ * test. */
4432
+ _pycSettle: () => Promise.allSettled([...PYC_BUILDS.values()]),
4309
4433
  version: VERSION,
4310
4434
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kern-sandbox",
3
- "version": "0.2.37",
3
+ "version": "0.2.39",
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",