diff --git a/bend2/main.ts b/bend2/main.ts index b2783f0ea..6f28ef5bd 100755 --- a/bend2/main.ts +++ b/bend2/main.ts @@ -805,48 +805,69 @@ async function cli_login(): Promise { // pkg_files is the package the loader read for this file, the entry first: // every .bend file at its namespace (the entry at its name), every foreign // .c or .js file at its path from the entry's directory; base and the -// store's packages stay out. A path that climbs above the entry's directory -// takes the entry's ancestor directories along, as many as the deepest climb. +// store's packages stay out. The entry's directory is the one the loader +// roots namespaces at, so an entry linked from another directory, or one +// at the file system's root, is refused. // A LICENSE beside a published file goes along; a license/ directory, in // any case, is refused (it clashes with LICENSE on a case-blind disk). -// A leading byte order mark is dropped: the hub stores the text as sent, -// and an importer's fetch drops the mark, so a file sent with one would -// never match its hash. function pkg_files(file: string, book: Bend.Book, seen: Map): Record { - const dir = fs.realpathSync(path.dirname(file)) + "/"; + const ent = fs.realpathSync(file); + const dir = path.join(path.dirname(ent), "/"); + if (dir !== path.join(fs.realpathSync(path.dirname(file)), "/")) { + throw "Error: " + file + " cannot be published: it links to " + ent + + ", in another directory; publish that file"; + } + if (dir === "/") { + throw "Error: " + file + " cannot be published: it is at the file" + + " system's root; move it into a directory"; + } const raws = [...[...seen].flatMap(([real, ns]): [string, string][] => real === BASE || ns === null || ns.startsWith("0x") ? [] : [[ns === "" ? path.basename(file) : ns + ".bend", real]]), ...Object.entries(book.tlds).flatMap(([k, tld]): [string, string][] => tld.$ !== "Def" || tld.i === undefined || tld.b === true || k.startsWith("0x") ? [] : tld.i.map((f) => - [f.startsWith(dir) ? f.slice(dir.length) : f, f]))]; - const ups = raws.map(([p]) => path.posix.normalize(p).split("/") - .filter((s) => s === "..").length); - const anc = fs.realpathSync(path.dirname(file)).split("/") - .slice(-Math.max(0, ...ups) || Infinity); + [path.posix.normalize(f.slice(dir.length)), f]))]; + // .bend files come first, so a foreign file's .bend, its directory a + // prefix of the file's path, is checked inside dir before its path is cut const files: Record = {}; - for (const [raw, real] of raws) { - const p = path.posix.join(...anc, raw); - if (p.startsWith("/") || p.startsWith("..")) { - throw "Error: " + real + " cannot be published (an absolute import," - + " or a climb above the file system)"; - } + for (const [p, src] of raws) { if (p.split("/").slice(0, -1).some((s) => s.toLowerCase() === "license")) { - throw "Error: " + real + " cannot be published: it is in a directory" + throw "Error: " + src + " cannot be published: it is in a directory" + " named license, which clashes with a LICENSE file; rename it"; } - files[p] = fs.readFileSync(real, "utf8").replace(/^\uFEFF/, ""); - if (fs.readdirSync(path.dirname(real)).includes("LICENSE")) { - files[path.posix.join(path.posix.dirname(p), "LICENSE")] = - fs.readFileSync(path.join(path.dirname(real), "LICENSE"), "utf8") - .replace(/^\uFEFF/, ""); + files[p] = pkg_read(p, src, dir, "move it"); + if (fs.readdirSync(path.dirname(src)).includes("LICENSE")) { + const lic = path.posix.join(path.posix.dirname(p), "LICENSE"); + files[lic] = pkg_read(lic, path.join(path.dirname(src), "LICENSE"), dir, + "it is the LICENSE beside " + src + "; copy it"); } } return files; } +// pkg_read is the text of src, published at p: p and src's real path stay +// inside dir, the entry's directory. p is the import's own path, normalized +// as text, since an importer resolves it inside the package that way; one +// that climbs out and back in names a file the package lacks. A leading +// byte order mark is dropped: the hub stores the text as sent, and an +// importer's fetch drops the mark, so a file sent with one would never +// match its hash. +function pkg_read(p: string, src: string, dir: string, fix: string): string { + const real = fs.realpathSync(src); + if (!real.startsWith(dir)) { + throw "Error: " + src + " cannot be published: it is outside " + dir + + " (the entry's directory); " + fix + " inside"; + } + if (p.startsWith("../") || p.startsWith("/")) { + throw "Error: " + src + " cannot be published: its import climbs out of" + + " the package (" + p + "); import it by a path that stays inside " + + dir; + } + return fs.readFileSync(real, "utf8").replace(/^\uFEFF/, ""); +} + function sha256(text: string): string { return crypto.createHash("sha256").update(text).digest("hex"); } diff --git a/gates/ping.ts b/gates/ping.ts index 19bf3de03..b94f6a8c1 100644 --- a/gates/ping.ts +++ b/gates/ping.ts @@ -27,7 +27,10 @@ // latest.json fallback name the version, no sha256 and the move notice; the // formula carries the sum; --publish ships LICENSE files, names the license // as the hub does, sends no leading byte order mark (a package published -// from a file with one imports), refuses a License/ directory, and every +// from a file with one imports), refuses a License/ directory and a file +// leaving the entry's directory (a climb, even one back in, a link out, a +// linked entry, LICENSE included) before mining, publishes a link inside +// and a nested file's climb that stays inside, and every // request carries User-Agent: bend/. SKIP when the site repo is not at // lib.SITE. @@ -561,6 +564,51 @@ try { dir.code === 1 && dir.err.includes("in a directory named license") && !dir.err.includes("mining") && seen.filter((s) => s.startsWith("POST / ")).length === posts); + const ffi = (at: string) => "import Base\ndef boot(a: U32) -> IO(Unit):\n" + + ' import "' + at + '"\n\ndef main() -> IO(Unit):\n do IO:\n' + + ' IO.print("hi\\n")\n'; + const link = (at: string, to: string) => { + fs.mkdirSync(path.dirname(path.join(TMP, "pub", at)), { recursive: true }); + fs.symlinkSync(to, path.join(TMP, "pub", at)); + }; + fs.mkdirSync(path.join(TMP, "pub", "out"), { recursive: true }); + fs.writeFileSync(path.join(TMP, "pub", "out.c"), "// secret\n"); + fs.writeFileSync(path.join(TMP, "pub", "out", "x.c"), "// secret\n"); + fs.writeFileSync(path.join(TMP, "pub", "out", "lic_link.bend"), two); + link("file/p.c", "../out.c"); + link("tree/d", "../out"); + link("lic/LICENSE", "../out.c"); + link("link/lic_link.bend", "../out/lic_link.bend"); + const away = "cannot be published: it is outside "; + const outs: [string, Record, string][] = [ + ["climb", { "lic_climb.bend": ffi("../out.c") }, away], + ["file", { "lic_file.bend": ffi("./p.c") }, away], + ["tree", { "lic_tree.bend": ffi("./d/x.c") }, away], + ["lic", { "lic_lic.bend": two }, "; it is the LICENSE beside "], + ["link", {}, "cannot be published: it links to "], + ["up", { "lic_up.bend": use("../out/lic_link.bend") }, away], + ["back", { "lic_back.bend": ffi("../back/ok.c"), "ok.c": "// ok\n" }, + "its import climbs out of the package (../back/ok.c)"]]; + for (const [dir, pkg, why] of outs) { + const count = seen.filter((s) => s.startsWith("POST / ")).length; + const run = await publish(dir, pkg); + check("a published " + dir + " leaving the entry's directory is refused" + + " before mining: " + run.err, run.code === 1 + && run.err.includes(why) + && !run.err.includes("mining") + && seen.filter((s) => s.startsWith("POST / ")).length === count); + } + link("in/p.c", "real.c"); + const inside = await publish("in", { "lic_in.bend": ffi("./p.c"), + "real.c": "// ok\n" }); + const nest = { "lic_nest.bend": use("sub/f.bend"), "ok.c": "// ok\n", + "sub/f.bend": two + '\ndef boot(a: U32) -> IO(Unit):\n import "../ok.c"\n' }; + const up = await publish("nest", nest); + check("a link inside the entry's directory goes, and a nested file's climb" + + " that stays inside goes at its path: " + inside.err + up.err, + inside.code === 0 && inside.out.startsWith(pkg_hash({ "lic_in.bend": + ffi("./p.c"), "p.c": "// ok\n" }) + "\n") && up.code === 0 + && up.out.startsWith(pkg_hash(nest) + "\n")); fs.writeFileSync(path.join(TMP, "pkg.bend"), "import Base\nimport " + hash + "/sub/two.bend as T\nimport bend-ping-probe@1.0.0.0/x.bend as X\n" + "def main() -> Nat:\n T.two\n");