// Malformed repos must be RETIRABLE.
//
// assertValidRepoName exists to stop malformed names being CREATED
// (lib/repo-name.js). Applying it unchanged to the retire path meant a
// malformed repo could never be REMOVED — the live fleet carries real repos
// called `--help` (rejected: starts with "-") and `undefined` (rejected:
// stringified sentinel), and both were untombstonable. The guard against
// making them also protected them.
//
// Retirement is cleanup, so a malformed name is accepted when that repo
// actually exists here — and refused when it does not, so nobody can pollute
// the replicated event log with tombstones for names that were never repos.
// Traversal and control characters stay refused either way: the name reaches
// the filesystem, and "it exists" is not a reason to relax containment.
import test from 'brittle'
import process from '#node:process'
import { mkdtempSync, mkdirSync } from '#node:fs'
import { join } from '#node:path'
import { tmpdir } from '#node:os'
import { Client } from 'hyper-svc'
import { startPearGit } from '../index.js'
import { execFileSync } from '../lib/_compat-exec.js'
process.env.PGV3_USE_RAW_IDENTITY = '1'
const tmp = (p) => mkdtempSync(join(tmpdir(), p))
async function setup (t, junkNames = []) {
const node = await startPearGit({
storage: tmp('pgv3-retire-'), uds: join(tmp('pgv3-retire-s-'), 'svc.sock'),
http: { port: 0 }, swarm: false, autoImportSSH: false
})
t.teardown(async () => { try { await node.close() } catch {} })
// Materialize the junk exactly as it exists on the fleet: a real bare repo
// whose NAME the validator rejects.
mkdirSync(node.svc._reposDir, { recursive: true })
for (const n of junkNames) {
execFileSync('git', ['init', '--bare', '-q', join(node.svc._reposDir, n + '.git')])
}
const client = await Client.discover({ uds: node.svc.uds.path })
t.teardown(async () => { try { await client.close() } catch {} })
return { node, client }
}
test('a malformed repo that EXISTS can be retired', async (t) => {
const { client } = await setup(t, ['--help', 'undefined'])
for (const name of ['--help', 'undefined']) {
const res = await client.call('retire-repo', { name })
t.is(res.retired, name, name + ' tombstoned')
t.ok(/NOT removed/.test(res.note || ''), 'and the response says bytes were not removed')
}
const { retired } = await client.call('list-retired-repos', {})
t.ok(retired.includes('--help') && retired.includes('undefined'),
'both appear in the replicated tombstone set')
})
test('a malformed name that does NOT exist is still refused', async (t) => {
const { client } = await setup(t)
// Otherwise anyone could append tombstones for arbitrary junk.
await t.exception(
client.call('retire-repo', { name: '--never-existed' }),
/malformed names may only be retired when the repo actually exists/,
'refused with the reason'
)
})
test('traversal and control characters are refused even if they exist', async (t) => {
const { client } = await setup(t)
for (const bad of ['../etc', 'a/b', 'x y']) {
await t.exception(client.call('retire-repo', { name: bad }), /unsafe repo name/,
JSON.stringify(bad) + ' refused')
}
})
test('well-formed names are unaffected', async (t) => {
const { client } = await setup(t, ['normal-repo'])
const res = await client.call('retire-repo', { name: 'normal-repo' })
t.is(res.retired, 'normal-repo', 'ordinary retire still works')
})
test('unretire accepts the same names, so a junk retire is reversible', async (t) => {
const { client } = await setup(t, ['--help'])
await client.call('retire-repo', { name: '--help' })
const back = await client.call('unretire-repo', { name: '--help' })
t.is(back.unretired, '--help', 'un-retirable too — a mistake is not permanent')
const { retired } = await client.call('list-retired-repos', {})
t.absent(retired.includes('--help'), 'and it left the tombstone set')
})
test('retire still refuses a missing name outright', async (t) => {
const { client } = await setup(t)
await t.exception(client.call('retire-repo', {}), /requires \{ name \}/)
})