fix(desktop): read the PPA key add-apt-repository writes into Signed-By (#8982)

* fix(desktop): read the PPA key add-apt-repository writes into Signed-By

On Ubuntu 24.04, add-apt-repository puts the PPA's armored key inside the
deb822 Signed-By field, and REA refused such repositories, so PPAs added the
standard way could not be exported. REA now reads that key by deb822 rules,
verifies the release with it through the same private gpgv keyring as key
files, as the entry's only candidate (as apt does), retains it as armored and
exports it as apt-keys/<name>.asc. A held key that does not verify is refused
with a reason that names no URL.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(desktop): check the held-key refusal's facts, not its sentence

The PPA refusal tests now assert that the reason names the sources file
and the release file and no URL, and that export carries that reason,
instead of comparing whole sentences.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Edgar ArakelyanandClaude Opus 5.5 authored and GitHub committed 2026-11-07 01:49:11 +08:00
1 parent 91d537098a
commit f8356f3d71
10 files changed
+366 -62

No files matched your search

+16 -10
View File
@@ -201,21 +201,27 @@ found by verification, not by name: the candidates are the
entry's `signed-by` keyrings, or else every `.gpg` and `.asc` key in apt's trusted
parts folder and its trusted keyring, and each is checked with
`gpgv` against the release's `InRelease` (or `Release` with `Release.gpg`)
beside its index in apt's lists folder. A key counts when gpgv reports a
valid signature by it and no bad, expired, revoked or unverifiable signature
by it, whatever gpgv's exit status: a release also signed by a key the host
lacks makes gpgv fail. Each candidate is copied, an armored key decoded to a
binary keyring, into a private temporary folder that is removed afterwards; a
folder REA cannot remove fails the capture with the folder as its cleanup
resource. The key that verifies is recorded in `signing_key` with its path,
beside its index in apt's lists folder. A deb822 entry whose `Signed-By` field
holds the armored key itself, as `add-apt-repository ppa:<owner>/<name>`
writes it on Ubuntu 24.04, has that key as its only candidate, as apt trusts
no other for it: REA reads the field's continuation lines as deb822 writes
them, a `.` line standing for an empty one, from the key block's `BEGIN` line
to its last `END` line, and records the key's path as the entry's sources
file. A key counts when gpgv reports a valid signature by it and no bad,
expired, revoked or unverifiable signature by it, whatever gpgv's exit status:
a release also signed by a key the host lacks makes gpgv fail. Each candidate
is copied, an armored key decoded to a binary keyring, into a private
temporary folder that is removed afterwards; a folder REA cannot remove
fails the capture with the folder as its cleanup resource. The key that
verifies is recorded in `signing_key` with its path,
SHA-256, format (`binary` or `armored`) and primary fingerprint, and its bytes
are retained under `content/`. A key file holding one key is retained as it
is; from one holding several, such as a legacy `/etc/apt/trusted.gpg`, only
the transferable key with the verifying fingerprint is retained, in binary, so
the bundle trusts no other key. When no candidate verifies, `gpgv` is
missing, apt holds no signed release, no sources entry matches, the entry's
`Signed-By` holds the key itself, or the key is larger than
`--max-file-bytes` or the content budget, `signing_key` is null and
missing, apt holds no signed release, no sources entry matches, the key the
entry's `Signed-By` holds does not verify the release, or the key is larger
than `--max-file-bytes` or the content budget, `signing_key` is null and
`signing_key_reason` says why. Ubuntu Pro's ESM repositories (origins such as
`UbuntuESMApps`, host `esm.ubuntu.com`) are not checked: apt reaches them only
with the host's Ubuntu Pro credentials, which a computer does not have.
+5 -3
View File
@@ -88,9 +88,11 @@ The class declares:
- For a vendor repository, `sources=[AptSource(apt=..., suite=...,
components=[...], key="apt-keys/<name>.gpg")]`, with the retained key copied
to that path (`.asc` when it is armored, which agentenv tells by its
content). apt takes one key per URI and suite, so sources that share them,
such as one repository listed for two architectures, are declared once, with
their components merged; one with a different key is left out. A repository
content). A PPA added with `add-apt-repository`, whose sources entry holds its
key in `Signed-By`, exports that key as `apt-keys/<name>.asc`. apt takes one
key per URI and suite, so sources that share them, such as one repository
listed for two architectures, are declared once, with their components
merged; one with a different key is left out. A repository
whose key the capture did not verify, an Ubuntu Pro (ESM) repository, one
whose sources entry's URI held a user name or password (which the capture
did not record and the export never writes; the reason names neither them
@@ -36,11 +36,16 @@ that key, with those libraries, and starts it.
A VALIDSIG counts whatever gpgv's exit status, unless a BADSIG, EXPSIG,
EXPKEYSIG, REVKEYSIG or ERRSIG names the same key. Candidates are the
entry's Signed-By keyrings, or every `.gpg` and `.asc` key in
`/etc/apt/trusted.gpg.d` and `/etc/apt/trusted.gpg`. Each is written into a
private temporary folder, an armored key decoded to binary in REA, and the
folder is removed; a failed removal fails the capture with the folder's
owner and cleanup facts, as does a sources file, key or library whose
handle cannot be closed. When the repository check and the library walk
`/etc/apt/trusted.gpg.d` and `/etc/apt/trusted.gpg`. A deb822 Signed-By that
holds an armored key block, as `add-apt-repository ppa:…` writes it, is the
entry's only candidate: its continuation lines are stripped, a `.` line read
as an empty one, from the `BEGIN` line to the last `END` line, and the key's
path is the entry's sources file; a held key that does not verify is refused
with a reason naming that file and the release file, and no URL. Each is
written into a private temporary folder, an armored key decoded to binary in
REA, and the folder is removed; a failed removal fails the capture with the
folder's owner and cleanup facts, as does a sources file, key or library
whose handle cannot be closed. When the repository check and the library walk
both fail, the failure keeps both causes and every owner. The verified
key's path, SHA-256, format and primary fingerprint are recorded and its
bytes retained in the capture's content store, within `--max-file-bytes`
@@ -70,9 +75,10 @@ that key, with those libraries, and starts it.
- Export: a package from the Ubuntu 24.04 archive installs by name as before,
for amd64 and arm64. Otherwise each usable vendor repository is declared as
`AptSource(apt, suite, components, key="apt-keys/<name>.gpg|.asc")` with the
retained key copied into the environment folder, one source per URI and
suite; the package is named `<package>/<release>` from the first declared
repository, so its release wins over the archive, and the bundle supports
retained key copied into the environment folder (`.asc` for an armored key,
such as one a PPA's Signed-By held), one source per URI and suite; the
package is named `<package>/<release>` from the first declared repository,
so its release wins over the archive, and the bundle supports
only the capture host's architecture. A repository is usable when its key
was verified, its sources entry's URI held no user name or password
(`uri_credentials_removed`; a capture written before that field whose
@@ -94,9 +100,8 @@ that key, with those libraries, and starts it.
## Non-goals
- Keys held inline in a deb822 `Signed-By` field, Signed-By fingerprints
matched against the signing key, and repositories reached over anything but
http or https.
- Signed-By fingerprints matched against the signing key, and repositories
reached over anything but http or https.
- Pinning the captured version, carrying the host's apt pinning over, or
checking that the capture host's suite suits Ubuntu 24.04.
- Ubuntu Pro repositories, which need the host's credentials, and other
+21 -10
View File
@@ -14,7 +14,11 @@ import {
type AptSourceStanza,
} from "../domain/desktop/aptRepositories.js";
import type { AptRepository } from "../domain/desktop/desktopCapture.js";
import { withGpgvKeyCheck, type VerifiedKey } from "./GpgvKeyCheck.js";
import {
withGpgvKeyCheck,
type HeldKey,
type VerifiedKey,
} from "./GpgvKeyCheck.js";
import { isHostFile, readHostFile } from "./HostFileRead.js";
import { succeeded, type QueryRunner } from "./HostQuery.js";
@@ -159,12 +163,15 @@ const signedRelease = async (
: undefined;
};
/** The keys gpgv checks a release with: candidate key files, or the one key the sources entry holds. */
type KeyCandidates = readonly string[] | HeldKey;
interface RepositoryContext {
readonly apt: AptLocations;
readonly targets: ReadonlyMap<string, AptIndexTarget> | undefined;
readonly sources: (file: string) => Promise<readonly AptSourceStanza[]>;
readonly verify: (
candidates: readonly string[],
candidates: KeyCandidates,
release: readonly string[],
) => Promise<VerifiedKey | { readonly reason: string }>;
}
@@ -178,7 +185,7 @@ const keyCheck = async (
| { readonly reason: string }
| {
readonly release: readonly string[];
readonly candidates: readonly string[];
readonly candidates: KeyCandidates;
}
> => {
if (target === undefined)
@@ -192,16 +199,17 @@ const keyCheck = async (
return {
reason: `no sources entry at ${target.sourcesFile}:${String(target.position)} configures it`,
};
if (entry.inlineKey)
return {
reason:
"its Signed-By field holds the key itself, which REA does not read",
};
const release = await signedRelease(target);
if (release === undefined)
return {
reason: `apt holds no signed release file for it in ${dirname(target.filename)}`,
};
// apt trusts only the key an entry holds, never the keys it trusts for all repositories.
if (entry.inlineKey !== null)
return {
release,
candidates: { path: entry.file, bytes: Buffer.from(entry.inlineKey) },
};
const candidates = await candidateKeys(entry, context.apt);
return candidates.length > MAX_CANDIDATE_KEYS
? { reason: `more than ${String(MAX_CANDIDATE_KEYS)} candidate keys` }
@@ -269,7 +277,8 @@ const repositoryFacts = async (
* The apt repositories other than Ubuntu's that list the installed version.
* `apt-get indextargets` names each source's sources entry, release and
* signed release file; the key is the one whose signature over that release
* gpgv verified: the entry's Signed-By keyrings, or every key apt trusts.
* gpgv verified: the key the entry's Signed-By holds, its Signed-By keyrings,
* or every key apt trusts.
*/
export const inspectAptRepositories = async (
installed: {
@@ -298,7 +307,9 @@ export const inspectAptRepositories = async (
targets,
sources: files.read,
verify: (candidates, release) =>
check.firstVerifying(candidates, release),
"bytes" in candidates
? check.heldVerifying(candidates, release)
: check.firstVerifying(candidates, release),
};
const keys = new Map<string, Buffer>();
const repositories: AptRepository[] = [];
+106
View File
@@ -17,10 +17,14 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import {
A_ARMORED,
B_ARMORED,
GPGV_A_ONLY,
inlineSignedBy,
VENDOR_A,
VENDOR_B,
} from "../domain/desktop/aptKeys.fixture.js";
import { exportInstall } from "../domain/desktop/appExportInstall.js";
import { elfImage } from "../domain/desktop/elfImage.fixture.js";
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
import type { QueryOutcome, QueryRunner } from "./HostQuery.js";
@@ -137,6 +141,35 @@ const PRIVATE: Repository = {
releaseFile: "apt.vendor.example_repo_dists_stable_",
};
const PPA_URI = "https://ppa.launchpadcontent.net/vendor/typora/ubuntu";
/** A PPA as `add-apt-repository ppa:vendor/typora` adds it on Ubuntu 24.04, with the key it holds in Signed-By. */
const heldKeyPpa = (armored: string): Repository => ({
list: "vendor-ubuntu-typora-noble.sources",
line: () =>
[
"Types: deb",
`URIs: ${PPA_URI}/`,
"Suites: noble",
"Components: main",
inlineSignedBy(armored),
"",
].join("\n"),
source: `${PPA_URI} noble/main arm64 Packages`,
release:
"v=24.04,o=LP-PPA-vendor-typora,a=noble,n=noble,l=Typora,c=main,b=arm64",
indexTarget: [
"Codename: noble",
"Origin: LP-PPA-vendor-typora",
"Suite: noble",
`Base-URI: ${PPA_URI}/dists/noble/`,
"Component: main",
"Release: noble",
`Repo-URI: ${PPA_URI}/`,
].join("\n"),
releaseFile: "ppa.launchpadcontent.net_vendor_typora_ubuntu_dists_noble_",
});
interface FixtureOptions {
readonly repository?: Repository;
/** The bytes of trusted.gpg.d/typora.gpg (or .asc when armored). */
@@ -489,6 +522,79 @@ describe.skipIf(process.platform === "win32")("vendor repositories", () => {
});
});
describe.skipIf(process.platform === "win32")(
"keys a PPA's Signed-By holds",
() => {
it("verifies the key a PPA's Signed-By holds in a private keyring, retains it armored, and exports it as an .asc key", async () => {
const armored = Buffer.from(A_ARMORED);
const repository = heldKeyPpa(A_ARMORED);
const {
root,
keyrings,
signingKeys,
package: installed,
} = await inspected({ repository });
expect(installed.apt_repositories).toEqual([
expect.objectContaining({
source: repository.source,
uri: PPA_URI,
suite: "noble",
components: ["main"],
signing_key: {
path: join(root, "apt/sources.list.d", repository.list),
sha256: sha256(armored),
format: "armored",
fingerprint: VENDOR_A.fingerprint,
},
}),
]);
expect(signingKeys.get(sha256(armored))?.equals(armored)).toBe(true);
expect(keyrings).toHaveLength(1);
expect(keyrings[0]?.startsWith(root)).toBe(false);
await expect(access(keyrings[0] ?? "")).rejects.toThrow();
const install = exportInstall(installed, "typora", "arm64");
expect(install.ok && install.value.sources).toEqual([
{
uri: PPA_URI,
suite: "noble",
components: ["main"],
keyFile: "apt-keys/typora.asc",
keySha256: sha256(armored),
},
]);
});
it("refuses a PPA whose held key does not verify its release, without trying the keys apt trusts for all repositories or naming a URL", async () => {
const repository = heldKeyPpa(B_ARMORED);
const {
root,
keyrings,
signingKeys,
package: installed,
} = await inspected({ repository });
const refused = installed.apt_repositories?.[0];
const reason = refused?.signing_key_reason;
if (reason === undefined) throw new Error("expected a refusal reason");
expect(refused?.signing_key).toBeNull();
expect(reason).toContain(
join(root, "apt/sources.list.d", repository.list),
);
expect(reason).toContain(
join(root, "lists", `${repository.releaseFile}InRelease`),
);
expect(reason).not.toMatch(/[a-z][a-z0-9+.-]*:\/\//iu);
expect(keyrings).toHaveLength(1);
expect(signingKeys.size).toBe(0);
expect(exportInstall(installed, "typora", "arm64")).toMatchObject({
ok: false,
error: { field: "capture", reason: expect.stringContaining(reason) },
});
});
},
);
describe.skipIf(process.platform === "win32")("library packages", () => {
it("keeps the library packages the Ubuntu archive offers and sets aside the rest", async () => {
const vendor = await inspected();
+45
View File
@@ -6,6 +6,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import {
A_ARMORED,
SIGNED_BY_BOTH,
VENDOR_A,
VENDOR_B,
@@ -62,6 +63,25 @@ describe.skipIf(!hasGpgv)("withGpgvKeyCheck with gpgv", () => {
expect(verified.fingerprint).toBe(VENDOR_A.fingerprint);
expect(verified.bytes.equals(VENDOR_A.key)).toBe(true);
});
it("verifies the armored key a sources entry holds and retains it as it is, named by the entry's file", async () => {
const { release } = await files({});
const sources = "/etc/apt/sources.list.d/a-ubuntu-b-noble.sources";
const verified = await withGpgvKeyCheck(runHostQuery, undefined, (check) =>
check.heldVerifying({ path: sources, bytes: Buffer.from(A_ARMORED) }, [
release,
]),
);
if ("reason" in verified) throw new Error(verified.reason);
expect(verified).toMatchObject({
path: sources,
format: "armored",
fingerprint: VENDOR_A.fingerprint,
});
expect(verified.bytes.toString()).toBe(A_ARMORED);
});
});
describe("withGpgvKeyCheck cleanup", () => {
@@ -94,4 +114,29 @@ describe("withGpgvKeyCheck cleanup", () => {
);
await expect(access(owned.path)).rejects.toMatchObject({ code: "ENOENT" });
});
it("writes a held key's keyring into the private folder and removes it, saying why the key does not verify", async () => {
const { release } = await files({});
const keyrings: string[] = [];
const runQuery: QueryRunner = async (_command, argv) => {
keyrings.push(argv[argv.indexOf("--keyring") + 1] ?? "");
return { stdout: "[GNUPG:] NEWSIG\n", exitCode: 2 };
};
const sources = "/etc/apt/sources.list.d/a-ubuntu-b-noble.sources";
const verified = await withGpgvKeyCheck(runQuery, undefined, (check) =>
check.heldVerifying({ path: sources, bytes: Buffer.from(A_ARMORED) }, [
release,
]),
);
if (!("reason" in verified)) throw new Error("expected a refusal");
expect(verified.reason).toContain(sources);
expect(verified.reason).toContain(release);
expect(verified.reason).not.toMatch(/[a-z][a-z0-9+.-]*:\/\//iu);
expect(keyrings).toHaveLength(1);
await expect(access(keyrings[0] ?? "")).rejects.toMatchObject({
code: "ENOENT",
});
});
});
+53 -20
View File
@@ -16,7 +16,10 @@ import type { QueryRunner } from "./HostQuery.js";
const FILE_MAX_BYTES = 1024 * 1024;
/** A candidate key file that verified a release, and the bytes of it to retain. */
const released = (release: readonly string[]): string =>
release[0] ?? "its release file";
/** A candidate key file, or a key a sources entry holds, that verified a release, and the bytes of it to retain. */
export interface VerifiedKey {
readonly path: string;
readonly bytes: Buffer;
@@ -26,6 +29,12 @@ export interface VerifiedKey {
readonly fingerprint: string;
}
/** A key a deb822 sources entry holds in its Signed-By field, named by the entry's sources file. */
export interface HeldKey {
readonly path: string;
readonly bytes: Buffer;
}
/** Runs gpgv on one candidate keyring at a time, written into a private folder it owns. */
class GpgvKeyCheck {
#root: PrivateRuntimeRoot | undefined;
@@ -55,31 +64,53 @@ class GpgvKeyCheck {
candidates: readonly string[],
release: readonly string[],
): Promise<VerifiedKey | { readonly reason: string }> {
const released = release[0] ?? "its release file";
const unreadable: string[] = [];
for (const path of candidates) {
const bytes = await this.#read(path);
if (bytes === "unreadable") unreadable.push(path);
if (typeof bytes === "string") continue;
const signature = await this.#verify(bytes, release);
if (signature === "missing")
return {
reason:
"gpgv is not installed, so no key could be checked against the repository's signature",
};
if (signature === undefined) continue;
const kept = verifyingKey(bytes, signature.primary);
if (!kept.ok)
return { reason: `${path} verifies ${released}, but ${kept.error}` };
return {
path,
...kept.value,
sha256: createHash("sha256").update(kept.value.bytes).digest("hex"),
fingerprint: signature.primary,
};
const verified = await this.#verifying({ path, bytes }, release);
if (verified !== undefined) return verified;
}
return {
reason: `no candidate key (${candidates.join(", ")}) verifies ${released}${unreadable.length === 0 ? "" : `; unreadable: ${unreadable.join(", ")}`}`,
reason: `no candidate key (${candidates.join(", ")}) verifies ${released(release)}${unreadable.length === 0 ? "" : `; unreadable: ${unreadable.join(", ")}`}`,
};
}
/** The key a sources entry holds when it verifies the release, or why not. */
async heldVerifying(
key: HeldKey,
release: readonly string[],
): Promise<VerifiedKey | { readonly reason: string }> {
return (
(await this.#verifying(key, release)) ?? {
reason: `the key the Signed-By field of ${key.path} holds does not verify ${released(release)}`,
}
);
}
/** The key with the bytes to retain when it verifies the release, why it cannot count, or undefined when it does not verify. */
async #verifying(
key: { readonly path: string; readonly bytes: Buffer },
release: readonly string[],
): Promise<VerifiedKey | { readonly reason: string } | undefined> {
const signature = await this.#verify(key.bytes, release);
if (signature === "missing")
return {
reason:
"gpgv is not installed, so no key could be checked against the repository's signature",
};
if (signature === undefined) return undefined;
const kept = verifyingKey(key.bytes, signature.primary);
if (!kept.ok)
return {
reason: `${key.path} verifies ${released(release)}, but ${kept.error}`,
};
return {
path: key.path,
...kept.value,
sha256: createHash("sha256").update(kept.value.bytes).digest("hex"),
fingerprint: signature.primary,
};
}
@@ -118,7 +149,9 @@ class GpgvKeyCheck {
export const withGpgvKeyCheck = async <Value>(
runQuery: QueryRunner,
signal: AbortSignal | undefined,
use: (check: Pick<GpgvKeyCheck, "firstVerifying">) => Promise<Value>,
use: (
check: Pick<GpgvKeyCheck, "firstVerifying" | "heldVerifying">,
) => Promise<Value>,
): Promise<Value> => {
const check = new GpgvKeyCheck(runQuery, signal);
let outcome:
+34
View File
@@ -23,6 +23,40 @@ export const VENDOR_B = {
),
};
/** `gpg --armor --export A`, GnuPG 2.4.9. */
export const A_ARMORED = `-----BEGIN PGP PUBLIC KEY BLOCK-----
mDMEauuPaBYJKwYBBAHaRw8BAQdAU6pi/2EhXePFnrHKRH29KuHZHwAQElOjI9pN
c4b8HbG0GFZlbmRvciBBIDxhQGV4YW1wbGUuY29tPoiZBBMWCgBBFiEEbBcWys4/
mLGBw840ZwHP1biTA2wFAmrrj2gCGwMFCQXw8dgFCwkIBwICIgIGFQoJCAsCBBYC
AwECHgcCF4AACgkQZwHP1biTA2x38AD/TyC+wrSKRFUaH3E/VTwobbbg5wZbGYMk
n1ytPPD61xkA/j+dC/mvcEkT5YfMLx7KFPrlRnp+SAAWZBzW9ywLNo4A
=Em8s
-----END PGP PUBLIC KEY BLOCK-----
`;
/** `gpg --armor --export B`, GnuPG 2.4.9. */
export const B_ARMORED = `-----BEGIN PGP PUBLIC KEY BLOCK-----
mDMEauuPaBYJKwYBBAHaRw8BAQdA7npYbfFfwg0YB91jv2Ion9nZATrqRjMxfyoM
zYbuIKW0GFZlbmRvciBCIDxiQGV4YW1wbGUuY29tPoiZBBMWCgBBFiEEwo1/MzfH
muvX3Ul2+uUZIWP8omgFAmrrj2gCGwMFCQXw8dgFCwkIBwICIgIGFQoJCAsCBBYC
AwECHgcCF4AACgkQ+uUZIWP8omi6wwEAwdUiyGmAnfqDDEbm5jx1LyKa7NtHEEcH
FNiRbBi99DQBAJTsAHBzv2QhQ4t3tCelAnVI/u7T47wfKi3NgIp1MBkC
=OjLx
-----END PGP PUBLIC KEY BLOCK-----
`;
/** The Signed-By field `add-apt-repository ppa:…` writes for a key on Ubuntu 24.04: each line indented, an empty one as ` .`. */
export const inlineSignedBy = (armored: string): string =>
[
"Signed-By: ",
...armored
.trimEnd()
.split("\n")
.map((line) => (line === "" ? " ." : ` ${line}`)),
].join("\n");
/** `gpg --armor --export A B`: both keys in one armor block. */
export const BOTH_ARMORED = `-----BEGIN PGP PUBLIC KEY BLOCK-----
+50 -4
View File
@@ -1,6 +1,11 @@
import { describe, expect, it } from "vitest";
import { GPGV_A_ONLY, VENDOR_A } from "./aptKeys.fixture.js";
import {
A_ARMORED,
GPGV_A_ONLY,
inlineSignedBy,
VENDOR_A,
} from "./aptKeys.fixture.js";
import {
aptListFileName,
dearmorPublicKeys,
@@ -107,7 +112,7 @@ describe("parseOneLineSources", () => {
components: [],
architectures: [],
signedBy: [],
inlineKey: false,
inlineKey: null,
},
]);
});
@@ -150,7 +155,7 @@ describe("parseDeb822Sources", () => {
});
});
it("skips disabled stanzas yet counts them, as apt does, and marks a Signed-By that holds the key itself", () => {
it("skips disabled stanzas yet counts them, as apt does, and reads a Signed-By that holds the key itself", () => {
const stanzas = parseDeb822Sources(
[
"# a comment-only paragraph apt does not count",
@@ -180,11 +185,52 @@ describe("parseDeb822Sources", () => {
uris: ["https://ppa.launchpadcontent.net/a/b/ubuntu"],
architectures: ["arm64"],
signedBy: [],
inlineKey: true,
inlineKey:
"-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBGdummy\n-----END PGP PUBLIC KEY BLOCK-----\n",
}),
]);
});
it("reads the key a PPA's Signed-By holds as add-apt-repository writes it, so it dearmors to the key", () => {
const [stanza] = parseDeb822Sources(
[
"Types: deb",
"URIs: https://ppa.launchpadcontent.net/freecad-maintainers/freecad-stable/ubuntu/",
"Suites: noble",
"Components: main",
inlineSignedBy(A_ARMORED),
"",
].join("\n"),
"/etc/apt/sources.list.d/freecad-maintainers-ubuntu-freecad-stable-noble.sources",
);
expect(stanza).toMatchObject({ signedBy: [], inlineKey: A_ARMORED });
expect(
dearmorPublicKeys(Buffer.from(stanza?.inlineKey ?? ""))?.equals(
VENDOR_A.key,
),
).toBe(true);
});
it("reads a key that starts on the Signed-By line, with tab-indented lines and a field after it", () => {
const [stanza] = parseDeb822Sources(
[
"Types: deb",
"URIs: https://ppa.launchpadcontent.net/a/b/ubuntu/",
"Suites: noble",
`Signed-By: ${A_ARMORED.trimEnd().replaceAll("\n\n", "\n.\n").replaceAll("\n", "\n\t ")}`,
"# a comment between fields",
"Components: main",
].join("\n"),
"a-ubuntu-b-noble.sources",
);
expect(stanza).toMatchObject({
components: ["main"],
inlineKey: A_ARMORED,
});
});
it("keeps a stanza's URIs and suites as lists and resolves a target's pair from them, without expanding every pair", () => {
const uris = Array.from(
{ length: 500 },
+20 -4
View File
@@ -18,8 +18,8 @@ export interface AptSourceStanza {
readonly architectures: readonly string[];
/** Keyring paths and key fingerprints the entry is signed by. */
readonly signedBy: readonly string[];
/** Whether Signed-By holds a key block rather than paths or fingerprints. */
readonly inlineKey: boolean;
/** The armored key block Signed-By holds in place of paths or fingerprints; null when it holds none. */
readonly inlineKey: string | null;
}
/** One `deb` entry of an apt sources file for one URI and suite. */
@@ -55,6 +55,22 @@ export const withoutCredentials = (uri: string): string =>
export const withoutCredentialsIn = (text: string): string =>
text.replace(/(?<scheme>[a-z][a-z0-9+.-]*:\/\/)[^\s/]*@/giu, "$<scheme>");
/**
* The key block a deb822 Signed-By field holds, as apt writes it to a key
* file: each line stripped and a `.` line made empty, from its BEGIN line to
* its last END line; null when the field holds no key block.
*/
const inlineKeyBlock = (value: string): string | null => {
if (!value.includes(ARMOR_BEGIN)) return null;
const lines = value.split("\n").map((line) => {
const stripped = line.trim();
return stripped === "." ? "" : stripped;
});
const begin = Math.max(lines.indexOf(ARMOR_BEGIN), 0);
const end = lines.lastIndexOf(ARMOR_END);
return `${lines.slice(begin, end < begin ? lines.length : end + 1).join("\n")}\n`;
};
/** Whether a suite as an entry writes it is a target's release, which apt leaves empty for the flat suite `/`. */
const isRelease = (suite: string, release: string): boolean =>
suite === release || (suite === "/" && release === "");
@@ -69,7 +85,7 @@ const stanza = (
},
): AptSourceStanza => {
const signedBy = fields.options.get("signed-by") ?? "";
const inlineKey = signedBy.includes(ARMOR_BEGIN);
const inlineKey = inlineKeyBlock(signedBy);
const uris = fields.uris.map(withoutCredentials);
return {
...at,
@@ -80,7 +96,7 @@ const stanza = (
suites: fields.suites,
components: fields.components,
architectures: listValues(fields.options.get("arch")),
signedBy: inlineKey ? [] : listValues(signedBy),
signedBy: inlineKey === null ? listValues(signedBy) : [],
inlineKey,
};
};