hyperdrive
Version:
Hyperdrive is a secure, real time distributed file system
774 lines (619 loc) • 19.7 kB
JavaScript
var hypercore = require('hypercore')
var mutexify = require('mutexify')
var raf = require('random-access-file')
var thunky = require('thunky')
var tree = require('append-tree')
var collect = require('stream-collector')
var sodium = require('sodium-universal')
var inherits = require('inherits')
var events = require('events')
var duplexify = require('duplexify')
var from = require('from2')
var each = require('stream-each')
var uint64be = require('uint64be')
var unixify = require('unixify')
var path = require('path')
var messages = require('./lib/messages')
var stat = require('./lib/stat')
var DEFAULT_FMODE = (4 | 2 | 0) << 6 | ((4 | 0 | 0) << 3) | (4 | 0 | 0) // rw-r--r--
var DEFAULT_DMODE = (4 | 2 | 1) << 6 | ((4 | 0 | 1) << 3) | (4 | 0 | 1) // rwxr-xr-x
module.exports = Hyperdrive
function Hyperdrive (storage, key, opts) {
if (!(this instanceof Hyperdrive)) return new Hyperdrive(storage, key, opts)
events.EventEmitter.call(this)
if (isObject(key)) {
opts = key
key = null
}
if (!opts) opts = {}
this.key = null
this.discoveryKey = null
this.live = true
this.latest = !!opts.latest
this._storages = defaultStorage(this, storage, opts)
this.metadata = opts.metadata || hypercore(this._storages.metadata, key, {secretKey: opts.secretKey})
this.content = opts.content || null
this.maxRequests = opts.maxRequests || 16
this.readable = true
this.storage = storage
this.tree = tree(this.metadata, {offset: 1, valueEncoding: messages.Stat})
if (typeof opts.version === 'number') this.tree = this.tree.checkout(opts.version)
this.sparse = !!opts.sparse
this.indexing = !!opts.indexing
this._latestSynced = 0
this._latestVersion = 0
this._latestStorage = this.latest ? this._storages.metadata('latest') : null
this._checkout = opts._checkout
this._lock = mutexify()
var self = this
this.metadata.on('append', update)
this.metadata.on('error', onerror)
this.ready = thunky(open)
this.ready(onready)
function onready (err) {
if (err) return onerror(err)
self.emit('ready')
if (self.content) self.emit('content')
if (self.latest && !self.metadata.writable) {
self._trackLatest(onerror)
}
}
function onerror (err) {
if (err) self.emit('error', err)
}
function update () {
self.version = self.tree.version
self.emit('update')
}
function open (cb) {
self._open(cb)
}
}
inherits(Hyperdrive, events.EventEmitter)
Object.defineProperty(Hyperdrive.prototype, 'version', {
enumerable: true,
get: function () {
return this._checkout ? this.tree.version : (this.metadata.length ? this.metadata.length - 1 : 0)
}
})
Object.defineProperty(Hyperdrive.prototype, 'writable', {
enumerable: true,
get: function () {
return this.metadata.writable
}
})
Hyperdrive.prototype._trackLatest = function (cb) {
var self = this
this.ready(function (err) {
if (err) return cb(err)
self._latestStorage.read(0, 8, function (_, data) {
self._latestVersion = data ? uint64be.decode(data) : 0
loop()
})
})
function loop (err) {
if (err) return cb(err)
if (stableVersion()) return fetch()
// TODO: lock downloading while doing this
self._clearDangling(self._latestVersion, self.version, onclear)
}
function fetch () {
if (self.sparse) {
if (stableVersion()) return self.metadata.update(loop)
return loop(null)
}
self.emit('syncing')
self._fetchVersion(self._latestSynced, function (err, fullySynced) {
if (err) return cb(err)
if (fullySynced) {
self._latestSynced = self._latestVersion
self.emit('sync')
if (!self._checkout) self.metadata.update(loop) // TODO: only if live
return
}
loop(null)
})
}
function onclear (err) {
if (err) return cb(err)
self._latestVersion = self.version
self._latestStorage.write(0, uint64be.encode(self._latestVersion), loop)
}
function stableVersion () {
var latest = self.version
return latest < 0 || self._latestVersion === latest
}
}
Hyperdrive.prototype._fetchVersion = function (prev, cb) {
var self = this
var version = self.version
var updated = false
var done = false
var error = null
var stream = null
var queued = 0
var maxQueued = 64
var waitingData = null
var waitingCallback = null
this.metadata.update(function () {
updated = true
if (stream) stream.destroy()
kick()
})
this._ensureContent(function (err) {
if (err) return cb(err)
if (updated) return cb(null, false)
// var snapshot = self.checkout(version)
stream = self.tree.checkout(prev).diff(version, {puts: true, dels: false})
each(stream, ondata, ondone)
})
function ondata (data, next) {
if (updated) return next(new Error('Out of date'))
if (queued >= maxQueued) {
waitingData = data
waitingCallback = next
return
}
var start = data.value.offset
var end = start + data.value.blocks
if (start === end) return next()
queued++
self.content.download({start: start, end: end}, function (err) {
queued--
if (waitingCallback) {
data = waitingData
waitingData = null
next = waitingCallback
waitingCallback = null
return ondata(data, next)
}
if (err) {
stream.destroy(err)
error = err
return
}
kick()
})
next()
}
function kick () {
if (!done || queued) return
queued = -1 // hack to not call this again
if (updated) return cb(null, false)
if (error) return cb(error)
cb(null, version === self.version)
}
function ondone (err) {
if (err) error = err
done = true
kick()
}
}
Hyperdrive.prototype._clearDangling = function (a, b, cb) {
var current = this.tree.checkout(a, {cached: true})
var latest = this.tree.checkout(b)
var stream = current.diff(latest, {dels: true, puts: false})
var self = this
this._ensureContent(oncontent)
function oncontent (err) {
if (err) return cb(err)
each(stream, ondata, cb)
}
function ondata (data, next) {
var st = data.value
self.content.cancel(st.offset, st.offset + st.blocks)
self.content.clear(st.offset, st.offset + st.blocks, {byteOffset: st.byteOffset, byteLength: st.size}, next)
}
}
Hyperdrive.prototype.replicate = function (opts) {
if (!opts) opts = {}
opts.expectedFeeds = 2
var self = this
var stream = this.metadata.replicate(opts)
this._ensureContent(function (err) {
if (err) return stream.destroy(err)
if (stream.destroyed) return
self.content.replicate({
live: opts.live,
download: opts.download,
upload: opts.upload,
stream: stream
})
})
return stream
}
Hyperdrive.prototype.checkout = function (version) {
return Hyperdrive(null, null, {
_checkout: this._checkout || this,
metadata: this.metadata,
version: version
})
}
Hyperdrive.prototype.history = function (opts) {
return this.tree.history(opts)
}
// TODO: move to ./lib
Hyperdrive.prototype.createReadStream = function (name, opts) {
if (!opts) opts = {}
name = unixify(name)
var self = this
var first = true
var start = 0
var end = 0
var offset = 0
var length = typeof opts.end === 'number' ? 1 + opts.end - (opts.start || 0) : typeof opts.length === 'number' ? opts.length : -1
var range = null
var ended = false
var stream = from(read)
stream.on('close', cleanup)
stream.on('end', cleanup)
return stream
function cleanup () {
if (range) self.content.undownload(range)
range = null
ended = true
}
function read (size, cb) {
if (first) return open(size, cb)
if (start === end || length === 0) return cb(null, null)
self.content.get(start++, function (err, data) {
if (err) return cb(err)
if (offset) data = data.slice(offset)
offset = 0
if (length > -1) {
if (length < data.length) data = data.slice(0, length)
length -= data.length
}
cb(null, data)
})
}
function open (size, cb) {
first = false
self._ensureContent(function (err) {
if (err) return cb(err)
self.tree.get(name, function (err, stat) {
if (err) return cb(err)
if (ended) return
start = stat.offset
end = stat.offset + stat.blocks
var byteOffset = stat.byteOffset
if (opts.start) self.content.seek(byteOffset + opts.start, {start: start, end: end}, onstart)
else onstart(null, start, 0)
function onend (err, index) {
if (err || !range) return
self.content.undownload(range)
range = self.content.download({start: start, end: index, linear: true})
}
function onstart (err, index, off) {
if (err) return cb(err)
offset = off
start = index
range = self.content.download({start: start, end: end, linear: true})
if (length > -1 && length < stat.size) {
self.content.seek(byteOffset + length, {start: start, end: end}, onend)
}
read(size, cb)
}
})
})
}
}
Hyperdrive.prototype.readFile = function (name, opts, cb) {
if (typeof opts === 'function') return this.readFile(name, null, opts)
if (typeof opts === 'string') opts = {encoding: opts}
if (!opts) opts = {}
name = unixify(name)
collect(this.createReadStream(name), function (err, bufs) {
if (err) return cb(err)
var buf = bufs.length === 1 ? bufs[0] : Buffer.concat(bufs)
cb(null, opts.encoding && opts.encoding !== 'binary' ? buf.toString(opts.encoding) : buf)
})
}
Hyperdrive.prototype.createWriteStream = function (name, opts) {
if (!opts) opts = {}
name = unixify(name)
var self = this
var proxy = duplexify()
// TODO: support piping through a "split" stream like rabin
proxy.setReadable(false)
this._ensureContent(function (err) {
if (err) return proxy.destroy(err)
if (self._checkout) return proxy.destroy(new Error('Cannot write to a checkout'))
if (proxy.destroyed) return
self._lock(function (release) {
if (!self.latest || proxy.destroyed) return append(null)
self.tree.get(name, function (err, st) {
if (err && err.notFound) return append(null)
if (err) return append(err)
if (!st.size) return append(null)
self.content.clear(st.offset, st.offset + st.blocks, append)
})
function append (err) {
if (err) proxy.destroy(err)
if (proxy.destroyed) return release()
// No one should mutate the content other than us
var byteOffset = self.content.byteLength
var offset = self.content.length
self.emit('appending', name, opts)
// TODO: revert the content feed if this fails!!!! (add an option to the write stream for this (atomic: true))
var stream = self.content.createWriteStream()
proxy.on('close', done)
proxy.on('finish', done)
proxy.setWritable(stream)
proxy.on('prefinish', function () {
var st = {
mode: (opts.mode || DEFAULT_FMODE) | stat.IFREG,
uid: opts.uid || 0,
gid: opts.gid || 0,
size: self.content.byteLength - byteOffset,
blocks: self.content.length - offset,
offset: offset,
byteOffset: byteOffset,
mtime: getTime(opts.mtime),
ctime: getTime(opts.ctime)
}
proxy.cork()
self.tree.put(name, st, function (err) {
if (err) return proxy.destroy(err)
self.emit('append', name, opts)
proxy.uncork()
})
})
}
function done () {
proxy.removeListener('close', done)
proxy.removeListener('finish', done)
release()
}
})
})
return proxy
}
Hyperdrive.prototype.writeFile = function (name, buf, opts, cb) {
if (typeof opts === 'function') return this.writeFile(name, buf, null, opts)
if (typeof opts === 'string') opts = {encoding: opts}
if (!opts) opts = {}
if (typeof buf === 'string') buf = new Buffer(buf, opts.encoding || 'utf-8')
if (!cb) cb = noop
name = unixify(name)
var bufs = split(buf) // split the input incase it is a big buffer.
var stream = this.createWriteStream(name, opts)
stream.on('error', cb)
stream.on('finish', cb)
for (var i = 0; i < bufs.length; i++) stream.write(bufs[i])
stream.end()
}
Hyperdrive.prototype.mkdir = function (name, opts, cb) {
if (typeof opts === 'function') return this.mkdir(name, null, opts)
if (typeof opts === 'number') opts = {mode: opts}
if (!opts) opts = {}
if (!cb) cb = noop
name = unixify(name)
var self = this
this.ready(function (err) {
if (err) return cb(err)
if (self._checkout) return cb(new Error('Cannot write to a checkout'))
self._lock(function (release) {
var st = {
mode: (opts.mode || DEFAULT_DMODE) | stat.IFDIR,
uid: opts.uid,
gid: opts.gid,
mtime: getTime(opts.mtime),
ctime: getTime(opts.ctime),
offset: self.content.length,
byteOffset: self.content.byteLength
}
self.tree.put(name, st, function (err) {
release(cb, err)
})
})
})
}
Hyperdrive.prototype._statDirectory = function (name, cb) {
this.tree.list(name, function (err, list) {
if (name !== '/' && (err || !list.length)) return cb(err || new Error(name + ' could not be found'))
var st = stat()
st.mode = stat.IFDIR | DEFAULT_DMODE
cb(null, st)
})
}
Hyperdrive.prototype.access = function (name, cb) {
name = unixify(name)
this.stat(name, function (err) {
cb(err)
})
}
Hyperdrive.prototype.exists = function (name, cb) {
this.access(name, function (err) {
cb(!err)
})
}
Hyperdrive.prototype.lstat = function (name, cb) {
var self = this
name = unixify(name)
this.tree.get(name, function (err, st) {
if (err) return self._statDirectory(name, cb)
cb(null, stat(st))
})
}
Hyperdrive.prototype.stat = function (name, cb) {
this.lstat(name, cb)
}
Hyperdrive.prototype.readdir = function (name, opts, cb) {
if (typeof opts === 'function') return this.readdir(name, null, opts)
name = unixify(name)
if (name === '/') return this._readdirRoot(opts, cb) // TODO: should be an option in append-tree prob
this.tree.list(name, opts, cb)
}
Hyperdrive.prototype._readdirRoot = function (opts, cb) {
this.tree.list('/', opts, function (_, list) {
if (list) return cb(null, list)
cb(null, [])
})
}
Hyperdrive.prototype.unlink = function (name, cb) {
name = unixify(name)
this._del(name, cb || noop)
}
Hyperdrive.prototype.rmdir = function (name, cb) {
if (!cb) cb = noop
name = unixify(name)
var self = this
this.readdir(name, function (err, list) {
if (err) return cb(err)
if (list.length) return cb(new Error('Directory is not empty'))
self._del(name, cb)
})
}
Hyperdrive.prototype._del = function (name, cb) {
var self = this
this._ensureContent(function (err) {
if (err) return cb(err)
self._lock(function (release) {
if (!self.latest) return del(null)
self.tree.get(name, function (err, value) {
if (err) return done(err)
self.content.clear(value.offset, value.offset + value.blocks, del)
})
function del (err) {
if (err) return done(err)
self.tree.del(name, done)
}
function done (err) {
release(cb, err)
}
})
})
}
Hyperdrive.prototype.close = function (cb) {
if (!cb) cb = noop
var self = this
this.ready(function (err) {
if (err) return cb(err)
self.metadata.close(function (err) {
if (!self.content) return cb(err)
self.content.close(cb)
})
})
}
Hyperdrive.prototype._ensureContent = function (cb) {
var self = this
this.ready(function (err) {
if (err) return cb(err)
if (!self.content) return self._loadIndex(cb)
cb(null)
})
}
Hyperdrive.prototype._loadIndex = function (cb) {
var self = this
if (this._checkout) this._checkout._loadIndex(done)
else this.metadata.get(0, {valueEncoding: messages.Index}, done)
function done (err, index) {
if (err) return cb(err)
if (self.content) return self.content.ready(cb)
var keyPair = self.metadata.writable && contentKeyPair(self.metadata.secretKey)
var opts = {
sparse: self.latest || self.sparse,
maxRequests: self.maxRequests,
secretKey: keyPair && keyPair.secretKey,
storeSecretKey: false,
indexing: self.metadata.writable && self.indexing
}
self.content = self._checkout ? self._checkout.content : hypercore(self._storages.content, index.content, opts)
self.content.ready(function (err) {
if (err) return cb(err)
self.emit('content')
cb()
})
}
}
Hyperdrive.prototype._open = function (cb) {
var self = this
this.tree.ready(function (err) {
if (err) return cb(err)
self.metadata.ready(function (err) {
if (err) return cb(err)
if (self.content) return cb(null)
self.key = self.metadata.key
self.discoveryKey = self.metadata.discoveryKey
if (!self.metadata.writable || self._checkout) onnotwriteable()
else onwritable()
})
})
function onnotwriteable () {
if (self.metadata.has(0)) return self._loadIndex(cb)
self._loadIndex(noop)
cb()
}
function onwritable () {
var wroteIndex = self.metadata.has(0)
if (wroteIndex) return self._loadIndex(cb)
if (!self.content) {
var keyPair = contentKeyPair(self.metadata.secretKey)
self.content = hypercore(self._storages.content, keyPair.publicKey, {
sparse: self.sparse || self.latest,
secretKey: keyPair.secretKey,
storeSecretKey: false,
indexing: self.metadata.writable && self.indexing
})
}
self.content.ready(function () {
if (self.metadata.has(0)) return cb(new Error('Index already written'))
self.metadata.append(messages.Index.encode({type: 'hyperdrive', content: self.content.key}), cb)
})
}
}
function isObject (val) {
return !!val && typeof val !== 'string' && !Buffer.isBuffer(val)
}
function wrap (self, storage) {
return {
metadata: function (name, opts) {
return storage.metadata(name, opts, self)
},
content: function (name, opts) {
return storage.content(name, opts, self)
}
}
}
function defaultStorage (self, storage, opts) {
var folder = ''
if (typeof storage === 'object' && storage) return wrap(self, storage)
if (typeof storage === 'string') {
folder = storage
storage = raf
}
return {
metadata: function (name) {
return storage(path.join(folder, 'metadata', name))
},
content: function (name) {
return storage(path.join(folder, 'content', name))
}
}
}
function noop () {}
function split (buf) {
var list = []
for (var i = 0; i < buf.length; i += 65536) {
list.push(buf.slice(i, i + 65536))
}
return list
}
function getTime (date) {
if (typeof date === 'number') return date
if (!date) return Date.now()
return date.getTime()
}
function contentKeyPair (secretKey) {
var seed = new Buffer(sodium.crypto_sign_SEEDBYTES)
var context = new Buffer('hyperdri') // 8 byte context
var keyPair = {
publicKey: new Buffer(sodium.crypto_sign_PUBLICKEYBYTES),
secretKey: new Buffer(sodium.crypto_sign_SECRETKEYBYTES)
}
sodium.crypto_kdf_derive_from_key(seed, 1, context, secretKey)
sodium.crypto_sign_seed_keypair(keyPair.publicKey, keyPair.secretKey, seed)
if (seed.fill) seed.fill(0)
return keyPair
}