From f62c28e33e4566e2c67ba734cdbac983d478ac05 Mon Sep 17 00:00:00 2001 From: trevore69 Date: Sun, 2 Aug 2026 18:20:25 +0000 Subject: [PATCH] fix(site): refuse a starter that does not exist instead of serving an empty root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `site --template ` joined the value straight into a path under examples/templates. When that directory was not there — a typo, or `--template --install` reading the next flag as the name — fs.stat failed, the seed step was dropped, and the install carried on to the end reporting success with nothing in the root. That is the 404 the seeding exists to prevent, reached by the one route that also hides it: the run says the config is installed, so the empty page reads as a broken name rather than a mistyped flag. The name is also now checked for shape before it is joined into a path. templates.mjs already refuses to treat anything with a slash or a dot as a bundled name, for exactly this reason; site did not apply that rule, so a value with enough ../ in it named a copy source anywhere on the box, and that copy lands in a root a web server is about to publish. --- src/serve.mjs | 47 ++++++++++++++++++++++++++- test/serve.test.mjs | 78 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 123 insertions(+), 2 deletions(-) diff --git a/src/serve.mjs b/src/serve.mjs index ec4a1b6..662a59d 100644 --- a/src/serve.mjs +++ b/src/serve.mjs @@ -22,6 +22,8 @@ import { promises as fs } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { classifySource, listTemplates } from "./templates.mjs"; + /** * What a freshly installed site contains before anyone has written anything. * @@ -36,6 +38,42 @@ import { fileURLToPath } from "node:url"; */ export const DEFAULT_TEMPLATE = "caddy-static"; +/** + * Which starter to seed, and whether that answer is usable at all. + * + * `--template` is checked rather than trusted, because every way of getting it + * wrong lands in the same place: the directory does not exist, the seed step is + * quietly dropped, and the root is left empty — which serves the 404 the + * seeding exists to prevent, after reporting that everything worked. A typo has + * to be louder than that. + * + * The shape check is templates.mjs's existing rule rather than a new one: a + * bundled name is one label, so anything carrying a slash or a dot is not a + * name and must not be joined into a path underneath examples/templates. + */ +export async function chooseTemplate(rest = [], { list = listTemplates } = {}) { + if (rest.includes("--empty")) return { ok: true, template: null }; + + const at = rest.indexOf("--template"); + if (at < 0) return { ok: true, template: DEFAULT_TEMPLATE }; + + const asked = rest[at + 1]; + // `--template --install` reads the next flag as the starter, and the flag it + // ate still takes effect, so the site installs with nothing in its root. + if (asked === undefined || asked.startsWith("-")) { + return { ok: false, error: "--template takes the name of a starter" }; + } + if (classifySource(asked).kind !== "bundled") { + return { ok: false, error: `${JSON.stringify(asked)} is not a starter name` }; + } + + const available = (await list()).map((t) => t.name); + if (!available.includes(asked)) { + return { ok: false, error: `there is no starter called ${JSON.stringify(asked)}`, available }; + } + return { ok: true, template: asked }; +} + /** Where each server keeps drop-in site config. */ const SERVERS = { nginx: { dir: "/etc/nginx/conf.d", ext: ".conf", reload: ["systemctl", "reload", "nginx"], check: ["nginx", "-t"] }, @@ -211,7 +249,14 @@ export async function serveCommand(args = [], out = console.log, deps = {}) { return 1; } const root = flag("--root") || `/srv/${name}`; - const template = rest.includes("--empty") ? null : (flag("--template") || DEFAULT_TEMPLATE); + const choice = await chooseTemplate(rest); + if (!choice.ok) { + out(`moshcode site: ${choice.error}`); + if (choice.available) out(` bundled: ${choice.available.join(", ")}`); + out(" `moshcode template list` shows them all, or use --empty to seed nothing."); + return 1; + } + const template = choice.template; const here = path.dirname(fileURLToPath(import.meta.url)); const seedFrom = template ? path.join(here, "..", "examples", "templates", template, "site") : null; // An empty root serves 404, which reads as a broken install. Seed only when diff --git a/test/serve.test.mjs b/test/serve.test.mjs index 585b980..72f586c 100644 --- a/test/serve.test.mjs +++ b/test/serve.test.mjs @@ -5,7 +5,7 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { caddySite, detectServer, nginxSite, servePlan, serveCommand } from "../src/serve.mjs"; +import { caddySite, chooseTemplate, detectServer, nginxSite, servePlan, serveCommand } from "../src/serve.mjs"; test("no HTTPS redirect is ever emitted, for either server", () => { // The trap: a box's default vhost usually 301s to https, and for an ending @@ -153,3 +153,79 @@ test("an existing site is never seeded over", async () => { assert.equal(seeded, false, "/etc is not empty, so nothing is seeded"); assert.doesNotMatch(lines.join("\n"), /seed /); }); + +test("a starter that does not exist is refused, not silently skipped", async () => { + // The failure this prevents: a typo makes the seed directory missing, the + // seed step is dropped, and the root is left empty — which is the 404 the + // seeding exists to prevent, arrived at while reporting success. + const list = async () => [{ name: "caddy-static" }, { name: "bun-caddy-sqlite" }]; + + const typo = await chooseTemplate(["--template", "caddy-statik"], { list }); + assert.equal(typo.ok, false); + assert.match(typo.error, /no starter called/); + // Naming the two that do exist is most of the fix: the typo is one letter. + assert.deepEqual(typo.available, ["caddy-static", "bun-caddy-sqlite"]); + + assert.equal((await chooseTemplate(["--template", "caddy-static"], { list })).template, "caddy-static"); + // No --template at all is still the default starter, and --empty still wins. + assert.equal((await chooseTemplate([], { list })).template, "caddy-static"); + assert.equal((await chooseTemplate(["--empty"], { list })).template, null); + assert.equal((await chooseTemplate(["--empty", "--template", "caddy-statik"], { list })).ok, true); +}); + +test("--template with no value does not read the next flag as the starter", async () => { + // `site x.y --template --install` took "--install" as the name, found no + // such directory, seeded nothing — and installed anyway, because --install + // is matched separately. + const list = async () => [{ name: "caddy-static" }]; + for (const args of [["--template"], ["--template", "--install"], ["--template", "--empty"]]) { + const choice = await chooseTemplate(args, { list }); + assert.equal(choice.ok, args.includes("--empty"), JSON.stringify(args)); + if (!choice.ok) assert.match(choice.error, /takes the name of a starter/); + } +}); + +test("a starter name cannot climb out of the bundled directory", async () => { + // templates.mjs already refuses to treat anything with a slash or a dot as a + // bundled name, for this reason. `site` joined the raw value into a path + // under examples/templates without that check, so a value with enough ../ in + // it named a copy source anywhere on the box — and the copy lands in a root a + // web server is about to publish. + const list = async () => [{ name: "caddy-static" }]; + for (const bad of ["../../../../etc", "a/b", "./caddy-static", "caddy static"]) { + const choice = await chooseTemplate(["--template", bad], { list }); + assert.equal(choice.ok, false, bad); + assert.match(choice.error, /not a starter name/); + } +}); + +test("a bad --template stops the install before anything is written", async () => { + for (const args of [ + ["blue.eggs", "--install", "--template", "caddy-statik"], + ["blue.eggs", "--install", "--template", "../../../../tmp"], + ["blue.eggs", "--template", "--install"], + ]) { + const lines = []; + let touched = 0; + const code = await serveCommand(args, (l) => lines.push(l), { + detect: async () => "nginx", + write: async () => { touched += 1; }, + mkdir: async () => { touched += 1; }, + copy: async () => { touched += 1; }, + }); + assert.equal(code, 1, JSON.stringify(args)); + assert.equal(touched, 0, "a starter we cannot find is not a reason to install a site with an empty root"); + } + + // The control: the starter that does exist still seeds. + const lines = []; + let seeded = null; + const code = await serveCommand(["blue.eggs", "--install", "--template", "caddy-static"], (l) => lines.push(l), { + detect: async () => "nginx", + write: async () => {}, + mkdir: async () => {}, + copy: async (from) => { seeded = from; }, + }); + assert.equal(code, 0); + assert.match(String(seeded), /examples\/templates\/caddy-static\/site$/); +});