/** * ───────────────────────────────────────────── * SPB Git — Personal Git Platform * ───────────────────────────────────────────── * Author : Simon-Pierre Boucher * Contact : contact@spboucher.ai * File : src/git/releases.mjs * Purpose : Release assets — binary artifacts (dmg, pkg…) attached to tags * License : MIT © Simon-Pierre Boucher * ───────────────────────────────────────────── */ import { createWriteStream, createReadStream, existsSync, readdirSync, statSync, readFileSync, writeFileSync, rmSync, mkdirSync, renameSync, } from 'node:fs'; import { join, dirname } from 'node:path'; import { createHash, randomBytes } from 'node:crypto'; import { pipeline } from 'node:stream/promises'; import { Transform } from 'node:stream'; import { safeJoin } from '../lib/util.mjs'; /** Assets live OUTSIDE the bare repos: /releases///. */ const ASSET_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,120}$/; const TAG_NAME_RE = /^[\w][\w./-]{0,100}$/; const MAX_ASSET_BYTES = 4 * 1024 * 1024 * 1024; // 4 GiB /** Download MIME types for common artifacts. */ export const ASSET_MIME = Object.freeze({ '.dmg': 'application/x-apple-diskimage', '.pkg': 'application/octet-stream', '.zip': 'application/zip', '.gz': 'application/gzip', '.tgz': 'application/gzip', '.xz': 'application/x-xz', '.zst': 'application/zstd', '.exe': 'application/vnd.microsoft.portable-executable', '.msi': 'application/x-msi', '.deb': 'application/vnd.debian.binary-package', '.rpm': 'application/x-rpm', '.appimage': 'application/octet-stream', '.apk': 'application/vnd.android.package-archive', '.ipa': 'application/octet-stream', '.whl': 'application/octet-stream', '.jar': 'application/java-archive', }); /** @param {string} name @returns {boolean} */ export function isValidAssetName(name) { return typeof name === 'string' && ASSET_NAME_RE.test(name) && !name.endsWith('.sha256'); } /** @param {string} tag @returns {boolean} */ export function isValidTagName(tag) { return typeof tag === 'string' && TAG_NAME_RE.test(tag) && !tag.includes('..'); } /** * Filesystem store for release assets. */ export class ReleaseStore { /** @param {string} dataDir */ constructor(dataDir) { this.root = join(dataDir, 'releases'); } /** @returns {string} absolute asset path (traversal-safe) */ assetPath(repo, tag, name) { if (!isValidTagName(tag) || !isValidAssetName(name)) throw new Error('invalid tag or asset name'); return safeJoin(this.root, repo, tag.replaceAll('/', '_'), name); } /** * Persist an incoming stream as an asset. Computes sha256 while writing. * @param {string} repo * @param {string} tag * @param {string} name * @param {NodeJS.ReadableStream} stream * @returns {Promise<{name: string, size: number, sha256: string}>} */ async save(repo, tag, name, stream) { const dest = this.assetPath(repo, tag, name); mkdirSync(dirname(dest), { recursive: true }); const tmp = `${dest}.${randomBytes(4).toString('hex')}.tmp`; const hash = createHash('sha256'); let size = 0; const counter = new Transform({ transform(chunk, _enc, done) { size += chunk.length; if (size > MAX_ASSET_BYTES) { done(new Error('asset exceeds the 4 GiB limit')); return; } hash.update(chunk); done(null, chunk); }, }); try { await pipeline(stream, counter, createWriteStream(tmp)); } catch (err) { rmSync(tmp, { force: true }); throw err; } renameSync(tmp, dest); const sha256 = hash.digest('hex'); writeFileSync(`${dest}.sha256`, `${sha256} ${name}\n`); return { name, size, sha256 }; } /** * @returns {{name: string, size: number, sha256: string|null, uploaded: string}|null} */ stat(repo, tag, name) { let path; try { path = this.assetPath(repo, tag, name); } catch { return null; } if (!existsSync(path)) return null; const st = statSync(path); let sha256 = null; try { sha256 = readFileSync(`${path}.sha256`, 'utf8').split(/\s+/)[0] || null; } catch { /* older asset without sidecar */ } return { name, size: st.size, sha256, uploaded: st.mtime.toISOString() }; } /** * All assets of a repo grouped by tag. * @param {string} repo * @returns {Record>} */ list(repo) { const repoDir = safeJoin(this.root, repo); const result = {}; if (!existsSync(repoDir)) return result; for (const tag of readdirSync(repoDir)) { const tagDir = join(repoDir, tag); if (!statSync(tagDir).isDirectory()) continue; const assets = readdirSync(tagDir) .filter((f) => !f.endsWith('.sha256') && !f.endsWith('.tmp')) .map((f) => this.stat(repo, tag, f)) .filter(Boolean) .sort((a, b) => a.name.localeCompare(b.name)); if (assets.length > 0) result[tag] = assets; } return result; } /** * @returns {boolean} true when the asset existed and was removed */ remove(repo, tag, name) { let path; try { path = this.assetPath(repo, tag, name); } catch { return false; } if (!existsSync(path)) return false; rmSync(path, { force: true }); rmSync(`${path}.sha256`, { force: true }); return true; } /** Remove every asset of a repo (used by repo soft-delete). */ removeRepo(repo) { try { rmSync(safeJoin(this.root, repo), { recursive: true, force: true }); } catch { /* nothing to remove */ } } /** @returns {import('node:fs').ReadStream} */ readStream(repo, tag, name) { return createReadStream(this.assetPath(repo, tag, name)); } }