@crowdedkingdoms/ckx-kit 0.0.0-stage → 0.1.1-dev.1
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/LICENSE-APACHE +176 -0
- package/LICENSE-MIT +21 -0
- package/README.md +76 -2
- package/checks/compute-toolchain/host-catalog.json +68 -0
- package/crates/ckx-sdk/Cargo.toml +17 -0
- package/crates/ckx-sdk/LICENSE-APACHE +176 -0
- package/crates/ckx-sdk/LICENSE-MIT +21 -0
- package/crates/ckx-sdk/README.md +47 -0
- package/crates/ckx-sdk/src/abi.rs +115 -0
- package/crates/ckx-sdk/src/cron.rs +717 -0
- package/crates/ckx-sdk/src/emit.rs +140 -0
- package/crates/ckx-sdk/src/lib.rs +588 -0
- package/crates/ckx-sdk/src/model.rs +482 -0
- package/crates/ckx-sdk/src/node.rs +942 -0
- package/crates/ckx-sdk/src/rt.rs +519 -0
- package/crates/crowdy-client-sdk/Cargo.toml +15 -0
- package/crates/crowdy-client-sdk/LICENSE-APACHE +176 -0
- package/crates/crowdy-client-sdk/LICENSE-MIT +21 -0
- package/crates/crowdy-client-sdk/README.md +25 -0
- package/crates/crowdy-client-sdk/src/lib.rs +698 -0
- package/dist/checks/src/build/client-capabilities.js +123 -0
- package/dist/checks/src/build/exec-source.js +343 -0
- package/dist/checks/src/build/rust-source-guard.js +165 -0
- package/dist/checks/src/build/wasm-inspect.js +249 -0
- package/dist/cli/src/build.js +99 -0
- package/dist/cli/src/crate.js +64 -0
- package/dist/cli/src/doctor.js +51 -0
- package/dist/cli/src/kit.js +66 -0
- package/dist/cli/src/main.js +109 -0
- package/dist/cli/src/new.js +74 -0
- package/dist/cli/src/tools.js +104 -0
- package/kit.pin +9 -0
- package/package.json +81 -4
- package/rust-toolchain.toml +6 -0
- package/starters/ckx.json +9 -0
- package/starters/grid-mod/Cargo.toml +13 -0
- package/starters/grid-mod/src/lib.rs +82 -0
- package/starters/matchmaker/Cargo.toml +13 -0
- package/starters/matchmaker/src/lib.rs +362 -0
- package/starters/npc-mobs/Cargo.toml +14 -0
- package/starters/npc-mobs/src/lib.rs +469 -0
- package/starters/session/Cargo.toml +13 -0
- package/starters/session/src/lib.rs +395 -0
- package/starters/world-tick/Cargo.toml +14 -0
- package/starters/world-tick/src/lib.rs +346 -0
- package/templates/client/Cargo.toml +16 -0
- package/templates/client/src/lib.rs +31 -0
- package/templates/hub/Cargo.toml +11 -0
- package/templates/hub/src/lib.rs +73 -0
- package/templates/spoke/Cargo.toml +11 -0
- package/templates/spoke/src/lib.rs +53 -0
- package/tools/instrument/Cargo.lock +140 -0
- package/tools/instrument/Cargo.toml +11 -0
- package/tools/instrument/src/main.rs +165 -0
- package/vendor.lock +167 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.parseHostCatalog = parseHostCatalog;
|
|
4
|
+
exports.readHostCatalog = readHostCatalog;
|
|
5
|
+
exports.deriveClientCapabilities = deriveClientCapabilities;
|
|
6
|
+
exports.aggregateClientCapabilities = aggregateClientCapabilities;
|
|
7
|
+
exports.canonicalJson = canonicalJson;
|
|
8
|
+
exports.capabilityHash = capabilityHash;
|
|
9
|
+
exports.capabilitySummaryWidens = capabilitySummaryWidens;
|
|
10
|
+
const node_crypto_1 = require("node:crypto");
|
|
11
|
+
const node_fs_1 = require("node:fs");
|
|
12
|
+
/** The host catalog, checked: known groups, valid targets, no name twice. */
|
|
13
|
+
function parseHostCatalog(json) {
|
|
14
|
+
const raw = JSON.parse(json);
|
|
15
|
+
const groups = raw.groups ?? {};
|
|
16
|
+
if (!Array.isArray(raw.functions) || raw.functions.length === 0) {
|
|
17
|
+
throw new Error('host catalog lists no functions');
|
|
18
|
+
}
|
|
19
|
+
const functions = [];
|
|
20
|
+
const seen = new Set();
|
|
21
|
+
for (const entry of raw.functions) {
|
|
22
|
+
const fn = (entry ?? {});
|
|
23
|
+
const name = fn.name;
|
|
24
|
+
if (typeof name !== 'string' || seen.has(name)) {
|
|
25
|
+
throw new Error(`host catalog names ${String(name)} badly or twice`);
|
|
26
|
+
}
|
|
27
|
+
seen.add(name);
|
|
28
|
+
if (typeof fn.group !== 'string' || !(fn.group in groups)) {
|
|
29
|
+
throw new Error(`host catalog: ${name} is in unknown group ${String(fn.group)}`);
|
|
30
|
+
}
|
|
31
|
+
const targets = fn.targets;
|
|
32
|
+
if (!Array.isArray(targets) ||
|
|
33
|
+
targets.length === 0 ||
|
|
34
|
+
targets.some((t) => t !== 'server' && t !== 'client')) {
|
|
35
|
+
throw new Error(`host catalog: ${name} has invalid targets`);
|
|
36
|
+
}
|
|
37
|
+
functions.push({
|
|
38
|
+
name,
|
|
39
|
+
group: fn.group,
|
|
40
|
+
targets: targets,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
return { catalogVersion: Number(raw.catalogVersion), groups, functions };
|
|
44
|
+
}
|
|
45
|
+
async function readHostCatalog(file) {
|
|
46
|
+
return parseHostCatalog(await node_fs_1.promises.readFile(file, 'utf8'));
|
|
47
|
+
}
|
|
48
|
+
const sorted = (values) => [...new Set(values)].sort();
|
|
49
|
+
/**
|
|
50
|
+
* A built CLIENT half's summary. Reads the module's import and export sections through
|
|
51
|
+
* `WebAssembly.Module`, which compiles it and runs nothing.
|
|
52
|
+
*/
|
|
53
|
+
function deriveClientCapabilities(bytes, catalog) {
|
|
54
|
+
const module = new WebAssembly.Module(bytes);
|
|
55
|
+
const imports = WebAssembly.Module.imports(module).map((i) => `${i.module}.${i.name}`);
|
|
56
|
+
const exportedFunctions = WebAssembly.Module.exports(module)
|
|
57
|
+
.filter((e) => e.kind === 'function')
|
|
58
|
+
.map((e) => e.name);
|
|
59
|
+
const haystack = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.length);
|
|
60
|
+
const reachable = catalog.functions.filter((fn) => fn.targets.includes('client') &&
|
|
61
|
+
(imports.includes(`ck.${fn.name}`) ||
|
|
62
|
+
haystack.includes(Buffer.from(fn.name, 'utf8'))));
|
|
63
|
+
return {
|
|
64
|
+
version: 1,
|
|
65
|
+
target: 'client',
|
|
66
|
+
imports: sorted(imports),
|
|
67
|
+
hostFunctions: sorted(reachable.map((fn) => fn.name)),
|
|
68
|
+
capabilityGroups: sorted(reachable.map((fn) => fn.group)),
|
|
69
|
+
presentationHooks: sorted(reachable.filter((fn) => fn.group === 'present').map((fn) => fn.name)),
|
|
70
|
+
exportedFunctions: sorted(exportedFunctions),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/** The union of several summaries: one author's CLIENT halves in a grid. */
|
|
74
|
+
function aggregateClientCapabilities(summaries) {
|
|
75
|
+
const union = (key) => sorted(summaries.flatMap((s) => {
|
|
76
|
+
const values = s[key];
|
|
77
|
+
return Array.isArray(values) ? values : [];
|
|
78
|
+
}));
|
|
79
|
+
return {
|
|
80
|
+
version: 1,
|
|
81
|
+
target: 'client',
|
|
82
|
+
imports: union('imports'),
|
|
83
|
+
hostFunctions: union('hostFunctions'),
|
|
84
|
+
capabilityGroups: union('capabilityGroups'),
|
|
85
|
+
presentationHooks: union('presentationHooks'),
|
|
86
|
+
exportedFunctions: union('exportedFunctions'),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/** JSON with every object's keys sorted, so equal summaries hash equally. */
|
|
90
|
+
function canonicalJson(value) {
|
|
91
|
+
return JSON.stringify(sortKeys(value));
|
|
92
|
+
}
|
|
93
|
+
function sortKeys(value) {
|
|
94
|
+
if (Array.isArray(value))
|
|
95
|
+
return value.map(sortKeys);
|
|
96
|
+
if (value && typeof value === 'object') {
|
|
97
|
+
return Object.fromEntries(Object.keys(value)
|
|
98
|
+
.sort()
|
|
99
|
+
.map((k) => [k, sortKeys(value[k])]));
|
|
100
|
+
}
|
|
101
|
+
return value;
|
|
102
|
+
}
|
|
103
|
+
/** What consent binds to: the SHA-256 of the canonical summary, hex. */
|
|
104
|
+
function capabilityHash(summary) {
|
|
105
|
+
return (0, node_crypto_1.createHash)('sha256').update(canonicalJson(summary)).digest('hex');
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Whether `next` asks for anything `prev` did not: an import, a host call, a capability group,
|
|
109
|
+
* a presentation hook or an exported entry point (an export such as `on_event` is a channel the
|
|
110
|
+
* page feeds). Trust in an author stops covering their CLIENT halves in a grid once their union
|
|
111
|
+
* widens; narrowing keeps it.
|
|
112
|
+
*/
|
|
113
|
+
function capabilitySummaryWidens(prev, next) {
|
|
114
|
+
const widens = (a, b) => {
|
|
115
|
+
const have = new Set(a ?? []);
|
|
116
|
+
return (b ?? []).some((v) => !have.has(v));
|
|
117
|
+
};
|
|
118
|
+
return (widens(prev.imports, next.imports) ||
|
|
119
|
+
widens(prev.hostFunctions, next.hostFunctions) ||
|
|
120
|
+
widens(prev.capabilityGroups, next.capabilityGroups) ||
|
|
121
|
+
widens(prev.presentationHooks, next.presentationHooks) ||
|
|
122
|
+
widens(prev.exportedFunctions, next.exportedFunctions));
|
|
123
|
+
}
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* What a ck-exec build may contain, and how its manifest is rewritten for the toolchain.
|
|
4
|
+
*
|
|
5
|
+
* A build is one or more crates, each a `ckx-sdk` library built as a `cdylib` for
|
|
6
|
+
* `wasm32-unknown-unknown`: one module per crate, which the app's manifest names by digest.
|
|
7
|
+
* A CLIENT build is the browser half of a player's mod instead: one `crowdy-client-sdk` crate.
|
|
8
|
+
* Cargo runs build scripts and proc-macros at compile time, so every dependency is one the
|
|
9
|
+
* platform vendored and reviewed, and nothing may add another.
|
|
10
|
+
*/
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.EXEC_ARTIFACT_MAX_BYTES = exports.EXEC_ABI_EXPORTS = exports.EXEC_ABI_IMPORTS = exports.EXEC_MAX_SOURCE_BYTES = exports.EXEC_MAX_FILES_PER_CRATE = exports.EXEC_MAX_CRATES = exports.EXEC_ALLOWED_SECTIONS = exports.CLIENT_TICK_INTERVAL_MS = exports.BUILD_SDK = exports.CLIENT_ALLOWED_CRATES = exports.EXEC_ALLOWED_CRATES = void 0;
|
|
13
|
+
exports.validateExecPath = validateExecPath;
|
|
14
|
+
exports.validateExecManifest = validateExecManifest;
|
|
15
|
+
exports.validateExecCrates = validateExecCrates;
|
|
16
|
+
exports.rewriteExecManifest = rewriteExecManifest;
|
|
17
|
+
exports.clientTickIntervalMs = clientTickIntervalMs;
|
|
18
|
+
exports.validateExecArtifact = validateExecArtifact;
|
|
19
|
+
const rust_source_guard_1 = require("./rust-source-guard");
|
|
20
|
+
/** Dependencies a ck-exec crate may declare (the toolchain vendors them and their deps). */
|
|
21
|
+
exports.EXEC_ALLOWED_CRATES = new Set([
|
|
22
|
+
'ckx-sdk',
|
|
23
|
+
'serde',
|
|
24
|
+
'serde_json',
|
|
25
|
+
]);
|
|
26
|
+
/** Dependencies a CLIENT crate may declare: the client SDK and the same serde crates. */
|
|
27
|
+
exports.CLIENT_ALLOWED_CRATES = new Set([
|
|
28
|
+
'crowdy-client-sdk',
|
|
29
|
+
'serde',
|
|
30
|
+
'serde_json',
|
|
31
|
+
]);
|
|
32
|
+
/** The SDK each kind builds against; its dependency line is pointed at the toolchain's copy. */
|
|
33
|
+
exports.BUILD_SDK = {
|
|
34
|
+
exec: 'ckx-sdk',
|
|
35
|
+
client: 'crowdy-client-sdk',
|
|
36
|
+
};
|
|
37
|
+
const ALLOWED_CRATES = {
|
|
38
|
+
exec: exports.EXEC_ALLOWED_CRATES,
|
|
39
|
+
client: exports.CLIENT_ALLOWED_CRATES,
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* The browser tick Crowdy Studio reads from a CLIENT crate, `[package.metadata.crowdy]
|
|
43
|
+
* tick_interval_ms`: 1000 ms when absent, clamped to 16-1000 ms.
|
|
44
|
+
*/
|
|
45
|
+
exports.CLIENT_TICK_INTERVAL_MS = { default: 1_000, min: 16, max: 1_000 };
|
|
46
|
+
const CLIENT_METADATA_SECTION = 'package.metadata.crowdy';
|
|
47
|
+
/** Manifest sections a crate may contain; everything else (build scripts, patches) is refused. */
|
|
48
|
+
exports.EXEC_ALLOWED_SECTIONS = new Set([
|
|
49
|
+
'package',
|
|
50
|
+
'lib',
|
|
51
|
+
'dependencies',
|
|
52
|
+
]);
|
|
53
|
+
exports.EXEC_MAX_CRATES = 16;
|
|
54
|
+
exports.EXEC_MAX_FILES_PER_CRATE = 64;
|
|
55
|
+
exports.EXEC_MAX_SOURCE_BYTES = 2 * 1024 * 1024;
|
|
56
|
+
const CRATE_NAME = /^[a-z][a-z0-9_-]{0,63}$/;
|
|
57
|
+
const SOURCE_PATH = /^[A-Za-z0-9_\-./]+$/;
|
|
58
|
+
const TOP_FILES = new Set(['Cargo.toml', 'README.md']);
|
|
59
|
+
function fail(message) {
|
|
60
|
+
throw new Error(message);
|
|
61
|
+
}
|
|
62
|
+
/** A crate file path: `Cargo.toml`, `README.md`, or a Rust file under `src/`. */
|
|
63
|
+
function validateExecPath(path) {
|
|
64
|
+
if (!SOURCE_PATH.test(path) || path.length > 200)
|
|
65
|
+
fail(`bad source path '${path}'`);
|
|
66
|
+
const parts = path.split('/');
|
|
67
|
+
if (parts.some((p) => p === '' || p === '.' || p === '..'))
|
|
68
|
+
fail(`bad source path '${path}'`);
|
|
69
|
+
if (TOP_FILES.has(path))
|
|
70
|
+
return;
|
|
71
|
+
if (parts[0] !== 'src' || !path.endsWith('.rs')) {
|
|
72
|
+
fail(`'${path}' is not Cargo.toml, README.md or a .rs file under src/`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function stripComment(line) {
|
|
76
|
+
let inString = false;
|
|
77
|
+
for (let i = 0; i < line.length; i++) {
|
|
78
|
+
const c = line[i];
|
|
79
|
+
if (c === '"' && line[i - 1] !== '\\')
|
|
80
|
+
inString = !inString;
|
|
81
|
+
if (c === '#' && !inString)
|
|
82
|
+
return line.slice(0, i);
|
|
83
|
+
}
|
|
84
|
+
return line;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Keys each section may hold. Anything else is refused: several Cargo keys name a file on
|
|
88
|
+
* the build host (`[lib] path`, `[package] workspace`, `readme`, `build`).
|
|
89
|
+
*/
|
|
90
|
+
const PACKAGE_KEYS = new Set([
|
|
91
|
+
'name',
|
|
92
|
+
'version',
|
|
93
|
+
'edition',
|
|
94
|
+
'description',
|
|
95
|
+
'publish',
|
|
96
|
+
'license',
|
|
97
|
+
'authors',
|
|
98
|
+
'rust-version',
|
|
99
|
+
]);
|
|
100
|
+
const LIB_KEYS = new Set(['name', 'crate-type']);
|
|
101
|
+
const DEPENDENCY_KEYS = new Set([
|
|
102
|
+
'version',
|
|
103
|
+
'features',
|
|
104
|
+
'default-features',
|
|
105
|
+
]);
|
|
106
|
+
/**
|
|
107
|
+
* The keys of a one-line inline table's body, split at its top-level commas; null unless every
|
|
108
|
+
* entry is a BARE key and a value. A quoted key (`"path" = …`) is the same key to cargo, so a
|
|
109
|
+
* check that reads only bare words before `=` would let it through.
|
|
110
|
+
*/
|
|
111
|
+
function inlineTableKeys(body) {
|
|
112
|
+
const entries = [];
|
|
113
|
+
let depth = 0;
|
|
114
|
+
let quote = null;
|
|
115
|
+
let start = 0;
|
|
116
|
+
for (let i = 0; i < body.length; i++) {
|
|
117
|
+
const c = body[i];
|
|
118
|
+
if (quote) {
|
|
119
|
+
if (c === '\\' && quote === '"')
|
|
120
|
+
i++;
|
|
121
|
+
else if (c === quote)
|
|
122
|
+
quote = null;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (c === '"' || c === "'")
|
|
126
|
+
quote = c;
|
|
127
|
+
else if (c === '[' || c === '{')
|
|
128
|
+
depth++;
|
|
129
|
+
else if (c === ']' || c === '}')
|
|
130
|
+
depth--;
|
|
131
|
+
else if (c === ',' && depth === 0) {
|
|
132
|
+
entries.push(body.slice(start, i));
|
|
133
|
+
start = i + 1;
|
|
134
|
+
}
|
|
135
|
+
if (depth < 0)
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
if (quote || depth !== 0)
|
|
139
|
+
return null;
|
|
140
|
+
entries.push(body.slice(start));
|
|
141
|
+
if (entries.length === 1 && entries[0].trim() === '')
|
|
142
|
+
return [];
|
|
143
|
+
const keys = [];
|
|
144
|
+
for (const entry of entries) {
|
|
145
|
+
const kv = entry.match(/^\s*([A-Za-z0-9_-]+)\s*=\s*(.*?)\s*$/);
|
|
146
|
+
if (!kv || !DEPENDENCY_VALUE.test(kv[2]))
|
|
147
|
+
return null;
|
|
148
|
+
keys.push(kv[1]);
|
|
149
|
+
}
|
|
150
|
+
return keys;
|
|
151
|
+
}
|
|
152
|
+
const TOML_STRING = `(?:"(?:[^"\\\\]|\\\\.)*"|'[^']*')`;
|
|
153
|
+
/** One value of a dependency table: a string, a boolean, or an array of strings. */
|
|
154
|
+
const DEPENDENCY_VALUE = new RegExp(`^(?:${TOML_STRING}|true|false|\\[\\s*(?:${TOML_STRING}\\s*(?:,\\s*${TOML_STRING}\\s*)*,?)?\\s*\\])$`);
|
|
155
|
+
/**
|
|
156
|
+
* The crate's Cargo.toml: only [package], [lib] as a cdylib, and [dependencies] from the
|
|
157
|
+
* allowlist, one key per line. The SDK's line is replaced by the toolchain's copy, so its
|
|
158
|
+
* value does not matter; the others are a version or `{ version, features,
|
|
159
|
+
* default-features }` from the vendored registry. A CLIENT crate may also carry
|
|
160
|
+
* `[package.metadata.crowdy]` with `tick_interval_ms`, which cargo ignores.
|
|
161
|
+
*/
|
|
162
|
+
function validateExecManifest(manifest, kind = 'exec') {
|
|
163
|
+
const allowed = ALLOWED_CRATES[kind];
|
|
164
|
+
const sdk = exports.BUILD_SDK[kind];
|
|
165
|
+
let section = '';
|
|
166
|
+
let cdylib = false;
|
|
167
|
+
for (const raw of manifest.split(/\r?\n/)) {
|
|
168
|
+
const line = stripComment(raw).trim();
|
|
169
|
+
if (!line)
|
|
170
|
+
continue;
|
|
171
|
+
const header = line.match(/^\[([^\]]+)\]$/);
|
|
172
|
+
if (header) {
|
|
173
|
+
section = header[1].trim();
|
|
174
|
+
const metadata = kind === 'client' && section === CLIENT_METADATA_SECTION;
|
|
175
|
+
if (!exports.EXEC_ALLOWED_SECTIONS.has(section) && !metadata) {
|
|
176
|
+
fail(`Cargo.toml section [${section}] is not allowed (only ${[...exports.EXEC_ALLOWED_SECTIONS].join(', ')}${kind === 'client' ? `, ${CLIENT_METADATA_SECTION}` : ''})`);
|
|
177
|
+
}
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const kv = line.match(/^([A-Za-z0-9_-]+)\s*=\s*(.+)$/);
|
|
181
|
+
if (!kv)
|
|
182
|
+
fail(`Cargo.toml line '${line}' is not a single-line key = value`);
|
|
183
|
+
const [, key, value] = kv;
|
|
184
|
+
if (section === '')
|
|
185
|
+
fail(`Cargo.toml key '${key}' must be inside a section`);
|
|
186
|
+
if (section === 'package' && !PACKAGE_KEYS.has(key)) {
|
|
187
|
+
fail(`Cargo.toml [package] ${key} is not allowed (only ${[...PACKAGE_KEYS].join(', ')})`);
|
|
188
|
+
}
|
|
189
|
+
if (section === 'lib') {
|
|
190
|
+
if (!LIB_KEYS.has(key)) {
|
|
191
|
+
fail(`Cargo.toml [lib] ${key} is not allowed (only ${[...LIB_KEYS].join(', ')})`);
|
|
192
|
+
}
|
|
193
|
+
if (key === 'crate-type') {
|
|
194
|
+
if (!/^\[\s*"cdylib"\s*(,\s*"rlib"\s*)?\]$/.test(value))
|
|
195
|
+
fail('[lib] crate-type must be ["cdylib"]');
|
|
196
|
+
cdylib = true;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (section === CLIENT_METADATA_SECTION) {
|
|
200
|
+
if (key !== 'tick_interval_ms') {
|
|
201
|
+
fail(`Cargo.toml [${CLIENT_METADATA_SECTION}] ${key} is not allowed (only tick_interval_ms)`);
|
|
202
|
+
}
|
|
203
|
+
if (!/^\d{1,6}$/.test(value)) {
|
|
204
|
+
fail('tick_interval_ms must be a whole number of milliseconds');
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (section === 'dependencies') {
|
|
208
|
+
if (!allowed.has(key)) {
|
|
209
|
+
fail(`Cargo.toml dependency '${key}' is not in the allowlist (${[...allowed].join(', ')})`);
|
|
210
|
+
}
|
|
211
|
+
if (key !== sdk && !/^"[^"]*"$/.test(value)) {
|
|
212
|
+
const table = value.match(/^\{(.*)\}$/);
|
|
213
|
+
const keys = table ? inlineTableKeys(table[1]) : null;
|
|
214
|
+
if (!keys) {
|
|
215
|
+
fail(`dependency '${key}' must be a version or a one-line { version, features } table with bare keys`);
|
|
216
|
+
}
|
|
217
|
+
for (const k of keys) {
|
|
218
|
+
if (!DEPENDENCY_KEYS.has(k)) {
|
|
219
|
+
fail(`dependency '${key}' may not set '${k}': only a version from the vendored registry, features and default-features`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (!cdylib)
|
|
226
|
+
fail('Cargo.toml needs [lib] crate-type = ["cdylib"]');
|
|
227
|
+
}
|
|
228
|
+
/** Every crate of a build: names, paths, sizes, manifests. */
|
|
229
|
+
function validateExecCrates(crates, kind = 'exec') {
|
|
230
|
+
if (crates.length === 0)
|
|
231
|
+
fail('a build names at least one crate');
|
|
232
|
+
if (kind === 'client' && crates.length !== 1)
|
|
233
|
+
fail('a CLIENT half is built from exactly one crate');
|
|
234
|
+
if (crates.length > exports.EXEC_MAX_CRATES)
|
|
235
|
+
fail(`a build has at most ${exports.EXEC_MAX_CRATES} crates`);
|
|
236
|
+
const names = new Set();
|
|
237
|
+
let total = 0;
|
|
238
|
+
for (const c of crates) {
|
|
239
|
+
if (!CRATE_NAME.test(c.name))
|
|
240
|
+
fail(`crate name '${c.name}' must be lowercase letters, digits, '-' or '_'`);
|
|
241
|
+
if (names.has(c.name))
|
|
242
|
+
fail(`crate '${c.name}' appears twice`);
|
|
243
|
+
names.add(c.name);
|
|
244
|
+
const paths = Object.keys(c.files);
|
|
245
|
+
if (paths.length > exports.EXEC_MAX_FILES_PER_CRATE)
|
|
246
|
+
fail(`crate '${c.name}' has more than ${exports.EXEC_MAX_FILES_PER_CRATE} files`);
|
|
247
|
+
for (const p of paths) {
|
|
248
|
+
validateExecPath(p);
|
|
249
|
+
if (p.endsWith('.rs'))
|
|
250
|
+
(0, rust_source_guard_1.assertNoHostReads)(`${c.name}/${p}`, c.files[p]);
|
|
251
|
+
total += Buffer.byteLength(c.files[p], 'utf8');
|
|
252
|
+
}
|
|
253
|
+
if (!('Cargo.toml' in c.files))
|
|
254
|
+
fail(`crate '${c.name}' has no Cargo.toml`);
|
|
255
|
+
if (!('src/lib.rs' in c.files))
|
|
256
|
+
fail(`crate '${c.name}' has no src/lib.rs`);
|
|
257
|
+
validateExecManifest(c.files['Cargo.toml'], kind);
|
|
258
|
+
}
|
|
259
|
+
if (total > exports.EXEC_MAX_SOURCE_BYTES)
|
|
260
|
+
fail(`a build's sources are at most ${exports.EXEC_MAX_SOURCE_BYTES} bytes`);
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Points the kind's SDK at the toolchain's pinned copy and appends the platform release
|
|
264
|
+
* profile (a crate may not declare its own; the validator refuses [profile]).
|
|
265
|
+
*/
|
|
266
|
+
function rewriteExecManifest(manifest, sdkPath, kind = 'exec') {
|
|
267
|
+
const sdk = exports.BUILD_SDK[kind];
|
|
268
|
+
const line = new RegExp(`^\\s*${sdk}\\s*=`);
|
|
269
|
+
const lines = manifest
|
|
270
|
+
.split(/\r?\n/)
|
|
271
|
+
.map((l) => (line.test(l) ? `${sdk} = { path = "${sdkPath}" }` : l));
|
|
272
|
+
return `${lines.join('\n')}\n\n[profile.release]\nopt-level = 3\nlto = true\ncodegen-units = 1\npanic = "abort"\nstrip = "symbols"\n`;
|
|
273
|
+
}
|
|
274
|
+
/** A validated CLIENT manifest's `tick_interval_ms`, clamped as Crowdy Studio clamps it. */
|
|
275
|
+
function clientTickIntervalMs(manifest) {
|
|
276
|
+
let section = '';
|
|
277
|
+
for (const raw of manifest.split(/\r?\n/)) {
|
|
278
|
+
const line = stripComment(raw).trim();
|
|
279
|
+
const header = line.match(/^\[([^\]]+)\]$/);
|
|
280
|
+
if (header) {
|
|
281
|
+
section = header[1].trim();
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
const kv = line.match(/^tick_interval_ms\s*=\s*(\d+)$/);
|
|
285
|
+
if (section === CLIENT_METADATA_SECTION && kv && Number(kv[1]) > 0) {
|
|
286
|
+
return Math.min(exports.CLIENT_TICK_INTERVAL_MS.max, Math.max(exports.CLIENT_TICK_INTERVAL_MS.min, Number(kv[1])));
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return exports.CLIENT_TICK_INTERVAL_MS.default;
|
|
290
|
+
}
|
|
291
|
+
/** The guest ABI's imports (`ckx` module), from ck-exec's docs/GUEST-ABI.md. */
|
|
292
|
+
exports.EXEC_ABI_IMPORTS = new Set([
|
|
293
|
+
'log',
|
|
294
|
+
'now_ms',
|
|
295
|
+
'call',
|
|
296
|
+
'send',
|
|
297
|
+
'publish',
|
|
298
|
+
'node',
|
|
299
|
+
'wake_at',
|
|
300
|
+
'subscribe',
|
|
301
|
+
'unsubscribe',
|
|
302
|
+
'persist_now',
|
|
303
|
+
'emit',
|
|
304
|
+
]);
|
|
305
|
+
/** Exports every ck-exec module has. */
|
|
306
|
+
exports.EXEC_ABI_EXPORTS = [
|
|
307
|
+
['memory', 'memory'],
|
|
308
|
+
['ckx_abi', 'function'],
|
|
309
|
+
['ckx_alloc', 'function'],
|
|
310
|
+
['ckx_init', 'function'],
|
|
311
|
+
['ckx_handle', 'function'],
|
|
312
|
+
['ckx_persist', 'function'],
|
|
313
|
+
['ckx_evict', 'function'],
|
|
314
|
+
];
|
|
315
|
+
/** The largest module the execution manager accepts (ck-exec `bounds::ARTIFACT_MAX_BYTES`). */
|
|
316
|
+
exports.EXEC_ARTIFACT_MAX_BYTES = 16 * 1024 * 1024;
|
|
317
|
+
/**
|
|
318
|
+
* A built module fit for ck-exec: compiles, imports nothing but the guest ABI, exports its
|
|
319
|
+
* entry points, and fits the manager's limit.
|
|
320
|
+
*/
|
|
321
|
+
async function validateExecArtifact(bytes) {
|
|
322
|
+
if (bytes.length > exports.EXEC_ARTIFACT_MAX_BYTES)
|
|
323
|
+
fail(`module is ${bytes.length} bytes; the limit is ${exports.EXEC_ARTIFACT_MAX_BYTES}`);
|
|
324
|
+
let module;
|
|
325
|
+
try {
|
|
326
|
+
module = await WebAssembly.compile(bytes);
|
|
327
|
+
}
|
|
328
|
+
catch (err) {
|
|
329
|
+
fail(`malformed WASM module: ${err.message}`);
|
|
330
|
+
}
|
|
331
|
+
for (const imp of WebAssembly.Module.imports(module)) {
|
|
332
|
+
if (imp.module !== 'ckx' ||
|
|
333
|
+
imp.kind !== 'function' ||
|
|
334
|
+
!exports.EXEC_ABI_IMPORTS.has(imp.name)) {
|
|
335
|
+
fail(`module import ${imp.module}::${imp.name} (${imp.kind}) is not part of the ck-exec guest ABI`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
const exported = new Map(WebAssembly.Module.exports(module).map((e) => [e.name, e.kind]));
|
|
339
|
+
for (const [name, kind] of exports.EXEC_ABI_EXPORTS) {
|
|
340
|
+
if (exported.get(name) !== kind)
|
|
341
|
+
fail(`module does not export ${kind} '${name}'; build it with ckx-sdk's export macro`);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Rust that reads the build host at compile time. Crates are compiled on the API's hosts,
|
|
4
|
+
* so `include_str!("/etc/…")`, `env!("…")` or `#[path = "/proc/…"] mod x;` would put the
|
|
5
|
+
* host's files or environment into the module, or into the compiler errors the developer
|
|
6
|
+
* reads back. Refused:
|
|
7
|
+
* - the reading macros by name, anywhere in code (a `use … as` rename, or passing the
|
|
8
|
+
* name into a macro, still names them);
|
|
9
|
+
* - `#[path]` and `#[link]` attributes, including inside `cfg_attr`;
|
|
10
|
+
* - an out-of-line `mod x;` inside a `macro_rules!` body, where a metavariable could
|
|
11
|
+
* become a `#[path]` the attribute check never sees.
|
|
12
|
+
*
|
|
13
|
+
* Comments and string literals are skipped. Anything the scanner cannot classify counts as
|
|
14
|
+
* code, so where it and rustc disagree it is only stricter. This is one layer: the build
|
|
15
|
+
* also runs with a scrubbed environment, and a filesystem sandbox is the backstop.
|
|
16
|
+
*/
|
|
17
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
+
exports.rustCodeOnly = rustCodeOnly;
|
|
19
|
+
exports.assertNoHostReads = assertNoHostReads;
|
|
20
|
+
const BANNED_IDENTS = [
|
|
21
|
+
'include',
|
|
22
|
+
'include_str',
|
|
23
|
+
'include_bytes',
|
|
24
|
+
'env',
|
|
25
|
+
'option_env',
|
|
26
|
+
'asm',
|
|
27
|
+
'global_asm',
|
|
28
|
+
'naked_asm',
|
|
29
|
+
];
|
|
30
|
+
const BANNED_IDENT = new RegExp(`\\b(${BANNED_IDENTS.join('|')})\\b`);
|
|
31
|
+
const ATTR_START = /#\s*!?\s*\[/g;
|
|
32
|
+
const BANNED_ATTR = /\b(path|link|link_args)\b/;
|
|
33
|
+
const MACRO_RULES = /\bmacro_rules\s*!\s*[A-Za-z_]\w*\s*[{([]/g;
|
|
34
|
+
const OUT_OF_LINE_MOD = /\bmod\s+[$A-Za-z_][\w$]*\s*;/;
|
|
35
|
+
const CLOSE = { '{': '}', '(': ')', '[': ']' };
|
|
36
|
+
/** The index of the bracket closing the one at `open`, or the end of `code`. */
|
|
37
|
+
function closing(code, open) {
|
|
38
|
+
const o = code[open];
|
|
39
|
+
const c = CLOSE[o];
|
|
40
|
+
let depth = 0;
|
|
41
|
+
for (let j = open; j < code.length; j++) {
|
|
42
|
+
if (code[j] === o)
|
|
43
|
+
depth++;
|
|
44
|
+
else if (code[j] === c && --depth === 0)
|
|
45
|
+
return j;
|
|
46
|
+
}
|
|
47
|
+
return code.length - 1;
|
|
48
|
+
}
|
|
49
|
+
function isIdentChar(c) {
|
|
50
|
+
return c !== undefined && /[A-Za-z0-9_]/.test(c);
|
|
51
|
+
}
|
|
52
|
+
/** `src` with comments and string/char literal contents blanked; newlines are kept. */
|
|
53
|
+
function rustCodeOnly(src) {
|
|
54
|
+
const out = [];
|
|
55
|
+
const blank = (from, to) => {
|
|
56
|
+
for (let k = from; k < to; k++)
|
|
57
|
+
out.push(src[k] === '\n' ? '\n' : ' ');
|
|
58
|
+
};
|
|
59
|
+
let i = 0;
|
|
60
|
+
const n = src.length;
|
|
61
|
+
while (i < n) {
|
|
62
|
+
const c = src[i];
|
|
63
|
+
const d = src[i + 1];
|
|
64
|
+
if (c === '/' && d === '/') {
|
|
65
|
+
const end = src.indexOf('\n', i);
|
|
66
|
+
const stop = end === -1 ? n : end;
|
|
67
|
+
blank(i, stop);
|
|
68
|
+
i = stop;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (c === '/' && d === '*') {
|
|
72
|
+
let depth = 0;
|
|
73
|
+
let j = i;
|
|
74
|
+
while (j < n) {
|
|
75
|
+
if (src[j] === '/' && src[j + 1] === '*') {
|
|
76
|
+
depth++;
|
|
77
|
+
j += 2;
|
|
78
|
+
}
|
|
79
|
+
else if (src[j] === '*' && src[j + 1] === '/') {
|
|
80
|
+
depth--;
|
|
81
|
+
j += 2;
|
|
82
|
+
if (depth === 0)
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
j++;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
blank(i, j);
|
|
90
|
+
i = j;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const prefixed = !isIdentChar(src[i - 1]);
|
|
94
|
+
if (prefixed && (c === 'r' || ((c === 'b' || c === 'c') && d === 'r'))) {
|
|
95
|
+
let j = c === 'r' ? i + 1 : i + 2;
|
|
96
|
+
let hashes = 0;
|
|
97
|
+
while (src[j] === '#') {
|
|
98
|
+
hashes++;
|
|
99
|
+
j++;
|
|
100
|
+
}
|
|
101
|
+
if (src[j] === '"') {
|
|
102
|
+
const close = `"${'#'.repeat(hashes)}`;
|
|
103
|
+
const end = src.indexOf(close, j + 1);
|
|
104
|
+
if (end !== -1) {
|
|
105
|
+
blank(i, end + close.length);
|
|
106
|
+
i = end + close.length;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (c === '"') {
|
|
112
|
+
let j = i + 1;
|
|
113
|
+
while (j < n && src[j] !== '"')
|
|
114
|
+
j += src[j] === '\\' ? 2 : 1;
|
|
115
|
+
blank(i, Math.min(n, j + 1));
|
|
116
|
+
i = j + 1;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (c === "'") {
|
|
120
|
+
let j = -1;
|
|
121
|
+
if (d === '\\') {
|
|
122
|
+
const e = src[i + 2];
|
|
123
|
+
const k = e === 'u' ? src.indexOf('}', i + 2) + 1 : e === 'x' ? i + 5 : i + 3;
|
|
124
|
+
if (k > 0 && src[k] === "'")
|
|
125
|
+
j = k;
|
|
126
|
+
}
|
|
127
|
+
else if (src[i + 2] === "'") {
|
|
128
|
+
j = i + 2;
|
|
129
|
+
}
|
|
130
|
+
if (j !== -1) {
|
|
131
|
+
blank(i, j + 1);
|
|
132
|
+
i = j + 1;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
out.push(c);
|
|
137
|
+
i++;
|
|
138
|
+
}
|
|
139
|
+
return out.join('');
|
|
140
|
+
}
|
|
141
|
+
/** Throws with the file and line of the first construct that could read the build host. */
|
|
142
|
+
function assertNoHostReads(file, src) {
|
|
143
|
+
const code = rustCodeOnly(src);
|
|
144
|
+
const lineOf = (at) => code.slice(0, at).split('\n').length;
|
|
145
|
+
const ident = BANNED_IDENT.exec(code);
|
|
146
|
+
if (ident) {
|
|
147
|
+
throw new Error(`${file}:${lineOf(ident.index)}: \`${ident[1]}\` is not allowed in platform builds (it can read the build host at compile time)`);
|
|
148
|
+
}
|
|
149
|
+
for (const m of code.matchAll(ATTR_START)) {
|
|
150
|
+
const open = m.index + m[0].length - 1;
|
|
151
|
+
const body = code.slice(open, closing(code, open) + 1);
|
|
152
|
+
const attr = BANNED_ATTR.exec(body);
|
|
153
|
+
if (attr) {
|
|
154
|
+
throw new Error(`${file}:${lineOf(m.index)}: the #[${attr[1]}] attribute is not allowed in platform builds`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
for (const m of code.matchAll(MACRO_RULES)) {
|
|
158
|
+
const open = m.index + m[0].length - 1;
|
|
159
|
+
const body = code.slice(open, closing(code, open) + 1);
|
|
160
|
+
const decl = OUT_OF_LINE_MOD.exec(body);
|
|
161
|
+
if (decl) {
|
|
162
|
+
throw new Error(`${file}:${lineOf(open + decl.index)}: a macro may not declare an out-of-line module (\`mod name;\`); declare it outside the macro`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|