clone

a personal forge on plain git: static pages, stacked-diff reviews, signed local ci, repo sites and releases

main
forge / lib / access.ts
access.ts 3.9K
1// Who may read what, for the servers: the rules bin/forge applies over ssh,2// in TypeScript. A repo is public while its bare repo has3// git-daemon-export-ok; a private one can be read by its owner and the names4// in its forge/collaborators and forge/readers (can_read in bin/forge).5// Users are the names in .forge/keys, the forge's only source of identity,6// so someone whose keys are removed can't read anything here either.7// test/scenarios/identity-can-read.sh checks that both sides agree.8//9// node lib/access.ts can-read <git-dir> <user> exits 0 if they may10import fs from 'node:fs';11import path from 'node:path';12import { fileURLToPath } from 'node:url';1314// valid_name in bin/forge: owner and repo names, and user names.15export function validName(n: string): boolean {16 if (!n || n.startsWith('.') || !/^[A-Za-z0-9._-]+$/.test(n) || n.endsWith('.git')) return false;17 return !['assets', 'sites', 'previews', 'hosts', 'git-shell-commands'].includes(n);18}1920const isFile = (p: string): boolean => {21 try {22 return fs.statSync(p).isFile();23 } catch {24 return false;25 }26};2728// The bare repo of owner/name, if it is one: valid names, and a HEAD.29export function repoDir(root: string, slug: string): string | null {30 const [owner, name, ...rest] = slug.split('/');31 if (rest.length || !validName(owner ?? '') || !validName(name ?? '')) return null;32 const dir = path.join(root, owner, `${name}.git`);33 return isFile(path.join(dir, 'HEAD')) ? dir : null;34}3536export const isPublic = (dir: string): boolean => isFile(path.join(dir, 'git-daemon-export-ok'));3738// Whole lines, as grep -qxF matches them.39function listed(file: string, user: string): boolean {40 try {41 return fs.readFileSync(file, 'utf8').split('\n').includes(user);42 } catch {43 return false;44 }45}4647// can_read <git-dir> <user>.48export function canRead(dir: string, user: string | null): boolean {49 if (isPublic(dir)) return true;50 if (!user) return false;51 if (path.basename(path.dirname(dir)) === user) return true;52 return listed(path.join(dir, 'forge', 'collaborators'), user) || listed(path.join(dir, 'forge', 'readers'), user);53}5455// The users of the forge: the first word of each line of .forge/keys.56// Read again only when the file changes.57let seen: { file: string; mtime: number; names: Set<string> } | null = null;58export function users(state: string): Set<string> {59 const file = path.join(state, 'keys');60 let mtime = -1;61 try {62 mtime = fs.statSync(file).mtimeMs;63 } catch {}64 if (seen?.file === file && seen.mtime === mtime) return seen.names;65 const names = new Set<string>();66 if (mtime >= 0) {67 for (const line of fs.readFileSync(file, 'utf8').split('\n')) {68 const name = line.trim().split(/\s+/)[0];69 if (name && !name.startsWith('#')) names.add(name);70 }71 }72 seen = { file, mtime, names };73 return names;74}7576// Every private repo user may read, as owner/name, in the order ls lists them.77export function readable(root: string, user: string): string[] {78 const out: string[] = [];79 let owners: string[] = [];80 try {81 owners = fs.readdirSync(root);82 } catch {}83 for (const owner of owners) {84 if (!validName(owner)) continue;85 let names: string[] = [];86 try {87 names = fs.readdirSync(path.join(root, owner));88 } catch {}89 for (const d of names) {90 if (!d.endsWith('.git')) continue;91 const dir = repoDir(root, `${owner}/${d.slice(0, -4)}`);92 if (dir && !isPublic(dir) && canRead(dir, user)) out.push(`${owner}/${d.slice(0, -4)}`);93 }94 }95 return out.sort((a, b) => a.localeCompare(b, 'en', { sensitivity: 'base' }) || (a < b ? -1 : a > b ? 1 : 0));96}9798if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {99 const [cmd, dir, user] = process.argv.slice(2);100 if (cmd !== 'can-read' || !dir) {101 console.error('usage: node lib/access.ts can-read <git-dir> <user>');102 process.exit(2);103 }104 process.exit(canRead(dir, user || null) ? 0 : 1);105}