1'use strict' 2 3const crypto = require('crypto') 4const { 5 appendFile, 6 mkdir, 7 readFile, 8 readdir, 9 rm, 10 writeFile, 11} = require('fs/promises') 12const { Minipass } = require('minipass') 13const path = require('path') 14const ssri = require('ssri') 15const uniqueFilename = require('unique-filename') 16 17const contentPath = require('./content/path') 18const hashToSegments = require('./util/hash-to-segments') 19const indexV = require('../package.json')['cache-version'].index 20const { moveFile } = require('@npmcli/fs') 21 22module.exports.NotFoundError = class NotFoundError extends Error { 23 constructor (cache, key) { 24 super(`No cache entry for ${key} found in ${cache}`) 25 this.code = 'ENOENT' 26 this.cache = cache 27 this.key = key 28 } 29} 30 31module.exports.compact = compact 32 33async function compact (cache, key, matchFn, opts = {}) { 34 const bucket = bucketPath(cache, key) 35 const entries = await bucketEntries(bucket) 36 const newEntries = [] 37 // we loop backwards because the bottom-most result is the newest 38 // since we add new entries with appendFile 39 for (let i = entries.length - 1; i >= 0; --i) { 40 const entry = entries[i] 41 // a null integrity could mean either a delete was appended 42 // or the user has simply stored an index that does not map 43 // to any content. we determine if the user wants to keep the 44 // null integrity based on the validateEntry function passed in options. 45 // if the integrity is null and no validateEntry is provided, we break 46 // as we consider the null integrity to be a deletion of everything 47 // that came before it. 48 if (entry.integrity === null && !opts.validateEntry) { 49 break 50 } 51 52 // if this entry is valid, and it is either the first entry or 53 // the newEntries array doesn't already include an entry that 54 // matches this one based on the provided matchFn, then we add 55 // it to the beginning of our list 56 if ((!opts.validateEntry || opts.validateEntry(entry) === true) && 57 (newEntries.length === 0 || 58 !newEntries.find((oldEntry) => matchFn(oldEntry, entry)))) { 59 newEntries.unshift(entry) 60 } 61 } 62 63 const newIndex = '\n' + newEntries.map((entry) => { 64 const stringified = JSON.stringify(entry) 65 const hash = hashEntry(stringified) 66 return `${hash}\t${stringified}` 67 }).join('\n') 68 69 const setup = async () => { 70 const target = uniqueFilename(path.join(cache, 'tmp'), opts.tmpPrefix) 71 await mkdir(path.dirname(target), { recursive: true }) 72 return { 73 target, 74 moved: false, 75 } 76 } 77 78 const teardown = async (tmp) => { 79 if (!tmp.moved) { 80 return rm(tmp.target, { recursive: true, force: true }) 81 } 82 } 83 84 const write = async (tmp) => { 85 await writeFile(tmp.target, newIndex, { flag: 'wx' }) 86 await mkdir(path.dirname(bucket), { recursive: true }) 87 // we use @npmcli/move-file directly here because we 88 // want to overwrite the existing file 89 await moveFile(tmp.target, bucket) 90 tmp.moved = true 91 } 92 93 // write the file atomically 94 const tmp = await setup() 95 try { 96 await write(tmp) 97 } finally { 98 await teardown(tmp) 99 } 100 101 // we reverse the list we generated such that the newest 102 // entries come first in order to make looping through them easier 103 // the true passed to formatEntry tells it to keep null 104 // integrity values, if they made it this far it's because 105 // validateEntry returned true, and as such we should return it 106 return newEntries.reverse().map((entry) => formatEntry(cache, entry, true)) 107} 108 109module.exports.insert = insert 110 111async function insert (cache, key, integrity, opts = {}) { 112 const { metadata, size, time } = opts 113 const bucket = bucketPath(cache, key) 114 const entry = { 115 key, 116 integrity: integrity && ssri.stringify(integrity), 117 time: time || Date.now(), 118 size, 119 metadata, 120 } 121 try { 122 await mkdir(path.dirname(bucket), { recursive: true }) 123 const stringified = JSON.stringify(entry) 124 // NOTE - Cleverness ahoy! 125 // 126 // This works because it's tremendously unlikely for an entry to corrupt 127 // another while still preserving the string length of the JSON in 128 // question. So, we just slap the length in there and verify it on read. 129 // 130 // Thanks to @isaacs for the whiteboarding session that ended up with 131 // this. 132 await appendFile(bucket, `\n${hashEntry(stringified)}\t${stringified}`) 133 } catch (err) { 134 if (err.code === 'ENOENT') { 135 return undefined 136 } 137 138 throw err 139 } 140 return formatEntry(cache, entry) 141} 142 143module.exports.find = find 144 145async function find (cache, key) { 146 const bucket = bucketPath(cache, key) 147 try { 148 const entries = await bucketEntries(bucket) 149 return entries.reduce((latest, next) => { 150 if (next && next.key === key) { 151 return formatEntry(cache, next) 152 } else { 153 return latest 154 } 155 }, null) 156 } catch (err) { 157 if (err.code === 'ENOENT') { 158 return null 159 } else { 160 throw err 161 } 162 } 163} 164 165module.exports.delete = del 166 167function del (cache, key, opts = {}) { 168 if (!opts.removeFully) { 169 return insert(cache, key, null, opts) 170 } 171 172 const bucket = bucketPath(cache, key) 173 return rm(bucket, { recursive: true, force: true }) 174} 175 176module.exports.lsStream = lsStream 177 178function lsStream (cache) { 179 const indexDir = bucketDir(cache) 180 const stream = new Minipass({ objectMode: true }) 181 182 // Set all this up to run on the stream and then just return the stream 183 Promise.resolve().then(async () => { 184 const buckets = await readdirOrEmpty(indexDir) 185 await Promise.all(buckets.map(async (bucket) => { 186 const bucketPath = path.join(indexDir, bucket) 187 const subbuckets = await readdirOrEmpty(bucketPath) 188 await Promise.all(subbuckets.map(async (subbucket) => { 189 const subbucketPath = path.join(bucketPath, subbucket) 190 191 // "/cachename/<bucket 0xFF>/<bucket 0xFF>./*" 192 const subbucketEntries = await readdirOrEmpty(subbucketPath) 193 await Promise.all(subbucketEntries.map(async (entry) => { 194 const entryPath = path.join(subbucketPath, entry) 195 try { 196 const entries = await bucketEntries(entryPath) 197 // using a Map here prevents duplicate keys from showing up 198 // twice, I guess? 199 const reduced = entries.reduce((acc, entry) => { 200 acc.set(entry.key, entry) 201 return acc 202 }, new Map()) 203 // reduced is a map of key => entry 204 for (const entry of reduced.values()) { 205 const formatted = formatEntry(cache, entry) 206 if (formatted) { 207 stream.write(formatted) 208 } 209 } 210 } catch (err) { 211 if (err.code === 'ENOENT') { 212 return undefined 213 } 214 throw err 215 } 216 })) 217 })) 218 })) 219 stream.end() 220 return stream 221 }).catch(err => stream.emit('error', err)) 222 223 return stream 224} 225 226module.exports.ls = ls 227 228async function ls (cache) { 229 const entries = await lsStream(cache).collect() 230 return entries.reduce((acc, xs) => { 231 acc[xs.key] = xs 232 return acc 233 }, {}) 234} 235 236module.exports.bucketEntries = bucketEntries 237 238async function bucketEntries (bucket, filter) { 239 const data = await readFile(bucket, 'utf8') 240 return _bucketEntries(data, filter) 241} 242 243function _bucketEntries (data, filter) { 244 const entries = [] 245 data.split('\n').forEach((entry) => { 246 if (!entry) { 247 return 248 } 249 250 const pieces = entry.split('\t') 251 if (!pieces[1] || hashEntry(pieces[1]) !== pieces[0]) { 252 // Hash is no good! Corruption or malice? Doesn't matter! 253 // EJECT EJECT 254 return 255 } 256 let obj 257 try { 258 obj = JSON.parse(pieces[1]) 259 } catch (_) { 260 // eslint-ignore-next-line no-empty-block 261 } 262 // coverage disabled here, no need to test with an entry that parses to something falsey 263 // istanbul ignore else 264 if (obj) { 265 entries.push(obj) 266 } 267 }) 268 return entries 269} 270 271module.exports.bucketDir = bucketDir 272 273function bucketDir (cache) { 274 return path.join(cache, `index-v${indexV}`) 275} 276 277module.exports.bucketPath = bucketPath 278 279function bucketPath (cache, key) { 280 const hashed = hashKey(key) 281 return path.join.apply( 282 path, 283 [bucketDir(cache)].concat(hashToSegments(hashed)) 284 ) 285} 286 287module.exports.hashKey = hashKey 288 289function hashKey (key) { 290 return hash(key, 'sha256') 291} 292 293module.exports.hashEntry = hashEntry 294 295function hashEntry (str) { 296 return hash(str, 'sha1') 297} 298 299function hash (str, digest) { 300 return crypto 301 .createHash(digest) 302 .update(str) 303 .digest('hex') 304} 305 306function formatEntry (cache, entry, keepAll) { 307 // Treat null digests as deletions. They'll shadow any previous entries. 308 if (!entry.integrity && !keepAll) { 309 return null 310 } 311 312 return { 313 key: entry.key, 314 integrity: entry.integrity, 315 path: entry.integrity ? contentPath(cache, entry.integrity) : undefined, 316 size: entry.size, 317 time: entry.time, 318 metadata: entry.metadata, 319 } 320} 321 322function readdirOrEmpty (dir) { 323 return readdir(dir).catch((err) => { 324 if (err.code === 'ENOENT' || err.code === 'ENOTDIR') { 325 return [] 326 } 327 328 throw err 329 }) 330} 331