You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

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