@celilo/e2e 0.9.3 → 0.10.0
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/config/cpanel-host/cpanel-host-ca.crt +20 -0
- package/config/cpanel-host/docroot/.htaccess +14 -0
- package/config/cpanel-host/docroot/cms.html +4 -0
- package/config/cpanel-host/site-tls.crt +20 -0
- package/config/cpanel-host/site-tls.key +28 -0
- package/config/cpanel-host/site.conf +27 -0
- package/config/dns/com.zone +7 -0
- package/config/dns/knot-namecheap.conf +6 -0
- package/config/dns/tangohost.com.zone +14 -0
- package/config/resolver/unbound.conf +26 -0
- package/config/routing/management-routes.sh +9 -4
- package/config/routing/resolver-internal-routes.sh +10 -0
- package/docker/Dockerfile.cpanel-host-sim +70 -0
- package/docker/Dockerfile.firewall +12 -0
- package/docker/Dockerfile.management +6 -0
- package/docker/Dockerfile.observer +11 -0
- package/docker/Dockerfile.signal-cli +60 -0
- package/docker/Dockerfile.signal-release +36 -0
- package/docker/Dockerfile.signal-sim +17 -0
- package/docker/Dockerfile.target-machine +9 -0
- package/package.json +3 -3
- package/registry-server/package.json +17 -0
- package/registry-server/src/auth.test.ts +76 -0
- package/registry-server/src/auth.ts +105 -0
- package/registry-server/src/bootstrap-packaging.test.ts +71 -0
- package/registry-server/src/bootstrap.ts +246 -0
- package/registry-server/src/index.ts +16 -0
- package/registry-server/src/introspection.test.ts +247 -0
- package/registry-server/src/introspection.ts +204 -0
- package/registry-server/src/landing.ts +90 -0
- package/registry-server/src/module-owner-store.test.ts +85 -0
- package/registry-server/src/module-owner-store.ts +108 -0
- package/registry-server/src/rate-limit.test.ts +62 -0
- package/registry-server/src/rate-limit.ts +83 -0
- package/registry-server/src/scoped-token-store.test.ts +93 -0
- package/registry-server/src/scoped-token-store.ts +110 -0
- package/registry-server/src/server.test.ts +771 -0
- package/registry-server/src/server.ts +701 -0
- package/registry-server/src/storage.test.ts +148 -0
- package/registry-server/src/storage.ts +150 -0
- package/registry-server/src/validation.test.ts +86 -0
- package/registry-server/src/validation.ts +60 -0
- package/registry-server/tsconfig.json +15 -0
- package/scripts/stage-libsignal.ts +116 -0
- package/simulators/signal-cli/server.ts +272 -0
- package/src/cli/build.ts +7 -0
- package/src/cli/index.ts +6 -0
- package/src/container-manager.ts +5 -1
- package/src/docker-compose-generator.ts +108 -4
- package/src/network-builder.ts +52 -0
- package/src/simulator-ips.ts +9 -0
- package/src/types.ts +34 -0
|
@@ -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
|
+
}
|