local.js (3400B)
1 // src/lib/storage/local.js - filesystem object storage 2 // 3 // The default backend, used whenever no S3 bucket is configured. Objects are 4 // plain files under storage.path, so artifacts remain inspectable with 5 // ordinary tools and a single node install needs nothing else running. 6 7 import fs from 'node:fs'; 8 import fsp from 'node:fs/promises'; 9 import path from 'node:path'; 10 import crypto from 'node:crypto'; 11 import { pipeline } from 'node:stream/promises'; 12 import { Readable } from 'node:stream'; 13 import { StorageNotFound, assertKey } from './key.js'; 14 15 export function createLocalStorage(cfg) { 16 const root = cfg.storage.path; 17 18 function resolve(key) { 19 assertKey(key); 20 const full = path.resolve(root, key); 21 // Defence in depth: assertKey already rejects traversal, but a symlinked 22 // storage root should not become an escape either. 23 if (full !== root && !full.startsWith(root + path.sep)) { 24 throw new Error(`storage key escapes the storage root: ${key}`); 25 } 26 return full; 27 } 28 29 return { 30 driver: 'local', 31 32 async put(key, body, opts = {}) { 33 const full = resolve(key); 34 await fsp.mkdir(path.dirname(full), { recursive: true }); 35 36 // Write to a sibling temp file and rename, so a reader never observes a 37 // partially written object. 38 const tmp = `${full}.${crypto.randomBytes(6).toString('hex')}.part`; 39 const hash = crypto.createHash('sha256'); 40 let size = 0; 41 42 try { 43 if (Buffer.isBuffer(body) || typeof body === 'string') { 44 const buf = Buffer.isBuffer(body) ? body : Buffer.from(body, 'utf8'); 45 hash.update(buf); 46 size = buf.length; 47 await fsp.writeFile(tmp, buf); 48 } else { 49 const source = body instanceof Readable ? body : Readable.from(body); 50 const out = fs.createWriteStream(tmp); 51 source.on('data', (chunk) => { 52 hash.update(chunk); 53 size += chunk.length; 54 }); 55 await pipeline(source, out); 56 } 57 await fsp.rename(tmp, full); 58 } catch (e) { 59 await fsp.rm(tmp, { force: true }); 60 throw e; 61 } 62 63 if (opts.size !== undefined && opts.size !== size) { 64 await fsp.rm(full, { force: true }); 65 throw new Error(`storage put size mismatch for ${key}: declared ${opts.size}, wrote ${size}`); 66 } 67 68 return { key, size, sha256: hash.digest('hex') }; 69 }, 70 71 async get(key, opts = {}) { 72 const full = resolve(key); 73 let stat; 74 try { 75 stat = await fsp.stat(full); 76 } catch (e) { 77 if (e.code === 'ENOENT') throw new StorageNotFound(key); 78 throw e; 79 } 80 81 const range = opts.range; 82 const start = range?.start ?? 0; 83 const end = range?.end ?? undefined; 84 85 return { 86 key, 87 size: stat.size, 88 stream: fs.createReadStream(full, end === undefined ? { start } : { start, end }), 89 }; 90 }, 91 92 async head(key) { 93 try { 94 const stat = await fsp.stat(resolve(key)); 95 return { key, size: stat.size, modified: stat.mtimeMs }; 96 } catch (e) { 97 if (e.code === 'ENOENT') return null; 98 throw e; 99 } 100 }, 101 102 async delete(key) { 103 await fsp.rm(resolve(key), { force: true }); 104 }, 105 106 // Local files cannot be handed out directly; callers stream them instead. 107 async presign() { 108 return null; 109 }, 110 }; 111 }