Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ Orchestrator: `scripts/build-vite.js`. Flags: `--local`, `--headless`, `--path-p
- `src/utils/single-page.js` — Bundle manifest reading/validation + registry expansion, shared by both fetch paths and by Astro
- `scripts/build-vite.js` — Build orchestrator (3-step pipeline)
- `scripts/fetch-apps.js` — GitHub Release artifact downloader
- `scripts/artifacts.js` — Safe tarball extraction + tree copy, shared by both fetch paths. Validates archive members (no traversal, no absolute paths, no symlinks) before anything is written, and replaces the old `cp -r`/`tar` shell-outs so the build runs on Windows
- `actions/publish-single-page-docs/` — Reusable GitHub Action that turns a repo's markdown into a single-page bundle

### Three Onboarding Types
Expand Down
155 changes: 155 additions & 0 deletions scripts/artifacts.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
/**
* artifacts.js — safe extraction and copying of sub-app artifacts.
*
* Every artifact this build consumes is a `dist.tar.gz` produced by *another*
* repository's release pipeline, so its contents are untrusted input: a
* compromised (or merely careless) doc repo must not be able to write outside
* the staging directory, and must not be able to smuggle a symlink into the
* published site.
*
* Both onboarding paths share this module — scripts/fetch-apps.js (GitHub
* releases) and scripts/build-vite.js (`prebuilt`/`localPath`) — so the two
* cannot drift on the safety checks the way they previously drifted on
* portability.
*/

import { execFileSync } from 'node:child_process';
import { copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
import { basename, dirname, join } from 'node:path';

/** Forward slashes — GNU tar in git-bash rejects backslash-separated -C targets. */
const posix = (p) => p.replace(/\\/g, '/');

/**
* Rejects an archive member that would escape the extraction root.
*
* tar itself strips a leading `/` and GNU tar refuses `..` members, but that is
* a defence we neither control nor can rely on across GNU tar and the bsdtar
* shipped in Windows System32. Checking the listing first makes the guarantee
* ours and produces an error naming the offending entry.
*/
function assertNoTraversal(entries, label) {
for (const entry of entries) {
const name = entry.replace(/\\/g, '/');
if (name.startsWith('/')) {
throw new Error(`${label}: archive member "${entry}" is an absolute path — refusing to extract.`);
}
if (/^[a-z]:/i.test(name)) {
throw new Error(`${label}: archive member "${entry}" carries a drive letter — refusing to extract.`);
}
if (name.split('/').includes('..')) {
throw new Error(`${label}: archive member "${entry}" escapes the archive root via ".." — refusing to extract.`);
}
}
}

/**
* Rejects link members declared in the archive.
*
* A symlink that survived into apps/ would be followed later by the HTML crawl
* and the asset copy, which is how a doc artifact could publish a file from
* outside its own tree — a CI secret, say.
*
* This reads the *declaration* rather than the extracted result because the two
* differ by platform: Windows silently drops symlink members when the process
* lacks the create-symlink privilege, so a post-extraction check alone would
* pass on a developer machine and only fail on Linux CI. GNU tar and bsdtar
* disagree about the rest of the verbose line, but both start it with the mode
* string, whose first character is `l` for a symlink and `h` for a hardlink.
*/
function assertNoLinkMembers(verboseListing, label) {
for (const line of verboseListing.split('\n')) {
const trimmed = line.trim();
if (!trimmed) continue;
const type = trimmed[0];
if (type === 'l' || type === 'h') {
throw new Error(
`${label}: archive declares a ${type === 'l' ? 'symlink' : 'hardlink'} member ` +
`(${trimmed}) — refusing to use it. Doc artifacts must contain regular files only.`,
);
}
}
}

/**
* Rejects symlinks anywhere under `dir`, after extraction.
*
* Belt and braces with assertNoLinkMembers: `lstat` behaves the same everywhere
* and catches anything the listing parse missed.
*/
function assertNoSymlinks(dir, label, root = dir) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isSymbolicLink()) {
throw new Error(
`${label}: archive contains a symlink at "${posix(full.slice(root.length + 1))}" — ` +
`refusing to use it. Doc artifacts must contain regular files only.`,
);
}
if (entry.isDirectory()) assertNoSymlinks(full, label, root);
}
}

/**
* Extracts a .tar.gz into `destDir`, validating it first.
*
* Portability: GNU tar (git-bash) parses a leading "C:" in the ARCHIVE name as a
* remote host, so the command runs from the tarball's own directory and passes
* only its basename to -f. The -C target is not parsed that way, it just needs
* forward slashes. This avoids --force-local, which bsdtar rejects.
*
* @param {string} tarPath - path to the .tar.gz
* @param {string} destDir - directory to extract into (created if absent)
* @param {string} label - human-readable origin, used in error messages
*/
export function extractTarball(tarPath, destDir, label) {
mkdirSync(destDir, { recursive: true });

const cwd = dirname(tarPath);
const name = basename(tarPath);

const opts = { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] };
const entries = execFileSync('tar', ['-tzf', name], opts)
.split('\n').map((l) => l.trim()).filter(Boolean);
if (entries.length === 0) throw new Error(`${label}: archive is empty.`);
assertNoTraversal(entries, label);
assertNoLinkMembers(execFileSync('tar', ['-tvzf', name], opts), label);

execFileSync('tar', ['-xzf', name, '-C', posix(destDir)], { cwd, stdio: 'pipe' });
assertNoSymlinks(destDir, label);
}

/**
* Materialises a local artifact into a staging directory.
*
* The artifact may be a `.tar.gz` tarball (as published to GitHub Releases) or a
* directory. Tarballs are extracted under `stageRoot/{name}`; directories are
* used in place.
*/
export function stageArtifact(srcPath, name, stageRoot, label = name) {
if (!lstatSync(srcPath).isFile()) return srcPath;

const stageDir = join(stageRoot, name);
if (existsSync(stageDir)) rmSync(stageDir, { recursive: true });
extractTarball(srcPath, stageDir, label);
return stageDir;
}

/**
* Recursively copies a directory tree, skipping symlinks.
*
* Replaces the `cp -r` this build used to shell out to, which does not exist on
* Windows outside a POSIX shell. `readdirSync(withFileTypes)` also avoids a
* `statSync` syscall per entry, and reports symlinks without following them.
*/
export function copyDir(src, dest) {
mkdirSync(dest, { recursive: true });
const entries = readdirSync(src, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : 1));
for (const entry of entries) {
if (entry.isSymbolicLink()) continue;
const srcPath = join(src, entry.name);
const destPath = join(dest, entry.name);
if (entry.isDirectory()) copyDir(srcPath, destPath);
else if (entry.isFile()) copyFileSync(srcPath, destPath);
}
}
48 changes: 10 additions & 38 deletions scripts/build-vite.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,12 @@
* Sub-app pages are rendered by src/pages/[...path].astro via getStaticPaths.
*/

import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync, copyFileSync, readdirSync, statSync } from 'fs';
import { join, dirname, basename, resolve, isAbsolute } from 'path';
import { fileURLToPath } from 'url';
import { homedir } from 'os';
import { execSync } from 'child_process';
import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync, copyFileSync, readdirSync, statSync } from 'node:fs';
import { join, dirname, resolve, isAbsolute } from 'node:path';
import { fileURLToPath } from 'node:url';
import { homedir } from 'node:os';
import { execSync } from 'node:child_process';
import { copyDir, stageArtifact } from './artifacts.js';
import { fetchApps } from './fetch-apps.js';
import {
BUNDLE_MANIFEST, bundleDirName, bundleKey, expandBundle, findBundleRoot,
Expand All @@ -39,38 +40,8 @@ const ok = (msg) => console.log('\x1b[32m✓\x1b[0m ' + msg);
const warn = (msg) => console.warn('\x1b[33m⚠\x1b[0m ' + msg);
const step = (msg) => console.log('\n\x1b[1m' + msg + '\x1b[0m');

function copyDir(src, dest) {
mkdirSync(dest, { recursive: true });
for (const entry of readdirSync(src).sort()) {
const srcPath = join(src, entry);
const destPath = join(dest, entry);
if (statSync(srcPath).isDirectory()) copyDir(srcPath, destPath);
else copyFileSync(srcPath, destPath);
}
}

/**
* Materialises a local artifact into a staging directory.
*
* The artifact may be a `.tar.gz` tarball (as published to GitHub Releases) or a
* directory. Tarballs are extracted under tmp/prebuilt/{name}/; directories are
* used in place.
*/
function stageArtifact(srcPath, name) {
if (!statSync(srcPath).isFile()) return srcPath;

const stageDir = join(ROOT, 'tmp', 'prebuilt', name);
if (existsSync(stageDir)) rmSync(stageDir, { recursive: true });
mkdirSync(stageDir, { recursive: true });
// Portable across GNU tar (git-bash) and bsdtar (Windows System32): GNU tar
// parses a leading "C:" in the ARCHIVE name as a remote host, so run from the
// tarball's dir and pass only its basename to -f (the -C target is not parsed
// that way). This avoids --force-local, which bsdtar rejects.
const posix = (p) => p.replace(/\\/g, '/');
execSync('tar -xzf "' + basename(srcPath) + '" -C "' + posix(stageDir) + '"',
{ cwd: dirname(srcPath), stdio: 'pipe' });
return stageDir;
}
/** Where `prebuilt` tarballs are unpacked before being copied into apps/. */
const STAGE_ROOT = join(ROOT, 'tmp', 'prebuilt');

/** Expands a registry path (`~` and repo-relative forms allowed) to an absolute one. */
function artifactPath(raw) {
Expand Down Expand Up @@ -98,8 +69,9 @@ function resolveArtifactPath(app, raw, label) {
* @returns {Array|null} expanded app entries for a single-page bundle, else null
*/
function preparePrebuilt(app) {
const label = app.slug ?? bundleKey(app);
const srcPath = resolveArtifactPath(app, app.prebuilt, 'prebuilt');
const stageDir = stageArtifact(srcPath, bundleDirName(app.slug ?? bundleKey(app)));
const stageDir = stageArtifact(srcPath, bundleDirName(label), STAGE_ROOT, label);

if (isSinglePage(app)) return installBundle(app, stageDir, 'prebuilt');

Expand Down
Loading