@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
package/ts/rpm/sign.ts
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import * as plugins from '../plugins.js';
|
|
2
|
+
import { PackageByteSource } from '../readers/bytesource.js';
|
|
3
|
+
import { READ_CHUNK_BYTES } from '../readers/bytes.js';
|
|
4
|
+
import { LinuxRepoError } from '../shared/errors.js';
|
|
5
|
+
import { resolveLinuxPackageLimits, type ILinuxPackageLimits } from '../shared/limits.js';
|
|
6
|
+
import { assertTimestamp } from '../shared/validation.js';
|
|
7
|
+
import { createDetachedBinarySignature } from '../signing/openpgpframing.js';
|
|
8
|
+
import { singleChunk, type IOpenPgpSigner } from '../signing/signers.js';
|
|
9
|
+
import { readRpmHeaders, RPM_SIGNATURE_TAG, RPM_TYPE, writeSignatureHeader, type IRpmPackageHeaders } from './header.js';
|
|
10
|
+
|
|
11
|
+
const PAYLOAD_DIGEST = 5092;
|
|
12
|
+
const PAYLOAD_DIGEST_ALGORITHM = 5093;
|
|
13
|
+
|
|
14
|
+
/** go-rpmutils `getPayloadDigest` algorithms (`signatures.go`, RFC 4880 ids). */
|
|
15
|
+
const PAYLOAD_DIGEST_ALGORITHMS = new Map<number, string>([
|
|
16
|
+
[1, 'md5'],
|
|
17
|
+
[2, 'sha1'],
|
|
18
|
+
[8, 'sha256'],
|
|
19
|
+
[9, 'sha384'],
|
|
20
|
+
[10, 'sha512'],
|
|
21
|
+
[11, 'sha224'],
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Signs an RPM package as Gitea 28.2.0 does on upload (`services/packages/rpm/sign.go:17-38`,
|
|
26
|
+
* go-rpmutils v0.4.0 `SignRpmStream` and `DumpSignatureHeader(false)`):
|
|
27
|
+
* - the main header digest is checked, and the payload is checked against
|
|
28
|
+
* PAYLOADDIGEST (5092/5093), else the signature header MD5 (1004) over
|
|
29
|
+
* header and payload; a package with neither is `missing-field`, a mismatch
|
|
30
|
+
* `digest-mismatch`;
|
|
31
|
+
* - tag 1002 gets a signature over the main header and payload, tag 268 one
|
|
32
|
+
* over the main header alone; tags 1005, 267 and 1008 are removed; every
|
|
33
|
+
* other signature tag from 64 up is kept byte for byte, the tags are written
|
|
34
|
+
* in ascending order behind a new region tag 62, and the header is padded to
|
|
35
|
+
* 8 bytes.
|
|
36
|
+
*
|
|
37
|
+
* The signed package is `prefix` (the lead and the new signature header)
|
|
38
|
+
* followed by the original bytes from `resumeAt`, the start of the main
|
|
39
|
+
* header. The source is read once; only the headers are held in memory, and
|
|
40
|
+
* the payload streams through the signer. A signer that returns before reading
|
|
41
|
+
* the data to its end is `signing-failed`.
|
|
42
|
+
*/
|
|
43
|
+
export async function signRpmPackage(input: {
|
|
44
|
+
source: AsyncIterable<Uint8Array>;
|
|
45
|
+
signer: IOpenPgpSigner;
|
|
46
|
+
timestamp: Date;
|
|
47
|
+
limits?: Partial<ILinuxPackageLimits>;
|
|
48
|
+
}): Promise<{ prefix: Uint8Array; resumeAt: number }> {
|
|
49
|
+
if (input === null || typeof input !== 'object') {
|
|
50
|
+
throw new LinuxRepoError('invalid-input', 'input must be an object', { field: 'input' });
|
|
51
|
+
}
|
|
52
|
+
if (input.signer === null || typeof input.signer !== 'object' || typeof input.signer.signBinary !== 'function') {
|
|
53
|
+
throw new LinuxRepoError('invalid-input', 'signer must implement IOpenPgpSigner', { field: 'signer' });
|
|
54
|
+
}
|
|
55
|
+
assertTimestamp(input.timestamp);
|
|
56
|
+
const limits = resolveLinuxPackageLimits(input.limits);
|
|
57
|
+
const bytes = new PackageByteSource(input.source, limits.maxPackageBytes);
|
|
58
|
+
const headers = await readRpmHeaders(bytes, limits);
|
|
59
|
+
const payloadCheck = payloadDigestCheck(headers);
|
|
60
|
+
|
|
61
|
+
const signedData = new SignedDataStream(headers.main.bytes, bytes, payloadCheck);
|
|
62
|
+
let headerAndPayload: Uint8Array;
|
|
63
|
+
try {
|
|
64
|
+
headerAndPayload = await createDetachedBinarySignature({ data: signedData, signer: input.signer, timestamp: input.timestamp });
|
|
65
|
+
} catch (error) {
|
|
66
|
+
// a failure of the codec's own stream (or the caller's source) wins over what the signer made of it
|
|
67
|
+
throw signedData.failure !== undefined ? signedData.failure.error : error;
|
|
68
|
+
}
|
|
69
|
+
if (signedData.failure !== undefined) {
|
|
70
|
+
throw signedData.failure.error;
|
|
71
|
+
}
|
|
72
|
+
if (!signedData.completed) {
|
|
73
|
+
throw new LinuxRepoError('signing-failed', 'the signer returned before reading the package to its end', { field: 'signer' });
|
|
74
|
+
}
|
|
75
|
+
const headerOnly = await createDetachedBinarySignature({
|
|
76
|
+
data: singleChunk(headers.main.bytes),
|
|
77
|
+
signer: input.signer,
|
|
78
|
+
timestamp: input.timestamp,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const removed = new Set<number>([
|
|
82
|
+
RPM_SIGNATURE_TAG.pgp,
|
|
83
|
+
RPM_SIGNATURE_TAG.rsa,
|
|
84
|
+
RPM_SIGNATURE_TAG.gpg,
|
|
85
|
+
RPM_SIGNATURE_TAG.dsa,
|
|
86
|
+
RPM_SIGNATURE_TAG.reservedSpace,
|
|
87
|
+
]);
|
|
88
|
+
const signatureHeader = writeSignatureHeader(headers.signature, removed, [
|
|
89
|
+
{ tag: RPM_SIGNATURE_TAG.pgp, type: RPM_TYPE.bin, count: headerAndPayload.length, contents: headerAndPayload },
|
|
90
|
+
{ tag: RPM_SIGNATURE_TAG.rsa, type: RPM_TYPE.bin, count: headerOnly.length, contents: headerOnly },
|
|
91
|
+
]);
|
|
92
|
+
const prefix = new Uint8Array(headers.lead.length + signatureHeader.length);
|
|
93
|
+
prefix.set(headers.lead, 0);
|
|
94
|
+
prefix.set(signatureHeader, headers.lead.length);
|
|
95
|
+
return { prefix, resumeAt: headers.mainStart };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** How the payload is checked (go-rpmutils `digestPayload`, `verify_digests.go`). */
|
|
99
|
+
interface IPayloadCheck {
|
|
100
|
+
hash: ReturnType<typeof plugins.crypto.createHash>;
|
|
101
|
+
/** Whether the digest also covers the main header (the legacy MD5). */
|
|
102
|
+
coversHeader: boolean;
|
|
103
|
+
matches(digestArg: Buffer): boolean;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function payloadDigestCheck(headersArg: IRpmPackageHeaders): IPayloadCheck {
|
|
107
|
+
// the same lookups, in the same order, as go-rpmutils `getPayloadDigest`
|
|
108
|
+
const digests = headersArg.main.strings(PAYLOAD_DIGEST);
|
|
109
|
+
const algorithms = digests === null || digests.length === 0 ? null : headersArg.main.uint32s(PAYLOAD_DIGEST_ALGORITHM);
|
|
110
|
+
const algorithm = algorithms === null || algorithms.length === 0 ? undefined : PAYLOAD_DIGEST_ALGORITHMS.get(algorithms[0]);
|
|
111
|
+
if (algorithm !== undefined && digests !== null) {
|
|
112
|
+
const expected = digests[0];
|
|
113
|
+
return {
|
|
114
|
+
hash: plugins.crypto.createHash(algorithm),
|
|
115
|
+
coversHeader: false,
|
|
116
|
+
matches: (digestArg) => {
|
|
117
|
+
const actual = new TextEncoder().encode(digestArg.toString('hex'));
|
|
118
|
+
return actual.length === expected.length && actual.every((byte, i) => byte === expected[i]);
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
const md5 = headersArg.signature.binary(RPM_SIGNATURE_TAG.md5);
|
|
123
|
+
if (md5 !== null && md5.length !== 0) {
|
|
124
|
+
return {
|
|
125
|
+
hash: plugins.crypto.createHash('md5'),
|
|
126
|
+
coversHeader: true,
|
|
127
|
+
matches: (digestArg) => digestArg.length === md5.length && digestArg.every((byte, i) => byte === md5[i]),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
throw new LinuxRepoError('missing-field', 'the RPM package has no usable payload digest', { field: 'payload digest' });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The data tag 1002 signs: the main header, then the payload as it is pulled
|
|
135
|
+
* from the package source, hashed for the payload check on the way. Iterable
|
|
136
|
+
* once; it pulls only when the signer asks. Any failure (the caller's source,
|
|
137
|
+
* a limit, truncation, a digest mismatch) is thrown to the signer and kept, so
|
|
138
|
+
* `signRpmPackage` reports it instead of whatever the signer made of it.
|
|
139
|
+
*/
|
|
140
|
+
class SignedDataStream implements AsyncIterable<Uint8Array> {
|
|
141
|
+
public completed = false;
|
|
142
|
+
public failure: { error: unknown } | undefined = undefined;
|
|
143
|
+
private iterated = false;
|
|
144
|
+
private headerSent = false;
|
|
145
|
+
private queue: Promise<unknown> = Promise.resolve();
|
|
146
|
+
|
|
147
|
+
constructor(
|
|
148
|
+
private readonly mainHeader: Uint8Array,
|
|
149
|
+
private readonly source: PackageByteSource,
|
|
150
|
+
private readonly check: IPayloadCheck,
|
|
151
|
+
) {}
|
|
152
|
+
|
|
153
|
+
public [Symbol.asyncIterator](): AsyncIterator<Uint8Array> {
|
|
154
|
+
if (this.iterated) {
|
|
155
|
+
const error = new LinuxRepoError('signing-failed', 'the signer read the package data more than once', { field: 'signer' });
|
|
156
|
+
this.failure ??= { error };
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
this.iterated = true;
|
|
160
|
+
return {
|
|
161
|
+
next: () => {
|
|
162
|
+
// serialize: a signer that does not await one next() before the next one still gets the bytes in order
|
|
163
|
+
const result = this.queue.then(() => this.pull());
|
|
164
|
+
this.queue = result.catch(() => undefined);
|
|
165
|
+
return result;
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private async pull(): Promise<IteratorResult<Uint8Array>> {
|
|
171
|
+
if (this.failure !== undefined) {
|
|
172
|
+
throw this.failure.error;
|
|
173
|
+
}
|
|
174
|
+
if (this.completed) {
|
|
175
|
+
return { done: true, value: undefined };
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
if (!this.headerSent) {
|
|
179
|
+
this.headerSent = true;
|
|
180
|
+
if (this.check.coversHeader) {
|
|
181
|
+
this.check.hash.update(this.mainHeader);
|
|
182
|
+
}
|
|
183
|
+
return { done: false, value: this.mainHeader };
|
|
184
|
+
}
|
|
185
|
+
const chunk = await this.source.read(READ_CHUNK_BYTES);
|
|
186
|
+
if (chunk !== null) {
|
|
187
|
+
this.check.hash.update(chunk);
|
|
188
|
+
return { done: false, value: chunk };
|
|
189
|
+
}
|
|
190
|
+
if (!this.check.matches(this.check.hash.digest())) {
|
|
191
|
+
throw new LinuxRepoError('digest-mismatch', 'the RPM payload does not match its digest', { field: 'payload digest' });
|
|
192
|
+
}
|
|
193
|
+
this.completed = true;
|
|
194
|
+
return { done: true, value: undefined };
|
|
195
|
+
} catch (error) {
|
|
196
|
+
this.failure = { error };
|
|
197
|
+
throw error;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { LinuxRepoError } from '../shared/errors.js';
|
|
2
|
+
import type { IRpmUpdate, IRpmUpdateCollection, IRpmUpdatePackage, IRpmUpdateReference } from '../shared/types.js';
|
|
3
|
+
import { assertTimestamp } from '../shared/validation.js';
|
|
4
|
+
import { compareCodePoints, element, textElement, XML_HEADER } from './xml.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Merges posted errata into the stored ones, as Gitea 28.2.0's errata route
|
|
8
|
+
* does (`routers/api/packages/rpm/rpm.go:367-447`). Pure: the inputs are
|
|
9
|
+
* never changed, and incoming updates are applied in order.
|
|
10
|
+
* - A known id: its collections merge by `short` and its packages are
|
|
11
|
+
* de-duplicated by name, version, release and arch; the seven scalar fields
|
|
12
|
+
* and `references` are replaced; `updated` becomes the timestamp and
|
|
13
|
+
* `issued` is kept.
|
|
14
|
+
* - A new id: the update is appended; `issued` and `updated` get the
|
|
15
|
+
* timestamp only when they are `null`.
|
|
16
|
+
* - De-duplication applies to every append, including the collections of a new
|
|
17
|
+
* update (Gitea appends those as posted).
|
|
18
|
+
* Dates are written as `YYYY-MM-DD HH:MM:SS` in UTC (Gitea uses its server's
|
|
19
|
+
* local time). Contents are not validated, as in Gitea; only the shape is.
|
|
20
|
+
*/
|
|
21
|
+
export function mergeRpmUpdates(input: { existing: IRpmUpdate[]; incoming: IRpmUpdate[]; timestamp: Date }): IRpmUpdate[] {
|
|
22
|
+
if (input === null || typeof input !== 'object') {
|
|
23
|
+
throw new LinuxRepoError('invalid-input', 'input must be an object', { field: 'input' });
|
|
24
|
+
}
|
|
25
|
+
assertRpmUpdates(input.existing, 'existing');
|
|
26
|
+
assertRpmUpdates(input.incoming, 'incoming');
|
|
27
|
+
assertTimestamp(input.timestamp);
|
|
28
|
+
const now = formatRpmUpdateDate(input.timestamp);
|
|
29
|
+
const merged = input.existing.map(cloneUpdate);
|
|
30
|
+
for (const incoming of input.incoming) {
|
|
31
|
+
const update = cloneUpdate(incoming);
|
|
32
|
+
const known = merged.find((candidate) => candidate.id === update.id);
|
|
33
|
+
if (known !== undefined) {
|
|
34
|
+
mergeCollections(known.collections, update.collections);
|
|
35
|
+
known.from = update.from;
|
|
36
|
+
known.status = update.status;
|
|
37
|
+
known.type = update.type;
|
|
38
|
+
known.version = update.version;
|
|
39
|
+
known.title = update.title;
|
|
40
|
+
known.severity = update.severity;
|
|
41
|
+
known.description = update.description;
|
|
42
|
+
known.references = update.references;
|
|
43
|
+
known.updated = now;
|
|
44
|
+
} else {
|
|
45
|
+
const appended: IRpmUpdate = { ...update, issued: update.issued ?? now, updated: update.updated ?? now, collections: [] };
|
|
46
|
+
mergeCollections(appended.collections, update.collections);
|
|
47
|
+
merged.push(appended);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return merged;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The updates of an index: updates with the same id merge as
|
|
55
|
+
* Gitea's `buildUpdateInfo` does (`services/packages/rpm/repository.go:560-620`),
|
|
56
|
+
* without setting any date. The scalar fields, dates and references come from
|
|
57
|
+
* the update with the newest `updated`, then the newest `issued`, then the
|
|
58
|
+
* greatest canonical JSON of those fields; packages that collide keep the
|
|
59
|
+
* values of the higher-ranked update. Updates sort by id, collections by
|
|
60
|
+
* `short`, packages by name, version, release and arch; references keep their
|
|
61
|
+
* order. The input order never decides.
|
|
62
|
+
*/
|
|
63
|
+
export function combineRpmUpdates(updatesArg: IRpmUpdate[]): IRpmUpdate[] {
|
|
64
|
+
const byId = new Map<string, IRpmUpdate[]>();
|
|
65
|
+
for (const update of updatesArg) {
|
|
66
|
+
const group = byId.get(update.id);
|
|
67
|
+
if (group === undefined) {
|
|
68
|
+
byId.set(update.id, [update]);
|
|
69
|
+
} else {
|
|
70
|
+
group.push(update);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const combined: IRpmUpdate[] = [];
|
|
74
|
+
for (const group of byId.values()) {
|
|
75
|
+
const ranked = [...group].sort(compareRank);
|
|
76
|
+
const result: IRpmUpdate = { ...cloneUpdate(ranked[0]), collections: [] };
|
|
77
|
+
for (const update of ranked) {
|
|
78
|
+
mergeCollections(result.collections, update.collections.map(cloneCollection));
|
|
79
|
+
}
|
|
80
|
+
result.collections.sort((a, b) => compareCodePoints(a.short, b.short));
|
|
81
|
+
for (const collection of result.collections) {
|
|
82
|
+
collection.packages.sort(comparePackages);
|
|
83
|
+
}
|
|
84
|
+
combined.push(result);
|
|
85
|
+
}
|
|
86
|
+
return combined.sort((a, b) => compareCodePoints(a.id, b.id));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** `updateinfo.xml` for combined updates, as Go's `encoding/xml` writes Gitea's `Update` type. */
|
|
90
|
+
export function renderUpdateInfo(updatesArg: IRpmUpdate[]): string {
|
|
91
|
+
const updates = updatesArg.map((update) =>
|
|
92
|
+
element(
|
|
93
|
+
'update',
|
|
94
|
+
[
|
|
95
|
+
['from', update.from],
|
|
96
|
+
['status', update.status],
|
|
97
|
+
['type', update.type],
|
|
98
|
+
['version', update.version],
|
|
99
|
+
],
|
|
100
|
+
[
|
|
101
|
+
textElement('id', update.id),
|
|
102
|
+
textElement('title', update.title),
|
|
103
|
+
textElement('severity', update.severity),
|
|
104
|
+
textElement('description', update.description),
|
|
105
|
+
update.issued === null ? '' : element('issued', [['date', update.issued]]),
|
|
106
|
+
update.updated === null ? '' : element('updated', [['date', update.updated]]),
|
|
107
|
+
element(
|
|
108
|
+
'references',
|
|
109
|
+
[],
|
|
110
|
+
update.references
|
|
111
|
+
.map((reference) =>
|
|
112
|
+
element('reference', [
|
|
113
|
+
['href', reference.href],
|
|
114
|
+
['id', reference.id],
|
|
115
|
+
['title', reference.title],
|
|
116
|
+
['type', reference.type],
|
|
117
|
+
]),
|
|
118
|
+
)
|
|
119
|
+
.join(''),
|
|
120
|
+
),
|
|
121
|
+
element(
|
|
122
|
+
'pkglist',
|
|
123
|
+
[],
|
|
124
|
+
update.collections
|
|
125
|
+
.map((collection) =>
|
|
126
|
+
element(
|
|
127
|
+
'collection',
|
|
128
|
+
[['short', collection.short]],
|
|
129
|
+
collection.packages
|
|
130
|
+
.map((pkg) =>
|
|
131
|
+
element(
|
|
132
|
+
'package',
|
|
133
|
+
[
|
|
134
|
+
['arch', pkg.arch],
|
|
135
|
+
['name', pkg.name],
|
|
136
|
+
['release', pkg.release],
|
|
137
|
+
['src', pkg.src],
|
|
138
|
+
['version', pkg.version],
|
|
139
|
+
],
|
|
140
|
+
textElement('filename', pkg.fileName),
|
|
141
|
+
),
|
|
142
|
+
)
|
|
143
|
+
.join(''),
|
|
144
|
+
),
|
|
145
|
+
)
|
|
146
|
+
.join(''),
|
|
147
|
+
),
|
|
148
|
+
].join(''),
|
|
149
|
+
),
|
|
150
|
+
);
|
|
151
|
+
return `${XML_HEADER}${element('updates', [['xmlns', 'http://linux.duke.edu/metadata/updateinfo']], updates.join(''))}`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** `YYYY-MM-DD HH:MM:SS` in UTC. */
|
|
155
|
+
export function formatRpmUpdateDate(timestampArg: Date): string {
|
|
156
|
+
return timestampArg.toISOString().slice(0, 19).replace('T', ' ');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Refuses anything that is not an array of plain-JSON `IRpmUpdate` values. */
|
|
160
|
+
export function assertRpmUpdates(valueArg: unknown, fieldArg: string): asserts valueArg is IRpmUpdate[] {
|
|
161
|
+
if (!Array.isArray(valueArg)) {
|
|
162
|
+
throw new LinuxRepoError('invalid-input', `${fieldArg} must be an array of RPM updates`, { field: fieldArg });
|
|
163
|
+
}
|
|
164
|
+
valueArg.forEach((update: unknown, i) => {
|
|
165
|
+
const at = `${fieldArg}[${i}]`;
|
|
166
|
+
const record = assertObject(update, at);
|
|
167
|
+
for (const key of ['id', 'from', 'status', 'type', 'version', 'title', 'severity', 'description']) {
|
|
168
|
+
assertStringField(record, key, at);
|
|
169
|
+
}
|
|
170
|
+
for (const key of ['issued', 'updated']) {
|
|
171
|
+
if (record[key] !== null) {
|
|
172
|
+
assertStringField(record, key, at);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
assertArrayField(record, 'references', at).forEach((reference: unknown, r) => {
|
|
176
|
+
const referenceRecord = assertObject(reference, `${at}.references[${r}]`);
|
|
177
|
+
for (const key of ['href', 'id', 'title', 'type']) {
|
|
178
|
+
assertStringField(referenceRecord, key, `${at}.references[${r}]`);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
assertArrayField(record, 'collections', at).forEach((collection: unknown, c) => {
|
|
182
|
+
const collectionAt = `${at}.collections[${c}]`;
|
|
183
|
+
const collectionRecord = assertObject(collection, collectionAt);
|
|
184
|
+
assertStringField(collectionRecord, 'short', collectionAt);
|
|
185
|
+
assertArrayField(collectionRecord, 'packages', collectionAt).forEach((pkg: unknown, p) => {
|
|
186
|
+
const pkgRecord = assertObject(pkg, `${collectionAt}.packages[${p}]`);
|
|
187
|
+
for (const key of ['name', 'version', 'release', 'arch', 'src', 'fileName']) {
|
|
188
|
+
assertStringField(pkgRecord, key, `${collectionAt}.packages[${p}]`);
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function assertObject(valueArg: unknown, fieldArg: string): Record<string, unknown> {
|
|
196
|
+
if (valueArg === null || typeof valueArg !== 'object' || Array.isArray(valueArg)) {
|
|
197
|
+
throw new LinuxRepoError('invalid-input', `${fieldArg} must be an object`, { field: fieldArg });
|
|
198
|
+
}
|
|
199
|
+
return valueArg as Record<string, unknown>;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function assertStringField(recordArg: Record<string, unknown>, keyArg: string, atArg: string): void {
|
|
203
|
+
if (typeof recordArg[keyArg] !== 'string') {
|
|
204
|
+
throw new LinuxRepoError('invalid-input', `${atArg}.${keyArg} must be a string`, { field: `${atArg}.${keyArg}` });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function assertArrayField(recordArg: Record<string, unknown>, keyArg: string, atArg: string): unknown[] {
|
|
209
|
+
const value = recordArg[keyArg];
|
|
210
|
+
if (!Array.isArray(value)) {
|
|
211
|
+
throw new LinuxRepoError('invalid-input', `${atArg}.${keyArg} must be an array`, { field: `${atArg}.${keyArg}` });
|
|
212
|
+
}
|
|
213
|
+
return value;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Merges `incomingArg` into `targetArg` by `short`, de-duplicating packages (mutates the target only). */
|
|
217
|
+
function mergeCollections(targetArg: IRpmUpdateCollection[], incomingArg: IRpmUpdateCollection[]): void {
|
|
218
|
+
for (const collection of incomingArg) {
|
|
219
|
+
let target = targetArg.find((candidate) => candidate.short === collection.short);
|
|
220
|
+
if (target === undefined) {
|
|
221
|
+
target = { short: collection.short, packages: [] };
|
|
222
|
+
targetArg.push(target);
|
|
223
|
+
}
|
|
224
|
+
for (const pkg of collection.packages) {
|
|
225
|
+
if (!target.packages.some((candidate) => samePackage(candidate, pkg))) {
|
|
226
|
+
target.packages.push(clonePackage(pkg));
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function samePackage(aArg: IRpmUpdatePackage, bArg: IRpmUpdatePackage): boolean {
|
|
233
|
+
return aArg.name === bArg.name && aArg.version === bArg.version && aArg.release === bArg.release && aArg.arch === bArg.arch;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function comparePackages(aArg: IRpmUpdatePackage, bArg: IRpmUpdatePackage): number {
|
|
237
|
+
return (
|
|
238
|
+
compareCodePoints(aArg.name, bArg.name) ||
|
|
239
|
+
compareCodePoints(aArg.version, bArg.version) ||
|
|
240
|
+
compareCodePoints(aArg.release, bArg.release) ||
|
|
241
|
+
compareCodePoints(aArg.arch, bArg.arch)
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Highest rank first: newest `updated`, newest `issued`, greatest canonical JSON. */
|
|
246
|
+
function compareRank(aArg: IRpmUpdate, bArg: IRpmUpdate): number {
|
|
247
|
+
return (
|
|
248
|
+
compareNullableDesc(aArg.updated, bArg.updated) ||
|
|
249
|
+
compareNullableDesc(aArg.issued, bArg.issued) ||
|
|
250
|
+
compareCodePoints(canonicalScalars(bArg), canonicalScalars(aArg))
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function compareNullableDesc(aArg: string | null, bArg: string | null): number {
|
|
255
|
+
if (aArg === bArg) {
|
|
256
|
+
return 0;
|
|
257
|
+
}
|
|
258
|
+
if (aArg === null) {
|
|
259
|
+
return 1;
|
|
260
|
+
}
|
|
261
|
+
if (bArg === null) {
|
|
262
|
+
return -1;
|
|
263
|
+
}
|
|
264
|
+
return compareCodePoints(bArg, aArg);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function canonicalScalars(updateArg: IRpmUpdate): string {
|
|
268
|
+
return JSON.stringify([
|
|
269
|
+
updateArg.from,
|
|
270
|
+
updateArg.status,
|
|
271
|
+
updateArg.type,
|
|
272
|
+
updateArg.version,
|
|
273
|
+
updateArg.title,
|
|
274
|
+
updateArg.severity,
|
|
275
|
+
updateArg.description,
|
|
276
|
+
updateArg.issued,
|
|
277
|
+
updateArg.updated,
|
|
278
|
+
updateArg.references.map((reference) => [reference.href, reference.id, reference.title, reference.type]),
|
|
279
|
+
]);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function cloneUpdate(updateArg: IRpmUpdate): IRpmUpdate {
|
|
283
|
+
return {
|
|
284
|
+
id: updateArg.id,
|
|
285
|
+
from: updateArg.from,
|
|
286
|
+
status: updateArg.status,
|
|
287
|
+
type: updateArg.type,
|
|
288
|
+
version: updateArg.version,
|
|
289
|
+
title: updateArg.title,
|
|
290
|
+
severity: updateArg.severity,
|
|
291
|
+
description: updateArg.description,
|
|
292
|
+
issued: updateArg.issued,
|
|
293
|
+
updated: updateArg.updated,
|
|
294
|
+
references: updateArg.references.map(
|
|
295
|
+
(reference): IRpmUpdateReference => ({ href: reference.href, id: reference.id, title: reference.title, type: reference.type }),
|
|
296
|
+
),
|
|
297
|
+
collections: updateArg.collections.map(cloneCollection),
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function cloneCollection(collectionArg: IRpmUpdateCollection): IRpmUpdateCollection {
|
|
302
|
+
return { short: collectionArg.short, packages: collectionArg.packages.map(clonePackage) };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function clonePackage(pkgArg: IRpmUpdatePackage): IRpmUpdatePackage {
|
|
306
|
+
return {
|
|
307
|
+
name: pkgArg.name,
|
|
308
|
+
version: pkgArg.version,
|
|
309
|
+
release: pkgArg.release,
|
|
310
|
+
arch: pkgArg.arch,
|
|
311
|
+
src: pkgArg.src,
|
|
312
|
+
fileName: pkgArg.fileName,
|
|
313
|
+
};
|
|
314
|
+
}
|
package/ts/rpm/xml.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The XML output of Go's `encoding/xml` as Gitea 28.2.0 uses it: the header
|
|
3
|
+
* `xml.Header`, no indentation, no self-closing elements, attributes in struct
|
|
4
|
+
* field order, and Go's escaping (`EscapeText`/`EscapeString`): `"` and `'`
|
|
5
|
+
* become numeric references, tab, line feed and carriage return are escaped,
|
|
6
|
+
* and a code point outside the XML character range becomes U+FFFD.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const XML_HEADER = '<?xml version="1.0" encoding="UTF-8"?>\n';
|
|
10
|
+
|
|
11
|
+
const NEEDS_ESCAPE = /["'&<>\u0000-\u001f\ud800-\udfff\ufffe\uffff]/;
|
|
12
|
+
|
|
13
|
+
/** Go's escaping of character data and attribute values. */
|
|
14
|
+
export function escapeXml(valueArg: string): string {
|
|
15
|
+
if (!NEEDS_ESCAPE.test(valueArg)) {
|
|
16
|
+
return valueArg;
|
|
17
|
+
}
|
|
18
|
+
let result = '';
|
|
19
|
+
for (const character of valueArg) {
|
|
20
|
+
const codePoint = character.codePointAt(0)!;
|
|
21
|
+
switch (character) {
|
|
22
|
+
case '"':
|
|
23
|
+
result += '"';
|
|
24
|
+
break;
|
|
25
|
+
case "'":
|
|
26
|
+
result += ''';
|
|
27
|
+
break;
|
|
28
|
+
case '&':
|
|
29
|
+
result += '&';
|
|
30
|
+
break;
|
|
31
|
+
case '<':
|
|
32
|
+
result += '<';
|
|
33
|
+
break;
|
|
34
|
+
case '>':
|
|
35
|
+
result += '>';
|
|
36
|
+
break;
|
|
37
|
+
case '\t':
|
|
38
|
+
result += '	';
|
|
39
|
+
break;
|
|
40
|
+
case '\n':
|
|
41
|
+
result += '
';
|
|
42
|
+
break;
|
|
43
|
+
case '\r':
|
|
44
|
+
result += '
';
|
|
45
|
+
break;
|
|
46
|
+
default:
|
|
47
|
+
result += isXmlCharacter(codePoint) ? character : '\ufffd';
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return result;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Go's `isInCharacterRange`. */
|
|
54
|
+
function isXmlCharacter(codePointArg: number): boolean {
|
|
55
|
+
return (
|
|
56
|
+
codePointArg === 0x09 ||
|
|
57
|
+
codePointArg === 0x0a ||
|
|
58
|
+
codePointArg === 0x0d ||
|
|
59
|
+
(codePointArg >= 0x20 && codePointArg <= 0xd7ff) ||
|
|
60
|
+
(codePointArg >= 0xe000 && codePointArg <= 0xfffd) ||
|
|
61
|
+
(codePointArg >= 0x10000 && codePointArg <= 0x10ffff)
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Attributes in order; a value of `undefined` is omitted (Go's `omitempty` for an empty string is the caller's choice). */
|
|
66
|
+
export type TXmlAttributes = Array<[string, string | number | undefined]>;
|
|
67
|
+
|
|
68
|
+
/** `<name attr="…">content</name>`, where `contentArg` is already XML. */
|
|
69
|
+
export function element(nameArg: string, attributesArg: TXmlAttributes, contentArg = ''): string {
|
|
70
|
+
let start = `<${nameArg}`;
|
|
71
|
+
for (const [attribute, value] of attributesArg) {
|
|
72
|
+
if (value !== undefined) {
|
|
73
|
+
start += ` ${attribute}="${escapeXml(String(value))}"`;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return `${start}>${contentArg}</${nameArg}>`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** `<name>escaped text</name>`. */
|
|
80
|
+
export function textElement(nameArg: string, textArg: string, attributesArg: TXmlAttributes = []): string {
|
|
81
|
+
return element(nameArg, attributesArg, escapeXml(textArg));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Go's `strings.Compare`: UTF-8 byte order, which is code point order. */
|
|
85
|
+
export function compareCodePoints(aArg: string, bArg: string): number {
|
|
86
|
+
const length = Math.min(aArg.length, bArg.length);
|
|
87
|
+
for (let i = 0; i < length; i++) {
|
|
88
|
+
const a = aArg.charCodeAt(i);
|
|
89
|
+
const b = bArg.charCodeAt(i);
|
|
90
|
+
if (a !== b) {
|
|
91
|
+
// a surrogate (an astral code point) sorts after every other BMP unit
|
|
92
|
+
const aSurrogate = a >= 0xd800 && a <= 0xdfff;
|
|
93
|
+
const bSurrogate = b >= 0xd800 && b <= 0xdfff;
|
|
94
|
+
if (aSurrogate !== bSurrogate) {
|
|
95
|
+
return aSurrogate ? 1 : -1;
|
|
96
|
+
}
|
|
97
|
+
return a < b ? -1 : 1;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return aArg.length === bArg.length ? 0 : aArg.length < bArg.length ? -1 : 1;
|
|
101
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether an architecture can name an index path segment. Readers record any
|
|
3
|
+
* architecture a package states (Gitea accepts them); builders refuse an entry
|
|
4
|
+
* whose architecture fails this rule with `invalid-input`, and never skip it
|
|
5
|
+
* silently. Refused: the empty string, `.` and `..`, and any value containing
|
|
6
|
+
* `/`, whitespace or a control character.
|
|
7
|
+
*/
|
|
8
|
+
export function isIndexableArchitecture(architectureArg: string): boolean {
|
|
9
|
+
if (typeof architectureArg !== 'string') {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
if (architectureArg === '' || architectureArg === '.' || architectureArg === '..') {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
return !/[/\s\p{Cc}]/u.test(architectureArg);
|
|
16
|
+
}
|