makedrive
Version:
Webmaker Filesystem
1,026 lines (873 loc) • 28.8 kB
JavaScript
var request = require('request');
var expect = require('chai').expect;
var ws = require('ws');
var filesystem = require('../../server/lib/filesystem.js');
var SyncMessage = require('../../lib/syncmessage');
var rsync = require('../../lib/rsync');
var rsyncUtils = rsync.utils;
var rsyncOptions = require('../../lib/constants').rsyncDefaults;
var Filer = require('../../lib/filer.js');
var Buffer = Filer.Buffer;
var Path = Filer.Path;
var uuid = require( "node-uuid" );
var async = require('async');
var diffHelper = require("../../lib/diff");
var deepEqual = require('deep-equal');
var MakeDrive = require('../../client/src/index.js');
// Ensure the client timeout restricts tests to a reasonable length
var env = require('../../server/lib/environment');
env.set('CLIENT_TIMEOUT_MS', 1000);
// Set maximum file size limit to 2000000 bytes
env.set('MAX_SYNC_SIZE_BYTES', 2000000);
// Enable a username:password for BASIC_AUTH_USERS to enable /api/get route
env.set('BASIC_AUTH_USERS', 'testusername:testpassword');
env.set('AUTHENTICATION_PROVIDER', 'passport-webmaker');
var server = require('../../server/server.js');
var app = server.app;
var serverURL = 'http://127.0.0.1:' + env.get('PORT'),
socketURL = serverURL.replace( 'http', 'ws' );
// Mock Webmaker auth
app.post('/mocklogin/:username', function(req, res) {
var username = req.param('username');
if(!username){
// Expected username.
res.send(500);
} else if( req.session && req.session.user && req.session.user.username === username) {
// Already logged in.
res.send(401);
} else{
// Login worked.
req.session.user = {username: username};
res.send(200);
}
});
// Mock File Upload into Filer FileSystem. URLs will look like this:
// /upload/:username/:path (where :path will contain /s)
app.post('/upload/*', function(req, res) {
var parts = req.path.split('/');
var username = parts[2];
var path = '/' + parts.slice(3).join('/');
var fileData = [];
req.on('data', function(chunk) {
fileData.push(new Buffer(chunk));
});
req.on('end', function() {
fileData = Buffer.concat(fileData);
var fs = filesystem.create(username);
fs.writeFile(path, fileData, function(err) {
if(err) {
res.send(500, {error: err});
return;
}
res.send(200);
});
});
});
/**
* Misc Helpers
*/
function ready(callback) {
if(server.ready) {
callback();
} else {
server.once('ready', callback);
}
}
function uniqueUsername() {
return 'user' + uuid.v4();
}
function upload(username, path, contents, callback) {
ready(function() {
request.post({
url: serverURL + '/upload/' + username + path,
headers: {
'Content-Type': 'application/octet-stream'
},
body: contents
}, function(err, res) {
expect(err).not.to.exist;
expect(res.statusCode).to.equal(200);
callback();
});
});
}
function comparePaths(a, b) {
// If objects have a .path property, use it.
if(a.path && b.path) {
a = a.path;
b = b.path;
}
if(a > b) return 1;
if(a < b) return -1;
return 0;
}
function toSyncMessage(string) {
try {
string = JSON.parse(string);
string = SyncMessage.parse(string);
} catch(e) {
expect(e, "[Error parsing a SyncMessage to JSON]").to.not.exist;
}
return string;
}
/**
* Connection Helpers
*/
function getWebsocketToken(options, callback){
// Fail early and noisily when missing options.jar
if(!(options && options.jar)) {
throw('Expected options.jar');
}
ready(function() {
request({
url: serverURL + '/api/sync',
jar: options.jar,
json: true
}, function(err, response, body) {
expect(err, "[Error getting a token: " + err + "]").to.not.exist;
expect(response.statusCode, "[Error getting a token: " + response.body.message + "]").to.equal(200);
options.token = body;
callback(null, options);
});
});
}
function jar() {
return request.jar();
}
function authenticate(options, callback){
// If no options passed, generate a unique username and jar
if(typeof options === 'function') {
callback = options;
options = {};
}
options.jar = options.jar || jar();
options.username = options.username || uniqueUsername();
options.logoutUser = function (cb) {
// Reset the jar to kill existing auth cookie
options.jar = jar();
cb();
};
ready(function() {
request.post({
url: serverURL + '/mocklogin/' + options.username,
jar: options.jar
}, function(err, res) {
if(err) {
return callback(err);
}
expect(res.statusCode).to.equal(200);
callback(null, options);
});
});
}
function authenticateAndConnect(options, callback) {
if(typeof options === 'function') {
callback = options;
options = {};
}
authenticate(options, function(err, result) {
if(err) {
return callback(err);
}
getWebsocketToken(result, function(err, result1) {
if(err){
return callback(err);
}
var testDone = result1.done;
result1.done = function() {
options.logoutUser(function() {
testDone && testDone();
});
};
callback(null, result1);
});
});
}
/**
* Socket Helpers
*/
function openSocket(socketData, options) {
if (typeof options !== "object") {
if (socketData && !socketData.token) {
options = socketData;
socketData = null;
}
}
options = options || {};
var socket = new ws(socketURL);
function defaultHandler(msg, failout) {
failout = failout || true;
return function(code, data) {
var details = "";
if (code) {
details += ": " + code.toString();
}
if (data) {
details += " " + data.toString();
}
expect(failout, "[Unexpected socket on" + msg + " event]" + details).to.be.false;
};
}
if (socketData) {
var customMessageHandler = options.onMessage;
options.onOpen = function() {
socket.send(JSON.stringify({
token: socketData.token
}));
};
options.onMessage = function(message) {
expect(message).to.equal(SyncMessage.response.authz.stringify());
if (customMessageHandler) {
socket.once("message", customMessageHandler);
}
socket.send(SyncMessage.response.authz.stringify());
};
}
var onOpen = options.onOpen || defaultHandler("open");
var onMessage = options.onMessage || defaultHandler("message");
var onError = options.onError || defaultHandler("error");
var onClose = options.onClose || defaultHandler("close");
socket.once("message", onMessage);
socket.once("error", onError);
socket.once("open", onOpen);
socket.once("close", onClose);
return {
socket: socket,
onClose: onClose,
onMessage: onMessage,
onError: onError,
onOpen: onOpen,
setClose: function(func){
socket.removeListener("close", onClose);
socket.once("close", func);
this.onClose = socket._events.close.listener;
},
setMessage: function(func){
socket.removeListener("message", onMessage);
socket.once("message", func);
this.onMessage = socket._events.message.listener;
},
setError: function(func){
socket.removeListener("error", onError);
socket.once("error", func);
this.onError = socket._events.error.listener;
},
setOpen: function(func){
socket.removeListener("open", onOpen);
socket.once("open", func);
this.onOpen = socket._events.open.listener;
}
};
}
// Expects 1 parameter, with each subsequent one being an object
// containing a socket and an onClose callback to be deregistered
function cleanupSockets(done) {
var sockets = Array.prototype.slice.call(arguments, 1);
sockets.forEach(function(socketPackage) {
var socket = socketPackage.socket;
socket.removeListener('close', socketPackage.onClose);
socket.close();
});
done();
}
function sendSyncMessage(socketPackage, syncMessage, callback) {
socketPackage.setMessage(callback);
socketPackage.socket.send(syncMessage.stringify());
}
/**
* Sync Helpers
*/
var downstreamSyncSteps = {
requestSync: function(socketPackage, data, fs, customAssertions, cb) {
if (!cb) {
cb = customAssertions;
customAssertions = null;
}
socketPackage.socket.removeListener("message", socketPackage.onMessage);
socketPackage.socket.once("message", function(message) {
// Reattach original listener
socketPackage.socket.once("message", socketPackage.onMessage);
if (!customAssertions) {
message = toSyncMessage(message);
expect(message.type, "[Diffs error: \"" + (message.content && message.content.error) + "\"]").to.equal(SyncMessage.REQUEST);
expect(message.name).to.equal(SyncMessage.CHKSUM);
expect(message.content).to.exist;
expect(message.content.srcList).to.exist;
expect(message.content.path).to.exist;
return cb(null, data);
}
customAssertions(message, cb);
});
// send response reset
var resetDownstream = SyncMessage.response.reset;
socketPackage.socket.send(resetDownstream.stringify());
},
generateDiffs: function(socketPackage, data, fs, customAssertions, cb) {
if (!cb) {
cb = customAssertions;
customAssertions = null;
}
var path = data.path;
var srcList = data.srcList;
socketPackage.socket.removeListener("message", socketPackage.onMessage);
socketPackage.socket.once("message", function(message) {
// Reattach original listener
socketPackage.socket.once("message", socketPackage.onMessage);
if (!customAssertions) {
message = toSyncMessage(message);
expect(message.type, "[Diffs error: \"" + (message.content && message.content.error) + "\"]").to.equal(SyncMessage.RESPONSE);
expect(message.name).to.equal(SyncMessage.DIFFS);
expect(message.content).to.exist;
expect(message.content.diffs).to.exist;
expect(message.content.path).to.exist;
data.diffs = diffHelper.deserialize(message.content.diffs);
return cb(null, data);
}
customAssertions(message, cb);
});
rsync.checksums(fs, path, srcList, rsyncOptions, function( err, checksums ) {
if(err){
cb(err);
}
var diffRequest = SyncMessage.request.diffs;
diffRequest.content = {
checksums: checksums
};
socketPackage.socket.send(diffRequest.stringify());
});
},
patchClientFilesystem: function(socketPackage, data, fs, customAssertions, cb) {
if (!cb) {
cb = customAssertions;
customAssertions = null;
}
socketPackage.socket.removeListener("message", socketPackage.onMessage);
socketPackage.socket.once("message", function(message) {
// Reattach the original listener
socketPackage.socket.once("message", socketPackage.onMessage);
if(!customAssertions) {
message = toSyncMessage(message);
expect(message).to.exist;
expect(message).to.deep.equal(SyncMessage.response.verification);
return cb(null, data);
}
customAssertions(message, cb);
});
rsync.patch(fs, data.path, data.diffs, rsyncOptions, function(err, paths) {
expect(err, "[Rsync patch error: \"" + err + "\"]").not.to.exist;
rsyncUtils.generateChecksums(fs, paths.synced, function(err, checksums) {
expect(err, "[Rsync path checksum error: \"" + err + "\"]").not.to.exist;
expect(checksums).to.exist;
var patchResponse = SyncMessage.response.patch;
patchResponse.content = {checksums: checksums};
socketPackage.socket.send(patchResponse.stringify());
});
});
}
};
var upstreamSyncSteps = {
requestSync: function(socketPackage, data, customAssertions, cb) {
if (!cb) {
cb = customAssertions;
customAssertions = null;
}
socketPackage.socket.removeListener("message", socketPackage.onMessage);
socketPackage.socket.once("message", function(message) {
// Reattach original listener
socketPackage.socket.once("message", socketPackage.onMessage);
if (!customAssertions) {
message = toSyncMessage(message);
expect(message).to.exist;
expect(message.type).to.equal(SyncMessage.RESPONSE);
expect(message.name, "[SyncMessage Type error. SyncMessage.content was: " + message.content + "]").to.equal(SyncMessage.SYNC);
return cb(data);
}
customAssertions(message, cb);
});
var requestSyncMessage = SyncMessage.request.sync;
requestSyncMessage.content = {path: data.path};
socketPackage.socket.send(requestSyncMessage.stringify());
},
generateChecksums: function(socketPackage, data, customAssertions, cb) {
if (!cb) {
cb = customAssertions;
customAssertions = null;
}
socketPackage.socket.removeListener("message", socketPackage.onMessage);
socketPackage.socket.once("message", function(message) {
// Reattach original listener
socketPackage.socket.once("message", socketPackage.onMessage);
message = toSyncMessage(message);
if (!customAssertions) {
expect(message).to.exist;
expect(message.type).to.equal(SyncMessage.REQUEST);
expect(message.name, "[SyncMessage Type error. SyncMessage.content was: " + message.content + "]").to.equal(SyncMessage.DIFFS);
expect(message.content).to.exist;
expect(message.content.checksums).to.exist;
expect(message.content.path).to.exist;
return cb();
}
customAssertions(message, cb);
});
var requestChksumMsg = SyncMessage.request.chksum;
requestChksumMsg.content = {
srcList: data.srcList
};
socketPackage.socket.send(requestChksumMsg.stringify());
},
patchServerFilesystem: function(socketPackage, data, fs, customAssertions, cb) {
if (!cb) {
cb = customAssertions;
customAssertions = null;
}
var path = data.path;
var checksums = data.checksums;
socketPackage.socket.removeListener("message", socketPackage.onMessage);
socketPackage.socket.once("message", function(message) {
// Reattach original listener
socketPackage.socket.once("message", socketPackage.onMessage);
if (!customAssertions) {
message = toSyncMessage(message);
expect(message.type, "[Diffs error: \"" + (message.content && message.content.error) + "\"]").to.equal(SyncMessage.RESPONSE);
expect(message.name).to.equal(SyncMessage.PATCH);
return cb();
}
customAssertions(message, cb);
});
rsync.diff(fs, path, checksums, rsyncOptions, function( err, diffs ) {
if(err){
return cb(err);
}
var patchResponse = SyncMessage.response.patch;
patchResponse.content = {
diffs: diffHelper.serialize(diffs)
};
socketPackage.socket.send(patchResponse.stringify());
});
}
};
function prepareDownstreamSync(finalStep, username, token, cb){
if (typeof cb !== "function") {
cb = token;
token = username;
username = finalStep;
finalStep = null;
}
var testFile = {
name: "test.txt",
content: "Hello world!"
};
// Set up server filesystem
upload(username, '/' + testFile.name, testFile.content, function() {
// Set up client filesystem
var fs = filesystem.create(username + 'client');
var socketPackage = openSocket({
onMessage: function(message) {
message = toSyncMessage(message);
expect(message).to.exist;
expect(message.type).to.equal(SyncMessage.RESPONSE);
expect(message.name).to.equal(SyncMessage.AUTHZ);
expect(message.content).to.be.null;
socketPackage.socket.once("message", function(message) {
message = toSyncMessage(message);
expect(message).to.exist;
expect(message.type).to.equal(SyncMessage.REQUEST);
expect(message.name).to.equal(SyncMessage.CHKSUM);
expect(message.content).to.exist;
expect(message.content.srcList).to.exist;
expect(message.content.path).to.exist;
var downstreamData = {
srcList: message.content.srcList,
path: message.content.path
};
// Complete required sync steps
if (!finalStep) {
return cb(null, downstreamData, fs, socketPackage);
}
downstreamSyncSteps.generateDiffs(socketPackage, downstreamData, fs, function(err, data1) {
if(err){
return cb(err);
}
if (finalStep === "generateDiffs") {
return cb(null, data1, fs, socketPackage);
}
downstreamSyncSteps.patchClientFilesystem(socketPackage, data1, fs, function(err, data2) {
if(err){
return cb(err);
}
cb(null, data2, fs, socketPackage);
});
});
});
socketPackage.socket.send(SyncMessage.response.authz.stringify());
},
onOpen: function() {
socketPackage.socket.send(JSON.stringify({
token: token
}));
}
});
});
}
function completeDownstreamSync(username, token, cb) {
prepareDownstreamSync("patch", username, token, function(err, data, fs, socketPackage) {
if(err){
cb(err);
}
cb(null, data, fs, socketPackage);
});
}
function prepareUpstreamSync(finalStep, username, token, cb){
if (typeof cb !== "function") {
cb = token;
token = username;
username = finalStep;
finalStep = null;
}
completeDownstreamSync(username, token, function(err, data, fs, socketPackage) {
if(err){
return cb(err);
}
// Complete required sync steps
if (!finalStep) {
return cb(null, data, fs, socketPackage);
}
upstreamSyncSteps.requestSync(socketPackage, data, function(data1) {
if (finalStep === "requestSync") {
return cb(data1, fs, socketPackage);
}
upstreamSyncSteps.generateChecksums(socketPackage, data1, fs, function(data2) {
if (finalStep === "generateChecksums") {
return cb(data2, fs, socketPackage);
}
upstreamSyncSteps.patchServerFilesystem(socketPackage, data2, fs, function(err, data3) {
if(err){
return cb(err);
}
cb(null, data3, fs, socketPackage);
});
});
});
});
}
function createFilesystemLayout(fs, layout, callback) {
var paths = Object.keys(layout);
var sh = fs.Shell();
function createPath(path, callback) {
var contents = layout[path];
// Path is either a file (string/Buffer) or empty dir (null)
if(contents) {
sh.mkdirp(Path.dirname(path), function(err) {
if(err) {
return callback(err);
}
fs.writeFile(path, contents, callback);
});
} else {
sh.mkdirp(path, callback);
}
}
async.eachSeries(paths, createPath, callback);
}
/**
* Deletes all paths specified in paths array, or everything
* if no paths are given.
*/
function deleteFilesystemLayout(fs, paths, callback) {
if(!paths) {
fs.readdir('/', function(err, entries) {
if(err) {
return callback(err);
}
entries = entries.map(function(path) {
return Path.join('/', path);
});
deleteFilesystemLayout(fs, entries, callback);
});
} else {
var sh = fs.Shell();
var rm = function(path, callback) {
sh.rm(path, {recursive: true}, callback);
};
async.eachSeries(paths, rm, callback);
}
}
// Strip .modified times from ever element in the array, or its .contents
function stripModified(listing) {
function strip(item) {
delete item.modified;
if(item.contents) {
item.contents = stripModified(item.contents);
}
return item;
}
if(Array.isArray(listing)) {
return listing.map(strip);
} else {
return strip(listing);
}
}
/**
* Makes sure that the layout given matches what's actually
* in the current fs. Use ensureFilesystemContents if you
* want to ensure file/dir contents vs. paths.
*/
function ensureFilesystemLayout(fs, layout, callback) {
// Start by creating the layout, then compare a deep ls()
var fs2 = new Filer.FileSystem({provider: new Filer.FileSystem.providers.Memory(uniqueUsername())});
createFilesystemLayout(fs2, layout, function(err) {
if(err) {
return callback(err);
}
var sh = fs.Shell();
sh.ls('/', {recursive: true}, function(err, fsListing) {
if(err) {
return callback(err);
}
var sh2 = fs2.Shell();
sh2.ls('/', {recursive: true}, function(err, fs2Listing) {
if(err) {
return callback(err);
}
// Remove modified
fsListing = stripModified(fsListing);
fs2Listing = stripModified(fs2Listing);
expect(deepEqual(fsListing, fs2Listing, {ignoreArrayOrder: true, compareFn: comparePaths})).to.be.true;
callback();
});
});
});
}
/**
* Makes sure that the layout given matches what's actually
* in the remote fs. Use ensureFilesystemContents if you
* want to ensure file/dir contents vs. paths.
*/
function ensureRemoteFilesystemLayout(layout, jar, callback) {
// Start by creating the layout, then compare a deep ls()
var layoutFS = new Filer.FileSystem({provider: new Filer.FileSystem.providers.Memory(uniqueUsername())});
createFilesystemLayout(layoutFS, layout, function(err) {
if(err) {
return callback(err);
}
var sh = layoutFS.Shell();
sh.ls('/', {recursive: true}, function(err, layoutFSListing) {
if(err) {
return callback(err);
}
ready(function() {
// Now grab the remote server listing using the /j/* route
request.get({
url: serverURL + '/j/',
jar: jar,
json: true
}, function(err, res, remoteFSListing) {
expect(err).not.to.exist;
expect(res.statusCode).to.equal(200);
// Remove modified
layoutFSListing = stripModified(layoutFSListing);
remoteFSListing = stripModified(remoteFSListing);
expect(deepEqual(remoteFSListing,
layoutFSListing,
{ignoreArrayOrder: true, compareFn: comparePaths})).to.be.true;
callback(err);
});
});
});
});
}
/**
* Ensure that the files and dirs match the layout's contents.
* Use ensureFilesystemLayout if you want to ensure file/dir paths vs. contents.
*/
function ensureFilesystemContents(fs, layout, callback) {
function ensureFileContents(filename, expectedContents, callback) {
var encoding = Buffer.isBuffer(expectedContents) ? null : 'utf8';
fs.readFile(filename, encoding, function(err, actualContents) {
if(err) {
return callback(err);
}
expect(actualContents).to.deep.equal(expectedContents);
callback();
});
}
function ensureEmptyDir(dirname, callback) {
fs.stat(dirname, function(err, stats) {
if(err) {
return callback(err);
}
expect(stats.isDirectory()).to.be.true;
// Also make sure it's empty
fs.readdir(dirname, function(err, entries) {
if(err) {
return callback(err);
}
expect(entries.length).to.equal(0);
callback();
});
});
}
function processPath(path, callback) {
var contents = layout[path];
if(contents) {
ensureFileContents(path, contents, callback);
} else {
ensureEmptyDir(path, callback);
}
}
async.eachSeries(Object.keys(layout), processPath, callback);
}
/**
* Ensure that the remote files and dirs match the layout's contents.
* Use ensureRemoteFilesystemLayout if you want to ensure file/dir paths vs. contents.
*/
function ensureRemoteFilesystemContents(layout, jar, callback) {
function ensureRemoteFileContents(filename, expectedContents, callback) {
request.get({
url: serverURL + '/j' + filename,
jar: jar,
json: true
}, function(err, res, actualContents) {
expect(err).not.to.exist;
expect(res.statusCode).to.equal(200);
if(!Buffer.isBuffer(expectedContents)) {
expectedContents = new Buffer(expectedContents);
}
if(!Buffer.isBuffer(actualContents)) {
actualContents = new Buffer(actualContents);
}
expect(actualContents).to.deep.equal(expectedContents);
callback(err);
});
}
function ensureRemoteEmptyDir(dirname, callback) {
request.get({
url: serverURL + '/j' + dirname,
jar: jar,
json: true
}, function(err, res, listing) {
expect(err).not.to.exist;
expect(res.statusCode).to.equal(200);
expect(Array.isArray(listing)).to.be.true;
expect(listing.length).to.equal(0);
callback(err);
});
}
function processPath(path, callback) {
ready(function() {
var contents = layout[path];
if(contents) {
ensureRemoteFileContents(path, contents, callback);
} else {
ensureRemoteEmptyDir(path, callback);
}
});
}
async.eachSeries(Object.keys(layout), processPath, callback);
}
/**
* Runs ensureFilesystemLayout and ensureFilesystemContents on fs
* for given layout, making sure all paths and files/dirs match expected.
*/
function ensureFilesystem(fs, layout, callback) {
ensureFilesystemLayout(fs, layout, function(err) {
if(err) {
return callback(err);
}
ensureFilesystemContents(fs, layout, callback);
});
}
/**
* Runs ensureRemoteFilesystemLayout and ensureRemoteFilesystemContents
* for given layout, making sure all paths and files/dirs match expected.
*/
function ensureRemoteFilesystem(layout, jar, callback) {
ensureRemoteFilesystemLayout(layout, jar, function(err) {
if(err) {
return callback(err);
}
ensureRemoteFilesystemContents(layout, jar, callback);
});
}
/**
* Setup a new client connection and do a downstream sync, leaving the
* connection open. If a layout is given, we also sync that up to the server.
* Callers should disconnect the client when done. Callers can pass Filer
* FileSystem options on the options object.
*/
function setupSyncClient(options, callback) {
authenticateAndConnect(options, function(err, result) {
if(err) {
return callback(err);
}
// Make sure we have sane defaults on the options object for a filesystem
options.provider = options.provider ||
new Filer.FileSystem.providers.Memory(result.username + Date.now());
options.manual = options.manual !== false;
options.forceCreate = true;
var fs = MakeDrive.fs(options);
var sync = fs.sync;
var client = {
jar: result.jar,
username: result.username,
fs: fs,
sync: sync
};
sync.once('connected', function onConnected() {
if(options.layout) {
sync.once('completed', function onUpstreamCompleted() {
callback(null, client);
});
createFilesystemLayout(fs, options.layout, function(err) {
if(err) {
return callback(err);
}
sync.request();
});
} else {
callback(null, client);
}
});
sync.once('error', function(err) {
// This should never happen, and if it does, we need to fail loudly.
console.error('Unexepcted sync `error` event', err.stack);
throw err;
});
sync.connect(socketURL, result.token);
});
}
module.exports = {
// Misc helpers
ready: ready,
app: app,
serverURL: serverURL,
socketURL: socketURL,
username: uniqueUsername,
createJar: jar,
toSyncMessage: toSyncMessage,
// Connection helpers
authenticate: authenticate,
authenticatedConnection: authenticateAndConnect,
getWebsocketToken: getWebsocketToken,
// Socket helpers
openSocket: openSocket,
upload: upload,
cleanupSockets: cleanupSockets,
// Filesystem helpers
createFilesystemLayout: createFilesystemLayout,
deleteFilesystemLayout: deleteFilesystemLayout,
ensureFilesystemContents: ensureFilesystemContents,
ensureFilesystemLayout: ensureFilesystemLayout,
ensureRemoteFilesystemContents: ensureRemoteFilesystemContents,
ensureRemoteFilesystemLayout: ensureRemoteFilesystemLayout,
ensureFilesystem: ensureFilesystem,
ensureRemoteFilesystem: ensureRemoteFilesystem,
// Sync helpers
prepareDownstreamSync: prepareDownstreamSync,
prepareUpstreamSync: prepareUpstreamSync,
downstreamSyncSteps: downstreamSyncSteps,
upstreamSyncSteps: upstreamSyncSteps,
sendSyncMessage: sendSyncMessage,
completeDownstreamSync: completeDownstreamSync,
setupSyncClient: setupSyncClient
};