Skip to content

Commit 9523c84

Browse files
bakkotjasnell
authored andcommitted
fs: add disposable mkdtempSync
PR-URL: nodejs#58516 Reviewed-By: Matteo Collina <[email protected]> Reviewed-By: James M Snell <[email protected]> Reviewed-By: Chengzhong Wu <[email protected]> Reviewed-By: LiviaMedeiros <[email protected]>
1 parent 1effb26 commit 9523c84

File tree

7 files changed

+329
-0
lines changed

7 files changed

+329
-0
lines changed

doc/api/fs.md

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1318,6 +1318,37 @@ characters directly to the `prefix` string. For instance, given a directory
13181318
`prefix` must end with a trailing platform-specific path separator
13191319
(`require('node:path').sep`).
13201320
1321+
### `fsPromises.mkdtempDisposable(prefix[, options])`
1322+
1323+
<!-- YAML
1324+
added: REPLACEME
1325+
-->
1326+
1327+
* `prefix` {string|Buffer|URL}
1328+
* `options` {string|Object}
1329+
* `encoding` {string} **Default:** `'utf8'`
1330+
* Returns: {Promise} Fulfills with a Promise for an async-disposable Object:
1331+
* `path` {string} The path of the created directory.
1332+
* `remove` {AsyncFunction} A function which removes the created directory.
1333+
* `[Symbol.asyncDispose]` {AsyncFunction} The same as `remove`.
1334+
1335+
The resulting Promise holds an async-disposable object whose `path` property
1336+
holds the created directory path. When the object is disposed, the directory
1337+
and its contents will be removed asynchronously if it still exists. If the
1338+
directory cannot be deleted, disposal will throw an error. The object has an
1339+
async `remove()` method which will perform the same task.
1340+
1341+
Both this function and the disposal function on the resulting object are
1342+
async, so it should be used with `await` + `await using` as in
1343+
`await using dir = await fsPromises.mkdtempDisposable('prefix')`.
1344+
1345+
<!-- TODO: link MDN docs for disposables once https://github.com/mdn/content/pull/38027 lands -->
1346+
1347+
For detailed information, see the documentation of [`fsPromises.mkdtemp()`][].
1348+
1349+
The optional `options` argument can be a string specifying an encoding, or an
1350+
object with an `encoding` property specifying the character encoding to use.
1351+
13211352
### `fsPromises.open(path, flags[, mode])`
13221353
13231354
<!-- YAML
@@ -5917,6 +5948,36 @@ this API: [`fs.mkdtemp()`][].
59175948
The optional `options` argument can be a string specifying an encoding, or an
59185949
object with an `encoding` property specifying the character encoding to use.
59195950
5951+
### `fs.mkdtempDisposableSync(prefix[, options])`
5952+
5953+
<!-- YAML
5954+
added: REPLACEME
5955+
-->
5956+
5957+
* `prefix` {string|Buffer|URL}
5958+
* `options` {string|Object}
5959+
* `encoding` {string} **Default:** `'utf8'`
5960+
* Returns: {Object} A disposable object:
5961+
* `path` {string} The path of the created directory.
5962+
* `remove` {Function} A function which removes the created directory.
5963+
* `[Symbol.dispose]` {Function} The same as `remove`.
5964+
5965+
Returns a disposable object whose `path` property holds the created directory
5966+
path. When the object is disposed, the directory and its contents will be
5967+
removed if it still exists. If the directory cannot be deleted, disposal will
5968+
throw an error. The object has a `remove()` method which will perform the same
5969+
task.
5970+
5971+
<!-- TODO: link MDN docs for disposables once https://github.com/mdn/content/pull/38027 lands -->
5972+
5973+
For detailed information, see the documentation of [`fs.mkdtemp()`][].
5974+
5975+
There is no callback-based version of this API because it is designed for use
5976+
with the `using` syntax.
5977+
5978+
The optional `options` argument can be a string specifying an encoding, or an
5979+
object with an `encoding` property specifying the character encoding to use.
5980+
59205981
### `fs.opendirSync(path[, options])`
59215982
59225983
<!-- YAML
@@ -8511,6 +8572,7 @@ the file contents.
85118572
[`fs.writev()`]: #fswritevfd-buffers-position-callback
85128573
[`fsPromises.access()`]: #fspromisesaccesspath-mode
85138574
[`fsPromises.copyFile()`]: #fspromisescopyfilesrc-dest-mode
8575+
[`fsPromises.mkdtemp()`]: #fspromisesmkdtempprefix-options
85148576
[`fsPromises.open()`]: #fspromisesopenpath-flags-mode
85158577
[`fsPromises.opendir()`]: #fspromisesopendirpath-options
85168578
[`fsPromises.rm()`]: #fspromisesrmpath-options

lib/fs.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ const {
4242
StringPrototypeCharCodeAt,
4343
StringPrototypeIndexOf,
4444
StringPrototypeSlice,
45+
SymbolDispose,
4546
uncurryThis,
4647
} = primordials;
4748

@@ -3009,6 +3010,36 @@ function mkdtempSync(prefix, options) {
30093010
return binding.mkdtemp(prefix, options.encoding);
30103011
}
30113012

3013+
/**
3014+
* Synchronously creates a unique temporary directory.
3015+
* The returned value is a disposable object which removes the
3016+
* directory and its contents when disposed.
3017+
* @param {string | Buffer | URL} prefix
3018+
* @param {string | { encoding?: string; }} [options]
3019+
* @returns {object} A disposable object with a "path" property.
3020+
*/
3021+
function mkdtempDisposableSync(prefix, options) {
3022+
options = getOptions(options);
3023+
3024+
prefix = getValidatedPath(prefix, 'prefix');
3025+
warnOnNonPortableTemplate(prefix);
3026+
3027+
const path = binding.mkdtemp(prefix, options.encoding);
3028+
// Stash the full path in case of process.chdir()
3029+
const fullPath = pathModule.resolve(process.cwd(), path);
3030+
3031+
const remove = () => {
3032+
binding.rmSync(fullPath, 0 /* maxRetries */, true /* recursive */, 100 /* retryDelay */);
3033+
};
3034+
return {
3035+
path,
3036+
remove,
3037+
[SymbolDispose]() {
3038+
remove();
3039+
},
3040+
};
3041+
}
3042+
30123043
/**
30133044
* Asynchronously copies `src` to `dest`. By
30143045
* default, `dest` is overwritten if it already exists.
@@ -3214,6 +3245,7 @@ module.exports = fs = {
32143245
mkdirSync,
32153246
mkdtemp,
32163247
mkdtempSync,
3248+
mkdtempDisposableSync,
32173249
open,
32183250
openSync,
32193251
openAsBlob,

lib/internal/fs/promises.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1188,6 +1188,39 @@ async function mkdtemp(prefix, options) {
11881188
);
11891189
}
11901190

1191+
async function mkdtempDisposable(prefix, options) {
1192+
options = getOptions(options);
1193+
1194+
prefix = getValidatedPath(prefix, 'prefix');
1195+
warnOnNonPortableTemplate(prefix);
1196+
1197+
const cwd = process.cwd();
1198+
const path = await PromisePrototypeThen(
1199+
binding.mkdtemp(prefix, options.encoding, kUsePromises),
1200+
undefined,
1201+
handleErrorFromBinding,
1202+
);
1203+
// Stash the full path in case of process.chdir()
1204+
const fullPath = pathModule.resolve(cwd, path);
1205+
1206+
const remove = async () => {
1207+
const rmrf = lazyRimRaf();
1208+
await rmrf(fullPath, {
1209+
maxRetries: 0,
1210+
recursive: true,
1211+
retryDelay: 0,
1212+
});
1213+
};
1214+
return {
1215+
__proto__: null,
1216+
path,
1217+
remove,
1218+
async [SymbolAsyncDispose]() {
1219+
await remove();
1220+
},
1221+
};
1222+
}
1223+
11911224
async function writeFile(path, data, options) {
11921225
options = getOptions(options, {
11931226
encoding: 'utf8',
@@ -1300,6 +1333,7 @@ module.exports = {
13001333
lutimes,
13011334
realpath,
13021335
mkdtemp,
1336+
mkdtempDisposable,
13031337
writeFile,
13041338
appendFile,
13051339
readFile,

test/fixtures/permission/fs-write.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,7 @@ const relativeProtectedFolder = process.env.RELATIVEBLOCKEDFOLDER;
199199
});
200200
}
201201

202+
// fs.mkdtemp
202203
{
203204
assert.throws(() => {
204205
fs.mkdtempSync(path.join(blockedFolder, 'any-folder'));
@@ -212,6 +213,16 @@ const relativeProtectedFolder = process.env.RELATIVEBLOCKEDFOLDER;
212213
}));
213214
}
214215

216+
// fs.mkdtempDisposableSync
217+
{
218+
assert.throws(() => {
219+
fs.mkdtempDisposableSync(path.join(blockedFolder, 'any-folder'));
220+
},{
221+
code: 'ERR_ACCESS_DENIED',
222+
permission: 'FileSystemWrite',
223+
});
224+
}
225+
215226
// fs.rename
216227
{
217228
assert.throws(() => {
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
'use strict';
2+
3+
const common = require('../common');
4+
const assert = require('assert');
5+
const fs = require('fs');
6+
const path = require('path');
7+
const { isMainThread } = require('worker_threads');
8+
9+
const tmpdir = require('../common/tmpdir');
10+
tmpdir.refresh();
11+
12+
// Basic usage
13+
{
14+
const result = fs.mkdtempDisposableSync(tmpdir.resolve('foo.'));
15+
16+
assert.strictEqual(path.basename(result.path).length, 'foo.XXXXXX'.length);
17+
assert.strictEqual(path.dirname(result.path), tmpdir.path);
18+
assert(fs.existsSync(result.path));
19+
20+
result.remove();
21+
22+
assert(!fs.existsSync(result.path));
23+
24+
// Second removal does not throw error
25+
result.remove();
26+
}
27+
28+
// Usage with [Symbol.dispose]()
29+
{
30+
const result = fs.mkdtempDisposableSync(tmpdir.resolve('foo.'));
31+
32+
assert(fs.existsSync(result.path));
33+
34+
result[Symbol.dispose]();
35+
36+
assert(!fs.existsSync(result.path));
37+
38+
// Second removal does not throw error
39+
result[Symbol.dispose]();
40+
}
41+
42+
// `chdir`` does not affect removal
43+
// Can't use chdir in workers
44+
if (isMainThread) {
45+
const originalCwd = process.cwd();
46+
47+
process.chdir(tmpdir.path);
48+
const first = fs.mkdtempDisposableSync('first.');
49+
const second = fs.mkdtempDisposableSync('second.');
50+
51+
const fullFirstPath = path.join(tmpdir.path, first.path);
52+
const fullSecondPath = path.join(tmpdir.path, second.path);
53+
54+
assert(fs.existsSync(fullFirstPath));
55+
assert(fs.existsSync(fullSecondPath));
56+
57+
process.chdir(fullFirstPath);
58+
second.remove();
59+
60+
assert(!fs.existsSync(fullSecondPath));
61+
62+
process.chdir(tmpdir.path);
63+
first.remove();
64+
assert(!fs.existsSync(fullFirstPath));
65+
66+
process.chdir(originalCwd);
67+
}
68+
69+
// Errors from cleanup are thrown
70+
// It is difficult to arrange for rmdir to fail on windows
71+
if (!common.isWindows) {
72+
const base = fs.mkdtempDisposableSync(tmpdir.resolve('foo.'));
73+
74+
// On Unix we can prevent removal by making the parent directory read-only
75+
const child = fs.mkdtempDisposableSync(path.join(base.path, 'bar.'));
76+
77+
const originalMode = fs.statSync(base.path).mode;
78+
fs.chmodSync(base.path, 0o444);
79+
80+
assert.throws(() => {
81+
child.remove();
82+
}, /EACCES|EPERM/);
83+
84+
fs.chmodSync(base.path, originalMode);
85+
86+
// Removal works once permissions are reset
87+
child.remove();
88+
assert(!fs.existsSync(child.path));
89+
90+
base.remove();
91+
assert(!fs.existsSync(base.path));
92+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
'use strict';
2+
3+
const common = require('../common');
4+
const assert = require('assert');
5+
const fs = require('fs');
6+
const fsPromises = require('fs/promises');
7+
const path = require('path');
8+
const { isMainThread } = require('worker_threads');
9+
10+
const tmpdir = require('../common/tmpdir');
11+
tmpdir.refresh();
12+
13+
async function basicUsage() {
14+
const result = await fsPromises.mkdtempDisposable(tmpdir.resolve('foo.'));
15+
16+
assert.strictEqual(path.basename(result.path).length, 'foo.XXXXXX'.length);
17+
assert.strictEqual(path.dirname(result.path), tmpdir.path);
18+
assert(fs.existsSync(result.path));
19+
20+
await result.remove();
21+
22+
assert(!fs.existsSync(result.path));
23+
24+
// Second removal does not throw error
25+
result.remove();
26+
}
27+
28+
async function symbolAsyncDispose() {
29+
const result = await fsPromises.mkdtempDisposable(tmpdir.resolve('foo.'));
30+
31+
assert(fs.existsSync(result.path));
32+
33+
await result[Symbol.asyncDispose]();
34+
35+
assert(!fs.existsSync(result.path));
36+
37+
// Second removal does not throw error
38+
await result[Symbol.asyncDispose]();
39+
}
40+
41+
async function chdirDoesNotAffectRemoval() {
42+
// Can't use chdir in workers
43+
if (!isMainThread) return;
44+
45+
const originalCwd = process.cwd();
46+
47+
process.chdir(tmpdir.path);
48+
const first = await fsPromises.mkdtempDisposable('first.');
49+
const second = await fsPromises.mkdtempDisposable('second.');
50+
51+
const fullFirstPath = path.join(tmpdir.path, first.path);
52+
const fullSecondPath = path.join(tmpdir.path, second.path);
53+
54+
assert(fs.existsSync(fullFirstPath));
55+
assert(fs.existsSync(fullSecondPath));
56+
57+
process.chdir(fullFirstPath);
58+
await second.remove();
59+
60+
assert(!fs.existsSync(fullSecondPath));
61+
62+
process.chdir(tmpdir.path);
63+
await first.remove();
64+
assert(!fs.existsSync(fullFirstPath));
65+
66+
process.chdir(originalCwd);
67+
}
68+
69+
async function errorsAreReThrown() {
70+
// It is difficult to arrange for rmdir to fail on windows
71+
if (common.isWindows) return;
72+
const base = await fsPromises.mkdtempDisposable(tmpdir.resolve('foo.'));
73+
74+
// On Unix we can prevent removal by making the parent directory read-only
75+
const child = await fsPromises.mkdtempDisposable(path.join(base.path, 'bar.'));
76+
77+
const originalMode = fs.statSync(base.path).mode;
78+
fs.chmodSync(base.path, 0o444);
79+
80+
await assert.rejects(child.remove(), /EACCES|EPERM/);
81+
82+
fs.chmodSync(base.path, originalMode);
83+
84+
// Removal works once permissions are reset
85+
await child.remove();
86+
assert(!fs.existsSync(child.path));
87+
88+
await base.remove();
89+
assert(!fs.existsSync(base.path));
90+
}
91+
92+
(async () => {
93+
await basicUsage();
94+
await symbolAsyncDispose();
95+
await chdirDoesNotAffectRemoval();
96+
await errorsAreReThrown();
97+
})().then(common.mustCall());

0 commit comments

Comments
 (0)