Skip to content

Commit

Permalink
feat: add withOwner and withOwnerSync methods (#21)
Browse files Browse the repository at this point in the history
  • Loading branch information
lukekarrys authored Mar 21, 2022
1 parent 55679fa commit 40ee281
Show file tree
Hide file tree
Showing 12 changed files with 512 additions and 38 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ polyfills, and extensions, of the core `fs` module.
- `fs.mkdtemp` extended to accept an `owner` option
- `fs.writeFile` extended to accept an `owner` option
- `fs.withTempDir` added
- `fs.withOwner` added
- `fs.withOwnerSync` added
- `fs.cp` polyfill for node < 16.7.0

## The `owner` option
Expand Down
92 changes: 92 additions & 0 deletions lib/common/owner-sync.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
const { dirname, resolve } = require('path')

const fileURLToPath = require('./file-url-to-path/index.js')
const fs = require('../fs.js')

// given a path, find the owner of the nearest parent
const find = (path) => {
// if we have no getuid, permissions are irrelevant on this platform
if (!process.getuid) {
return {}
}

// fs methods accept URL objects with a scheme of file: so we need to unwrap
// those into an actual path string before we can resolve it
const resolved = path != null && path.href && path.origin
? resolve(fileURLToPath(path))
: resolve(path)

let stat

try {
stat = fs.lstatSync(resolved)
} finally {
// if we got a stat, return its contents
if (stat) {
return { uid: stat.uid, gid: stat.gid }
}

// try the parent directory
if (resolved !== dirname(resolved)) {
return find(dirname(resolved))
}

// no more parents, never got a stat, just return an empty object
return {}
}
}

// given a path, uid, and gid update the ownership of the path if necessary
const update = (path, uid, gid) => {
// nothing to update, just exit
if (uid === undefined && gid === undefined) {
return
}

try {
// see if the permissions are already the same, if they are we don't
// need to do anything, so return early
const stat = fs.statSync(path)
if (uid === stat.uid && gid === stat.gid) {
return
}
} catch (err) {}

try {
fs.chownSync(path, uid, gid)
} catch (err) {}
}

// accepts a `path` and the `owner` property of an options object and normalizes
// it into an object with numerical `uid` and `gid`
const validate = (path, input) => {
let uid
let gid

if (typeof input === 'string' || typeof input === 'number') {
uid = input
gid = input
} else if (input && typeof input === 'object') {
uid = input.uid
gid = input.gid
}

if (uid === 'inherit' || gid === 'inherit') {
const owner = find(path)
if (uid === 'inherit') {
uid = owner.uid
}

if (gid === 'inherit') {
gid = owner.gid
}
}

return { uid, gid }
}

module.exports = {
find,
update,
validate,
}
12 changes: 3 additions & 9 deletions lib/copy-file.js
Original file line number Diff line number Diff line change
@@ -1,22 +1,16 @@
const fs = require('./fs.js')
const getOptions = require('./common/get-options.js')
const owner = require('./common/owner.js')
const withOwner = require('./with-owner.js')

const copyFile = async (src, dest, opts) => {
const options = getOptions(opts, {
copy: ['mode', 'owner'],
copy: ['mode'],
wrap: 'mode',
})

const { uid, gid } = await owner.validate(dest, options.owner)

// the node core method as of 16.5.0 does not support the mode being in an
// object, so we have to pass the mode value directly
const result = await fs.copyFile(src, dest, options.mode)

await owner.update(dest, uid, gid)

return result
return withOwner(dest, () => fs.copyFile(src, dest, options.mode), opts)
}

module.exports = copyFile
12 changes: 9 additions & 3 deletions lib/fs.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
const fs = require('fs')
const promisify = require('@gar/promisify')

// this module returns the core fs module wrapped in a proxy that promisifies
const isLower = (s) => s === s.toLowerCase() && s !== s.toUpperCase()

const fsSync = Object.fromEntries(Object.entries(fs).filter(([k, v]) =>
typeof v === 'function' && (k.endsWith('Sync') || !isLower(k[0]))
))

// this module returns the core fs async fns wrapped in a proxy that promisifies
// method calls within the getter. we keep it in a separate module so that the
// overridden methods have a consistent way to get to promisified fs methods
// without creating a circular dependency
module.exports = promisify(fs)
// without creating a circular dependency. the ctors and sync methods are kept untouched
module.exports = { ...promisify(fs), ...fsSync }
2 changes: 2 additions & 0 deletions lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,7 @@ module.exports = {
mkdtemp: require('./mkdtemp.js'),
rm: require('./rm/index.js'),
withTempDir: require('./with-temp-dir.js'),
withOwner: require('./with-owner.js'),
withOwnerSync: require('./with-owner-sync.js'),
writeFile: require('./write-file.js'),
}
17 changes: 7 additions & 10 deletions lib/mkdir/index.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
const fs = require('../fs.js')
const getOptions = require('../common/get-options.js')
const node = require('../common/node.js')
const owner = require('../common/owner.js')
const withOwner = require('../with-owner.js')

const polyfill = require('./polyfill.js')

Expand All @@ -12,21 +12,18 @@ const useNative = node.satisfies('>=10.12.0')
// extends mkdir with the ability to specify an owner of the new dir
const mkdir = async (path, opts) => {
const options = getOptions(opts, {
copy: ['mode', 'recursive', 'owner'],
copy: ['mode', 'recursive'],
wrap: 'mode',
})
const { uid, gid } = await owner.validate(path, options.owner)

// the polyfill is tested separately from this module, no need to hack
// process.version to try to trigger it just for coverage
// istanbul ignore next
const result = useNative
? await fs.mkdir(path, options)
: await polyfill(path, options)

await owner.update(path, uid, gid)

return result
return withOwner(
path,
() => useNative ? fs.mkdir(path, options) : polyfill(path, options),
opts
)
}

module.exports = mkdir
11 changes: 3 additions & 8 deletions lib/mkdtemp.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ const { dirname, sep } = require('path')

const fs = require('./fs.js')
const getOptions = require('./common/get-options.js')
const owner = require('./common/owner.js')
const withOwner = require('./with-owner.js')

const mkdtemp = async (prefix, opts) => {
const options = getOptions(opts, {
copy: ['encoding', 'owner'],
copy: ['encoding'],
wrap: 'encoding',
})

Expand All @@ -16,13 +16,8 @@ const mkdtemp = async (prefix, opts) => {
// /tmp -> /tmpABCDEF, infers from /
// /tmp/ -> /tmp/ABCDEF, infers from /tmp
const root = prefix.endsWith(sep) ? prefix : dirname(prefix)
const { uid, gid } = await owner.validate(root, options.owner)

const result = await fs.mkdtemp(prefix, options)

await owner.update(result, uid, gid)

return result
return withOwner(root, () => fs.mkdtemp(prefix, options), opts)
}

module.exports = mkdtemp
21 changes: 21 additions & 0 deletions lib/with-owner-sync.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
const getOptions = require('./common/get-options.js')
const owner = require('./common/owner-sync.js')

const withOwnerSync = (path, fn, opts) => {
const options = getOptions(opts, {
copy: ['owner'],
})

const { uid, gid } = owner.validate(path, options.owner)

const result = fn({ uid, gid })

owner.update(path, uid, gid)
if (typeof result === 'string') {
owner.update(result, uid, gid)
}

return result
}

module.exports = withOwnerSync
21 changes: 21 additions & 0 deletions lib/with-owner.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
const getOptions = require('./common/get-options.js')
const owner = require('./common/owner.js')

const withOwner = async (path, fn, opts) => {
const options = getOptions(opts, {
copy: ['owner'],
})

const { uid, gid } = await owner.validate(path, options.owner)

const result = await fn({ uid, gid })

await Promise.all([
owner.update(path, uid, gid),
typeof result === 'string' ? owner.update(result, uid, gid) : null,
])

return result
}

module.exports = withOwner
11 changes: 3 additions & 8 deletions lib/write-file.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,14 @@
const fs = require('./fs.js')
const getOptions = require('./common/get-options.js')
const owner = require('./common/owner.js')
const withOwner = require('./with-owner.js')

const writeFile = async (file, data, opts) => {
const options = getOptions(opts, {
copy: ['encoding', 'mode', 'flag', 'signal', 'owner'],
copy: ['encoding', 'mode', 'flag', 'signal'],
wrap: 'encoding',
})
const { uid, gid } = await owner.validate(file, options.owner)

const result = await fs.writeFile(file, data, options)

await owner.update(file, uid, gid)

return result
return withOwner(file, () => fs.writeFile(file, data, options), opts)
}

module.exports = writeFile
Loading

0 comments on commit 40ee281

Please sign in to comment.