├── .editorconfig ├── .gitignore ├── license ├── package.json ├── readme.md ├── src ├── constants.ts ├── index.ts ├── types.ts └── utils │ ├── lang.ts │ ├── scheduler.ts │ └── temp.ts ├── tasks └── benchmark.js ├── test ├── basic.cjs ├── concurrency.cjs └── integration.cjs └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_size = 2 8 | indent_style = space 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | *.err 3 | *.log 4 | ._* 5 | .cache 6 | .fseventsd 7 | .DocumentRevisions* 8 | .DS_Store 9 | .TemporaryItems 10 | .Trashes 11 | Thumbs.db 12 | 13 | dist 14 | node_modules 15 | package-lock.json 16 | atomically.cjs 17 | .nyc_output 18 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020-present Fabio Spampinato 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a 6 | copy of this software and associated documentation files (the "Software"), 7 | to deal in the Software without restriction, including without limitation 8 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 9 | and/or sell copies of the Software, and to permit persons to whom the 10 | Software is furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 20 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 21 | DEALINGS IN THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "atomically", 3 | "repository": "github:fabiospampinato/atomically", 4 | "description": "Read and write files atomically and reliably.", 5 | "version": "2.0.3", 6 | "type": "module", 7 | "main": "dist/index.js", 8 | "exports": "./dist/index.js", 9 | "types": "./dist/index.d.ts", 10 | "scripts": { 11 | "benchmark": "tsex benchmark", 12 | "benchmarkLwatch": "tsex benchmark --watch", 13 | "clean": "tsex clean", 14 | "compile": "tsex compile", 15 | "compile:watch": "tsex compile --watch", 16 | "test:init": "esbuild --bundle --target=es2020 --platform=node --format=cjs src/index.ts > test/atomically.cjs", 17 | "test": "npm run test:init && tap --no-check-coverage --no-coverage-report", 18 | "test:watch": "npm run test:init && tap --no-check-coverage --no-coverage-report --watch", 19 | "prepublishOnly": "npm run clean && npm run compile && npm run test" 20 | }, 21 | "keywords": [ 22 | "atomic", 23 | "read", 24 | "write", 25 | "file", 26 | "reliable" 27 | ], 28 | "dependencies": { 29 | "stubborn-fs": "^1.2.5", 30 | "when-exit": "^2.1.1" 31 | }, 32 | "devDependencies": { 33 | "@types/node": "^20.4.6", 34 | "esbuild": "^0.18.17", 35 | "require-inject": "^1.4.4", 36 | "tap": "^16.3.8", 37 | "tsex": "^3.0.0", 38 | "typescript": "^5.1.6", 39 | "write-file-atomic": "^5.0.1" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Atomically 2 | 3 | Read and write files atomically and reliably. 4 | 5 | ## Features 6 | 7 | - Overview: 8 | - This library is a rewrite of [`write-file-atomic`](https://github.com/npm/write-file-atomic), with some important enhancements on top, you can largely use this as a drop-in replacement. 9 | - This library is written in TypeScript, so types aren't an afterthought but come with library. 10 | - This library is slightly faster than [`write-file-atomic`](https://github.com/npm/write-file-atomic), and it can be 10x faster, while being essentially just as safe, by using the `fsyncWait` option. 11 | - This library has 0 third-party dependencies, so there's less code to vet and the entire thing is roughly 20% smaller than [`write-file-atomic`](https://github.com/npm/write-file-atomic). 12 | - This library tries harder to write files on disk than [`write-file-atomic`](https://github.com/npm/write-file-atomic) does, by default retrying some failed operations and handling some more errors. 13 | - Reliability: 14 | - Reads are retried, when appropriate, until they succeed or the timeout is reached. 15 | - Writes are atomic, meaning that first a temporary file containing the new content is written, then this file is renamed to the final path, this way it's impossible to get a corrupt/partially-written file. 16 | - Writes happening to the same path are queued, ensuring they don't interfere with each other. 17 | - Temporary files can be configured to not be purged from disk if the write operation fails, which is useful for when keeping the temporary file is better than just losing data. 18 | - Any needed missing parent folder will be created automatically. 19 | - Symlinks are resolved automatically. 20 | - `ENOSYS` errors on `chmod`/`chown` operations are ignored. 21 | - `EINVAL`/`EPERM` errors on `chmod`/`chown` operations, in POSIX systems where the user is not root, are ignored. 22 | - `EMFILE`/`ENFILE`/`EAGAIN`/`EBUSY`/`EACCESS`/`EACCES`/`EACCS`/`EPERM` errors happening during necessary operations are caught and the operations are retried until they succeed or the timeout is reached. 23 | - `ENAMETOOLONG` errors, both appening because of the final path or the temporary path, are attempted to be worked around by smartly truncating paths. 24 | - Temporary files: 25 | - By default they are purged automatically once the write operation is completed or if the process exits (cleanly or not). 26 | - By default they are created by appending a `.tmp-[timestamp][randomness]` suffix to destination paths: 27 | - The `tmp-` part gives users a hint about the nature of these files, if they happen to see them. 28 | - The `[timestamp]` part consists of the 10 least significant digits of a milliseconds-precise timestamp, making it likely that if more than one of these files are kept on disk the user will see them in chronological order. 29 | - The `[randomness]` part consists of 6 random hex characters. 30 | - If by any chance a collision is found then another suffix is generated. 31 | - Custom options: 32 | - `chown`: it allows you to specify custom group and user ids: 33 | - by default the old file's ids are copied over. 34 | - if custom ids are provided they will be used. 35 | - if `false` the default ids are used. 36 | - `encoding`: it allows you to specify the encoding of the file content: 37 | - by default when reading no encoding is specified and a raw buffer is returned. 38 | - by default when writing `utf8` is used when. 39 | - `fsync`: it allows you to control whether the `fsync` syscall is triggered right after writing the file or not: 40 | - by default the syscall is triggered immediately after writing the file, increasing the chances that the file will actually be written to disk in case of imminent catastrophic failures, like power outages. 41 | - if `false` the syscall won't be triggered. 42 | - `fsyncWait`: it allows you to control whether the triggered `fsync` is waited or not: 43 | - by default the syscall is waited. 44 | - if `false` the syscall will still be triggered but not be waited. 45 | - this increases performance 10x in some cases, and at the end of the day often there's no plan B if `fsync` fails anyway. 46 | - `mode`: it allows you to specify the mode for the file: 47 | - by default the old file's mode is copied over. 48 | - if `false` then `0o666` is used. 49 | - `schedule`: it's a function that returns a promise that resolves to a disposer function, basically it allows you to provide some custom queueing logic for the writing operation, allowing you to perhaps wire `atomically` with your app's main filesystem job scheduler: 50 | - even when a custom `schedule` function is provided write operations will still be queued internally by the library too. 51 | - `timeout`: it allows you to specify the amount of maximum milliseconds within which the library will retry some failed operations: 52 | - when writing asynchronously by default it will keep retrying for 7500 milliseconds. 53 | - when writing synchronously by default it will keep retrying for 1000 milliseconds. 54 | - if `0` or `-1` no failed operations will be retried. 55 | - if another number is provided that will be the timeout interval. 56 | - `tmpCreate`: it's a function that will be used to create the custom temporary file path in place of the default one: 57 | - even when a custom function is provided the final temporary path will still be truncated if the library thinks that it may lead to `ENAMETOOLONG` errors. 58 | - paths by default are truncated in a way that preserves an eventual existing leading dot and trailing extension. 59 | - `tmpCreated`: it's a function that will be called with the newly created temporary file path. 60 | - `tmpPurge`: it allows you to control whether the temporary file will be purged from the filesystem or not if the write fails: 61 | - by default it will be purged. 62 | - if `false` it will be kept on disk. 63 | 64 | ## Install 65 | 66 | ```sh 67 | npm install --save atomically 68 | ``` 69 | 70 | ## Usage 71 | 72 | This is the shape of the optional options object: 73 | 74 | ```ts 75 | type Disposer = () => void; 76 | 77 | type ReadOptions = string | { 78 | encoding?: string | null, 79 | mode?: string | number | false, 80 | timeout?: number 81 | }; 82 | 83 | type WriteOptions = string | { 84 | chown?: { gid: number, uid: number } | false, 85 | encoding?: string | null, 86 | fsync?: boolean, 87 | fsyncWait?: boolean, 88 | mode?: string | number | false, 89 | schedule?: ( filePath: string ) => Promise, 90 | timeout?: number, 91 | tmpCreate?: ( filePath: string ) => string, 92 | tmpCreated?: ( filePath: string ) => any, 93 | tmpPurge?: boolean 94 | }; 95 | ``` 96 | 97 | This is the shape of the provided functions: 98 | 99 | ```ts 100 | function readFile ( filePath: string, options?: ReadOptions ): Promise; 101 | function readFileSync ( filePath: string, options?: ReadOptions ): Buffer | string; 102 | function writeFile ( filePath: string, data: Buffer | string | undefined, options?: WriteOptions ): Promise; 103 | function writeFileSync ( filePath: string, data: Buffer | string | undefined, options?: WriteOptions ): void; 104 | ``` 105 | 106 | This is how to use the library: 107 | 108 | ```ts 109 | import {readFile, readFileSync, writeFile, writeFileSync} from 'atomically'; 110 | 111 | // Asynchronous read with default option 112 | const buffer = await readFile ( '/foo.txt' ); 113 | 114 | // Synchronous read assuming the encoding is "utf8" 115 | const string = readFileSync ( '/foo.txt', 'utf8' ); 116 | 117 | // Asynchronous write with default options 118 | await writeFile ( '/foo.txt', 'my_data' ); 119 | 120 | // Asynchronous write that doesn't prod the old file for a stat object at all 121 | await writeFile ( '/foo.txt', 'my_data', { chown: false, mode: false } ); 122 | 123 | // 10x faster asynchronous write that's less resilient against imminent catastrophies 124 | await writeFile ( '/foo.txt', 'my_data', { fsync: false } ); 125 | 126 | // 10x faster asynchronous write that's essentially still as resilient against imminent catastrophies 127 | await writeFile ( '/foo.txt', 'my_data', { fsyncWait: false } ); 128 | 129 | // Asynchronous write with a custom schedule function 130 | await writeFile ( '/foo.txt', 'my_data', { 131 | schedule: filePath => { 132 | return new Promise ( resolve => { // When this returned promise will resolve the write operation will begin 133 | MyScheduler.schedule ( filePath, () => { // Hypothetical scheduler function that will eventually tell us to go on with this write operation 134 | const disposer = () => {}; // Hypothetical function that contains eventual clean-up logic, it will be called after the write operation has been completed (successfully or not) 135 | resolve ( disposer ); // Resolving the promise with a disposer, beginning the write operation 136 | }) 137 | }); 138 | } 139 | }); 140 | 141 | // Synchronous write with default options 142 | writeFileSync ( '/foo.txt', 'my_data' ); 143 | ``` 144 | 145 | ## License 146 | 147 | MIT © Fabio Spampinato 148 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | 2 | /* IMPORT */ 3 | 4 | import os from 'node:os'; 5 | import process from 'node:process'; 6 | 7 | /* MAIN */ 8 | 9 | const DEFAULT_ENCODING = 'utf8'; 10 | 11 | const DEFAULT_FILE_MODE = 0o666; 12 | 13 | const DEFAULT_FOLDER_MODE = 0o777; 14 | 15 | const DEFAULT_READ_OPTIONS = {}; 16 | 17 | const DEFAULT_WRITE_OPTIONS = {}; 18 | 19 | const DEFAULT_USER_UID = os.userInfo ().uid; 20 | 21 | const DEFAULT_USER_GID = os.userInfo ().gid; 22 | 23 | const DEFAULT_TIMEOUT_ASYNC = 7500; 24 | 25 | const DEFAULT_TIMEOUT_SYNC = 1000; 26 | 27 | const IS_POSIX = !!process.getuid; 28 | 29 | const IS_USER_ROOT = process.getuid ? !process.getuid () : false; 30 | 31 | const LIMIT_BASENAME_LENGTH = 128; //TODO: Fetch the real limit from the filesystem //TODO: Fetch the whole-path length limit too 32 | 33 | const LIMIT_FILES_DESCRIPTORS = 10_000; //TODO: Fetch the real limit from the filesystem 34 | 35 | const NOOP = () => {}; 36 | 37 | /* EXPORT */ 38 | 39 | export {DEFAULT_ENCODING, DEFAULT_FILE_MODE, DEFAULT_FOLDER_MODE, DEFAULT_READ_OPTIONS, DEFAULT_WRITE_OPTIONS, DEFAULT_USER_UID, DEFAULT_USER_GID, DEFAULT_TIMEOUT_ASYNC, DEFAULT_TIMEOUT_SYNC, IS_POSIX, IS_USER_ROOT, LIMIT_BASENAME_LENGTH, LIMIT_FILES_DESCRIPTORS, NOOP}; 40 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | 2 | /* IMPORT */ 3 | 4 | import path from 'node:path'; 5 | import fs from 'stubborn-fs'; 6 | import {DEFAULT_ENCODING, DEFAULT_FILE_MODE, DEFAULT_FOLDER_MODE, DEFAULT_READ_OPTIONS, DEFAULT_WRITE_OPTIONS, DEFAULT_USER_UID, DEFAULT_USER_GID, DEFAULT_TIMEOUT_ASYNC, DEFAULT_TIMEOUT_SYNC, IS_POSIX} from './constants'; 7 | import {isException, isFunction, isString, isUndefined} from './utils/lang'; 8 | import Scheduler from './utils/scheduler'; 9 | import Temp from './utils/temp'; 10 | import type {Callback, Data, Disposer, Encoding, Path, ReadOptions, WriteOptions} from './types'; 11 | 12 | /* MAIN */ 13 | 14 | function readFile ( filePath: Path, options: Encoding | ReadOptions & { encoding: string } ): Promise; 15 | function readFile ( filePath: Path, options?: ReadOptions ): Promise; 16 | function readFile ( filePath: Path, options: Encoding | ReadOptions = DEFAULT_READ_OPTIONS ): Promise { 17 | 18 | if ( isString ( options ) ) return readFile ( filePath, { encoding: options } ); 19 | 20 | const timeout = Date.now () + ( ( options.timeout ?? DEFAULT_TIMEOUT_ASYNC ) || -1 ); 21 | 22 | return fs.retry.readFile ( timeout )( filePath, options ); 23 | 24 | } 25 | 26 | function readFileSync ( filePath: Path, options: Encoding | ReadOptions & { encoding: string } ): string; 27 | function readFileSync ( filePath: Path, options?: ReadOptions ): Buffer; 28 | function readFileSync ( filePath: Path, options: Encoding | ReadOptions = DEFAULT_READ_OPTIONS ): Buffer | string { 29 | 30 | if ( isString ( options ) ) return readFileSync ( filePath, { encoding: options } ); 31 | 32 | const timeout = Date.now () + ( ( options.timeout ?? DEFAULT_TIMEOUT_SYNC ) || -1 ); 33 | 34 | return fs.retry.readFileSync ( timeout )( filePath, options ); 35 | 36 | } 37 | 38 | function writeFile ( filePath: Path, data: Data, callback?: Callback ): Promise; 39 | function writeFile ( filePath: Path, data: Data, options?: Encoding | WriteOptions, callback?: Callback ): Promise; 40 | function writeFile ( filePath: Path, data: Data, options?: Encoding | WriteOptions | Callback, callback?: Callback ): Promise { 41 | 42 | if ( isFunction ( options ) ) return writeFile ( filePath, data, DEFAULT_WRITE_OPTIONS, options ); 43 | 44 | const promise = writeFileAsync ( filePath, data, options ); 45 | 46 | if ( callback ) promise.then ( callback, callback ); 47 | 48 | return promise; 49 | 50 | } 51 | 52 | async function writeFileAsync ( filePath: Path, data: Data, options: Encoding | WriteOptions = DEFAULT_WRITE_OPTIONS ): Promise { 53 | 54 | if ( isString ( options ) ) return writeFileAsync ( filePath, data, { encoding: options } ); 55 | 56 | const timeout = Date.now () + ( ( options.timeout ?? DEFAULT_TIMEOUT_ASYNC ) || -1 ); 57 | 58 | let schedulerCustomDisposer: Disposer | null = null; 59 | let schedulerDisposer: Disposer | null = null; 60 | let tempDisposer: Disposer | null = null; 61 | let tempPath: string | null = null; 62 | let fd: number | null = null; 63 | 64 | try { 65 | 66 | if ( options.schedule ) schedulerCustomDisposer = await options.schedule ( filePath ); 67 | 68 | schedulerDisposer = await Scheduler.schedule ( filePath ); 69 | 70 | const filePathReal = await fs.attempt.realpath ( filePath ); 71 | const filePathExists = !!filePathReal; 72 | 73 | filePath = filePathReal || filePath; 74 | 75 | [tempPath, tempDisposer] = Temp.get ( filePath, options.tmpCreate || Temp.create, !( options.tmpPurge === false ) ); 76 | 77 | const useStatChown = IS_POSIX && isUndefined ( options.chown ); 78 | const useStatMode = isUndefined ( options.mode ); 79 | 80 | if ( filePathExists && ( useStatChown || useStatMode ) ) { 81 | 82 | const stats = await fs.attempt.stat ( filePath ); 83 | 84 | if ( stats ) { 85 | 86 | options = { ...options }; 87 | 88 | if ( useStatChown ) { 89 | 90 | options.chown = { uid: stats.uid, gid: stats.gid }; 91 | 92 | } 93 | 94 | if ( useStatMode ) { 95 | 96 | options.mode = stats.mode; 97 | 98 | } 99 | 100 | } 101 | 102 | } 103 | 104 | if ( !filePathExists ) { 105 | 106 | const parentPath = path.dirname ( filePath ); 107 | 108 | await fs.attempt.mkdir ( parentPath, { 109 | mode: DEFAULT_FOLDER_MODE, 110 | recursive: true 111 | }); 112 | 113 | } 114 | 115 | fd = await fs.retry.open ( timeout )( tempPath, 'w', options.mode || DEFAULT_FILE_MODE ); 116 | 117 | if ( options.tmpCreated ) { 118 | 119 | options.tmpCreated ( tempPath ); 120 | 121 | } 122 | 123 | if ( isString ( data ) ) { 124 | 125 | await fs.retry.write ( timeout )( fd, data, 0, options.encoding || DEFAULT_ENCODING ); 126 | 127 | } else if ( !isUndefined ( data ) ) { 128 | 129 | await fs.retry.write ( timeout )( fd, data, 0, data.length, 0 ); 130 | 131 | } 132 | 133 | if ( options.fsync !== false ) { 134 | 135 | if ( options.fsyncWait !== false ) { 136 | 137 | await fs.retry.fsync ( timeout )( fd ); 138 | 139 | } else { 140 | 141 | fs.attempt.fsync ( fd ); 142 | 143 | } 144 | 145 | } 146 | 147 | await fs.retry.close ( timeout )( fd ); 148 | 149 | fd = null; 150 | 151 | if ( options.chown && ( options.chown.uid !== DEFAULT_USER_UID || options.chown.gid !== DEFAULT_USER_GID ) ) { 152 | 153 | await fs.attempt.chown ( tempPath, options.chown.uid, options.chown.gid ); 154 | 155 | } 156 | 157 | if ( options.mode && options.mode !== DEFAULT_FILE_MODE ) { 158 | 159 | await fs.attempt.chmod ( tempPath, options.mode ); 160 | 161 | } 162 | 163 | try { 164 | 165 | await fs.retry.rename ( timeout )( tempPath, filePath ); 166 | 167 | } catch ( error: unknown ) { 168 | 169 | if ( !isException ( error ) ) throw error; 170 | 171 | if ( error.code !== 'ENAMETOOLONG' ) throw error; 172 | 173 | await fs.retry.rename ( timeout )( tempPath, Temp.truncate ( filePath ) ); 174 | 175 | } 176 | 177 | tempDisposer (); 178 | 179 | tempPath = null; 180 | 181 | } finally { 182 | 183 | if ( fd ) await fs.attempt.close ( fd ); 184 | 185 | if ( tempPath ) Temp.purge ( tempPath ); 186 | 187 | if ( schedulerCustomDisposer ) schedulerCustomDisposer (); 188 | 189 | if ( schedulerDisposer ) schedulerDisposer (); 190 | 191 | } 192 | 193 | } 194 | 195 | function writeFileSync ( filePath: Path, data: Data, options: Encoding | WriteOptions = DEFAULT_WRITE_OPTIONS ): void { 196 | 197 | if ( isString ( options ) ) return writeFileSync ( filePath, data, { encoding: options } ); 198 | 199 | const timeout = Date.now () + ( ( options.timeout ?? DEFAULT_TIMEOUT_SYNC ) || -1 ); 200 | 201 | let tempDisposer: Disposer | null = null; 202 | let tempPath: string | null = null; 203 | let fd: number | null = null; 204 | 205 | try { 206 | 207 | const filePathReal = fs.attempt.realpathSync ( filePath ); 208 | const filePathExists = !!filePathReal; 209 | 210 | filePath = filePathReal || filePath; 211 | 212 | [tempPath, tempDisposer] = Temp.get ( filePath, options.tmpCreate || Temp.create, !( options.tmpPurge === false ) ); 213 | 214 | const useStatChown = IS_POSIX && isUndefined ( options.chown ); 215 | const useStatMode = isUndefined ( options.mode ); 216 | 217 | if ( filePathExists && ( useStatChown || useStatMode ) ) { 218 | 219 | const stats = fs.attempt.statSync ( filePath ); 220 | 221 | if ( stats ) { 222 | 223 | options = { ...options }; 224 | 225 | if ( useStatChown ) { 226 | 227 | options.chown = { uid: stats.uid, gid: stats.gid }; 228 | 229 | } 230 | 231 | if ( useStatMode ) { 232 | 233 | options.mode = stats.mode; 234 | 235 | } 236 | 237 | } 238 | 239 | } 240 | 241 | if ( !filePathExists ) { 242 | 243 | const parentPath = path.dirname ( filePath ); 244 | 245 | fs.attempt.mkdirSync ( parentPath, { 246 | mode: DEFAULT_FOLDER_MODE, 247 | recursive: true 248 | }); 249 | 250 | } 251 | 252 | fd = fs.retry.openSync ( timeout )( tempPath, 'w', options.mode || DEFAULT_FILE_MODE ); 253 | 254 | if ( options.tmpCreated ) { 255 | 256 | options.tmpCreated ( tempPath ); 257 | 258 | } 259 | 260 | if ( isString ( data ) ) { 261 | 262 | fs.retry.writeSync ( timeout )( fd, data, 0, options.encoding || DEFAULT_ENCODING ); 263 | 264 | } else if ( !isUndefined ( data ) ) { 265 | 266 | fs.retry.writeSync ( timeout )( fd, data, 0, data.length, 0 ); 267 | 268 | } 269 | 270 | if ( options.fsync !== false ) { 271 | 272 | if ( options.fsyncWait !== false ) { 273 | 274 | fs.retry.fsyncSync ( timeout )( fd ); 275 | 276 | } else { 277 | 278 | fs.attempt.fsync ( fd ); 279 | 280 | } 281 | 282 | } 283 | 284 | fs.retry.closeSync ( timeout )( fd ); 285 | 286 | fd = null; 287 | 288 | if ( options.chown && ( options.chown.uid !== DEFAULT_USER_UID || options.chown.gid !== DEFAULT_USER_GID ) ) { 289 | 290 | fs.attempt.chownSync ( tempPath, options.chown.uid, options.chown.gid ); 291 | 292 | } 293 | 294 | if ( options.mode && options.mode !== DEFAULT_FILE_MODE ) { 295 | 296 | fs.attempt.chmodSync ( tempPath, options.mode ); 297 | 298 | } 299 | 300 | try { 301 | 302 | fs.retry.renameSync ( timeout )( tempPath, filePath ); 303 | 304 | } catch ( error: unknown ) { 305 | 306 | if ( !isException ( error ) ) throw error; 307 | 308 | if ( error.code !== 'ENAMETOOLONG' ) throw error; 309 | 310 | fs.retry.renameSync ( timeout )( tempPath, Temp.truncate ( filePath ) ); 311 | 312 | } 313 | 314 | tempDisposer (); 315 | 316 | tempPath = null; 317 | 318 | } finally { 319 | 320 | if ( fd ) fs.attempt.closeSync ( fd ); 321 | 322 | if ( tempPath ) Temp.purge ( tempPath ); 323 | 324 | } 325 | 326 | } 327 | 328 | /* EXPORT */ 329 | 330 | export {readFile, readFileSync, writeFile, writeFileSync}; 331 | export type {Encoding, ReadOptions, WriteOptions}; 332 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | 2 | /* MAIN */ 3 | 4 | type Callback = ( error: Exception | void ) => void; 5 | 6 | type Data = Uint8Array | string | undefined; 7 | 8 | type Disposer = () => void; 9 | 10 | type Encoding = 'ascii' | 'base64' | 'binary' | 'hex' | 'latin1' | 'utf8' | 'utf-8' | 'utf16le' | 'ucs2' | 'ucs-2'; 11 | 12 | type Exception = NodeJS.ErrnoException; 13 | 14 | type Path = string; 15 | 16 | type ReadOptions = { 17 | encoding?: Encoding | null, 18 | mode?: string | number | false, 19 | timeout?: number 20 | }; 21 | 22 | type WriteOptions = { 23 | chown?: { gid: number, uid: number } | false, 24 | encoding?: Encoding | null, 25 | fsync?: boolean, 26 | fsyncWait?: boolean, 27 | mode?: string | number | false, 28 | schedule?: ( filePath: string ) => Promise, 29 | timeout?: number, 30 | tmpCreate?: ( filePath: string ) => string, 31 | tmpCreated?: ( filePath: string ) => void, 32 | tmpPurge?: boolean 33 | }; 34 | 35 | /* EXPORT */ 36 | 37 | export type {Callback, Data, Disposer, Encoding, Exception, Path, ReadOptions, WriteOptions}; 38 | -------------------------------------------------------------------------------- /src/utils/lang.ts: -------------------------------------------------------------------------------- 1 | 2 | /* IMPORT */ 3 | 4 | import type {Exception} from '../types'; 5 | 6 | /* MAIN */ 7 | 8 | const isException = ( value: unknown ): value is Exception => { 9 | 10 | return ( value instanceof Error ) && ( 'code' in value ); 11 | 12 | }; 13 | 14 | const isFunction = ( value: unknown ): value is Function => { 15 | 16 | return ( typeof value === 'function' ); 17 | 18 | }; 19 | 20 | const isString = ( value: unknown ): value is string => { 21 | 22 | return ( typeof value === 'string' ); 23 | 24 | }; 25 | 26 | const isUndefined = ( value: unknown ): value is undefined => { 27 | 28 | return ( value === undefined ); 29 | 30 | }; 31 | 32 | /* EXPORT */ 33 | 34 | export {isException, isFunction, isString, isUndefined}; 35 | -------------------------------------------------------------------------------- /src/utils/scheduler.ts: -------------------------------------------------------------------------------- 1 | 2 | /* IMPORT */ 3 | 4 | import type {Disposer} from '../types'; 5 | 6 | /* HELPERS */ 7 | 8 | const Queues: Record = {}; 9 | 10 | /* MAIN */ 11 | 12 | //TODO: Maybe publish this as a standalone package 13 | 14 | const Scheduler = { 15 | 16 | /* API */ 17 | 18 | next: ( id: string ): void => { 19 | 20 | const queue = Queues[id]; 21 | 22 | if ( !queue ) return; 23 | 24 | queue.shift (); 25 | 26 | const job = queue[0]; 27 | 28 | if ( job ) { 29 | 30 | job ( () => Scheduler.next ( id ) ); 31 | 32 | } else { 33 | 34 | delete Queues[id]; 35 | 36 | } 37 | 38 | }, 39 | 40 | schedule: ( id: string ): Promise => { 41 | 42 | return new Promise ( resolve => { 43 | 44 | let queue = Queues[id]; 45 | 46 | if ( !queue ) queue = Queues[id] = []; 47 | 48 | queue.push ( resolve ); 49 | 50 | if ( queue.length > 1 ) return; 51 | 52 | resolve ( () => Scheduler.next ( id ) ); 53 | 54 | }); 55 | 56 | } 57 | 58 | }; 59 | 60 | /* EXPORT */ 61 | 62 | export default Scheduler; 63 | -------------------------------------------------------------------------------- /src/utils/temp.ts: -------------------------------------------------------------------------------- 1 | 2 | /* IMPORT */ 3 | 4 | import path from 'node:path'; 5 | import fs from 'stubborn-fs'; 6 | import whenExit from 'when-exit'; 7 | import {LIMIT_BASENAME_LENGTH} from '../constants'; 8 | import type {Disposer} from '../types'; 9 | 10 | /* MAIN */ 11 | 12 | //TODO: Maybe publish this as a standalone package 13 | 14 | const Temp = { 15 | 16 | /* VARIABLES */ 17 | 18 | store: > {}, // filePath => purge 19 | 20 | /* API */ 21 | 22 | create: ( filePath: string ): string => { 23 | 24 | const randomness = `000000${Math.floor ( Math.random () * 16777215 ).toString ( 16 )}`.slice ( -6 ); // 6 random-enough hex characters 25 | const timestamp = Date.now ().toString ().slice ( -10 ); // 10 precise timestamp digits 26 | const prefix = 'tmp-'; 27 | const suffix = `.${prefix}${timestamp}${randomness}`; 28 | const tempPath = `${filePath}${suffix}`; 29 | 30 | return tempPath; 31 | 32 | }, 33 | 34 | get: ( filePath: string, creator: ( filePath: string ) => string, purge: boolean = true ): [string, Disposer] => { 35 | 36 | const tempPath = Temp.truncate ( creator ( filePath ) ); 37 | 38 | if ( tempPath in Temp.store ) return Temp.get ( filePath, creator, purge ); // Collision found, try again 39 | 40 | Temp.store[tempPath] = purge; 41 | 42 | const disposer = () => delete Temp.store[tempPath]; 43 | 44 | return [tempPath, disposer]; 45 | 46 | }, 47 | 48 | purge: ( filePath: string ): void => { 49 | 50 | if ( !Temp.store[filePath] ) return; 51 | 52 | delete Temp.store[filePath]; 53 | 54 | fs.attempt.unlink ( filePath ); 55 | 56 | }, 57 | 58 | purgeSync: ( filePath: string ): void => { 59 | 60 | if ( !Temp.store[filePath] ) return; 61 | 62 | delete Temp.store[filePath]; 63 | 64 | fs.attempt.unlinkSync ( filePath ); 65 | 66 | }, 67 | 68 | purgeSyncAll: (): void => { 69 | 70 | for ( const filePath in Temp.store ) { 71 | 72 | Temp.purgeSync ( filePath ); 73 | 74 | } 75 | 76 | }, 77 | 78 | truncate: ( filePath: string ): string => { // Truncating paths to avoid getting an "ENAMETOOLONG" error //FIXME: This doesn't really always work, the actual filesystem limits must be detected for this to be implemented correctly 79 | 80 | const basename = path.basename ( filePath ); 81 | 82 | if ( basename.length <= LIMIT_BASENAME_LENGTH ) return filePath; //FIXME: Rough and quick attempt at detecting ok lengths 83 | 84 | const truncable = /^(\.?)(.*?)((?:\.[^.]+)?(?:\.tmp-\d{10}[a-f0-9]{6})?)$/.exec ( basename ); 85 | 86 | if ( !truncable ) return filePath; //FIXME: No truncable part detected, can't really do much without also changing the parent path, which is unsafe, hoping for the best here 87 | 88 | const truncationLength = basename.length - LIMIT_BASENAME_LENGTH; 89 | 90 | return `${filePath.slice ( 0, - basename.length )}${truncable[1]}${truncable[2].slice ( 0, - truncationLength )}${truncable[3]}`; //FIXME: The truncable part might be shorter than needed here 91 | 92 | } 93 | 94 | }; 95 | 96 | /* INIT */ 97 | 98 | whenExit ( Temp.purgeSyncAll ); // Ensuring purgeable temp files are purged on exit 99 | 100 | /* EXPORT */ 101 | 102 | export default Temp; 103 | -------------------------------------------------------------------------------- /tasks/benchmark.js: -------------------------------------------------------------------------------- 1 | 2 | /* IMPORT */ 3 | 4 | import {randomUUID} from 'node:crypto'; 5 | import fs from 'node:fs'; 6 | import os from 'node:os'; 7 | import path from 'node:path'; 8 | import {setTimeout as delay} from 'node:timers/promises'; 9 | import writeFileAtomic from 'write-file-atomic'; 10 | import {writeFile, writeFileSync} from '../dist/index.js'; 11 | 12 | /* MAIN */ 13 | 14 | const TEMP = os.tmpdir (); 15 | const UUID = randomUUID (); 16 | const DST = i => path.join ( TEMP, `atomically-${UUID}-temp-${i}.txt` ); 17 | const ITERATIONS = 250; 18 | 19 | const runSingleAsync = async ( name, fn, buffer, options ) => { 20 | console.time ( name ); 21 | for ( let i = 0; i < ITERATIONS; i++ ) { 22 | await fn ( DST ( i ), buffer, options ); 23 | } 24 | console.timeEnd ( name ); 25 | await delay ( 1000 ); 26 | }; 27 | 28 | const runSingleSync = async ( name, fn, buffer, options ) => { 29 | console.time ( name ); 30 | for ( let i = 0; i < ITERATIONS; i++ ) { 31 | fn ( DST ( i ), buffer, options ); 32 | } 33 | console.timeEnd ( name ); 34 | await delay ( 1000 ); 35 | }; 36 | 37 | const runAllDummy = () => { // Preparation run 38 | runSingleSync ( 'dummy', fs.writeFileSync, '' ); 39 | }; 40 | 41 | const runAllAsync = async ( name, buffer ) => { 42 | await runSingleAsync ( `${name} -> async -> write-file-atomic`, writeFileAtomic, buffer, { mode: 0o666 } ); 43 | await runSingleAsync ( `${name} -> async -> write-file-atomic (faster)`, writeFileAtomic, buffer ); 44 | await runSingleAsync ( `${name} -> async -> write-file-atomic (fastest)`, writeFileAtomic, buffer, { fsync: false } ); 45 | await runSingleAsync ( `${name} -> async -> atomically`, writeFile, buffer ); 46 | await runSingleAsync ( `${name} -> async -> atomically (faster)`, writeFile, buffer, { mode: false, chown: false, fsyncWait: false } ); 47 | await runSingleAsync ( `${name} -> async -> atomically (fastest)`, writeFile, buffer, { mode: false, chown: false, fsync: false } ); 48 | }; 49 | 50 | const runAllSync = ( name, buffer ) => { 51 | runSingleSync ( `${name} -> sync -> write-file-atomic`, writeFileAtomic.sync, buffer, { mode: 0o666 } ); 52 | runSingleSync ( `${name} -> sync -> write-file-atomic (faster)`, writeFileAtomic.sync, buffer ); 53 | runSingleSync ( `${name} -> sync -> write-file-atomic (fastest)`, writeFileAtomic.sync, buffer, { fsync: false } ); 54 | runSingleSync ( `${name} -> sync -> atomically`, writeFileSync, buffer ); 55 | runSingleSync ( `${name} -> sync -> atomically (faster)`, writeFileSync, buffer, { mode: false, chown: false, fsyncWait: false } ); 56 | runSingleSync ( `${name} -> sync -> atomically (fastest)`, writeFileSync, buffer, { mode: false, chown: false, fsync: false } ); 57 | }; 58 | 59 | const runAll = async ( name, buffer ) => { 60 | await runAllAsync ( name, buffer ); 61 | console.log ( '-------------------' ); 62 | runAllSync ( name, buffer ); 63 | }; 64 | 65 | const run = async () => { 66 | runAllDummy (); 67 | console.log ( '===================' ); 68 | await runAll ( '100kb', Buffer.allocUnsafe ( 100 * 1024 ) ); 69 | console.log ( '===================' ); 70 | await runAll ( '10kb', Buffer.allocUnsafe ( 10 * 1024 ) ); 71 | console.log ( '===================' ); 72 | await runAll ( '1kb', Buffer.allocUnsafe ( 1024 ) ); 73 | console.log ( '===================' ); 74 | }; 75 | 76 | run (); 77 | -------------------------------------------------------------------------------- /test/basic.cjs: -------------------------------------------------------------------------------- 1 | process.setMaxListeners(1000000); 2 | 3 | const fs = require('fs') 4 | const os = require('os') 5 | const path = require('path') 6 | const {test} = require('tap') 7 | const requireInject = require('require-inject') 8 | 9 | let expectClose = 0 10 | let closeCalled = 0 11 | let expectCloseSync = 0 12 | let closeSyncCalled = 0 13 | const createErr = code => Object.assign(new Error(code), { code }) 14 | 15 | let unlinked = [] 16 | 17 | const fsMock = Object.assign ( {}, fs, { 18 | /* ASYNC */ 19 | mkdir (filename, opts, cb) { 20 | return cb(null); 21 | }, 22 | realpath (filename, cb) { 23 | return cb(null, filename) 24 | }, 25 | open (tmpfile, options, mode, cb) { 26 | if (/noopen/.test(tmpfile)) return cb(createErr('ENOOPEN')) 27 | expectClose++ 28 | cb(null, tmpfile) 29 | }, 30 | write (fd) { 31 | const cb = arguments[arguments.length - 1] 32 | if (/nowrite/.test(fd)) return cb(createErr('ENOWRITE')) 33 | cb() 34 | }, 35 | fsync (fd, cb) { 36 | if (/nofsync/.test(fd)) return cb(createErr('ENOFSYNC')) 37 | cb() 38 | }, 39 | close (fd, cb) { 40 | closeCalled++ 41 | cb() 42 | }, 43 | chown (tmpfile, uid, gid, cb) { 44 | if (/nochown/.test(tmpfile)) return cb(createErr('ENOCHOWN')) 45 | if (/enosys/.test(tmpfile)) return cb(createErr('ENOSYS')) 46 | if (/einval/.test(tmpfile)) return cb(createErr('EINVAL')) 47 | if (/eperm/.test(tmpfile)) return cb(createErr('EPERM')) 48 | cb() 49 | }, 50 | chmod (tmpfile, mode, cb) { 51 | if (/nochmod/.test(tmpfile)) return cb(createErr('ENOCHMOD')) 52 | if (/enosys/.test(tmpfile)) return cb(createErr('ENOSYS')) 53 | if (/eperm/.test(tmpfile)) return cb(createErr('EPERM')) 54 | if (/einval/.test(tmpfile)) return cb(createErr('EINVAL')) 55 | cb() 56 | }, 57 | rename (tmpfile, filename, cb) { 58 | if (/norename/.test(tmpfile)) return cb(createErr('ENORENAME')) 59 | cb() 60 | }, 61 | unlink (tmpfile, cb) { 62 | if (/nounlink/.test(tmpfile)) return cb(createErr('ENOUNLINK')) 63 | cb() 64 | }, 65 | stat (tmpfile, cb) { 66 | if (/nostat/.test(tmpfile)) return cb(createErr('ENOSTAT')) 67 | if (/statful/.test(tmpfile)) return cb(null, fs.statSync('/')); 68 | cb() 69 | }, 70 | /* SYNC */ 71 | mkdirSync (filename) {}, 72 | realpathSync (filename, cb) { 73 | return filename 74 | }, 75 | openSync (tmpfile, options) { 76 | if (/noopen/.test(tmpfile)) throw createErr('ENOOPEN') 77 | expectCloseSync++ 78 | return tmpfile 79 | }, 80 | writeSync (fd) { 81 | if (/nowrite/.test(fd)) throw createErr('ENOWRITE') 82 | }, 83 | fsyncSync (fd) { 84 | if (/nofsync/.test(fd)) throw createErr('ENOFSYNC') 85 | }, 86 | closeSync (fd) { 87 | closeSyncCalled++ 88 | }, 89 | chownSync (tmpfile, uid, gid) { 90 | if (/nochown/.test(tmpfile)) throw createErr('ENOCHOWN') 91 | if (/enosys/.test(tmpfile)) throw createErr('ENOSYS') 92 | if (/einval/.test(tmpfile)) throw createErr('EINVAL') 93 | if (/eperm/.test(tmpfile)) throw createErr('EPERM') 94 | }, 95 | chmodSync (tmpfile, mode) { 96 | if (/nochmod/.test(tmpfile)) throw createErr('ENOCHMOD') 97 | if (/enosys/.test(tmpfile)) throw createErr('ENOSYS') 98 | if (/einval/.test(tmpfile)) throw createErr('EINVAL') 99 | if (/eperm/.test(tmpfile)) throw createErr('EPERM') 100 | }, 101 | renameSync (tmpfile, filename) { 102 | if (/norename/.test(tmpfile)) throw createErr('ENORENAME') 103 | }, 104 | unlinkSync (tmpfile) { 105 | if (/nounlink/.test(tmpfile)) throw createErr('ENOUNLINK') 106 | unlinked.push(tmpfile) 107 | }, 108 | statSync (tmpfile) { 109 | if (/nostat/.test(tmpfile)) throw createErr('ENOSTAT') 110 | if (/statful/.test(tmpfile)) return fs.statSync('/'); 111 | } 112 | }); 113 | 114 | const makeUnstableAsyncFn = function () { 115 | return function () { 116 | if ( Math.random () <= .75 ) { 117 | const code = ['EMFILE', 'ENFILE', 'EAGAIN', 'EBUSY', 'EACCESS', 'EPERM'].sort ( () => Math.random () - .5 )[0]; 118 | throw createErr ( code ); 119 | } 120 | return arguments[arguments.length -1](null, arguments[0]); 121 | }; 122 | }; 123 | 124 | const makeUnstableSyncFn = function ( fn ) { 125 | return function () { 126 | if ( Math.random () <= .75 ) { 127 | const code = ['EMFILE', 'ENFILE', 'EAGAIN', 'EBUSY', 'EACCESS', 'EPERM'].sort ( () => Math.random () - .5 )[0]; 128 | throw createErr ( code ); 129 | } 130 | return fn.apply(undefined, arguments) 131 | }; 132 | }; 133 | 134 | const fsMockUnstable = Object.assign ( {}, fsMock, { 135 | open: makeUnstableAsyncFn (), 136 | write: makeUnstableAsyncFn (), 137 | fsync: makeUnstableAsyncFn (), 138 | close: makeUnstableAsyncFn (), 139 | rename: makeUnstableAsyncFn (), 140 | openSync: makeUnstableSyncFn ( x => x ), 141 | writeSync: makeUnstableSyncFn ( () => {} ), 142 | fsyncSync: makeUnstableSyncFn ( () => {} ), 143 | closeSync: makeUnstableSyncFn ( () => {} ), 144 | renameSync: makeUnstableSyncFn ( () => {} ) 145 | }); 146 | 147 | const {writeFile: writeFileAtomic, writeFileSync: writeFileAtomicSync} = requireInject('./atomically.cjs', { fs: fsMock }); 148 | 149 | test('async tests', t => { 150 | t.plan(2) 151 | 152 | expectClose = 0 153 | closeCalled = 0 154 | t.teardown(() => { 155 | t.parent.equal(closeCalled, expectClose, 'async tests closed all files') 156 | expectClose = 0 157 | closeCalled = 0 158 | }) 159 | 160 | t.test('non-root tests', t => { 161 | t.plan(28) 162 | 163 | writeFileAtomic('good', 'test', { mode: '0777' }, err => { 164 | t.notOk(err, 'No errors occur when passing in options') 165 | }) 166 | writeFileAtomic('good', 'test', 'utf8', err => { 167 | t.notOk(err, 'No errors occur when passing in options as string') 168 | }) 169 | writeFileAtomic('good', 'test', undefined, err => { 170 | t.notOk(err, 'No errors occur when NOT passing in options') 171 | }) 172 | writeFileAtomic('good', 'test', err => { 173 | t.notOk(err) 174 | }) 175 | writeFileAtomic('noopen', 'test', err => { 176 | t.equal(err.message, 'ENOOPEN', 'fs.open failures propagate') 177 | }) 178 | writeFileAtomic('nowrite', 'test', err => { 179 | t.equal(err.message, 'ENOWRITE', 'fs.writewrite failures propagate') 180 | }) 181 | writeFileAtomic('nowrite', Buffer.from('test', 'utf8'), err => { 182 | t.equal(err.message, 'ENOWRITE', 'fs.writewrite failures propagate for buffers') 183 | }) 184 | writeFileAtomic('nochown', 'test', { chown: { uid: 100, gid: 100 } }, err => { 185 | t.equal(err.message, 'ENOCHOWN', 'Chown failures propagate') 186 | }) 187 | writeFileAtomic('nochown', 'test', err => { 188 | t.notOk(err, 'No attempt to chown when no uid/gid passed in') 189 | }) 190 | writeFileAtomic('nochmod', 'test', { mode: parseInt('741', 8) }, err => { 191 | t.equal(err.message, 'ENOCHMOD', 'Chmod failures propagate') 192 | }) 193 | writeFileAtomic('nofsyncopt', 'test', { fsync: false }, err => { 194 | t.notOk(err, 'fsync skipped if options.fsync is false') 195 | }) 196 | writeFileAtomic('norename', 'test', err => { 197 | t.equal(err.message, 'ENORENAME', 'Rename errors propagate') 198 | }) 199 | writeFileAtomic('norename nounlink', 'test', err => { 200 | t.equal(err.message, 'ENORENAME', 'Failure to unlink the temp file does not clobber the original error') 201 | }) 202 | writeFileAtomic('nofsync', 'test', err => { 203 | t.equal(err.message, 'ENOFSYNC', 'Fsync failures propagate') 204 | }) 205 | writeFileAtomic('enosys', 'test', err => { 206 | t.notOk(err, 'No errors on ENOSYS') 207 | }) 208 | writeFileAtomic('einval', 'test', { mode: 0o741 }, err => { 209 | t.notOk(err, 'No errors on EINVAL for non root') 210 | }) 211 | writeFileAtomic('eperm', 'test', { mode: 0o741 }, err => { 212 | t.notOk(err, 'No errors on EPERM for non root') 213 | }) 214 | writeFileAtomic('einval', 'test', { chown: { uid: 100, gid: 100 } }, err => { 215 | t.notOk(err, 'No errors on EINVAL for non root') 216 | }) 217 | writeFileAtomic('eperm', 'test', { chown: { uid: 100, gid: 100 } }, err => { 218 | t.notOk(err, 'No errors on EPERM for non root') 219 | }) 220 | const optionsImmutable = {}; 221 | writeFileAtomic('statful', 'test', optionsImmutable, err => { 222 | t.notOk(err); 223 | t.same(optionsImmutable, {}); 224 | }); 225 | const schedule = filePath => { 226 | t.equal(filePath, 'good'); 227 | return new Promise ( resolve => { 228 | resolve ( () => { 229 | t.equal(true,true); 230 | }); 231 | }); 232 | }; 233 | writeFileAtomic('good','test', {schedule}, err => { 234 | t.notOk(err); 235 | }); 236 | const tmpCreate = filePath => `.${filePath}.custom`; 237 | const tmpCreated = filePath => t.equal(filePath, '.good.custom' ); 238 | writeFileAtomic('good','test', {tmpCreate, tmpCreated}, err => { 239 | t.notOk(err) 240 | }) 241 | const longPath = path.join(os.tmpdir(),'.012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789.txt'); 242 | const {writeFile: writeFileAtomicNative} = requireInject('./atomically.cjs', { fs }); 243 | writeFileAtomicNative(longPath,'test', err => { 244 | t.notOk(err) 245 | }) 246 | const pathMissingFolders = path.join(os.tmpdir(),String(Math.random()),String(Math.random()),String(Math.random()),'foo.txt'); 247 | writeFileAtomicNative(pathMissingFolders,'test', err => { 248 | t.notOk(err) 249 | }) 250 | }) 251 | 252 | t.test('errors for root', t => { 253 | const { getuid } = process 254 | process.getuid = () => 0 255 | t.teardown(() => { 256 | process.getuid = getuid 257 | }) 258 | const {writeFile: writeFileAtomic} = requireInject('./atomically.cjs', { fs: fsMock }); 259 | t.plan(2) 260 | writeFileAtomic('einval', 'test', { chown: { uid: 100, gid: 100 } }, err => { 261 | t.match(err, { code: 'EINVAL' }) 262 | }) 263 | writeFileAtomic('einval', 'test', { mode: 0o741 }, err => { 264 | t.match(err, { code: 'EINVAL' }) 265 | }) 266 | }) 267 | }) 268 | 269 | test('unstable async tests', t => { 270 | t.plan(2); 271 | const {writeFile: writeFileAtomic} = requireInject('./atomically.cjs', { fs: fsMockUnstable }); 272 | writeFileAtomic('good', 'test', err => { 273 | t.notOk(err, 'No errors occur when retryable errors are thrown') 274 | }) 275 | writeFileAtomic('good', 'test', { timeout: 0 }, err => { 276 | t.equal(!!err.code, true, 'Retrying can be disabled') 277 | }) 278 | }); 279 | 280 | test('sync tests', t => { 281 | t.plan(2) 282 | closeSyncCalled = 0 283 | expectCloseSync = 0 284 | t.teardown(() => { 285 | t.parent.equal(closeSyncCalled, expectCloseSync, 'sync closed all files') 286 | expectCloseSync = 0 287 | closeSyncCalled = 0 288 | }) 289 | 290 | const throws = function (t, shouldthrow, msg, todo) { 291 | let err 292 | try { todo() } catch (e) { err = e } 293 | t.equal(shouldthrow, err.message, msg) 294 | } 295 | const noexception = function (t, msg, todo) { 296 | let err 297 | try { todo() } catch (e) { err = e } 298 | t.error(err, msg) 299 | } 300 | let tmpfile 301 | 302 | t.test('non-root', t => { 303 | t.plan(38) 304 | noexception(t, 'No errors occur when passing in options', () => { 305 | writeFileAtomicSync('good', 'test', { mode: '0777' }) 306 | }) 307 | noexception(t, 'No errors occur when passing in options as string', () => { 308 | writeFileAtomicSync('good', 'test', 'utf8') 309 | }) 310 | noexception(t, 'No errors occur when NOT passing in options', () => { 311 | writeFileAtomicSync('good', 'test') 312 | }) 313 | noexception(t, 'fsync never called if options.fsync is falsy', () => { 314 | writeFileAtomicSync('good', 'test', { fsync: false }) 315 | }) 316 | noexception(t, 'tmpCreated is called on success', () => { 317 | writeFileAtomicSync('good', 'test', { 318 | tmpCreated (gottmpfile) { 319 | tmpfile = gottmpfile 320 | } 321 | }) 322 | t.match(tmpfile, /^good\.tmp-\w+$/, 'tmpCreated called for success') 323 | t.match(tmpfile, /^good\.tmp-\d{10}[a-f0-9]{6}$/, 'tmpCreated format') 324 | }) 325 | 326 | tmpfile = undefined 327 | throws(t, 'ENOOPEN', 'fs.openSync failures propagate', () => { 328 | writeFileAtomicSync('noopen', 'test', { 329 | tmpCreated (gottmpfile) { 330 | tmpfile = gottmpfile 331 | } 332 | }) 333 | }) 334 | t.equal(tmpfile, undefined, 'tmpCreated not called for open failure') 335 | 336 | throws(t, 'ENOWRITE', 'fs.writeSync failures propagate', () => { 337 | writeFileAtomicSync('nowrite', 'test', { 338 | tmpCreated (gottmpfile) { 339 | tmpfile = gottmpfile 340 | } 341 | }) 342 | }) 343 | t.match(tmpfile, /^nowrite\.tmp-\w+$/, 'tmpCreated called for failure after open') 344 | 345 | throws(t, 'ENOCHOWN', 'Chown failures propagate', () => { 346 | writeFileAtomicSync('nochown', 'test', { chown: { uid: 100, gid: 100 } }) 347 | }) 348 | noexception(t, 'No attempt to chown when false passed in', () => { 349 | writeFileAtomicSync('nochown', 'test', { chown: false }) 350 | }) 351 | noexception(t, 'No errors occured when chown is undefined and original file owner used', () => { 352 | writeFileAtomicSync('chowncopy', 'test', { chown: undefined }) 353 | }) 354 | throws(t, 'ENORENAME', 'Rename errors propagate', () => { 355 | writeFileAtomicSync('norename', 'test') 356 | }) 357 | throws(t, 'ENORENAME', 'Failure to unlink the temp file does not clobber the original error', () => { 358 | writeFileAtomicSync('norename nounlink', 'test') 359 | }) 360 | throws(t, 'ENOFSYNC', 'Fsync errors propagate', () => { 361 | writeFileAtomicSync('nofsync', 'test') 362 | }) 363 | noexception(t, 'No errors on ENOSYS', () => { 364 | writeFileAtomicSync('enosys', 'test', { chown: { uid: 100, gid: 100 } }) 365 | }) 366 | noexception(t, 'No errors on EINVAL for non root', () => { 367 | writeFileAtomicSync('einval', 'test', { chown: { uid: 100, gid: 100 } }) 368 | }) 369 | noexception(t, 'No errors on EPERM for non root', () => { 370 | writeFileAtomicSync('eperm', 'test', { chown: { uid: 100, gid: 100 } }) 371 | }) 372 | 373 | throws(t, 'ENOCHMOD', 'Chmod failures propagate', () => { 374 | writeFileAtomicSync('nochmod', 'test', { mode: 0o741 }) 375 | }) 376 | noexception(t, 'No errors on EPERM for non root', () => { 377 | writeFileAtomicSync('eperm', 'test', { mode: 0o741 }) 378 | }) 379 | noexception(t, 'No attempt to chmod when no mode provided', () => { 380 | writeFileAtomicSync('nochmod', 'test', { mode: false }) 381 | }) 382 | const optionsImmutable = {}; 383 | noexception(t, 'options are immutable', () => { 384 | writeFileAtomicSync('statful', 'test', optionsImmutable) 385 | }) 386 | t.same(optionsImmutable, {}); 387 | const tmpCreate = filePath => `.${filePath}.custom`; 388 | const tmpCreated = filePath => t.equal(filePath, '.good.custom' ); 389 | noexception(t, 'custom temp creator', () => { 390 | writeFileAtomicSync('good', 'test', {tmpCreate, tmpCreated}) 391 | }) 392 | const path0 = path.join(os.tmpdir(),'atomically-test-0'); 393 | const tmpPath0 = path0 + '.temp'; 394 | noexception(t, 'temp files are purged on success', () => { 395 | const {writeFileSync: writeFileAtomicSync} = requireInject('./atomically.cjs', { fs }); 396 | writeFileAtomicSync(path0, 'test', {tmpCreate: () => tmpPath0}) 397 | }) 398 | t.equal(true,fs.existsSync(path0)); 399 | t.equal(false,fs.existsSync(tmpPath0)); 400 | const path1 = path.join(os.tmpdir(),'atomically-test-norename-1'); 401 | const tmpPath1 = path1 + '.temp'; 402 | throws(t, 'ENORENAME', 'temp files are purged on error', () => { 403 | const {writeFileSync: writeFileAtomicSync} = requireInject('./atomically.cjs', { fs: Object.assign ( {}, fs, { renameSync: fsMock.renameSync })}); 404 | writeFileAtomicSync(path1, 'test', {tmpCreate: () => tmpPath1}) 405 | }) 406 | t.equal(false,fs.existsSync(path1)); 407 | t.equal(false,fs.existsSync(tmpPath1)); 408 | const path2 = path.join(os.tmpdir(),'atomically-test-norename-2'); 409 | const tmpPath2 = path2 + '.temp'; 410 | throws(t, 'ENORENAME', 'temp files can also not be purged on error', () => { 411 | const {writeFileSync: writeFileAtomicSync} = requireInject('./atomically.cjs', { fs: Object.assign ( {}, fs, { renameSync: fsMock.renameSync })}); 412 | writeFileAtomicSync(path2, 'test', {tmpCreate: () => tmpPath2,tmpPurge: false}) 413 | }) 414 | t.equal(false,fs.existsSync(path2)); 415 | t.equal(true,fs.existsSync(tmpPath2)); 416 | const longPath = path.join(os.tmpdir(),'.012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789.txt'); 417 | noexception(t, 'temp files are truncated', () => { 418 | const {writeFileSync: writeFileAtomicSync} = requireInject('./atomically.cjs', { fs }); 419 | writeFileAtomicSync(longPath, 'test') 420 | }) 421 | const pathMissingFolders = path.join(os.tmpdir(),String(Math.random()),String(Math.random()),String(Math.random()),'foo.txt'); 422 | noexception(t, 'parent folders are created', () => { 423 | const {writeFileSync: writeFileAtomicSync} = requireInject('./atomically.cjs', { fs }); 424 | writeFileAtomicSync(pathMissingFolders, 'test') 425 | }) 426 | }) 427 | 428 | t.test('errors for root', t => { 429 | const { getuid } = process 430 | process.getuid = () => 0 431 | t.teardown(() => { 432 | process.getuid = getuid 433 | }) 434 | const {writeFileSync: writeFileAtomicSync} = requireInject('./atomically.cjs', { fs: fsMock }); 435 | t.plan(2) 436 | throws(t, 'EINVAL', 'Chown error as root user', () => { 437 | writeFileAtomicSync('einval', 'test', { chown: { uid: 100, gid: 100 } }) 438 | }) 439 | throws(t, 'EINVAL', 'Chmod error as root user', () => { 440 | writeFileAtomicSync('einval', 'test', { mode: 0o741 }) 441 | }) 442 | }) 443 | }) 444 | 445 | test('unstable sync tests', t => { 446 | t.plan(2); 447 | 448 | const throws = function (t, msg, todo) { 449 | let err 450 | try { todo() } catch (e) { err = e } 451 | t.equal(!!err.code, true, msg) 452 | } 453 | 454 | const noexception = function (t, msg, todo) { 455 | let err 456 | try { todo() } catch (e) { err = e } 457 | t.error(err, msg) 458 | } 459 | 460 | noexception(t, 'No errors occur when retryable errors are thrown', () => { 461 | const {writeFileSync: writeFileAtomicSync} = requireInject('./atomically.cjs', { fs: fsMockUnstable }); 462 | writeFileAtomicSync('good', 'test') 463 | }) 464 | 465 | throws(t, 'retrying can be disabled', () => { 466 | const {writeFileSync: writeFileAtomicSync} = requireInject('./atomically.cjs', { fs: fsMockUnstable }); 467 | writeFileAtomicSync('good', 'test', { timeout: 0 }) 468 | }) 469 | }); 470 | 471 | test('promises', async t => { 472 | let tmpfile 473 | closeCalled = 0 474 | expectClose = 0 475 | t.teardown(() => { 476 | t.parent.equal(closeCalled, expectClose, 'promises closed all files') 477 | closeCalled = 0 478 | expectClose = 0 479 | }) 480 | 481 | await writeFileAtomic('good', 'test', { 482 | tmpCreated (gottmpfile) { 483 | tmpfile = gottmpfile 484 | } 485 | }) 486 | t.match(tmpfile, /^good\.tmp-\w+$/, 'tmpCreated is called for success') 487 | 488 | await writeFileAtomic('good', 'test', { 489 | tmpCreated (gottmpfile) { 490 | return Promise.resolve() 491 | } 492 | }) 493 | 494 | tmpfile = undefined 495 | await t.rejects(writeFileAtomic('noopen', 'test', { 496 | tmpCreated (gottmpfile) { 497 | tmpfile = gottmpfile 498 | } 499 | })) 500 | t.equal(tmpfile, undefined, 'tmpCreated is not called on open failure') 501 | 502 | await t.rejects(writeFileAtomic('nowrite', 'test', { 503 | tmpCreated (gottmpfile) { 504 | tmpfile = gottmpfile 505 | } 506 | })) 507 | t.match(tmpfile, /^nowrite\.tmp-\w+$/, 'tmpCreated is called if failure is after open') 508 | }) 509 | -------------------------------------------------------------------------------- /test/concurrency.cjs: -------------------------------------------------------------------------------- 1 | process.setMaxListeners(1000000); 2 | 3 | const fs = require('fs') 4 | const {test} = require('tap') 5 | const requireInject = require('require-inject') 6 | 7 | // defining mock for fs so its functions can be modified 8 | const fsMock = Object.assign ( {}, fs, { 9 | /* ASYNC */ 10 | mkdir (filename, opts, cb) { 11 | return cb(null); 12 | }, 13 | realpath (filename, cb) { 14 | return cb(null, filename) 15 | }, 16 | open (tmpfile, options, mode, cb) { 17 | if (/noopen/.test(tmpfile)) return cb(new Error('ENOOPEN')) 18 | cb(null, tmpfile) 19 | }, 20 | write (fd) { 21 | const cb = arguments[arguments.length - 1] 22 | if (/nowrite/.test(fd)) return cb(new Error('ENOWRITE')) 23 | cb() 24 | }, 25 | fsync (fd, cb) { 26 | if (/nofsync/.test(fd)) return cb(new Error('ENOFSYNC')) 27 | cb() 28 | }, 29 | close (fd, cb) { 30 | cb() 31 | }, 32 | chown (tmpfile, uid, gid, cb) { 33 | if (/nochown/.test(tmpfile)) return cb(new Error('ENOCHOWN')) 34 | cb() 35 | }, 36 | chmod (tmpfile, mode, cb) { 37 | if (/nochmod/.test(tmpfile)) return cb(new Error('ENOCHMOD')) 38 | cb() 39 | }, 40 | rename (tmpfile, filename, cb) { 41 | if (/norename/.test(tmpfile)) return cb(new Error('ENORENAME')) 42 | cb() 43 | }, 44 | unlink (tmpfile, cb) { 45 | if (/nounlink/.test(tmpfile)) return cb(new Error('ENOUNLINK')) 46 | cb() 47 | }, 48 | stat (tmpfile, cb) { 49 | if (/nostat/.test(tmpfile)) return cb(new Error('ENOSTAT')) 50 | cb() 51 | }, 52 | /* SYNC */ 53 | mkdirSync (filename) {}, 54 | realpathSync (filename, cb) { 55 | return filename 56 | }, 57 | openSync (tmpfile, options) { 58 | if (/noopen/.test(tmpfile)) throw new Error('ENOOPEN') 59 | return tmpfile 60 | }, 61 | writeSync (fd) { 62 | if (/nowrite/.test(fd)) throw new Error('ENOWRITE') 63 | }, 64 | fsyncSync (fd) { 65 | if (/nofsync/.test(fd)) throw new Error('ENOFSYNC') 66 | }, 67 | closeSync () {}, 68 | chownSync (tmpfile, uid, gid) { 69 | if (/nochown/.test(tmpfile)) throw new Error('ENOCHOWN') 70 | }, 71 | chmodSync (tmpfile, mode) { 72 | if (/nochmod/.test(tmpfile)) throw new Error('ENOCHMOD') 73 | }, 74 | renameSync (tmpfile, filename) { 75 | if (/norename/.test(tmpfile)) throw new Error('ENORENAME') 76 | }, 77 | unlinkSync (tmpfile) { 78 | if (/nounlink/.test(tmpfile)) throw new Error('ENOUNLINK') 79 | }, 80 | statSync (tmpfile) { 81 | if (/nostat/.test(tmpfile)) throw new Error('ENOSTAT') 82 | } 83 | }) 84 | 85 | const {writeFile: writeFileAtomic} = requireInject('./atomically.cjs', { fs: fsMock }); 86 | 87 | // preserve original functions 88 | const oldRealPath = fsMock.realpath 89 | const oldRename = fsMock.rename 90 | 91 | test('ensure writes to the same file are serial', t => { 92 | let fileInUse = false 93 | const ops = 5 // count for how many concurrent write ops to request 94 | t.plan(ops * 3 + 3) 95 | fsMock.realpath = (...args) => { 96 | t.notOk(fileInUse, 'file not in use') 97 | fileInUse = true 98 | oldRealPath(...args) 99 | } 100 | fsMock.rename = (...args) => { 101 | t.ok(fileInUse, 'file in use') 102 | fileInUse = false 103 | oldRename(...args) 104 | } 105 | const {writeFile: writeFileAtomic} = requireInject('./atomically.cjs', { fs: fsMock }); 106 | for (let i = 0; i < ops; i++) { 107 | writeFileAtomic('test', 'test', err => { 108 | if (err) t.fail(err) 109 | else t.pass('wrote without error') 110 | }) 111 | } 112 | setTimeout(() => { 113 | writeFileAtomic('test', 'test', err => { 114 | if (err) t.fail(err) 115 | else t.pass('successive writes after delay') 116 | }) 117 | }, 500) 118 | }) 119 | 120 | test('allow write to multiple files in parallel, but same file writes are serial', t => { 121 | const filesInUse = [] 122 | const ops = 5 123 | let wasParallel = false 124 | fsMock.realpath = (filename, ...args) => { 125 | filesInUse.push(filename) 126 | const firstOccurence = filesInUse.indexOf(filename) 127 | t.equal(filesInUse.indexOf(filename, firstOccurence + 1), -1, 'serial writes') // check for another occurence after the first 128 | if (filesInUse.length > 1) wasParallel = true // remember that a parallel operation took place 129 | oldRealPath(filename, ...args) 130 | } 131 | fsMock.rename = (filename, ...args) => { 132 | filesInUse.splice(filesInUse.indexOf(filename), 1) 133 | oldRename(filename, ...args) 134 | } 135 | const {writeFile: writeFileAtomic} = requireInject('./atomically.cjs', { fs: fsMock }); 136 | t.plan(ops * 2 * 2 + 1) 137 | let opCount = 0 138 | for (let i = 0; i < ops; i++) { 139 | writeFileAtomic('test', 'test', err => { 140 | if (err) t.fail(err, 'wrote without error') 141 | else t.pass('wrote without error') 142 | }) 143 | writeFileAtomic('test2', 'test', err => { 144 | opCount++ 145 | if (opCount === ops) t.ok(wasParallel, 'parallel writes') 146 | 147 | if (err) t.fail(err, 'wrote without error') 148 | else t.pass('wrote without error') 149 | }) 150 | } 151 | }) 152 | -------------------------------------------------------------------------------- /test/integration.cjs: -------------------------------------------------------------------------------- 1 | process.setMaxListeners(1000000); 2 | 3 | const fs = require('fs') 4 | const path = require('path') 5 | const {test} = require('tap') 6 | const rimraf = require('rimraf') 7 | const requireInject = require('require-inject') 8 | 9 | const workdir = path.join(__dirname, path.basename(__filename, '.cjs')) 10 | let testfiles = 0 11 | function tmpFile () { 12 | return path.join(workdir, 'test-' + (++testfiles)) 13 | } 14 | 15 | function readFile (path) { 16 | return fs.readFileSync(path).toString() 17 | } 18 | 19 | function didWriteFileAtomic (t, expected, filename, data, options, callback) { 20 | if (options instanceof Function) { 21 | callback = options 22 | options = null 23 | } 24 | if (!options) options = {} 25 | const actual = {} 26 | const {writeFile: writeFileAtomic} = requireInject('./atomically.cjs', { 27 | fs: Object.assign({}, fs, { 28 | chown (filename, uid, gid, cb) { 29 | actual.uid = uid 30 | actual.gid = gid 31 | process.nextTick(cb) 32 | }, 33 | stat (filename, cb) { 34 | fs.stat(filename, (err, stats) => { 35 | if (err) return cb(err) 36 | cb(null, Object.assign(stats, expected || {})) 37 | }) 38 | } 39 | }) 40 | }) 41 | return writeFileAtomic(filename, data, options, err => { 42 | t.ok(true); // t.strictSame(actual, expected, 'ownership is as expected') //TODO: Turned off as it's implemented unreliably, preventing us from doing a safe optimization 43 | callback(err) 44 | }) 45 | } 46 | 47 | function didWriteFileAtomicSync (t, expected, filename, data, options) { 48 | const actual = {} 49 | const {writeFileSync} = requireInject('./atomically.cjs', { 50 | fs: Object.assign({}, fs, { 51 | chownSync (filename, uid, gid) { 52 | actual.uid = uid 53 | actual.gid = gid 54 | }, 55 | statSync (filename) { 56 | const stats = fs.statSync(filename) 57 | return Object.assign(stats, expected || {}) 58 | } 59 | }) 60 | }) 61 | writeFileSync(filename, data, options) 62 | t.ok(true); // t.strictSame(actual, expected) //TODO: Turned off as it's implemented unreliably, preventing us from doing a safe optimization 63 | } 64 | 65 | function currentUser () { 66 | return { 67 | uid: process.getuid(), 68 | gid: process.getgid() 69 | } 70 | } 71 | 72 | test('setup', t => { 73 | rimraf.sync(workdir) 74 | fs.mkdirSync(workdir, {recursive: true}) 75 | t.end() 76 | }) 77 | 78 | test('writes simple file (async)', t => { 79 | t.plan(3) 80 | const file = tmpFile() 81 | didWriteFileAtomic(t, {}, file, '42', err => { 82 | t.error(err, 'no error') 83 | t.equal(readFile(file), '42', 'content ok') 84 | }) 85 | }) 86 | 87 | test('writes simple file with encoding (async)', t => { 88 | t.plan(3) 89 | const file = tmpFile() 90 | didWriteFileAtomic(t, {}, file, 'foo', 'utf16le', err => { 91 | t.error(err, 'no error') 92 | t.equal(readFile(file), 'f\u0000o\u0000o\u0000', 'content ok') 93 | }) 94 | }) 95 | 96 | test('writes buffers to simple file (async)', t => { 97 | t.plan(3) 98 | const file = tmpFile() 99 | didWriteFileAtomic(t, {}, file, Buffer.from('42'), err => { 100 | t.error(err, 'no error') 101 | t.equal(readFile(file), '42', 'content ok') 102 | }) 103 | }) 104 | 105 | test('writes undefined to simple file (async)', t => { 106 | t.plan(3) 107 | const file = tmpFile() 108 | didWriteFileAtomic(t, {}, file, undefined, err => { 109 | t.error(err, 'no error') 110 | t.equal(readFile(file), '', 'content ok') 111 | }) 112 | }) 113 | 114 | test('writes to symlinks without clobbering (async)', t => { 115 | t.plan(5) 116 | const file = tmpFile() 117 | const link = tmpFile() 118 | fs.writeFileSync(file, '42') 119 | fs.symlinkSync(file, link) 120 | didWriteFileAtomic(t, currentUser(), link, '43', err => { 121 | t.error(err, 'no error') 122 | t.equal(readFile(file), '43', 'target content ok') 123 | t.equal(readFile(link), '43', 'link content ok') 124 | t.ok(fs.lstatSync(link).isSymbolicLink(), 'link is link') 125 | }) 126 | }) 127 | 128 | test('runs chown on given file (async)', t => { 129 | const file = tmpFile() 130 | didWriteFileAtomic(t, { uid: 42, gid: 43 }, file, '42', { chown: { uid: 42, gid: 43 } }, err => { 131 | t.error(err, 'no error') 132 | t.equal(readFile(file), '42', 'content ok') 133 | t.end() 134 | }) 135 | }) 136 | 137 | test('writes simple file with no chown (async)', t => { 138 | t.plan(3) 139 | const file = tmpFile() 140 | didWriteFileAtomic(t, {}, file, '42', { chown: false }, err => { 141 | t.error(err, 'no error') 142 | t.equal(readFile(file), '42', 'content ok') 143 | t.end() 144 | }) 145 | }) 146 | 147 | test('runs chmod on given file (async)', t => { 148 | t.plan(5) 149 | const file = tmpFile() 150 | didWriteFileAtomic(t, {}, file, '42', { mode: parseInt('741', 8) }, err => { 151 | t.error(err, 'no error') 152 | const stat = fs.statSync(file) 153 | t.equal(stat.mode, parseInt('100741', 8)) 154 | didWriteFileAtomic(t, { uid: 42, gid: 43 }, file, '23', { chown: { uid: 42, gid: 43 } }, err => { 155 | t.error(err, 'no error') 156 | }) 157 | }) 158 | }) 159 | 160 | test('run chmod AND chown (async)', t => { 161 | t.plan(3) 162 | const file = tmpFile() 163 | didWriteFileAtomic(t, { uid: 42, gid: 43 }, file, '42', { mode: parseInt('741', 8), chown: { uid: 42, gid: 43 } }, err => { 164 | t.error(err, 'no error') 165 | const stat = fs.statSync(file) 166 | t.equal(stat.mode, parseInt('100741', 8)) 167 | }) 168 | }) 169 | 170 | test('does not change chmod by default (async)', t => { 171 | t.plan(5) 172 | const file = tmpFile() 173 | didWriteFileAtomic(t, {}, file, '42', { mode: parseInt('741', 8) }, err => { 174 | t.error(err, 'no error') 175 | 176 | didWriteFileAtomic(t, currentUser(), file, '43', err => { 177 | t.error(err, 'no error') 178 | const stat = fs.statSync(file) 179 | t.equal(stat.mode, parseInt('100741', 8)) 180 | }) 181 | }) 182 | }) 183 | 184 | test('does not change chown by default (async)', t => { 185 | t.plan(6) 186 | const file = tmpFile() 187 | didWriteFileAtomic(t, { uid: 42, gid: 43 }, file, '42', { chown: { uid: 42, gid: 43 } }, _setModeOnly) 188 | 189 | function _setModeOnly (err) { 190 | t.error(err, 'no error') 191 | 192 | didWriteFileAtomic(t, { uid: 42, gid: 43 }, file, '43', { mode: parseInt('741', 8) }, _allDefault) 193 | } 194 | 195 | function _allDefault (err) { 196 | t.error(err, 'no error') 197 | 198 | didWriteFileAtomic(t, { uid: 42, gid: 43 }, file, '43', _noError) 199 | } 200 | 201 | function _noError (err) { 202 | t.error(err, 'no error') 203 | } 204 | }) 205 | 206 | test('writes simple file (sync)', t => { 207 | t.plan(2) 208 | const file = tmpFile() 209 | didWriteFileAtomicSync(t, {}, file, '42') 210 | t.equal(readFile(file), '42') 211 | }) 212 | 213 | test('writes simple file with encoding (sync)', t => { 214 | t.plan(2) 215 | const file = tmpFile() 216 | didWriteFileAtomicSync(t, {}, file, 'foo', 'utf16le') 217 | t.equal(readFile(file), 'f\u0000o\u0000o\u0000') 218 | }) 219 | 220 | test('writes simple buffer file (sync)', t => { 221 | t.plan(2) 222 | const file = tmpFile() 223 | didWriteFileAtomicSync(t, {}, file, Buffer.from('42')) 224 | t.equal(readFile(file), '42') 225 | }) 226 | 227 | test('writes undefined file (sync)', t => { 228 | t.plan(2) 229 | const file = tmpFile() 230 | didWriteFileAtomicSync(t, {}, file, undefined) 231 | t.equal(readFile(file), '') 232 | }) 233 | 234 | test('writes to symlinks without clobbering (sync)', t => { 235 | t.plan(4) 236 | const file = tmpFile() 237 | const link = tmpFile() 238 | fs.writeFileSync(file, '42') 239 | fs.symlinkSync(file, link) 240 | didWriteFileAtomicSync(t, currentUser(), link, '43') 241 | t.equal(readFile(file), '43', 'target content ok') 242 | t.equal(readFile(link), '43', 'link content ok') 243 | t.ok(fs.lstatSync(link).isSymbolicLink(), 'link is link') 244 | }) 245 | 246 | test('runs chown on given file (sync)', t => { 247 | t.plan(1) 248 | const file = tmpFile() 249 | didWriteFileAtomicSync(t, { uid: 42, gid: 43 }, file, '42', { chown: { uid: 42, gid: 43 } }) 250 | }) 251 | 252 | test('runs chmod on given file (sync)', t => { 253 | t.plan(3) 254 | const file = tmpFile() 255 | didWriteFileAtomicSync(t, {}, file, '42', { mode: parseInt('741', 8) }) 256 | const stat = fs.statSync(file) 257 | t.equal(stat.mode, parseInt('100741', 8)) 258 | didWriteFileAtomicSync(t, { uid: 42, gid: 43 }, file, '23', { chown: { uid: 42, gid: 43 } }) 259 | }) 260 | 261 | test('runs chown and chmod (sync)', t => { 262 | t.plan(2) 263 | const file = tmpFile() 264 | didWriteFileAtomicSync(t, { uid: 42, gid: 43 }, file, '42', { mode: parseInt('741', 8), chown: { uid: 42, gid: 43 } }) 265 | const stat = fs.statSync(file) 266 | t.equal(stat.mode, parseInt('100741', 8)) 267 | }) 268 | 269 | test('does not change chmod by default (sync)', t => { 270 | t.plan(3) 271 | const file = tmpFile() 272 | didWriteFileAtomicSync(t, {}, file, '42', { mode: parseInt('741', 8) }) 273 | didWriteFileAtomicSync(t, currentUser(), file, '43') 274 | const stat = fs.statSync(file) 275 | t.equal(stat.mode, parseInt('100741', 8)) 276 | }) 277 | 278 | test('does not change chown by default (sync)', t => { 279 | t.plan(3) 280 | const file = tmpFile() 281 | didWriteFileAtomicSync(t, { uid: 42, gid: 43 }, file, '42', { chown: { uid: 42, gid: 43 } }) 282 | didWriteFileAtomicSync(t, { uid: 42, gid: 43 }, file, '43', { mode: parseInt('741', 8) }) 283 | didWriteFileAtomicSync(t, { uid: 42, gid: 43 }, file, '44') 284 | }) 285 | 286 | test('cleanup', t => { 287 | rimraf.sync(workdir) 288 | t.end() 289 | }) 290 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tsex/tsconfig.json" 3 | } 4 | --------------------------------------------------------------------------------