/**
* Repo/resource name validation for the RPC boundary.
*
* WHY THIS EXISTS: `IssueRegistry.getForRepo(name)` mints a tracker autobase
* on first reference, and `RefReconcilerManager.ensureFor(name)` additionally
* runs `git init --bare`. Both guarded only on falsiness. Two handlers passed
* client-supplied strings straight in — `handlers/issues.js` (`params.repo`)
* and `handlers/orgs.js` (`params.resource`) — so any truthy string minted a
* permanent, swarm-announced repo plus a tracker autobase recorded in
* issue-registry-index.json.
*
* That produced 17 junk repos across the fleet (audited 2026-07-28), e.g.
* `undefined` (a stringified JS undefined — note it is TRUTHY) and `--help`
* (a name that breaks any tool interpolating it into a command line).
*
* SCOPE — read this before assuming it fixes everything. This catches the
* MALFORMED class only: sentinels, flag-like names, path traversal, control
* characters, over-long names. It CANNOT catch a mistyped subcommand, because
* `create` / `list` / `new` / `help` / `comment` are indistinguishable from a
* repo legitimately named that. Those got minted because the issues read path
* AUTO-CREATES on reference; the fix for that class is to require the repo to
* already exist, which is a separate behavioural change.
*
* Verified against all 138 live repo names: accepts 137, rejects only `--help`.
*/
/**
* Values that mean "a bug upstream stringified something".
* All are truthy strings, so the old `if (!name)` guard let every one through.
*/
const SENTINELS = new Set([
'undefined',
'null',
'nan',
'false',
'true',
'[object object]'
])
// Live names use [A-Za-z0-9-_]; `.` is permitted but `.`/`..` are rejected
// below. Must start alphanumeric so a name can never look like a CLI flag.
const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
// Longest live name is 32 chars. 100 leaves headroom without allowing a name
// that could blow out a path.
const MAX_LENGTH = 100
/**
* @param {unknown} name
* @returns {string|null} a human-readable reason, or null if the name is fine
*/
export function repoNameError (name) {
if (typeof name !== 'string') return `must be a string, got ${typeof name}`
const trimmed = name.trim()
if (!trimmed) return 'must not be empty or whitespace'
if (trimmed !== name) return 'must not have leading or trailing whitespace'
if (name.length > MAX_LENGTH) return `must be at most ${MAX_LENGTH} characters`
if (SENTINELS.has(name.toLowerCase())) {
return `"${name}" looks like a stringified value, not a repo name`
}
// Explicit, ahead of the charset check, so the message is actionable.
if (name === '.' || name === '..') return 'must not be "." or ".."'
if (name.includes('/') || name.includes('\\')) return 'must not contain a path separator'
if (name.includes('..')) return 'must not contain ".."'
// eslint-disable-next-line no-control-regex
if (/[\x00-\x1f\x7f]/.test(name)) return 'must not contain control characters'
if (name.startsWith('-')) return 'must not start with "-" (looks like a CLI flag)'
if (!NAME_RE.test(name)) {
return 'must start with a letter or digit and contain only letters, digits, ".", "_" or "-"'
}
return null
}
/**
* @param {unknown} name
* @returns {boolean}
*/
export function isValidRepoName (name) {
return repoNameError(name) === null
}
/**
* Throw if the name is not usable. `label` names the field in the message so
* the caller can say "repo" or "resource" as appropriate.
*
* @param {unknown} name
* @param {string} [label]
* @returns {string} the validated name
*/
export function assertValidRepoName (name, label = 'repo') {
const reason = repoNameError(name)
if (reason) {
const err = new Error(`invalid ${label} name: ${reason}`)
err.code = 'EINVALID_REPO_NAME'
err.status = 400
throw err
}
return name
}
export const __test = { SENTINELS, NAME_RE, MAX_LENGTH }