herdr-plugin-amq 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/blobs.mjs ADDED
@@ -0,0 +1,348 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import crypto from "node:crypto";
4
+ import { execFileSync } from "node:child_process";
5
+
6
+ // ─── Standard MIME Types Dictionary ──────────────────────────────────────────
7
+
8
+ export const MIME_TYPES = {
9
+ // Images
10
+ ".png": "image/png",
11
+ ".jpg": "image/jpeg",
12
+ ".jpeg": "image/jpeg",
13
+ ".gif": "image/gif",
14
+ ".webp": "image/webp",
15
+ ".svg": "image/svg+xml",
16
+ ".bmp": "image/bmp",
17
+ ".ico": "image/x-icon",
18
+
19
+ // Text / Logs / Source
20
+ ".log": "text/plain; charset=utf-8",
21
+ ".txt": "text/plain; charset=utf-8",
22
+ ".csv": "text/plain; charset=utf-8",
23
+ ".json": "application/json; charset=utf-8",
24
+ ".md": "text/markdown; charset=utf-8",
25
+ ".gd": "text/plain; charset=utf-8",
26
+ ".tscn": "text/plain; charset=utf-8",
27
+ ".tres": "text/plain; charset=utf-8",
28
+ ".sh": "text/plain; charset=utf-8",
29
+ ".diff": "text/plain; charset=utf-8",
30
+ ".patch": "text/plain; charset=utf-8",
31
+
32
+ // Documents / Data
33
+ ".pdf": "application/pdf",
34
+ ".zip": "application/zip",
35
+ ".tar": "application/x-tar",
36
+ ".gz": "application/gzip",
37
+ };
38
+
39
+ export function getMimeType(ext) {
40
+ const normalized = (ext || "").toLowerCase();
41
+ return MIME_TYPES[normalized] || "application/octet-stream";
42
+ }
43
+
44
+ // ─── Option A: Content-Addressed Storage (CAS) Blobstore ──────────────────────
45
+
46
+ export function getBlobsDir(amqRoot) {
47
+ const dir = path.join(amqRoot, "blobs");
48
+ if (!fs.existsSync(dir)) {
49
+ fs.mkdirSync(dir, { recursive: true });
50
+ }
51
+ return dir;
52
+ }
53
+
54
+ export function computeSha256(bufferOrString) {
55
+ return crypto.createHash("sha256").update(bufferOrString).digest("hex");
56
+ }
57
+
58
+ export function computeFileSha256(filePath) {
59
+ const fileBuffer = fs.readFileSync(filePath);
60
+ return computeSha256(fileBuffer);
61
+ }
62
+
63
+ /**
64
+ * Store a file or buffer into the content-addressed blobstore.
65
+ * Storage structure: <amqRoot>/blobs/<prefix-2-chars>/<sha256><ext>
66
+ */
67
+ export function storeBlob(input, amqRoot, originalName = "") {
68
+ if (!amqRoot) throw new Error("amqRoot is required to store blobs");
69
+
70
+ let contentBuffer;
71
+ let filename = originalName;
72
+
73
+ if (Buffer.isBuffer(input)) {
74
+ contentBuffer = input;
75
+ } else if (typeof input === "string") {
76
+ if (fs.existsSync(input)) {
77
+ contentBuffer = fs.readFileSync(input);
78
+ if (!filename) filename = path.basename(input);
79
+ } else {
80
+ contentBuffer = Buffer.from(input, "utf8");
81
+ }
82
+ } else {
83
+ throw new Error("Invalid input: expected Buffer, filePath string, or content string");
84
+ }
85
+
86
+ const sha256 = computeSha256(contentBuffer);
87
+ const ext = filename ? path.extname(filename).toLowerCase() : "";
88
+ const baseDir = getBlobsDir(amqRoot);
89
+ const shardDir = path.join(baseDir, sha256.slice(0, 2));
90
+
91
+ if (!fs.existsSync(shardDir)) {
92
+ fs.mkdirSync(shardDir, { recursive: true });
93
+ }
94
+
95
+ const targetName = `${sha256}${ext}`;
96
+ const targetPath = path.join(shardDir, targetName);
97
+
98
+ // Write blob atomically if not already stored
99
+ if (!fs.existsSync(targetPath)) {
100
+ const tmpPath = `${targetPath}.${Date.now()}.tmp`;
101
+ fs.writeFileSync(tmpPath, contentBuffer);
102
+ fs.renameSync(tmpPath, targetPath);
103
+ }
104
+
105
+ const sizeBytes = contentBuffer.length;
106
+ const isImage = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp"].includes(ext);
107
+ const isLog = [".log", ".txt", ".csv", ".json", ".out", ".diff", ".patch"].includes(ext);
108
+
109
+ return {
110
+ type: "blob",
111
+ sha256,
112
+ name: filename || `${sha256.slice(0, 10)}${ext}`,
113
+ ext,
114
+ mime: getMimeType(ext),
115
+ sizeBytes,
116
+ isImage,
117
+ isLog,
118
+ exists: true,
119
+ url: `/api/blob/${sha256}${ext ? `?ext=${encodeURIComponent(ext)}` : ""}`,
120
+ };
121
+ }
122
+
123
+ /**
124
+ * Locate an existing blob by its 64-char SHA256 hex string.
125
+ * Strictly verifies hash syntax to prevent path traversal.
126
+ */
127
+ export function getBlob(sha256, amqRoot) {
128
+ if (!sha256 || !amqRoot) return null;
129
+ const cleanHash = sha256.trim().toLowerCase();
130
+ if (!/^[a-f0-9]{64}$/.test(cleanHash)) {
131
+ return null;
132
+ }
133
+
134
+ const shardDir = path.join(getBlobsDir(amqRoot), cleanHash.slice(0, 2));
135
+ if (!fs.existsSync(shardDir)) return null;
136
+
137
+ try {
138
+ const entries = fs.readdirSync(shardDir);
139
+ const match = entries.find((file) => file.startsWith(cleanHash));
140
+ if (!match) return null;
141
+
142
+ const fullPath = path.join(shardDir, match);
143
+ const stat = fs.statSync(fullPath);
144
+ if (!stat.isFile()) return null;
145
+
146
+ const ext = path.extname(match).toLowerCase();
147
+ return {
148
+ filePath: fullPath,
149
+ sha256: cleanHash,
150
+ name: match,
151
+ ext,
152
+ mime: getMimeType(ext),
153
+ sizeBytes: stat.size,
154
+ };
155
+ } catch {
156
+ return null;
157
+ }
158
+ }
159
+
160
+ // ─── Option B: Git Commit & Object Pinning ────────────────────────────────────
161
+
162
+ /**
163
+ * Pin a repository file to a specific commit or HEAD.
164
+ * Returns immutable git ref descriptor.
165
+ */
166
+ export function pinGitRef(repoRoot, relativePath, commit = "HEAD") {
167
+ if (!repoRoot || !relativePath) return null;
168
+
169
+ try {
170
+ const cleanRel = relativePath.replace(/^[/\\]+/, "");
171
+ // Resolve commit SHA
172
+ const commitSha = execFileSync("git", ["rev-parse", commit], {
173
+ cwd: repoRoot,
174
+ encoding: "utf8",
175
+ stdio: ["ignore", "pipe", "ignore"],
176
+ }).trim();
177
+
178
+ // Resolve git blob hash
179
+ const blobSha = execFileSync("git", ["rev-parse", `${commitSha}:${cleanRel}`], {
180
+ cwd: repoRoot,
181
+ encoding: "utf8",
182
+ stdio: ["ignore", "pipe", "ignore"],
183
+ }).trim();
184
+
185
+ // Get blob size
186
+ const sizeStr = execFileSync("git", ["cat-file", "-s", blobSha], {
187
+ cwd: repoRoot,
188
+ encoding: "utf8",
189
+ stdio: ["ignore", "pipe", "ignore"],
190
+ }).trim();
191
+ const sizeBytes = parseInt(sizeStr, 10) || 0;
192
+
193
+ const ext = path.extname(cleanRel).toLowerCase();
194
+ const isImage = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp"].includes(ext);
195
+ const isLog = [".log", ".txt", ".csv", ".json", ".out", ".diff", ".patch"].includes(ext);
196
+
197
+ return {
198
+ type: "git",
199
+ commit: commitSha,
200
+ shortCommit: commitSha.slice(0, 10),
201
+ blob: blobSha,
202
+ path: cleanRel,
203
+ name: path.basename(cleanRel),
204
+ ext,
205
+ mime: getMimeType(ext),
206
+ sizeBytes,
207
+ isImage,
208
+ isLog,
209
+ exists: true,
210
+ url: `/api/git-file?commit=${commitSha}&path=${encodeURIComponent(cleanRel)}`,
211
+ };
212
+ } catch {
213
+ return null;
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Read file content strictly from git object database.
219
+ */
220
+ export function readGitRef(repoRoot, commitSha, relativePath) {
221
+ if (!repoRoot || !commitSha || !relativePath) return null;
222
+
223
+ // Validate commit SHA syntax
224
+ if (!/^[a-f0-9]{7,40}$/i.test(commitSha.trim())) {
225
+ return null;
226
+ }
227
+
228
+ // Reject path traversal tokens
229
+ const cleanRel = relativePath.replace(/^[/\\]+/, "");
230
+ if (cleanRel.includes("..") || path.isAbsolute(cleanRel)) {
231
+ return null;
232
+ }
233
+
234
+ try {
235
+ const buffer = execFileSync("git", ["show", `${commitSha}:${cleanRel}`], {
236
+ cwd: repoRoot,
237
+ stdio: ["ignore", "pipe", "ignore"],
238
+ maxBuffer: 50 * 1024 * 1024, // 50MB limit
239
+ });
240
+
241
+ const ext = path.extname(cleanRel).toLowerCase();
242
+ return {
243
+ buffer,
244
+ sizeBytes: buffer.length,
245
+ mime: getMimeType(ext),
246
+ ext,
247
+ name: path.basename(cleanRel),
248
+ };
249
+ } catch {
250
+ return null;
251
+ }
252
+ }
253
+
254
+ // ─── Unified Attachment Ingestion (Hybrid Option A + B) ───────────────────────
255
+
256
+ /**
257
+ * Ingest or resolve an attachment candidate safely.
258
+ * - Ephemeral files (like /tmp/*) are automatically copied into the CAS blobstore.
259
+ * - Committed files in repoRoot can be pinned to git HEAD.
260
+ * - Already pinned/stored items retain their immutable URLs.
261
+ */
262
+ export function ingestAttachment(candidate, amqRoot, repoRoot) {
263
+ if (!candidate) return null;
264
+
265
+ // Already a structured descriptor
266
+ if (typeof candidate === "object") {
267
+ if (candidate.type === "blob" && candidate.sha256) {
268
+ const stored = getBlob(candidate.sha256, amqRoot);
269
+ if (stored) {
270
+ return {
271
+ ...candidate,
272
+ exists: true,
273
+ sizeBytes: stored.sizeBytes,
274
+ url: `/api/blob/${candidate.sha256}${candidate.ext ? `?ext=${encodeURIComponent(candidate.ext)}` : ""}`,
275
+ };
276
+ }
277
+ }
278
+
279
+ if (candidate.type === "git" && candidate.commit && candidate.path) {
280
+ return {
281
+ ...candidate,
282
+ exists: true,
283
+ url: `/api/git-file?commit=${candidate.commit}&path=${encodeURIComponent(candidate.path)}`,
284
+ };
285
+ }
286
+
287
+ if (candidate.path) {
288
+ return ingestAttachment(candidate.path, amqRoot, repoRoot);
289
+ }
290
+ }
291
+
292
+ // String path or reference
293
+ if (typeof candidate === "string") {
294
+ const raw = candidate.trim();
295
+ if (!raw) return null;
296
+
297
+ // Check if it's already a blob hash reference: blob:<sha256> or <sha256>
298
+ const hashMatch = raw.match(/^(?:blob:)?([a-f0-9]{64})$/i);
299
+ if (hashMatch) {
300
+ const stored = getBlob(hashMatch[1], amqRoot);
301
+ if (stored) {
302
+ return {
303
+ type: "blob",
304
+ sha256: stored.sha256,
305
+ name: stored.name,
306
+ ext: stored.ext,
307
+ mime: stored.mime,
308
+ sizeBytes: stored.sizeBytes,
309
+ exists: true,
310
+ url: `/api/blob/${stored.sha256}${stored.ext ? `?ext=${encodeURIComponent(stored.ext)}` : ""}`,
311
+ };
312
+ }
313
+ }
314
+
315
+ // Check if file exists on disk
316
+ let diskPath = null;
317
+ if (fs.existsSync(raw) && fs.statSync(raw).isFile()) {
318
+ diskPath = path.resolve(raw);
319
+ } else if (repoRoot) {
320
+ const candidateInRepo = path.resolve(repoRoot, raw);
321
+ if (fs.existsSync(candidateInRepo) && fs.statSync(candidateInRepo).isFile()) {
322
+ diskPath = candidateInRepo;
323
+ }
324
+ }
325
+
326
+ if (diskPath && amqRoot) {
327
+ // Is it an ephemeral file (e.g. in /tmp, /var/tmp, or scratch)?
328
+ const isEphemeral = diskPath.startsWith("/tmp") || diskPath.includes("/scratch/");
329
+
330
+ if (isEphemeral) {
331
+ // Freeze it forever into the AMQ blobstore!
332
+ return storeBlob(diskPath, amqRoot, path.basename(diskPath));
333
+ }
334
+
335
+ // If it's inside repoRoot and git is present, try pinning to git
336
+ if (repoRoot && diskPath.startsWith(repoRoot)) {
337
+ const rel = path.relative(repoRoot, diskPath);
338
+ const gitRef = pinGitRef(repoRoot, rel, "HEAD");
339
+ if (gitRef) return gitRef;
340
+ }
341
+
342
+ // Default fallback for existing files: store into blobstore for guarantee
343
+ return storeBlob(diskPath, amqRoot, path.basename(diskPath));
344
+ }
345
+ }
346
+
347
+ return null;
348
+ }