@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.
Files changed (131) hide show
  1. package/.smartconfig.json +64 -0
  2. package/dist_ts/00_commitinfo_data.d.ts +8 -0
  3. package/dist_ts/00_commitinfo_data.js +9 -0
  4. package/dist_ts/alpine/apkindex.d.ts +39 -0
  5. package/dist_ts/alpine/apkindex.js +211 -0
  6. package/dist_ts/alpine/pkginfo.d.ts +45 -0
  7. package/dist_ts/alpine/pkginfo.js +159 -0
  8. package/dist_ts/alpine/reader.d.ts +20 -0
  9. package/dist_ts/alpine/reader.js +89 -0
  10. package/dist_ts/arch/builder.d.ts +41 -0
  11. package/dist_ts/arch/builder.js +234 -0
  12. package/dist_ts/arch/reader.d.ts +16 -0
  13. package/dist_ts/arch/reader.js +271 -0
  14. package/dist_ts/arch/signer.d.ts +21 -0
  15. package/dist_ts/arch/signer.js +66 -0
  16. package/dist_ts/arch/vercmp.d.ts +12 -0
  17. package/dist_ts/arch/vercmp.js +170 -0
  18. package/dist_ts/debian/control.d.ts +22 -0
  19. package/dist_ts/debian/control.js +137 -0
  20. package/dist_ts/debian/gostring.d.ts +26 -0
  21. package/dist_ts/debian/gostring.js +145 -0
  22. package/dist_ts/debian/indexbuilder.d.ts +47 -0
  23. package/dist_ts/debian/indexbuilder.js +299 -0
  24. package/dist_ts/debian/mailaddress.d.ts +12 -0
  25. package/dist_ts/debian/mailaddress.js +736 -0
  26. package/dist_ts/debian/reader.d.ts +30 -0
  27. package/dist_ts/debian/reader.js +110 -0
  28. package/dist_ts/index.d.ts +23 -0
  29. package/dist_ts/index.js +18 -0
  30. package/dist_ts/plugins.d.ts +8 -0
  31. package/dist_ts/plugins.js +11 -0
  32. package/dist_ts/readers/ar.d.ts +31 -0
  33. package/dist_ts/readers/ar.js +87 -0
  34. package/dist_ts/readers/bytes.d.ts +58 -0
  35. package/dist_ts/readers/bytes.js +147 -0
  36. package/dist_ts/readers/bytesource.d.ts +40 -0
  37. package/dist_ts/readers/bytesource.js +154 -0
  38. package/dist_ts/readers/decompress.d.ts +43 -0
  39. package/dist_ts/readers/decompress.js +269 -0
  40. package/dist_ts/readers/gzipmember.d.ts +32 -0
  41. package/dist_ts/readers/gzipmember.js +176 -0
  42. package/dist_ts/readers/tar.d.ts +85 -0
  43. package/dist_ts/readers/tar.js +731 -0
  44. package/dist_ts/readers/text.d.ts +16 -0
  45. package/dist_ts/readers/text.js +96 -0
  46. package/dist_ts/rpm/builder.d.ts +28 -0
  47. package/dist_ts/rpm/builder.js +208 -0
  48. package/dist_ts/rpm/header.d.ts +138 -0
  49. package/dist_ts/rpm/header.js +546 -0
  50. package/dist_ts/rpm/reader.d.ts +20 -0
  51. package/dist_ts/rpm/reader.js +306 -0
  52. package/dist_ts/rpm/repoconfig.d.ts +15 -0
  53. package/dist_ts/rpm/repoconfig.js +38 -0
  54. package/dist_ts/rpm/repodata.d.ts +44 -0
  55. package/dist_ts/rpm/repodata.js +148 -0
  56. package/dist_ts/rpm/sign.d.ts +30 -0
  57. package/dist_ts/rpm/sign.js +183 -0
  58. package/dist_ts/rpm/updates.d.ts +38 -0
  59. package/dist_ts/rpm/updates.js +265 -0
  60. package/dist_ts/rpm/xml.d.ts +18 -0
  61. package/dist_ts/rpm/xml.js +90 -0
  62. package/dist_ts/shared/architecture.d.ts +8 -0
  63. package/dist_ts/shared/architecture.js +17 -0
  64. package/dist_ts/shared/errors.d.ts +38 -0
  65. package/dist_ts/shared/errors.js +22 -0
  66. package/dist_ts/shared/gopath.d.ts +13 -0
  67. package/dist_ts/shared/gopath.js +53 -0
  68. package/dist_ts/shared/gostrings.d.ts +14 -0
  69. package/dist_ts/shared/gostrings.js +29 -0
  70. package/dist_ts/shared/limits.d.ts +47 -0
  71. package/dist_ts/shared/limits.js +57 -0
  72. package/dist_ts/shared/types.d.ts +213 -0
  73. package/dist_ts/shared/types.js +13 -0
  74. package/dist_ts/shared/url.d.ts +18 -0
  75. package/dist_ts/shared/url.js +371 -0
  76. package/dist_ts/shared/validation.d.ts +21 -0
  77. package/dist_ts/shared/validation.js +58 -0
  78. package/dist_ts/signing/keys.d.ts +36 -0
  79. package/dist_ts/signing/keys.js +89 -0
  80. package/dist_ts/signing/openpgpframing.d.ts +33 -0
  81. package/dist_ts/signing/openpgpframing.js +93 -0
  82. package/dist_ts/signing/signers.d.ts +63 -0
  83. package/dist_ts/signing/signers.js +248 -0
  84. package/dist_ts/writers/gotar.d.ts +43 -0
  85. package/dist_ts/writers/gotar.js +218 -0
  86. package/license.md +19 -0
  87. package/package.json +56 -3
  88. package/readme.md +176 -0
  89. package/ts/00_commitinfo_data.ts +8 -0
  90. package/ts/alpine/apkindex.ts +241 -0
  91. package/ts/alpine/pkginfo.ts +183 -0
  92. package/ts/alpine/reader.ts +96 -0
  93. package/ts/arch/builder.ts +283 -0
  94. package/ts/arch/reader.ts +307 -0
  95. package/ts/arch/signer.ts +70 -0
  96. package/ts/arch/vercmp.ts +188 -0
  97. package/ts/debian/control.ts +150 -0
  98. package/ts/debian/gostring.ts +150 -0
  99. package/ts/debian/indexbuilder.ts +355 -0
  100. package/ts/debian/mailaddress.ts +754 -0
  101. package/ts/debian/reader.ts +126 -0
  102. package/ts/index.ts +46 -0
  103. package/ts/plugins.ts +13 -0
  104. package/ts/readers/ar.ts +102 -0
  105. package/ts/readers/bytes.ts +182 -0
  106. package/ts/readers/bytesource.ts +164 -0
  107. package/ts/readers/decompress.ts +332 -0
  108. package/ts/readers/gzipmember.ts +209 -0
  109. package/ts/readers/tar.ts +835 -0
  110. package/ts/readers/text.ts +91 -0
  111. package/ts/rpm/builder.ts +253 -0
  112. package/ts/rpm/header.ts +628 -0
  113. package/ts/rpm/reader.ts +317 -0
  114. package/ts/rpm/repoconfig.ts +45 -0
  115. package/ts/rpm/repodata.ts +219 -0
  116. package/ts/rpm/sign.ts +200 -0
  117. package/ts/rpm/updates.ts +314 -0
  118. package/ts/rpm/xml.ts +101 -0
  119. package/ts/shared/architecture.ts +16 -0
  120. package/ts/shared/errors.ts +78 -0
  121. package/ts/shared/gopath.ts +54 -0
  122. package/ts/shared/gostrings.ts +31 -0
  123. package/ts/shared/limits.ts +93 -0
  124. package/ts/shared/types.ts +251 -0
  125. package/ts/shared/url.ts +387 -0
  126. package/ts/shared/validation.ts +71 -0
  127. package/ts/signing/keys.ts +110 -0
  128. package/ts/signing/openpgpframing.ts +115 -0
  129. package/ts/signing/signers.ts +315 -0
  130. package/ts/writers/gotar.ts +256 -0
  131. 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
+ }