Skip to content

Commit a6244de

Browse files
committed
2.0.0 - Hole punching in Windows
1 parent e31cd77 commit a6244de

File tree

6 files changed

+234
-22
lines changed

6 files changed

+234
-22
lines changed

README.md

+42-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# `set-sparse`
22

3-
A simple function that sets the `FSCTL_SET_SPARSE` windows flag via `DeviceIoControl`.
3+
A simple function that sets the `FSCTL_SET_SPARSE` or `FSCTL_SET_ZERO_DATA` windows flag via `DeviceIoControl`.
44

55
## Why
66

@@ -15,10 +15,26 @@ _this is a file with a lot of zeros, notice the Size vs Size on disk_
1515
## Usage
1616

1717
```js
18-
const setSparse = require('set-sparse')
18+
const sparse = require('set-sparse')
1919
const create_if_nonexistant = true
2020

21-
const success = setSparse('./file-to-set', create_if_nonexistant)
21+
const success = sparse.setSparse('./file-to-set', create_if_nonexistant)
22+
// you can now write to sections of your file
23+
const { open } = require('fs/promises')
24+
const fd = await open('./file-to-set', 'r+')
25+
await fd.write(Buffer.from([1]), 0, 1, 10000000) // 10mb file
26+
fd.close() // file is *still small!*
27+
28+
// And if we start with a dense file
29+
const { open, stat } = require('fs/promises')
30+
const fd = await open('./other-file', 'w+')
31+
const TEN_MB = Buffer.alloc(10000000, 'repeating-bytes')
32+
await fd.write(TEN_MB, 0, TEN_MB.length, 0) // 10mb file
33+
fd.close() // file is TEN_MB even set as sparse
34+
sparse.setSparse('./other-file') // still 10mb
35+
// punch a hole leaving 1 non-zero byte on either side
36+
sparse.holePunch('./other-file', 1, TEN_MB.length)
37+
await stat('./other-file') // reclaim all those zeros, saving space!
2238
```
2339

2440
On Mac and Linux this library is a no-op and will always return `false`.
@@ -29,6 +45,29 @@ On Mac and Linux this library is a no-op and will always return `false`.
2945
* Setting the sparse flag does not tell Windows to go clear up space. You should set the flag before writing data to the file.
3046
* Do not open the file in node with `await open(file, 'w+')` this will disable sparse mode. Use `a+` or `r+`.
3147

48+
## API
49+
50+
### `setSparse(filepath[, create_if_nonexistant])`
51+
52+
* `filepath String`: a relative or absolute path.
53+
* `create_if_nonexistant Boolean = false`: if `setSparse` should create the file if it doesn't exist
54+
55+
This function is synchronous but not dependent on the file's existing size and is
56+
generally very fast.
57+
58+
Returns `true` if operation succeeded.
59+
60+
### `holePunch(filepath, start_byte, beyond_final_zero)`
61+
62+
* `filepath String`: a relative or absolute path.
63+
* `start_byte Number`: the starting (inclusive) byte to set to zero
64+
* `beyond_final_zero Number`: the ending (exclusive) byte to set to zero
65+
66+
This function is synchronous and runtime (whilst good) is dependent on the size
67+
of the new hole.
68+
69+
Returns `true` if operation succeeded.
70+
3271
## Development
3372

3473
```bash

package.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
{
22
"name": "set-sparse",
3-
"version": "1.3.1",
3+
"version": "2.0.0",
44
"description": "A simple function that sets the FSCTL_SET_SPARSE Windows flag to save space in sparse files.",
55
"main": "./src/index.js",
66
"scripts": {
7-
"build": "node-gyp rebuild",
7+
"build": "node-gyp rebuild --release",
88
"postbuild": "cp ./build/Release/sparse.node ./src/sparse.node",
99
"test": "tap --comments --no-coverage -R spec"
1010
},

src/index.js

+5-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
const noop = () => false;
22

33
try {
4-
module.exports = require('./sparse.node').setSparse;
4+
module.exports = require('./sparse.node');
55
} catch (_) {
6-
module.exports = noop;
6+
module.exports = {
7+
setSparse: noop,
8+
holePunch: noop
9+
};
710
}

src/sparse.cpp

+64-2
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,28 @@ bool set_sparse(const wchar_t *path, const bool create) {
2121
return result;
2222
}
2323

24+
bool hole_punch(const wchar_t *path, const LONGLONG start, const LONGLONG end) {
25+
bool result = false;
26+
HANDLE hnd = CreateFileW(path, FILE_GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ, NULL, OPEN_EXISTING, 0, NULL);
27+
if (hnd != INVALID_HANDLE_VALUE) {
28+
DWORD d;
29+
LARGE_INTEGER _start;
30+
_start.QuadPart = start;
31+
LARGE_INTEGER _end;
32+
_end.QuadPart = end;
33+
FILE_ZERO_DATA_INFORMATION info;
34+
info.FileOffset = _start;
35+
info.BeyondFinalZero = _end;
36+
if (DeviceIoControl(hnd, FSCTL_SET_ZERO_DATA, &info, sizeof(info), NULL, 0, &d, NULL)) {
37+
result = true;
38+
}
39+
CloseHandle(hnd);
40+
}
41+
return result;
42+
}
2443

25-
napi_value wrap_function(napi_env env, napi_callback_info info) {
44+
45+
napi_value wrap_set_function(napi_env env, napi_callback_info info) {
2646
napi_value result;
2747
napi_get_new_target(env, info, &result);
2848
if (result) {
@@ -54,12 +74,54 @@ napi_value wrap_function(napi_env env, napi_callback_info info) {
5474
return result;
5575
}
5676

77+
napi_value wrap_punch_function(napi_env env, napi_callback_info info) {
78+
napi_value result;
79+
napi_get_new_target(env, info, &result);
80+
if (result) {
81+
result = NULL;
82+
napi_throw_error(env, SYB_EXP_INVAL, SYB_ERR_NOT_A_CONSTRUCTOR);
83+
} else {
84+
napi_value argv[3];
85+
size_t argc = 3;
86+
napi_get_cb_info(env, info, &argc, argv, NULL, NULL);
87+
if (argc < 3) {
88+
napi_throw_error(env, SYB_EXP_INVAL, SYB_ERR_WRONG_ARGUMENTS);
89+
} else {
90+
size_t str_len;
91+
napi_value tmp;
92+
napi_coerce_to_string(env, argv[0], &tmp);
93+
napi_get_value_string_utf16(env, tmp, NULL, 0, &str_len);
94+
str_len += 1;
95+
wchar_t *path = (wchar_t*)malloc(sizeof(wchar_t) * str_len);
96+
napi_get_value_string_utf16(env, tmp, (char16_t*)path, str_len, NULL);
97+
98+
LONGLONG start = 0;
99+
LONGLONG end = 0;
100+
napi_coerce_to_number(env, argv[1], &tmp);
101+
napi_get_value_int64(env, tmp, &start);
102+
napi_coerce_to_number(env, argv[2], &tmp);
103+
napi_get_value_int64(env, tmp, &end);
104+
105+
if (start < 0 || end <= 0) {
106+
napi_throw_error(env, SYB_EXP_INVAL, SYB_ERR_WRONG_ARGUMENTS);
107+
} else {
108+
napi_get_boolean(env, hole_punch(path, start, end), &result);
109+
}
110+
free(path);
111+
}
112+
}
113+
return result;
114+
}
115+
57116

58117
napi_value init(napi_env env, napi_value exports) {
59118
napi_value f;
60-
napi_create_function(env, NULL, 0, wrap_function, NULL, &f);
119+
napi_create_function(env, NULL, 0, wrap_set_function, NULL, &f);
61120
napi_set_named_property(env, exports, "setSparse", f);
62121

122+
napi_create_function(env, NULL, 0, wrap_punch_function, NULL, &f);
123+
napi_set_named_property(env, exports, "holePunch", f);
124+
63125
return exports;
64126
}
65127

test/holepunch.test.js

+107
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
const t = require('tap')
2+
const path = require('path')
3+
const os = require('os')
4+
const fs = require('fs')
5+
const { open, stat } = require('fs/promises')
6+
const fswin = require('fswin')
7+
const sparse = require('..')
8+
const assert = require('assert');
9+
10+
const A_ONE = Buffer.from([1])
11+
const TEN_MB = Buffer.alloc(100000000, 'helloworld')
12+
let i = 0
13+
14+
t.autoend(true)
15+
16+
if(process.platform === 'win32') {
17+
18+
t.test('basic functionality', async t => {
19+
let fd; // file descriptor
20+
21+
const _path = t.testdir({})
22+
const sparseFile = path.join(_path, 'sparse-file')
23+
24+
sparse.setSparse(sparseFile, true)
25+
fd = await open(sparseFile, 'r+')
26+
await fd.write(TEN_MB, 0, TEN_MB.length, 0)
27+
fd.close()
28+
29+
let fullsize = fswin.ntfs.getCompressedSizeSync(sparseFile)
30+
31+
// will leave a file with a non-zero byte at either end.
32+
sparse.holePunch(sparseFile, 1, TEN_MB.length)
33+
34+
let sparseSize = fswin.ntfs.getCompressedSizeSync(sparseFile)
35+
t.ok(
36+
sparseSize <= fullsize / 2,
37+
'filesize should be significantly smaller\n' +
38+
`(sparse size: ${sparseSize} regular size: ${fullsize})`
39+
)
40+
t.end()
41+
})
42+
43+
t.test('holepunch a basic file.', async t => {
44+
const _path=t.testdir({})
45+
const basicFile = path.join(_path, 'basic-file')
46+
47+
const fd = await open(basicFile, 'w+')
48+
await fd.write(TEN_MB, 0, TEN_MB.length, 0)
49+
fd.close()
50+
51+
sparse.setSparse(basicFile)
52+
sparse.holePunch(basicFile, 1, TEN_MB.length)
53+
54+
let stats = fswin.ntfs.getCompressedSizeSync(basicFile)
55+
t.ok(stats < TEN_MB.length, `file should be much smaller than 10mb (${stats})`)
56+
})
57+
58+
t.test('holepunch non-existant file.', async t => {
59+
const _path=t.testdir({})
60+
const basicFile = path.join(_path, 'basic-file')
61+
62+
t.notOk(sparse.holePunch(basicFile, 1, TEN_MB.length))
63+
})
64+
65+
t.test('handle invalid cases like', async t => {
66+
const _path = t.testdir({
67+
basic: 'helloworld'
68+
})
69+
const file = path.join(_path, 'basic')
70+
71+
t.test('wrong number of arguments', async t => {
72+
try {
73+
sparse.holePunch(file)
74+
t.fail('holePunch should throw with invalid parameters')
75+
} catch (err) {
76+
t.pass('ensure fails 1 arg')
77+
}
78+
try {
79+
sparse.holePunch(file, 0)
80+
t.fail('holePunch should throw with invalid parameters')
81+
} catch (err) {
82+
t.pass('ensure fails with 2 args')
83+
}
84+
})
85+
t.test('negative numbers', async t => {
86+
try {
87+
sparse.holePunch(file, -10, 1000)
88+
t.fail('holePunch should throw with invalid parameters')
89+
} catch (err) {
90+
t.pass('ensure fails with negative numbers')
91+
}
92+
})
93+
t.test('ranges outside of filesize', async t => {
94+
let stats = await stat(file)
95+
const size_before = stats.size
96+
assert(size_before < 999)
97+
sparse.holePunch(file, 0, 1000)
98+
stats = await stat(file)
99+
t.ok(stats.size === size_before, 'file should not get larger')
100+
})
101+
})
102+
103+
} else {
104+
105+
t.notOk(sparse.holePunch('./wont-be-created', 0, 1), 'On non-windows ensure no-op works')
106+
107+
}

test/sparse.test.js

+14-13
Original file line numberDiff line numberDiff line change
@@ -4,23 +4,21 @@ const os = require('os')
44
const fs = require('fs')
55
const { open } = require('fs/promises')
66
const fswin = require('fswin')
7-
const setSparse = require('..')
7+
const setSparse = require('..').setSparse
88

9-
const tmp = path.join(os.tmpdir(), 'set-sparse-' + process.pid + '-' + Date.now())
109
const A_ONE = Buffer.from([1])
1110
let i = 0
1211

1312
t.autoend(true)
14-
t.comment(tmp)
15-
fs.mkdirSync(tmp, { recursive: true })
1613

1714
if(process.platform === 'win32') {
1815

1916
t.test('basic functionality', async t => {
2017
let fd; // file descriptor
18+
const _path = t.testdir({})
2119

22-
const regularFile = path.join(tmp, String(i++))
23-
const sparseFile = path.join(tmp, String(i++))
20+
const regularFile = path.join(_path, String('regular-file'))
21+
const sparseFile = path.join(_path, String('sparse-file'))
2422
fd = await open(regularFile, 'w+')
2523
await fd.write(A_ONE, 0, 1, 0)
2624
await fd.write(A_ONE, 0, 1, 10000000)
@@ -34,21 +32,24 @@ if(process.platform === 'win32') {
3432

3533
const sparseSize = fswin.ntfs.getCompressedSizeSync(sparseFile)
3634
const regularSize = fswin.ntfs.getCompressedSizeSync(regularFile)
37-
t.comment('sparse size: ' + sparseSize +
38-
' regular size: ' + regularSize)
39-
t.ok(sparseSize < regularSize / 2, 'sparse file should use far less blocks')
40-
t.end()
35+
t.ok(
36+
sparseSize < regularSize / 2,
37+
'sparse file should use far less blocks\n'+
38+
`(sparse size: ${sparseSize} regular size: ${regularSize})`
39+
)
4140
})
4241

4342
t.test('creation flag', t => {
43+
const _path = t.testdir({})
44+
4445
t.test('when true', async t => {
45-
const file = path.join(tmp, String(i++))
46+
const file = path.join(_path, String('when-true'))
4647
setSparse(file, true)
4748
t.ok(fs.accessSync(file) === undefined, 'ensure file is created')
4849
t.end()
4950
})
5051
t.test('when false', async t => {
51-
const file = path.join(tmp, String(i++))
52+
const file = path.join(_path, String('when-false'))
5253
setSparse(file, false)
5354
try {
5455
fs.accessSync(file)
@@ -59,7 +60,7 @@ if(process.platform === 'win32') {
5960
t.end()
6061
})
6162
t.test('when undefined', async t => {
62-
const file = path.join(tmp, String(i++))
63+
const file = path.join(_path, String('when-undefined'))
6364
setSparse(file)
6465
try {
6566
fs.accessSync(file)

0 commit comments

Comments
 (0)