@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,126 @@
1
+ import { LinuxRepoError, limitExceeded } from '../shared/errors.js';
2
+ import { resolveLinuxPackageLimits, type ILinuxPackageLimits } from '../shared/limits.js';
3
+ import { goPathBase } from '../shared/gopath.js';
4
+ import type { IDebianPackageRecord } from '../shared/types.js';
5
+ import { ArReader } from '../readers/ar.js';
6
+ import { 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 } from '../readers/tar.js';
10
+ import { parseDebianControlFile, type IDebianControl } from './control.js';
11
+
12
+ const CONTROL_TAR = 'control.tar';
13
+
14
+ /** The control archive forms Gitea 28.2.0 reads (`metadata.go:108-136`). */
15
+ const CONTROL_COMPRESSIONS: Record<string, TLinuxCompression> = {
16
+ '': 'none',
17
+ '.gz': 'gzip',
18
+ '.xz': 'xz',
19
+ '.zst': 'zstd',
20
+ };
21
+
22
+ /**
23
+ * Reads a `.deb` package in one pass into Gitea 28.2.0's record
24
+ * (`modules/packages/debian/metadata.go:92-163`).
25
+ *
26
+ * - The `ar` members are walked in order; `debian-binary` is not required.
27
+ * The first member whose name starts with `control.tar` (a trailing `/`
28
+ * stripped from the suffix) is decompressed: `control.tar`, `.gz`, `.xz` or
29
+ * `.zst`; any other suffix is `unsupported-compression`.
30
+ * - In it, the first regular entry whose base name is `control` is parsed. A
31
+ * control archive without one moves on to the next `ar` member, as Gitea
32
+ * does; none at all is `missing-field` (`control`).
33
+ * - The control member is decoded to the decoder's end on both paths, after
34
+ * the control file is parsed and when it holds none: a cut or corrupt
35
+ * control member is `truncated` or `malformed-archive` in every form, where
36
+ * Gitea, which stops at the control entry, accepts it. For a plain
37
+ * `control.tar` the end is the end of its `ar` member; the bytes after the
38
+ * tar end marker are read, not parsed.
39
+ * - Once the control file is parsed the `ar` archive is not walked further:
40
+ * the rest of the input is only hashed (`metadata.go:155-156`).
41
+ * - Bounds: the control member's compressed size by `maxMetadataBytes`, its
42
+ * decompressed bytes by `maxDecompressedMetadataBytes` (Gitea's 32 MiB),
43
+ * tar entries and `ar` members by `maxEntries`, tar header chains and
44
+ * control lines by `maxHeaderBytes`, the whole input by `maxPackageBytes`.
45
+ * A control member that decompresses past `maxDecompressedMetadataBytes` is
46
+ * `limit-exceeded` even when `control` comes first, where Gitea cuts the
47
+ * stream at 32 MiB and keeps a `control` found before the cut.
48
+ */
49
+ export async function readDebianPackage(
50
+ source: AsyncIterable<Uint8Array>,
51
+ limits?: Partial<ILinuxPackageLimits>,
52
+ ): Promise<IDebianPackageRecord> {
53
+ const resolved = resolveLinuxPackageLimits(limits);
54
+ const input = new PackageByteSource(source, resolved.maxPackageBytes);
55
+ const control = await readControl(input, resolved);
56
+ await input.drain();
57
+ return {
58
+ format: 'debian',
59
+ name: control.name,
60
+ version: control.version,
61
+ architecture: control.architecture,
62
+ fileName: `${control.name}_${control.version}_${control.architecture}.deb`,
63
+ digests: input.digests(),
64
+ metadata: control.metadata,
65
+ };
66
+ }
67
+
68
+ async function readControl(inputArg: PackageByteSource, limitsArg: Readonly<ILinuxPackageLimits>): Promise<IDebianControl> {
69
+ const archive = new ArReader(inputArg, limitsArg.maxEntries);
70
+ for (;;) {
71
+ const member = await archive.next();
72
+ if (member === null) {
73
+ throw new LinuxRepoError('missing-field', 'the package has no control file', { field: 'control' });
74
+ }
75
+ if (!member.name.startsWith(CONTROL_TAR)) {
76
+ continue;
77
+ }
78
+ const suffix = member.name.slice(CONTROL_TAR.length).replace(/\/$/, '');
79
+ const compression = Object.hasOwn(CONTROL_COMPRESSIONS, suffix) ? CONTROL_COMPRESSIONS[suffix] : undefined;
80
+ if (compression === undefined) {
81
+ throw new LinuxRepoError('unsupported-compression', `unsupported control archive "${member.name}"`, {
82
+ field: 'control.tar',
83
+ value: member.name,
84
+ });
85
+ }
86
+ if (member.size > limitsArg.maxMetadataBytes) {
87
+ throw limitExceeded('maxMetadataBytes', limitsArg.maxMetadataBytes, `control member ${member.name}`);
88
+ }
89
+ const control = await withDecompressingReader(
90
+ member.body,
91
+ compression,
92
+ { maxOutputBytes: limitsArg.maxDecompressedMetadataBytes, outputLimit: 'maxDecompressedMetadataBytes' },
93
+ async (decompressed) => {
94
+ const tar = new TarReader(decompressed, {
95
+ maxEntries: limitsArg.maxEntries,
96
+ maxHeaderBytes: limitsArg.maxHeaderBytes,
97
+ });
98
+ let parsed: IDebianControl | null = null;
99
+ for (;;) {
100
+ const entry = await tar.next();
101
+ if (entry === null) {
102
+ break;
103
+ }
104
+ if (entry.typeflag !== '0' || goPathBase(entry.name) !== 'control') {
105
+ continue;
106
+ }
107
+ const bytes = await readTarEntryData(
108
+ entry,
109
+ limitsArg.maxDecompressedMetadataBytes,
110
+ 'maxDecompressedMetadataBytes',
111
+ );
112
+ parsed = parseDebianControlFile(bytes, limitsArg.maxHeaderBytes);
113
+ break;
114
+ }
115
+ // Gitea stops at the control entry; the codec decodes the member to
116
+ // the decoder's end, so a cut or corrupt control member is refused in
117
+ // every form (a plain control.tar ends with its ar member)
118
+ await skipToEnd(decompressed);
119
+ return parsed;
120
+ },
121
+ );
122
+ if (control !== null) {
123
+ return control;
124
+ }
125
+ }
126
+ }
package/ts/index.ts ADDED
@@ -0,0 +1,46 @@
1
+ export type {
2
+ TLinuxPackageFormat,
3
+ ILinuxPackageDigests,
4
+ IAlpinePackageDigests,
5
+ ILinuxPackageRecordBase,
6
+ IAlpinePackageMetadata,
7
+ IAlpinePackageRecord,
8
+ IDebianPackageMetadata,
9
+ IDebianPackageRecord,
10
+ IRpmDependency,
11
+ IRpmFile,
12
+ IRpmChangelog,
13
+ IRpmPackageMetadata,
14
+ IRpmPackageRecord,
15
+ IRpmUpdate,
16
+ IRpmUpdateReference,
17
+ IRpmUpdateCollection,
18
+ IRpmUpdatePackage,
19
+ IArchPackageMetadata,
20
+ IArchPackageRecord,
21
+ TLinuxPackageRecord,
22
+ TLinuxIndexFileStage,
23
+ ILinuxIndexFile,
24
+ } from './shared/types.js';
25
+ export { DEFAULT_LINUX_PACKAGE_LIMITS } from './shared/limits.js';
26
+ export type { ILinuxPackageLimits } from './shared/limits.js';
27
+ export { LinuxRepoError } from './shared/errors.js';
28
+ export type { TLinuxRepoErrorCode, ILinuxRepoErrorDetail } from './shared/errors.js';
29
+ export { isIndexableArchitecture } from './shared/architecture.js';
30
+ export { createAlpineSigner, createOpenPgpSigner } from './signing/signers.js';
31
+ export type { IAlpineSigner, IOpenPgpSigner } from './signing/signers.js';
32
+ export { generateAlpineKeyPair, generateOpenPgpKeyPair, giteaAlpineKeyName } from './signing/keys.js';
33
+ export type { IAlpineKeyPair, IOpenPgpKeyPair } from './signing/keys.js';
34
+ export { readDebianPackage } from './debian/reader.js';
35
+ export { buildDebianIndex } from './debian/indexbuilder.js';
36
+ export { readRpmPackage } from './rpm/reader.js';
37
+ export { signRpmPackage } from './rpm/sign.js';
38
+ export { buildRpmIndex } from './rpm/builder.js';
39
+ export { mergeRpmUpdates } from './rpm/updates.js';
40
+ export { renderRpmRepoConfig } from './rpm/repoconfig.js';
41
+ export { readArchPackage } from './arch/reader.js';
42
+ export { buildArchIndex } from './arch/builder.js';
43
+ export type { IArchIndexEntry } from './arch/builder.js';
44
+ export { signArchPackage } from './arch/signer.js';
45
+ export { readAlpinePackage } from './alpine/reader.js';
46
+ export { buildAlpineIndex } from './alpine/apkindex.js';
package/ts/plugins.ts ADDED
@@ -0,0 +1,13 @@
1
+ // node native scope
2
+ import * as crypto from 'node:crypto';
3
+ import * as stream from 'node:stream';
4
+ import * as streamWeb from 'node:stream/web';
5
+ import * as zlib from 'node:zlib';
6
+
7
+ export { crypto, stream, streamWeb, zlib };
8
+
9
+ // third party scope
10
+ import * as lzma from '@napi-rs/lzma';
11
+ import * as openpgp from 'openpgp';
12
+
13
+ export { lzma, openpgp };
@@ -0,0 +1,102 @@
1
+ import { LinuxRepoError, limitExceeded, truncated } from '../shared/errors.js';
2
+ import { LimitedReader, readUpTo, type IByteReader } from './bytes.js';
3
+ import { decodeGoUtf8, decodeLatin1 } from './text.js';
4
+
5
+ const AR_MAGIC = '!<arch>\n';
6
+ const AR_HEADER_BYTES = 60;
7
+
8
+ export interface IArMember {
9
+ /**
10
+ * The header name with trailing spaces removed. Nothing else is resolved: a
11
+ * GNU trailing `/` stays, and GNU (`//`) or BSD (`#1/`) long-name forms are
12
+ * ordinary member names, as Gitea's `blakesmith/ar` reports them.
13
+ */
14
+ name: string;
15
+ size: number;
16
+ /** Exactly `size` bytes; reading past the input's end fails with `truncated`. */
17
+ body: LimitedReader;
18
+ }
19
+
20
+ /**
21
+ * A bounded reader for `ar` archives (the `.deb` container). It checks the
22
+ * `!<arch>\n` magic, the header terminator and the decimal size, which
23
+ * `blakesmith/ar` does not; every real `ar` writer produces them. A missing
24
+ * padding byte at the very end of the input ends the archive cleanly, as in
25
+ * `blakesmith/ar`.
26
+ */
27
+ export class ArReader {
28
+ private started = false;
29
+ private done = false;
30
+ private current: LimitedReader | null = null;
31
+ private pad = 0;
32
+ private members = 0;
33
+
34
+ constructor(
35
+ private readonly input: IByteReader,
36
+ private readonly maxEntries: number,
37
+ ) {}
38
+
39
+ public async next(): Promise<IArMember | null> {
40
+ if (this.done) {
41
+ return null;
42
+ }
43
+ if (!this.started) {
44
+ await this.readMagic();
45
+ this.started = true;
46
+ }
47
+ if (this.current !== null) {
48
+ await this.current.skipRest();
49
+ this.current = null;
50
+ }
51
+ if (this.pad > 0) {
52
+ this.pad = 0;
53
+ const padding = await readUpTo(this.input, 1);
54
+ if (padding.length === 0) {
55
+ this.done = true;
56
+ return null;
57
+ }
58
+ }
59
+ const header = await readUpTo(this.input, AR_HEADER_BYTES);
60
+ if (header.length === 0) {
61
+ this.done = true;
62
+ return null;
63
+ }
64
+ if (header.length < AR_HEADER_BYTES) {
65
+ throw truncated('ar member header');
66
+ }
67
+ if (header[58] !== 0x60 || header[59] !== 0x0a) {
68
+ throw malformed('ar member header has no "`\\n" terminator');
69
+ }
70
+ const name = decodeGoUtf8(header.subarray(0, 16)).replace(/ +$/, '');
71
+ const sizeText = decodeLatin1(header.subarray(48, 58)).replace(/ +$/, '');
72
+ if (!/^[0-9]+$/.test(sizeText)) {
73
+ throw malformed('ar member size is not a decimal number');
74
+ }
75
+ const size = Number(sizeText);
76
+ if (!Number.isSafeInteger(size)) {
77
+ throw malformed('ar member size is out of range');
78
+ }
79
+ this.members += 1;
80
+ if (this.members > this.maxEntries) {
81
+ throw limitExceeded('maxEntries', this.maxEntries, 'ar member count');
82
+ }
83
+ this.current = new LimitedReader(this.input, size, 'ar member data');
84
+ this.pad = size % 2;
85
+ return { name, size, body: this.current };
86
+ }
87
+
88
+ private async readMagic(): Promise<void> {
89
+ const magic = await readUpTo(this.input, AR_MAGIC.length);
90
+ const text = decodeLatin1(magic);
91
+ if (!AR_MAGIC.startsWith(text)) {
92
+ throw new LinuxRepoError('malformed-archive', 'input is not an ar archive', { field: 'ar magic' });
93
+ }
94
+ if (magic.length < AR_MAGIC.length) {
95
+ throw truncated('ar magic');
96
+ }
97
+ }
98
+ }
99
+
100
+ function malformed(messageArg: string): LinuxRepoError {
101
+ return new LinuxRepoError('malformed-archive', messageArg, { field: 'ar member header' });
102
+ }
@@ -0,0 +1,182 @@
1
+ import { truncated } from '../shared/errors.js';
2
+
3
+ /**
4
+ * A pull-based byte reader. `read` returns between 1 and `max` bytes, or
5
+ * `null` at the end of input. `unread` pushes bytes back so the next `read`
6
+ * returns them first (used where a decoder consumed less than it was given).
7
+ */
8
+ export interface IByteReader {
9
+ read(maxArg: number): Promise<Uint8Array | null>;
10
+ unread(bytesArg: Uint8Array): void;
11
+ }
12
+
13
+ /** The read size the readers use when they pull without a structural bound. */
14
+ export const READ_CHUNK_BYTES = 64 * 1024;
15
+
16
+ export function concatBytes(chunksArg: Uint8Array[], totalArg?: number): Uint8Array {
17
+ if (chunksArg.length === 1) {
18
+ return chunksArg[0];
19
+ }
20
+ const total = totalArg ?? chunksArg.reduce((sum, chunk) => sum + chunk.length, 0);
21
+ const result = new Uint8Array(total);
22
+ let offset = 0;
23
+ for (const chunk of chunksArg) {
24
+ result.set(chunk, offset);
25
+ offset += chunk.length;
26
+ }
27
+ return result;
28
+ }
29
+
30
+ /**
31
+ * Reads up to `lengthArg` bytes; returns fewer only when the input ends.
32
+ */
33
+ export async function readUpTo(readerArg: IByteReader, lengthArg: number): Promise<Uint8Array> {
34
+ const chunks: Uint8Array[] = [];
35
+ let total = 0;
36
+ while (total < lengthArg) {
37
+ const chunk = await readerArg.read(lengthArg - total);
38
+ if (chunk === null) {
39
+ break;
40
+ }
41
+ chunks.push(chunk);
42
+ total += chunk.length;
43
+ }
44
+ return chunks.length === 0 ? new Uint8Array(0) : concatBytes(chunks, total);
45
+ }
46
+
47
+ /**
48
+ * Reads exactly `lengthArg` bytes, or fails with `truncated` naming `whatArg`.
49
+ */
50
+ export async function readExact(
51
+ readerArg: IByteReader,
52
+ lengthArg: number,
53
+ whatArg: string,
54
+ ): Promise<Uint8Array> {
55
+ const bytes = await readUpTo(readerArg, lengthArg);
56
+ if (bytes.length !== lengthArg) {
57
+ throw truncated(whatArg);
58
+ }
59
+ return bytes;
60
+ }
61
+
62
+ /**
63
+ * Skips exactly `lengthArg` bytes, or fails with `truncated` naming `whatArg`.
64
+ */
65
+ export async function skipExact(
66
+ readerArg: IByteReader,
67
+ lengthArg: number,
68
+ whatArg: string,
69
+ ): Promise<void> {
70
+ let remaining = lengthArg;
71
+ while (remaining > 0) {
72
+ const chunk = await readerArg.read(Math.min(remaining, READ_CHUNK_BYTES));
73
+ if (chunk === null) {
74
+ throw truncated(whatArg);
75
+ }
76
+ remaining -= chunk.length;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Reads and discards `readerArg` until it reports its end. The reader's own
82
+ * bounds apply: a decompressing reader counts the output against its
83
+ * `maxOutputBytes` and reports a cut or corrupt stream when its decoder does.
84
+ */
85
+ export async function skipToEnd(readerArg: IByteReader): Promise<void> {
86
+ while ((await readerArg.read(READ_CHUNK_BYTES)) !== null) {
87
+ // the bytes are discarded; the reader counts and checks them
88
+ }
89
+ }
90
+
91
+ /** A reader over bytes already in memory. */
92
+ export class BufferReader implements IByteReader {
93
+ private bytes: Uint8Array;
94
+ private offset = 0;
95
+ private pushedBack: Uint8Array[] = [];
96
+
97
+ constructor(bytesArg: Uint8Array) {
98
+ this.bytes = bytesArg;
99
+ }
100
+
101
+ public async read(maxArg: number): Promise<Uint8Array | null> {
102
+ const pushed = this.pushedBack.pop();
103
+ if (pushed !== undefined) {
104
+ return takeFront(pushed, maxArg, this.pushedBack);
105
+ }
106
+ if (this.offset >= this.bytes.length) {
107
+ return null;
108
+ }
109
+ const end = Math.min(this.bytes.length, this.offset + Math.max(1, maxArg));
110
+ const chunk = this.bytes.subarray(this.offset, end);
111
+ this.offset = end;
112
+ return chunk;
113
+ }
114
+
115
+ public unread(bytesArg: Uint8Array): void {
116
+ if (bytesArg.length > 0) {
117
+ this.pushedBack.push(bytesArg);
118
+ }
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Exposes exactly `lengthArg` bytes of an inner reader. Reading past the inner
124
+ * end fails with `truncated` naming `whatArg`; `skipRest` consumes what the
125
+ * consumer left.
126
+ */
127
+ export class LimitedReader implements IByteReader {
128
+ private remaining: number;
129
+ private pushedBack: Uint8Array[] = [];
130
+
131
+ constructor(
132
+ private readonly inner: IByteReader,
133
+ lengthArg: number,
134
+ private readonly what: string,
135
+ ) {
136
+ this.remaining = lengthArg;
137
+ }
138
+
139
+ /** Bytes of the window the consumer has not read yet. */
140
+ public get unreadBytes(): number {
141
+ return this.remaining + this.pushedBack.reduce((sum, chunk) => sum + chunk.length, 0);
142
+ }
143
+
144
+ public async read(maxArg: number): Promise<Uint8Array | null> {
145
+ const pushed = this.pushedBack.pop();
146
+ if (pushed !== undefined) {
147
+ return takeFront(pushed, maxArg, this.pushedBack);
148
+ }
149
+ if (this.remaining === 0) {
150
+ return null;
151
+ }
152
+ const chunk = await this.inner.read(Math.min(Math.max(1, maxArg), this.remaining));
153
+ if (chunk === null) {
154
+ throw truncated(this.what);
155
+ }
156
+ this.remaining -= chunk.length;
157
+ return chunk;
158
+ }
159
+
160
+ public unread(bytesArg: Uint8Array): void {
161
+ if (bytesArg.length > 0) {
162
+ this.pushedBack.push(bytesArg);
163
+ }
164
+ }
165
+
166
+ public async skipRest(): Promise<void> {
167
+ this.pushedBack = [];
168
+ await skipExact(this.inner, this.remaining, this.what);
169
+ this.remaining = 0;
170
+ }
171
+ }
172
+
173
+ /** Splits `chunkArg` at `maxArg`, pushing the tail back onto `stackArg`. */
174
+ export function takeFront(chunkArg: Uint8Array, maxArg: number, stackArg: Uint8Array[]): Uint8Array {
175
+ const max = Math.max(1, maxArg);
176
+ if (chunkArg.length <= max) {
177
+ return chunkArg;
178
+ }
179
+ stackArg.push(chunkArg.subarray(max));
180
+ return chunkArg.subarray(0, max);
181
+ }
182
+
@@ -0,0 +1,164 @@
1
+ import * as plugins from '../plugins.js';
2
+ import { LinuxRepoError, limitExceeded } from '../shared/errors.js';
3
+ import type { ILinuxPackageDigests } from '../shared/types.js';
4
+ import { takeFront, type IByteReader } from './bytes.js';
5
+
6
+ /**
7
+ * The digest tee: pulls the caller's package bytes once, counts them against
8
+ * `maxPackageBytes`, and feeds every byte exactly once into MD5, SHA-1,
9
+ * SHA-256 and SHA-512 at the moment it is pulled.
10
+ *
11
+ * - It pulls with `next()` only. It never calls `return()` or `throw()` on the
12
+ * caller's iterator, so a web `ReadableStream` is never cancelled.
13
+ * - An error from the caller's iterator propagates unchanged. After any error
14
+ * the source stops pulling for good.
15
+ * - `drain()` reads the rest of the input so the digests cover the whole file;
16
+ * `digests()` is available only after the input has ended.
17
+ */
18
+ export class PackageByteSource implements IByteReader {
19
+ private readonly source: AsyncIterable<Uint8Array>;
20
+ private readonly maxPackageBytes: number;
21
+ private iterator: AsyncIterator<Uint8Array> | null = null;
22
+ private readonly pushedBack: Uint8Array[] = [];
23
+ private readonly hashes = {
24
+ md5: plugins.crypto.createHash('md5'),
25
+ sha1: plugins.crypto.createHash('sha1'),
26
+ sha256: plugins.crypto.createHash('sha256'),
27
+ sha512: plugins.crypto.createHash('sha512'),
28
+ };
29
+ private pulledBytes = 0;
30
+ private deliveredBytes = 0;
31
+ private ended = false;
32
+ private failure: unknown = undefined;
33
+ private failed = false;
34
+ private finalDigests: ILinuxPackageDigests | null = null;
35
+
36
+ constructor(sourceArg: AsyncIterable<Uint8Array>, maxPackageBytesArg: number) {
37
+ if (
38
+ sourceArg === null ||
39
+ typeof sourceArg !== 'object' ||
40
+ typeof (sourceArg as Partial<AsyncIterable<Uint8Array>>)[Symbol.asyncIterator] !== 'function'
41
+ ) {
42
+ throw new LinuxRepoError('invalid-input', 'source must be an AsyncIterable<Uint8Array>', {
43
+ field: 'source',
44
+ });
45
+ }
46
+ this.source = sourceArg;
47
+ this.maxPackageBytes = maxPackageBytesArg;
48
+ }
49
+
50
+ /** Offset of the next byte `read` returns, counted from the start of the file. */
51
+ public get position(): number {
52
+ return this.deliveredBytes;
53
+ }
54
+
55
+ /** Bytes pulled from the caller so far. */
56
+ public get pulled(): number {
57
+ return this.pulledBytes;
58
+ }
59
+
60
+ public async read(maxArg: number): Promise<Uint8Array | null> {
61
+ const pushed = this.pushedBack.pop();
62
+ if (pushed !== undefined) {
63
+ const chunk = takeFront(pushed, maxArg, this.pushedBack);
64
+ this.deliveredBytes += chunk.length;
65
+ return chunk;
66
+ }
67
+ const pulled = await this.pull();
68
+ if (pulled === null) {
69
+ return null;
70
+ }
71
+ const chunk = takeFront(pulled, maxArg, this.pushedBack);
72
+ this.deliveredBytes += chunk.length;
73
+ return chunk;
74
+ }
75
+
76
+ public unread(bytesArg: Uint8Array): void {
77
+ if (bytesArg.length === 0) {
78
+ return;
79
+ }
80
+ this.pushedBack.push(bytesArg);
81
+ this.deliveredBytes -= bytesArg.length;
82
+ }
83
+
84
+ /** Reads and discards the rest of the input. */
85
+ public async drain(): Promise<void> {
86
+ this.deliveredBytes += this.pushedBack.reduce((sum, chunk) => sum + chunk.length, 0);
87
+ this.pushedBack.length = 0;
88
+ while ((await this.pull()) !== null) {
89
+ // every pulled chunk is hashed and counted inside pull()
90
+ }
91
+ this.deliveredBytes = this.pulledBytes;
92
+ }
93
+
94
+ /** Size and digests of the whole input; only after the input has ended. */
95
+ public digests(): ILinuxPackageDigests {
96
+ if (!this.ended || this.failed) {
97
+ throw new Error('PackageByteSource.digests() called before the input ended');
98
+ }
99
+ if (this.finalDigests === null) {
100
+ this.finalDigests = {
101
+ size: this.pulledBytes,
102
+ md5: this.hashes.md5.digest('hex'),
103
+ sha1: this.hashes.sha1.digest('hex'),
104
+ sha256: this.hashes.sha256.digest('hex'),
105
+ sha512: this.hashes.sha512.digest('hex'),
106
+ };
107
+ }
108
+ return { ...this.finalDigests };
109
+ }
110
+
111
+ private async pull(): Promise<Uint8Array | null> {
112
+ if (this.failed) {
113
+ throw this.failure;
114
+ }
115
+ if (this.ended) {
116
+ return null;
117
+ }
118
+ try {
119
+ this.iterator ??= this.source[Symbol.asyncIterator]();
120
+ for (;;) {
121
+ let result: IteratorResult<Uint8Array>;
122
+ try {
123
+ result = await this.iterator.next();
124
+ } catch (error) {
125
+ // the caller's error, unchanged
126
+ this.fail(error);
127
+ throw error;
128
+ }
129
+ if (result.done) {
130
+ this.ended = true;
131
+ return null;
132
+ }
133
+ const value: unknown = result.value;
134
+ if (!(value instanceof Uint8Array)) {
135
+ throw new LinuxRepoError('invalid-input', 'source yielded a chunk that is not a Uint8Array', {
136
+ field: 'source',
137
+ });
138
+ }
139
+ if (value.length === 0) {
140
+ continue;
141
+ }
142
+ if (this.pulledBytes + value.length > this.maxPackageBytes) {
143
+ throw limitExceeded('maxPackageBytes', this.maxPackageBytes, 'package input');
144
+ }
145
+ this.pulledBytes += value.length;
146
+ this.hashes.md5.update(value);
147
+ this.hashes.sha1.update(value);
148
+ this.hashes.sha256.update(value);
149
+ this.hashes.sha512.update(value);
150
+ return value;
151
+ }
152
+ } catch (error) {
153
+ this.fail(error);
154
+ throw error;
155
+ }
156
+ }
157
+
158
+ private fail(errorArg: unknown): void {
159
+ if (!this.failed) {
160
+ this.failed = true;
161
+ this.failure = errorArg;
162
+ }
163
+ }
164
+ }