@celilo/e2e 0.9.3 → 0.10.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.
Files changed (62) hide show
  1. package/config/cpanel-host/cpanel-host-ca.crt +20 -0
  2. package/config/cpanel-host/docroot/.htaccess +14 -0
  3. package/config/cpanel-host/docroot/cms.html +4 -0
  4. package/config/cpanel-host/site-tls.crt +20 -0
  5. package/config/cpanel-host/site-tls.key +28 -0
  6. package/config/cpanel-host/site.conf +27 -0
  7. package/config/dns/com.zone +7 -0
  8. package/config/dns/knot-namecheap.conf +6 -0
  9. package/config/dns/tangohost.com.zone +14 -0
  10. package/config/resolver/unbound.conf +26 -0
  11. package/config/routing/fw-ext-routes.sh +16 -1
  12. package/config/routing/management-routes.sh +14 -4
  13. package/config/routing/minio-startup.sh +9 -0
  14. package/config/routing/public-sim-entrypoint.sh +24 -0
  15. package/config/routing/resolver-internal-routes.sh +10 -0
  16. package/docker/Dockerfile.apt-repo-sim +8 -0
  17. package/docker/Dockerfile.cpanel-host-sim +75 -0
  18. package/docker/Dockerfile.firewall +12 -0
  19. package/docker/Dockerfile.management +6 -0
  20. package/docker/Dockerfile.minio +7 -0
  21. package/docker/Dockerfile.npm-registry-sim +8 -0
  22. package/docker/Dockerfile.observer +11 -0
  23. package/docker/Dockerfile.registry +8 -0
  24. package/docker/Dockerfile.signal-cli +60 -0
  25. package/docker/Dockerfile.signal-release +44 -0
  26. package/docker/Dockerfile.signal-sim +17 -0
  27. package/docker/Dockerfile.target-machine +9 -0
  28. package/package.json +3 -3
  29. package/registry-server/package.json +17 -0
  30. package/registry-server/src/auth.test.ts +76 -0
  31. package/registry-server/src/auth.ts +105 -0
  32. package/registry-server/src/bootstrap-packaging.test.ts +71 -0
  33. package/registry-server/src/bootstrap.ts +250 -0
  34. package/registry-server/src/index.ts +16 -0
  35. package/registry-server/src/introspection.test.ts +247 -0
  36. package/registry-server/src/introspection.ts +204 -0
  37. package/registry-server/src/landing.ts +90 -0
  38. package/registry-server/src/module-owner-store.test.ts +85 -0
  39. package/registry-server/src/module-owner-store.ts +108 -0
  40. package/registry-server/src/rate-limit.test.ts +62 -0
  41. package/registry-server/src/rate-limit.ts +83 -0
  42. package/registry-server/src/scoped-token-store.test.ts +93 -0
  43. package/registry-server/src/scoped-token-store.ts +110 -0
  44. package/registry-server/src/server.test.ts +771 -0
  45. package/registry-server/src/server.ts +701 -0
  46. package/registry-server/src/storage.test.ts +148 -0
  47. package/registry-server/src/storage.ts +150 -0
  48. package/registry-server/src/validation.test.ts +86 -0
  49. package/registry-server/src/validation.ts +60 -0
  50. package/registry-server/tsconfig.json +15 -0
  51. package/scripts/stage-libsignal.ts +116 -0
  52. package/simulators/signal-cli/server.ts +328 -0
  53. package/src/cli/build.ts +7 -0
  54. package/src/cli/index.ts +6 -0
  55. package/src/container-manager.ts +47 -10
  56. package/src/docker-compose-generator.ts +120 -16
  57. package/src/exit-cleanup.test.ts +56 -0
  58. package/src/index.ts +7 -0
  59. package/src/network-builder.ts +66 -0
  60. package/src/public-sim-routes.test.ts +117 -0
  61. package/src/simulator-ips.ts +9 -0
  62. package/src/types.ts +71 -1
@@ -0,0 +1,148 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
+ import { existsSync, mkdtempSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import type { IndexEntry } from './storage';
6
+ import { RegistryStorage } from './storage';
7
+
8
+ let dataDir: string;
9
+ let storage: RegistryStorage;
10
+
11
+ beforeEach(() => {
12
+ dataDir = mkdtempSync(join(tmpdir(), 'celilo-registry-test-'));
13
+ storage = new RegistryStorage(dataDir);
14
+ });
15
+
16
+ afterEach(() => {
17
+ rmSync(dataDir, { recursive: true, force: true });
18
+ });
19
+
20
+ // ── indexPath ─────────────────────────────────────────────────────────────────
21
+
22
+ describe('RegistryStorage.indexPath', () => {
23
+ test('1-char name → index/1/{name}', () => {
24
+ expect(storage.indexPath('a')).toBe(join(dataDir, 'index', '1', 'a'));
25
+ });
26
+
27
+ test('2-char name → index/2/{name}', () => {
28
+ expect(storage.indexPath('ab')).toBe(join(dataDir, 'index', '2', 'ab'));
29
+ });
30
+
31
+ test('3-char name → index/3/{first2}/{name}', () => {
32
+ expect(storage.indexPath('abc')).toBe(join(dataDir, 'index', '3', 'ab', 'abc'));
33
+ });
34
+
35
+ test('4-char name → index/{01}/{23}/{name}', () => {
36
+ expect(storage.indexPath('abcd')).toBe(join(dataDir, 'index', 'ab', 'cd', 'abcd'));
37
+ });
38
+
39
+ test('long name uses first 4 chars for directories', () => {
40
+ expect(storage.indexPath('homebridge')).toBe(
41
+ join(dataDir, 'index', 'ho', 'me', 'homebridge'),
42
+ );
43
+ });
44
+
45
+ test('real module names produce expected paths', () => {
46
+ expect(storage.indexPath('caddy')).toBe(join(dataDir, 'index', 'ca', 'dd', 'caddy'));
47
+ expect(storage.indexPath('celilo-registry')).toBe(
48
+ join(dataDir, 'index', 'ce', 'li', 'celilo-registry'),
49
+ );
50
+ });
51
+ });
52
+
53
+ // ── readIndex / appendIndex ───────────────────────────────────────────────────
54
+
55
+ describe('RegistryStorage index read/write', () => {
56
+ function entry(vers: string, yanked = false): IndexEntry {
57
+ return { name: 'homebridge', vers, deps: [], cksum: 'abc123', yanked };
58
+ }
59
+
60
+ test('readIndex returns empty array for unknown module', () => {
61
+ expect(storage.readIndex('nonexistent')).toEqual([]);
62
+ });
63
+
64
+ test('appendIndex then readIndex round-trips entries', () => {
65
+ storage.appendIndex(entry('1.0.0+1'));
66
+ storage.appendIndex(entry('1.0.0+2'));
67
+ const result = storage.readIndex('homebridge');
68
+ expect(result).toHaveLength(2);
69
+ expect(result[0].vers).toBe('1.0.0+1');
70
+ expect(result[1].vers).toBe('1.0.0+2');
71
+ });
72
+
73
+ test('updateIndex replaces all entries', () => {
74
+ storage.appendIndex(entry('1.0.0+1'));
75
+ storage.appendIndex(entry('1.0.0+2'));
76
+ storage.updateIndex('homebridge', [entry('1.0.0+2', true), entry('1.0.0+3')]);
77
+ const result = storage.readIndex('homebridge');
78
+ expect(result).toHaveLength(2);
79
+ expect(result[0].yanked).toBe(true);
80
+ expect(result[1].vers).toBe('1.0.0+3');
81
+ });
82
+
83
+ test('appendIndex creates parent directories', () => {
84
+ storage.appendIndex(entry('1.0.0+1'));
85
+ expect(existsSync(storage.indexPath('homebridge'))).toBe(true);
86
+ });
87
+ });
88
+
89
+ // ── storePackage / readPackage ────────────────────────────────────────────────
90
+
91
+ describe('RegistryStorage package store/read', () => {
92
+ test('storePackage returns sha256 hex', () => {
93
+ const data = Buffer.from('fake netapp bytes');
94
+ const cksum = storage.storePackage('homebridge', '1.0.0+1', data);
95
+ expect(cksum).toMatch(/^[0-9a-f]{64}$/);
96
+ });
97
+
98
+ test('packageExists returns false before store, true after', () => {
99
+ expect(storage.packageExists('homebridge', '1.0.0+1')).toBe(false);
100
+ storage.storePackage('homebridge', '1.0.0+1', Buffer.from('data'));
101
+ expect(storage.packageExists('homebridge', '1.0.0+1')).toBe(true);
102
+ });
103
+
104
+ test('readPackage returns stored bytes', () => {
105
+ const data = Buffer.from('fake netapp bytes');
106
+ storage.storePackage('homebridge', '1.0.0+1', data);
107
+ const result = storage.readPackage('homebridge', '1.0.0+1');
108
+ expect(result?.toString()).toBe('fake netapp bytes');
109
+ });
110
+
111
+ test('readPackage returns null for missing version', () => {
112
+ expect(storage.readPackage('homebridge', '9.9.9+1')).toBeNull();
113
+ });
114
+
115
+ test('sha256 is consistent with stored data', () => {
116
+ const data = Buffer.from('deterministic content');
117
+ const cksum = storage.storePackage('homebridge', '1.0.0+1', data);
118
+ expect(cksum).toBe(storage.sha256(data));
119
+ });
120
+ });
121
+
122
+ // ── listModules / getModule ───────────────────────────────────────────────────
123
+
124
+ describe('RegistryStorage listModules / getModule', () => {
125
+ test('listModules returns empty when no modules stored', () => {
126
+ expect(storage.listModules()).toEqual([]);
127
+ });
128
+
129
+ test('listModules returns stored modules with their index entries', () => {
130
+ storage.storePackage('homebridge', '1.0.0+1', Buffer.from('a'));
131
+ storage.appendIndex({ name: 'homebridge', vers: '1.0.0+1', deps: [], cksum: 'x', yanked: false });
132
+ const list = storage.listModules();
133
+ expect(list).toHaveLength(1);
134
+ expect(list[0].name).toBe('homebridge');
135
+ expect(list[0].versions).toHaveLength(1);
136
+ });
137
+
138
+ test('getModule returns null for unknown module', () => {
139
+ expect(storage.getModule('nonexistent')).toBeNull();
140
+ });
141
+
142
+ test('getModule returns module with versions when index has entries', () => {
143
+ storage.appendIndex({ name: 'caddy', vers: '2.0.0+1', deps: [], cksum: 'y', yanked: false });
144
+ const mod = storage.getModule('caddy');
145
+ expect(mod?.name).toBe('caddy');
146
+ expect(mod?.versions).toHaveLength(1);
147
+ });
148
+ });
@@ -0,0 +1,150 @@
1
+ /**
2
+ * File-backed storage for the registry.
3
+ *
4
+ * Layout under DATA_DIR:
5
+ * modules/{name}/{version}/{name}-{version}.netapp — package files
6
+ * index/{1,2,ab/cd}/{name} — sparse index files (one JSON line per version)
7
+ * downloads/{name} — per-module download counter (single integer)
8
+ */
9
+
10
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
11
+ import { createHash } from 'node:crypto';
12
+ import { dirname, join } from 'node:path';
13
+
14
+ export interface IndexEntry {
15
+ name: string;
16
+ vers: string;
17
+ deps: string[];
18
+ cksum: string;
19
+ yanked: boolean;
20
+ /**
21
+ * One-line summary from the module's `manifest.yml#description`,
22
+ * captured at publish time. Optional because pre-Phase-2 published
23
+ * modules have no description recorded and need a republish to
24
+ * backfill — see apps/celilo/designs/REGISTRY_BROWSE_UI.md (Phase 2
25
+ * step 0). Old index files without this field still parse cleanly.
26
+ */
27
+ description?: string;
28
+ }
29
+
30
+ export interface ModuleVersion {
31
+ name: string;
32
+ vers: string;
33
+ cksum: string;
34
+ yanked: boolean;
35
+ uploadedAt: string;
36
+ }
37
+
38
+ export class RegistryStorage {
39
+ private readonly dataDir: string;
40
+
41
+ constructor(dataDir: string) {
42
+ this.dataDir = dataDir;
43
+ mkdirSync(join(dataDir, 'modules'), { recursive: true });
44
+ mkdirSync(join(dataDir, 'index'), { recursive: true });
45
+ }
46
+
47
+ // Returns Cargo-style index path for a module name.
48
+ // 1-char names → index/1/{name}
49
+ // 2-char names → index/2/{name}
50
+ // 3-char names → index/3/{first-two-chars}/{name}
51
+ // 4+ char names → index/{chars 0-1}/{chars 2-3}/{name}
52
+ indexPath(name: string): string {
53
+ if (name.length === 1) return join(this.dataDir, 'index', '1', name);
54
+ if (name.length === 2) return join(this.dataDir, 'index', '2', name);
55
+ if (name.length === 3) return join(this.dataDir, 'index', '3', name.slice(0, 2), name);
56
+ return join(this.dataDir, 'index', name.slice(0, 2), name.slice(2, 4), name);
57
+ }
58
+
59
+ packagePath(name: string, version: string): string {
60
+ return join(this.dataDir, 'modules', name, version, `${name}-${version}.netapp`);
61
+ }
62
+
63
+ sha256(data: Buffer): string {
64
+ return createHash('sha256').update(data).digest('hex');
65
+ }
66
+
67
+ readIndex(name: string): IndexEntry[] {
68
+ const path = this.indexPath(name);
69
+ if (!existsSync(path)) return [];
70
+ return readFileSync(path, 'utf-8')
71
+ .split('\n')
72
+ .filter(Boolean)
73
+ .map((line) => JSON.parse(line) as IndexEntry);
74
+ }
75
+
76
+ appendIndex(entry: IndexEntry): void {
77
+ const path = this.indexPath(entry.name);
78
+ mkdirSync(dirname(path), { recursive: true });
79
+ writeFileSync(path, `${JSON.stringify(entry)}\n`, { flag: 'a' });
80
+ }
81
+
82
+ updateIndex(name: string, entries: IndexEntry[]): void {
83
+ const path = this.indexPath(name);
84
+ mkdirSync(dirname(path), { recursive: true });
85
+ writeFileSync(path, `${entries.map((e) => JSON.stringify(e)).join('\n')}\n`);
86
+ }
87
+
88
+ storePackage(name: string, version: string, data: Buffer): string {
89
+ const path = this.packagePath(name, version);
90
+ mkdirSync(dirname(path), { recursive: true });
91
+ writeFileSync(path, data);
92
+ return this.sha256(data);
93
+ }
94
+
95
+ packageExists(name: string, version: string): boolean {
96
+ return existsSync(this.packagePath(name, version));
97
+ }
98
+
99
+ readPackage(name: string, version: string): Buffer | null {
100
+ const path = this.packagePath(name, version);
101
+ return existsSync(path) ? readFileSync(path) : null;
102
+ }
103
+
104
+ listModules(): Array<{ name: string; versions: IndexEntry[] }> {
105
+ const modulesDir = join(this.dataDir, 'modules');
106
+ if (!existsSync(modulesDir)) return [];
107
+ const names = readdirSync(modulesDir, { withFileTypes: true })
108
+ .filter((d) => d.isDirectory())
109
+ .map((d) => d.name);
110
+ return names.map((name) => ({ name, versions: this.readIndex(name) }));
111
+ }
112
+
113
+ getModule(name: string): { name: string; versions: IndexEntry[] } | null {
114
+ const versions = this.readIndex(name);
115
+ return versions.length > 0 ? { name, versions } : null;
116
+ }
117
+
118
+ /**
119
+ * Per-module download counter. Stored as a single integer in
120
+ * `<dataDir>/downloads/<name>`. Read-modify-write, NOT atomic
121
+ * across concurrent processes — a lost increment under contention
122
+ * is acceptable for a low-traffic registry. If we ever need real
123
+ * concurrency safety this is the place to swap in fcntl locks or
124
+ * a sqlite counter.
125
+ *
126
+ * Validation: `name` is checked by the caller (server.ts uses
127
+ * `isValidName` before reaching this layer); we still constrain
128
+ * the path lookup to the downloads/ subdirectory as defense in
129
+ * depth against path traversal in `name`.
130
+ */
131
+ private downloadsPath(name: string): string {
132
+ return join(this.dataDir, 'downloads', name);
133
+ }
134
+
135
+ getDownloads(name: string): number {
136
+ const path = this.downloadsPath(name);
137
+ if (!existsSync(path)) return 0;
138
+ const raw = readFileSync(path, 'utf-8').trim();
139
+ const n = Number.parseInt(raw, 10);
140
+ return Number.isFinite(n) && n >= 0 ? n : 0;
141
+ }
142
+
143
+ incrementDownloads(name: string): number {
144
+ const path = this.downloadsPath(name);
145
+ mkdirSync(dirname(path), { recursive: true });
146
+ const next = this.getDownloads(name) + 1;
147
+ writeFileSync(path, `${next}\n`);
148
+ return next;
149
+ }
150
+ }
@@ -0,0 +1,86 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { isValidName, isValidVersion, validateNameAndVersion } from './validation';
3
+
4
+ describe('isValidName', () => {
5
+ test.each(['homebridge', 'my-module', 'a', 'a1', 'dns-external', 'x-y-z-2'])(
6
+ 'accepts %s',
7
+ (name) => {
8
+ expect(isValidName(name)).toBe(true);
9
+ },
10
+ );
11
+
12
+ test.each([
13
+ ['empty', ''],
14
+ ['uppercase', 'Homebridge'],
15
+ ['underscore', 'my_module'],
16
+ ['leading hyphen', '-foo'],
17
+ ['trailing hyphen', 'foo-'],
18
+ ['double hyphen', 'foo--bar'],
19
+ ['space', 'foo bar'],
20
+ ['path traversal', '../etc'],
21
+ ['slash', 'foo/bar'],
22
+ ['dot', 'foo.bar'],
23
+ ['null byte', 'foo\0bar'],
24
+ ['newline', 'foo\nbar'],
25
+ ['carriage return', 'foo\rbar'],
26
+ ['URL-encoded slash after decode would be', '..%2F..'],
27
+ ['over 128 chars', `${'a'.repeat(129)}`],
28
+ ])('rejects %s', (_label, name) => {
29
+ expect(isValidName(name)).toBe(false);
30
+ });
31
+ });
32
+
33
+ describe('isValidVersion', () => {
34
+ test.each(['1.0.0+1', '1.4.2+1', '0.0.1+99', '1.5.0+1', '2:1.0.0+1', '0.0.0+1'])(
35
+ 'accepts %s',
36
+ (vers) => {
37
+ expect(isValidVersion(vers)).toBe(true);
38
+ },
39
+ );
40
+
41
+ test.each([
42
+ ['empty', ''],
43
+ ['no pkgrev', '1.0.0'],
44
+ ['pkgrev not numeric', '1.0.0+abc'],
45
+ ['semver not numeric', 'a.b.c+1'],
46
+ ['hyphen pkgrev', '1.0.0-1'],
47
+ ['path traversal', '../../etc'],
48
+ ['slash', '1.0.0+1/foo'],
49
+ ['dot-dot', '1.0.0+1/..'],
50
+ ['null byte', '1.0.0+1\0'],
51
+ ['newline', '1.0.0+1\n'],
52
+ ['space', '1.0.0 +1'],
53
+ ['extra dot', '1.0.0.0+1'],
54
+ ['negative pkgrev', '1.0.0+-1'],
55
+ ['four parts', '1.2.3.4+1'],
56
+ ['pkgrev missing', '1.0.0+'],
57
+ ['URL-encoded bad version', '..%2F..%2Fetc'],
58
+ ['over 128 chars', `${'1'.repeat(129)}.0.0+1`],
59
+ ])('rejects %s', (_label, vers) => {
60
+ expect(isValidVersion(vers)).toBe(false);
61
+ });
62
+ });
63
+
64
+ describe('validateNameAndVersion', () => {
65
+ test('accepts a matched valid pair', () => {
66
+ expect(validateNameAndVersion('homebridge', '1.4.2+1')).toEqual({ ok: true });
67
+ });
68
+
69
+ test('flags the name field when bad', () => {
70
+ const r = validateNameAndVersion('../etc', '1.0.0+1');
71
+ expect(r.ok).toBe(false);
72
+ if (!r.ok) expect(r.field).toBe('name');
73
+ });
74
+
75
+ test('flags the vers field when bad', () => {
76
+ const r = validateNameAndVersion('homebridge', '../etc');
77
+ expect(r.ok).toBe(false);
78
+ if (!r.ok) expect(r.field).toBe('vers');
79
+ });
80
+
81
+ test('reports name first when both are bad (fail-fast)', () => {
82
+ const r = validateNameAndVersion('../etc', '1.0.0');
83
+ expect(r.ok).toBe(false);
84
+ if (!r.ok) expect(r.field).toBe('name');
85
+ });
86
+ });
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Input validation for every user-supplied identifier that ends up on the
3
+ * filesystem path. These two are the ONLY allowed shapes for module names
4
+ * and versions on this registry; both must be enforced at every public
5
+ * entry point in server.ts (publish, download, metadata, yank, unyank,
6
+ * sparse index) because `storage.packagePath()` / `storage.indexPath()`
7
+ * feed their inputs straight to `node:path.join`, which happily normalizes
8
+ * `..` segments and escapes the data directory.
9
+ *
10
+ * Keep these as pure regex predicates so there is no way to accidentally
11
+ * skip them — if the string passes, the path is guaranteed to contain
12
+ * only safe chars (lowercase alphanum + `-` / digits + `.` / `+`). No
13
+ * slashes, no `..`, no control chars.
14
+ */
15
+
16
+ /** Module names: kebab-case lowercase, no leading/trailing hyphen. */
17
+ const NAME_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/;
18
+
19
+ /**
20
+ * Module versions: optional Debian-style epoch (`N:`), then semver
21
+ * (major.minor.patch), then a literal `+`, then a numeric pkg revision.
22
+ * Examples: `1.4.2+1`, `1.0.0+12`, `2:1.0.0+1` (epoch bump).
23
+ * Rejects: `1.0.0+abc`, `1.0.0+1/../etc`, `1.0.0 +1`, empty, traversal.
24
+ */
25
+ const VERSION_REGEX = /^(\d+:)?\d+\.\d+\.\d+\+\d+$/;
26
+
27
+ /** Max bytes we'll accept for either identifier — far beyond any real use. */
28
+ const MAX_IDENT_LENGTH = 128;
29
+
30
+ export function isValidName(name: string): boolean {
31
+ return name.length > 0 && name.length <= MAX_IDENT_LENGTH && NAME_REGEX.test(name);
32
+ }
33
+
34
+ export function isValidVersion(vers: string): boolean {
35
+ return vers.length > 0 && vers.length <= MAX_IDENT_LENGTH && VERSION_REGEX.test(vers);
36
+ }
37
+
38
+ /** Throw-style version for handler call sites — returns the offending field name. */
39
+ export function validateNameAndVersion(
40
+ name: string,
41
+ vers: string,
42
+ ): { ok: true } | { ok: false; field: 'name' | 'vers'; message: string } {
43
+ if (!isValidName(name)) {
44
+ return {
45
+ ok: false,
46
+ field: 'name',
47
+ message:
48
+ 'Invalid module name — must be kebab-case lowercase, 1-128 chars (e.g. "my-module")',
49
+ };
50
+ }
51
+ if (!isValidVersion(vers)) {
52
+ return {
53
+ ok: false,
54
+ field: 'vers',
55
+ message:
56
+ 'Invalid version — must match "<major>.<minor>.<patch>+<pkgrev>" (e.g. "1.4.2+1")',
57
+ };
58
+ }
59
+ return { ok: true };
60
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "skipLibCheck": true,
8
+ "types": ["bun-types"],
9
+ "allowImportingTsExtensions": true,
10
+ "noEmit": true,
11
+ "resolveJsonModule": true,
12
+ "isolatedModules": true
13
+ },
14
+ "include": ["src/**/*"]
15
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * stage-libsignal.ts — put the libsignal JNI native (linux-aarch64) into the
3
+ * apt-repo staging pool, building it if we don't already have it.
4
+ *
5
+ * WHY THIS IS BUILT ON THE DEV MACHINE AND NOT IN THE E2E NETWORK
6
+ *
7
+ * The signal module needs a native that upstream does not publish for
8
+ * linux-aarch64 (see modules/signal/build/). Compiling it takes minutes,
9
+ * pulls hundreds of crates and a BoringSSL submodule, and needs crates.io and
10
+ * GitHub — none of which exist inside the sealed e2e network, and all of which
11
+ * would blow a 5-minute test budget.
12
+ *
13
+ * So the compile happens HERE, at `cele2e build-infra` time, on the host's own
14
+ * network, using the module's own recipe. The deploy then just installs a
15
+ * .deb, which is exactly what it does in production. The e2e network stays
16
+ * sealed and the deploy test stays fast, while the recipe is still exercised
17
+ * for real on every rebuild rather than rotting untested.
18
+ *
19
+ * Cached by version: the compile only re-runs when the version changes or the
20
+ * cached .deb is deleted.
21
+ *
22
+ * Graceful no-op when Docker is unavailable or the build fails — build-infra
23
+ * still completes, and the signal deploy test is what fails loudly (with an
24
+ * empty repo) rather than breaking the build for everyone. Mirrors how
25
+ * stageAptRepo treats a missing nfpm.
26
+ */
27
+
28
+ import { spawnSync } from 'node:child_process';
29
+ import { copyFileSync, existsSync, mkdirSync, readdirSync } from 'node:fs';
30
+ import { join } from 'node:path';
31
+
32
+ const green = '\x1b[32m';
33
+ const yellow = '\x1b[33m';
34
+ const dim = '\x1b[2m';
35
+ const reset = '\x1b[0m';
36
+
37
+ /**
38
+ * Must match `modules/signal/manifest.yml`'s `libsignal_deb_url` default and
39
+ * `modules/signal/build/build.sh`. The build script verifies the pairing
40
+ * against the real signal-cli release, so a mismatch here fails the build
41
+ * loudly rather than producing a native that silently will not load.
42
+ */
43
+ const LIBSIGNAL_VERSION = '0.96.3';
44
+ const DEB_NAME = `libsignal-jni_${LIBSIGNAL_VERSION}_arm64.deb`;
45
+
46
+ export function stageLibsignal(repoRoot: string, pkgDir: string): boolean {
47
+ const buildDir = join(repoRoot, 'modules', 'signal', 'build');
48
+ if (!existsSync(buildDir)) return false;
49
+
50
+ // Only aarch64 needs this native at all — an x86_64 host uses the one already
51
+ // bundled in the libsignal-client JAR. Building it there would mean an
52
+ // emulated compile of something nothing would install.
53
+ if (process.arch !== 'arm64') {
54
+ console.log(
55
+ ` ${'libsignal-jni'.padEnd(28)} ${dim}skip (host is ${process.arch}; JAR ships that native)${reset}`,
56
+ );
57
+ return true;
58
+ }
59
+
60
+ const pool = join(pkgDir, '.apt-repo-cache', 'pool');
61
+ const cache = join(pkgDir, '.libsignal-cache');
62
+ mkdirSync(pool, { recursive: true });
63
+ mkdirSync(cache, { recursive: true });
64
+
65
+ const cached = join(cache, DEB_NAME);
66
+
67
+ if (!existsSync(cached)) {
68
+ process.stdout.write(` ${'libsignal-jni (build)'.padEnd(28)} `);
69
+ const t0 = Date.now();
70
+
71
+ const image = 'celilo-e2e/libsignal-builder';
72
+ const build = spawnSync(
73
+ 'docker',
74
+ ['build', '--platform', 'linux/arm64', '-q', '-t', image, buildDir],
75
+ { stdio: 'pipe' },
76
+ );
77
+ if (build.status !== 0) {
78
+ console.log(`${yellow}skip${reset} ${dim}(docker build failed)${reset}`);
79
+ return false;
80
+ }
81
+
82
+ // Named volumes keep the cargo registry and the checkout across runs, so a
83
+ // version bump is a few minutes rather than a fresh fetch of everything.
84
+ const run = spawnSync(
85
+ 'docker',
86
+ [
87
+ 'run',
88
+ '--rm',
89
+ '--platform',
90
+ 'linux/arm64',
91
+ '-v',
92
+ `${cache}:/out`,
93
+ '-v',
94
+ 'celilo-libsignal-work:/work',
95
+ '-v',
96
+ 'celilo-libsignal-cargo:/root/.cargo/registry',
97
+ image,
98
+ ],
99
+ { stdio: 'pipe' },
100
+ );
101
+ if (run.status !== 0 || !existsSync(cached)) {
102
+ console.log(`${yellow}skip${reset} ${dim}(build failed)${reset}`);
103
+ return false;
104
+ }
105
+ console.log(
106
+ `${green}✔${reset} ${dim}${Math.round((Date.now() - t0) / 1000)}s${reset}`,
107
+ );
108
+ }
109
+
110
+ process.stdout.write(` ${'libsignal-jni (stage)'.padEnd(28)} `);
111
+ for (const file of readdirSync(cache).filter((f) => f.endsWith('.deb'))) {
112
+ copyFileSync(join(cache, file), join(pool, file));
113
+ }
114
+ console.log(`${green}✔${reset} ${dim}${DEB_NAME}${reset}`);
115
+ return true;
116
+ }