/** * ───────────────────────────────────────────── * SPB Git — Personal Git Platform * ───────────────────────────────────────────── * Author : Simon-Pierre Boucher * Contact : contact@spboucher.ai * File : src/lib/util.mjs * Purpose : Small shared helpers (validation, formatting, atomic IO) * License : MIT © Simon-Pierre Boucher * ───────────────────────────────────────────── */ import { writeFileSync, renameSync, mkdirSync } from 'node:fs'; import { dirname, join, normalize } from 'node:path'; import { randomBytes, createHash } from 'node:crypto'; /** Repo naming rule from CLAUDE.md §3.1 — reject everything else hard. */ export const REPO_NAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/; /** * @param {string} name * @returns {boolean} true when the name is a safe, valid repository name */ export function isValidRepoName(name) { if (typeof name !== 'string' || !REPO_NAME_RE.test(name)) return false; if (name === '.' || name === '..' || name.includes('..')) return false; if (name.endsWith('.git')) return false; return true; } /** * Join `child` under `root` and guarantee the result stays inside `root`. * @param {string} root absolute base directory * @param {...string} segments path pieces (may come from user input) * @returns {string} normalized absolute path * @throws when the resolved path escapes the root */ export function safeJoin(root, ...segments) { const joined = normalize(join(root, ...segments)); const normalizedRoot = normalize(root).replace(/\/+$/, ''); if (joined !== normalizedRoot && !joined.startsWith(normalizedRoot + '/')) { throw new Error('path traversal rejected'); } return joined; } /** * Validate a repo-relative tree path (no traversal, no absolute, no NUL). * @param {string} path * @returns {boolean} */ export function isValidTreePath(path) { if (typeof path !== 'string') return false; if (path.includes('\0') || path.startsWith('/')) return false; const parts = path.split('/'); return parts.every((p) => p !== '' && p !== '.' && p !== '..'); } /** * Write JSON atomically (tmp file + rename) so readers never see partial data. * @param {string} path * @param {unknown} value */ export function atomicWriteJSON(path, value) { mkdirSync(dirname(path), { recursive: true }); const tmp = `${path}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`; writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 }); renameSync(tmp, path); } /** * @param {number} bytes * @returns {string} human size, e.g. "4.2 MB" */ export function formatBytes(bytes) { if (!Number.isFinite(bytes) || bytes < 0) return '0 B'; const units = ['B', 'KB', 'MB', 'GB', 'TB']; let i = 0; let value = bytes; while (value >= 1024 && i < units.length - 1) { value /= 1024; i += 1; } return `${i === 0 ? value : value.toFixed(1)} ${units[i]}`; } /** * @param {Date|number|string} input * @returns {string} GitHub-style relative time ("3 h ago") */ export function relativeTime(input) { const date = input instanceof Date ? input : new Date(input); const seconds = Math.floor((Date.now() - date.getTime()) / 1000); if (!Number.isFinite(seconds)) return ''; if (seconds < 0) return 'just now'; if (seconds < 45) return 'just now'; if (seconds < 90) return '1 min ago'; const minutes = Math.floor(seconds / 60); if (minutes < 60) return `${minutes} min ago`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours} h ago`; const days = Math.floor(hours / 24); if (days < 30) return days === 1 ? 'yesterday' : `${days} days ago`; const months = Math.floor(days / 30); if (months < 12) return `${months} mo ago`; const years = Math.floor(days / 365); return years <= 1 ? '1 year ago' : `${years} years ago`; } /** * @param {string} text * @returns {string} HTML-escaped text */ export function escapeHtml(text) { return String(text) .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } /** * Deterministic 5×5 identicon SVG for an author email. * @param {string} email * @param {number} [size] rendered square size in px * @returns {string} inline SVG markup */ export function identiconSvg(email, size = 32) { const hash = createHash('sha256').update(email.trim().toLowerCase()).digest(); const hue = ((hash[0] << 8) | hash[1]) % 360; const fg = `hsl(${hue} 55% 55%)`; const bg = 'transparent'; const cell = size / 5; let rects = ''; for (let x = 0; x < 3; x += 1) { for (let y = 0; y < 5; y += 1) { if (hash[2 + x * 5 + y] % 2 === 0) continue; for (const cx of x === 2 ? [2] : [x, 4 - x]) { rects += ``; } } } return `${rects}`; } /** * Run at most `limit` async jobs concurrently. * @template T,R * @param {T[]} items * @param {number} limit * @param {(item: T, index: number) => Promise} worker * @returns {Promise} results in input order */ export async function mapLimit(items, limit, worker) { const results = new Array(items.length); let next = 0; const runners = Array.from({ length: Math.min(limit, items.length) }, async () => { while (next < items.length) { const index = next; next += 1; results[index] = await worker(items[index], index); } }); await Promise.all(runners); return results; }