conductor

CI task system
git clone git://git.finwo.net/app/conductor
Log | Files | Refs | README | LICENSE

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 }