Search code examples
node.jsunixelectronfile-permissionsmacos-big-sur

Electron app can't access files on only one network drive


I'm building an Electron app that manages a collection of photographs on an NAS. That NAS has two logical volumes, named "alpha" and "beta". For reasons I want to understand (and fix!), my app gets an ENOENT error whenever it tries to run CLI tools against files on beta, but not when it runs CLI tools against alpha. Additionally, it is permitted to perform regular FS operations (e.g. readdir, stat, even rename) against files on both volumes without error. For example: the app first learns about the files on beta because it scans the filesystem using find; that scan succeeds.

I'm using the CLI tool exiftool to extract image metadata from all these files (e.g. dimensions, capture device, etc). Here's the command my app runs, using child_process.spawn:

# as a shell command
exiftool -json -n PATH_TO_FILE
// as a node module usable by Electron
const ChildProcess = require('child_process')

module.exports = async function readExif( PATH_TO_FILE ) {
    if (typeof PATH_TO_FILE !== 'string') throw new TypeError('PATH_TO_FILE must be a string')

    let exifStdout = await new Promise(( resolve, reject ) => {
        let stdout = ''
        let stderr = ''
        const process = ChildProcess.spawn(
            'exiftool',
            ['-json', '-n', PATH_TO_FILE],
            { uid: 501, gid: 20 }
        )

        process.on('error', ( error ) => reject(error)) // couldn't launch the process
        process.stdout.on('data', ( data ) => stdout += data)
        process.stderr.on('data', ( data ) => stderr += data)
        process.on('close', () => stderr ? reject(stderr) : resolve(stdout))
    })

    let exifData = JSON.parse(exifStdout)

    return exifData[0] // exiftool always returns an array; unwrap it so our return mirrors our input
}

If I run that using node from the command line, it works no matter where the target file is. If Electron runs it, it'll work against alpha but throw against beta. And if I run it from the integrated terminal inside VS Code, to which I have denied Network Folders permissions, it predictably throws against alpha and beta; that's true whether I use node or Electron, so long as it's from inside VS Code.

Here's console.log(error) when run as a packaged Electron app:

Error: spawn exiftool ENOENT
    at Process.ChildProcess._handle.onexit (internal/child_process.js:264:19)
    at onErrorNT (internal/child_process.js:456:16)
    at processTicksAndRejections (internal/process/task_queues.js:81:21) {
  errno: 'ENOENT',
  code: 'ENOENT',
  syscall: 'spawn exiftool',
  path: 'exiftool',
  spawnargs: [
    '-json',
    '-n',
    '/Volumes/beta/photographs/IMG_9999.jpg'
  ]
}

Things I've verified experimentally:

  • it works if I run the command verbatim from the command line; so, my user can read these files, as can exiftool
  • moving a file from alpha to beta (using Finder) causes it to become unreadable by my app, and vice versa
  • my implementation is fine: if I use node from the terminal to run a tiny test.js that imports readExif, and manually provide the path to a file on beta, it works

All of the above makes me think this is a permissions issue, but there are so many other proofs that permissions are fine:

  • Before using exiftool on a file, my app stats the file individually using FS.stat to get its size & timestamps; this stat call works against both volumes (note: the app doesn't run these calls in direct sequence: it stats the file today, then adds the file to the exiftool scan queue which may be processed days later)
  • The first time I run each new build of the app, MacOS requires me to re-confirm granting Network Folders permissions; I always grant them
  • As an experiment, I gave my app Full Disk Access (which it really doesn't need), but that had no effect; I tried this specifically because my terminal app, iTerm2, has Full Disk Access, and I thought maybe that accounts for the different behavior vs "production"
  • My app UI lets me open individual files (using Electron.shell.openPath(PATH_TO_FILE)), which succeeds for files on either volume

I've also done what I can to inspect basic file permissions, but I can see no differences. These volumes get mounted by the user through the Finder, which places them at /Volumes/alpha and /Volumes/beta. While mounted, ls shows that those two nodes have the same perms:

/Volumes $ ls -lht
total 64
drwx------  1 Tom   staff    16K Mar 25 18:46 alpha
drwx------  1 Tom   staff    16K Mar 25 02:00 beta
lrwxr-xr-x  1 root  wheel     1B Dec 31  2019 Macintosh HD -> /

I've also hand-checked the perms on individual photos, but their permissions are the same. I've used the NAS admin tools to verify that my user account has unrestricted access to both volumes; it does, or none of my manual experiments would work.

And, as a last-ditch effort, I hard-coded my own uid and gid into the spawn call to make Electron execute the command as myself, but that also had no effect: I still get ENOENT when Electron runs exiftool against any file on beta, simply because the file is on beta. beta, again, a network volume to which I have full access, to which I have repeatedly granted the app full access, that this app already interacts with fine in several other ways, and that is not visibly different from alpha in any way that I can detect despite having root on both devices.

I have even shouted "Yes, there is ENT!" at it. I'm completely out of ideas.

Why are these two scenarios different, and how can I fix it? Or failing that, how can I further debug it?

Any suggestions are very welcome.

My package versions:

  • electron: 9.2
  • electron-packager: 14.2
  • node: 14.13

Electron running on a Mac running MacOS 11.2.3

NAS is running an ext4 filesystem on some flavor of Linux.


Solution

  • Specifying the full path of the command worked:

    const process = ChildProcess.spawn(
        '/usr/local/bin/exiftool',
        ['-json', '-n', PATH_TO_FILE]
    )
    

    I don't know why this isn't necessary when the target is on alpha, but it isn't.

    This version works from the packaged app.