From a523cb02cfdcb09b51ade244e1b09e4936b53402 Mon Sep 17 00:00:00 2001 From: Alexander-Sorrell-IT <284331358+Alexander-Sorrell-IT@users.noreply.github.com> Date: Fri, 29 May 2026 15:57:08 -0500 Subject: [PATCH 1/3] feat(targets): add pkg-pacman target (Arch custom repository) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the pacman distribution target — a custom Arch binary repo via repo-add (NOT the AUR; pkg-aur covers that). Mirrors the pkg-apt/pkg-dnf convention. - build() generates a real PKGBUILD (pkgver normalized: no '-'/':'; shell-escaped pkgdesc/url) + a pacman.conf [repo] snippet for consumers. - ship() is dry-run-safe and surfaces makepkg + repo-add (--sign) commands; live publish throws not-implemented rather than a false success. - Register pkg-pacman in cli adapter-registry; TARGETS.md row added. --- TARGETS.md | 1 + packages/cli/src/adapter-registry.ts | 2 +- packages/targets/pkg-pacman/README.md | 44 +++++ packages/targets/pkg-pacman/package.json | 17 ++ packages/targets/pkg-pacman/src/index.test.ts | 82 ++++++++++ packages/targets/pkg-pacman/src/index.ts | 150 ++++++++++++++++++ packages/targets/pkg-pacman/tsconfig.json | 5 + pnpm-lock.yaml | 6 + 8 files changed, 306 insertions(+), 1 deletion(-) create mode 100644 packages/targets/pkg-pacman/README.md create mode 100644 packages/targets/pkg-pacman/package.json create mode 100644 packages/targets/pkg-pacman/src/index.test.ts create mode 100644 packages/targets/pkg-pacman/src/index.ts create mode 100644 packages/targets/pkg-pacman/tsconfig.json diff --git a/TARGETS.md b/TARGETS.md index 2b7f76d1..92b240c0 100644 --- a/TARGETS.md +++ b/TARGETS.md @@ -23,6 +23,7 @@ Each row is a **target adapter** — one plugin in `packages/targets/*`. Status | `pkg-snap` | Snapcraft | 🚧 | | `pkg-flatpak` | Flathub | ✅ | | `pkg-aur` | Arch User Repo | 🚧 | +| `pkg-pacman` | Arch custom repo (repo-add) | ✅ | | `pkg-nix` | nixpkgs | 🚧 | ### Desktop diff --git a/packages/cli/src/adapter-registry.ts b/packages/cli/src/adapter-registry.ts index 6f78ff5d..9860bed6 100644 --- a/packages/cli/src/adapter-registry.ts +++ b/packages/cli/src/adapter-registry.ts @@ -162,7 +162,7 @@ export const CATEGORIES: readonly AdapterCategory[] = [ id: 'targets', pkgPrefix: '@profullstack/sh1pt-target', description: 'Distribution targets — stores, registries, CDNs, deploy platforms', - adapters: ['browser-chrome', 'browser-edge', 'browser-firefox', 'browser-safari', 'chat-discord', 'chat-signal', 'chat-slack', 'chat-telegram', 'chat-whatsapp', 'console-steam', 'deploy-coolify', 'deploy-denodeploy', 'deploy-firebase', 'deploy-fly', 'deploy-lambda', 'deploy-netlify', 'deploy-railway', 'deploy-render', 'deploy-vercel', 'deploy-workers', 'desktop-linux', 'desktop-mac', 'desktop-steamos', 'desktop-win', 'exe-dev', 'mobile-android', 'mobile-expo', 'mobile-ios', 'payment-adyen', 'payment-coinpay', 'payment-paypal', 'payment-square', 'payment-stripe', 'pkg-apt', 'pkg-aube', 'pkg-aur', 'pkg-cdn', 'pkg-deno', 'pkg-docker', 'pkg-fdroid', 'pkg-flatpak', 'pkg-ghpackages', 'pkg-homebrew', 'pkg-jsr', 'pkg-nix', 'pkg-npm', 'pkg-perry', 'pkg-scoop', 'pkg-snap', 'pkg-winget', 'plugin-jetbrains', 'plugin-vscode', 'qa-geisterhand', 'sdk-pypi', 'tv-androidtv', 'tv-firetv', 'tv-roku', 'tv-tvos', 'tv-webos', 'web-static', 'xr-meta-quest', 'xr-pico', 'xr-sidequest', 'xr-steamvr', 'xr-visionos', 'xr-webxr'], + adapters: ['browser-chrome', 'browser-edge', 'browser-firefox', 'browser-safari', 'chat-discord', 'chat-signal', 'chat-slack', 'chat-telegram', 'chat-whatsapp', 'console-steam', 'deploy-coolify', 'deploy-denodeploy', 'deploy-firebase', 'deploy-fly', 'deploy-lambda', 'deploy-netlify', 'deploy-railway', 'deploy-render', 'deploy-vercel', 'deploy-workers', 'desktop-linux', 'desktop-mac', 'desktop-steamos', 'desktop-win', 'exe-dev', 'mobile-android', 'mobile-expo', 'mobile-ios', 'payment-adyen', 'payment-coinpay', 'payment-paypal', 'payment-square', 'payment-stripe', 'pkg-apt', 'pkg-aube', 'pkg-aur', 'pkg-cdn', 'pkg-deno', 'pkg-docker', 'pkg-fdroid', 'pkg-flatpak', 'pkg-ghpackages', 'pkg-homebrew', 'pkg-jsr', 'pkg-nix', 'pkg-npm', 'pkg-pacman', 'pkg-perry', 'pkg-scoop', 'pkg-snap', 'pkg-winget', 'plugin-jetbrains', 'plugin-vscode', 'qa-geisterhand', 'sdk-pypi', 'tv-androidtv', 'tv-firetv', 'tv-roku', 'tv-tvos', 'tv-webos', 'web-static', 'xr-meta-quest', 'xr-pico', 'xr-sidequest', 'xr-steamvr', 'xr-visionos', 'xr-webxr'], }, { id: 'vcs', diff --git a/packages/targets/pkg-pacman/README.md b/packages/targets/pkg-pacman/README.md new file mode 100644 index 00000000..369a4930 --- /dev/null +++ b/packages/targets/pkg-pacman/README.md @@ -0,0 +1,44 @@ +# Pacman / Arch custom repository + +Provides the Pacman / Arch custom repository sh1pt target adapter for build, publish, or deployment workflows. + +## What it does + +- Registers a target surface that sh1pt can build, publish, or deploy to. +- Provides adapter metadata and lifecycle hooks used by the CLI. +- Includes setup guidance for required credentials or provider configuration. +- Implements build behavior used by sh1pt target workflows. +- Implements publish behavior used by sh1pt promotion or release workflows. + +## Package + +- Name: `@profullstack/sh1pt-target-pkg-pacman` +- Path: `packages/targets/pkg-pacman` +- Adapter ID: `pkg-pacman` +- Homepage: https://sh1pt.com + +## Scripts + +- `build`: `tsc -p tsconfig.json` +- `prepublishOnly`: `pnpm build` +- `typecheck`: `tsc -p tsconfig.json --noEmit` + +## Usage + +```bash +pnpm add @profullstack/sh1pt-target-pkg-pacman +``` + +## Development + +```bash +pnpm --filter @profullstack/sh1pt-target-pkg-pacman typecheck +``` + +Run tests from the repository root when this module includes a test file: + +```bash +pnpm vitest run packages/targets/pkg-pacman/src/index.test.ts +``` + + diff --git a/packages/targets/pkg-pacman/package.json b/packages/targets/pkg-pacman/package.json new file mode 100644 index 00000000..80759a1c --- /dev/null +++ b/packages/targets/pkg-pacman/package.json @@ -0,0 +1,17 @@ +{ + "name": "@profullstack/sh1pt-target-pkg-pacman", + "version": "0.1.15", + "type": "module", + "main": "./src/index.ts", + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "prepublishOnly": "pnpm build" + }, + "dependencies": { "@profullstack/sh1pt-core": "workspace:*" }, + "license": "MIT", + "repository": { "type": "git", "url": "git+https://github.com/profullstack/sh1pt.git", "directory": "packages/targets/pkg-pacman" }, + "homepage": "https://sh1pt.com", + "bugs": "https://github.com/profullstack/sh1pt/issues", + "files": ["dist"] +} diff --git a/packages/targets/pkg-pacman/src/index.test.ts b/packages/targets/pkg-pacman/src/index.test.ts new file mode 100644 index 00000000..79db4f74 --- /dev/null +++ b/packages/targets/pkg-pacman/src/index.test.ts @@ -0,0 +1,82 @@ +import { fakeBuildContext, fakeShipContext, smokeTest } from '@profullstack/sh1pt-core/testing'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import adapter from './index.js'; + +smokeTest(adapter, { idPrefix: 'pkg', requireKind: true }); + +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +describe('pacman PKGBUILD generation', () => { + it('writes a valid PKGBUILD with normalized pkgver and a pacman.conf snippet', async () => { + const outDir = await mkdtemp(join(tmpdir(), 'sh1pt-pacman-')); + tempDirs.push(outDir); + + const result = await adapter.build(fakeBuildContext({ outDir, version: 'v1.5.0-rc1' }) as any, { + pkgname: 'myapp', + pkgdesc: 'My App', + url: 'https://example.com/myapp', + license: ['MIT'], + depends: ['glibc'], + makedepends: ['git'], + sourceUrl: 'https://example.com/myapp-1.5.0.tar.gz', + sha256sum: 'a'.repeat(64), + repoName: 'sovereign', + }); + + const pkgbuildPath = join(outDir, 'pacman', 'PKGBUILD'); + expect(result.artifact).toBe(pkgbuildPath); + + const pkgbuild = await readFile(pkgbuildPath, 'utf-8'); + expect(pkgbuild).toContain('pkgname=myapp'); + // pacman pkgver may not contain '-' or ':' — normalized to '_'. + expect(pkgbuild).toContain('pkgver=1.5.0_rc1'); + expect(pkgbuild).toContain('pkgrel=1'); + expect(pkgbuild).toContain("arch=('x86_64')"); + expect(pkgbuild).toContain("license=('MIT')"); + expect(pkgbuild).toContain("depends=('glibc')"); + expect(pkgbuild).toContain('source=("$pkgname-$pkgver.tar.gz::https://example.com/myapp-1.5.0.tar.gz")'); + expect(pkgbuild).toContain(`sha256sums=('${'a'.repeat(64)}')`); + expect(pkgbuild).toContain('package() {'); + + const conf = await readFile(join(outDir, 'pacman', 'sovereign.pacman.conf'), 'utf-8'); + expect(conf).toContain('[sovereign]'); + expect(conf).toContain('Server = '); + }); + + it('escapes shell metacharacters in pkgdesc and defaults source to SKIP', async () => { + const outDir = await mkdtemp(join(tmpdir(), 'sh1pt-pacman-')); + tempDirs.push(outDir); + + await adapter.build(fakeBuildContext({ outDir, version: '2.0.0' }) as any, { + pkgname: 'myapp', + pkgdesc: 'Has "quotes" and $VARS and `backticks`', + }); + + const pkgbuild = await readFile(join(outDir, 'pacman', 'PKGBUILD'), 'utf-8'); + // Double-quote, $ and backtick are escaped so the bash PKGBUILD stays valid. + expect(pkgbuild).toContain('pkgdesc="Has \\"quotes\\" and \\$VARS and \\`backticks\\`"'); + // No source provided -> empty arrays, not a broken reference. + expect(pkgbuild).toContain('source=()'); + expect(pkgbuild).toContain('sha256sums=()'); + }); + + it('keeps dry-run shipping side-effect free; live publish throws not-implemented', async () => { + const ship = await adapter.ship(fakeShipContext({ version: '1.5.0', dryRun: true }) as any, { + pkgname: 'myapp', + repoName: 'sovereign', + }); + expect(ship.id).toBe('dry-run'); + expect((ship.meta?.commands as string[])?.some((c) => c.startsWith('repo-add'))).toBe(true); + + await expect(adapter.ship(fakeShipContext({ version: '1.5.0', dryRun: false }) as any, { + pkgname: 'myapp', + })).rejects.toThrow(/PACMAN_GPG_KEY|not implemented/i); + }); +}); diff --git a/packages/targets/pkg-pacman/src/index.ts b/packages/targets/pkg-pacman/src/index.ts new file mode 100644 index 00000000..63ea71a1 --- /dev/null +++ b/packages/targets/pkg-pacman/src/index.ts @@ -0,0 +1,150 @@ +import { defineTarget, manualSetup } from '@profullstack/sh1pt-core'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; + +type PacmanArch = 'x86_64' | 'aarch64' | 'any'; + +interface Config { + pkgname: string; // e.g. "myapp" + pkgdesc?: string; + url?: string; // homepage + license?: string[]; // e.g. ["MIT"] + pkgrel?: string; // default "1" + arch?: PacmanArch[]; // default ["x86_64"] + depends?: string[]; + makedepends?: string[]; + sourceUrl?: string; // upstream source tarball + sha256sum?: string; // checksum of the source ("SKIP" if none) + repoName?: string; // custom pacman repo db name (NOT the AUR) + repoBaseUrl?: string; // where the repo db is hosted (Server= in pacman.conf) +} + +// pacman pkgver may not contain '-' or ':'. Strip leading v, map '-'/':' to '_'. +function pkgver(version: string): string { + return version.replace(/^v/, '').replace(/[-:]/g, '_'); +} + +function arches(config: Config): PacmanArch[] { + return config.arch ?? ['x86_64']; +} + +// Escape a value for a double-quoted bash string in the PKGBUILD. +function shEscape(value: string): string { + return value.replace(/(["`$\\])/g, '\\$1'); +} + +function bashArray(values: string[]): string { + return values.map((v) => `'${v.replace(/'/g, "'\\''")}'`).join(' '); +} + +function renderPkgbuild(ctx: { version: string }, config: Config): string { + const ver = pkgver(ctx.version); + const rel = config.pkgrel ?? '1'; + const desc = config.pkgdesc ?? `Release package for ${config.pkgname}`; + const license = config.license ?? ['MIT']; + const lines = [ + '# Maintainer: sh1pt ', + `pkgname=${config.pkgname}`, + `pkgver=${ver}`, + `pkgrel=${rel}`, + `pkgdesc="${shEscape(desc)}"`, + `arch=(${bashArray(arches(config))})`, + ]; + if (config.url) lines.push(`url="${shEscape(config.url)}"`); + lines.push(`license=(${bashArray(license)})`); + if (config.depends?.length) lines.push(`depends=(${bashArray(config.depends)})`); + if (config.makedepends?.length) lines.push(`makedepends=(${bashArray(config.makedepends)})`); + if (config.sourceUrl) { + lines.push(`source=("$pkgname-$pkgver.tar.gz::${shEscape(config.sourceUrl)}")`); + lines.push(`sha256sums=('${config.sha256sum ?? 'SKIP'}')`); + } else { + lines.push('source=()'); + lines.push('sha256sums=()'); + } + lines.push(''); + lines.push('build() {'); + lines.push(' # TODO: project build steps (make, cargo build --release, go build, …)'); + lines.push(' :'); + lines.push('}'); + lines.push(''); + lines.push('package() {'); + lines.push(' # TODO: install built artifacts into "$pkgdir"'); + lines.push(' :'); + lines.push('}'); + lines.push(''); + return lines.join('\n'); +} + +function renderRepoConf(config: Config): string { + const repo = config.repoName ?? config.pkgname; + const server = config.repoBaseUrl ?? `https://pacman.sh1pt.com/${repo}/$arch`; + return [ + `# Add to /etc/pacman.conf to consume the custom ${repo} repository:`, + `[${repo}]`, + `Server = ${server}`, + '', + ].join('\n'); +} + +function publishCommands(ctx: { version: string }, config: Config): string[] { + const repo = config.repoName ?? config.pkgname; + const ver = pkgver(ctx.version); + const rel = config.pkgrel ?? '1'; + const arch = arches(config)[0] ?? 'x86_64'; + const pkgfile = `${config.pkgname}-${ver}-${rel}-${arch}.pkg.tar.zst`; + return [ + 'makepkg -s --sign', + `repo-add --sign ${repo}.db.tar.zst ${pkgfile}`, + `rsync ./ ${config.repoBaseUrl ?? 'pacman.sh1pt.com:/var/www/pacman/' + repo}/`, + ]; +} + +export default defineTarget({ + id: 'pkg-pacman', + kind: 'package-manager', + label: 'Pacman / Arch custom repository', + async build(ctx, config) { + const pkgbuildPath = join(ctx.outDir, 'pacman', 'PKGBUILD'); + const confPath = join(ctx.outDir, 'pacman', `${config.repoName ?? config.pkgname}.pacman.conf`); + ctx.log(`generate PKGBUILD for ${config.pkgname} v${ctx.version} [${arches(config).join(', ')}]`); + await mkdir(dirname(pkgbuildPath), { recursive: true }); + await Promise.all([ + writeFile(pkgbuildPath, renderPkgbuild(ctx, config), 'utf-8'), + writeFile(confPath, renderRepoConf(config), 'utf-8'), + ]); + return { + artifact: pkgbuildPath, + meta: { pkgbuild: pkgbuildPath, repoConf: confPath, commands: publishCommands(ctx, config) }, + }; + }, + async ship(ctx, config) { + const repo = config.repoName ?? config.pkgname; + ctx.log(`publish ${config.pkgname}@${ctx.version} to pacman repo (${repo})`); + if (ctx.dryRun) return { id: 'dry-run', meta: { commands: publishCommands(ctx, config) } }; + if (!ctx.secret('PACMAN_GPG_KEY')) { + throw new Error('PACMAN_GPG_KEY not in vault — run: sh1pt secret set PACMAN_GPG_KEY "$(gpg --export-secret-keys --armor )"'); + } + // Live publish (makepkg + repo-add + signed upload) is not implemented yet — + // fail loudly rather than report a false success. + throw new Error( + `pkg-pacman live publish for ${config.pkgname} is not implemented yet — ` + + 'use dryRun to preview the makepkg + repo-add commands.', + ); + }, + async status(id) { + const name = id.split('@')[0] ?? id; + return { state: 'in-review', url: `https://pacman.sh1pt.com/${name}/` }; + }, + setup: manualSetup({ + label: 'Pacman / Arch custom repository', + vendorDocUrl: 'https://man.archlinux.org/man/repo-add.8.en', + steps: [ + 'This is a custom pacman binary repo (repo-add), NOT the AUR — for the AUR use pkg-aur.', + 'Generate a signing key: gpg --full-generate-key', + 'Run: sh1pt secret set PACMAN_GPG_KEY "$(gpg --export-secret-keys --armor )"', + 'Run: sh1pt secret set PACMAN_GPG_PASSPHRASE ', + 'Build needs an Arch-like host with base-devel (makepkg) + the repo-add tool.', + 'Consumers add the [repo] + Server line (generated .pacman.conf) to /etc/pacman.conf.', + ], + }), +}); diff --git a/packages/targets/pkg-pacman/tsconfig.json b/packages/targets/pkg-pacman/tsconfig.json new file mode 100644 index 00000000..cf441478 --- /dev/null +++ b/packages/targets/pkg-pacman/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist", "rootDir": "src" }, + "include": ["src/**/*"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d5378866..67075473 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1880,6 +1880,12 @@ importers: specifier: workspace:* version: link:../../core + packages/targets/pkg-pacman: + dependencies: + '@profullstack/sh1pt-core': + specifier: workspace:* + version: link:../../core + packages/targets/pkg-perry: dependencies: '@profullstack/sh1pt-core': From 81d21afaeaf854f777e83af859c03b26ed78dde4 Mon Sep 17 00:00:00 2001 From: Alexander-Sorrell-IT <284331358+Alexander-Sorrell-IT@users.noreply.github.com> Date: Fri, 29 May 2026 16:13:46 -0500 Subject: [PATCH 2/3] fix(pkg-pacman): validate names + quote commands (adversarial review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - assertValidNames(): reject pkgname not matching Arch rules and repoName outside [A-Za-z0-9_-] — closes PKGBUILD syntax + pacman.conf [section] injection vectors (called from build() and ship()). - Quote repo db / pkgfile / rsync dest in publishCommands. - sha256sums via bashArray (escapes embedded quotes). - ship() throws not-implemented immediately (no misleading secret check). - Tests: bash -n syntax validation with hostile inputs + name-rejection cases. --- packages/targets/pkg-pacman/src/index.test.ts | 31 +++++++++++++++++ packages/targets/pkg-pacman/src/index.ts | 34 +++++++++++++++---- 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/packages/targets/pkg-pacman/src/index.test.ts b/packages/targets/pkg-pacman/src/index.test.ts index 79db4f74..818c2941 100644 --- a/packages/targets/pkg-pacman/src/index.test.ts +++ b/packages/targets/pkg-pacman/src/index.test.ts @@ -1,4 +1,5 @@ import { fakeBuildContext, fakeShipContext, smokeTest } from '@profullstack/sh1pt-core/testing'; +import { execFileSync } from 'node:child_process'; import { mkdtemp, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -79,4 +80,34 @@ describe('pacman PKGBUILD generation', () => { pkgname: 'myapp', })).rejects.toThrow(/PACMAN_GPG_KEY|not implemented/i); }); + + it('generates syntactically valid bash (bash -n) even with hostile field values', async () => { + const outDir = await mkdtemp(join(tmpdir(), 'sh1pt-pacman-')); + tempDirs.push(outDir); + + await adapter.build(fakeBuildContext({ outDir, version: '1.0.0' }) as any, { + pkgname: 'myapp', + pkgdesc: 'evil"; rm -rf /; echo "$(whoami)` `', + url: 'https://x.test/$(id)`uname`', + sha256sum: "ab'cd", + }); + + // bash -n parses without executing — proves the generated PKGBUILD is valid bash. + const out = execFileSync('bash', ['-n', join(outDir, 'pacman', 'PKGBUILD')], { encoding: 'utf-8', stdio: 'pipe' }); + expect(out).toBe(''); + }); + + it('rejects pkgname / repoName that would break the PKGBUILD or pacman.conf', async () => { + const outDir = await mkdtemp(join(tmpdir(), 'sh1pt-pacman-')); + tempDirs.push(outDir); + + await expect(adapter.build(fakeBuildContext({ outDir, version: '1.0.0' }) as any, { + pkgname: 'bad name; rm -rf /', + })).rejects.toThrow(/invalid pkgname/i); + + await expect(adapter.build(fakeBuildContext({ outDir, version: '1.0.0' }) as any, { + pkgname: 'myapp', + repoName: 'evil]\n[core', + })).rejects.toThrow(/invalid repoName/i); + }); }); diff --git a/packages/targets/pkg-pacman/src/index.ts b/packages/targets/pkg-pacman/src/index.ts index 63ea71a1..b80611da 100644 --- a/packages/targets/pkg-pacman/src/index.ts +++ b/packages/targets/pkg-pacman/src/index.ts @@ -37,6 +37,25 @@ function bashArray(values: string[]): string { return values.map((v) => `'${v.replace(/'/g, "'\\''")}'`).join(' '); } +// Reject inputs that could produce an invalid PKGBUILD or inject into the +// pacman.conf [section] header / shell commands. pkgname follows Arch rules; +// the repo db/section name allows only [A-Za-z0-9_-]. +function assertValidNames(config: Config): void { + if (!/^[a-z0-9][a-z0-9@._+-]*$/.test(config.pkgname)) { + throw new Error( + `pkg-pacman: invalid pkgname '${config.pkgname}' — Arch pkgnames are lowercase ` + + 'alphanumerics plus @ . _ + -, and may not start with "-" or "."', + ); + } + const repo = config.repoName ?? config.pkgname; + if (!/^[A-Za-z0-9_-]+$/.test(repo)) { + throw new Error( + `pkg-pacman: invalid repoName '${repo}' — only letters, digits, "-" and "_" ` + + 'are allowed (it becomes a pacman.conf [section] header and a repo db filename)', + ); + } +} + function renderPkgbuild(ctx: { version: string }, config: Config): string { const ver = pkgver(ctx.version); const rel = config.pkgrel ?? '1'; @@ -56,7 +75,7 @@ function renderPkgbuild(ctx: { version: string }, config: Config): string { if (config.makedepends?.length) lines.push(`makedepends=(${bashArray(config.makedepends)})`); if (config.sourceUrl) { lines.push(`source=("$pkgname-$pkgver.tar.gz::${shEscape(config.sourceUrl)}")`); - lines.push(`sha256sums=('${config.sha256sum ?? 'SKIP'}')`); + lines.push(`sha256sums=(${bashArray([config.sha256sum ?? 'SKIP'])})`); } else { lines.push('source=()'); lines.push('sha256sums=()'); @@ -92,10 +111,11 @@ function publishCommands(ctx: { version: string }, config: Config): string[] { const rel = config.pkgrel ?? '1'; const arch = arches(config)[0] ?? 'x86_64'; const pkgfile = `${config.pkgname}-${ver}-${rel}-${arch}.pkg.tar.zst`; + const dest = config.repoBaseUrl ?? `pacman.sh1pt.com:/var/www/pacman/${repo}`; return [ 'makepkg -s --sign', - `repo-add --sign ${repo}.db.tar.zst ${pkgfile}`, - `rsync ./ ${config.repoBaseUrl ?? 'pacman.sh1pt.com:/var/www/pacman/' + repo}/`, + `repo-add --sign '${repo}.db.tar.zst' '${pkgfile}'`, + `rsync ./ '${dest}/'`, ]; } @@ -104,6 +124,7 @@ export default defineTarget({ kind: 'package-manager', label: 'Pacman / Arch custom repository', async build(ctx, config) { + assertValidNames(config); const pkgbuildPath = join(ctx.outDir, 'pacman', 'PKGBUILD'); const confPath = join(ctx.outDir, 'pacman', `${config.repoName ?? config.pkgname}.pacman.conf`); ctx.log(`generate PKGBUILD for ${config.pkgname} v${ctx.version} [${arches(config).join(', ')}]`); @@ -118,14 +139,13 @@ export default defineTarget({ }; }, async ship(ctx, config) { + assertValidNames(config); const repo = config.repoName ?? config.pkgname; ctx.log(`publish ${config.pkgname}@${ctx.version} to pacman repo (${repo})`); if (ctx.dryRun) return { id: 'dry-run', meta: { commands: publishCommands(ctx, config) } }; - if (!ctx.secret('PACMAN_GPG_KEY')) { - throw new Error('PACMAN_GPG_KEY not in vault — run: sh1pt secret set PACMAN_GPG_KEY "$(gpg --export-secret-keys --armor )"'); - } // Live publish (makepkg + repo-add + signed upload) is not implemented yet — - // fail loudly rather than report a false success. + // fail loudly rather than report a false success. (PACMAN_GPG_KEY is + // documented in setup(); not checked here since the path is unimplemented.) throw new Error( `pkg-pacman live publish for ${config.pkgname} is not implemented yet — ` + 'use dryRun to preview the makepkg + repo-add commands.', From 8ee5b45ec3075d7c9cd7122fc06cd773978b6ac3 Mon Sep 17 00:00:00 2001 From: Alexander-Sorrell-IT <284331358+Alexander-Sorrell-IT@users.noreply.github.com> Date: Fri, 29 May 2026 16:22:24 -0500 Subject: [PATCH 3/3] fix(pkg-pacman): validate repoBaseUrl + pkgrel (Greptile P1/P2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - assertValidNames() now rejects repoBaseUrl containing newlines or single quotes (P1 security): a newline injected a fake [section] into the generated .pacman.conf; a single quote broke out of the rsync command quoting. - Validate pkgrel is a positive integer (P2) — it's embedded unquoted in the PKGBUILD. - Fix misleading test description (empty arrays, not SKIP); add rejection tests for hostile repoBaseUrl and pkgrel. --- packages/targets/pkg-pacman/src/index.test.ts | 14 +++++++++++++- packages/targets/pkg-pacman/src/index.ts | 11 +++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/targets/pkg-pacman/src/index.test.ts b/packages/targets/pkg-pacman/src/index.test.ts index 818c2941..409cdd8a 100644 --- a/packages/targets/pkg-pacman/src/index.test.ts +++ b/packages/targets/pkg-pacman/src/index.test.ts @@ -51,7 +51,7 @@ describe('pacman PKGBUILD generation', () => { expect(conf).toContain('Server = '); }); - it('escapes shell metacharacters in pkgdesc and defaults source to SKIP', async () => { + it('escapes shell metacharacters in pkgdesc and defaults source to empty arrays', async () => { const outDir = await mkdtemp(join(tmpdir(), 'sh1pt-pacman-')); tempDirs.push(outDir); @@ -109,5 +109,17 @@ describe('pacman PKGBUILD generation', () => { pkgname: 'myapp', repoName: 'evil]\n[core', })).rejects.toThrow(/invalid repoName/i); + + // repoBaseUrl with a newline would inject a fake [section] into pacman.conf. + await expect(adapter.build(fakeBuildContext({ outDir, version: '1.0.0' }) as any, { + pkgname: 'myapp', + repoBaseUrl: 'https://x.test\n[core]\nServer = https://evil.test', + })).rejects.toThrow(/invalid repoBaseUrl/i); + + // pkgrel must be a positive integer (it's embedded unquoted in the PKGBUILD). + await expect(adapter.build(fakeBuildContext({ outDir, version: '1.0.0' }) as any, { + pkgname: 'myapp', + pkgrel: '1\nextrafield=injected', + })).rejects.toThrow(/invalid pkgrel/i); }); }); diff --git a/packages/targets/pkg-pacman/src/index.ts b/packages/targets/pkg-pacman/src/index.ts index b80611da..e26ac016 100644 --- a/packages/targets/pkg-pacman/src/index.ts +++ b/packages/targets/pkg-pacman/src/index.ts @@ -54,6 +54,17 @@ function assertValidNames(config: Config): void { 'are allowed (it becomes a pacman.conf [section] header and a repo db filename)', ); } + // repoBaseUrl flows verbatim into the .pacman.conf "Server =" line and into the + // single-quoted rsync command — a newline injects a fake [section] and a single + // quote breaks out of the rsync quoting. + if (config.repoBaseUrl !== undefined && /[\r\n']/.test(config.repoBaseUrl)) { + throw new Error('pkg-pacman: invalid repoBaseUrl — must not contain newlines or single-quote characters'); + } + // pkgrel is embedded unquoted in the PKGBUILD; Arch requires a positive integer. + const rel = config.pkgrel ?? '1'; + if (!/^[1-9][0-9]*$/.test(rel)) { + throw new Error(`pkg-pacman: invalid pkgrel '${rel}' — must be a positive integer`); + } } function renderPkgbuild(ctx: { version: string }, config: Config): string {