Compare commits

...
Sign in to create a new pull request.
Author SHA1 Message Date
Heimdall
abd68deaf4 README: state the two suite prerequisites (real .NET 8, built CultLib)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 12:26:37 +00:00
Heimdall
f0e62566ea tests: hostile 50k-char cursor is the longest the client Uri can carry, still a server-side 400
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 12:26:37 +00:00
Heimdall
0ffd97baed Bridge and git gate: repo-root and excluded-dir containment by path.relative, not a hardcoded backslash
github-draft-pr threw 'Target path escapes repo root' on Linux and the gate never excluded its own wrapper dir. One helper, tools/path-within.mjs, replaces the three copies; case-insensitivity now follows the platform's path flavour.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 12:26:37 +00:00
7 changed files with 76 additions and 11 deletions

View file

@ -89,7 +89,7 @@ For the full staged roadmap, including contribution points, revenue share, patro
- set `Heimdall:PatronSupportIntakeSecret` before enabling Heimdall patron support intake in production
- default connection string points at local PostgreSQL
- build with `dotnet build Bifrost.slnx`
- test with `DOTNET_ROLL_FORWARD=Major dotnet test Bifrost.slnx` if the machine only has the .NET 10 runtime installed
- test with `dotnet test Bifrost.slnx`; the suite needs a real .NET 8 runtime (`DOTNET_ROOT`, with a runsettings `DotNetHostPath` if the SDK is newer; .NET 10 roll-forward makes Mvc.Testing 8.0.0 return 500 on every JSON response) and a built CultLib TypeScript runtime (`dist/` and `node_modules`) at `$VOIDBOT_CULTLIB_ROOT`
- dry-run a Reddit organizing thread with `node tools/bifrost-bridge.mjs reddit-post --title "Thread title" --persona-name Bifrost --content "Thread body" --dry-run true`
- print the Eve provider advertisement with `node tools/provider-advertisement.mjs print`
- export the Eve provider store with `node tools/provider-advertisement.mjs export --out .bifrost/provider-store.cc`

View file

@ -210,7 +210,8 @@ public sealed class ConceptRankedExportTests : IClassFixture<ConceptRankedExport
yield return ["not base64!"];
yield return ["AAAA"];
yield return [new string('A', 201)];
yield return [new string('A', 100_000)];
// 50,000 chars is the longest the client Uri (65,519 cap on Unix) can carry with the path; far past the 200-char RankCursor.MaxLength.
yield return [new string('A', 50_000)];
yield return [B64("")];
yield return [B64("1|0|0|5")];
yield return [B64("1|0|0|5|alpha|extra")];

View file

@ -0,0 +1,50 @@
import test from "node:test";
import assert from "node:assert/strict";
import path from "node:path";
import { isWithin } from "../tools/path-within.mjs";
const flavours = [
{ name: "posix", pathApi: path.posix, root: "/srv/repo", sep: "/", inside: "/srv/repo/src/a.txt", sibling: "/srv/repo-evil/a.txt", other: "/etc/passwd" },
{ name: "win32", pathApi: path.win32, root: "C:\\Work\\Repo", sep: "\\", inside: "C:\\Work\\Repo\\src\\a.txt", sibling: "C:\\Work\\Repo-evil\\a.txt", other: "D:\\Work\\Repo\\a.txt" },
];
for (const f of flavours) {
const opts = { pathApi: f.pathApi };
test(`${f.name}: a path inside the root, and the root itself, are within`, () => {
assert.equal(isWithin(f.root, f.inside, opts), true);
assert.equal(isWithin(f.root, f.root, opts), true);
assert.equal(isWithin(f.root, f.root + f.sep, opts), true);
});
test(`${f.name}: a sibling sharing the root as a name prefix is refused`, () => {
assert.equal(isWithin(f.root, f.sibling, opts), false);
});
test(`${f.name}: a dot-dot escape and an unrelated absolute path are refused`, () => {
assert.equal(isWithin(f.root, f.root + f.sep + ["..", "x"].join(f.sep), opts), false);
assert.equal(isWithin(f.root, f.other, opts), false);
assert.equal(isWithin(f.root, f.root + f.sep + "..", opts), false);
});
test(`${f.name}: a name that merely starts with two dots is inside`, () => {
assert.equal(isWithin(f.root, f.root + f.sep + "..cache" + f.sep + "x", opts), true);
});
test(`${f.name}: excluded dir, strictly beneath it vs not`, () => {
const excluded = f.root + f.sep + "tools" + f.sep + "git-gate";
const strict = { ...opts, allowSelf: false };
assert.equal(isWithin(excluded, excluded + f.sep + "git.cmd", strict), true);
assert.equal(isWithin(excluded, excluded, strict), false);
assert.equal(isWithin(excluded, excluded + "-other" + f.sep + "git", strict), false);
assert.equal(isWithin(excluded, f.root + f.sep + "bin" + f.sep + "git", strict), false);
});
}
test("win32: comparison ignores case, as Windows paths do", () => {
assert.equal(isWithin("C:\\Work\\Repo", "c:\\work\\repo\\SRC\\a.txt", { pathApi: path.win32 }), true);
assert.equal(isWithin("C:\\Work\\Repo", "c:\\work\\repo-evil\\a.txt", { pathApi: path.win32 }), false);
});
test("posix: comparison is case-sensitive", () => {
assert.equal(isWithin("/srv/repo", "/srv/Repo/a.txt", { pathApi: path.posix }), false);
});

View file

@ -4,6 +4,7 @@ import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { isWithin } from "./path-within.mjs";
import {
beginCrossingReceipt,
crossingProvenanceFromOptions,
@ -182,7 +183,7 @@ async function createGitHubDraftPr(options) {
}
const targetPath = resolve(repoRoot, relativePath);
if (!targetPath.toLowerCase().startsWith(`${repoRoot.toLowerCase()}\\`) && targetPath.toLowerCase() !== repoRoot.toLowerCase()) {
if (!isWithin(repoRoot, targetPath)) {
throw new Error(`Target path escapes repo root: ${relativePath}`);
}

View file

@ -1,7 +1,8 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import { dirname, resolve } from "node:path";
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { isWithin } from "../path-within.mjs";
const args = process.argv.slice(2);
const wrapperDir = dirname(fileURLToPath(import.meta.url));
@ -90,9 +91,7 @@ function resolveExecutable(command, excludedDir) {
}
function isWithinExcludedDir(candidate, excludedDir) {
const normalizedCandidate = resolve(candidate).toLowerCase();
const normalizedExcludedDir = `${resolve(excludedDir).toLowerCase()}\\`;
return normalizedCandidate.startsWith(normalizedExcludedDir);
return isWithin(excludedDir, candidate, { allowSelf: false });
}
function isWindowsCommandScript(command) {

View file

@ -1,7 +1,8 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import { dirname, resolve } from "node:path";
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { isWithin } from "../path-within.mjs";
const args = process.argv.slice(2);
const wrapperDir = dirname(fileURLToPath(import.meta.url));
@ -59,9 +60,7 @@ function resolveExecutable(command, excludedDir) {
}
function isWithinExcludedDir(candidate, excludedDir) {
const normalizedCandidate = resolve(candidate).toLowerCase();
const normalizedExcludedDir = `${resolve(excludedDir).toLowerCase()}\\`;
return normalizedCandidate.startsWith(normalizedExcludedDir);
return isWithin(excludedDir, candidate, { allowSelf: false });
}
function findGitSubcommand(input) {

15
tools/path-within.mjs Normal file
View file

@ -0,0 +1,15 @@
import path from "node:path";
// True when `candidate` is `root` itself (only if allowSelf) or lies beneath it.
// Containment is path.relative-based, so it follows the host's separator and
// case rules; pass path.win32 or path.posix to pin a flavour.
export function isWithin(root, candidate, { pathApi = path, allowSelf = true } = {}) {
const relative = pathApi.relative(pathApi.resolve(root), pathApi.resolve(candidate));
if (relative === "") {
return allowSelf;
}
if (pathApi.isAbsolute(relative) || relative === ".." || relative.startsWith(`..${pathApi.sep}`)) {
return false;
}
return true;
}