@push.rocks/smartlinuxrepo 0.0.0-stage → 0.1.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/.smartconfig.json +64 -0
- package/dist_ts/00_commitinfo_data.d.ts +8 -0
- package/dist_ts/00_commitinfo_data.js +9 -0
- package/dist_ts/alpine/apkindex.d.ts +39 -0
- package/dist_ts/alpine/apkindex.js +211 -0
- package/dist_ts/alpine/pkginfo.d.ts +45 -0
- package/dist_ts/alpine/pkginfo.js +159 -0
- package/dist_ts/alpine/reader.d.ts +20 -0
- package/dist_ts/alpine/reader.js +89 -0
- package/dist_ts/arch/builder.d.ts +41 -0
- package/dist_ts/arch/builder.js +234 -0
- package/dist_ts/arch/reader.d.ts +16 -0
- package/dist_ts/arch/reader.js +271 -0
- package/dist_ts/arch/signer.d.ts +21 -0
- package/dist_ts/arch/signer.js +66 -0
- package/dist_ts/arch/vercmp.d.ts +12 -0
- package/dist_ts/arch/vercmp.js +170 -0
- package/dist_ts/debian/control.d.ts +22 -0
- package/dist_ts/debian/control.js +137 -0
- package/dist_ts/debian/gostring.d.ts +26 -0
- package/dist_ts/debian/gostring.js +145 -0
- package/dist_ts/debian/indexbuilder.d.ts +47 -0
- package/dist_ts/debian/indexbuilder.js +299 -0
- package/dist_ts/debian/mailaddress.d.ts +12 -0
- package/dist_ts/debian/mailaddress.js +736 -0
- package/dist_ts/debian/reader.d.ts +30 -0
- package/dist_ts/debian/reader.js +110 -0
- package/dist_ts/index.d.ts +23 -0
- package/dist_ts/index.js +18 -0
- package/dist_ts/plugins.d.ts +8 -0
- package/dist_ts/plugins.js +11 -0
- package/dist_ts/readers/ar.d.ts +31 -0
- package/dist_ts/readers/ar.js +87 -0
- package/dist_ts/readers/bytes.d.ts +58 -0
- package/dist_ts/readers/bytes.js +147 -0
- package/dist_ts/readers/bytesource.d.ts +40 -0
- package/dist_ts/readers/bytesource.js +154 -0
- package/dist_ts/readers/decompress.d.ts +43 -0
- package/dist_ts/readers/decompress.js +269 -0
- package/dist_ts/readers/gzipmember.d.ts +32 -0
- package/dist_ts/readers/gzipmember.js +176 -0
- package/dist_ts/readers/tar.d.ts +85 -0
- package/dist_ts/readers/tar.js +731 -0
- package/dist_ts/readers/text.d.ts +16 -0
- package/dist_ts/readers/text.js +96 -0
- package/dist_ts/rpm/builder.d.ts +28 -0
- package/dist_ts/rpm/builder.js +208 -0
- package/dist_ts/rpm/header.d.ts +138 -0
- package/dist_ts/rpm/header.js +546 -0
- package/dist_ts/rpm/reader.d.ts +20 -0
- package/dist_ts/rpm/reader.js +306 -0
- package/dist_ts/rpm/repoconfig.d.ts +15 -0
- package/dist_ts/rpm/repoconfig.js +38 -0
- package/dist_ts/rpm/repodata.d.ts +44 -0
- package/dist_ts/rpm/repodata.js +148 -0
- package/dist_ts/rpm/sign.d.ts +30 -0
- package/dist_ts/rpm/sign.js +183 -0
- package/dist_ts/rpm/updates.d.ts +38 -0
- package/dist_ts/rpm/updates.js +265 -0
- package/dist_ts/rpm/xml.d.ts +18 -0
- package/dist_ts/rpm/xml.js +90 -0
- package/dist_ts/shared/architecture.d.ts +8 -0
- package/dist_ts/shared/architecture.js +17 -0
- package/dist_ts/shared/errors.d.ts +38 -0
- package/dist_ts/shared/errors.js +22 -0
- package/dist_ts/shared/gopath.d.ts +13 -0
- package/dist_ts/shared/gopath.js +53 -0
- package/dist_ts/shared/gostrings.d.ts +14 -0
- package/dist_ts/shared/gostrings.js +29 -0
- package/dist_ts/shared/limits.d.ts +47 -0
- package/dist_ts/shared/limits.js +57 -0
- package/dist_ts/shared/types.d.ts +213 -0
- package/dist_ts/shared/types.js +13 -0
- package/dist_ts/shared/url.d.ts +18 -0
- package/dist_ts/shared/url.js +371 -0
- package/dist_ts/shared/validation.d.ts +21 -0
- package/dist_ts/shared/validation.js +58 -0
- package/dist_ts/signing/keys.d.ts +36 -0
- package/dist_ts/signing/keys.js +89 -0
- package/dist_ts/signing/openpgpframing.d.ts +33 -0
- package/dist_ts/signing/openpgpframing.js +93 -0
- package/dist_ts/signing/signers.d.ts +63 -0
- package/dist_ts/signing/signers.js +248 -0
- package/dist_ts/writers/gotar.d.ts +43 -0
- package/dist_ts/writers/gotar.js +218 -0
- package/license.md +19 -0
- package/package.json +56 -3
- package/readme.md +176 -0
- package/ts/00_commitinfo_data.ts +8 -0
- package/ts/alpine/apkindex.ts +241 -0
- package/ts/alpine/pkginfo.ts +183 -0
- package/ts/alpine/reader.ts +96 -0
- package/ts/arch/builder.ts +283 -0
- package/ts/arch/reader.ts +307 -0
- package/ts/arch/signer.ts +70 -0
- package/ts/arch/vercmp.ts +188 -0
- package/ts/debian/control.ts +150 -0
- package/ts/debian/gostring.ts +150 -0
- package/ts/debian/indexbuilder.ts +355 -0
- package/ts/debian/mailaddress.ts +754 -0
- package/ts/debian/reader.ts +126 -0
- package/ts/index.ts +46 -0
- package/ts/plugins.ts +13 -0
- package/ts/readers/ar.ts +102 -0
- package/ts/readers/bytes.ts +182 -0
- package/ts/readers/bytesource.ts +164 -0
- package/ts/readers/decompress.ts +332 -0
- package/ts/readers/gzipmember.ts +209 -0
- package/ts/readers/tar.ts +835 -0
- package/ts/readers/text.ts +91 -0
- package/ts/rpm/builder.ts +253 -0
- package/ts/rpm/header.ts +628 -0
- package/ts/rpm/reader.ts +317 -0
- package/ts/rpm/repoconfig.ts +45 -0
- package/ts/rpm/repodata.ts +219 -0
- package/ts/rpm/sign.ts +200 -0
- package/ts/rpm/updates.ts +314 -0
- package/ts/rpm/xml.ts +101 -0
- package/ts/shared/architecture.ts +16 -0
- package/ts/shared/errors.ts +78 -0
- package/ts/shared/gopath.ts +54 -0
- package/ts/shared/gostrings.ts +31 -0
- package/ts/shared/limits.ts +93 -0
- package/ts/shared/types.ts +251 -0
- package/ts/shared/url.ts +387 -0
- package/ts/shared/validation.ts +71 -0
- package/ts/signing/keys.ts +110 -0
- package/ts/signing/openpgpframing.ts +115 -0
- package/ts/signing/signers.ts +315 -0
- package/ts/writers/gotar.ts +256 -0
- package/README.md +0 -3
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import * as plugins from '../plugins.js';
|
|
2
|
+
import { isIndexableArchitecture } from '../shared/architecture.js';
|
|
3
|
+
import { LinuxRepoError } from '../shared/errors.js';
|
|
4
|
+
import type { IArchPackageRecord, ILinuxIndexFile } from '../shared/types.js';
|
|
5
|
+
import { assertBytes, assertSegmentName, assertTimestamp, hasControlCharacter } from '../shared/validation.js';
|
|
6
|
+
import { createDetachedBinarySignature } from '../signing/openpgpframing.js';
|
|
7
|
+
import { singleChunk, type IOpenPgpSigner } from '../signing/signers.js';
|
|
8
|
+
import { writeGoTar } from '../writers/gotar.js';
|
|
9
|
+
import { ARCH_NAME_PATTERN, ARCH_VERSION_PATTERN } from './reader.js';
|
|
10
|
+
import { compareArchVersions } from './vercmp.js';
|
|
11
|
+
|
|
12
|
+
/** One package of an Arch repository index. */
|
|
13
|
+
export interface IArchIndexEntry {
|
|
14
|
+
record: IArchPackageRecord;
|
|
15
|
+
/**
|
|
16
|
+
* The package's detached signature (the `.sig` file's bytes) as stored at
|
|
17
|
+
* upload time; written as `%PGPSIG%`. Without it the entry has no
|
|
18
|
+
* `%PGPSIG%` and pacman fetches `<file>.sig`. The builder never signs a package.
|
|
19
|
+
*/
|
|
20
|
+
packageSignature?: Uint8Array;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Gitea's stored name of the index archive (`services/packages/arch/repository.go:37`). */
|
|
24
|
+
const INDEX_ARCHIVE_NAME = 'packages.db';
|
|
25
|
+
const ANY_ARCHITECTURE = 'any';
|
|
26
|
+
/** `int64(os.ModePerm)` (`repository.go:393`). */
|
|
27
|
+
const ENTRY_MODE = 0o777;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Builds the Arch index files of one repository: for every architecture that
|
|
31
|
+
* has packages, the gzip tar `packages.db` and its detached signature, as
|
|
32
|
+
* Gitea 28.2.0 `buildPackagesIndex` builds them.
|
|
33
|
+
*
|
|
34
|
+
* - `any` packages also go into every other architecture's index.
|
|
35
|
+
* - Each index lists one version per package (Gitea's identity: the
|
|
36
|
+
* lower-cased name), the newest by pacman's version comparison; versions
|
|
37
|
+
* that compare equal are ordered by architecture (the index's own before
|
|
38
|
+
* `any`), then bytewise by version and file name, so input order never decides.
|
|
39
|
+
* - Entries are written sorted bytewise by `<name>-<version>`, each as
|
|
40
|
+
* `<name>-<version>/files` then `<name>-<version>/desc`.
|
|
41
|
+
* - Only the index is signed (binary detached signature over the gzip bytes).
|
|
42
|
+
*
|
|
43
|
+
* Paths are `<repository>/<architecture>/packages.db` and `packages.db.sig`
|
|
44
|
+
* (no repository prefix when `repository` is `''`), without aliases: the
|
|
45
|
+
* request names Gitea maps onto them (`<repo>.db`, `.files`, the `any`
|
|
46
|
+
* fallback) are the consumer's route, documented in the readme.
|
|
47
|
+
*/
|
|
48
|
+
export async function buildArchIndex(inputArg: {
|
|
49
|
+
repository: string;
|
|
50
|
+
entries: IArchIndexEntry[];
|
|
51
|
+
signer: IOpenPgpSigner;
|
|
52
|
+
timestamp: Date;
|
|
53
|
+
}): Promise<ILinuxIndexFile[]> {
|
|
54
|
+
if (inputArg === null || typeof inputArg !== 'object') {
|
|
55
|
+
throw new LinuxRepoError('invalid-input', 'input must be an object', { field: 'input' });
|
|
56
|
+
}
|
|
57
|
+
assertRepository(inputArg.repository);
|
|
58
|
+
assertTimestamp(inputArg.timestamp);
|
|
59
|
+
if (inputArg.signer === null || typeof inputArg.signer !== 'object' || typeof inputArg.signer.signBinary !== 'function') {
|
|
60
|
+
throw new LinuxRepoError('invalid-input', 'signer must be an IOpenPgpSigner', { field: 'signer' });
|
|
61
|
+
}
|
|
62
|
+
if (!Array.isArray(inputArg.entries)) {
|
|
63
|
+
throw new LinuxRepoError('invalid-input', 'entries must be an array', { field: 'entries' });
|
|
64
|
+
}
|
|
65
|
+
const fileNames = new Set<string>();
|
|
66
|
+
for (const entry of inputArg.entries) {
|
|
67
|
+
assertEntry(entry);
|
|
68
|
+
if (fileNames.has(entry.record.fileName)) {
|
|
69
|
+
throw new LinuxRepoError('invalid-input', `duplicate package file ${entry.record.fileName}`, {
|
|
70
|
+
field: 'entries',
|
|
71
|
+
value: entry.record.fileName,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
fileNames.add(entry.record.fileName);
|
|
75
|
+
}
|
|
76
|
+
if (inputArg.entries.length === 0) {
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const architectures = [...new Set(inputArg.entries.map((entry) => entry.record.architecture))].sort(compareText);
|
|
81
|
+
const files: ILinuxIndexFile[] = [];
|
|
82
|
+
for (const architecture of architectures) {
|
|
83
|
+
const candidates = inputArg.entries.filter(
|
|
84
|
+
(entry) =>
|
|
85
|
+
entry.record.architecture === architecture ||
|
|
86
|
+
(architecture !== ANY_ARCHITECTURE && entry.record.architecture === ANY_ARCHITECTURE),
|
|
87
|
+
);
|
|
88
|
+
const selected = selectLatest(candidates, architecture);
|
|
89
|
+
const tarFiles = selected.flatMap((entry) => {
|
|
90
|
+
const directory = `${entry.record.name}-${entry.record.version}`;
|
|
91
|
+
return [
|
|
92
|
+
{ name: `${directory}/files`, mode: ENTRY_MODE, data: encodeText(renderArchFiles(entry.record)) },
|
|
93
|
+
{ name: `${directory}/desc`, mode: ENTRY_MODE, data: encodeText(renderArchDesc(entry)) },
|
|
94
|
+
];
|
|
95
|
+
});
|
|
96
|
+
const archive = toUint8Array(plugins.zlib.gzipSync(writeGoTar(tarFiles)));
|
|
97
|
+
const signature = await createDetachedBinarySignature({
|
|
98
|
+
data: singleChunk(archive),
|
|
99
|
+
signer: inputArg.signer,
|
|
100
|
+
timestamp: inputArg.timestamp,
|
|
101
|
+
});
|
|
102
|
+
const directory = inputArg.repository === '' ? architecture : `${inputArg.repository}/${architecture}`;
|
|
103
|
+
files.push(
|
|
104
|
+
indexFile(`${directory}/${INDEX_ARCHIVE_NAME}`, archive, 'application/gzip'),
|
|
105
|
+
indexFile(`${directory}/${INDEX_ARCHIVE_NAME}.sig`, signature, 'application/pgp-signature'),
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
return files.sort((a, b) => compareText(a.path, b.path));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The `desc` member of one entry (`repository.go:353-379`). */
|
|
112
|
+
export function renderArchDesc(entryArg: IArchIndexEntry): string {
|
|
113
|
+
const record = entryArg.record;
|
|
114
|
+
const metadata = record.metadata;
|
|
115
|
+
const signature =
|
|
116
|
+
entryArg.packageSignature === undefined ? '' : toBuffer(entryArg.packageSignature).toString('base64');
|
|
117
|
+
return renderFields([
|
|
118
|
+
['FILENAME', record.fileName],
|
|
119
|
+
['MD5SUM', record.digests.md5],
|
|
120
|
+
['SHA256SUM', record.digests.sha256],
|
|
121
|
+
['PGPSIG', signature],
|
|
122
|
+
['CSIZE', String(record.digests.size)],
|
|
123
|
+
['ISIZE', String(metadata.installedSize)],
|
|
124
|
+
['NAME', record.name],
|
|
125
|
+
['BASE', metadata.base],
|
|
126
|
+
['ARCH', record.architecture],
|
|
127
|
+
['VERSION', record.version],
|
|
128
|
+
['DESC', metadata.description],
|
|
129
|
+
['URL', metadata.projectUrl],
|
|
130
|
+
['LICENSE', joinFields(metadata.licenses)],
|
|
131
|
+
['GROUPS', joinFields(metadata.groups)],
|
|
132
|
+
['BUILDDATE', String(metadata.buildDate)],
|
|
133
|
+
['PACKAGER', metadata.packager],
|
|
134
|
+
['PROVIDES', joinFields(metadata.provides)],
|
|
135
|
+
['REPLACES', joinFields(metadata.replaces)],
|
|
136
|
+
['CONFLICTS', joinFields(metadata.conflicts)],
|
|
137
|
+
['DEPENDS', joinFields(metadata.depends)],
|
|
138
|
+
['OPTDEPENDS', joinFields(metadata.optDepends)],
|
|
139
|
+
['MAKEDEPENDS', joinFields(metadata.makeDepends)],
|
|
140
|
+
['CHECKDEPENDS', joinFields(metadata.checkDepends)],
|
|
141
|
+
]);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The `files` member of one entry (`repository.go:346-350`). */
|
|
145
|
+
export function renderArchFiles(recordArg: IArchPackageRecord): string {
|
|
146
|
+
return renderFields([['FILES', joinFields(recordArg.metadata.files)]]);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** `writeFields`: `%KEY%\nvalue\n\n` per non-empty value. */
|
|
150
|
+
function renderFields(fieldsArg: Array<[string, string]>): string {
|
|
151
|
+
let text = '';
|
|
152
|
+
for (const [key, value] of fieldsArg) {
|
|
153
|
+
if (value !== '') {
|
|
154
|
+
text += `%${key}%\n${value}\n\n`;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return text;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** `joinFields`: LF-joined, dropping values that contain CR or LF (they would forge index lines). */
|
|
161
|
+
function joinFields(valuesArg: string[]): string {
|
|
162
|
+
return valuesArg.filter((value) => !/[\n\r]/.test(value)).join('\n');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function selectLatest(entriesArg: IArchIndexEntry[], architectureArg: string): IArchIndexEntry[] {
|
|
166
|
+
const latest = new Map<string, IArchIndexEntry>();
|
|
167
|
+
for (const entry of entriesArg) {
|
|
168
|
+
const key = entry.record.name.toLowerCase();
|
|
169
|
+
const current = latest.get(key);
|
|
170
|
+
if (current === undefined || isPreferred(entry, current, architectureArg)) {
|
|
171
|
+
latest.set(key, entry);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return [...latest.values()].sort((a, b) =>
|
|
175
|
+
compareText(`${a.record.name}-${a.record.version}`, `${b.record.name}-${b.record.version}`),
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** True when `candidateArg` wins over `currentArg`: newer by alpm, then the tie order. */
|
|
180
|
+
function isPreferred(candidateArg: IArchIndexEntry, currentArg: IArchIndexEntry, architectureArg: string): boolean {
|
|
181
|
+
const byVersion = compareArchVersions(candidateArg.record.version, currentArg.record.version);
|
|
182
|
+
if (byVersion !== 0) {
|
|
183
|
+
return byVersion > 0;
|
|
184
|
+
}
|
|
185
|
+
const ownArchitecture = (entryArg: IArchIndexEntry) => (entryArg.record.architecture === architectureArg ? 0 : 1);
|
|
186
|
+
const byArchitecture = ownArchitecture(candidateArg) - ownArchitecture(currentArg);
|
|
187
|
+
if (byArchitecture !== 0) {
|
|
188
|
+
return byArchitecture < 0;
|
|
189
|
+
}
|
|
190
|
+
const byVersionText = compareText(candidateArg.record.version, currentArg.record.version);
|
|
191
|
+
if (byVersionText !== 0) {
|
|
192
|
+
return byVersionText < 0;
|
|
193
|
+
}
|
|
194
|
+
return compareText(candidateArg.record.fileName, currentArg.record.fileName) < 0;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function indexFile(pathArg: string, bytesArg: Uint8Array, mediaTypeArg: string): ILinuxIndexFile {
|
|
198
|
+
return {
|
|
199
|
+
path: pathArg,
|
|
200
|
+
bytes: bytesArg,
|
|
201
|
+
sha256: plugins.crypto.createHash('sha256').update(bytesArg).digest('hex'),
|
|
202
|
+
mediaType: mediaTypeArg,
|
|
203
|
+
stage: 'pointer',
|
|
204
|
+
aliases: [],
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** `''`, or `/`-separated segments that are non-empty, not `.` or `..`, and free of control characters. */
|
|
209
|
+
function assertRepository(valueArg: unknown): asserts valueArg is string {
|
|
210
|
+
if (typeof valueArg !== 'string') {
|
|
211
|
+
throw new LinuxRepoError('invalid-input', 'repository must be a string', { field: 'repository' });
|
|
212
|
+
}
|
|
213
|
+
if (valueArg === '') {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
for (const segment of valueArg.split('/')) {
|
|
217
|
+
if (segment === '' || segment === '.' || segment === '..' || hasControlCharacter(segment)) {
|
|
218
|
+
throw new LinuxRepoError(
|
|
219
|
+
'invalid-input',
|
|
220
|
+
'repository segments must be non-empty, not "." or "..", and free of control characters',
|
|
221
|
+
{ field: 'repository' },
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The record values that become index paths and lines must be what the
|
|
229
|
+
* reader produces: Gitea's name and version patterns, an indexable
|
|
230
|
+
* architecture, a file name that is one path segment.
|
|
231
|
+
*/
|
|
232
|
+
function assertEntry(entryArg: unknown): asserts entryArg is IArchIndexEntry {
|
|
233
|
+
if (entryArg === null || typeof entryArg !== 'object') {
|
|
234
|
+
throw new LinuxRepoError('invalid-input', 'each entry must be an object', { field: 'entries' });
|
|
235
|
+
}
|
|
236
|
+
const entry = entryArg as Partial<IArchIndexEntry>;
|
|
237
|
+
const record = entry.record;
|
|
238
|
+
if (record === null || typeof record !== 'object' || record.format !== 'arch') {
|
|
239
|
+
throw new LinuxRepoError('invalid-input', 'each entry needs an Arch package record', { field: 'entries' });
|
|
240
|
+
}
|
|
241
|
+
if (typeof record.fileName !== 'string') {
|
|
242
|
+
throw new LinuxRepoError('invalid-input', 'record.fileName must be a string', { field: 'record.fileName' });
|
|
243
|
+
}
|
|
244
|
+
if (typeof record.name !== 'string' || !ARCH_NAME_PATTERN.test(record.name)) {
|
|
245
|
+
throw new LinuxRepoError('invalid-input', `package ${record.fileName} has an invalid name`, {
|
|
246
|
+
field: 'record.name',
|
|
247
|
+
value: record.fileName,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
if (typeof record.version !== 'string' || !ARCH_VERSION_PATTERN.test(record.version)) {
|
|
251
|
+
throw new LinuxRepoError('invalid-input', `package ${record.fileName} has an invalid version`, {
|
|
252
|
+
field: 'record.version',
|
|
253
|
+
value: record.fileName,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
if (!isIndexableArchitecture(record.architecture)) {
|
|
257
|
+
throw new LinuxRepoError(
|
|
258
|
+
'invalid-input',
|
|
259
|
+
`package ${record.fileName} has an architecture that cannot name an index path`,
|
|
260
|
+
{ field: 'record.architecture', value: record.fileName },
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
assertSegmentName(record.fileName, 'record.fileName');
|
|
264
|
+
if (entry.packageSignature !== undefined) {
|
|
265
|
+
assertBytes(entry.packageSignature, 'packageSignature');
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function compareText(aArg: string, bArg: string): number {
|
|
270
|
+
return Buffer.compare(Buffer.from(aArg, 'utf8'), Buffer.from(bArg, 'utf8'));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function encodeText(textArg: string): Uint8Array {
|
|
274
|
+
return new TextEncoder().encode(textArg);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function toBuffer(bytesArg: Uint8Array): Buffer {
|
|
278
|
+
return Buffer.from(bytesArg.buffer, bytesArg.byteOffset, bytesArg.byteLength);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function toUint8Array(bufferArg: Buffer): Uint8Array {
|
|
282
|
+
return new Uint8Array(bufferArg.buffer, bufferArg.byteOffset, bufferArg.byteLength);
|
|
283
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { LinuxRepoError, limitExceeded } from '../shared/errors.js';
|
|
2
|
+
import { goPathBase, goPathClean } from '../shared/gopath.js';
|
|
3
|
+
import { resolveLinuxPackageLimits, type ILinuxPackageLimits } from '../shared/limits.js';
|
|
4
|
+
import type { IArchPackageMetadata, IArchPackageRecord } from '../shared/types.js';
|
|
5
|
+
import { isValidUrl } from '../shared/url.js';
|
|
6
|
+
import { readUpTo, skipToEnd } from '../readers/bytes.js';
|
|
7
|
+
import { PackageByteSource } from '../readers/bytesource.js';
|
|
8
|
+
import { withDecompressingReader, type TLinuxCompression } from '../readers/decompress.js';
|
|
9
|
+
import { readTarEntryData, TarReader, type ITarEntry } from '../readers/tar.js';
|
|
10
|
+
import { decodeGoUtf8 } from '../readers/text.js';
|
|
11
|
+
import { goParseInt64, goTrimSpace } from '../shared/gostrings.js';
|
|
12
|
+
|
|
13
|
+
/** Gitea `namePattern` (`modules/packages/arch/metadata.go:47`). */
|
|
14
|
+
export const ARCH_NAME_PATTERN = /^[a-zA-Z0-9@._+-]+$/;
|
|
15
|
+
/** Gitea `versionPattern` (`metadata.go:49`); Go's `\d` and `\w` are ASCII, as in a JS RegExp without `u`. */
|
|
16
|
+
export const ARCH_VERSION_PATTERN = /^(?:\d:)?[\w.+~]+(?:-[-\w.+~]+)?$/;
|
|
17
|
+
|
|
18
|
+
const MAGIC_BYTES = 10;
|
|
19
|
+
const GO_C_ISDIR = 0o40000;
|
|
20
|
+
|
|
21
|
+
interface IPackageInfo {
|
|
22
|
+
name: string;
|
|
23
|
+
version: string;
|
|
24
|
+
architecture: string;
|
|
25
|
+
metadata: Omit<IArchPackageMetadata, 'compression' | 'files'>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Reads an Arch package (`.pkg.tar.zst`, `.xz` or `.gz`, chosen by magic as
|
|
30
|
+
* Gitea 28.2.0 does) into a record: `.PKGINFO` metadata and the file list,
|
|
31
|
+
* derived exactly as Gitea's `arch.ParsePackage`, plus the size and digests of
|
|
32
|
+
* the whole file. The whole payload is decompressed (bounded by
|
|
33
|
+
* `maxPayloadBytes`) because the file list needs every tar entry, and to the
|
|
34
|
+
* end of the compressed stream, so a cut or corrupt container is refused
|
|
35
|
+
* even where it lies past the tar end marker (Go stops reading there).
|
|
36
|
+
*/
|
|
37
|
+
export async function readArchPackage(
|
|
38
|
+
sourceArg: AsyncIterable<Uint8Array>,
|
|
39
|
+
limitsArg?: Partial<ILinuxPackageLimits>,
|
|
40
|
+
): Promise<IArchPackageRecord> {
|
|
41
|
+
const limits = resolveLinuxPackageLimits(limitsArg);
|
|
42
|
+
const source = new PackageByteSource(sourceArg, limits.maxPackageBytes);
|
|
43
|
+
|
|
44
|
+
const magic = await readUpTo(source, MAGIC_BYTES);
|
|
45
|
+
source.unread(magic);
|
|
46
|
+
const container = detectContainer(magic);
|
|
47
|
+
if (container === null) {
|
|
48
|
+
throw new LinuxRepoError('unsupported-compression', 'not a zst, xz or gz compressed Arch package', {
|
|
49
|
+
field: 'compression',
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const walked = await withDecompressingReader(
|
|
54
|
+
source,
|
|
55
|
+
container.decoder,
|
|
56
|
+
{ maxOutputBytes: limits.maxPayloadBytes, outputLimit: 'maxPayloadBytes' },
|
|
57
|
+
async (payloadArg) => {
|
|
58
|
+
const result = await walkPayload(
|
|
59
|
+
new TarReader(payloadArg, { maxEntries: limits.maxEntries, maxHeaderBytes: limits.maxHeaderBytes }),
|
|
60
|
+
limits,
|
|
61
|
+
);
|
|
62
|
+
// Go stops at the tar end marker; the codec decodes the rest of the
|
|
63
|
+
// stream as well, so a package cut or corrupted after the marker fails
|
|
64
|
+
// loudly instead of being stored and signed
|
|
65
|
+
await skipToEnd(payloadArg);
|
|
66
|
+
return result;
|
|
67
|
+
},
|
|
68
|
+
);
|
|
69
|
+
if (walked.info === null) {
|
|
70
|
+
throw new LinuxRepoError('missing-field', '.PKGINFO file is missing', { field: '.PKGINFO' });
|
|
71
|
+
}
|
|
72
|
+
await source.drain();
|
|
73
|
+
const info = walked.info;
|
|
74
|
+
return {
|
|
75
|
+
format: 'arch',
|
|
76
|
+
name: info.name,
|
|
77
|
+
version: info.version,
|
|
78
|
+
architecture: info.architecture,
|
|
79
|
+
fileName: `${info.name}-${info.version}-${info.architecture}.pkg.tar.${container.extension}`,
|
|
80
|
+
digests: source.digests(),
|
|
81
|
+
metadata: {
|
|
82
|
+
compression: container.extension,
|
|
83
|
+
...info.metadata,
|
|
84
|
+
files: walked.files,
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function detectContainer(
|
|
90
|
+
magicArg: Uint8Array,
|
|
91
|
+
): { extension: IArchPackageMetadata['compression']; decoder: TLinuxCompression } | null {
|
|
92
|
+
const startsWith = (bytesArg: number[]) => bytesArg.every((byte, index) => magicArg[index] === byte);
|
|
93
|
+
if (startsWith([0x28, 0xb5, 0x2f, 0xfd])) {
|
|
94
|
+
return { extension: 'zst', decoder: 'zstd' };
|
|
95
|
+
}
|
|
96
|
+
if (startsWith([0xfd, 0x37, 0x7a, 0x58, 0x5a])) {
|
|
97
|
+
return { extension: 'xz', decoder: 'xz' };
|
|
98
|
+
}
|
|
99
|
+
if (startsWith([0x1f, 0x8b])) {
|
|
100
|
+
return { extension: 'gz', decoder: 'gzip' };
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Gitea's tar walk (`metadata.go:135-163`): regular entries only; a regular
|
|
107
|
+
* entry whose base name is `.PKGINFO` is parsed (a later one replaces an
|
|
108
|
+
* earlier one); every other regular entry whose base name does not start with
|
|
109
|
+
* `.` and whose name holds no CR or LF joins the file list, in archive order.
|
|
110
|
+
*/
|
|
111
|
+
async function walkPayload(
|
|
112
|
+
tarArg: TarReader,
|
|
113
|
+
limitsArg: Readonly<ILinuxPackageLimits>,
|
|
114
|
+
): Promise<{ info: IPackageInfo | null; files: string[] }> {
|
|
115
|
+
let info: IPackageInfo | null = null;
|
|
116
|
+
const files: string[] = [];
|
|
117
|
+
let fileBytes = 0;
|
|
118
|
+
for (;;) {
|
|
119
|
+
const entry = await tarArg.next();
|
|
120
|
+
if (entry === null) {
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
if (entry.typeflag !== '0') {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const base = fileInfoName(entry);
|
|
127
|
+
if (base === '.PKGINFO') {
|
|
128
|
+
const data = await readTarEntryData(entry, limitsArg.maxDecompressedMetadataBytes, 'maxDecompressedMetadataBytes');
|
|
129
|
+
info = parsePackageInfo(data, limitsArg);
|
|
130
|
+
} else if (!base.startsWith('.')) {
|
|
131
|
+
if (/[\n\r]/.test(entry.name)) {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
// Gitea's BoundedFileList: entry count and total name bytes
|
|
135
|
+
const nameBytes = Buffer.byteLength(entry.name, 'utf8');
|
|
136
|
+
if (files.length >= limitsArg.maxEntries) {
|
|
137
|
+
throw limitExceeded('maxEntries', limitsArg.maxEntries, 'Arch file list');
|
|
138
|
+
}
|
|
139
|
+
if (fileBytes + nameBytes > limitsArg.maxFileListBytes) {
|
|
140
|
+
throw limitExceeded('maxFileListBytes', limitsArg.maxFileListBytes, 'Arch file list');
|
|
141
|
+
}
|
|
142
|
+
fileBytes += nameBytes;
|
|
143
|
+
files.push(entry.name);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return { info, files };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Go `tar.Header.FileInfo().Name()` for a regular entry: `path.Base`, after `path.Clean` when the mode marks a directory. */
|
|
150
|
+
function fileInfoName(entryArg: ITarEntry): string {
|
|
151
|
+
const typeBits = (Number(BigInt.asUintN(32, entryArg.mode)) & ~0o7777) >>> 0;
|
|
152
|
+
if (typeBits === GO_C_ISDIR) {
|
|
153
|
+
return goPathBase(goPathClean(entryArg.name));
|
|
154
|
+
}
|
|
155
|
+
return goPathBase(entryArg.name);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const MULTI_VALUE_KEYS = {
|
|
159
|
+
license: 'licenses',
|
|
160
|
+
provides: 'provides',
|
|
161
|
+
depend: 'depends',
|
|
162
|
+
replaces: 'replaces',
|
|
163
|
+
optdepend: 'optDepends',
|
|
164
|
+
makedepend: 'makeDepends',
|
|
165
|
+
checkdepend: 'checkDepends',
|
|
166
|
+
conflict: 'conflicts',
|
|
167
|
+
backup: 'backup',
|
|
168
|
+
group: 'groups',
|
|
169
|
+
xdata: 'xdata',
|
|
170
|
+
} as const;
|
|
171
|
+
|
|
172
|
+
type TMultiValueKey = keyof typeof MULTI_VALUE_KEYS;
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Gitea's `ParsePackageInfo` (`metadata.go:178-267`): lines as Go's
|
|
176
|
+
* `bufio.ScanLines` splits them (a trailing CR dropped), `#` lines and lines
|
|
177
|
+
* without `=` skipped, key and value `strings.TrimSpace`d, single values last
|
|
178
|
+
* one wins, `builddate` and `size` parsed as int64 (an unparsable value
|
|
179
|
+
* refuses the package), then the name, version and architecture checks and
|
|
180
|
+
* the URL check. Lines are bounded by `maxHeaderBytes` (Gitea: 64 KiB) and
|
|
181
|
+
* each multi-valued key by `maxEntries`.
|
|
182
|
+
*/
|
|
183
|
+
function parsePackageInfo(dataArg: Uint8Array, limitsArg: Readonly<ILinuxPackageLimits>): IPackageInfo {
|
|
184
|
+
const info: IPackageInfo = {
|
|
185
|
+
name: '',
|
|
186
|
+
version: '',
|
|
187
|
+
architecture: '',
|
|
188
|
+
metadata: {
|
|
189
|
+
base: '',
|
|
190
|
+
description: '',
|
|
191
|
+
projectUrl: '',
|
|
192
|
+
licenses: [],
|
|
193
|
+
installedSize: 0,
|
|
194
|
+
buildDate: 0,
|
|
195
|
+
packager: '',
|
|
196
|
+
groups: [],
|
|
197
|
+
provides: [],
|
|
198
|
+
replaces: [],
|
|
199
|
+
depends: [],
|
|
200
|
+
optDepends: [],
|
|
201
|
+
makeDepends: [],
|
|
202
|
+
checkDepends: [],
|
|
203
|
+
conflicts: [],
|
|
204
|
+
xdata: [],
|
|
205
|
+
backup: [],
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
for (const rawLine of splitLines(dataArg, limitsArg.maxHeaderBytes)) {
|
|
209
|
+
const line = decodeGoUtf8(rawLine);
|
|
210
|
+
if (line.startsWith('#')) {
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const equals = line.indexOf('=');
|
|
214
|
+
if (equals < 0) {
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
const key = goTrimSpace(line.slice(0, equals));
|
|
218
|
+
const value = goTrimSpace(line.slice(equals + 1));
|
|
219
|
+
switch (key) {
|
|
220
|
+
case 'pkgname':
|
|
221
|
+
info.name = value;
|
|
222
|
+
break;
|
|
223
|
+
case 'pkgbase':
|
|
224
|
+
info.metadata.base = value;
|
|
225
|
+
break;
|
|
226
|
+
case 'pkgver':
|
|
227
|
+
info.version = value;
|
|
228
|
+
break;
|
|
229
|
+
case 'pkgdesc':
|
|
230
|
+
info.metadata.description = value;
|
|
231
|
+
break;
|
|
232
|
+
case 'url':
|
|
233
|
+
info.metadata.projectUrl = value;
|
|
234
|
+
break;
|
|
235
|
+
case 'packager':
|
|
236
|
+
info.metadata.packager = value;
|
|
237
|
+
break;
|
|
238
|
+
case 'arch':
|
|
239
|
+
info.architecture = value;
|
|
240
|
+
break;
|
|
241
|
+
case 'builddate':
|
|
242
|
+
info.metadata.buildDate = parseInteger(value, 'builddate');
|
|
243
|
+
break;
|
|
244
|
+
case 'size':
|
|
245
|
+
info.metadata.installedSize = parseInteger(value, 'size');
|
|
246
|
+
break;
|
|
247
|
+
default:
|
|
248
|
+
if (Object.hasOwn(MULTI_VALUE_KEYS, key)) {
|
|
249
|
+
const list = info.metadata[MULTI_VALUE_KEYS[key as TMultiValueKey]];
|
|
250
|
+
if (list.length >= limitsArg.maxEntries) {
|
|
251
|
+
throw limitExceeded('maxEntries', limitsArg.maxEntries, `.PKGINFO ${key} values`);
|
|
252
|
+
}
|
|
253
|
+
list.push(value);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (!ARCH_NAME_PATTERN.test(info.name)) {
|
|
258
|
+
throw new LinuxRepoError('invalid-field', 'package name is invalid', { field: 'pkgname' });
|
|
259
|
+
}
|
|
260
|
+
if (!ARCH_VERSION_PATTERN.test(info.version)) {
|
|
261
|
+
throw new LinuxRepoError('invalid-field', 'package version is invalid', { field: 'pkgver' });
|
|
262
|
+
}
|
|
263
|
+
if (info.architecture === '') {
|
|
264
|
+
throw new LinuxRepoError('invalid-field', 'package architecture is invalid', { field: 'arch' });
|
|
265
|
+
}
|
|
266
|
+
if (!isValidUrl(info.metadata.projectUrl)) {
|
|
267
|
+
info.metadata.projectUrl = '';
|
|
268
|
+
}
|
|
269
|
+
return info;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** int64 as Go parses it; values outside the safe integer range cannot live in a JSON record. */
|
|
273
|
+
function parseInteger(valueArg: string, fieldArg: string): number {
|
|
274
|
+
const value = goParseInt64(valueArg);
|
|
275
|
+
if (value === null) {
|
|
276
|
+
throw new LinuxRepoError('invalid-field', `.PKGINFO ${fieldArg} is not an integer`, { field: fieldArg });
|
|
277
|
+
}
|
|
278
|
+
if (value > BigInt(Number.MAX_SAFE_INTEGER) || value < BigInt(Number.MIN_SAFE_INTEGER)) {
|
|
279
|
+
throw new LinuxRepoError('invalid-field', `.PKGINFO ${fieldArg} is outside the safe integer range`, {
|
|
280
|
+
field: fieldArg,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
return Number(value);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Go `bufio.ScanLines`: split at LF, drop one trailing CR, keep a final unterminated line when non-empty. */
|
|
287
|
+
function splitLines(dataArg: Uint8Array, maxLineBytesArg: number): Uint8Array[] {
|
|
288
|
+
const lines: Uint8Array[] = [];
|
|
289
|
+
let start = 0;
|
|
290
|
+
while (start < dataArg.length) {
|
|
291
|
+
let end = dataArg.indexOf(0x0a, start);
|
|
292
|
+
const terminated = end >= 0;
|
|
293
|
+
if (!terminated) {
|
|
294
|
+
end = dataArg.length;
|
|
295
|
+
}
|
|
296
|
+
if (end - start > maxLineBytesArg) {
|
|
297
|
+
throw limitExceeded('maxHeaderBytes', maxLineBytesArg, '.PKGINFO line');
|
|
298
|
+
}
|
|
299
|
+
let line = dataArg.subarray(start, end);
|
|
300
|
+
if (line.length > 0 && line[line.length - 1] === 0x0d) {
|
|
301
|
+
line = line.subarray(0, line.length - 1);
|
|
302
|
+
}
|
|
303
|
+
lines.push(line);
|
|
304
|
+
start = terminated ? end + 1 : end;
|
|
305
|
+
}
|
|
306
|
+
return lines;
|
|
307
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { LinuxRepoError } from '../shared/errors.js';
|
|
2
|
+
import { resolveLinuxPackageLimits, type ILinuxPackageLimits } from '../shared/limits.js';
|
|
3
|
+
import { assertTimestamp } from '../shared/validation.js';
|
|
4
|
+
import { READ_CHUNK_BYTES } from '../readers/bytes.js';
|
|
5
|
+
import { PackageByteSource } from '../readers/bytesource.js';
|
|
6
|
+
import { createDetachedBinarySignature } from '../signing/openpgpframing.js';
|
|
7
|
+
import type { IOpenPgpSigner } from '../signing/signers.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Signs an Arch package at upload time, as Gitea 28.2.0 signs every upload
|
|
11
|
+
* (`routers/api/packages/arch/arch.go:83`, `openpgp.DetachSign`): returns the
|
|
12
|
+
* `.sig` file, one binary detached signature packet over the package bytes.
|
|
13
|
+
* The package streams to the signer once, counted against `maxPackageBytes`
|
|
14
|
+
* of `limits` (pass the limits the package was read with); the codec keeps no
|
|
15
|
+
* copy, but the built-in OpenPGP signer still holds it in memory: openpgp.js
|
|
16
|
+
* buffers the whole document when detach-signing a stream.
|
|
17
|
+
* A failure of the package stream (the limit, or the caller's source error
|
|
18
|
+
* unchanged) wins over whatever the signer made of it. Pass the result as the index
|
|
19
|
+
* entry's `packageSignature`. Imported packages keep their stored signature
|
|
20
|
+
* and are never re-signed.
|
|
21
|
+
*/
|
|
22
|
+
export async function signArchPackage(inputArg: {
|
|
23
|
+
source: AsyncIterable<Uint8Array>;
|
|
24
|
+
signer: IOpenPgpSigner;
|
|
25
|
+
timestamp: Date;
|
|
26
|
+
limits?: Partial<ILinuxPackageLimits>;
|
|
27
|
+
}): Promise<Uint8Array> {
|
|
28
|
+
if (inputArg === null || typeof inputArg !== 'object') {
|
|
29
|
+
throw new LinuxRepoError('invalid-input', 'input must be an object', { field: 'input' });
|
|
30
|
+
}
|
|
31
|
+
assertTimestamp(inputArg.timestamp);
|
|
32
|
+
if (inputArg.signer === null || typeof inputArg.signer !== 'object' || typeof inputArg.signer.signBinary !== 'function') {
|
|
33
|
+
throw new LinuxRepoError('invalid-input', 'signer must be an IOpenPgpSigner', { field: 'signer' });
|
|
34
|
+
}
|
|
35
|
+
const limits = resolveLinuxPackageLimits(inputArg.limits);
|
|
36
|
+
const source = new PackageByteSource(inputArg.source, limits.maxPackageBytes);
|
|
37
|
+
let ended = false;
|
|
38
|
+
let failure: { error: unknown } | undefined;
|
|
39
|
+
const chunks = async function* (): AsyncGenerator<Uint8Array> {
|
|
40
|
+
for (;;) {
|
|
41
|
+
let chunk: Uint8Array | null;
|
|
42
|
+
try {
|
|
43
|
+
chunk = await source.read(READ_CHUNK_BYTES);
|
|
44
|
+
} catch (error) {
|
|
45
|
+
failure = { error };
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
if (chunk === null) {
|
|
49
|
+
ended = true;
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
yield chunk;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
let packet: Uint8Array;
|
|
56
|
+
try {
|
|
57
|
+
packet = await createDetachedBinarySignature({ data: chunks(), signer: inputArg.signer, timestamp: inputArg.timestamp });
|
|
58
|
+
} catch (error) {
|
|
59
|
+
throw failure !== undefined ? failure.error : error;
|
|
60
|
+
}
|
|
61
|
+
if (failure !== undefined) {
|
|
62
|
+
throw failure.error;
|
|
63
|
+
}
|
|
64
|
+
if (!ended) {
|
|
65
|
+
throw new LinuxRepoError('signing-failed', 'the signer returned before reading the whole package', {
|
|
66
|
+
field: 'signer',
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return packet;
|
|
70
|
+
}
|