OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / lib / repo-name.js

107 lines · javascript ref:
/**
 * 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 }