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.
Files changed (2) hide show
  1. package/index.js +110 -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.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
- 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,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.37",
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",