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.
287 lines
7.8 KiB
287 lines
7.8 KiB
'use strict' |
|
|
|
const util = require('util') |
|
|
|
const pMap = require('p-map') |
|
const contentPath = require('./content/path') |
|
const fixOwner = require('./util/fix-owner') |
|
const fs = require('fs') |
|
const fsm = require('fs-minipass') |
|
const glob = util.promisify(require('glob')) |
|
const index = require('./entry-index') |
|
const path = require('path') |
|
const rimraf = util.promisify(require('rimraf')) |
|
const ssri = require('ssri') |
|
|
|
const hasOwnProperty = (obj, key) => |
|
Object.prototype.hasOwnProperty.call(obj, key) |
|
|
|
const stat = util.promisify(fs.stat) |
|
const truncate = util.promisify(fs.truncate) |
|
const writeFile = util.promisify(fs.writeFile) |
|
const readFile = util.promisify(fs.readFile) |
|
|
|
const verifyOpts = (opts) => ({ |
|
concurrency: 20, |
|
log: { silly () {} }, |
|
...opts, |
|
}) |
|
|
|
module.exports = verify |
|
|
|
function verify (cache, opts) { |
|
opts = verifyOpts(opts) |
|
opts.log.silly('verify', 'verifying cache at', cache) |
|
|
|
const steps = [ |
|
markStartTime, |
|
fixPerms, |
|
garbageCollect, |
|
rebuildIndex, |
|
cleanTmp, |
|
writeVerifile, |
|
markEndTime, |
|
] |
|
|
|
return steps |
|
.reduce((promise, step, i) => { |
|
const label = step.name |
|
const start = new Date() |
|
return promise.then((stats) => { |
|
return step(cache, opts).then((s) => { |
|
s && |
|
Object.keys(s).forEach((k) => { |
|
stats[k] = s[k] |
|
}) |
|
const end = new Date() |
|
if (!stats.runTime) |
|
stats.runTime = {} |
|
|
|
stats.runTime[label] = end - start |
|
return Promise.resolve(stats) |
|
}) |
|
}) |
|
}, Promise.resolve({})) |
|
.then((stats) => { |
|
stats.runTime.total = stats.endTime - stats.startTime |
|
opts.log.silly( |
|
'verify', |
|
'verification finished for', |
|
cache, |
|
'in', |
|
`${stats.runTime.total}ms` |
|
) |
|
return stats |
|
}) |
|
} |
|
|
|
function markStartTime (cache, opts) { |
|
return Promise.resolve({ startTime: new Date() }) |
|
} |
|
|
|
function markEndTime (cache, opts) { |
|
return Promise.resolve({ endTime: new Date() }) |
|
} |
|
|
|
function fixPerms (cache, opts) { |
|
opts.log.silly('verify', 'fixing cache permissions') |
|
return fixOwner |
|
.mkdirfix(cache, cache) |
|
.then(() => { |
|
// TODO - fix file permissions too |
|
return fixOwner.chownr(cache, cache) |
|
}) |
|
.then(() => null) |
|
} |
|
|
|
// Implements a naive mark-and-sweep tracing garbage collector. |
|
// |
|
// The algorithm is basically as follows: |
|
// 1. Read (and filter) all index entries ("pointers") |
|
// 2. Mark each integrity value as "live" |
|
// 3. Read entire filesystem tree in `content-vX/` dir |
|
// 4. If content is live, verify its checksum and delete it if it fails |
|
// 5. If content is not marked as live, rimraf it. |
|
// |
|
function garbageCollect (cache, opts) { |
|
opts.log.silly('verify', 'garbage collecting content') |
|
const indexStream = index.lsStream(cache) |
|
const liveContent = new Set() |
|
indexStream.on('data', (entry) => { |
|
if (opts.filter && !opts.filter(entry)) |
|
return |
|
|
|
liveContent.add(entry.integrity.toString()) |
|
}) |
|
return new Promise((resolve, reject) => { |
|
indexStream.on('end', resolve).on('error', reject) |
|
}).then(() => { |
|
const contentDir = contentPath.contentDir(cache) |
|
return glob(path.join(contentDir, '**'), { |
|
follow: false, |
|
nodir: true, |
|
nosort: true, |
|
}).then((files) => { |
|
return Promise.resolve({ |
|
verifiedContent: 0, |
|
reclaimedCount: 0, |
|
reclaimedSize: 0, |
|
badContentCount: 0, |
|
keptSize: 0, |
|
}).then((stats) => |
|
pMap( |
|
files, |
|
(f) => { |
|
const split = f.split(/[/\\]/) |
|
const digest = split.slice(split.length - 3).join('') |
|
const algo = split[split.length - 4] |
|
const integrity = ssri.fromHex(digest, algo) |
|
if (liveContent.has(integrity.toString())) { |
|
return verifyContent(f, integrity).then((info) => { |
|
if (!info.valid) { |
|
stats.reclaimedCount++ |
|
stats.badContentCount++ |
|
stats.reclaimedSize += info.size |
|
} else { |
|
stats.verifiedContent++ |
|
stats.keptSize += info.size |
|
} |
|
return stats |
|
}) |
|
} else { |
|
// No entries refer to this content. We can delete. |
|
stats.reclaimedCount++ |
|
return stat(f).then((s) => { |
|
return rimraf(f).then(() => { |
|
stats.reclaimedSize += s.size |
|
return stats |
|
}) |
|
}) |
|
} |
|
}, |
|
{ concurrency: opts.concurrency } |
|
).then(() => stats) |
|
) |
|
}) |
|
}) |
|
} |
|
|
|
function verifyContent (filepath, sri) { |
|
return stat(filepath) |
|
.then((s) => { |
|
const contentInfo = { |
|
size: s.size, |
|
valid: true, |
|
} |
|
return ssri |
|
.checkStream(new fsm.ReadStream(filepath), sri) |
|
.catch((err) => { |
|
if (err.code !== 'EINTEGRITY') |
|
throw err |
|
|
|
return rimraf(filepath).then(() => { |
|
contentInfo.valid = false |
|
}) |
|
}) |
|
.then(() => contentInfo) |
|
}) |
|
.catch((err) => { |
|
if (err.code === 'ENOENT') |
|
return { size: 0, valid: false } |
|
|
|
throw err |
|
}) |
|
} |
|
|
|
function rebuildIndex (cache, opts) { |
|
opts.log.silly('verify', 'rebuilding index') |
|
return index.ls(cache).then((entries) => { |
|
const stats = { |
|
missingContent: 0, |
|
rejectedEntries: 0, |
|
totalEntries: 0, |
|
} |
|
const buckets = {} |
|
for (const k in entries) { |
|
/* istanbul ignore else */ |
|
if (hasOwnProperty(entries, k)) { |
|
const hashed = index.hashKey(k) |
|
const entry = entries[k] |
|
const excluded = opts.filter && !opts.filter(entry) |
|
excluded && stats.rejectedEntries++ |
|
if (buckets[hashed] && !excluded) |
|
buckets[hashed].push(entry) |
|
else if (buckets[hashed] && excluded) { |
|
// skip |
|
} else if (excluded) { |
|
buckets[hashed] = [] |
|
buckets[hashed]._path = index.bucketPath(cache, k) |
|
} else { |
|
buckets[hashed] = [entry] |
|
buckets[hashed]._path = index.bucketPath(cache, k) |
|
} |
|
} |
|
} |
|
return pMap( |
|
Object.keys(buckets), |
|
(key) => { |
|
return rebuildBucket(cache, buckets[key], stats, opts) |
|
}, |
|
{ concurrency: opts.concurrency } |
|
).then(() => stats) |
|
}) |
|
} |
|
|
|
function rebuildBucket (cache, bucket, stats, opts) { |
|
return truncate(bucket._path).then(() => { |
|
// This needs to be serialized because cacache explicitly |
|
// lets very racy bucket conflicts clobber each other. |
|
return bucket.reduce((promise, entry) => { |
|
return promise.then(() => { |
|
const content = contentPath(cache, entry.integrity) |
|
return stat(content) |
|
.then(() => { |
|
return index |
|
.insert(cache, entry.key, entry.integrity, { |
|
metadata: entry.metadata, |
|
size: entry.size, |
|
}) |
|
.then(() => { |
|
stats.totalEntries++ |
|
}) |
|
}) |
|
.catch((err) => { |
|
if (err.code === 'ENOENT') { |
|
stats.rejectedEntries++ |
|
stats.missingContent++ |
|
return |
|
} |
|
throw err |
|
}) |
|
}) |
|
}, Promise.resolve()) |
|
}) |
|
} |
|
|
|
function cleanTmp (cache, opts) { |
|
opts.log.silly('verify', 'cleaning tmp directory') |
|
return rimraf(path.join(cache, 'tmp')) |
|
} |
|
|
|
function writeVerifile (cache, opts) { |
|
const verifile = path.join(cache, '_lastverified') |
|
opts.log.silly('verify', 'writing verifile to ' + verifile) |
|
try { |
|
return writeFile(verifile, '' + +new Date()) |
|
} finally { |
|
fixOwner.chownr.sync(cache, verifile) |
|
} |
|
} |
|
|
|
module.exports.lastRun = lastRun |
|
|
|
function lastRun (cache) { |
|
return readFile(path.join(cache, '_lastverified'), 'utf8').then( |
|
(data) => new Date(+data) |
|
) |
|
}
|
|
|