From da31f70d339aa9bb79d4dce1ce0f1c5bb54e185e Mon Sep 17 00:00:00 2001 From: David Viejo Date: Sat, 19 Sep 2026 09:11:16 +0200 Subject: [PATCH] feat(catalog): support plugin subdirectories and custom git refs Signed-off-by: David Viejo --- .github/workflows/catalog.yml | 39 ++--------------------- registry/README.md | 30 +++++++++++++----- scripts/catalog-build.test.ts | 6 ++-- scripts/catalog.test.ts | 58 +++++++++++++++++++++++++++++++++-- scripts/catalog.ts | 46 +++++++++++++++++++-------- scripts/safe-extract.test.ts | 27 ++++++++++++++-- scripts/safe-extract.ts | 12 +++++--- scripts/validate-build.sh | 5 +-- 8 files changed, 152 insertions(+), 71 deletions(-) diff --git a/.github/workflows/catalog.yml b/.github/workflows/catalog.yml index 178c172..c7a6480 100644 --- a/.github/workflows/catalog.yml +++ b/.github/workflows/catalog.yml @@ -42,42 +42,7 @@ jobs: --mount type=bind,src="$PWD",dst=/work,readonly --workdir /work \ oven/bun:1.4.2@sha256:9114c058aeae42162ee16dd5084b95fe9473970bb6bcb5b232ab1630f0546895 bun scripts/catalog.ts --check - name: Compile plugin sources in isolated containers - run: | - set -euo pipefail - image='oven/bun:1.4.2@sha256:9114c058aeae42162ee16dd5084b95fe9473970bb6bcb5b232ab1630f0546895' - scratch="$(mktemp -d)" - trap 'rm -rf "$scratch"' EXIT - docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp --read-only --cap-drop=ALL --security-opt=no-new-privileges \ - --pids-limit=128 --memory=512m --cpus=1 --tmpfs /tmp:rw,nosuid,nodev,size=64m \ - --mount type=bind,src="$PWD",dst=/work,readonly --workdir /work \ - "$image" bun scripts/catalog.ts --plan > "$scratch/plan.json" - jq -c '.[]' "$scratch/plan.json" | while IFS= read -r item; do - repo="$(jq -r '.repository | sub("^https://github.com/"; "")' <<< "$item")" - commit="$(jq -r '.commit' <<< "$item")" - project="$scratch/project" - mkdir -p "$project" - curl --fail --location --silent --show-error --max-time 60 --max-filesize 20971520 \ - "https://api.github.com/repos/$repo/tarball/$commit" -o "$scratch/source.tar.gz" - test "$(wc -c < "$scratch/source.tar.gz")" -le 20971520 - docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp --cap-drop=ALL --security-opt=no-new-privileges \ - --pids-limit=128 --memory=512m --cpus=1 --tmpfs /tmp:rw,nosuid,nodev,size=128m \ - --mount type=bind,src="$project",dst=/work \ - --mount type=bind,src="$scratch/source.tar.gz",dst=/source.tar.gz,readonly \ - --mount type=bind,src="$PWD/scripts",dst=/scripts,readonly --workdir /work \ - "$image" bun /scripts/safe-extract.ts /source.tar.gz /work - docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp --cap-drop=ALL --security-opt=no-new-privileges \ - --pids-limit=128 --memory=2g --cpus=2 --tmpfs /tmp:rw,nosuid,nodev,size=128m \ - --mount type=bind,src="$project",dst=/work --workdir /work \ - "$image" bun install --frozen-lockfile --ignore-scripts - docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp --network=none --cap-drop=ALL --security-opt=no-new-privileges \ - --pids-limit=128 --memory=2g --cpus=2 --tmpfs /tmp:rw,nosuid,nodev,size=128m \ - --mount type=bind,src="$project",dst=/work --workdir /work "$image" sh -c ' - entrypoint="$(bun -e "const p=await Bun.file(\"package.json\").json();const e=p.temps?.entrypoint;if(typeof e!==\"string\"||!/^src\\/[a-zA-Z0-9_./-]+\\.tsx?$/.test(e)||e.includes(\"..\"))process.exit(1);console.log(e)")" - bun build "$entrypoint" --target=bun --compile --outfile /tmp/temps-plugin-check - ' - echo "Build checked $repo@$commit (install scripts disabled; compile network disabled)" - rm -rf "$project" "$scratch/source.tar.gz" - done + run: bash scripts/validate-build.sh refresh: if: >- @@ -127,7 +92,7 @@ jobs: test ! -L verified-catalog/catalog.json test "$(wc -c < verified-catalog/catalog.json)" -le 5242880 jq -e ' - .schema_version == 1 and + (.schema_version == 1 or .schema_version == 2) and (.generated_at | type == "string") and (.plugins | type == "array" and all(.[]; (.name | type == "string") and diff --git a/registry/README.md b/registry/README.md index 4f0e98f..93b8d7a 100644 --- a/registry/README.md +++ b/registry/README.md @@ -5,26 +5,40 @@ Open a pull request adding `registry/.json`: ```json { "repo": "your-org/your-plugin", - "categories": ["observability"] + "categories": ["observability"], + "path": "plugins/my-plugin", + "ref": "release/v1" } ``` -The filename must match `temps.name` in the repository's root `package.json`. -Only public GitHub repositories with a root `package.json` and nonempty root -`README.md` are supported. Monorepo paths are deliberately not supported yet; -the installer must understand paths before listings may specify them. The -generator reads metadata from a pinned commit SHA, not a mutable branch URL. +The filename must match `temps.name` in the selected directory's `package.json`. +Only public GitHub repositories are supported. Optional `path` selects a plugin +subdirectory; omit it (or use an empty string) for the repository root. Optional +`ref` selects a branch, tag, or commit; omit it for the repository default branch. +The generator resolves this ref to an immutable commit before reading metadata or +building. Separate plugins in one repository use separate listing files and paths. +The same repository/path cannot be listed twice with different refs. + +The selected directory must contain its own `package.json`, `bun.lock`, nonempty +`README.md`, and all sources and generated assets needed by the installer. +Only that subtree is passed to the builder: dependencies on parent workspace files +or sibling packages are not supported. Paths are relative, case-sensitive and +cannot contain empty segments, `.`, `..`, `.git`, backslashes or encoded separators. +Catalogs with subdirectory plugins use schema version 2, so older hosts fail closed +instead of silently installing the repository root. Upgrade Temps for these listings. +Catalog installation remains pinned to the reviewed commit; selecting a new ref +for an installed plugin is an explicit update action. Allowed categories: `analytics`, `automation`, `databases`, `developer-tools`, `observability`, `seo`, `security`, `other`. The first is the primary category. The `temps` manifest may supply `title`, `summary`, `description`, `platforms`, `docsUrl`, `logo`, and `screenshots` (objects with relative `path`, `alt`, and -optional `caption`). Logo and screenshot paths are relative to repository root. +optional `caption`). Logo and screenshot paths are relative to the selected plugin directory. Other fields derive from `package.json` and GitHub repository metadata. Pull-request validation checks metadata and README availability, then installs -dependencies with lifecycle scripts disabled and compiles the root manifest's +dependencies with lifecycle scripts disabled and compiles the selected directory's manifest entrypoint in a capped, offline container. It does **not** execute the plugin or audit its security. The generated catalog records `validation.build: "passed"` only when the tested commit matches the catalog commit. Consumers must not diff --git a/scripts/catalog-build.test.ts b/scripts/catalog-build.test.ts index e18bd90..add7fb1 100644 --- a/scripts/catalog-build.test.ts +++ b/scripts/catalog-build.test.ts @@ -11,7 +11,9 @@ for (const path of ["../.github/workflows/catalog.yml", "./validate-build.sh"]) expect(command).toContain('--cap-drop=ALL'); expect(command).toContain('--security-opt=no-new-privileges'); } - expect(source).toContain('--network=none'); - expect(source).toContain('--ignore-scripts'); + if (path === "./validate-build.sh") { + expect(source).toContain('--network=none'); + expect(source).toContain('--ignore-scripts'); + } else expect(source).toContain("bash scripts/validate-build.sh"); }); } diff --git a/scripts/catalog.test.ts b/scripts/catalog.test.ts index 7199353..d19a0c3 100644 --- a/scripts/catalog.test.ts +++ b/scripts/catalog.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { parseListing, resolvePlugin } from "./catalog"; +import { parseListing, resolvePlugin, generate, buildPlan } from "./catalog"; const sha = "a".repeat(40); const listing = { repo: "gotempsh/temps-plugin-template", categories: ["developer-tools"] }; @@ -19,8 +19,8 @@ function fixture(packageJson: object = pkg, readme = "# My plugin\n") { describe("catalog submissions", () => { test("accepts minimal exact listing", () => expect(parseListing("my-plugin", listing)).toEqual(listing)); - test("rejects path overrides and traversal", () => { - expect(() => parseListing("my-plugin", { ...listing, path: "subdir" })).toThrow("only repo and categories"); + test("rejects unknown keys and traversal", () => { + expect(() => parseListing("my-plugin", { ...listing, directory: "subdir" })).toThrow("only repo, categories"); expect(() => parseListing("my-plugin", { ...listing, repo: "gotempsh/../evil" })).toThrow("invalid GitHub"); }); test("resolves real template-shaped manifest at immutable commit", async () => { @@ -39,3 +39,55 @@ describe("catalog submissions", () => { await expect(resolvePlugin("my-plugin", listing, fixture({ ...pkg, temps: { ...pkg.temps, logo: "../secret" } }))).rejects.toThrow("invalid asset path"); }); }); + +for (const path of ["../evil", "/absolute", "a//b", "a/./b", "a/../b", "a/", "a\\b", "%2e%2e/x", ".git/x", "a/.GIT/x", "x".repeat(513)]) { + test(`rejects unsafe path ${path}`, () => expect(() => parseListing("my-plugin", { ...listing, path })).toThrow("invalid plugin path")); +} +for (const ref of ["", "--upload-pack=evil", "x..y", "main:evil", "a b"]) { + test(`rejects unsafe ref ${ref}`, () => expect(() => parseListing("my-plugin", { ...listing, ref })).toThrow("invalid Git ref")); +} +test("pins a subdirectory at a slash-containing ref and makes assets directory-relative", async () => { + const calls: string[] = []; + const fetcher = (async (input: RequestInfo | URL) => { + const url = String(input); calls.push(url); + if (url.endsWith(`/repos/${listing.repo}`)) return Response.json({ full_name: listing.repo, default_branch: "main", private: false }); + if (url.endsWith("/commits/release%2Fv1")) return Response.json({ sha }); + if (url.endsWith(`/${sha}/plugins/demo/package.json`)) return Response.json({ ...pkg, temps: { ...pkg.temps, logo: "assets/logo.png" } }); + if (url.endsWith(`/${sha}/plugins/demo/README.md`)) return new Response("# Demo"); + return new Response("missing", { status: 404 }); + }) as typeof fetch; + const result = await resolvePlugin("my-plugin", { ...listing, path: "plugins/demo", ref: "release/v1" }, fetcher); + expect(result.path).toBe("plugins/demo"); + expect(result.ref).toBe("release/v1"); + expect(result.logoUrl).toEndWith(`/${sha}/plugins/demo/assets/logo.png`); + expect(result.readmeUrl).toEndWith(`/${sha}/plugins/demo/README.md`); + expect(buildPlan([result])[0]).toEqual({ repository: result.repository, commit: sha, path: "plugins/demo", ref: "release/v1" }); + expect(calls.some(url => url.endsWith("/commits/main"))).toBe(false); +}); +test("missing custom ref fails without falling back to default branch", async () => { + const fetcher = (async (input: RequestInfo | URL) => String(input).includes("/commits/") + ? new Response("missing", { status: 404 }) : Response.json({ full_name: listing.repo, default_branch: "main" })) as typeof fetch; + await expect(resolvePlugin("my-plugin", { ...listing, ref: "missing" }, fetcher)).rejects.toThrow("404"); +}); + +test("multiple paths in one repository generate v2, but duplicate source paths fail", async () => { + const { mkdtemp, mkdir, writeFile, rm } = await import("node:fs/promises"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const root = await mkdtemp(join(tmpdir(), "catalog-paths-")); + try { + await mkdir(join(root, "registry")); + await writeFile(join(root, "registry/my-plugin.json"), JSON.stringify({ ...listing, path: "plugins/a" })); + await writeFile(join(root, "registry/other.json"), JSON.stringify({ ...listing, path: "plugins/b" })); + const fetcher = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/plugins/b/package.json")) return Response.json({ ...pkg, temps: { ...pkg.temps, name: "other" } }); + return fixture()(input); + }) as typeof fetch; + const catalog = await generate(root, fetcher); + expect(catalog.schema_version).toBe(2); + expect(catalog.plugins.map(p => p.path)).toEqual(["plugins/a", "plugins/b"]); + await writeFile(join(root, "registry/other.json"), JSON.stringify({ ...listing, path: "plugins/a", ref: "v2" })); + await expect(generate(root, fetcher)).rejects.toThrow("duplicate repository and path"); + } finally { await rm(root, { recursive: true, force: true }); } +}); diff --git a/scripts/catalog.ts b/scripts/catalog.ts index 3065a1a..d73ac0a 100644 --- a/scripts/catalog.ts +++ b/scripts/catalog.ts @@ -8,20 +8,37 @@ const repoPattern = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,38})\/[a-zA-Z0-9._-]{1,100}$/ const namePattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; const shaPattern = /^[a-f0-9]{40}$/; -export type Listing = { repo: string; categories: string[] }; +export type Listing = { repo: string; categories: string[]; path?: string; ref?: string }; export type CatalogPlugin = { name: string; title: string; summary: string; description: string; author: string; - category: string; repository: string; docsUrl: string | null; logoUrl: string | null; + category: string; repository: string; path?: string; ref?: string; docsUrl: string | null; logoUrl: string | null; screenshots: { url: string; alt: string; caption: string }[]; latestVersion: string; platforms: string[]; commit: string; readmeUrl: string; validation: { metadata: "passed"; build: "not_run" | "passed" }; }; +/** Same canonical, repository-relative selector accepted by the host installer. */ +export function validPath(path: string): boolean { + return path.length <= 512 && (path === "" || path.split("/").every(part => + /^[A-Za-z0-9_.-]+$/.test(part) && ![".", "..", ".git"].includes(part.toLowerCase()))); +} + +export function validRef(ref: string): boolean { + return ref.length > 0 && ref.length <= 128 && !ref.startsWith("-") && + /^[A-Za-z0-9_./-]+$/.test(ref) && !ref.includes(".."); +} + +export function buildPlan(plugins: CatalogPlugin[]) { + return plugins.map(plugin => ({ repository: plugin.repository, path: plugin.path ?? "", ref: plugin.ref, commit: plugin.commit })); +} + export function parseListing(filename: string, value: unknown): Listing { if (!namePattern.test(filename)) throw new Error(`Invalid listing filename: ${filename}`); if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${filename}: expected object`); const data = value as Record; - if (Object.keys(data).sort().join(",") !== "categories,repo") throw new Error(`${filename}: only repo and categories are allowed`); + if (Object.keys(data).some(key => !["categories", "repo", "path", "ref"].includes(key))) throw new Error(`${filename}: only repo, categories, path and ref are allowed`); + if (data.path !== undefined && (typeof data.path !== "string" || !validPath(data.path))) throw new Error(`${filename}: invalid plugin path`); + if (data.ref !== undefined && (typeof data.ref !== "string" || !validRef(data.ref))) throw new Error(`${filename}: invalid Git ref`); if (typeof data.repo !== "string" || !repoPattern.test(data.repo) || data.repo.includes("..")) throw new Error(`${filename}: invalid GitHub owner/repo`); if (!Array.isArray(data.categories) || data.categories.length === 0 || data.categories.some(c => typeof c !== "string" || !categories.has(c)) || new Set(data.categories).size !== data.categories.length) throw new Error(`${filename}: invalid categories`); return data as Listing; @@ -51,15 +68,16 @@ function requiredString(value: unknown, context: string): string { export async function resolvePlugin(name: string, listing: Listing, fetcher: typeof fetch = fetch): Promise { const repo = await githubJson(`${API}/repos/${listing.repo}`, fetcher); if (repo.private || repo.full_name?.toLowerCase() !== listing.repo.toLowerCase()) throw new Error(`${listing.repo}: repository is private or renamed`); - const branch = requiredString(repo.default_branch, `${listing.repo} default branch`); + const branch = requiredString(listing.ref ?? repo.default_branch, `${listing.repo} default branch`); const commit = await githubJson(`${API}/repos/${listing.repo}/commits/${encodeURIComponent(branch)}`, fetcher); const sha = requiredString(commit.sha, `${listing.repo} commit`); if (!shaPattern.test(sha)) throw new Error(`${listing.repo}: invalid commit SHA`); - const pkg = JSON.parse(await githubText(listing.repo, sha, "package.json", fetcher)); + const prefix = listing.path ? `${listing.path}/` : ""; + const pkg = JSON.parse(await githubText(listing.repo, sha, `${prefix}package.json`, fetcher)); const manifest = pkg?.temps; if (!manifest || typeof manifest !== "object") throw new Error(`${listing.repo}: package.json missing temps manifest`); if (requiredString(manifest.name, `${listing.repo} temps.name`) !== name) throw new Error(`${listing.repo}: temps.name must match ${name}`); - const readme = await githubText(listing.repo, sha, "README.md", fetcher); + const readme = await githubText(listing.repo, sha, `${prefix}README.md`, fetcher); if (!readme.trim()) throw new Error(`${listing.repo}: README.md is empty`); const title = requiredString(manifest.title ?? pkg.displayName ?? name, `${listing.repo} title`); const summary = requiredString(manifest.summary ?? pkg.description, `${listing.repo} summary`); @@ -68,10 +86,10 @@ export async function resolvePlugin(name: string, listing: Listing, fetcher: typ const version = requiredString(pkg.version, `${listing.repo} version`); const platforms = Array.isArray(manifest.platforms) ? manifest.platforms : []; if (platforms.some((p: unknown) => typeof p !== "string" || !/^[a-z0-9_-]+$/.test(p))) throw new Error(`${listing.repo}: invalid platforms`); - const rawBase = `https://raw.githubusercontent.com/${listing.repo}/${sha}`; + const rawBase = `https://raw.githubusercontent.com/${listing.repo}/${sha}${listing.path ? `/${listing.path}` : ""}`; const asset = (path: unknown): string | null => { if (path == null) return null; - if (typeof path !== "string" || !/^(?!\/)(?!.*(?:^|\/)\.\.?\/)[a-zA-Z0-9_./-]+$/.test(path)) throw new Error(`${listing.repo}: invalid asset path`); + if (typeof path !== "string" || !path || !validPath(path)) throw new Error(`${listing.repo}: invalid asset path`); return `${rawBase}/${path}`; }; const shots = manifest.screenshots ?? []; @@ -79,6 +97,7 @@ export async function resolvePlugin(name: string, listing: Listing, fetcher: typ return { name, title, summary, description, author: requiredString(author ?? repo.owner?.login, `${listing.repo} author`), category: categoryLabels[listing.categories[0]], repository: `https://github.com/${listing.repo}`, + ...(listing.path ? { path: listing.path } : {}), ref: branch, docsUrl: typeof manifest.docsUrl === "string" && /^https:\/\//.test(manifest.docsUrl) ? manifest.docsUrl : null, logoUrl: asset(manifest.logo), screenshots: shots.map((shot: any) => { const url = asset(shot?.path); @@ -98,11 +117,12 @@ export async function generate(root: string, fetcher: typeof fetch = fetch) { for (const filename of names) { const name = filename.slice(0, -5); const listing = parseListing(name, JSON.parse(await readFile(join(registry, filename), "utf8"))); - if (repos.has(listing.repo.toLowerCase())) throw new Error(`${filename}: duplicate repository`); - repos.add(listing.repo.toLowerCase()); + const identity = `${listing.repo.toLowerCase()}#${listing.path ?? ""}`; + if (repos.has(identity)) throw new Error(`${filename}: duplicate repository and path`); + repos.add(identity); plugins.push(await resolvePlugin(name, listing, fetcher)); } - return { schema_version: 1, generated_at: new Date().toISOString(), plugins }; + return { schema_version: plugins.some(plugin => plugin.path) ? 2 : 1, generated_at: new Date().toISOString(), plugins }; } if (import.meta.main) { @@ -111,13 +131,13 @@ if (import.meta.main) { if (mode !== "--check" && mode !== "--write" && mode !== "--plan") throw new Error("Usage: bun scripts/catalog.ts --check|--write|--plan"); const catalog = await generate(root); if (mode === "--plan") { - console.log(JSON.stringify(catalog.plugins.map(plugin => ({ repository: plugin.repository, commit: plugin.commit })))); + console.log(JSON.stringify(buildPlan(catalog.plugins))); process.exit(0); } if (mode === "--write") { if (process.env.CATALOG_BUILD_VERIFIED === "1") { const plan = JSON.parse(await readFile(join(root, ".catalog-build-plan.json"), "utf8")); - if (!Array.isArray(plan) || JSON.stringify(plan) !== JSON.stringify(catalog.plugins.map(plugin => ({ repository: plugin.repository, commit: plugin.commit })))) { + if (!Array.isArray(plan) || JSON.stringify(plan) !== JSON.stringify(buildPlan(catalog.plugins))) { throw new Error("Build plan does not match resolved catalog commits"); } for (const plugin of catalog.plugins) plugin.validation.build = "passed"; diff --git a/scripts/safe-extract.test.ts b/scripts/safe-extract.test.ts index 0709f6c..224adab 100644 --- a/scripts/safe-extract.test.ts +++ b/scripts/safe-extract.test.ts @@ -1,10 +1,13 @@ -import { test, expect } from "bun:test"; +import { test, expect, afterEach } from "bun:test"; import { gzipSync } from "node:zlib"; -import { mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { mkdtemp, readFile, writeFile, rm, readdir } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { extract } from "./safe-extract"; +const temporary: string[] = []; +afterEach(async () => { await Promise.all(temporary.splice(0).map(path => rm(path, { recursive: true, force: true }))); }); + function entry(name: string, content: string, type = "0") { const bytes = Buffer.from(content); const header = Buffer.alloc(512); @@ -16,6 +19,7 @@ function entry(name: string, content: string, type = "0") { async function run(entries: Buffer[]) { const base = await mkdtemp(join(tmpdir(), "temps-catalog-test-")); + temporary.push(base); const archive = join(base, "source.tar.gz"); const output = join(base, "output"); await writeFile(archive, gzipSync(Buffer.concat([...entries, Buffer.alloc(1024)]))); @@ -37,3 +41,22 @@ test("rejects symlinks", async () => { const { archive, output } = await run([entry("source/link", "", "2")]); await expect(extract(archive, output)).rejects.toThrow("Unsafe tar entry type"); }); + +test("extracts selected subtree without exposing siblings or root workspace", async () => { + const { archive, output } = await run([ + entry("source/package.json", "root"), entry("source/bun.lock", "root lock"), + entry("source/plugins/demo/package.json", "plugin"), entry("source/plugins/demo/bun.lock", "plugin lock"), + entry("source/plugins/demo/src/index.ts", "console.log('plugin')"), + entry("source/plugins/other/private.txt", "sibling"), + ]); + await extract(archive, output, "plugins/demo"); + expect(await readFile(join(output, "package.json"), "utf8")).toBe("plugin"); + expect((await readdir(output)).sort()).toEqual(["bun.lock", "package.json", "src"]); +}); +test("missing subtree cannot fall back to root manifests", async () => { + const { archive, output } = await run([entry("source/package.json", "{}"), entry("source/bun.lock", "lock")]); + await expect(extract(archive, output, "missing")).rejects.toThrow("Selected plugin directory"); +}); +test("subtree selector rejects traversal before opening archive", async () => { + await expect(extract("missing", "unused", "../escape")).rejects.toThrow("Invalid plugin path"); +}); diff --git a/scripts/safe-extract.ts b/scripts/safe-extract.ts index 0fdf9a0..6bfff0c 100644 --- a/scripts/safe-extract.ts +++ b/scripts/safe-extract.ts @@ -1,3 +1,4 @@ +import { validPath } from "./catalog"; import { gunzipSync } from "node:zlib"; import { mkdir, writeFile } from "node:fs/promises"; import { dirname, join, posix } from "node:path"; @@ -6,7 +7,8 @@ const MAX_ARCHIVE = 20 * 1024 * 1024; const MAX_EXPANDED = 100 * 1024 * 1024; const MAX_ENTRIES = 10_000; -export async function extract(archivePath: string, destination: string) { +export async function extract(archivePath: string, destination: string, pluginPath = "") { + if (!validPath(pluginPath)) throw new Error("Invalid plugin path"); const compressed = await Bun.file(archivePath).arrayBuffer(); if (compressed.byteLength > MAX_ARCHIVE) throw new Error("Archive exceeds 20 MiB compressed limit"); const tar = gunzipSync(Buffer.from(compressed), { maxOutputLength: MAX_EXPANDED }); @@ -34,7 +36,9 @@ export async function extract(archivePath: string, destination: string) { if (rawName.startsWith("/") || rawName.includes("\\")) throw new Error("Unsafe archive path"); const parts = rawName.replace(/\/$/, "").split("/"); if (parts.some(part => !part || part === "." || part === "..")) throw new Error("Unsafe archive path"); - const relative = parts.slice(1).join("/"); + const fullPath = parts.slice(1).join("/"); + const prefix = pluginPath ? `${pluginPath}/` : ""; + const relative = fullPath.startsWith(prefix) ? fullPath.slice(prefix.length) : ""; if (relative) { if (posix.normalize(relative) !== relative || seen.has(relative)) throw new Error("Duplicate or unsafe archive path"); seen.add(relative); @@ -47,7 +51,7 @@ export async function extract(archivePath: string, destination: string) { } offset = next; } - if (!seen.has("package.json") || !seen.has("bun.lock")) throw new Error("Archive requires root package.json and bun.lock"); + if (!seen.has("package.json") || !seen.has("bun.lock")) throw new Error("Selected plugin directory requires package.json and bun.lock"); } -if (import.meta.main) await extract(process.argv[2], process.argv[3]); +if (import.meta.main) await extract(process.argv[2], process.argv[3], process.argv[4]); diff --git a/scripts/validate-build.sh b/scripts/validate-build.sh index a978936..67678a8 100644 --- a/scripts/validate-build.sh +++ b/scripts/validate-build.sh @@ -13,6 +13,7 @@ docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp --read-only --cap-drop=A jq -c '.[]' "$scratch/plan.json" | while IFS= read -r item; do repo="$(jq -r '.repository | sub("^https://github.com/"; "")' <<< "$item")" commit="$(jq -r '.commit' <<< "$item")" + plugin_path="$(jq -r '.path // ""' <<< "$item")" project="$scratch/project" mkdir -p "$project" curl --fail --location --silent --show-error --max-time 60 \ @@ -25,7 +26,7 @@ jq -c '.[]' "$scratch/plan.json" | while IFS= read -r item; do --pids-limit=128 --memory=512m --cpus=1 --tmpfs /tmp:rw,nosuid,nodev,size=128m \ --mount type=bind,src="$project",dst=/work --mount type=bind,src="$scratch/source.tar.gz",dst=/source.tar.gz,readonly \ --mount type=bind,src="$PWD/scripts",dst=/scripts,readonly \ - --workdir /work "$image" bun /scripts/safe-extract.ts /source.tar.gz /work + --workdir /work "$image" bun /scripts/safe-extract.ts /source.tar.gz /work "$plugin_path" docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp --cap-drop=ALL --security-opt=no-new-privileges \ --pids-limit=128 --memory=2g --cpus=2 --tmpfs /tmp:rw,nosuid,nodev,size=128m \ --mount type=bind,src="$project",dst=/work --workdir /work "$image" bun install --frozen-lockfile --ignore-scripts @@ -35,7 +36,7 @@ jq -c '.[]' "$scratch/plan.json" | while IFS= read -r item; do entrypoint="$(bun -e "const p=await Bun.file(\"package.json\").json();const e=p.temps?.entrypoint;if(typeof e!==\"string\"||!/^src\\/[a-zA-Z0-9_./-]+\\.tsx?$/.test(e)||e.includes(\"..\"))process.exit(1);console.log(e)")" bun build "$entrypoint" --target=bun --compile --outfile /tmp/temps-plugin-check ' - echo "Build checked $repo@$commit (install scripts disabled; compile network disabled)" + echo "Build checked $repo/$plugin_path@$commit (install scripts disabled; compile network disabled)" rm -rf "$project" "$scratch/source.tar.gz" done cp "$scratch/plan.json" .catalog-build-plan.json