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