Skip to content

Commit 87f4d07

Browse files
bakkotRafaelGSS
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 ab834a8 commit 87f4d07

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
@@ -1314,6 +1314,37 @@ characters directly to the `prefix` string. For instance, given a directory
13141314
`prefix` must end with a trailing platform-specific path separator
13151315
(`require('node:path').sep`).
13161316
1317+
### `fsPromises.mkdtempDisposable(prefix[, options])`
1318+
1319+
<!-- YAML
1320+
added: REPLACEME
1321+
-->
1322+
1323+
* `prefix` {string|Buffer|URL}
1324+
* `options` {string|Object}
1325+
* `encoding` {string} **Default:** `'utf8'`
1326+
* Returns: {Promise} Fulfills with a Promise for an async-disposable Object:
1327+
* `path` {string} The path of the created directory.
1328+
* `remove` {AsyncFunction} A function which removes the created directory.
1329+
* `[Symbol.asyncDispose]` {AsyncFunction} The same as `remove`.
1330+
1331+
The resulting Promise holds an async-disposable object whose `path` property
1332+
holds the created directory path. When the object is disposed, the directory
1333+
and its contents will be removed asynchronously if it still exists. If the
1334+
directory cannot be deleted, disposal will throw an error. The object has an
1335+
async `remove()` method which will perform the same task.
1336+
1337+
Both this function and the disposal function on the resulting object are
1338+
async, so it should be used with `await` + `await using` as in
1339+
`await using dir = await fsPromises.mkdtempDisposable('prefix')`.
1340+
1341+
<!-- TODO: link MDN docs for disposables once https://github.com/mdn/content/pull/38027 lands -->
1342+
1343+
For detailed information, see the documentation of [`fsPromises.mkdtemp()`][].
1344+
1345+
The optional `options` argument can be a string specifying an encoding, or an
1346+
object with an `encoding` property specifying the character encoding to use.
1347+
13171348
### `fsPromises.open(path, flags[, mode])`
13181349
13191350
<!-- YAML
@@ -5909,6 +5940,36 @@ this API: [`fs.mkdtemp()`][].
59095940
The optional `options` argument can be a string specifying an encoding, or an
59105941
object with an `encoding` property specifying the character encoding to use.
59115942
5943+
### `fs.mkdtempDisposableSync(prefix[, options])`
5944+
5945+
<!-- YAML
5946+
added: REPLACEME
5947+
-->
5948+
5949+
* `prefix` {string|Buffer|URL}
5950+
* `options` {string|Object}
5951+
* `encoding` {string} **Default:** `'utf8'`
5952+
* Returns: {Object} A disposable object:
5953+
* `path` {string} The path of the created directory.
5954+
* `remove` {Function} A function which removes the created directory.
5955+
* `[Symbol.dispose]` {Function} The same as `remove`.
5956+
5957+
Returns a disposable object whose `path` property holds the created directory
5958+
path. When the object is disposed, the directory and its contents will be
5959+
removed if it still exists. If the directory cannot be deleted, disposal will
5960+
throw an error. The object has a `remove()` method which will perform the same
5961+
task.
5962+
5963+
<!-- TODO: link MDN docs for disposables once https://github.com/mdn/content/pull/38027 lands -->
5964+
5965+
For detailed information, see the documentation of [`fs.mkdtemp()`][].
5966+
5967+
There is no callback-based version of this API because it is designed for use
5968+
with the `using` syntax.
5969+
5970+
The optional `options` argument can be a string specifying an encoding, or an
5971+
object with an `encoding` property specifying the character encoding to use.
5972+
59125973
### `fs.opendirSync(path[, options])`
59135974
59145975
<!-- YAML
@@ -8502,6 +8563,7 @@ the file contents.
85028563
[`fs.writev()`]: #fswritevfd-buffers-position-callback
85038564
[`fsPromises.access()`]: #fspromisesaccesspath-mode
85048565
[`fsPromises.copyFile()`]: #fspromisescopyfilesrc-dest-mode
8566+
[`fsPromises.mkdtemp()`]: #fspromisesmkdtempprefix-options
85058567
[`fsPromises.open()`]: #fspromisesopenpath-flags-mode
85068568
[`fsPromises.opendir()`]: #fspromisesopendirpath-options
85078569
[`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

@@ -3033,6 +3034,36 @@ function mkdtempSync(prefix, options) {
30333034
return binding.mkdtemp(prefix, options.encoding);
30343035
}
30353036

3037+
/**
3038+
* Synchronously creates a unique temporary directory.
3039+
* The returned value is a disposable object which removes the
3040+
* directory and its contents when disposed.
3041+
* @param {string | Buffer | URL} prefix
3042+
* @param {string | { encoding?: string; }} [options]
3043+
* @returns {object} A disposable object with a "path" property.
3044+
*/
3045+
function mkdtempDisposableSync(prefix, options) {
3046+
options = getOptions(options);
3047+
3048+
prefix = getValidatedPath(prefix, 'prefix');
3049+
warnOnNonPortableTemplate(prefix);
3050+
3051+
const path = binding.mkdtemp(prefix, options.encoding);
3052+
// Stash the full path in case of process.chdir()
3053+
const fullPath = pathModule.resolve(process.cwd(), path);
3054+
3055+
const remove = () => {
3056+
binding.rmSync(fullPath, 0 /* maxRetries */, true /* recursive */, 100 /* retryDelay */);
3057+
};
3058+
return {
3059+
path,
3060+
remove,
3061+
[SymbolDispose]() {
3062+
remove();
3063+
},
3064+
};
3065+
}
3066+
30363067
/**
30373068
* Asynchronously copies `src` to `dest`. By
30383069
* default, `dest` is overwritten if it already exists.
@@ -3238,6 +3269,7 @@ module.exports = fs = {
32383269
mkdirSync,
32393270
mkdtemp,
32403271
mkdtempSync,
3272+
mkdtempDisposableSync,
32413273
open,
32423274
openSync,
32433275
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)