Skip to content

Commit b1d3888

Browse files
committed
fix(index): who cares about race conditions anyway
PRETTY SURE THIS IS FINE
1 parent 954b1b3 commit b1d3888

File tree

3 files changed

+48
-116
lines changed

3 files changed

+48
-116
lines changed

Diff for: lib/entry-index.js

+22-72
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ const contentPath = require('./content/path')
55
const crypto = require('crypto')
66
const fixOwner = require('./util/fix-owner')
77
const fs = require('graceful-fs')
8-
const lockfile = require('lockfile')
98
const path = require('path')
109
const pipe = require('mississippi').pipe
1110
const Promise = require('bluebird')
@@ -14,64 +13,30 @@ const through = require('mississippi').through
1413

1514
const indexV = require('../package.json')['cache-version'].index
1615

16+
const appendFileAsync = Promise.promisify(fs.appendFile)
17+
1718
module.exports.insert = insert
1819
function insert (cache, key, digest, opts) {
1920
opts = opts || {}
2021
const bucket = bucketPath(cache, key)
21-
const lock = bucket + '.lock'
2222
return fixOwner.mkdirfix(
2323
path.dirname(bucket), opts.uid, opts.gid
24-
).then(() => (
25-
Promise.fromNode(_cb => {
26-
const cb = (err, entry) => {
27-
lockfile.unlock(lock, er => {
28-
_cb(err || er, entry)
29-
})
30-
}
31-
lockfile.lock(lock, {
32-
stale: 60000,
33-
retries: 10,
34-
wait: 10000
35-
}, function (err) {
36-
if (err) { return _cb(err) }
37-
fs.stat(bucket, function (err, existing) {
38-
if (err && err.code !== 'ENOENT' && err.code !== 'EPERM') {
39-
return cb(err)
40-
}
41-
const entry = {
42-
key: key,
43-
digest: digest,
44-
hashAlgorithm: opts.hashAlgorithm,
45-
time: +(new Date()),
46-
metadata: opts.metadata
47-
}
48-
// Because of the way these entries work,
49-
// the index is safe from fs.appendFile stopping
50-
// mid-write so long as newlines are *prepended*
51-
//
52-
// That is, if a write fails, it will be ignored
53-
// by `find`, and the next successful one will be
54-
// used.
55-
//
56-
// This should be -very rare-, since `fs.appendFile`
57-
// will often be atomic on most platforms unless
58-
// very large metadata has been included, but caches
59-
// like this one tend to last a long time. :)
60-
// Most corrupted reads are likely to be from attempting
61-
// to read the index while it's being written to --
62-
// which is safe, but not guaranteed to be atomic.
63-
const e = (existing ? '\n' : '') + JSON.stringify(entry)
64-
fs.appendFile(bucket, e, function (err) {
65-
cb(err, entry)
66-
})
67-
})
68-
})
69-
})
70-
)).then(entry => {
71-
return fixOwner.chownr(bucket, opts.uid, opts.gid).then(() => {
72-
return formatEntry(cache, entry)
73-
})
74-
})
24+
).then(() => {
25+
const entry = {
26+
key: key,
27+
digest: digest,
28+
hashAlgorithm: opts.hashAlgorithm,
29+
time: +(new Date()),
30+
metadata: opts.metadata
31+
}
32+
return appendFileAsync(
33+
bucket, '\n' + JSON.stringify(entry)
34+
).then(() => entry)
35+
}).then(entry => (
36+
fixOwner.chownr(bucket, opts.uid, opts.gid).then(() => (
37+
formatEntry(cache, entry)
38+
))
39+
))
7540
}
7641

7742
module.exports.find = find
@@ -126,7 +91,7 @@ function lsStream (cache) {
12691
fs.readFile(path.join(indexDir, bucket, f), 'utf8', function (err, data) {
12792
if (err) { return cb(err) }
12893
const entries = {}
129-
data.split('\n').forEach(function (entry) {
94+
data.split('\n').slice(1).forEach(function (entry) {
13095
let parsed
13196
try {
13297
parsed = JSON.parse(entry)
@@ -186,30 +151,15 @@ function bucketDir (cache) {
186151
module.exports._bucketPath = bucketPath
187152
function bucketPath (cache, key) {
188153
const hashed = hashKey(key)
189-
return path.join(bucketDir(cache), hashed.slice(0, 2), hashed)
154+
return path.join(bucketDir(cache), hashed.slice(0, 2), hashed.slice(2))
190155
}
191156

192157
module.exports._hashKey = hashKey
193158
function hashKey (key) {
194-
// NOTE (SECURITY)
195-
//
196-
// `sha1` conflicts can be generated, but it doesn't matter in this case,
197-
// since we intend for there to be regular conflicts anyway. You can have
198-
// the entire cache in a single bucket and all that'll do is just make a big
199-
// file with a lot of contention, if you can even pull it off in the `key`
200-
// string. So whatever. `sha1` is faster and it doesn't trigger the warnings
201-
// `md5` tends to (yet?...).
202-
//
203-
// Not to mention, that in the case of pacote/npm, the amount of control
204-
// anyone would have over this key is so minimal that it's incredibly
205-
// unlikely that they could intentionally generate a large number of
206-
// conflicts just with a package key such that they'd do anything resembling
207-
// a hash flood DOS.
208159
return crypto
209-
.createHash('sha1')
210-
.update(key.toLowerCase()) // lump case-variant keys into same bucket.
160+
.createHash('sha256')
161+
.update(key)
211162
.digest('hex')
212-
.slice(0, 7)
213163
}
214164

215165
function formatEntry (cache, entry) {

Diff for: test/index.insert.js

+25-43
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ test('basic insertion', function (t) {
3434
}, 'formatted entry returned')
3535
return fs.readFileAsync(BUCKET, 'utf8')
3636
}).then(data => {
37-
t.equal(data[0], '{', 'first entry starts with a {, not \\n')
37+
t.equal(data[0], '\n', 'first entry starts with a \\n')
3838
const entry = JSON.parse(data)
3939
t.ok(entry.time, 'entry has a timestamp')
4040
t.deepEqual(entry, {
@@ -55,7 +55,7 @@ test('inserts additional entries into existing key', function (t) {
5555
)).then(() => {
5656
return fs.readFileAsync(BUCKET, 'utf8')
5757
}).then(data => {
58-
const entries = data.split('\n').map(JSON.parse)
58+
const entries = data.split('\n').slice(1).map(JSON.parse)
5959
entries.forEach(function (e) { delete e.time })
6060
t.deepEqual(entries, [{
6161
key: KEY,
@@ -112,48 +112,30 @@ test('optional arbitrary metadata', function (t) {
112112
test('key case-sensitivity', function (t) {
113113
return Promise.join(
114114
index.insert(CACHE, KEY, DIGEST),
115-
index.insert(CACHE, KEY.toUpperCase(), DIGEST)
115+
index.insert(CACHE, KEY.toUpperCase(), DIGEST + 'upper')
116116
).then(() => {
117-
return fs.readFileAsync(BUCKET, 'utf8')
118-
}).then(data => {
119-
const entries = data.split('\n').map(JSON.parse).sort(e => (
120-
e.key === KEY
121-
? -1
122-
: 1
123-
))
124-
entries.forEach(function (e) { delete e.time })
125-
t.deepEqual(entries, [{
126-
key: KEY,
127-
digest: DIGEST
128-
}, {
129-
key: KEY.toUpperCase(),
130-
digest: DIGEST
131-
}], 'all entries present')
132-
})
133-
})
134-
135-
test('hash conflict in same bucket', function (t) {
136-
// NOTE - this test will break if `index._hashKey` changes its algorithm.
137-
// Adapt to it accordingly.
138-
const NEWKEY = KEY.toUpperCase()
139-
const CONFLICTING = KEY.toLowerCase()
140-
return index.insert(
141-
CACHE, NEWKEY, DIGEST
142-
).then(() => (
143-
index.insert(CACHE, CONFLICTING, DIGEST)
144-
)).then(() => {
145-
const bucket = index._bucketPath(CACHE, NEWKEY)
146-
return fs.readFileAsync(bucket, 'utf8')
147-
}).then(data => {
148-
const entries = data.split('\n').map(JSON.parse)
149-
entries.forEach(function (e) { delete e.time })
150-
t.deepEqual(entries, [{
151-
key: NEWKEY,
152-
digest: DIGEST
153-
}, {
154-
key: CONFLICTING,
155-
digest: DIGEST
156-
}], 'multiple entries for conflicting keys in the same bucket')
117+
return Promise.join(
118+
index.find(CACHE, KEY),
119+
index.find(CACHE, KEY.toUpperCase()),
120+
(entry, upperEntry) => {
121+
delete entry.time
122+
delete upperEntry.time
123+
t.deepEqual({
124+
key: entry.key,
125+
digest: entry.digest
126+
}, {
127+
key: KEY,
128+
digest: DIGEST
129+
}, 'regular entry exists')
130+
t.deepEqual({
131+
key: upperEntry.key,
132+
digest: upperEntry.digest
133+
}, {
134+
key: KEY.toUpperCase(),
135+
digest: DIGEST + 'upper'
136+
}, 'case-variant entry intact')
137+
}
138+
)
157139
})
158140
})
159141

Diff for: test/util/cache-index.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ function CacheIndex (entries, hashAlgorithm) {
2626
if (typeof lines.length !== 'number') {
2727
lines = [lines]
2828
}
29-
serialised = lines.map(JSON.stringify).join('\n')
29+
serialised = '\n' + lines.map(JSON.stringify).join('\n')
3030
}
3131
insertContent(tree, parts, serialised)
3232
})

0 commit comments

Comments
 (0)