sandbox-clone
An experimental sandbox for reducing the risk of accidentally writing or deleting out-of-scope files during testing.
npm i -D sandbox-clone
const { sandbox, unbox, isBoxed, Clone } = require('sandbox-clone');
-
sandbox()
monkey-patches the built-in Node.jsfs
module to limit writing outside a specified set of directories. -
unbox()
restores original un-sandboxed state. -
isBoxed()
reports whetherfs
is sandboxed or not. -
new Clone()
clones a directory into a temporary directory so that tests can manipulate the clone without modifying the original. Returns an object with these methods:-
.base()
returns the path of the temporary directory. -
.destroy()
deletes the temporary directory. -
.run()
sandboxes the temp directory while a specified function runs, then restores the realfs
. -
.snapshot()
loads the current contents of the cloned directory into a JavaScript object for later analysis. -
.diff()
finds differences between snapshots.
-
sandbox()
and unbox()
Prevent writing outside the specified directory (or directories) with sandbox()
. Restore the un-sandboxed real fs
methods with unbox()
.
const { W_OK } = fs.constants;
sandbox('/foo/bar');
// try/catch blocks omitted for brevity and clarity
fs.accessSync('/foo/bar/zote.txt', W_OK); // succeeds
fs.accessSync('/foo/bar/thed.txt', W_OK); // succeeds
fs.accessSync('/foo/sneg/baz.txt', W_OK); // fails
fs.accessSync('/boo/far.txt', W_OK); // fails
unbox();
fs.accessSync('/foo/sneg/baz.txt', W_OK); // succeeds now
fs.accessSync('/boo/far.txt', W_OK); // succeeds now
Variations
Multiple sandbox directories:
sandbox('/foo/bar', '/foo/sbor');
Relative paths:
process.chdir('/foo');
sandbox('bar');
isBoxed()
Reports whether sandboxing is in effect.
console.log(isBoxed()); // -> false
sandbox('/foo/bar');
console.log(isBoxed()); // -> true
unbox();
console.log(isBoxed()); // -> false
new Clone()
Clones a directory for file manipulation during tests. Returns an object with methods for managing the directory.
const clone = new Clone({
source: '/path/to/my-test'
});
.base()
The base()
method returns the path of the temporary clone. The directory will have the same basename as the original (my-test
in this example).
console.log(clone.base()); // -> "/tmp/clone-49MaGJ/my-test"
If you omit the source
option, an empty directory is created instead of an existing one being cloned. The temporary path will end with the name base
.
const clone = new Clone(); // No `source` option
console.log(clone.base()); // -> "/tmp/clone-We2MRT/base"
.destroy()
Deletes this Clone
's temporary directory:
clone.destroy();
You should use try
...finally
(without catch
) to ensure that the temp directory is deleted even when the test fails.
const clone = new Clone();
// Nothing between here and `try`.
try {
// Tests and assertions should go here inside the `try` block.
} finally {
// No tests or assertions in the `finally` block.
clone.destroy();
}
// Assertions can also go after the `finally` block.
.run(fn)
Sandboxes the temp directory, calls the supplied fn
function, and then restores the real un-sandboxed fs
methods. The fn
function can accept the path of the temp directory as its base
argument.
clone.run(base => {
fs.writeFileSync(path.join(base, 'foo.txt'), 'hello', 'utf8');
fs.writeFileSync(path.join(base, '../bar.txt'), 'nope', 'utf8'); // Fails
});
The function's return value gets passed through as the return value of clone.run()
.
let flavor = clone.run(base => {
// ...
return 'pineapple';
});
It works with await
/ async
too.
let flavor = await clone.run(async base => {
// ...
return 'asynchronous pineapple';
});
.snapshot()
Reads the contents of the temp directory into a JavaScript object for analysis. The keys are the files' pathnames relative to base
, and the values are the files' contents. It doesn't provide any information about directories, only the files.
With the following files...
/tmp/clone-49MaGJ/my-test/scripts/foo.js
/tmp/clone-49MaGJ/my-test/styles/bar.css
...the snapshot might look like this:
{
"scripts/foo.js": "console.log('foo');",
"styles/bar.css": ".bar { color: green }"
}
encodings
option
The Clone
constructor accepts an encodings
option in which you can specify how various file types are loaded into the snapshots. The default is 'utf8'
.
const clone = new Clone({
source: '/path/to/my-test',
encodings: {
gif: 'base64'
}
});
If you have a gif at /tmp/clone-49MaGJ/my-test/images/pixel.gif
, it'll be base64-encoded in the snapshot:
{
"images/pixel.gif": "R0lGODlhAQABAPAAAAAAAP///yH5BAEAAAEALAAAAAABAAEAAAICTAEAOw==",
"scripts/foo.js": "console.log('foo');",
"styles/bar.css": ".bar { color: green }"
}
.diff()
Reports which files were created, modified, and removed from the temp directory between snapshots.
const clone = new Clone({
source: '/path/to/my-test'
});
const before = clone.snapshot();
clone.run(base => {
// create, modify, and/or remove some files
});
const after = clone.snapshot();
const diffs = clone.diff(before, after);
Now diffs
holds an object with four properties -- created
, modified
, removed
, and unchanged
-- each of which holds an array of filenames (but not the files' contents).
{
"created": [
"images/icon.png",
"images/photo.jpg"
],
"modified": [
"styles/bar.css"
],
"removed": [
"images/pixel.gif"
],
"unchanged": [
"scripts/foo.js"
]
}
It doesn't know about renaming. The old name will be in deleted
and the new name will be in created
.
fs
methods:
Notes about the sandboxed -
Methods that expect file descriptors instead of paths can't be sandboxed.
-
Methods that only read from the filesystem but don't write aren't sandboxed.
- Exception: The
access
methods don't write to the filesystem, but are sandboxed anyway, because the way they work is by either issuing an error or not. Therefore the sandboxed versions are aware of themode
argument and only reject onfs.constants.W_OK
.
- Exception: The
-
The sandboxed
open
methods are unaware of the integer values for theflags
parameter. They are aware of the string values ('a'
,'a+'
,'as'
, etc.) however, and reject the ones that allow writing. -
The real
chown
andlchown
methods require elevated privileges, so the sandboxed versions are untested. -
The real
lchmod
methods are only implemented on MacOS, so the sandboxed versions are untested.