Skip to content

Commit 818173d

Browse files
bakkotaduh95
authored andcommitted
fs: add disposable mkdtempSync
PR-URL: #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 f1a2477 commit 818173d

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
@@ -1308,6 +1308,37 @@ characters directly to the `prefix` string. For instance, given a directory
13081308
`prefix` must end with a trailing platform-specific path separator
13091309
(`require('node:path').sep`).
13101310
1311+
### `fsPromises.mkdtempDisposable(prefix[, options])`
1312+
1313+
<!-- YAML
1314+
added: REPLACEME
1315+
-->
1316+
1317+
* `prefix` {string|Buffer|URL}
1318+
* `options` {string|Object}
1319+
* `encoding` {string} **Default:** `'utf8'`
1320+
* Returns: {Promise} Fulfills with a Promise for an async-disposable Object:
1321+
* `path` {string} The path of the created directory.
1322+
* `remove` {AsyncFunction} A function which removes the created directory.
1323+
* `[Symbol.asyncDispose]` {AsyncFunction} The same as `remove`.
1324+
1325+
The resulting Promise holds an async-disposable object whose `path` property
1326+
holds the created directory path. When the object is disposed, the directory
1327+
and its contents will be removed asynchronously if it still exists. If the
1328+
directory cannot be deleted, disposal will throw an error. The object has an
1329+
async `remove()` method which will perform the same task.
1330+
1331+
Both this function and the disposal function on the resulting object are
1332+
async, so it should be used with `await` + `await using` as in
1333+
`await using dir = await fsPromises.mkdtempDisposable('prefix')`.
1334+
1335+
<!-- TODO: link MDN docs for disposables once https://github.com/mdn/content/pull/38027 lands -->
1336+
1337+
For detailed information, see the documentation of [`fsPromises.mkdtemp()`][].
1338+
1339+
The optional `options` argument can be a string specifying an encoding, or an
1340+
object with an `encoding` property specifying the character encoding to use.
1341+
13111342
### `fsPromises.open(path, flags[, mode])`
13121343
13131344
<!-- YAML
@@ -5899,6 +5930,36 @@ this API: [`fs.mkdtemp()`][].
58995930
The optional `options` argument can be a string specifying an encoding, or an
59005931
object with an `encoding` property specifying the character encoding to use.
59015932
5933+
### `fs.mkdtempDisposableSync(prefix[, options])`
5934+
5935+
<!-- YAML
5936+
added: REPLACEME
5937+
-->
5938+
5939+
* `prefix` {string|Buffer|URL}
5940+
* `options` {string|Object}
5941+
* `encoding` {string} **Default:** `'utf8'`
5942+
* Returns: {Object} A disposable object:
5943+
* `path` {string} The path of the created directory.
5944+
* `remove` {Function} A function which removes the created directory.
5945+
* `[Symbol.dispose]` {Function} The same as `remove`.
5946+
5947+
Returns a disposable object whose `path` property holds the created directory
5948+
path. When the object is disposed, the directory and its contents will be
5949+
removed if it still exists. If the directory cannot be deleted, disposal will
5950+
throw an error. The object has a `remove()` method which will perform the same
5951+
task.
5952+
5953+
<!-- TODO: link MDN docs for disposables once https://github.com/mdn/content/pull/38027 lands -->
5954+
5955+
For detailed information, see the documentation of [`fs.mkdtemp()`][].
5956+
5957+
There is no callback-based version of this API because it is designed for use
5958+
with the `using` syntax.
5959+
5960+
The optional `options` argument can be a string specifying an encoding, or an
5961+
object with an `encoding` property specifying the character encoding to use.
5962+
59025963
### `fs.opendirSync(path[, options])`
59035964
59045965
<!-- YAML
@@ -8505,6 +8566,7 @@ the file contents.
85058566
[`fs.writev()`]: #fswritevfd-buffers-position-callback
85068567
[`fsPromises.access()`]: #fspromisesaccesspath-mode
85078568
[`fsPromises.copyFile()`]: #fspromisescopyfilesrc-dest-mode
8569+
[`fsPromises.mkdtemp()`]: #fspromisesmkdtempprefix-options
85088570
[`fsPromises.open()`]: #fspromisesopenpath-flags-mode
85098571
[`fsPromises.opendir()`]: #fspromisesopendirpath-options
85108572
[`fsPromises.rm()`]: #fspromisesrmpath-options

lib/fs.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ const {
9292
custom: kCustomPromisifiedSymbol,
9393
},
9494
SideEffectFreeRegExpPrototypeExec,
95+
SymbolDispose,
9596
defineLazyProperties,
9697
isWindows,
9798
isMacOS,
@@ -3049,6 +3050,36 @@ function mkdtempSync(prefix, options) {
30493050
return binding.mkdtemp(prefix, options.encoding);
30503051
}
30513052

3053+
/**
3054+
* Synchronously creates a unique temporary directory.
3055+
* The returned value is a disposable object which removes the
3056+
* directory and its contents when disposed.
3057+
* @param {string | Buffer | URL} prefix
3058+
* @param {string | { encoding?: string; }} [options]
3059+
* @returns {object} A disposable object with a "path" property.
3060+
*/
3061+
function mkdtempDisposableSync(prefix, options) {
3062+
options = getOptions(options);
3063+
3064+
prefix = getValidatedPath(prefix, 'prefix');
3065+
warnOnNonPortableTemplate(prefix);
3066+
3067+
const path = binding.mkdtemp(prefix, options.encoding);
3068+
// Stash the full path in case of process.chdir()
3069+
const fullPath = pathModule.resolve(process.cwd(), path);
3070+
3071+
const remove = () => {
3072+
rmSync(fullPath, { maxRetries: 0, recursive: true, force: true, retryDelay: 100 });
3073+
};
3074+
return {
3075+
path,
3076+
remove,
3077+
[SymbolDispose]() {
3078+
remove();
3079+
},
3080+
};
3081+
}
3082+
30523083
/**
30533084
* Asynchronously copies `src` to `dest`. By
30543085
* default, `dest` is overwritten if it already exists.
@@ -3261,6 +3292,7 @@ module.exports = fs = {
32613292
mkdirSync,
32623293
mkdtemp,
32633294
mkdtempSync,
3295+
mkdtempDisposableSync,
32643296
open,
32653297
openSync,
32663298
openAsBlob,

lib/internal/fs/promises.js

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

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