entry-index.js 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225
  1. 'use strict'
  2. const BB = require('bluebird')
  3. const contentPath = require('./content/path')
  4. const crypto = require('crypto')
  5. const fixOwner = require('./util/fix-owner')
  6. const fs = require('graceful-fs')
  7. const hashToSegments = require('./util/hash-to-segments')
  8. const ms = require('mississippi')
  9. const path = require('path')
  10. const ssri = require('ssri')
  11. const Y = require('./util/y.js')
  12. const indexV = require('../package.json')['cache-version'].index
  13. const appendFileAsync = BB.promisify(fs.appendFile)
  14. const readFileAsync = BB.promisify(fs.readFile)
  15. const readdirAsync = BB.promisify(fs.readdir)
  16. const concat = ms.concat
  17. const from = ms.from
  18. module.exports.NotFoundError = class NotFoundError extends Error {
  19. constructor (cache, key) {
  20. super(Y`No cache entry for \`${key}\` found in \`${cache}\``)
  21. this.code = 'ENOENT'
  22. this.cache = cache
  23. this.key = key
  24. }
  25. }
  26. module.exports.insert = insert
  27. function insert (cache, key, integrity, opts) {
  28. opts = opts || {}
  29. const bucket = bucketPath(cache, key)
  30. const entry = {
  31. key,
  32. integrity: integrity && ssri.stringify(integrity),
  33. time: Date.now(),
  34. size: opts.size,
  35. metadata: opts.metadata
  36. }
  37. return fixOwner.mkdirfix(
  38. path.dirname(bucket), opts.uid, opts.gid
  39. ).then(() => {
  40. const stringified = JSON.stringify(entry)
  41. // NOTE - Cleverness ahoy!
  42. //
  43. // This works because it's tremendously unlikely for an entry to corrupt
  44. // another while still preserving the string length of the JSON in
  45. // question. So, we just slap the length in there and verify it on read.
  46. //
  47. // Thanks to @isaacs for the whiteboarding session that ended up with this.
  48. return appendFileAsync(
  49. bucket, `\n${hashEntry(stringified)}\t${stringified}`
  50. )
  51. }).then(
  52. () => fixOwner.chownr(bucket, opts.uid, opts.gid)
  53. ).catch({code: 'ENOENT'}, () => {
  54. // There's a class of race conditions that happen when things get deleted
  55. // during fixOwner, or between the two mkdirfix/chownr calls.
  56. //
  57. // It's perfectly fine to just not bother in those cases and lie
  58. // that the index entry was written. Because it's a cache.
  59. }).then(() => {
  60. return formatEntry(cache, entry)
  61. })
  62. }
  63. module.exports.find = find
  64. function find (cache, key) {
  65. const bucket = bucketPath(cache, key)
  66. return bucketEntries(cache, bucket).then(entries => {
  67. return entries.reduce((latest, next) => {
  68. if (next && next.key === key) {
  69. return formatEntry(cache, next)
  70. } else {
  71. return latest
  72. }
  73. }, null)
  74. }).catch(err => {
  75. if (err.code === 'ENOENT') {
  76. return null
  77. } else {
  78. throw err
  79. }
  80. })
  81. }
  82. module.exports.delete = del
  83. function del (cache, key, opts) {
  84. return insert(cache, key, null, opts)
  85. }
  86. module.exports.lsStream = lsStream
  87. function lsStream (cache) {
  88. const indexDir = bucketDir(cache)
  89. const stream = from.obj()
  90. // "/cachename/*"
  91. readdirOrEmpty(indexDir).map(bucket => {
  92. const bucketPath = path.join(indexDir, bucket)
  93. // "/cachename/<bucket 0xFF>/*"
  94. return readdirOrEmpty(bucketPath).map(subbucket => {
  95. const subbucketPath = path.join(bucketPath, subbucket)
  96. // "/cachename/<bucket 0xFF>/<bucket 0xFF>/*"
  97. return readdirOrEmpty(subbucketPath).map(entry => {
  98. const getKeyToEntry = bucketEntries(
  99. cache,
  100. path.join(subbucketPath, entry)
  101. ).reduce((acc, entry) => {
  102. acc.set(entry.key, entry)
  103. return acc
  104. }, new Map())
  105. return getKeyToEntry.then(reduced => {
  106. for (let entry of reduced.values()) {
  107. const formatted = formatEntry(cache, entry)
  108. formatted && stream.push(formatted)
  109. }
  110. }).catch({code: 'ENOENT'}, nop)
  111. })
  112. })
  113. }).then(() => {
  114. stream.push(null)
  115. }, err => {
  116. stream.emit('error', err)
  117. })
  118. return stream
  119. }
  120. module.exports.ls = ls
  121. function ls (cache) {
  122. return BB.fromNode(cb => {
  123. lsStream(cache).on('error', cb).pipe(concat(entries => {
  124. cb(null, entries.reduce((acc, xs) => {
  125. acc[xs.key] = xs
  126. return acc
  127. }, {}))
  128. }))
  129. })
  130. }
  131. function bucketEntries (cache, bucket, filter) {
  132. return readFileAsync(
  133. bucket, 'utf8'
  134. ).then(data => {
  135. let entries = []
  136. data.split('\n').forEach(entry => {
  137. if (!entry) { return }
  138. const pieces = entry.split('\t')
  139. if (!pieces[1] || hashEntry(pieces[1]) !== pieces[0]) {
  140. // Hash is no good! Corruption or malice? Doesn't matter!
  141. // EJECT EJECT
  142. return
  143. }
  144. let obj
  145. try {
  146. obj = JSON.parse(pieces[1])
  147. } catch (e) {
  148. // Entry is corrupted!
  149. return
  150. }
  151. if (obj) {
  152. entries.push(obj)
  153. }
  154. })
  155. return entries
  156. })
  157. }
  158. module.exports._bucketDir = bucketDir
  159. function bucketDir (cache) {
  160. return path.join(cache, `index-v${indexV}`)
  161. }
  162. module.exports._bucketPath = bucketPath
  163. function bucketPath (cache, key) {
  164. const hashed = hashKey(key)
  165. return path.join.apply(path, [bucketDir(cache)].concat(
  166. hashToSegments(hashed)
  167. ))
  168. }
  169. module.exports._hashKey = hashKey
  170. function hashKey (key) {
  171. return hash(key, 'sha256')
  172. }
  173. module.exports._hashEntry = hashEntry
  174. function hashEntry (str) {
  175. return hash(str, 'sha1')
  176. }
  177. function hash (str, digest) {
  178. return crypto
  179. .createHash(digest)
  180. .update(str)
  181. .digest('hex')
  182. }
  183. function formatEntry (cache, entry) {
  184. // Treat null digests as deletions. They'll shadow any previous entries.
  185. if (!entry.integrity) { return null }
  186. return {
  187. key: entry.key,
  188. integrity: entry.integrity,
  189. path: contentPath(cache, entry.integrity),
  190. size: entry.size,
  191. time: entry.time,
  192. metadata: entry.metadata
  193. }
  194. }
  195. function readdirOrEmpty (dir) {
  196. return readdirAsync(dir)
  197. .catch({code: 'ENOENT'}, () => [])
  198. .catch({code: 'ENOTDIR'}, () => [])
  199. }
  200. function nop () {
  201. }