@celilo/e2e 0.9.2-alpha.0 → 0.9.3
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/proxy/squid.conf +7 -2
- package/config/resolver/unbound.conf +13 -3
- package/docker/Dockerfile.minio +1 -1
- package/docker/Dockerfile.target-machine +1 -1
- package/docker/Dockerfile.target-machine-docker +1 -1
- package/docker/Dockerfile.target-technitium +1 -1
- package/package.json +4 -5
- package/scripts/pack-celilo-packages.ts +79 -51
- package/src/cli/build.ts +28 -9
- package/src/cli/command-registry.ts +34 -6
- package/src/cli/index.ts +53 -21
- package/src/container-manager.ts +1 -1
- package/src/runner.ts +128 -57
- package/registry-server/package.json +0 -17
- package/registry-server/src/auth.test.ts +0 -76
- package/registry-server/src/auth.ts +0 -105
- package/registry-server/src/bootstrap-packaging.test.ts +0 -71
- package/registry-server/src/bootstrap.ts +0 -246
- package/registry-server/src/index.ts +0 -16
- package/registry-server/src/landing.ts +0 -90
- package/registry-server/src/rate-limit.test.ts +0 -62
- package/registry-server/src/rate-limit.ts +0 -83
- package/registry-server/src/scoped-token-store.test.ts +0 -93
- package/registry-server/src/scoped-token-store.ts +0 -110
- package/registry-server/src/server.test.ts +0 -509
- package/registry-server/src/server.ts +0 -572
- package/registry-server/src/storage.test.ts +0 -148
- package/registry-server/src/storage.ts +0 -150
- package/registry-server/src/validation.test.ts +0 -86
- package/registry-server/src/validation.ts +0 -60
- package/registry-server/tsconfig.json +0 -15
|
@@ -1,148 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,150 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,86 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,60 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,15 +0,0 @@
|
|
|
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
|
-
}
|