@push.rocks/smartlinuxrepo 1.1.2 → 1.2.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 (51) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/alpine/apkindexreader.d.ts +87 -0
  3. package/dist_ts/alpine/apkindexreader.js +193 -0
  4. package/dist_ts/arch/indexreader.d.ts +35 -0
  5. package/dist_ts/arch/indexreader.js +45 -0
  6. package/dist_ts/arch/reader.js +1 -1
  7. package/dist_ts/debian/indexbuilder.js +8 -23
  8. package/dist_ts/debian/indexreader.d.ts +107 -0
  9. package/dist_ts/debian/indexreader.js +274 -0
  10. package/dist_ts/debian/reader.js +1 -1
  11. package/dist_ts/debian/rules.d.ts +18 -0
  12. package/dist_ts/debian/rules.js +81 -2
  13. package/dist_ts/index.d.ts +14 -4
  14. package/dist_ts/index.js +9 -3
  15. package/dist_ts/readers/decompress.d.ts +1 -2
  16. package/dist_ts/readers/decompress.js +1 -1
  17. package/dist_ts/readers/document.d.ts +36 -0
  18. package/dist_ts/readers/document.js +110 -0
  19. package/dist_ts/rpm/repodata.d.ts +3 -2
  20. package/dist_ts/rpm/repodata.js +5 -4
  21. package/dist_ts/rpm/repodatareader.d.ts +173 -0
  22. package/dist_ts/rpm/repodatareader.js +414 -0
  23. package/dist_ts/rpm/xmlreader.d.ts +70 -0
  24. package/dist_ts/rpm/xmlreader.js +576 -0
  25. package/dist_ts/shared/errors.d.ts +12 -4
  26. package/dist_ts/shared/errors.js +1 -1
  27. package/dist_ts/shared/limits.d.ts +8 -1
  28. package/dist_ts/shared/limits.js +5 -2
  29. package/dist_ts/shared/types.d.ts +2 -0
  30. package/dist_ts/signing/verifiers.d.ts +43 -0
  31. package/dist_ts/signing/verifiers.js +81 -2
  32. package/package.json +1 -1
  33. package/readme.md +53 -2
  34. package/ts/00_commitinfo_data.ts +1 -1
  35. package/ts/alpine/apkindexreader.ts +265 -0
  36. package/ts/arch/indexreader.ts +66 -0
  37. package/ts/arch/reader.ts +2 -1
  38. package/ts/debian/indexbuilder.ts +13 -23
  39. package/ts/debian/indexreader.ts +372 -0
  40. package/ts/debian/reader.ts +2 -1
  41. package/ts/debian/rules.ts +83 -1
  42. package/ts/index.ts +40 -2
  43. package/ts/readers/decompress.ts +1 -3
  44. package/ts/readers/document.ts +136 -0
  45. package/ts/rpm/repodata.ts +4 -3
  46. package/ts/rpm/repodatareader.ts +582 -0
  47. package/ts/rpm/xmlreader.ts +680 -0
  48. package/ts/shared/errors.ts +13 -4
  49. package/ts/shared/limits.ts +9 -1
  50. package/ts/shared/types.ts +3 -0
  51. package/ts/signing/verifiers.ts +113 -1
@@ -0,0 +1,265 @@
1
+ import { LinuxRepoError, truncated } from '../shared/errors.js';
2
+ import { resolveLinuxPackageLimits, type ILinuxPackageLimits } from '../shared/limits.js';
3
+ import { BufferReader } from '../readers/bytes.js';
4
+ import {
5
+ assertReaderBytes,
6
+ assertReaderInput,
7
+ checkUnitBytes,
8
+ countUnit,
9
+ lines,
10
+ readIndexDocument,
11
+ } from '../readers/document.js';
12
+ import { readGzipMember } from '../readers/gzipmember.js';
13
+ import { readTarEntryData, TarReader, type ITarEntry } from '../readers/tar.js';
14
+ import { decodeGoUtf8 } from '../readers/text.js';
15
+
16
+ /** One `.SIGN.<type>.<keyName>` entry of an index's signature member. */
17
+ export interface IAlpineIndexSignatureEntry {
18
+ /** The text between `.SIGN.` and the next `.`: `'RSA'` for the `.SIGN.RSA.<keyName>` that Gitea and the codec write. */
19
+ readonly type: string;
20
+ /** The rest of the entry name: the key name apk looks the public key up by. */
21
+ readonly keyName: string;
22
+ /** The entry's data, padding excluded. */
23
+ readonly signature: Uint8Array;
24
+ }
25
+
26
+ /** One regular file of the signed stream's tar. */
27
+ export interface IAlpineIndexMember {
28
+ readonly name: string;
29
+ readonly bytes: Uint8Array;
30
+ }
31
+
32
+ /** One block of the `APKINDEX` member. */
33
+ export interface IAlpineIndexBlock {
34
+ /** The block's lines and the empty line that ends it (a block cut by the end of the text has none). */
35
+ readonly bytes: Uint8Array;
36
+ /** The block's offset in the `APKINDEX` member's bytes. */
37
+ readonly offset: number;
38
+ /** The `P:`, `V:` and `A:` values; `''` when absent. */
39
+ readonly name: string;
40
+ readonly version: string;
41
+ readonly architecture: string;
42
+ }
43
+
44
+ /** An `APKINDEX.tar.gz` read into its signature entries, signed bytes, members and blocks. */
45
+ export interface IAlpineIndex {
46
+ /** The `.SIGN.*` entries of the first gzip member; `[]` when the index is unsigned. */
47
+ readonly signatures: readonly IAlpineIndexSignatureEntry[];
48
+ /** The input after the signature member (all of it when unsigned): the bytes the signature covers. */
49
+ readonly signedBytes: Uint8Array;
50
+ /** The regular files of the signed stream's tar in order (`APKINDEX`, `DESCRIPTION`, ...). */
51
+ readonly members: readonly IAlpineIndexMember[];
52
+ /** The blocks of the one `APKINDEX` member, in order, duplicates included. */
53
+ readonly blocks: readonly IAlpineIndexBlock[];
54
+ }
55
+
56
+ /** The signature part of an index: its `.SIGN.*` entries and where the signed bytes start. */
57
+ export interface IAlpineIndexSignatureSplit {
58
+ signatures: IAlpineIndexSignatureEntry[];
59
+ /** Offset of the signed bytes in the input: the signature member's length, or 0 when unsigned. */
60
+ signedOffset: number;
61
+ }
62
+
63
+ const SIGN_PREFIX = '.SIGN.';
64
+ const APKINDEX = 'APKINDEX';
65
+ const COLON = 0x3a;
66
+
67
+ /**
68
+ * Splits an `APKINDEX.tar.gz` into its signature entries and the signed bytes,
69
+ * as apk decides it: the first gzip member is read with its exact length
70
+ * (`readGzipMember`, bounded as `readAlpinePackage` bounds the members before
71
+ * the control segment: `maxMetadataBytes` for the member, and
72
+ * `maxDecompressedMetadataBytes` for its tar), and when its tar's first entry
73
+ * is named `.SIGN.*` that member is the signature member: every entry in it
74
+ * must be a regular `.SIGN.<type>.<keyName>` file with a non-empty type and
75
+ * key name (`malformed-signature`). Otherwise the index is unsigned and the
76
+ * signed bytes are the whole input. Nothing after the first member is read
77
+ * here, so the signed bytes are never decoded. Empty input, or a signature
78
+ * member with nothing after it, is `truncated`; apk v3 (ADB) input is
79
+ * `unsupported-package-format`. Internal: shared by `readAlpineIndex` and the
80
+ * Alpine verifier.
81
+ */
82
+ export async function splitAlpineIndexSignature(
83
+ bytesArg: Uint8Array,
84
+ limitsArg: Readonly<ILinuxPackageLimits>,
85
+ ): Promise<IAlpineIndexSignatureSplit> {
86
+ if (bytesArg.length === 0) {
87
+ throw truncated('apk index');
88
+ }
89
+ if (bytesArg.length >= 3 && bytesArg[0] === 0x41 && bytesArg[1] === 0x44 && bytesArg[2] === 0x42) {
90
+ throw new LinuxRepoError('unsupported-package-format', 'apk v3 (ADB) indexes are not supported', {
91
+ field: 'apk index',
92
+ });
93
+ }
94
+ const member = await readGzipMember(new BufferReader(bytesArg), {
95
+ maxMemberBytes: limitsArg.maxMetadataBytes,
96
+ memberLimit: 'maxMetadataBytes',
97
+ maxOutputBytes: limitsArg.maxDecompressedMetadataBytes,
98
+ outputLimit: 'maxDecompressedMetadataBytes',
99
+ maxHeaderBytes: limitsArg.maxHeaderBytes,
100
+ });
101
+ if (member === null) {
102
+ throw truncated('apk index');
103
+ }
104
+ const tar = new TarReader(new BufferReader(member.output), {
105
+ maxEntries: limitsArg.maxEntries,
106
+ maxHeaderBytes: limitsArg.maxHeaderBytes,
107
+ });
108
+ // the first entry's name decides, as in apk
109
+ const first = await tar.next();
110
+ if (first === null || !first.name.startsWith(SIGN_PREFIX)) {
111
+ return { signatures: [], signedOffset: 0 };
112
+ }
113
+ const signatures: IAlpineIndexSignatureEntry[] = [];
114
+ for (let entry: ITarEntry | null = first; entry !== null; entry = await tar.next()) {
115
+ const parsed = parseSignatureEntryName(entry);
116
+ signatures.push({
117
+ ...parsed,
118
+ signature: await readTarEntryData(entry, limitsArg.maxDecompressedMetadataBytes, 'maxDecompressedMetadataBytes'),
119
+ });
120
+ }
121
+ if (member.memberBytes === bytesArg.length) {
122
+ throw truncated('apk index signed stream');
123
+ }
124
+ return { signatures, signedOffset: member.memberBytes };
125
+ }
126
+
127
+ function parseSignatureEntryName(entryArg: ITarEntry): { type: string; keyName: string } {
128
+ const refuse = (whatArg: string): never => {
129
+ throw new LinuxRepoError('malformed-signature', `the index signature member ${whatArg}`, {
130
+ field: 'apk index signature',
131
+ value: entryArg.name,
132
+ });
133
+ };
134
+ if (entryArg.typeflag !== '0' || entryArg.sparse) {
135
+ refuse('holds an entry that is not a regular file');
136
+ }
137
+ if (!entryArg.name.startsWith(SIGN_PREFIX)) {
138
+ refuse('holds an entry not named .SIGN.*');
139
+ }
140
+ const rest = entryArg.name.slice(SIGN_PREFIX.length);
141
+ const dot = rest.indexOf('.');
142
+ const type = dot < 0 ? rest : rest.slice(0, dot);
143
+ const keyName = dot < 0 ? '' : rest.slice(dot + 1);
144
+ if (type === '' || keyName === '') {
145
+ refuse('holds a .SIGN.* entry without a type or key name');
146
+ }
147
+ return { type, keyName };
148
+ }
149
+
150
+ /**
151
+ * Reads an `APKINDEX.tar.gz` into its signature entries, the signed bytes,
152
+ * the members of the signed stream and the blocks of its `APKINDEX` member.
153
+ *
154
+ * - The signature split is apk's first-entry rule: the first gzip member is
155
+ * the signature member when its tar's first entry is named `.SIGN.*`; it is
156
+ * bounded by `maxMetadataBytes` and `maxDecompressedMetadataBytes`, which
157
+ * an unsigned index's first member must meet too. An unsigned index has
158
+ * `signatures: []`. Verify a signature with `createAlpineVerifier`.
159
+ * - The signed bytes are decoded as a gzip stream (bounded by
160
+ * `maxIndexBytes`) and read as a tar: regular files become `members`, PAX
161
+ * global headers are skipped, a sparse entry is
162
+ * `unsupported-package-format` and any other type `malformed-index`.
163
+ * Exactly one `APKINDEX` member is required: none is `missing-field`
164
+ * (`APKINDEX`), two are `malformed-index`. Tar headers are not returned.
165
+ * - `APKINDEX` lines end in LF, the last one too, and every non-empty line is
166
+ * one byte other than `:`, then `:`, then the value (`malformed-index`). A
167
+ * block ends at an empty line, extra empty lines are skipped, and a block
168
+ * with two `P:`, `V:` or `A:` lines is `malformed-index`.
169
+ *
170
+ * Bounds: `maxEntries` for members and blocks, `maxMetadataBytes` for a
171
+ * member's data and one block, `maxHeaderBytes` for a line. Every returned
172
+ * array is a view into buffers the reader allocated, never the caller's input.
173
+ */
174
+ export async function readAlpineIndex(inputArg: {
175
+ bytes: Uint8Array;
176
+ limits?: Partial<ILinuxPackageLimits>;
177
+ }): Promise<IAlpineIndex> {
178
+ assertReaderInput(inputArg);
179
+ assertReaderBytes(inputArg.bytes);
180
+ const limits = resolveLinuxPackageLimits(inputArg.limits);
181
+ const split = await splitAlpineIndexSignature(inputArg.bytes, limits);
182
+ const signedBytes = inputArg.bytes.slice(split.signedOffset);
183
+ const decoded = await readIndexDocument(signedBytes, 'gzip', limits);
184
+
185
+ const members: IAlpineIndexMember[] = [];
186
+ const tar = new TarReader(new BufferReader(decoded), { maxEntries: limits.maxEntries, maxHeaderBytes: limits.maxHeaderBytes });
187
+ for (let entry = await tar.next(); entry !== null; entry = await tar.next()) {
188
+ if (entry.sparse) {
189
+ await readTarEntryData(entry, limits.maxMetadataBytes, 'maxMetadataBytes'); // refused: sparse data
190
+ }
191
+ if (entry.typeflag === 'g') {
192
+ continue;
193
+ }
194
+ if (entry.typeflag !== '0') {
195
+ throw new LinuxRepoError('malformed-index', `the APKINDEX tar holds an entry of type ${JSON.stringify(entry.typeflag)}`, {
196
+ field: 'APKINDEX tar',
197
+ value: entry.name,
198
+ });
199
+ }
200
+ members.push({ name: entry.name, bytes: await readTarEntryData(entry, limits.maxMetadataBytes, 'maxMetadataBytes') });
201
+ }
202
+ const indexMembers = members.filter((member) => member.name === APKINDEX);
203
+ if (indexMembers.length === 0) {
204
+ throw new LinuxRepoError('missing-field', 'the index has no APKINDEX member', { field: APKINDEX });
205
+ }
206
+ if (indexMembers.length > 1) {
207
+ throw new LinuxRepoError('malformed-index', 'the index holds more than one APKINDEX member', { field: APKINDEX });
208
+ }
209
+ return { signatures: split.signatures, signedBytes, members, blocks: readBlocks(indexMembers[0].bytes, limits) };
210
+ }
211
+
212
+ interface IBlockState {
213
+ start: number;
214
+ name: string | null;
215
+ version: string | null;
216
+ architecture: string | null;
217
+ }
218
+
219
+ function readBlocks(textArg: Uint8Array, limitsArg: Readonly<ILinuxPackageLimits>): IAlpineIndexBlock[] {
220
+ const blocks: IAlpineIndexBlock[] = [];
221
+ let block: IBlockState | null = null;
222
+ const finish = (endArg: number) => {
223
+ if (block !== null) {
224
+ const current: IBlockState = block;
225
+ block = null;
226
+ blocks.push({
227
+ bytes: textArg.subarray(current.start, endArg),
228
+ offset: current.start,
229
+ name: current.name ?? '',
230
+ version: current.version ?? '',
231
+ architecture: current.architecture ?? '',
232
+ });
233
+ }
234
+ };
235
+ for (const line of lines(textArg, limitsArg.maxHeaderBytes, 'APKINDEX')) {
236
+ if (line.end === line.start) {
237
+ finish(line.end + 1);
238
+ continue;
239
+ }
240
+ if (line.end - line.start < 2 || textArg[line.start] === COLON || textArg[line.start + 1] !== COLON) {
241
+ throw new LinuxRepoError('malformed-index', 'an APKINDEX line is not "<letter>:<value>"', {
242
+ field: APKINDEX,
243
+ value: line.start,
244
+ });
245
+ }
246
+ if (block === null) {
247
+ countUnit(blocks.length + 1, limitsArg, 'APKINDEX block');
248
+ block = { start: line.start, name: null, version: null, architecture: null };
249
+ }
250
+ checkUnitBytes(line.end + 1 - block.start, limitsArg, 'APKINDEX block');
251
+ const key = textArg[line.start];
252
+ const slot = key === 0x50 ? 'name' : key === 0x56 ? 'version' : key === 0x41 ? 'architecture' : null;
253
+ if (slot !== null) {
254
+ if (block[slot] !== null) {
255
+ throw new LinuxRepoError('malformed-index', `an APKINDEX block holds two ${String.fromCharCode(key)}: lines`, {
256
+ field: APKINDEX,
257
+ value: line.start,
258
+ });
259
+ }
260
+ block[slot] = decodeGoUtf8(textArg.subarray(line.start + 2, line.end));
261
+ }
262
+ }
263
+ finish(textArg.length);
264
+ return blocks;
265
+ }
@@ -0,0 +1,66 @@
1
+ import { LinuxRepoError } from '../shared/errors.js';
2
+ import { resolveLinuxPackageLimits, type ILinuxPackageLimits } from '../shared/limits.js';
3
+ import { BufferReader } from '../readers/bytes.js';
4
+ import type { TLinuxCompression } from '../shared/types.js';
5
+ import { assertLinuxCompression, assertReaderBytes, assertReaderInput, readIndexDocument } from '../readers/document.js';
6
+ import { readTarEntryData, TarReader } from '../readers/tar.js';
7
+
8
+ /** One regular file of a `packages.db` (`<name>-<version>/desc` or `/files`). */
9
+ export interface IArchIndexMember {
10
+ /** The tar entry name. */
11
+ readonly name: string;
12
+ /** The entry's data, without header or padding (a view into `decoded`). */
13
+ readonly bytes: Uint8Array;
14
+ }
15
+
16
+ /** A `packages.db` read into its members. */
17
+ export interface IArchIndex {
18
+ /** The decompressed tar (a copy for `'none'`). */
19
+ readonly decoded: Uint8Array;
20
+ /** Every regular file in archive order, duplicates included. */
21
+ readonly members: readonly IArchIndexMember[];
22
+ }
23
+
24
+ /**
25
+ * Reads an Arch repository database (`packages.db`, `<repo>.db.tar.gz`,
26
+ * `.files`; the caller names `compression`) into its members. The tar is
27
+ * read with Go `archive/tar` semantics: regular files become `members`,
28
+ * directory entries (pacman's `repo-add` writes them) and PAX global headers
29
+ * are skipped, a sparse entry is `unsupported-package-format`, and any other
30
+ * entry type (links, devices, FIFOs) is `malformed-index`. The member data is
31
+ * returned raw; `desc` and `files` are not parsed. Verify `packages.db.sig`
32
+ * with `createOpenPgpVerifier().verifyDetachedSignature` over the compressed
33
+ * file's bytes.
34
+ *
35
+ * Bounds: the decoded tar by `maxIndexBytes`, entries by `maxEntries`, one
36
+ * member's data by `maxMetadataBytes`, one header chain by `maxHeaderBytes`.
37
+ */
38
+ export async function readArchIndex(inputArg: {
39
+ bytes: Uint8Array;
40
+ compression: TLinuxCompression;
41
+ limits?: Partial<ILinuxPackageLimits>;
42
+ }): Promise<IArchIndex> {
43
+ assertReaderInput(inputArg);
44
+ assertReaderBytes(inputArg.bytes);
45
+ assertLinuxCompression(inputArg.compression);
46
+ const limits = resolveLinuxPackageLimits(inputArg.limits);
47
+ const decoded = await readIndexDocument(inputArg.bytes, inputArg.compression, limits);
48
+ const members: IArchIndexMember[] = [];
49
+ const tar = new TarReader(new BufferReader(decoded), { maxEntries: limits.maxEntries, maxHeaderBytes: limits.maxHeaderBytes });
50
+ for (let entry = await tar.next(); entry !== null; entry = await tar.next()) {
51
+ if (entry.sparse) {
52
+ await readTarEntryData(entry, limits.maxMetadataBytes, 'maxMetadataBytes'); // refused: sparse data
53
+ }
54
+ if (entry.typeflag === '5' || entry.typeflag === 'g') {
55
+ continue;
56
+ }
57
+ if (entry.typeflag !== '0') {
58
+ throw new LinuxRepoError('malformed-index', `the Arch database holds an entry of type ${JSON.stringify(entry.typeflag)}`, {
59
+ field: 'Arch database',
60
+ value: entry.name,
61
+ });
62
+ }
63
+ members.push({ name: entry.name, bytes: await readTarEntryData(entry, limits.maxMetadataBytes, 'maxMetadataBytes') });
64
+ }
65
+ return { decoded, members };
66
+ }
package/ts/arch/reader.ts CHANGED
@@ -5,7 +5,8 @@ import type { IArchPackageMetadata, IArchPackageRecord } from '../shared/types.j
5
5
  import { isValidUrl } from '../shared/url.js';
6
6
  import { readUpTo, skipToEnd } from '../readers/bytes.js';
7
7
  import { PackageByteSource } from '../readers/bytesource.js';
8
- import { withDecompressingReader, type TLinuxCompression } from '../readers/decompress.js';
8
+ import { withDecompressingReader } from '../readers/decompress.js';
9
+ import type { TLinuxCompression } from '../shared/types.js';
9
10
  import { readTarEntryData, TarReader, type ITarEntry } from '../readers/tar.js';
10
11
  import { decodeGoUtf8 } from '../readers/text.js';
11
12
  import { goParseInt64, goTrimSpace } from '../shared/gostrings.js';
@@ -6,10 +6,13 @@ import { assertString, assertTimestamp } from '../shared/validation.js';
6
6
  import type { IOpenPgpSigner } from '../signing/signers.js';
7
7
  import { createCleartextSignedMessage, createDetachedArmoredSignature } from '../signing/openpgpframing.js';
8
8
  import { compareUtf8, goTrimSpace, utf8ByteString } from './gostring.js';
9
- import { DEBIAN_SYMBOL_PATTERN, hasPoolFileNameRefusedCharacter, isDebianReleaseValue, isDebianSymbol } from './rules.js';
10
-
11
- /** The fields the `Packages` stanza writes itself; a control file's own copies are dropped. */
12
- const INDEX_OWNED_FIELDS = new Set(['filename', 'size', 'md5sum', 'sha1', 'sha256', 'sha512']);
9
+ import {
10
+ DEBIAN_SYMBOL_PATTERN,
11
+ hasPoolFileNameRefusedCharacter,
12
+ isDebianReleaseValue,
13
+ isDebianSymbol,
14
+ stripDebianIndexOwnedFields,
15
+ } from './rules.js';
13
16
 
14
17
  const PACKAGES_FILES = ['Packages', 'Packages.gz', 'Packages.xz'] as const;
15
18
 
@@ -197,24 +200,11 @@ export function renderDebianPackagesStanza(input: {
197
200
  assertSymbol(input.distribution, 'distribution');
198
201
  assertSymbol(input.component, 'component');
199
202
  assertRecord(input.record);
200
- const kept: string[] = [];
201
- let dropping = false;
202
- // Gitea trims the stored stanza again when it writes it (repository.go:202)
203
- for (const line of goTrimSpaceText(input.record.metadata.control).split('\n')) {
204
- if (line[0] === ' ' || line[0] === '\t') {
205
- if (!dropping) {
206
- kept.push(line);
207
- }
208
- continue;
209
- }
210
- const colon = line.indexOf(':');
211
- const field = colon < 0 ? null : goTrimSpaceText(line.slice(0, colon)).toLowerCase();
212
- dropping = field !== null && INDEX_OWNED_FIELDS.has(field);
213
- if (!dropping) {
214
- kept.push(line);
215
- }
216
- }
217
- const control = kept.join('\n');
203
+ // Gitea trims the stored stanza again when it writes it (repository.go:202);
204
+ // the trimmed text plus its final LF is one stanza, which the index-owned
205
+ // field rule walks (`stripDebianIndexOwnedFields`)
206
+ const stanza = new TextEncoder().encode(`${goTrimSpaceText(input.record.metadata.control)}\n`);
207
+ const control = new TextDecoder().decode(stripDebianIndexOwnedFields(stanza));
218
208
  if (control === '') {
219
209
  throw new LinuxRepoError('invalid-input', 'the record control stanza has no fields besides the index-owned ones', {
220
210
  field: 'record.metadata.control',
@@ -222,7 +212,7 @@ export function renderDebianPackagesStanza(input: {
222
212
  }
223
213
  const { digests } = input.record;
224
214
  return (
225
- `${control}\n` +
215
+ control +
226
216
  `Filename: pool/${input.distribution}/${input.component}/${input.record.fileName}\n` +
227
217
  `Size: ${digests.size}\n` +
228
218
  `MD5sum: ${digests.md5}\n` +