@push.rocks/smartlinuxrepo 0.0.0-stage → 1.0.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 +65 -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,241 @@
1
+ import * as plugins from '../plugins.js';
2
+ import { isIndexableArchitecture } from '../shared/architecture.js';
3
+ import { LinuxRepoError } from '../shared/errors.js';
4
+ import type { IAlpinePackageRecord, ILinuxIndexFile } from '../shared/types.js';
5
+ import { assertSegmentName, assertString, assertTimestamp } from '../shared/validation.js';
6
+ import type { IAlpineSigner } from '../signing/signers.js';
7
+ import { concatBytes } from '../readers/bytes.js';
8
+ import { writeGoTar } from '../writers/gotar.js';
9
+
10
+ /** Gitea's `alpine_module.NoArch`: these packages are listed in every architecture's index too. */
11
+ const NOARCH = 'noarch';
12
+ const INDEX_FILE_NAME = 'APKINDEX';
13
+ const INDEX_ARCHIVE_NAME = 'APKINDEX.tar.gz';
14
+ const INDEX_MODE = 0o600;
15
+ const encoder = new TextEncoder();
16
+
17
+ /**
18
+ * Builds the `APKINDEX.tar.gz` set of one Alpine repository (one branch and
19
+ * repository) as Gitea 28.2.0 does (`services/packages/alpine/repository.go:150-351`):
20
+ * one index per architecture the entries name, at
21
+ * `{branch}/{repository}/{architecture}/APKINDEX.tar.gz`. Every index other
22
+ * than `noarch` also lists the `noarch` packages, and every block's `A:` is the
23
+ * index's architecture.
24
+ *
25
+ * Each archive is a signature gzip member (a tar entry
26
+ * `.SIGN.RSA.<signer.keyName>` holding RSA PKCS#1 v1.5 over SHA-1 of the
27
+ * unsigned stream, without an end-of-archive marker) followed by the unsigned
28
+ * gzip member (a tar entry `APKINDEX`, mode 0600, with the marker). Tar headers
29
+ * are byte-for-byte what Go `archive/tar` writes for Gitea (`writeGoTar`); the
30
+ * signature entry is padded to its 512-byte block (a difference from Gitea),
31
+ * which Gitea's 4096-bit signatures already fill exactly. Entries are sorted by name, version, architecture and file
32
+ * name (bytewise), so the bytes depend on the entry set, not its order.
33
+ *
34
+ * Pure: the signer is the only call out. `timestamp` is validated but not
35
+ * written; the format carries no build time. An empty entry set returns `[]`.
36
+ * Bad input is `invalid-input` and names the entry; an error from the signer
37
+ * propagates unchanged; a signer result that is not non-empty bytes is
38
+ * `signing-failed`.
39
+ */
40
+ export async function buildAlpineIndex(inputArg: {
41
+ branch: string;
42
+ repository: string;
43
+ entries: IAlpinePackageRecord[];
44
+ signer: IAlpineSigner;
45
+ timestamp: Date;
46
+ }): Promise<ILinuxIndexFile[]> {
47
+ if (inputArg === null || typeof inputArg !== 'object') {
48
+ throw new LinuxRepoError('invalid-input', 'input must be an object', { field: 'input' });
49
+ }
50
+ assertLayoutSegment(inputArg.branch, 'branch');
51
+ assertLayoutSegment(inputArg.repository, 'repository');
52
+ assertTimestamp(inputArg.timestamp);
53
+ const signer = inputArg.signer;
54
+ if (signer === null || typeof signer !== 'object' || typeof signer.sign !== 'function') {
55
+ throw new LinuxRepoError('invalid-input', 'signer must be an IAlpineSigner', { field: 'signer' });
56
+ }
57
+ assertSegmentName(signer.keyName, 'signer.keyName');
58
+ if (!Array.isArray(inputArg.entries)) {
59
+ throw new LinuxRepoError('invalid-input', 'entries must be an array', { field: 'entries' });
60
+ }
61
+
62
+ const entries = inputArg.entries.map((record, index) => checkRecord(record, `entries[${index}]`));
63
+ const seen = new Set<string>();
64
+ for (const record of entries) {
65
+ const key = `${record.architecture}/${record.fileName}`;
66
+ if (seen.has(key)) {
67
+ throw new LinuxRepoError('invalid-input', `duplicate package file ${key}`, { field: 'entries', value: key });
68
+ }
69
+ seen.add(key);
70
+ }
71
+ entries.sort(compareRecords);
72
+
73
+ const architectures = [...new Set(entries.map((record) => record.architecture))].sort(compareBytes);
74
+ const files: ILinuxIndexFile[] = [];
75
+ for (const architecture of architectures) {
76
+ const listed = entries.filter(
77
+ (record) => record.architecture === architecture || (architecture !== NOARCH && record.architecture === NOARCH),
78
+ );
79
+ const text = listed.map((record) => renderAlpineIndexEntry(record, architecture)).join('');
80
+ const unsigned = await gzip(
81
+ writeGoTar([{ name: INDEX_FILE_NAME, mode: INDEX_MODE, data: encoder.encode(text) }], { endOfArchive: true }),
82
+ );
83
+ const signature: unknown = await signer.sign(unsigned);
84
+ if (!(signature instanceof Uint8Array) || signature.length === 0) {
85
+ throw new LinuxRepoError('signing-failed', 'the Alpine signer did not return signature bytes', {
86
+ field: 'signer',
87
+ });
88
+ }
89
+ const signatureMember = await gzip(
90
+ writeGoTar([{ name: `.SIGN.RSA.${signer.keyName}`, mode: INDEX_MODE, data: signature }], { endOfArchive: false }),
91
+ );
92
+ const bytes = concatBytes([signatureMember, unsigned]);
93
+ files.push({
94
+ path: `${inputArg.branch}/${inputArg.repository}/${architecture}/${INDEX_ARCHIVE_NAME}`,
95
+ bytes,
96
+ sha256: plugins.crypto.createHash('sha256').update(bytes).digest('hex'),
97
+ mediaType: 'application/gzip',
98
+ stage: 'pointer',
99
+ aliases: [],
100
+ });
101
+ }
102
+ return files;
103
+ }
104
+
105
+ /**
106
+ * One APKINDEX block for `recordArg` in the index of `architectureArg`, as
107
+ * Gitea writes it (`services/packages/alpine/repository.go:215-252`),
108
+ * including the blank line that ends it. Internal: 1.0.0 exports no
109
+ * per-entry renderers. The record must have passed `checkRecord`.
110
+ */
111
+ export function renderAlpineIndexEntry(recordArg: IAlpinePackageRecord, architectureArg: string): string {
112
+ const metadata = recordArg.metadata;
113
+ const lines = [
114
+ `C:Q1${Buffer.from(recordArg.digests.controlSha1, 'hex').toString('base64')}`,
115
+ `P:${recordArg.name}`,
116
+ `V:${recordArg.version}`,
117
+ `A:${architectureArg}`,
118
+ ];
119
+ if (metadata.description !== '') {
120
+ lines.push(`T:${metadata.description}`);
121
+ }
122
+ if (metadata.projectUrl !== '') {
123
+ lines.push(`U:${metadata.projectUrl}`);
124
+ }
125
+ if (metadata.license !== '') {
126
+ lines.push(`L:${metadata.license}`);
127
+ }
128
+ lines.push(
129
+ `S:${recordArg.digests.size}`,
130
+ `I:${metadata.installedSize}`,
131
+ `o:${metadata.origin}`,
132
+ `m:${metadata.maintainer}`,
133
+ `t:${metadata.buildDate}`,
134
+ );
135
+ if (metadata.commit !== '') {
136
+ lines.push(`c:${metadata.commit}`);
137
+ }
138
+ if (metadata.dependencies.length > 0) {
139
+ lines.push(`D:${metadata.dependencies.join(' ')}`);
140
+ }
141
+ if (metadata.provides.length > 0) {
142
+ lines.push(`p:${metadata.provides.join(' ')}`);
143
+ }
144
+ if (metadata.installIf !== '') {
145
+ lines.push(`i:${metadata.installIf}`);
146
+ }
147
+ if (metadata.providerPriority > 0) {
148
+ lines.push(`k:${metadata.providerPriority}`);
149
+ }
150
+ return `${lines.join('\n')}\n\n`;
151
+ }
152
+
153
+ /**
154
+ * Checks what the index writes. A value with a line break would forge APKINDEX
155
+ * fields; Gitea's line-based `.PKGINFO` parser never produces one, so refusing
156
+ * it refuses no package Gitea stored.
157
+ */
158
+ function checkRecord(recordArg: unknown, fieldArg: string): IAlpinePackageRecord {
159
+ const fail = (whatArg: string): never => {
160
+ throw new LinuxRepoError('invalid-input', `${fieldArg}: ${whatArg}`, { field: fieldArg });
161
+ };
162
+ if (recordArg === null || typeof recordArg !== 'object') {
163
+ return fail('must be an Alpine package record');
164
+ }
165
+ const record = recordArg as IAlpinePackageRecord;
166
+ if (record.format !== 'alpine' || record.metadata === null || typeof record.metadata !== 'object') {
167
+ return fail('must be an Alpine package record');
168
+ }
169
+ if (record.digests === null || typeof record.digests !== 'object') {
170
+ return fail('digests must be an object');
171
+ }
172
+ const text = (valueArg: unknown, nameArg: string, nonEmptyArg = false): void => {
173
+ if (typeof valueArg !== 'string' || /[\n]/.test(valueArg) || (nonEmptyArg && valueArg === '')) {
174
+ fail(`${nameArg} must be a${nonEmptyArg ? ' non-empty' : ''} string without line breaks`);
175
+ }
176
+ };
177
+ const integer = (valueArg: unknown, nameArg: string, minimumArg = -Number.MAX_SAFE_INTEGER): void => {
178
+ if (typeof valueArg !== 'number' || !Number.isSafeInteger(valueArg) || valueArg < minimumArg) {
179
+ fail(`${nameArg} must be a safe integer${minimumArg === 0 ? ' >= 0' : ''}`);
180
+ }
181
+ };
182
+ const list = (valueArg: unknown, nameArg: string): void => {
183
+ if (!Array.isArray(valueArg)) {
184
+ fail(`${nameArg} must be an array`);
185
+ }
186
+ (valueArg as unknown[]).forEach((item, index) => text(item, `${nameArg}[${index}]`));
187
+ };
188
+ text(record.name, 'name', true);
189
+ text(record.version, 'version', true);
190
+ assertString(record.fileName, `${fieldArg}.fileName`);
191
+ if (!isIndexableArchitecture(record.architecture)) {
192
+ fail(`architecture ${JSON.stringify(record.architecture)} of ${record.fileName} cannot name an index`);
193
+ }
194
+ if (typeof record.digests.controlSha1 !== 'string' || !/^[0-9a-f]{40}$/.test(record.digests.controlSha1)) {
195
+ fail('digests.controlSha1 must be 40 lowercase hex digits');
196
+ }
197
+ integer(record.digests.size, 'digests.size', 0);
198
+ const metadata = record.metadata;
199
+ for (const key of ['description', 'license', 'projectUrl', 'maintainer', 'origin', 'commit', 'installIf'] as const) {
200
+ text(metadata[key], `metadata.${key}`);
201
+ }
202
+ integer(metadata.installedSize, 'metadata.installedSize');
203
+ integer(metadata.buildDate, 'metadata.buildDate');
204
+ integer(metadata.providerPriority, 'metadata.providerPriority');
205
+ list(metadata.dependencies, 'metadata.dependencies');
206
+ list(metadata.provides, 'metadata.provides');
207
+ return record;
208
+ }
209
+
210
+ function assertLayoutSegment(valueArg: unknown, fieldArg: string): asserts valueArg is string {
211
+ assertSegmentName(valueArg, fieldArg);
212
+ if (valueArg === '.' || valueArg === '..') {
213
+ throw new LinuxRepoError('invalid-input', `${fieldArg} must not be "." or ".."`, { field: fieldArg });
214
+ }
215
+ }
216
+
217
+ function compareRecords(aArg: IAlpinePackageRecord, bArg: IAlpinePackageRecord): number {
218
+ return (
219
+ compareBytes(aArg.name, bArg.name) ||
220
+ compareBytes(aArg.version, bArg.version) ||
221
+ compareBytes(aArg.architecture, bArg.architecture) ||
222
+ compareBytes(aArg.fileName, bArg.fileName)
223
+ );
224
+ }
225
+
226
+ /** Orders strings by their UTF-8 bytes. */
227
+ function compareBytes(aArg: string, bArg: string): number {
228
+ return Buffer.compare(Buffer.from(aArg, 'utf8'), Buffer.from(bArg, 'utf8'));
229
+ }
230
+
231
+ function gzip(bytesArg: Uint8Array): Promise<Uint8Array> {
232
+ return new Promise((resolve, reject) => {
233
+ plugins.zlib.gzip(bytesArg, (error, result) => {
234
+ if (error) {
235
+ reject(error);
236
+ } else {
237
+ resolve(new Uint8Array(result.buffer, result.byteOffset, result.byteLength));
238
+ }
239
+ });
240
+ });
241
+ }
@@ -0,0 +1,183 @@
1
+ import { LinuxRepoError, limitExceeded } from '../shared/errors.js';
2
+ import type { ILinuxPackageLimits } from '../shared/limits.js';
3
+ import { goParseInt64, goTrimSpace } from '../shared/gostrings.js';
4
+ import { isValidUrl } from '../shared/url.js';
5
+ import { decodeGoUtf8 } from '../readers/text.js';
6
+
7
+ /** What Gitea 28.2.0 derives from an apk `.PKGINFO` (`modules/packages/alpine/metadata.go:137-227`). */
8
+ export interface IAlpinePackageInfo {
9
+ name: string;
10
+ version: string;
11
+ architecture: string;
12
+ description: string;
13
+ license: string;
14
+ projectUrl: string;
15
+ maintainer: string;
16
+ packager: string;
17
+ buildDate: number;
18
+ installedSize: number;
19
+ origin: string;
20
+ commit: string;
21
+ installIf: string;
22
+ provides: string[];
23
+ dependencies: string[];
24
+ providerPriority: number;
25
+ }
26
+
27
+ const MAX_SAFE = BigInt(Number.MAX_SAFE_INTEGER);
28
+
29
+ /**
30
+ * Parses `.PKGINFO` as Gitea's `ParsePackageInfo` does:
31
+ * - lines split as `bufio.ScanLines` (LF, one trailing CR dropped, a final
32
+ * line without LF counts);
33
+ * - `#` lines and lines without `=` are skipped; key and value are
34
+ * `strings.TrimSpace`d around the first `=`; the last value wins, except
35
+ * `provides` and `depend`, which collect every non-empty value;
36
+ * - `builddate`, `size` and `provider_priority` are `strconv.ParseInt(v, 10,
37
+ * 64)`; a value Go cannot parse (including one outside int64) is ignored,
38
+ * so the field keeps its previous value, 0 when there is none
39
+ * (`metadata.go:165-174`, `:203-207`);
40
+ * - an empty `pkgname` or `pkgver` is refused; a `url` Gitea's URL validation
41
+ * refuses becomes `''`.
42
+ *
43
+ * Bytes are decoded with `decodeGoUtf8`. Where the codec differs from Gitea
44
+ * (test/test.alpine.reader.node.ts pins each case against Gitea's own code):
45
+ * - a line is bounded by `maxHeaderBytes` instead of Go's 64 KiB scanner buffer;
46
+ * - `provides` plus `depend` values are bounded by `maxEntries` instead of 1024;
47
+ * - invalid UTF-8 becomes U+FFFD per byte, where Gitea 28.2.0 refuses the
48
+ * upload because its `encoding/json/v2` cannot store the metadata;
49
+ * - a number Go parses but JSON cannot hold exactly (beyond
50
+ * `Number.MAX_SAFE_INTEGER` in magnitude) is refused as `invalid-field`,
51
+ * never rounded.
52
+ */
53
+ export function parseAlpinePackageInfo(bytesArg: Uint8Array, limitsArg: Readonly<ILinuxPackageLimits>): IAlpinePackageInfo {
54
+ const info: IAlpinePackageInfo = {
55
+ name: '',
56
+ version: '',
57
+ architecture: '',
58
+ description: '',
59
+ license: '',
60
+ projectUrl: '',
61
+ maintainer: '',
62
+ packager: '',
63
+ buildDate: 0,
64
+ installedSize: 0,
65
+ origin: '',
66
+ commit: '',
67
+ installIf: '',
68
+ provides: [],
69
+ dependencies: [],
70
+ providerPriority: 0,
71
+ };
72
+ for (const line of scanLines(bytesArg, limitsArg.maxHeaderBytes)) {
73
+ if (line.startsWith('#')) {
74
+ continue;
75
+ }
76
+ const equals = line.indexOf('=');
77
+ if (equals < 0) {
78
+ continue;
79
+ }
80
+ const key = goTrimSpace(line.slice(0, equals));
81
+ const value = goTrimSpace(line.slice(equals + 1));
82
+ switch (key) {
83
+ case 'pkgname':
84
+ info.name = value;
85
+ break;
86
+ case 'pkgver':
87
+ info.version = value;
88
+ break;
89
+ case 'pkgdesc':
90
+ info.description = value;
91
+ break;
92
+ case 'url':
93
+ info.projectUrl = value;
94
+ break;
95
+ case 'builddate':
96
+ info.buildDate = goParseInt(value, key) ?? info.buildDate;
97
+ break;
98
+ case 'size':
99
+ info.installedSize = goParseInt(value, key) ?? info.installedSize;
100
+ break;
101
+ case 'arch':
102
+ info.architecture = value;
103
+ break;
104
+ case 'origin':
105
+ info.origin = value;
106
+ break;
107
+ case 'commit':
108
+ info.commit = value;
109
+ break;
110
+ case 'maintainer':
111
+ info.maintainer = value;
112
+ break;
113
+ case 'packager':
114
+ info.packager = value;
115
+ break;
116
+ case 'license':
117
+ info.license = value;
118
+ break;
119
+ case 'install_if':
120
+ info.installIf = value;
121
+ break;
122
+ case 'provides':
123
+ case 'depend':
124
+ if (value !== '') {
125
+ if (info.provides.length + info.dependencies.length >= limitsArg.maxEntries) {
126
+ throw limitExceeded('maxEntries', limitsArg.maxEntries, '.PKGINFO provides and depend values');
127
+ }
128
+ (key === 'provides' ? info.provides : info.dependencies).push(value);
129
+ }
130
+ break;
131
+ case 'provider_priority':
132
+ info.providerPriority = goParseInt(value, key) ?? info.providerPriority;
133
+ break;
134
+ }
135
+ }
136
+ if (info.name === '') {
137
+ throw new LinuxRepoError('missing-field', '.PKGINFO has no pkgname', { field: 'pkgname' });
138
+ }
139
+ if (info.version === '') {
140
+ throw new LinuxRepoError('missing-field', '.PKGINFO has no pkgver', { field: 'pkgver' });
141
+ }
142
+ if (!isValidUrl(info.projectUrl)) {
143
+ info.projectUrl = '';
144
+ }
145
+ return info;
146
+ }
147
+
148
+ /** `bufio.ScanLines` over the bytes, each line decoded as Gitea stores it. */
149
+ function* scanLines(bytesArg: Uint8Array, maxLineBytesArg: number): Generator<string> {
150
+ let start = 0;
151
+ while (start < bytesArg.length) {
152
+ const newline = bytesArg.indexOf(0x0a, start);
153
+ const end = newline < 0 ? bytesArg.length : newline;
154
+ let lineEnd = end;
155
+ if (lineEnd > start && bytesArg[lineEnd - 1] === 0x0d) {
156
+ lineEnd -= 1;
157
+ }
158
+ if (lineEnd - start > maxLineBytesArg) {
159
+ throw limitExceeded('maxHeaderBytes', maxLineBytesArg, '.PKGINFO line');
160
+ }
161
+ yield decodeGoUtf8(bytesArg.subarray(start, lineEnd));
162
+ start = newline < 0 ? bytesArg.length : newline + 1;
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Go `strconv.ParseInt(value, 10, 64)`: `null` where Go returns an error (Gitea
168
+ * then keeps the previous value); `invalid-field` for an int64 beyond the safe
169
+ * JSON integer range.
170
+ */
171
+ function goParseInt(valueArg: string, fieldArg: string): number | null {
172
+ const value = goParseInt64(valueArg);
173
+ if (value === null) {
174
+ return null;
175
+ }
176
+ if (value > MAX_SAFE || value < -MAX_SAFE) {
177
+ throw new LinuxRepoError('invalid-field', `.PKGINFO ${fieldArg} is beyond the safe integer range`, {
178
+ field: fieldArg,
179
+ value: valueArg,
180
+ });
181
+ }
182
+ return Number(value) + 0;
183
+ }
@@ -0,0 +1,96 @@
1
+ import { LinuxRepoError, truncated } from '../shared/errors.js';
2
+ import { resolveLinuxPackageLimits, type ILinuxPackageLimits } from '../shared/limits.js';
3
+ import type { IAlpinePackageRecord } from '../shared/types.js';
4
+ import { BufferReader, readUpTo } from '../readers/bytes.js';
5
+ import { PackageByteSource } from '../readers/bytesource.js';
6
+ import { readGzipMember } from '../readers/gzipmember.js';
7
+ import { readTarEntryData, TarReader } from '../readers/tar.js';
8
+ import { parseAlpinePackageInfo } from './pkginfo.js';
9
+
10
+ const PKGINFO = '.PKGINFO';
11
+
12
+ /**
13
+ * Reads an apk v2 package (concatenated gzip members: usually the signature,
14
+ * the control segment holding `.PKGINFO`, and the data) into a record, as
15
+ * Gitea 28.2.0's `ParsePackage` does (`modules/packages/alpine/metadata.go:77-134`):
16
+ * - gzip members are read one at a time; the first member whose tar holds an
17
+ * entry named exactly `.PKGINFO` is the control segment. Unsigned packages
18
+ * (no signature member) are accepted;
19
+ * - `digests.controlSha1` is the SHA-1 of exactly that member's bytes (the
20
+ * APKINDEX `C:` value is `Q1` plus its base64);
21
+ * - the rest of the input is hashed, never decompressed;
22
+ * - the record's `fileName` is Gitea's `{name}-{version}.apk`.
23
+ *
24
+ * Each member read before the control segment is buffered and bounded by
25
+ * `maxMetadataBytes` (member bytes) and `maxDecompressedMetadataBytes` (its
26
+ * tar). An apk v3 (ADB) package is `unsupported-package-format`, as Gitea
27
+ * refuses it too. On failure the reader stops pulling and returns no record.
28
+ */
29
+ export async function readAlpinePackage(
30
+ sourceArg: AsyncIterable<Uint8Array>,
31
+ limitsArg?: Partial<ILinuxPackageLimits>,
32
+ ): Promise<IAlpinePackageRecord> {
33
+ const limits = resolveLinuxPackageLimits(limitsArg);
34
+ const input = new PackageByteSource(sourceArg, limits.maxPackageBytes);
35
+
36
+ const head = await readUpTo(input, 3);
37
+ if (head.length === 0) {
38
+ throw truncated('apk package');
39
+ }
40
+ if (head.length === 3 && head[0] === 0x41 && head[1] === 0x44 && head[2] === 0x42) {
41
+ throw new LinuxRepoError('unsupported-package-format', 'apk v3 (ADB) packages are not supported', {
42
+ field: 'apk package',
43
+ });
44
+ }
45
+ input.unread(head);
46
+
47
+ for (;;) {
48
+ const member = await readGzipMember(input, {
49
+ maxMemberBytes: limits.maxMetadataBytes,
50
+ memberLimit: 'maxMetadataBytes',
51
+ maxOutputBytes: limits.maxDecompressedMetadataBytes,
52
+ outputLimit: 'maxDecompressedMetadataBytes',
53
+ maxHeaderBytes: limits.maxHeaderBytes,
54
+ });
55
+ if (member === null) {
56
+ throw new LinuxRepoError('missing-field', 'the package has no .PKGINFO', { field: PKGINFO });
57
+ }
58
+ const tar = new TarReader(new BufferReader(member.output), {
59
+ maxEntries: limits.maxEntries,
60
+ maxHeaderBytes: limits.maxHeaderBytes,
61
+ });
62
+ for (let entry = await tar.next(); entry !== null; entry = await tar.next()) {
63
+ if (entry.name !== PKGINFO) {
64
+ continue;
65
+ }
66
+ // Gitea stops at the first .PKGINFO and ignores the rest of the member's tar
67
+ const pkginfo = await readTarEntryData(entry, limits.maxDecompressedMetadataBytes, 'maxDecompressedMetadataBytes');
68
+ const info = parseAlpinePackageInfo(pkginfo, limits);
69
+ await input.drain();
70
+ const digests = input.digests();
71
+ return {
72
+ format: 'alpine',
73
+ name: info.name,
74
+ version: info.version,
75
+ architecture: info.architecture,
76
+ fileName: `${info.name}-${info.version}.apk`,
77
+ digests: { ...digests, controlSha1: member.memberSha1 },
78
+ metadata: {
79
+ description: info.description,
80
+ license: info.license,
81
+ projectUrl: info.projectUrl,
82
+ maintainer: info.maintainer,
83
+ packager: info.packager,
84
+ buildDate: info.buildDate,
85
+ installedSize: info.installedSize,
86
+ origin: info.origin,
87
+ commit: info.commit,
88
+ installIf: info.installIf,
89
+ provides: info.provides,
90
+ dependencies: info.dependencies,
91
+ providerPriority: info.providerPriority,
92
+ },
93
+ };
94
+ }
95
+ }
96
+ }