226 lines
		
	
	
		
			5.7 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			226 lines
		
	
	
		
			5.7 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| 'use strict'
 | |
| 
 | |
| const BB = require('bluebird')
 | |
| 
 | |
| const contentPath = require('./content/path')
 | |
| const crypto = require('crypto')
 | |
| const fixOwner = require('./util/fix-owner')
 | |
| const fs = require('graceful-fs')
 | |
| const hashToSegments = require('./util/hash-to-segments')
 | |
| const ms = require('mississippi')
 | |
| const path = require('path')
 | |
| const ssri = require('ssri')
 | |
| const Y = require('./util/y.js')
 | |
| 
 | |
| const indexV = require('../package.json')['cache-version'].index
 | |
| 
 | |
| const appendFileAsync = BB.promisify(fs.appendFile)
 | |
| const readFileAsync = BB.promisify(fs.readFile)
 | |
| const readdirAsync = BB.promisify(fs.readdir)
 | |
| const concat = ms.concat
 | |
| const from = ms.from
 | |
| 
 | |
| module.exports.NotFoundError = class NotFoundError extends Error {
 | |
|   constructor (cache, key) {
 | |
|     super(Y`No cache entry for \`${key}\` found in \`${cache}\``)
 | |
|     this.code = 'ENOENT'
 | |
|     this.cache = cache
 | |
|     this.key = key
 | |
|   }
 | |
| }
 | |
| 
 | |
| module.exports.insert = insert
 | |
| function insert (cache, key, integrity, opts) {
 | |
|   opts = opts || {}
 | |
|   const bucket = bucketPath(cache, key)
 | |
|   const entry = {
 | |
|     key,
 | |
|     integrity: integrity && ssri.stringify(integrity),
 | |
|     time: Date.now(),
 | |
|     size: opts.size,
 | |
|     metadata: opts.metadata
 | |
|   }
 | |
|   return fixOwner.mkdirfix(
 | |
|     path.dirname(bucket), opts.uid, opts.gid
 | |
|   ).then(() => {
 | |
|     const stringified = JSON.stringify(entry)
 | |
|     // NOTE - Cleverness ahoy!
 | |
|     //
 | |
|     // This works because it's tremendously unlikely for an entry to corrupt
 | |
|     // another while still preserving the string length of the JSON in
 | |
|     // question. So, we just slap the length in there and verify it on read.
 | |
|     //
 | |
|     // Thanks to @isaacs for the whiteboarding session that ended up with this.
 | |
|     return appendFileAsync(
 | |
|       bucket, `\n${hashEntry(stringified)}\t${stringified}`
 | |
|     )
 | |
|   }).then(
 | |
|     () => fixOwner.chownr(bucket, opts.uid, opts.gid)
 | |
|   ).catch({code: 'ENOENT'}, () => {
 | |
|     // There's a class of race conditions that happen when things get deleted
 | |
|     // during fixOwner, or between the two mkdirfix/chownr calls.
 | |
|     //
 | |
|     // It's perfectly fine to just not bother in those cases and lie
 | |
|     // that the index entry was written. Because it's a cache.
 | |
|   }).then(() => {
 | |
|     return formatEntry(cache, entry)
 | |
|   })
 | |
| }
 | |
| 
 | |
| module.exports.find = find
 | |
| function find (cache, key) {
 | |
|   const bucket = bucketPath(cache, key)
 | |
|   return bucketEntries(cache, bucket).then(entries => {
 | |
|     return entries.reduce((latest, next) => {
 | |
|       if (next && next.key === key) {
 | |
|         return formatEntry(cache, next)
 | |
|       } else {
 | |
|         return latest
 | |
|       }
 | |
|     }, null)
 | |
|   }).catch(err => {
 | |
|     if (err.code === 'ENOENT') {
 | |
|       return null
 | |
|     } else {
 | |
|       throw err
 | |
|     }
 | |
|   })
 | |
| }
 | |
| 
 | |
| module.exports.delete = del
 | |
| function del (cache, key, opts) {
 | |
|   return insert(cache, key, null, opts)
 | |
| }
 | |
| 
 | |
| module.exports.lsStream = lsStream
 | |
| function lsStream (cache) {
 | |
|   const indexDir = bucketDir(cache)
 | |
|   const stream = from.obj()
 | |
| 
 | |
|   // "/cachename/*"
 | |
|   readdirOrEmpty(indexDir).map(bucket => {
 | |
|     const bucketPath = path.join(indexDir, bucket)
 | |
| 
 | |
|     // "/cachename/<bucket 0xFF>/*"
 | |
|     return readdirOrEmpty(bucketPath).map(subbucket => {
 | |
|       const subbucketPath = path.join(bucketPath, subbucket)
 | |
| 
 | |
|       // "/cachename/<bucket 0xFF>/<bucket 0xFF>/*"
 | |
|       return readdirOrEmpty(subbucketPath).map(entry => {
 | |
|         const getKeyToEntry = bucketEntries(
 | |
|           cache,
 | |
|           path.join(subbucketPath, entry)
 | |
|         ).reduce((acc, entry) => {
 | |
|           acc.set(entry.key, entry)
 | |
|           return acc
 | |
|         }, new Map())
 | |
| 
 | |
|         return getKeyToEntry.then(reduced => {
 | |
|           for (let entry of reduced.values()) {
 | |
|             const formatted = formatEntry(cache, entry)
 | |
|             formatted && stream.push(formatted)
 | |
|           }
 | |
|         }).catch({code: 'ENOENT'}, nop)
 | |
|       })
 | |
|     })
 | |
|   }).then(() => {
 | |
|     stream.push(null)
 | |
|   }, err => {
 | |
|     stream.emit('error', err)
 | |
|   })
 | |
| 
 | |
|   return stream
 | |
| }
 | |
| 
 | |
| module.exports.ls = ls
 | |
| function ls (cache) {
 | |
|   return BB.fromNode(cb => {
 | |
|     lsStream(cache).on('error', cb).pipe(concat(entries => {
 | |
|       cb(null, entries.reduce((acc, xs) => {
 | |
|         acc[xs.key] = xs
 | |
|         return acc
 | |
|       }, {}))
 | |
|     }))
 | |
|   })
 | |
| }
 | |
| 
 | |
| function bucketEntries (cache, bucket, filter) {
 | |
|   return readFileAsync(
 | |
|     bucket, 'utf8'
 | |
|   ).then(data => {
 | |
|     let entries = []
 | |
|     data.split('\n').forEach(entry => {
 | |
|       if (!entry) { return }
 | |
|       const pieces = entry.split('\t')
 | |
|       if (!pieces[1] || hashEntry(pieces[1]) !== pieces[0]) {
 | |
|         // Hash is no good! Corruption or malice? Doesn't matter!
 | |
|         // EJECT EJECT
 | |
|         return
 | |
|       }
 | |
|       let obj
 | |
|       try {
 | |
|         obj = JSON.parse(pieces[1])
 | |
|       } catch (e) {
 | |
|         // Entry is corrupted!
 | |
|         return
 | |
|       }
 | |
|       if (obj) {
 | |
|         entries.push(obj)
 | |
|       }
 | |
|     })
 | |
|     return entries
 | |
|   })
 | |
| }
 | |
| 
 | |
| module.exports._bucketDir = bucketDir
 | |
| function bucketDir (cache) {
 | |
|   return path.join(cache, `index-v${indexV}`)
 | |
| }
 | |
| 
 | |
| module.exports._bucketPath = bucketPath
 | |
| function bucketPath (cache, key) {
 | |
|   const hashed = hashKey(key)
 | |
|   return path.join.apply(path, [bucketDir(cache)].concat(
 | |
|     hashToSegments(hashed)
 | |
|   ))
 | |
| }
 | |
| 
 | |
| module.exports._hashKey = hashKey
 | |
| function hashKey (key) {
 | |
|   return hash(key, 'sha256')
 | |
| }
 | |
| 
 | |
| module.exports._hashEntry = hashEntry
 | |
| function hashEntry (str) {
 | |
|   return hash(str, 'sha1')
 | |
| }
 | |
| 
 | |
| function hash (str, digest) {
 | |
|   return crypto
 | |
|   .createHash(digest)
 | |
|   .update(str)
 | |
|   .digest('hex')
 | |
| }
 | |
| 
 | |
| function formatEntry (cache, entry) {
 | |
|   // Treat null digests as deletions. They'll shadow any previous entries.
 | |
|   if (!entry.integrity) { return null }
 | |
|   return {
 | |
|     key: entry.key,
 | |
|     integrity: entry.integrity,
 | |
|     path: contentPath(cache, entry.integrity),
 | |
|     size: entry.size,
 | |
|     time: entry.time,
 | |
|     metadata: entry.metadata
 | |
|   }
 | |
| }
 | |
| 
 | |
| function readdirOrEmpty (dir) {
 | |
|   return readdirAsync(dir)
 | |
|   .catch({code: 'ENOENT'}, () => [])
 | |
|   .catch({code: 'ENOTDIR'}, () => [])
 | |
| }
 | |
| 
 | |
| function nop () {
 | |
| }
 |