entry-index.js 7.2 KB

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