From b301abe5ac4d62874e74469ce3106e5b17ae812b Mon Sep 17 00:00:00 2001 From: attaboy11 Date: Sun, 31 May 2026 07:24:16 +0100 Subject: [PATCH] Add Git LFS pointer integrity guard --- lfs-pointer-integrity-guard/README.md | 40 ++ lfs-pointer-integrity-guard/demo.js | 25 ++ lfs-pointer-integrity-guard/index.js | 399 ++++++++++++++++++ lfs-pointer-integrity-guard/render-video.js | 157 +++++++ lfs-pointer-integrity-guard/reports/demo.mp4 | Bin 0 -> 25899 bytes .../reports/integrity-audit.json | 253 +++++++++++ .../reports/release-gate.md | 43 ++ .../reports/summary.svg | 19 + lfs-pointer-integrity-guard/sample-data.js | 136 ++++++ lfs-pointer-integrity-guard/test.js | 71 ++++ 10 files changed, 1143 insertions(+) create mode 100644 lfs-pointer-integrity-guard/README.md create mode 100644 lfs-pointer-integrity-guard/demo.js create mode 100644 lfs-pointer-integrity-guard/index.js create mode 100644 lfs-pointer-integrity-guard/render-video.js create mode 100644 lfs-pointer-integrity-guard/reports/demo.mp4 create mode 100644 lfs-pointer-integrity-guard/reports/integrity-audit.json create mode 100644 lfs-pointer-integrity-guard/reports/release-gate.md create mode 100644 lfs-pointer-integrity-guard/reports/summary.svg create mode 100644 lfs-pointer-integrity-guard/sample-data.js create mode 100644 lfs-pointer-integrity-guard/test.js diff --git a/lfs-pointer-integrity-guard/README.md b/lfs-pointer-integrity-guard/README.md new file mode 100644 index 00000000..0d58c228 --- /dev/null +++ b/lfs-pointer-integrity-guard/README.md @@ -0,0 +1,40 @@ +# Git LFS Pointer Integrity Guard + +Self-contained reviewer slice for SCIBASE issue #10, Project Repository & Version Control. + +This guard focuses on the Git LFS and hash-based integrity requirement in the project repository spec. It audits large scientific files before a tagged repository release or export bundle is allowed: + +- parses canonical Git LFS pointer files +- compares pointer SHA-256 and byte size against large-object storage metadata +- verifies release/export manifest coverage for DOI bundles +- flags missing, stale, malformed, or mismatched large objects +- checks retention and quota risk before release +- emits deterministic release, hold, and remediation actions + +The sample data is synthetic and the module does not call GitHub, Git LFS, DOI providers, object stores, payment providers, or external services. + +## Files + +- `index.js` - audit engine, pointer parser, report renderers +- `sample-data.js` - synthetic repository, manifest, and object-store records +- `test.js` - deterministic regression tests +- `demo.js` - generates JSON, Markdown, and SVG reviewer artifacts +- `render-video.js` - renders the required short MP4 demo +- `reports/` - generated reviewer packet + +## Validation + +```bash +node lfs-pointer-integrity-guard/test.js +node lfs-pointer-integrity-guard/demo.js +node lfs-pointer-integrity-guard/render-video.js +node --check lfs-pointer-integrity-guard/index.js +node --check lfs-pointer-integrity-guard/sample-data.js +node --check lfs-pointer-integrity-guard/test.js +node --check lfs-pointer-integrity-guard/demo.js +node --check lfs-pointer-integrity-guard/render-video.js +git diff --check +ffprobe -v error -select_streams v:0 -show_entries stream=pix_fmt,width,height -show_entries format=duration,size -of default=nw=1 lfs-pointer-integrity-guard/reports/demo.mp4 +``` + +Expected decision for the synthetic fixture is `block-release`: it intentionally includes one passing LFS object plus malformed, missing, checksum-mismatched, size-mismatched, retention, manifest coverage, DOI/export, and quota-risk cases. diff --git a/lfs-pointer-integrity-guard/demo.js b/lfs-pointer-integrity-guard/demo.js new file mode 100644 index 00000000..50e5df67 --- /dev/null +++ b/lfs-pointer-integrity-guard/demo.js @@ -0,0 +1,25 @@ +const path = require('path'); +const { evaluateRepository, writeReports } = require('./index'); +const { repository } = require('./sample-data'); + +function runDemo(outputDir = path.join(__dirname, 'reports')) { + const audit = evaluateRepository(repository); + const reports = writeReports(audit, outputDir); + + console.log(`Decision: ${audit.decision}`); + console.log(`Checked ${audit.summary.lfsFilesChecked} Git LFS files`); + console.log(`Blockers: ${audit.summary.blockers}`); + console.log(`Holds: ${audit.summary.holds}`); + console.log(`Reports written:`); + console.log(`- ${reports.jsonPath}`); + console.log(`- ${reports.markdownPath}`); + console.log(`- ${reports.svgPath}`); + + return { audit, reports }; +} + +if (require.main === module) { + runDemo(); +} + +module.exports = { runDemo }; diff --git a/lfs-pointer-integrity-guard/index.js b/lfs-pointer-integrity-guard/index.js new file mode 100644 index 00000000..b5a2636e --- /dev/null +++ b/lfs-pointer-integrity-guard/index.js @@ -0,0 +1,399 @@ +const fs = require('fs'); +const path = require('path'); + +const LFS_POINTER_VERSION = 'https://git-lfs.github.com/spec/v1'; +const SHA256_RE = /^[a-f0-9]{64}$/; + +function parseLfsPointer(pointerText) { + const errors = []; + const fields = {}; + + if (typeof pointerText !== 'string' || pointerText.trim() === '') { + return { valid: false, errors: ['pointer-empty'], fields }; + } + + for (const rawLine of pointerText.trim().split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line) continue; + const [key, ...rest] = line.split(/\s+/); + fields[key] = rest.join(' '); + } + + if (fields.version !== LFS_POINTER_VERSION) { + errors.push('version-invalid'); + } + + const oid = fields.oid || ''; + if (!oid.startsWith('sha256:')) { + errors.push('oid-missing-sha256-prefix'); + } + + const sha256 = oid.startsWith('sha256:') ? oid.slice('sha256:'.length) : ''; + if (!SHA256_RE.test(sha256)) { + errors.push('oid-invalid-sha256'); + } + + const size = Number(fields.size); + if (!Number.isSafeInteger(size) || size < 0) { + errors.push('size-invalid'); + } + + return { + valid: errors.length === 0, + errors, + version: fields.version || null, + oid: sha256 || null, + size: Number.isSafeInteger(size) ? size : null, + fields, + }; +} + +function formatBytes(bytes) { + if (!Number.isFinite(bytes)) return 'unknown'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let value = bytes; + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit += 1; + } + return `${value.toFixed(value >= 10 || unit === 0 ? 0 : 1)} ${units[unit]}`; +} + +function normalizeDate(value) { + if (!value) return null; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +} + +function addFinding(findings, severity, code, filePath, message, evidence = {}) { + findings.push({ severity, code, path: filePath, message, evidence }); +} + +function evaluateRepository(repository) { + const storageByOid = new Map((repository.lfsObjects || []).map((object) => [object.oid, object])); + const manifestByPath = new Map((repository.releaseManifest || []).map((entry) => [entry.path, entry])); + const findings = []; + const fileResults = []; + const releaseDate = normalizeDate(repository.releaseDate) || new Date(); + const retentionMinimum = repository.retentionMinimumDays || 3650; + + for (const file of repository.files || []) { + if (file.storage !== 'git-lfs') continue; + + const parsed = parseLfsPointer(file.pointer); + const result = { + path: file.path, + parsed, + manifest: manifestByPath.get(file.path) || null, + storageObject: parsed.oid ? storageByOid.get(parsed.oid) || null : null, + status: 'pass', + findings: [], + }; + + if (!parsed.valid) { + addFinding( + result.findings, + 'blocker', + 'pointer-malformed', + file.path, + `Git LFS pointer is malformed: ${parsed.errors.join(', ')}`, + { errors: parsed.errors }, + ); + result.status = 'block'; + fileResults.push(result); + findings.push(...result.findings); + continue; + } + + const object = result.storageObject; + if (!object) { + addFinding( + result.findings, + 'blocker', + 'lfs-object-missing', + file.path, + 'Pointer references an object that is absent from large-object storage.', + { oid: parsed.oid, expectedSize: parsed.size }, + ); + } else { + if (object.sha256 !== parsed.oid) { + addFinding( + result.findings, + 'blocker', + 'lfs-checksum-mismatch', + file.path, + 'Stored large-object checksum does not match the pointer object id.', + { pointerSha256: parsed.oid, storedSha256: object.sha256 }, + ); + } + + if (object.size !== parsed.size) { + addFinding( + result.findings, + 'blocker', + 'lfs-size-mismatch', + file.path, + 'Stored large-object byte size does not match the pointer size.', + { pointerSize: parsed.size, storedSize: object.size }, + ); + } + + const expiresAt = normalizeDate(object.expiresAt); + if (expiresAt) { + const retentionDays = Math.floor((expiresAt.getTime() - releaseDate.getTime()) / 86400000); + if (retentionDays < retentionMinimum) { + addFinding( + result.findings, + 'hold', + 'lfs-retention-too-short', + file.path, + 'Large-object retention expires before the repository citation retention window.', + { expiresAt: object.expiresAt, retentionDays, requiredDays: retentionMinimum }, + ); + } + } + } + + const manifest = result.manifest; + if (!manifest) { + addFinding( + result.findings, + 'hold', + 'release-manifest-missing', + file.path, + 'Git LFS file is not represented in the tagged release/export manifest.', + { releaseTag: repository.releaseTag }, + ); + } else { + if (manifest.oid !== parsed.oid) { + addFinding( + result.findings, + 'blocker', + 'manifest-oid-drift', + file.path, + 'Release manifest points at a different large-object id than the Git LFS pointer.', + { pointerSha256: parsed.oid, manifestSha256: manifest.oid }, + ); + } + + if (manifest.size !== parsed.size) { + addFinding( + result.findings, + 'blocker', + 'manifest-size-drift', + file.path, + 'Release manifest byte size differs from the Git LFS pointer.', + { pointerSize: parsed.size, manifestSize: manifest.size }, + ); + } + + if (!manifest.doiBundleIncluded) { + addFinding( + result.findings, + 'hold', + 'doi-export-coverage-missing', + file.path, + 'Large object is omitted from the DOI/export bundle coverage map.', + { releaseTag: repository.releaseTag }, + ); + } + } + + result.status = result.findings.some((finding) => finding.severity === 'blocker') + ? 'block' + : result.findings.length > 0 + ? 'hold' + : 'pass'; + fileResults.push(result); + findings.push(...result.findings); + } + + const uniqueObjects = new Map(); + for (const result of fileResults) { + if (result.parsed.oid && result.storageObject) { + uniqueObjects.set(result.parsed.oid, result.storageObject.size); + } + } + const lfsUsageBytes = [...uniqueObjects.values()].reduce((total, size) => total + size, 0); + const projectedBytes = lfsUsageBytes + (repository.pendingLfsBytes || 0); + + if (repository.lfsQuotaBytes && projectedBytes > repository.lfsQuotaBytes) { + addFinding( + findings, + 'hold', + 'lfs-quota-risk', + repository.releaseTag, + 'Tagged release would exceed the configured Git LFS storage quota after pending uploads.', + { + currentUsage: lfsUsageBytes, + pendingBytes: repository.pendingLfsBytes || 0, + quotaBytes: repository.lfsQuotaBytes, + }, + ); + } + + const blockers = findings.filter((finding) => finding.severity === 'blocker'); + const holds = findings.filter((finding) => finding.severity === 'hold'); + const decision = blockers.length > 0 ? 'block-release' : holds.length > 0 ? 'hold-for-review' : 'allow-release'; + + return { + repository: { + name: repository.name, + releaseTag: repository.releaseTag, + releaseDate: repository.releaseDate, + lfsQuota: formatBytes(repository.lfsQuotaBytes), + projectedLfsUsage: formatBytes(projectedBytes), + }, + decision, + releaseEligible: decision === 'allow-release', + summary: { + lfsFilesChecked: fileResults.length, + blockers: blockers.length, + holds: holds.length, + passed: fileResults.filter((result) => result.status === 'pass').length, + projectedLfsUsageBytes: projectedBytes, + lfsQuotaBytes: repository.lfsQuotaBytes, + }, + findings, + fileResults: fileResults.map((result) => ({ + path: result.path, + status: result.status, + oid: result.parsed.oid, + pointerSize: result.parsed.size, + manifestCovered: Boolean(result.manifest), + storagePresent: Boolean(result.storageObject), + findings: result.findings, + })), + remediation: buildRemediation(findings), + }; +} + +function buildRemediation(findings) { + const actions = []; + const hasCode = (code) => findings.some((finding) => finding.code === code); + + if (hasCode('pointer-malformed')) { + actions.push('Regenerate malformed pointer files with git-lfs pointer canonical output before tagging.'); + } + if (hasCode('lfs-object-missing')) { + actions.push('Upload or restore missing large objects, then re-run pointer verification from a clean clone.'); + } + if (hasCode('lfs-checksum-mismatch') || hasCode('lfs-size-mismatch')) { + actions.push('Quarantine mismatched large objects and republish from verified source artifacts.'); + } + if (hasCode('manifest-oid-drift') || hasCode('manifest-size-drift')) { + actions.push('Regenerate the release/export manifest from the verified pointer set.'); + } + if (hasCode('release-manifest-missing') || hasCode('doi-export-coverage-missing')) { + actions.push('Add every Git LFS artifact to the DOI/export coverage map or document an explicit exclusion.'); + } + if (hasCode('lfs-retention-too-short')) { + actions.push('Move release artifacts to a retention tier that satisfies the citation retention window.'); + } + if (hasCode('lfs-quota-risk')) { + actions.push('Resolve pending large uploads, archive superseded objects, or raise quota before release.'); + } + + return actions; +} + +function renderMarkdown(audit) { + const lines = [ + `# Git LFS Pointer Integrity Guard`, + ``, + `Repository: ${audit.repository.name}`, + `Release: ${audit.repository.releaseTag}`, + `Decision: ${audit.decision}`, + ``, + `## Summary`, + ``, + `- LFS files checked: ${audit.summary.lfsFilesChecked}`, + `- Blockers: ${audit.summary.blockers}`, + `- Holds: ${audit.summary.holds}`, + `- Passed: ${audit.summary.passed}`, + `- Projected LFS usage: ${audit.repository.projectedLfsUsage} of ${audit.repository.lfsQuota}`, + ``, + `## Findings`, + ``, + ]; + + if (audit.findings.length === 0) { + lines.push(`No findings. Release can proceed.`); + } else { + for (const finding of audit.findings) { + lines.push(`- ${finding.severity.toUpperCase()} ${finding.code} (${finding.path}): ${finding.message}`); + } + } + + lines.push(``, `## Remediation`, ``); + for (const action of audit.remediation) { + lines.push(`- ${action}`); + } + + lines.push(``, `## File Results`, ``); + for (const result of audit.fileResults) { + lines.push( + `- ${result.status.toUpperCase()} ${result.path} (${result.storagePresent ? 'storage present' : 'storage missing'}, ${result.manifestCovered ? 'manifest covered' : 'manifest gap'})`, + ); + } + + return `${lines.join('\n')}\n`; +} + +function renderSvg(audit) { + const statusColor = audit.decision === 'allow-release' ? '#16a34a' : audit.decision === 'hold-for-review' ? '#ca8a04' : '#dc2626'; + const rows = audit.fileResults + .map((result, index) => { + const y = 178 + index * 34; + const color = result.status === 'pass' ? '#16a34a' : result.status === 'hold' ? '#ca8a04' : '#dc2626'; + return `${escapeXml(result.path)}${result.status.toUpperCase()}`; + }) + .join('\n'); + + return ` + + + + Git LFS Pointer Integrity Guard + ${escapeXml(audit.repository.name)} ${escapeXml(audit.repository.releaseTag)} + + ${escapeXml(audit.decision)} + Checked ${audit.summary.lfsFilesChecked} LFS files: ${audit.summary.blockers} blockers, ${audit.summary.holds} holds, ${audit.summary.passed} pass + ${rows} + + Projected LFS usage: ${escapeXml(audit.repository.projectedLfsUsage)} of ${escapeXml(audit.repository.lfsQuota)} + +`; +} + +function escapeXml(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function writeReports(audit, outputDir = path.join(__dirname, 'reports')) { + fs.mkdirSync(outputDir, { recursive: true }); + const jsonPath = path.join(outputDir, 'integrity-audit.json'); + const markdownPath = path.join(outputDir, 'release-gate.md'); + const svgPath = path.join(outputDir, 'summary.svg'); + + fs.writeFileSync(jsonPath, `${JSON.stringify(audit, null, 2)}\n`); + fs.writeFileSync(markdownPath, renderMarkdown(audit)); + fs.writeFileSync(svgPath, renderSvg(audit)); + + return { jsonPath, markdownPath, svgPath }; +} + +module.exports = { + LFS_POINTER_VERSION, + parseLfsPointer, + evaluateRepository, + formatBytes, + renderMarkdown, + renderSvg, + writeReports, +}; diff --git a/lfs-pointer-integrity-guard/render-video.js b/lfs-pointer-integrity-guard/render-video.js new file mode 100644 index 00000000..3960f80c --- /dev/null +++ b/lfs-pointer-integrity-guard/render-video.js @@ -0,0 +1,157 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); +const { runDemo } = require('./demo'); + +const FONT = { + A: ['01110', '10001', '10001', '11111', '10001', '10001', '10001'], + B: ['11110', '10001', '10001', '11110', '10001', '10001', '11110'], + C: ['01111', '10000', '10000', '10000', '10000', '10000', '01111'], + D: ['11110', '10001', '10001', '10001', '10001', '10001', '11110'], + E: ['11111', '10000', '10000', '11110', '10000', '10000', '11111'], + F: ['11111', '10000', '10000', '11110', '10000', '10000', '10000'], + G: ['01111', '10000', '10000', '10111', '10001', '10001', '01111'], + H: ['10001', '10001', '10001', '11111', '10001', '10001', '10001'], + I: ['11111', '00100', '00100', '00100', '00100', '00100', '11111'], + J: ['00111', '00010', '00010', '00010', '10010', '10010', '01100'], + K: ['10001', '10010', '10100', '11000', '10100', '10010', '10001'], + L: ['10000', '10000', '10000', '10000', '10000', '10000', '11111'], + M: ['10001', '11011', '10101', '10101', '10001', '10001', '10001'], + N: ['10001', '11001', '10101', '10011', '10001', '10001', '10001'], + O: ['01110', '10001', '10001', '10001', '10001', '10001', '01110'], + P: ['11110', '10001', '10001', '11110', '10000', '10000', '10000'], + Q: ['01110', '10001', '10001', '10001', '10101', '10010', '01101'], + R: ['11110', '10001', '10001', '11110', '10100', '10010', '10001'], + S: ['01111', '10000', '10000', '01110', '00001', '00001', '11110'], + T: ['11111', '00100', '00100', '00100', '00100', '00100', '00100'], + U: ['10001', '10001', '10001', '10001', '10001', '10001', '01110'], + V: ['10001', '10001', '10001', '10001', '10001', '01010', '00100'], + W: ['10001', '10001', '10001', '10101', '10101', '10101', '01010'], + X: ['10001', '10001', '01010', '00100', '01010', '10001', '10001'], + Y: ['10001', '10001', '01010', '00100', '00100', '00100', '00100'], + Z: ['11111', '00001', '00010', '00100', '01000', '10000', '11111'], + 0: ['01110', '10001', '10011', '10101', '11001', '10001', '01110'], + 1: ['00100', '01100', '00100', '00100', '00100', '00100', '01110'], + 2: ['01110', '10001', '00001', '00010', '00100', '01000', '11111'], + 3: ['11110', '00001', '00001', '01110', '00001', '00001', '11110'], + 4: ['00010', '00110', '01010', '10010', '11111', '00010', '00010'], + 5: ['11111', '10000', '10000', '11110', '00001', '00001', '11110'], + 6: ['01111', '10000', '10000', '11110', '10001', '10001', '01110'], + 7: ['11111', '00001', '00010', '00100', '01000', '01000', '01000'], + 8: ['01110', '10001', '10001', '01110', '10001', '10001', '01110'], + 9: ['01110', '10001', '10001', '01111', '00001', '00001', '11110'], + '-': ['00000', '00000', '00000', '11111', '00000', '00000', '00000'], + ':': ['00000', '00100', '00100', '00000', '00100', '00100', '00000'], + '/': ['00001', '00010', '00010', '00100', '01000', '01000', '10000'], + '.': ['00000', '00000', '00000', '00000', '00000', '01100', '01100'], + ' ': ['00000', '00000', '00000', '00000', '00000', '00000', '00000'], +}; + +function rgb(hex) { + const value = hex.replace('#', ''); + return [parseInt(value.slice(0, 2), 16), parseInt(value.slice(2, 4), 16), parseInt(value.slice(4, 6), 16)]; +} + +function createCanvas(width, height, background) { + const buffer = Buffer.alloc(width * height * 3); + fillRect(buffer, width, height, 0, 0, width, height, background); + return buffer; +} + +function fillRect(buffer, width, height, x, y, rectWidth, rectHeight, color) { + const [r, g, b] = color; + const x0 = Math.max(0, Math.floor(x)); + const y0 = Math.max(0, Math.floor(y)); + const x1 = Math.min(width, Math.ceil(x + rectWidth)); + const y1 = Math.min(height, Math.ceil(y + rectHeight)); + for (let py = y0; py < y1; py += 1) { + for (let px = x0; px < x1; px += 1) { + const offset = (py * width + px) * 3; + buffer[offset] = r; + buffer[offset + 1] = g; + buffer[offset + 2] = b; + } + } +} + +function drawText(buffer, width, height, text, x, y, scale, color) { + let cursor = x; + for (const raw of String(text).toUpperCase()) { + const glyph = FONT[raw] || FONT[' ']; + for (let row = 0; row < glyph.length; row += 1) { + for (let col = 0; col < glyph[row].length; col += 1) { + if (glyph[row][col] === '1') { + fillRect(buffer, width, height, cursor + col * scale, y + row * scale, scale, scale, color); + } + } + } + cursor += 6 * scale; + } +} + +function writePpm(framePath, buffer, width, height) { + fs.writeFileSync(framePath, Buffer.concat([Buffer.from(`P6\n${width} ${height}\n255\n`), buffer])); +} + +function renderFrame(framePath, audit, frameIndex, totalFrames) { + const width = 960; + const height = 540; + const bg = rgb('#0f172a'); + const slate = rgb('#334155'); + const ink = rgb('#0f172a'); + const paper = rgb('#f8fafc'); + const red = rgb('#dc2626'); + const amber = rgb('#ca8a04'); + const green = rgb('#16a34a'); + const buffer = createCanvas(width, height, bg); + const progress = frameIndex / Math.max(1, totalFrames - 1); + + fillRect(buffer, width, height, 46, 42, 868, 456, paper); + fillRect(buffer, width, height, 46, 42, 868, 78, rgb('#e2e8f0')); + fillRect(buffer, width, height, 76, 388, 808, 24, rgb('#cbd5e1')); + fillRect(buffer, width, height, 76, 388, 808 * progress, 24, audit.decision === 'block-release' ? red : amber); + + drawText(buffer, width, height, 'GIT LFS POINTER GUARD', 76, 72, 5, ink); + drawText(buffer, width, height, `DECISION ${audit.decision.replace('-', ' ')}`, 76, 146, 4, red); + drawText(buffer, width, height, `${audit.summary.blockers} BLOCKERS ${audit.summary.holds} HOLDS`, 76, 206, 4, slate); + drawText(buffer, width, height, 'CHECKS SHA SIZE MANIFEST QUOTA', 76, 256, 3, slate); + drawText(buffer, width, height, 'JSON MD SVG MP4 GENERATED', 76, 316, 3, green); + drawText(buffer, width, height, 'SYNTHETIC DATA ONLY', 76, 440, 3, slate); + + writePpm(framePath, buffer, width, height); +} + +function renderVideo() { + const outputDir = path.join(__dirname, 'reports'); + fs.mkdirSync(outputDir, { recursive: true }); + const { audit } = runDemo(outputDir); + const outputPath = path.join(outputDir, 'demo.mp4'); + const framesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lfs-video-')); + const frameCount = 120; + + for (let i = 0; i < frameCount; i += 1) { + renderFrame(path.join(framesDir, `frame-${String(i).padStart(3, '0')}.ppm`), audit, i, frameCount); + } + + const result = spawnSync( + 'ffmpeg', + ['-y', '-framerate', '30', '-i', path.join(framesDir, 'frame-%03d.ppm'), '-an', '-pix_fmt', 'yuv420p', outputPath], + { encoding: 'utf8' }, + ); + + fs.rmSync(framesDir, { recursive: true, force: true }); + + if (result.status !== 0) { + throw new Error(`ffmpeg failed:\n${result.stderr}`); + } + + console.log(`Video written: ${outputPath}`); + return outputPath; +} + +if (require.main === module) { + renderVideo(); +} + +module.exports = { renderVideo }; diff --git a/lfs-pointer-integrity-guard/reports/demo.mp4 b/lfs-pointer-integrity-guard/reports/demo.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..19cd502d453952c969b674e07887075f076e3116 GIT binary patch literal 25899 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 + + + + Git LFS Pointer Integrity Guard + SCIBASE.AI reproducibility-demo preprint-v2.1 + + block-release + Checked 7 LFS files: 4 blockers, 4 holds, 1 pass + data/trial-baseline.csvPASS +models/cell-segmentation.binBLOCK +results/calibration-video.movBLOCK +results/microscopy-stack.tifBLOCK +results/highres-figure.tiffBLOCK +data/supplementary-sensors.parquetHOLD +data/private-cohort-dictionary.xlsxHOLD + + Projected LFS usage: 268 MB of 260 MB + diff --git a/lfs-pointer-integrity-guard/sample-data.js b/lfs-pointer-integrity-guard/sample-data.js new file mode 100644 index 00000000..90b1c053 --- /dev/null +++ b/lfs-pointer-integrity-guard/sample-data.js @@ -0,0 +1,136 @@ +const { LFS_POINTER_VERSION } = require('./index'); + +function pointer(oid, size, version = LFS_POINTER_VERSION) { + return [`version ${version}`, `oid sha256:${oid}`, `size ${size}`].join('\n'); +} + +const OIDS = { + baselineCsv: 'a'.repeat(64), + modelWeights: 'b'.repeat(64), + calibrationVideo: 'c'.repeat(64), + microscopyStack: 'd'.repeat(64), + sensorParquet: 'e'.repeat(64), + cohortDictionary: 'f'.repeat(64), +}; + +const repository = { + name: 'SCIBASE.AI reproducibility-demo', + releaseTag: 'preprint-v2.1', + releaseDate: '2026-05-31T00:00:00Z', + retentionMinimumDays: 3650, + lfsQuotaBytes: 260 * 1024 * 1024, + pendingLfsBytes: 40 * 1024 * 1024, + files: [ + { + path: 'data/trial-baseline.csv', + storage: 'git-lfs', + pointer: pointer(OIDS.baselineCsv, 12 * 1024 * 1024), + }, + { + path: 'models/cell-segmentation.bin', + storage: 'git-lfs', + pointer: pointer(OIDS.modelWeights, 128 * 1024 * 1024), + }, + { + path: 'results/calibration-video.mov', + storage: 'git-lfs', + pointer: pointer(OIDS.calibrationVideo, 22 * 1024 * 1024), + }, + { + path: 'results/microscopy-stack.tif', + storage: 'git-lfs', + pointer: pointer(OIDS.microscopyStack, 144 * 1024 * 1024), + }, + { + path: 'results/highres-figure.tiff', + storage: 'git-lfs', + pointer: pointer('not-a-valid-sha', 'many-bytes'), + }, + { + path: 'data/supplementary-sensors.parquet', + storage: 'git-lfs', + pointer: pointer(OIDS.sensorParquet, 55 * 1024 * 1024), + }, + { + path: 'data/private-cohort-dictionary.xlsx', + storage: 'git-lfs', + pointer: pointer(OIDS.cohortDictionary, 18 * 1024 * 1024), + }, + { + path: 'manuscript/main.md', + storage: 'git', + contentSha256: '9'.repeat(64), + }, + ], + lfsObjects: [ + { + oid: OIDS.baselineCsv, + sha256: OIDS.baselineCsv, + size: 12 * 1024 * 1024, + storageClass: 'archive-10y', + expiresAt: '2037-06-01T00:00:00Z', + }, + { + oid: OIDS.modelWeights, + sha256: '1'.repeat(64), + size: 128 * 1024 * 1024, + storageClass: 'archive-10y', + expiresAt: '2037-06-01T00:00:00Z', + }, + { + oid: OIDS.calibrationVideo, + sha256: OIDS.calibrationVideo, + size: 15 * 1024 * 1024, + storageClass: 'archive-10y', + expiresAt: '2037-06-01T00:00:00Z', + }, + { + oid: OIDS.sensorParquet, + sha256: OIDS.sensorParquet, + size: 55 * 1024 * 1024, + storageClass: 'standard', + expiresAt: '2028-01-01T00:00:00Z', + }, + { + oid: OIDS.cohortDictionary, + sha256: OIDS.cohortDictionary, + size: 18 * 1024 * 1024, + storageClass: 'archive-10y', + expiresAt: '2037-06-01T00:00:00Z', + }, + ], + releaseManifest: [ + { + path: 'data/trial-baseline.csv', + oid: OIDS.baselineCsv, + size: 12 * 1024 * 1024, + doiBundleIncluded: true, + }, + { + path: 'models/cell-segmentation.bin', + oid: OIDS.modelWeights, + size: 128 * 1024 * 1024, + doiBundleIncluded: true, + }, + { + path: 'results/calibration-video.mov', + oid: OIDS.calibrationVideo, + size: 22 * 1024 * 1024, + doiBundleIncluded: true, + }, + { + path: 'results/microscopy-stack.tif', + oid: OIDS.microscopyStack, + size: 144 * 1024 * 1024, + doiBundleIncluded: true, + }, + { + path: 'data/private-cohort-dictionary.xlsx', + oid: OIDS.cohortDictionary, + size: 18 * 1024 * 1024, + doiBundleIncluded: false, + }, + ], +}; + +module.exports = { repository, OIDS, pointer }; diff --git a/lfs-pointer-integrity-guard/test.js b/lfs-pointer-integrity-guard/test.js new file mode 100644 index 00000000..15f54254 --- /dev/null +++ b/lfs-pointer-integrity-guard/test.js @@ -0,0 +1,71 @@ +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { + LFS_POINTER_VERSION, + evaluateRepository, + parseLfsPointer, + renderMarkdown, + renderSvg, + writeReports, +} = require('./index'); +const { OIDS, pointer, repository } = require('./sample-data'); + +function runTests() { + const parsed = parseLfsPointer(pointer(OIDS.baselineCsv, 1024)); + assert.strictEqual(parsed.valid, true); + assert.strictEqual(parsed.version, LFS_POINTER_VERSION); + assert.strictEqual(parsed.oid, OIDS.baselineCsv); + assert.strictEqual(parsed.size, 1024); + + const malformed = parseLfsPointer('version https://example.invalid\nsize nope\noid md5:abc'); + assert.strictEqual(malformed.valid, false); + assert(malformed.errors.includes('version-invalid')); + assert(malformed.errors.includes('oid-missing-sha256-prefix')); + assert(malformed.errors.includes('oid-invalid-sha256')); + assert(malformed.errors.includes('size-invalid')); + + const audit = evaluateRepository(repository); + assert.strictEqual(audit.decision, 'block-release'); + assert.strictEqual(audit.releaseEligible, false); + assert.strictEqual(audit.summary.lfsFilesChecked, 7); + assert(audit.summary.blockers >= 4); + assert(audit.summary.holds >= 3); + assert.strictEqual(audit.summary.passed, 1); + + const codes = new Set(audit.findings.map((finding) => finding.code)); + assert(codes.has('lfs-checksum-mismatch')); + assert(codes.has('lfs-size-mismatch')); + assert(codes.has('lfs-object-missing')); + assert(codes.has('pointer-malformed')); + assert(codes.has('release-manifest-missing')); + assert(codes.has('doi-export-coverage-missing')); + assert(codes.has('lfs-retention-too-short')); + assert(codes.has('lfs-quota-risk')); + + const markdown = renderMarkdown(audit); + assert(markdown.includes('Git LFS Pointer Integrity Guard')); + assert(markdown.includes('block-release')); + assert(markdown.includes('models/cell-segmentation.bin')); + + const svg = renderSvg(audit); + assert(svg.includes('