crossbind 2.0.0-beta.60 → 2.0.0-beta.63

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 (118) hide show
  1. package/README.md +8 -6
  2. package/package.json +6 -1
  3. package/src/actions/buildDependencies.js +37 -9
  4. package/src/actions/buildExternal.js +9 -1
  5. package/src/actions/buildJs.js +1 -1
  6. package/src/actions/buildLib.js +27 -8
  7. package/src/actions/buildNativeCommand.js +84 -0
  8. package/src/actions/buildNode.js +190 -0
  9. package/src/actions/buildTypes.js +8 -2
  10. package/src/actions/buildWasiCommand.js +0 -4
  11. package/src/actions/buildWasm.js +92 -95
  12. package/src/actions/cloudCommands.js +133 -0
  13. package/src/actions/createImportedBridges.js +64 -0
  14. package/src/actions/createInterface.js +318 -65
  15. package/src/actions/createLib.js +60 -43
  16. package/src/actions/getAllBridges.js +1 -1
  17. package/src/actions/getData.js +4 -1
  18. package/src/actions/getLinkInputs.js +70 -0
  19. package/src/actions/isSourceNewer.js +2 -0
  20. package/src/actions/licenses.js +175 -17
  21. package/src/actions/prepareConanDependencies.js +51 -0
  22. package/src/actions/publishBinary.js +32 -0
  23. package/src/actions/run.js +124 -32
  24. package/src/actions/runnerCommands.js +421 -0
  25. package/src/actions/writeRuntimeEntry.js +69 -0
  26. package/src/assets/cmake/CMakeLists.txt +1 -1
  27. package/src/assets/cmake/command/CMakeLists.txt +21 -0
  28. package/src/assets/cmake/command/command.cpp +1 -0
  29. package/src/assets/cmake/dist.cmake +2 -1
  30. package/src/assets/configure/install.sh +22 -0
  31. package/src/assets/js-runtime/adapters/path-url.js +6 -3
  32. package/src/assets/js-runtime/adapters/unboundMessage.js +2 -0
  33. package/src/assets/js-runtime/adapters/worker-comlink.js +87 -56
  34. package/src/assets/js-runtime/core.js +11 -0
  35. package/src/assets/node-import-hooks.mjs +61 -0
  36. package/src/assets/toolchain-digests.json +27 -13
  37. package/src/assets/wasi-runtime/stubs.c +29 -0
  38. package/src/bin.js +290 -37
  39. package/src/index.js +6 -1
  40. package/src/integration/bindHeaderImports.js +18 -0
  41. package/src/integration/getCrossbindScript.js +53 -19
  42. package/src/integration/getDependFilePath.js +18 -12
  43. package/src/integration/resolveNativeImport.js +14 -0
  44. package/src/node/dev.mjs +19 -0
  45. package/src/runner/blobs.js +135 -0
  46. package/src/runner/files.js +193 -0
  47. package/src/runner/server.js +320 -0
  48. package/src/state/attachConanDependencies.js +56 -0
  49. package/src/state/index.js +31 -20
  50. package/src/state/loadConfig.js +36 -24
  51. package/src/state/refreshBuiltDependencies.js +20 -0
  52. package/src/state/refreshConanDependencies.js +17 -0
  53. package/src/utils/androidToolchain.js +4 -0
  54. package/src/utils/appRustCrates.js +17 -1
  55. package/src/utils/appSources.js +112 -0
  56. package/src/utils/archiveFlags.js +12 -0
  57. package/src/utils/boundHeaders.js +79 -0
  58. package/src/utils/bridgeExtras.js +486 -0
  59. package/src/utils/bridgeRuntimeFixes.js +48 -0
  60. package/src/utils/bundledLicenses.js +26 -0
  61. package/src/utils/cargoImport.js +44 -0
  62. package/src/utils/cargoTarget.js +23 -3
  63. package/src/utils/cloudAccount.js +55 -0
  64. package/src/utils/conanDependencies.js +66 -0
  65. package/src/utils/conanImport.js +58 -0
  66. package/src/utils/conanInstall.js +154 -0
  67. package/src/utils/conanProfile.js +203 -0
  68. package/src/utils/conanStage.js +292 -0
  69. package/src/utils/copyToDistSource.js +13 -0
  70. package/src/utils/cppDts.js +134 -55
  71. package/src/utils/darwinToolchain.js +11 -0
  72. package/src/utils/dirLock.js +137 -28
  73. package/src/utils/downloadAndExtractFile.js +34 -7
  74. package/src/utils/embindArgumentGuards.js +84 -0
  75. package/src/utils/embindCallArguments.js +19 -0
  76. package/src/utils/embindJspiQueue.js +25 -0
  77. package/src/utils/embindRsFingerprint.js +26 -0
  78. package/src/utils/execContainer.js +49 -0
  79. package/src/utils/headerImports.js +56 -0
  80. package/src/utils/hostPlatform.js +21 -0
  81. package/src/utils/inputStamp.js +23 -0
  82. package/src/utils/iosToolchain.js +6 -0
  83. package/src/utils/isSourceCmakePackage.js +10 -0
  84. package/src/utils/licenseReport.js +11 -2
  85. package/src/utils/linkLayout.js +8 -1
  86. package/src/utils/linuxToolchain.js +24 -0
  87. package/src/utils/makeTreeWritable.js +12 -0
  88. package/src/utils/nativeImports.js +23 -0
  89. package/src/utils/nativeSourceStamp.js +36 -0
  90. package/src/utils/nodeDevBuild.js +92 -0
  91. package/src/utils/packageLicense.js +101 -0
  92. package/src/utils/pthreadMainScript.js +14 -0
  93. package/src/utils/pullDockerImage.js +10 -5
  94. package/src/utils/relocatePrebuilt.js +119 -0
  95. package/src/utils/remoteClient.js +127 -0
  96. package/src/utils/remoteOutputs.js +39 -0
  97. package/src/utils/remoteRunner.js +97 -0
  98. package/src/utils/remoteUpload.js +134 -0
  99. package/src/utils/replaceFile.js +8 -0
  100. package/src/utils/resolveCompanionPackage.js +51 -0
  101. package/src/utils/resolveEmbindNapi.js +22 -0
  102. package/src/utils/resolveEmbindRust.js +17 -52
  103. package/src/utils/runCargo.js +51 -53
  104. package/src/utils/runConan.js +257 -0
  105. package/src/utils/runtimeEntries.js +56 -0
  106. package/src/utils/rustBridgeGen.js +903 -253
  107. package/src/utils/rustMt.js +4 -0
  108. package/src/utils/scopedEmbind.js +22 -0
  109. package/src/utils/selectRunner.js +81 -0
  110. package/src/utils/sourceFingerprint.js +4 -3
  111. package/src/utils/swigInterface.js +86 -6
  112. package/src/utils/systemKeys.js +43 -3
  113. package/src/utils/targets.js +104 -3
  114. package/src/utils/toolchainNotices.js +5 -0
  115. package/src/utils/wasiToolchain.js +14 -1
  116. package/src/utils/windowsToolchain.js +18 -0
  117. package/vitest.config.js +11 -0
  118. package/src/utils/cppFieldBindings.js +0 -23
@@ -1,6 +1,9 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import state from '../state/index.js';
4
+ import { parseCargoImport, writeCargoMarker } from '../utils/cargoImport.js';
5
+ import { CONAN_PREFIX, resolveConanHeader } from '../utils/conanImport.js';
6
+ import refreshConanDependencies from '../state/refreshConanDependencies.js';
4
7
 
5
8
  const LAYOUT = { header: 'include', module: 'swig', source: '' };
6
9
 
@@ -76,25 +79,28 @@ function getFolderCandidates(pkg, alias) {
76
79
  }
77
80
 
78
81
  export default function getDependFilePath(source, target) {
79
- // `cargo:<crate>` imports (the node:/npm: convention for a non-npm store) resolve to a
80
- // generated marker .rs; the transformer turns it into the crate-import bridge (no surface
81
- // file, no package). The scheme is unambiguous, so npm names never collide and an
82
- // undeclared crate is a hard, actionable error instead of a silent npm fallthrough.
82
+ // `cargo:<crate>[/<module>...]` imports (the node:/npm: convention for a non-npm store)
83
+ // resolve to a generated marker .rs; the transformer turns it into the crate-import bridge
84
+ // (no surface file, no package). The scheme is unambiguous, so npm names never collide and
85
+ // an undeclared crate is a hard, actionable error instead of a silent npm fallthrough.
83
86
  if (source.startsWith('cargo:')) {
84
- const crateName = source.slice('cargo:'.length);
87
+ const cargoImport = parseCargoImport(source);
85
88
  const cargoDeps = state.config.cargoDependencies ?? {};
86
- if (!Object.hasOwn(cargoDeps, crateName)) {
87
- throw new Error(`crossbind: '${source}' is not declared - add '${crateName}' to cargoDependencies in crossbind.config.`);
88
- }
89
- const marker = path.join(state.config.paths.cache, 'rust-crates', `${crateName}.rs`);
90
- if (!fs.existsSync(marker)) {
91
- fs.mkdirSync(path.dirname(marker), { recursive: true });
92
- fs.writeFileSync(marker, `// crossbind cargo crate import marker: ${crateName}\n`);
89
+ if (!Object.hasOwn(cargoDeps, cargoImport.crateName)) {
90
+ throw new Error(`crossbind: '${source}' is not declared - add '${cargoImport.crateName}' to cargoDependencies in crossbind.config.`);
93
91
  }
92
+ const marker = writeCargoMarker(state.config.paths.cache, cargoImport);
94
93
  existsCache.set(marker, true);
95
94
  return marker;
96
95
  }
97
96
 
97
+ // `conan:<package>/<header>` names a header of a declared Conan package, staged per target like a
98
+ // port's prebuilt; from there the header takes the same path as any other.
99
+ if (source.startsWith(CONAN_PREFIX)) {
100
+ refreshConanDependencies(state.config);
101
+ return resolveConanHeader(state.config, source, target ?? state.targets.find((t) => t.platform === 'wasm'));
102
+ }
103
+
98
104
  const matches = getMatchingPackages(source);
99
105
  if (matches.length === 0) return null;
100
106
 
@@ -0,0 +1,14 @@
1
+ import { createRequire } from 'node:module';
2
+ import upath from 'upath';
3
+ import getDependFilePath from './getDependFilePath.js';
4
+
5
+ // A package that is not a crossbind dependency (the conformance kit) resolves the way the bundler finds it.
6
+ export default function resolveNativeImport(specifier, importer, target) {
7
+ if (specifier.startsWith('.')) return upath.resolve(upath.dirname(importer), specifier);
8
+ if (upath.isAbsolute(specifier)) return specifier;
9
+ try {
10
+ return getDependFilePath(specifier, target) ?? createRequire(importer).resolve(specifier);
11
+ } catch (e) {
12
+ return null;
13
+ }
14
+ }
@@ -0,0 +1,19 @@
1
+ // `node --import crossbind/node/dev app.mjs`: builds this machine's binary when the app's native sources or imports
2
+ // changed since the last build it ran, then serves the app's native imports through the hooks that build wrote.
3
+ import { pathToFileURL } from 'node:url';
4
+ import loadConfig from '../state/loadConfig.js';
5
+ import { DEV_BUILD_ENV, ensureDevBuild, projectDirOf } from '../utils/nodeDevBuild.js';
6
+
7
+ if (!process.env[DEV_BUILD_ENV]) {
8
+ // node --watch restarts when a build rewrites the outputs the last start loaded. Its SIGTERM waits for the build
9
+ // and its record, then ends this start: the next one finds the build done instead of starting it over.
10
+ let restarting = false;
11
+ const restart = () => { restarting = true; };
12
+ process.on('SIGTERM', restart);
13
+ const hooks = ensureDevBuild(await loadConfig(projectDirOf(process.argv[1])));
14
+ // A signal that came during the build is handled on the next turn of the event loop.
15
+ await new Promise((resolve) => { setImmediate(resolve); });
16
+ process.off('SIGTERM', restart);
17
+ if (restarting) process.exit(143);
18
+ if (hooks) await import(pathToFileURL(hooks).href);
19
+ }
@@ -0,0 +1,135 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import { pipeline } from 'node:stream/promises';
5
+ import { sha256Of } from './files.js';
6
+
7
+ const SHA256 = /^[0-9a-f]{64}$/;
8
+ const UPLOAD_ID = /^[\w-]{8,64}$/;
9
+ // A part nobody added to for an hour belongs to an upload that stopped; the disk may be memory (Cloud Run).
10
+ const STALE_PART_MS = 60 * 60 * 1000;
11
+
12
+ export const isHash = (value) => typeof value === 'string' && SHA256.test(value);
13
+ export const isUploadId = (value) => typeof value === 'string' && UPLOAD_ID.test(value);
14
+
15
+ // Content-addressed: a blob is stored under its sha256, and an upload whose bytes hash to something else is dropped.
16
+ export function createBlobStore(dir) {
17
+ fs.mkdirSync(dir, { recursive: true });
18
+ const root = `${path.resolve(dir)}${path.sep}`;
19
+
20
+ const pathOf = (hash) => {
21
+ if (!isHash(hash)) throw new Error(`crossbind runner: "${hash}" is not a sha256 digest`);
22
+ return path.join(dir, hash);
23
+ };
24
+
25
+ const has = (hash) => isHash(hash) && fs.existsSync(path.join(dir, hash));
26
+
27
+ async function putStream(hash, stream) {
28
+ const temp = path.join(dir, `.${hash}.${process.pid}.${crypto.randomUUID()}`);
29
+ const digest = crypto.createHash('sha256');
30
+ const output = fs.createWriteStream(temp);
31
+ try {
32
+ await pipeline(stream, async function* hashing(source) {
33
+ for await (const chunk of source) {
34
+ digest.update(chunk);
35
+ yield chunk;
36
+ }
37
+ }, output);
38
+ } catch (error) {
39
+ // pipeline settles before a file that is still opening closes; removed earlier, it would come back.
40
+ if (!output.closed) await new Promise((resolve) => { output.once('close', resolve); });
41
+ fs.rmSync(temp, { force: true });
42
+ throw error;
43
+ }
44
+ if (digest.digest('hex') !== hash) {
45
+ fs.rmSync(temp, { force: true });
46
+ return false;
47
+ }
48
+ fs.renameSync(temp, pathOf(hash));
49
+ return true;
50
+ }
51
+
52
+ function dropStaleParts() {
53
+ const now = Date.now();
54
+ for (const name of fs.readdirSync(dir).filter((file) => file.endsWith('.part'))) {
55
+ const stat = fs.statSync(path.join(dir, name), { throwIfNoEntry: false });
56
+ if (stat && now - stat.mtimeMs > STALE_PART_MS) fs.rmSync(path.join(dir, name), { force: true });
57
+ }
58
+ }
59
+
60
+ // A request names the part file, so its path is checked to stay in the store.
61
+ const partOf = (hash, upload) => {
62
+ const file = path.resolve(root, `.${hash}.${upload}.part`);
63
+ if (!file.startsWith(root)) throw new Error(`crossbind runner: "${hash}" and "${upload}" name no part in the store`);
64
+ return file;
65
+ };
66
+
67
+ // A blob too large for one request arrives in parts, in order and under the id of its upload; the last part
68
+ // checks the joined file against the hash. A part that breaks off leaves the ones before it, so the client sends
69
+ // that part again, and a part of a blob already joined answers as stored, since the last answer may have been lost.
70
+ async function writePart(hash, upload, { start, end, total }, stream) {
71
+ if (!isUploadId(upload)) throw new Error(`crossbind runner: "${upload}" is not an upload id`);
72
+ if (has(hash)) {
73
+ for await (const chunk of stream) void chunk;
74
+ return { status: 'stored' };
75
+ }
76
+ if (start === 0) dropStaleParts();
77
+ const partial = partOf(hash, upload);
78
+ const received = fs.existsSync(partial) ? fs.statSync(partial).size : 0;
79
+ if (start !== received) return { status: 'gap', received };
80
+ const restore = () => {
81
+ if (start === 0) fs.rmSync(partial, { force: true });
82
+ else if (fs.existsSync(partial)) fs.truncateSync(partial, start);
83
+ };
84
+ let length = 0;
85
+ const output = fs.createWriteStream(partial, { flags: 'a' });
86
+ try {
87
+ await pipeline(stream, async function* counting(source) {
88
+ for await (const chunk of source) {
89
+ length += chunk.length;
90
+ yield chunk;
91
+ }
92
+ }, output);
93
+ } catch (error) {
94
+ if (!output.closed) await new Promise((resolve) => { output.once('close', resolve); });
95
+ restore();
96
+ throw error;
97
+ }
98
+ if (length !== end - start + 1) {
99
+ restore();
100
+ return { status: 'short' };
101
+ }
102
+ if (end + 1 < total) return { status: 'partial', received: end + 1 };
103
+ const digest = crypto.createHash('sha256');
104
+ for await (const chunk of fs.createReadStream(partial)) digest.update(chunk);
105
+ const joined = digest.digest('hex');
106
+ if (joined !== hash) {
107
+ fs.rmSync(partial, { force: true });
108
+ return { status: 'mismatch' };
109
+ }
110
+ fs.renameSync(partial, pathOf(joined));
111
+ return { status: 'stored' };
112
+ }
113
+
114
+ // The parts of one upload go one at a time: a part whose connection broke off can still be writing when the client
115
+ // sends it again.
116
+ const writing = new Map();
117
+ function putPart(hash, upload, range, stream) {
118
+ const key = `${hash}.${upload}`;
119
+ const current = (writing.get(key) ?? Promise.resolve()).catch(() => {}).then(() => writePart(hash, upload, range, stream));
120
+ writing.set(key, current);
121
+ const forget = () => {
122
+ if (writing.get(key) === current) writing.delete(key);
123
+ };
124
+ current.then(forget, forget);
125
+ return current;
126
+ }
127
+
128
+ function putBuffer(data) {
129
+ const hash = sha256Of(data);
130
+ if (!has(hash)) fs.writeFileSync(pathOf(hash), data);
131
+ return hash;
132
+ }
133
+
134
+ return { pathOf, has, putStream, putPart, putBuffer };
135
+ }
@@ -0,0 +1,193 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+
5
+ // Shared by the client (utils/remoteClient.js) and the runner (server.js). The runner reaches the toolchain
6
+ // image as this folder alone, mounted or copied in, so nothing here may import from outside it.
7
+
8
+ export const sha256Of = (data) => crypto.createHash('sha256').update(data).digest('hex');
9
+
10
+ // `name` against a file name pattern: a trailing `*` matches any rest (`.env.*`).
11
+ const matchesName = (name, pattern) => (pattern.endsWith('*') ? name.startsWith(pattern.slice(0, -1)) : name === pattern);
12
+
13
+ // `rel` is relative to the root being walked, so a root that itself sits in node_modules (an installed port) still counts.
14
+ export function isExcluded(rel, rules) {
15
+ const segments = rel.split('/');
16
+ if (segments.some((segment) => rules.dirs.includes(segment))) return true;
17
+ if (rules.files?.some((pattern) => matchesName(segments.at(-1), pattern))) return true;
18
+ return rules.extensions.includes(path.posix.extname(rel));
19
+ }
20
+
21
+ // Segment by segment: `*` matches any one segment, `*.a` any segment with that ending.
22
+ export function matchesPattern(rel, pattern) {
23
+ const parts = rel.split('/');
24
+ const wanted = pattern.split('/');
25
+ return parts.length === wanted.length && wanted.every((want, i) => want === '*'
26
+ || (want.startsWith('*.') ? parts[i].endsWith(want.slice(1)) : want === parts[i]));
27
+ }
28
+
29
+ // A build tree such as a cargo target folder stays on the runner: a sync walk skips it, so it is neither
30
+ // uploaded nor deleted, and an output walk returns only the artifacts its `keep` patterns name.
31
+ function isServerOnly(base, rel, rules) {
32
+ const serverOnly = rules.serverOnly;
33
+ if (!serverOnly?.names.includes(path.posix.basename(rel))) return false;
34
+ return fs.existsSync(path.join(base, path.posix.dirname(rel), serverOnly.marker));
35
+ }
36
+
37
+ const isWithinReal = (real, parent) => real === parent || real.startsWith(`${parent}${path.sep}`);
38
+
39
+ // A link is followed only while it stays inside the base: one that leads out would dangle in a local container,
40
+ // and following it would send what it points at. A link back to a folder the walk is already inside never ends.
41
+ function statInside(full, realBase) {
42
+ const stat = fs.lstatSync(full);
43
+ if (!stat.isSymbolicLink()) return stat;
44
+ const real = fs.realpathSync(full);
45
+ if (!isWithinReal(real, realBase)) return null;
46
+ const target = fs.statSync(full);
47
+ return target.isDirectory() && isWithinReal(fs.realpathSync(path.dirname(full)), real) ? null : target;
48
+ }
49
+
50
+ // Every file below the roots that the rules keep, keyed by its path relative to base, and every folder:
51
+ // the host creates folders before a step writes into them, empty ones included.
52
+ export function walk(base, roots, rules, { outputs = false } = {}) {
53
+ const files = new Map();
54
+ const dirs = [];
55
+ let realBase;
56
+ try {
57
+ realBase = fs.realpathSync(base);
58
+ } catch {
59
+ return { files, dirs };
60
+ }
61
+ const visit = (rel, inRoot, serverOnlyRoot) => {
62
+ let stat;
63
+ try {
64
+ stat = statInside(path.join(base, rel), realBase);
65
+ } catch {
66
+ return;
67
+ }
68
+ if (!stat) return;
69
+ if (stat.isFile()) {
70
+ const kept = serverOnlyRoot === null
71
+ || rules.serverOnly.keep.some((pattern) => matchesPattern(rel.slice(serverOnlyRoot.length + 1), pattern));
72
+ if (kept) files.set(rel, { size: stat.size, mtimeMs: stat.mtimeMs, mode: stat.mode & 0o777 });
73
+ return;
74
+ }
75
+ if (!stat.isDirectory()) return;
76
+ let insideServerOnly = serverOnlyRoot;
77
+ if (insideServerOnly === null && isServerOnly(base, rel, rules)) {
78
+ if (!outputs) return;
79
+ insideServerOnly = rel;
80
+ }
81
+ if (insideServerOnly === null) dirs.push(rel);
82
+ for (const name of fs.readdirSync(path.join(base, rel)).sort()) {
83
+ const childInRoot = inRoot ? `${inRoot}/${name}` : name;
84
+ if (!isExcluded(childInRoot, rules)) visit(rel === '.' ? name : `${rel}/${name}`, childInRoot, insideServerOnly);
85
+ }
86
+ };
87
+ roots.forEach((root) => visit(root, '', null));
88
+ return { files, dirs };
89
+ }
90
+
91
+ export const snapshot = (base, roots, rules, options) => walk(base, roots, rules, options).files;
92
+
93
+ // Hashes reused while size and mtime are unchanged; `index` is updated in place and pruned to `files`.
94
+ export function hashIndex(base, files, index) {
95
+ const hashes = new Map();
96
+ for (const [rel, meta] of files) {
97
+ const cached = index.get(rel);
98
+ const sha256 = cached && cached.size === meta.size && cached.mtimeMs === meta.mtimeMs
99
+ ? cached.sha256
100
+ : sha256Of(fs.readFileSync(path.join(base, rel)));
101
+ index.set(rel, { size: meta.size, mtimeMs: meta.mtimeMs, sha256 });
102
+ hashes.set(rel, sha256);
103
+ }
104
+ [...index.keys()].filter((rel) => !files.has(rel)).forEach((rel) => index.delete(rel));
105
+ return hashes;
106
+ }
107
+
108
+ // A `*` segment stands for every folder at that level, so `b/*/p` names each conan package folder that exists.
109
+ export function expandRoots(base, roots) {
110
+ const step = (partials, segment) => partials.flatMap((partial) => {
111
+ if (segment !== '*') return [partial ? `${partial}/${segment}` : segment];
112
+ const dir = path.join(base, partial);
113
+ if (!fs.existsSync(dir)) return [];
114
+ return fs.readdirSync(dir, { withFileTypes: true })
115
+ .filter((entry) => entry.isDirectory())
116
+ .map((entry) => (partial ? `${partial}/${entry.name}` : entry.name))
117
+ .sort();
118
+ });
119
+ return roots.flatMap((root) => root.split('/').reduce(step, ['']));
120
+ }
121
+
122
+ // The store units a machine already holds (a crate version, a conan package folder). They never change once
123
+ // written, so the runner sends only the units missing there.
124
+ export function presentUnits(base, outputRoots) {
125
+ return expandRoots(base, outputRoots.filter((root) => root.includes('*'))).filter((unit) => {
126
+ try {
127
+ return fs.readdirSync(path.join(base, unit)).length > 0;
128
+ } catch {
129
+ return false;
130
+ }
131
+ });
132
+ }
133
+
134
+ // Permission bits only: a synced file never becomes setuid, setgid or sticky.
135
+ export const modeOf = (mode) => (Number.isInteger(mode) ? mode & 0o777 : 0o644);
136
+
137
+ export function planSync(current, manifest) {
138
+ const write = Object.entries(manifest).filter(([rel, entry]) => current.get(rel) !== entry.sha256).map(([rel]) => rel);
139
+ const remove = [...current.keys()].filter((rel) => !(rel in manifest));
140
+ return { write, remove };
141
+ }
142
+
143
+ // Written files get the current time as mtime, so make and ninja rebuild what depends on them.
144
+ export function applySync(base, plan, manifest, blobPathOf) {
145
+ plan.remove.forEach((rel) => fs.rmSync(path.join(base, rel), { force: true }));
146
+ plan.write.forEach((rel) => {
147
+ const dest = path.join(base, rel);
148
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
149
+ fs.rmSync(dest, { force: true });
150
+ fs.copyFileSync(blobPathOf(manifest[rel].sha256), dest);
151
+ fs.chmodSync(dest, modeOf(manifest[rel].mode));
152
+ });
153
+ }
154
+
155
+ export function diffSnapshots(before, after) {
156
+ const changed = [...after].filter(([rel, meta]) => {
157
+ const old = before.get(rel);
158
+ return !old || old.size !== meta.size || old.mtimeMs !== meta.mtimeMs;
159
+ }).map(([rel]) => rel);
160
+ const removed = [...before.keys()].filter((rel) => !after.has(rel));
161
+ return { changed, removed };
162
+ }
163
+
164
+ // Characters a host path would read as structure: a backslash is a separator on Windows, where a colon
165
+ // names a drive or a stream, and NUL ends a path anywhere.
166
+ const UNSAFE_PATH = process.platform === 'win32' ? /[\\:\0]/ : /[\\\0]/;
167
+
168
+ // The runner names files by container path; only a path inside one of the step's mounts maps back,
169
+ // so the runner cannot make the client write anywhere else.
170
+ export function hostPathOf(mounts, containerPath) {
171
+ const outside = () => new Error(`crossbind: the remote runner returned ${containerPath}, outside the mounts it was given.`);
172
+ const isClean = typeof containerPath === 'string' && !UNSAFE_PATH.test(containerPath)
173
+ && path.posix.normalize(containerPath) === containerPath;
174
+ const mount = isClean && mounts.find((m) => containerPath.startsWith(`${m.container}/`));
175
+ if (!mount) throw outside();
176
+ const rel = containerPath.slice(mount.container.length + 1);
177
+ const file = path.join(mount.host, rel);
178
+ const back = path.relative(mount.host, file);
179
+ if (back === '..' || back.startsWith(`..${path.sep}`) || path.isAbsolute(back)) throw outside();
180
+ return { mount, rel, file };
181
+ }
182
+
183
+ // Whether a step may hand this file back: it lies below one of the step's output roots, and below a store
184
+ // root (`registry/src/*/*`) only inside a unit the machine does not hold yet, as those never change.
185
+ export function isDeclaredOutput(rel, outputRoots, present = []) {
186
+ const parts = rel.split('/');
187
+ return outputRoots.some((root) => {
188
+ if (!root.includes('*')) return root === '.' || rel === root || rel.startsWith(`${root}/`);
189
+ const depth = root.split('/').length;
190
+ const unit = parts.slice(0, depth).join('/');
191
+ return parts.length > depth && matchesPattern(unit, root) && !present.includes(unit);
192
+ });
193
+ }