1'use strict'
2module.exports = writeFile
3module.exports.sync = writeFileSync
4module.exports._getTmpname = getTmpname // for testing
5module.exports._cleanupOnExit = cleanupOnExit
6
7const fs = require('fs')
8const MurmurHash3 = require('imurmurhash')
9const { onExit } = require('signal-exit')
10const path = require('path')
11const { promisify } = require('util')
12const activeFiles = {}
13
14// if we run inside of a worker_thread, `process.pid` is not unique
15/* istanbul ignore next */
16const threadId = (function getId () {
17  try {
18    const workerThreads = require('worker_threads')
19
20    /// if we are in main thread, this is set to `0`
21    return workerThreads.threadId
22  } catch (e) {
23    // worker_threads are not available, fallback to 0
24    return 0
25  }
26})()
27
28let invocations = 0
29function getTmpname (filename) {
30  return filename + '.' +
31    MurmurHash3(__filename)
32      .hash(String(process.pid))
33      .hash(String(threadId))
34      .hash(String(++invocations))
35      .result()
36}
37
38function cleanupOnExit (tmpfile) {
39  return () => {
40    try {
41      fs.unlinkSync(typeof tmpfile === 'function' ? tmpfile() : tmpfile)
42    } catch {
43      // ignore errors
44    }
45  }
46}
47
48function serializeActiveFile (absoluteName) {
49  return new Promise(resolve => {
50    // make a queue if it doesn't already exist
51    if (!activeFiles[absoluteName]) {
52      activeFiles[absoluteName] = []
53    }
54
55    activeFiles[absoluteName].push(resolve) // add this job to the queue
56    if (activeFiles[absoluteName].length === 1) {
57      resolve()
58    } // kick off the first one
59  })
60}
61
62// https://github.com/isaacs/node-graceful-fs/blob/master/polyfills.js#L315-L342
63function isChownErrOk (err) {
64  if (err.code === 'ENOSYS') {
65    return true
66  }
67
68  const nonroot = !process.getuid || process.getuid() !== 0
69  if (nonroot) {
70    if (err.code === 'EINVAL' || err.code === 'EPERM') {
71      return true
72    }
73  }
74
75  return false
76}
77
78async function writeFileAsync (filename, data, options = {}) {
79  if (typeof options === 'string') {
80    options = { encoding: options }
81  }
82
83  let fd
84  let tmpfile
85  /* istanbul ignore next -- The closure only gets called when onExit triggers */
86  const removeOnExitHandler = onExit(cleanupOnExit(() => tmpfile))
87  const absoluteName = path.resolve(filename)
88
89  try {
90    await serializeActiveFile(absoluteName)
91    const truename = await promisify(fs.realpath)(filename).catch(() => filename)
92    tmpfile = getTmpname(truename)
93
94    if (!options.mode || !options.chown) {
95      // Either mode or chown is not explicitly set
96      // Default behavior is to copy it from original file
97      const stats = await promisify(fs.stat)(truename).catch(() => {})
98      if (stats) {
99        if (options.mode == null) {
100          options.mode = stats.mode
101        }
102
103        if (options.chown == null && process.getuid) {
104          options.chown = { uid: stats.uid, gid: stats.gid }
105        }
106      }
107    }
108
109    fd = await promisify(fs.open)(tmpfile, 'w', options.mode)
110    if (options.tmpfileCreated) {
111      await options.tmpfileCreated(tmpfile)
112    }
113    if (ArrayBuffer.isView(data)) {
114      await promisify(fs.write)(fd, data, 0, data.length, 0)
115    } else if (data != null) {
116      await promisify(fs.write)(fd, String(data), 0, String(options.encoding || 'utf8'))
117    }
118
119    if (options.fsync !== false) {
120      await promisify(fs.fsync)(fd)
121    }
122
123    await promisify(fs.close)(fd)
124    fd = null
125
126    if (options.chown) {
127      await promisify(fs.chown)(tmpfile, options.chown.uid, options.chown.gid).catch(err => {
128        if (!isChownErrOk(err)) {
129          throw err
130        }
131      })
132    }
133
134    if (options.mode) {
135      await promisify(fs.chmod)(tmpfile, options.mode).catch(err => {
136        if (!isChownErrOk(err)) {
137          throw err
138        }
139      })
140    }
141
142    await promisify(fs.rename)(tmpfile, truename)
143  } finally {
144    if (fd) {
145      await promisify(fs.close)(fd).catch(
146        /* istanbul ignore next */
147        () => {}
148      )
149    }
150    removeOnExitHandler()
151    await promisify(fs.unlink)(tmpfile).catch(() => {})
152    activeFiles[absoluteName].shift() // remove the element added by serializeSameFile
153    if (activeFiles[absoluteName].length > 0) {
154      activeFiles[absoluteName][0]() // start next job if one is pending
155    } else {
156      delete activeFiles[absoluteName]
157    }
158  }
159}
160
161async function writeFile (filename, data, options, callback) {
162  if (options instanceof Function) {
163    callback = options
164    options = {}
165  }
166
167  const promise = writeFileAsync(filename, data, options)
168  if (callback) {
169    try {
170      const result = await promise
171      return callback(result)
172    } catch (err) {
173      return callback(err)
174    }
175  }
176
177  return promise
178}
179
180function writeFileSync (filename, data, options) {
181  if (typeof options === 'string') {
182    options = { encoding: options }
183  } else if (!options) {
184    options = {}
185  }
186  try {
187    filename = fs.realpathSync(filename)
188  } catch (ex) {
189    // it's ok, it'll happen on a not yet existing file
190  }
191  const tmpfile = getTmpname(filename)
192
193  if (!options.mode || !options.chown) {
194    // Either mode or chown is not explicitly set
195    // Default behavior is to copy it from original file
196    try {
197      const stats = fs.statSync(filename)
198      options = Object.assign({}, options)
199      if (!options.mode) {
200        options.mode = stats.mode
201      }
202      if (!options.chown && process.getuid) {
203        options.chown = { uid: stats.uid, gid: stats.gid }
204      }
205    } catch (ex) {
206      // ignore stat errors
207    }
208  }
209
210  let fd
211  const cleanup = cleanupOnExit(tmpfile)
212  const removeOnExitHandler = onExit(cleanup)
213
214  let threw = true
215  try {
216    fd = fs.openSync(tmpfile, 'w', options.mode || 0o666)
217    if (options.tmpfileCreated) {
218      options.tmpfileCreated(tmpfile)
219    }
220    if (ArrayBuffer.isView(data)) {
221      fs.writeSync(fd, data, 0, data.length, 0)
222    } else if (data != null) {
223      fs.writeSync(fd, String(data), 0, String(options.encoding || 'utf8'))
224    }
225    if (options.fsync !== false) {
226      fs.fsyncSync(fd)
227    }
228
229    fs.closeSync(fd)
230    fd = null
231
232    if (options.chown) {
233      try {
234        fs.chownSync(tmpfile, options.chown.uid, options.chown.gid)
235      } catch (err) {
236        if (!isChownErrOk(err)) {
237          throw err
238        }
239      }
240    }
241
242    if (options.mode) {
243      try {
244        fs.chmodSync(tmpfile, options.mode)
245      } catch (err) {
246        if (!isChownErrOk(err)) {
247          throw err
248        }
249      }
250    }
251
252    fs.renameSync(tmpfile, filename)
253    threw = false
254  } finally {
255    if (fd) {
256      try {
257        fs.closeSync(fd)
258      } catch (ex) {
259        // ignore close errors at this stage, error may have closed fd already.
260      }
261    }
262    removeOnExitHandler()
263    if (threw) {
264      cleanup()
265    }
266  }
267}
268