@celilo/cli 5.2.1 → 5.2.2
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/package.json
CHANGED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { chmodSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { inspectArtifactFile, verifyBuildArtifacts } from './artifact-bytes';
|
|
6
|
+
|
|
7
|
+
const ELF = Buffer.from([0x7f, 0x45, 0x4c, 0x46]);
|
|
8
|
+
|
|
9
|
+
function dir(): string {
|
|
10
|
+
return mkdtempSync(join(tmpdir(), 'artifact-bytes-'));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function write(base: string, rel: string, body: Buffer, exec = false): string {
|
|
14
|
+
const full = join(base, rel);
|
|
15
|
+
mkdirSync(join(full, '..'), { recursive: true });
|
|
16
|
+
writeFileSync(full, body);
|
|
17
|
+
if (exec) chmodSync(full, 0o755);
|
|
18
|
+
return full;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('inspectArtifactFile', () => {
|
|
22
|
+
test('rejects a right-sized file of pure NUL — the celilo#1463 shape', () => {
|
|
23
|
+
const d = dir();
|
|
24
|
+
const p = write(d, 'server-linux-x86_64', Buffer.alloc(4096), true);
|
|
25
|
+
expect(inspectArtifactFile(p)).toMatch(/4096 bytes of pure NUL/);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('rejects an empty file', () => {
|
|
29
|
+
const d = dir();
|
|
30
|
+
expect(inspectArtifactFile(write(d, 'bin', Buffer.alloc(0), true))).toMatch(/empty/);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('accepts a real ELF', () => {
|
|
34
|
+
const d = dir();
|
|
35
|
+
const p = write(d, 'bin', Buffer.concat([ELF, Buffer.alloc(1024, 0x41)]), true);
|
|
36
|
+
expect(inspectArtifactFile(p)).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('rejects an executable carrying no executable magic', () => {
|
|
40
|
+
const d = dir();
|
|
41
|
+
const p = write(d, 'bin', Buffer.from('error: build failed\n'), true);
|
|
42
|
+
expect(inspectArtifactFile(p)).toMatch(/no known executable magic/);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('does not apply the magic check to non-executable artifacts', () => {
|
|
46
|
+
// Modules declare .xz tarballs, .sha256 sidecars and index.html as
|
|
47
|
+
// artifacts. None is a program and none may be rejected as one.
|
|
48
|
+
const d = dir();
|
|
49
|
+
expect(inspectArtifactFile(write(d, 'forgejo.xz.sha256', Buffer.from('abc f\n')))).toBeNull();
|
|
50
|
+
expect(inspectArtifactFile(write(d, 'index.html', Buffer.from('<!doctype html>')))).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('a NUL file is rejected even when it is not executable', () => {
|
|
54
|
+
const d = dir();
|
|
55
|
+
expect(inspectArtifactFile(write(d, 'forgejo.xz', Buffer.alloc(64)))).toMatch(/pure NUL/);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('a file whose NUL run ends late is still accepted', () => {
|
|
59
|
+
// Guards the streaming scan: content past the first chunk must be seen.
|
|
60
|
+
const d = dir();
|
|
61
|
+
const body = Buffer.alloc((1 << 20) + 16);
|
|
62
|
+
body[body.length - 1] = 0x41;
|
|
63
|
+
expect(inspectArtifactFile(write(d, 'blob', body))).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('verifyBuildArtifacts', () => {
|
|
68
|
+
test('names the module, the artifact and its size', () => {
|
|
69
|
+
const d = dir();
|
|
70
|
+
write(d, 'ansible/roles/r/files/srv-linux-x86_64', Buffer.alloc(106129743 % 8192), true);
|
|
71
|
+
const reason = verifyBuildArtifacts(d, ['ansible/roles/r/files/srv-linux-x86_64'], 'dj-live');
|
|
72
|
+
expect(reason).toContain('dj-live');
|
|
73
|
+
expect(reason).toContain('ansible/roles/r/files/srv-linux-x86_64');
|
|
74
|
+
expect(reason).toMatch(/bytes of pure NUL/);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('passes a sound set', () => {
|
|
78
|
+
const d = dir();
|
|
79
|
+
write(d, 'files/a', Buffer.concat([ELF, Buffer.alloc(16, 1)]), true);
|
|
80
|
+
write(d, 'files/b.sha256', Buffer.from('deadbeef\n'));
|
|
81
|
+
expect(verifyBuildArtifacts(d, ['files/a', 'files/b.sha256'], 'm')).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('walks a declared directory artifact', () => {
|
|
85
|
+
const d = dir();
|
|
86
|
+
write(d, 'spa/index.html', Buffer.from('<!doctype html>'));
|
|
87
|
+
write(d, 'spa/assets/app.js', Buffer.alloc(32));
|
|
88
|
+
const reason = verifyBuildArtifacts(d, ['spa'], 'console');
|
|
89
|
+
expect(reason).toContain('assets/app.js');
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { closeSync, openSync, readSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { join, relative } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Executable magics, for a file the build marked executable.
|
|
6
|
+
*
|
|
7
|
+
* Only consulted when the artifact carries an exec bit, because
|
|
8
|
+
* `build.artifacts` is not a list of programs — modules declare `.xz`
|
|
9
|
+
* tarballs, a `.sha256` sidecar, an `index.html` and a whole `spa/`
|
|
10
|
+
* directory. A magic check applied to all of them would fail every module in
|
|
11
|
+
* the fleet; applied to the files the build itself chmod'd +x it is exactly
|
|
12
|
+
* the "is this runnable" question.
|
|
13
|
+
*/
|
|
14
|
+
const EXEC_MAGICS: readonly (readonly number[])[] = [
|
|
15
|
+
[0x7f, 0x45, 0x4c, 0x46], // ELF
|
|
16
|
+
[0xfe, 0xed, 0xfa, 0xce], // Mach-O 32
|
|
17
|
+
[0xfe, 0xed, 0xfa, 0xcf], // Mach-O 64
|
|
18
|
+
[0xce, 0xfa, 0xed, 0xfe], // Mach-O 32, byte-swapped
|
|
19
|
+
[0xcf, 0xfa, 0xed, 0xfe], // Mach-O 64, byte-swapped
|
|
20
|
+
[0xca, 0xfe, 0xba, 0xbe], // Mach-O universal
|
|
21
|
+
[0x4d, 0x5a], // PE/COFF "MZ"
|
|
22
|
+
[0x23, 0x21], // "#!" script
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
const CHUNK = 1 << 20;
|
|
26
|
+
|
|
27
|
+
/** Read up to `n` leading bytes without loading the file. */
|
|
28
|
+
function head(path: string, n: number): Buffer {
|
|
29
|
+
const fd = openSync(path, 'r');
|
|
30
|
+
try {
|
|
31
|
+
const buf = Buffer.alloc(n);
|
|
32
|
+
const read = readSync(fd, buf, 0, n, 0);
|
|
33
|
+
return buf.subarray(0, read);
|
|
34
|
+
} finally {
|
|
35
|
+
closeSync(fd);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* True when every byte of the file is NUL.
|
|
41
|
+
*
|
|
42
|
+
* Streams, and stops at the first non-zero byte — so a healthy artifact costs
|
|
43
|
+
* one 1 MiB read (its first byte is non-zero) and only a genuinely corrupt one
|
|
44
|
+
* is read through.
|
|
45
|
+
*/
|
|
46
|
+
function isAllNul(path: string, size: number): boolean {
|
|
47
|
+
const fd = openSync(path, 'r');
|
|
48
|
+
try {
|
|
49
|
+
const buf = Buffer.alloc(CHUNK);
|
|
50
|
+
for (let off = 0; off < size; ) {
|
|
51
|
+
const read = readSync(fd, buf, 0, CHUNK, off);
|
|
52
|
+
if (read === 0) break;
|
|
53
|
+
for (let i = 0; i < read; i++) {
|
|
54
|
+
if (buf[i] !== 0) return false;
|
|
55
|
+
}
|
|
56
|
+
off += read;
|
|
57
|
+
}
|
|
58
|
+
return true;
|
|
59
|
+
} finally {
|
|
60
|
+
closeSync(fd);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Inspect one artifact file's own bytes. Returns a reason, or null when sound.
|
|
66
|
+
*
|
|
67
|
+
* Takes the verdict from the bytes rather than from the exit code of whatever
|
|
68
|
+
* wrote them. celilo#1463: two 100 MiB module binaries arrived as right-sized
|
|
69
|
+
* files of pure NUL, and every stage downstream reported success — because
|
|
70
|
+
* every one of those stages hashes, signs and verifies the artifact it is
|
|
71
|
+
* given. A checksum computed over corruption certifies corruption. The first
|
|
72
|
+
* thing that noticed was systemd, on a production host, with `Exec format
|
|
73
|
+
* error` and a restart counter.
|
|
74
|
+
*/
|
|
75
|
+
export function inspectArtifactFile(path: string): string | null {
|
|
76
|
+
const size = statSync(path).size;
|
|
77
|
+
if (size === 0) return 'file is empty (0 bytes)';
|
|
78
|
+
if (isAllNul(path, size)) return `file is ${size} bytes of pure NUL — nothing was written`;
|
|
79
|
+
|
|
80
|
+
// Only a file the build marked runnable has to look runnable.
|
|
81
|
+
const mode = statSync(path).mode;
|
|
82
|
+
if (!(mode & 0o111)) return null;
|
|
83
|
+
|
|
84
|
+
const magic = head(path, 4);
|
|
85
|
+
const known = EXEC_MAGICS.some((m) => m.every((b, i) => magic[i] === b));
|
|
86
|
+
if (!known) {
|
|
87
|
+
return `file is executable but carries no known executable magic (starts ${magic.toString('hex')}) — not a runnable program`;
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Gate every declared build artifact on its own bytes.
|
|
94
|
+
*
|
|
95
|
+
* Returns a human-readable failure naming module, artifact and size, or null
|
|
96
|
+
* when all artifacts are sound. A declared directory is walked, because a
|
|
97
|
+
* module can declare a built tree (`spa/`) and a NUL file inside one is as
|
|
98
|
+
* invisible as a NUL file beside it.
|
|
99
|
+
*/
|
|
100
|
+
export function verifyBuildArtifacts(
|
|
101
|
+
buildDir: string,
|
|
102
|
+
artifacts: readonly string[],
|
|
103
|
+
moduleId: string,
|
|
104
|
+
): string | null {
|
|
105
|
+
const failures: string[] = [];
|
|
106
|
+
|
|
107
|
+
const check = (fullPath: string, label: string): void => {
|
|
108
|
+
const reason = inspectArtifactFile(fullPath);
|
|
109
|
+
if (reason) failures.push(` ${label}: ${reason}`);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// `root` stays the artifact's own directory so a nested file reports its
|
|
113
|
+
// whole path under the artifact, not just its basename.
|
|
114
|
+
const walk = (dir: string, root: string, artifact: string): void => {
|
|
115
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
116
|
+
const full = join(dir, entry.name);
|
|
117
|
+
if (entry.isDirectory()) walk(full, root, artifact);
|
|
118
|
+
else if (entry.isFile()) check(full, join(artifact, relative(root, full)));
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
for (const artifact of artifacts) {
|
|
123
|
+
const full = join(buildDir, artifact);
|
|
124
|
+
if (statSync(full).isDirectory()) walk(full, full, artifact);
|
|
125
|
+
else check(full, artifact);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (failures.length === 0) return null;
|
|
129
|
+
return [
|
|
130
|
+
`Refusing to package ${moduleId}: the build reported success but produced an unusable artifact.`,
|
|
131
|
+
...failures,
|
|
132
|
+
'',
|
|
133
|
+
"The verdict is taken from the artifact's own bytes, not from the build command's exit code. Every check downstream of here — checksums, signature, the registry sha256 — would hash this faithfully and certify it (celilo#1463).",
|
|
134
|
+
].join('\n');
|
|
135
|
+
}
|
|
@@ -9,6 +9,7 @@ import { parse as parseYaml } from 'yaml';
|
|
|
9
9
|
import { log } from '../../cli/prompts';
|
|
10
10
|
import { formatViolations, scanModuleDirectory } from '../../policy/module-script-scan';
|
|
11
11
|
import { validateModuleDirectory } from '../import';
|
|
12
|
+
import { verifyBuildArtifacts } from './artifact-bytes';
|
|
12
13
|
import { resolveBuildCommandPaths } from './build-paths';
|
|
13
14
|
import { computeFileChecksum } from './checksum';
|
|
14
15
|
import { classifyModulePath, includeNodeModulesPath } from './package-rules';
|
|
@@ -385,7 +386,14 @@ export async function buildModule(options: ModuleBuildOptions): Promise<ModuleBu
|
|
|
385
386
|
return { success: false, error: `Auto-build failed: Build exited with code ${msg}` };
|
|
386
387
|
}
|
|
387
388
|
|
|
388
|
-
// Verify artifacts exist
|
|
389
|
+
// Verify artifacts exist, and that they are actually usable.
|
|
390
|
+
//
|
|
391
|
+
// Existence was the whole check, and existence is exactly what a
|
|
392
|
+
// right-sized file of pure NUL has. celilo#1463: two ~100 MiB module
|
|
393
|
+
// binaries were packaged, signed, published, downloaded, verified and
|
|
394
|
+
// installed as all-zero files, and every stage reported success, because
|
|
395
|
+
// this is the last point in the pipeline that sees the artifact before it
|
|
396
|
+
// becomes the thing all those checks are computed OVER.
|
|
389
397
|
if (manifest.build.artifacts) {
|
|
390
398
|
const missing = manifest.build.artifacts.filter(
|
|
391
399
|
(a: string) => !existsSync(join(buildDir, a)),
|
|
@@ -396,6 +404,10 @@ export async function buildModule(options: ModuleBuildOptions): Promise<ModuleBu
|
|
|
396
404
|
error: `Build succeeded but artifacts missing: ${missing.join(', ')}`,
|
|
397
405
|
};
|
|
398
406
|
}
|
|
407
|
+
const unusable = verifyBuildArtifacts(buildDir, manifest.build.artifacts, moduleId);
|
|
408
|
+
if (unusable) {
|
|
409
|
+
return { success: false, error: unusable };
|
|
410
|
+
}
|
|
399
411
|
}
|
|
400
412
|
}
|
|
401
413
|
|