├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ └── codeql.yml ├── .gitignore ├── .node-version ├── .npmignore ├── LICENSE.md ├── README.md ├── binding.gyp ├── docker ├── arm64-cross-compile │ └── Dockerfile └── i386 │ └── Dockerfile ├── index.d.ts ├── index.js ├── package-lock.json ├── package.json ├── script ├── download-node-lib-win-arm64.ps1 └── upload.js ├── src ├── copy-folder.cmd ├── fs-admin-darwin.cc ├── fs-admin-linux.cc ├── fs-admin-win.cc ├── fs-admin.h └── main.cc └── test └── fs-admin.test.js /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | time: "11:00" 8 | open-pull-requests-limit: 10 9 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | tags: 7 | - v*.*.* 8 | pull_request: 9 | branches: [master] 10 | 11 | jobs: 12 | build: 13 | name: ${{ matrix.friendlyName }} 14 | env: 15 | DISPLAY: ":99.0" 16 | CC: "clang" 17 | CXX: "clang++" 18 | npm_config_clang: "1" 19 | 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | node-version: [15.x] 24 | os: [ubuntu-18.04, windows-latest, macos-latest] 25 | include: 26 | - os: ubuntu-18.04 27 | friendlyName: Ubuntu 28 | - os: windows-latest 29 | friendlyName: Windows 30 | - os: macos-latest 31 | friendlyName: macOS 32 | 33 | runs-on: ${{ matrix.os }} 34 | 35 | steps: 36 | - uses: actions/checkout@v2 37 | - name: Use Node.js ${{ matrix.node-version }} 38 | uses: actions/setup-node@v1 39 | with: 40 | node-version: ${{ matrix.node-version }} 41 | 42 | # This step can be removed as soon as official Windows arm64 builds are published: 43 | # https://github.com/nodejs/build/issues/2450#issuecomment-705853342 44 | - run: | 45 | $NodeVersion = (node --version) -replace '^.' 46 | $NodeFallbackVersion = "15.8.0" 47 | & .\script\download-node-lib-win-arm64.ps1 $NodeVersion $NodeFallbackVersion 48 | if: ${{ matrix.os == 'windows-latest' }} 49 | name: Install Windows arm64 node.lib 50 | 51 | - run: npm install 52 | name: Setup environment 53 | 54 | - run: npm run build 55 | name: Build native module from source 56 | 57 | - run: npm test 58 | name: Run tests (Windows/macOS) 59 | 60 | - run: npm run prebuild-napi-x64 61 | name: Prebuild (x64) 62 | 63 | - run: npm run prebuild-napi-arm64 64 | name: Prebuild (arm64) 65 | if: ${{ matrix.os != 'ubuntu-18.04' }} 66 | 67 | - run: npm run prebuild-napi-ia32 68 | if: ${{ matrix.os == 'windows-latest' }} 69 | name: Prebuild (Windows x86) 70 | 71 | - run: | 72 | mkdir -p prebuilds && chmod 777 prebuilds 73 | docker build -t node-fs-admin/i386 docker/i386 74 | docker run --rm -v ${PWD}:/project node-fs-admin/i386 /bin/bash -c "cd /project && npm run prebuild-napi-ia32 && rm -rf build" 75 | docker build -t node-fs-admin/arm64-cross-compile docker/arm64-cross-compile 76 | docker run --rm -v ${PWD}:/project node-fs-admin/arm64-cross-compile /bin/bash -c "cd /project && npm run prebuild-napi-arm64" 77 | if: ${{ matrix.os == 'ubuntu-18.04' }} 78 | name: Prebuild (Linux x86 + ARM64) 79 | 80 | - run: | 81 | ls prebuilds/ 82 | name: List prebuilds 83 | 84 | - name: Upload prebuilds to GitHub 85 | run: npm run upload 86 | if: github.event_name == 'push' && contains(github.ref, 'refs/tags/') 87 | env: 88 | GITHUB_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 89 | 90 | # Separate step for publishing to NPM so we're sure that generating + uploading prebuilds worked on all platforms 91 | npm-publish: 92 | needs: build 93 | name: Publish to NPM 94 | runs-on: ubuntu-20.04 95 | if: github.event_name == 'push' && contains(github.ref, 'refs/tags/') 96 | 97 | steps: 98 | - uses: actions/checkout@v2 99 | - name: Use Node.js 15 100 | uses: actions/setup-node@v1 101 | with: 102 | node-version: 15.x 103 | registry-url: "https://registry.npmjs.org" 104 | 105 | - run: sudo apt-get install libsecret-1-dev 106 | name: Install additional dependencies 107 | 108 | - run: npm install 109 | name: Setup environment 110 | 111 | - run: npm publish --access public 112 | name: Upload to NPM 113 | env: 114 | NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} 115 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "Code Scanning - Action" 2 | 3 | on: pull_request 4 | 5 | jobs: 6 | CodeQL-Build: 7 | 8 | # CodeQL runs on ubuntu-latest, windows-latest, and macos-latest 9 | runs-on: ${{ matrix.os }} 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | os: [ubuntu-latest, windows-latest, macos-latest] 14 | 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v2 18 | 19 | # Initializes the CodeQL tools for scanning. 20 | - name: Initialize CodeQL 21 | uses: github/codeql-action/init@v1 22 | with: 23 | languages: javascript, cpp 24 | 25 | # Setup dependencies (and build native modules from source) 26 | - name: Install dependencies 27 | run: | 28 | npm install 29 | npm run build 30 | 31 | # Run code analysis 32 | - name: Perform CodeQL Analysis 33 | uses: github/codeql-action/analyze@v1 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /build 3 | *.swp 4 | *.log 5 | *~ 6 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 14.15.1 2 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /build 2 | /docker 3 | /script 4 | /test 5 | *.log 6 | *~ 7 | .travis.yml 8 | appveyor.yml 9 | .node-version 10 | .npmignore 11 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 GitHub Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ##### Atom and all repositories under Atom will be archived on December 15, 2022. Learn more in our [official announcement](https://github.blog/2022-06-08-sunsetting-atom/) 2 | # fs-admin 3 | 4 | [![Build Status](https://travis-ci.org/atom/fs-admin.svg?branch=master)](https://travis-ci.org/atom/fs-admin) 5 | [![Build status](https://ci.appveyor.com/api/projects/status/5c5gpb9idn1xcw1y/branch/master?svg=true)](https://ci.appveyor.com/project/Atom/fs-admin/branch/master) 6 | 7 | Perform file system operations with administrator privileges. 8 | 9 | ## Installing 10 | 11 | ```sh 12 | npm install fs-admin 13 | ``` 14 | 15 | ## Packaging (Linux only) 16 | 17 | This library uses [PolicyKit](https://wiki.archlinux.org/index.php/Polkit) to escalate privileges when calling `createWriteStream(path)` on Linux. In particular, it will invoke `pkexec dd of=path` to stream the desired bytes into the specified location. 18 | 19 | ### PolicyKit 20 | 21 | Not all Linux distros may include PolicyKit as part of their standard installation. As such, it is recommended to make it an explicit dependency of your application package. The following is an example Debian control file that requires `policykit-1` to be installed as part of `my-application`: 22 | 23 | ``` 24 | Package: my-application 25 | Version: 1.0.0 26 | Depends: policykit-1 27 | ``` 28 | 29 | ### Policies 30 | 31 | When using this library as part of a Linux application, you may want to install a [Policy](https://wiki.archlinux.org/index.php/PolicyKit#Actions) as well. Although not mandatory, policy files allow customizing the behavior of `pkexec` by e.g., displaying a custom password prompt or retaining admin privileges for a short period of time: 32 | 33 | ```xml 34 | 35 | 38 | 39 | Your Application Name 40 | 41 | Admin privileges required 42 | Please enter your password to save this file 43 | /bin/dd 44 | true 45 | 46 | auth_admin_keep 47 | auth_admin_keep 48 | auth_admin_keep 49 | 50 | 51 | 52 | ``` 53 | 54 | Policy files should be installed in `/usr/share/polkit-1/actions` as part of your application's installation script. 55 | 56 | For more information, you can find a complete example of requiring PolicyKit and distributing policy files in the [Atom repository](https://github.com/atom/atom/pull/19412). 57 | -------------------------------------------------------------------------------- /binding.gyp: -------------------------------------------------------------------------------- 1 | { 2 | 'target_defaults': { 3 | 'win_delay_load_hook': 'false', 4 | 'conditions': [ 5 | ['OS=="win"', { 6 | 'msvs_disabled_warnings': [ 7 | 4530, # C++ exception handler used, but unwind semantics are not enabled 8 | 4506, # no definition for inline function 9 | ], 10 | }], 11 | ], 12 | }, 13 | 'targets': [ 14 | { 15 | 'target_name': 'fs_admin', 16 | 'defines': [ 17 | "NAPI_VERSION=<(napi_build_version)", 18 | ], 19 | 'cflags!': [ '-fno-exceptions' ], 20 | 'cflags_cc!': [ '-fno-exceptions' ], 21 | 'xcode_settings': { 'GCC_ENABLE_CPP_EXCEPTIONS': 'YES', 22 | 'CLANG_CXX_LIBRARY': 'libc++', 23 | 'MACOSX_DEPLOYMENT_TARGET': '10.7', 24 | }, 25 | 'msvs_settings': { 26 | 'VCCLCompilerTool': { 'ExceptionHandling': 1 }, 27 | }, 28 | 'sources': [ 29 | 'src/main.cc', 30 | ], 31 | 'include_dirs': [ 32 | ' void 8 | ): void; 9 | 10 | export function recursiveCopy( 11 | sourcePath: string, 12 | destinationPath: string, 13 | callback: (error: Error | null) => void 14 | ): void; 15 | 16 | export function symlink( 17 | target: string, 18 | filePath: string, 19 | callback: (error: Error | null) => void 20 | ): void; 21 | 22 | export function unlink( 23 | filePath: string, 24 | callback: (error: Error | null) => void 25 | ): void; 26 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const { spawn, spawnSync } = require('child_process') 3 | const EventEmitter = require('events') 4 | const binding = require('./build/Release/fs_admin.node') 5 | const fsAdmin = module.exports 6 | 7 | fsAdmin.testMode = false 8 | 9 | switch (process.platform) { 10 | case 'darwin': 11 | Object.assign(fsAdmin, { 12 | clearAuthorizationCache () { 13 | binding.clearAuthorizationCache() 14 | }, 15 | 16 | createWriteStream (filePath) { 17 | let authopen 18 | 19 | // Prompt for credentials synchronously to avoid creating multiple simultaneous prompts. 20 | if (!binding.spawnAsAdmin('/bin/echo', [], fsAdmin.testMode, () => {})) { 21 | const result = new EventEmitter() 22 | result.write = result.end = function () {} 23 | process.nextTick(() => result.emit('error', new Error('Failed to obtain credentials'))) 24 | return result 25 | } 26 | 27 | if (fsAdmin.testMode) { 28 | authopen = spawn('/bin/dd', ['of=' + filePath]) 29 | } else { 30 | authopen = spawn('/usr/libexec/authopen', ['-extauth', '-w', '-c', filePath]) 31 | authopen.stdin.write(binding.getAuthorizationForm()) 32 | } 33 | 34 | const result = new EventEmitter() 35 | 36 | result.write = (chunk, encoding, callback) => { 37 | authopen.stdin.write(chunk, encoding, callback) 38 | } 39 | 40 | result.end = (callback) => { 41 | if (callback) result.on('finish', callback) 42 | authopen.stdin.end() 43 | } 44 | 45 | authopen.on('exit', (exitCode) => { 46 | if (exitCode !== 0) { 47 | result.emit('error', new Error('authopen exited with code ' + exitCode)) 48 | } 49 | result.emit('finish') 50 | }) 51 | 52 | return result 53 | }, 54 | 55 | symlink (target, filePath, callback) { 56 | binding.spawnAsAdmin( 57 | '/bin/ln', 58 | ['-s', target, filePath], 59 | fsAdmin.testMode, 60 | wrapCallback('ln', callback) 61 | ) 62 | }, 63 | 64 | unlink (filePath, callback) { 65 | binding.spawnAsAdmin( 66 | '/bin/rm', 67 | ['-rf', filePath], 68 | fsAdmin.testMode, 69 | wrapCallback('rm', callback) 70 | ) 71 | }, 72 | 73 | makeTree (directoryPath, callback) { 74 | binding.spawnAsAdmin( 75 | '/bin/mkdir', 76 | ['-p', directoryPath], 77 | fsAdmin.testMode, 78 | wrapCallback('mkdir', callback) 79 | ) 80 | }, 81 | 82 | recursiveCopy (sourcePath, destinationPath, callback) { 83 | binding.spawnAsAdmin( 84 | '/bin/rm', 85 | ['-r', '-f', destinationPath], 86 | fsAdmin.testMode, 87 | wrapCallback('rm', (error) => { 88 | if (error) return callback(error) 89 | binding.spawnAsAdmin( 90 | '/bin/cp', 91 | ['-r', sourcePath, destinationPath], 92 | fsAdmin.testMode, 93 | wrapCallback('cp', callback) 94 | ) 95 | }) 96 | ) 97 | } 98 | }) 99 | break 100 | 101 | case 'win32': 102 | Object.assign(fsAdmin, { 103 | symlink (target, filePath, callback) { 104 | binding.spawnAsAdmin( 105 | 'cmd', 106 | ['/c', 'mklink', '/j', filePath, target], 107 | fsAdmin.testMode, 108 | wrapCallback('mklink', callback) 109 | ) 110 | }, 111 | 112 | unlink (filePath, callback) { 113 | fs.stat(filePath, (error, status) => { 114 | if (error) return callback(error) 115 | if (status.isDirectory()) { 116 | binding.spawnAsAdmin( 117 | 'cmd', 118 | ['/c', 'rmdir', '/s', '/q', filePath], 119 | fsAdmin.testMode, 120 | wrapCallback('rmdir', callback) 121 | ) 122 | } else { 123 | binding.spawnAsAdmin( 124 | 'cmd', 125 | ['/c', 'del', '/f', '/q', filePath], 126 | fsAdmin.testMode, 127 | wrapCallback('del', callback) 128 | ) 129 | } 130 | }) 131 | }, 132 | 133 | makeTree (directoryPath, callback) { 134 | binding.spawnAsAdmin( 135 | 'cmd', 136 | ['/c', 'mkdir', directoryPath], 137 | fsAdmin.testMode, 138 | wrapCallback('mkdir', callback) 139 | ) 140 | }, 141 | 142 | recursiveCopy (sourcePath, destinationPath, callback) { 143 | binding.spawnAsAdmin( 144 | 'cmd', 145 | ['/c', require.resolve('./src/copy-folder.cmd'), sourcePath, destinationPath], 146 | fsAdmin.testMode, 147 | wrapCallback('robocopy', callback) 148 | ) 149 | } 150 | }) 151 | break 152 | 153 | case 'linux': 154 | Object.assign(fsAdmin, { 155 | clearAuthorizationCache () { 156 | spawnSync('/bin/pkcheck', ['--revoke-temp']) 157 | }, 158 | 159 | createWriteStream (filePath) { 160 | if (!fsAdmin.testMode) { 161 | // Prompt for credentials synchronously to avoid creating multiple simultaneous prompts. 162 | const noopCommand = spawnSync('/usr/bin/pkexec', ['/bin/dd']) 163 | if (noopCommand.error || noopCommand.status !== 0) { 164 | const result = new EventEmitter() 165 | result.write = result.end = function () {} 166 | process.nextTick(() => { 167 | result.emit('error', new Error('Failed to obtain credentials')) 168 | }) 169 | return result 170 | } 171 | } 172 | 173 | const dd = fsAdmin.testMode 174 | ? spawn('/bin/dd', ['of=' + filePath]) 175 | : spawn('/usr/bin/pkexec', ['/bin/dd', 'of=' + filePath]) 176 | 177 | const stream = new EventEmitter() 178 | stream.write = (chunk, encoding, callback) => { 179 | dd.stdin.write(chunk, encoding, callback) 180 | } 181 | stream.end = (callback) => { 182 | if (callback) stream.on('finish', callback) 183 | dd.stdin.end() 184 | } 185 | dd.on('exit', (exitCode) => { 186 | if (exitCode !== 0) { 187 | stream.emit('error', new Error('dd exited with code ' + exitCode)) 188 | } 189 | stream.emit('finish') 190 | }) 191 | 192 | return stream 193 | } 194 | }) 195 | break 196 | } 197 | 198 | function wrapCallback (commandName, callback) { 199 | return (exitCode) => callback( 200 | exitCode === 0 201 | ? null 202 | : new Error(commandName + ' failed with exit status ' + exitCode) 203 | ) 204 | } 205 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fs-admin", 3 | "version": "0.20.0", 4 | "description": "Manipulate files with escalated privileges", 5 | "main": "index.js", 6 | "types": "index.d.ts", 7 | "scripts": { 8 | "test": "standard && mocha", 9 | "install": "prebuild-install || npm run build", 10 | "build": "node-gyp rebuild", 11 | "prebuild-napi-x64": "prebuild -t 3 -r napi -a x64 --strip", 12 | "prebuild-napi-ia32": "prebuild -t 3 -r napi -a ia32 --strip", 13 | "prebuild-napi-arm64": "prebuild -t 3 -r napi -a arm64 --strip", 14 | "upload": "node ./script/upload.js" 15 | }, 16 | "keywords": [ 17 | "file", 18 | "system", 19 | "privileges" 20 | ], 21 | "author": "Max Brunsfeld", 22 | "license": "MIT", 23 | "repository": { 24 | "type": "git", 25 | "url": "https://github.com/atom/fs-admin.git" 26 | }, 27 | "bugs": { 28 | "url": "https://github.com/atom/fs-admin/issues" 29 | }, 30 | "dependencies": { 31 | "node-addon-api": "^4.2.0", 32 | "prebuild-install": "^7.0.1" 33 | }, 34 | "devDependencies": { 35 | "mocha": "^9.2.0", 36 | "node-gyp": "^8.0.0", 37 | "prebuild": "^11.0.0", 38 | "standard": "^16.0.1", 39 | "temp": "^0.9.0" 40 | }, 41 | "binary": { 42 | "napi_versions": [ 43 | 3 44 | ] 45 | }, 46 | "config": { 47 | "runtime": "napi", 48 | "target": 3 49 | }, 50 | "standard": { 51 | "globals": [ 52 | "beforeEach", 53 | "describe", 54 | "it" 55 | ] 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /script/download-node-lib-win-arm64.ps1: -------------------------------------------------------------------------------- 1 | # This script can be removed as soon as official Windows arm64 builds are published: 2 | # https://github.com/nodejs/build/issues/2450#issuecomment-705853342 3 | 4 | $nodeVersion = $args[0] 5 | $fallbackVersion = $args[1] 6 | 7 | If ($null -eq $nodeVersion -Or $null -eq $fallbackVersion) { 8 | Write-Error "No NodeJS version given as argument to this file. Run it like download-nodejs-win-arm64.ps1 NODE_VERSION NODE_FALLBACK_VERSION" 9 | exit 1 10 | } 11 | 12 | $url = "https://unofficial-builds.nodejs.org/download/release/v$nodeVersion/win-arm64/node.lib" 13 | $fallbackUrl = "https://unofficial-builds.nodejs.org/download/release/v$fallbackVersion/win-arm64/node.lib" 14 | 15 | # Always write to the $nodeVersion cache folder, even if we're using the fallbackVersion 16 | $cacheFolder = "$env:TEMP\prebuild\napi\$nodeVersion\arm64" 17 | 18 | If (!(Test-Path $cacheFolder)) { 19 | New-Item -ItemType Directory -Force -Path $cacheFolder 20 | } 21 | 22 | $output = "$cacheFolder\node.lib" 23 | $start_time = Get-Date 24 | 25 | Try { 26 | Invoke-WebRequest -Uri $url -OutFile $output 27 | $downloadedNodeVersion = $nodeVersion 28 | } Catch { 29 | If ($_.Exception.Response -And $_.Exception.Response.StatusCode -eq "NotFound") { 30 | Write-Output "No arm64 node.lib found for Node Windows $nodeVersion, trying fallback version $fallbackVersion..." 31 | Invoke-WebRequest -Uri $fallbackUrl -OutFile $output 32 | $downloadedNodeVersion = $fallbackVersion 33 | } 34 | } 35 | 36 | Write-Output "Downloaded arm64 NodeJS lib v$downloadedNodeVersion to $output in $((Get-Date).Subtract($start_time).Seconds) second(s)" 37 | -------------------------------------------------------------------------------- /script/upload.js: -------------------------------------------------------------------------------- 1 | // to ensure that env not in the CI server log 2 | 3 | const path = require('path') 4 | const { spawnSync } = require('child_process') 5 | 6 | spawnSync(path.join(__dirname, '../node_modules/.bin/prebuild' + (process.platform === 'win32' ? '.cmd' : '')), ['--upload-all', process.env.GITHUB_AUTH_TOKEN], { stdio: 'inherit' }) 7 | -------------------------------------------------------------------------------- /src/copy-folder.cmd: -------------------------------------------------------------------------------- 1 | if exist %2 rmdir %2 /s /q 2 | (robocopy %1 %2 /e) ^& IF %ERRORLEVEL% LEQ 1 exit 0 3 | -------------------------------------------------------------------------------- /src/fs-admin-darwin.cc: -------------------------------------------------------------------------------- 1 | #include "fs-admin.h" 2 | 3 | #include 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | 10 | namespace fs_admin { 11 | 12 | using std::string; 13 | using std::vector; 14 | 15 | namespace { 16 | 17 | AuthorizationRef authorization_ref = nullptr; 18 | 19 | } 20 | 21 | AuthorizationRef GetAuthorizationRef() { 22 | if (!authorization_ref) { 23 | OSStatus status = AuthorizationCreate( 24 | NULL, 25 | kAuthorizationEmptyEnvironment, 26 | kAuthorizationFlagDefaults, 27 | &authorization_ref 28 | ); 29 | 30 | if (status != errAuthorizationSuccess) return nullptr; 31 | } 32 | 33 | return authorization_ref; 34 | } 35 | 36 | void ClearAuthorizationCacheImpl() { 37 | if (authorization_ref) { 38 | AuthorizationFree(authorization_ref, kAuthorizationFlagDefaults); 39 | authorization_ref = nullptr; 40 | } 41 | } 42 | 43 | string CreateAuthorizationForm() { 44 | AuthorizationRef authorization_ref = GetAuthorizationRef(); 45 | 46 | AuthorizationExternalForm form; 47 | OSStatus status = AuthorizationMakeExternalForm( 48 | authorization_ref, 49 | &form 50 | ); 51 | 52 | if (status != errAuthorizationSuccess) return {}; 53 | return string(form.bytes, form.bytes + kAuthorizationExternalFormLength); 54 | } 55 | 56 | void *StartChildProcess(const string &command, const vector &args, bool test_mode) { 57 | AuthorizationRef authorization_ref = GetAuthorizationRef(); 58 | 59 | vector argv; 60 | argv.reserve(args.size()); 61 | for (const string &arg : args) { 62 | argv.push_back(const_cast(arg.c_str())); 63 | } 64 | argv.push_back(nullptr); 65 | 66 | if (test_mode) { 67 | int pid = fork(); 68 | switch (pid) { 69 | case -1: 70 | return nullptr; 71 | 72 | case 0: 73 | argv.insert(argv.begin(), const_cast(command.c_str())); 74 | execvp(command.c_str(), argv.data()); 75 | abort(); 76 | 77 | default: 78 | int *result = new int; 79 | *result = pid; 80 | return result; 81 | } 82 | } 83 | 84 | FILE *pipe; 85 | 86 | #pragma clang diagnostic push 87 | #pragma clang diagnostic ignored "-Wdeprecated-declarations" 88 | OSStatus status = AuthorizationExecuteWithPrivileges( 89 | authorization_ref, 90 | command.c_str(), 91 | kAuthorizationFlagDefaults, 92 | &argv[0], 93 | &pipe 94 | ); 95 | #pragma clang diagnostic pop 96 | 97 | if (status != errAuthorizationSuccess) return nullptr; 98 | return pipe; 99 | } 100 | 101 | int WaitForChildProcessToExit(void *child_process, bool test_mode) { 102 | if (test_mode) { 103 | int *pid_pointer = static_cast(child_process); 104 | int pid = *pid_pointer; 105 | delete pid_pointer; 106 | 107 | int status; 108 | waitpid(pid, &status, 0); 109 | return WEXITSTATUS(status); 110 | } 111 | 112 | char buffer[513]; 113 | auto pipe = static_cast(child_process); 114 | while (true) { 115 | size_t bytes_read = fread(buffer, 512, 1, pipe); 116 | if (bytes_read == 0) break; 117 | fwrite(buffer, bytes_read, 1, stdout); 118 | fputs("", stdout); 119 | } 120 | 121 | fclose(pipe); 122 | 123 | int status; 124 | wait(&status); 125 | return WEXITSTATUS(status); 126 | } 127 | 128 | } // namespace fs_admin -------------------------------------------------------------------------------- /src/fs-admin-linux.cc: -------------------------------------------------------------------------------- 1 | #include "fs-admin.h" 2 | 3 | namespace fs_admin { 4 | 5 | using std::vector; 6 | using std::string; 7 | 8 | void *StartChildProcess(const string &command, const vector &args, bool test_mode) { 9 | return nullptr; 10 | } 11 | 12 | int WaitForChildProcessToExit(void *child_process, bool test_mode) { 13 | return -1; 14 | } 15 | 16 | string CreateAuthorizationForm() { return ""; } 17 | void ClearAuthorizationCacheImpl() {} 18 | 19 | } // namespace fs_admin 20 | -------------------------------------------------------------------------------- /src/fs-admin-win.cc: -------------------------------------------------------------------------------- 1 | #include "fs-admin.h" 2 | #include 3 | 4 | namespace fs_admin { 5 | 6 | using std::string; 7 | using std::vector; 8 | 9 | string QuoteCmdArg(const string& arg) { 10 | if (arg.size() == 0) 11 | return arg; 12 | 13 | // No quotation needed. 14 | if (arg.find_first_of(" \t\"") == string::npos) 15 | return arg; 16 | 17 | // No embedded double quotes or backlashes, just wrap quote marks around 18 | // the whole thing. 19 | if (arg.find_first_of("\"\\") == string::npos) 20 | return string("\"") + arg + '"'; 21 | 22 | // Expected input/output: 23 | // input : hello"world 24 | // output: "hello\"world" 25 | // input : hello""world 26 | // output: "hello\"\"world" 27 | // input : hello\world 28 | // output: hello\world 29 | // input : hello\\world 30 | // output: hello\\world 31 | // input : hello\"world 32 | // output: "hello\\\"world" 33 | // input : hello\\"world 34 | // output: "hello\\\\\"world" 35 | // input : hello world\ 36 | // output: "hello world\" 37 | string quoted; 38 | bool quote_hit = true; 39 | for (size_t i = arg.size(); i > 0; --i) { 40 | quoted.push_back(arg[i - 1]); 41 | 42 | if (quote_hit && arg[i - 1] == '\\') { 43 | quoted.push_back('\\'); 44 | } else if (arg[i - 1] == '"') { 45 | quote_hit = true; 46 | quoted.push_back('\\'); 47 | } else { 48 | quote_hit = false; 49 | } 50 | } 51 | 52 | return string("\"") + string(quoted.rbegin(), quoted.rend()) + '"'; 53 | } 54 | 55 | void *StartChildProcess(const string &command, const vector &args, bool test_mode) { 56 | CoInitializeEx(NULL, COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE); 57 | 58 | string parameters; 59 | for (size_t i = 0; i < args.size(); ++i) { 60 | parameters += QuoteCmdArg(args[i]) + ' '; 61 | } 62 | 63 | SHELLEXECUTEINFO shell_execute_info = {}; 64 | shell_execute_info.cbSize = sizeof(shell_execute_info); 65 | shell_execute_info.fMask = SEE_MASK_NOASYNC | SEE_MASK_NOCLOSEPROCESS; 66 | shell_execute_info.lpVerb = test_mode ? "open" : "runas"; 67 | shell_execute_info.lpFile = command.c_str(); 68 | shell_execute_info.lpParameters = parameters.c_str(); 69 | shell_execute_info.nShow = SW_HIDE; 70 | 71 | if (::ShellExecuteEx(&shell_execute_info) == FALSE || !shell_execute_info.hProcess) { 72 | return nullptr; 73 | } 74 | 75 | return shell_execute_info.hProcess; 76 | } 77 | 78 | int WaitForChildProcessToExit(void *child_process, bool test_mode) { 79 | HANDLE process = static_cast(child_process); 80 | ::WaitForSingleObject(process, INFINITE); 81 | 82 | DWORD code; 83 | if (::GetExitCodeProcess(process, &code) == 0) return -1; 84 | 85 | return code; 86 | } 87 | 88 | std::string CreateAuthorizationForm() { return ""; } 89 | void ClearAuthorizationCacheImpl() {} 90 | 91 | } // namespace fs_admin 92 | -------------------------------------------------------------------------------- /src/fs-admin.h: -------------------------------------------------------------------------------- 1 | #ifndef SRC_SPAWN_AS_ADMIN_H_ 2 | #define SRC_SPAWN_AS_ADMIN_H_ 3 | 4 | #include 5 | #include 6 | 7 | namespace fs_admin { 8 | 9 | std::string CreateAuthorizationForm(); 10 | 11 | void ClearAuthorizationCacheImpl(); 12 | 13 | void *StartChildProcess(const std::string &command, const std::vector &args, bool test_mode); 14 | 15 | int WaitForChildProcessToExit(void *, bool test_mode); 16 | 17 | } // namespace fs_admin 18 | 19 | #endif // SRC_SPAWN_AS_ADMIN_H_ 20 | -------------------------------------------------------------------------------- /src/main.cc: -------------------------------------------------------------------------------- 1 | #include "napi.h" 2 | #include "uv.h" 3 | #include "fs-admin.h" 4 | 5 | namespace fs_admin { 6 | 7 | using namespace Napi; 8 | 9 | class Worker : public Napi::AsyncWorker { 10 | void *child_process; 11 | int exit_code; 12 | bool test_mode; 13 | 14 | public: 15 | Worker(const Function& callback, void *child_process, bool test_mode) : 16 | Napi::AsyncWorker(callback), 17 | child_process(child_process), 18 | exit_code(-1), 19 | test_mode(test_mode) {} 20 | 21 | void Execute() { 22 | exit_code = WaitForChildProcessToExit(child_process, test_mode); 23 | } 24 | 25 | void OnOK() { 26 | const std::vector argv{Napi::Number::New(Env(), exit_code)}; 27 | Callback().Call(argv); 28 | } 29 | }; 30 | 31 | Napi::Value GetAuthorizationForm(const Napi::CallbackInfo& info) { 32 | const Napi::Env& env = info.Env(); 33 | auto auth_form = CreateAuthorizationForm(); 34 | auto buffer = Napi::Buffer::Copy(env, auth_form.c_str(), auth_form.size()); 35 | return buffer; 36 | } 37 | 38 | Napi::Value ClearAuthorizationCache(const Napi::CallbackInfo& info) { 39 | ClearAuthorizationCacheImpl(); 40 | return info.Env().Undefined(); 41 | } 42 | 43 | Napi::Value SpawnAsAdmin(const Napi::CallbackInfo& info) { 44 | const Napi::Env& env = info.Env(); 45 | if (!info[0].IsString()) { 46 | Napi::TypeError::New(env, "Command must be a string").ThrowAsJavaScriptException(); 47 | return env.Null(); 48 | } 49 | 50 | std::string command = info[0].As(); 51 | 52 | if (!info[1].IsArray()) { 53 | Napi::TypeError::New(env, "Arguments must be an array").ThrowAsJavaScriptException(); 54 | return env.Undefined(); 55 | } 56 | 57 | Napi::Array js_args = info[1].As(); 58 | std::vector args; 59 | args.reserve(js_args.Length()); 60 | for (uint32_t i = 0; i < js_args.Length(); ++i) { 61 | Napi::Value js_arg = js_args.Get(i); 62 | if (!js_arg.IsString()) { 63 | Napi::TypeError::New(env, "Arguments must be an array of strings").ThrowAsJavaScriptException(); 64 | return env.Undefined(); 65 | } 66 | 67 | args.push_back(js_arg.As()); 68 | } 69 | 70 | bool test_mode = false; 71 | if (info[2].ToBoolean().Value()) test_mode = true; 72 | 73 | if (!info[3].IsFunction()) { 74 | Napi::TypeError::New(env, "Callback must be a function").ThrowAsJavaScriptException(); 75 | return env.Undefined(); 76 | } 77 | 78 | void *child_process = StartChildProcess(command, args, test_mode); 79 | if (!child_process) { 80 | return Napi::Boolean::New(env, false); 81 | } else { 82 | auto worker = new Worker(info[3].As(), child_process, test_mode); 83 | worker->Queue(); 84 | return Napi::Boolean::New(env, true); 85 | } 86 | } 87 | 88 | Napi::Object Init(Napi::Env env, Napi::Object exports) { 89 | exports.Set(Napi::String::New(env, "getAuthorizationForm"), Napi::Function::New(env, GetAuthorizationForm)); 90 | exports.Set(Napi::String::New(env, "clearAuthorizationCache"), Napi::Function::New(env, ClearAuthorizationCache)); 91 | exports.Set(Napi::String::New(env, "spawnAsAdmin"), Napi::Function::New(env, SpawnAsAdmin)); 92 | return exports; 93 | } 94 | 95 | NODE_API_MODULE(fs_admin, Init) 96 | 97 | } // namespace spawn_as_admin 98 | -------------------------------------------------------------------------------- /test/fs-admin.test.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const assert = require('assert') 4 | const temp = require('temp') 5 | const fsAdmin = require('..') 6 | 7 | // Comment this out to test with actual privilege escalation. 8 | fsAdmin.testMode = true 9 | 10 | describe('fs-admin', function () { 11 | let dirPath, filePath 12 | 13 | beforeEach(() => { 14 | dirPath = temp.mkdirSync('fs-admin-test') 15 | filePath = path.join(dirPath, 'file') 16 | }) 17 | 18 | // Allow enough time for typing credentials 19 | if (!fsAdmin.testMode) this.timeout(10000) 20 | 21 | describe('createWriteStream', () => { 22 | if (process.platform === 'win32') return 23 | 24 | it('writes to the given file as the admin user', (done) => { 25 | fs.writeFileSync(filePath, '') 26 | 27 | if (!fsAdmin.testMode) { 28 | fs.chmodSync(filePath, 0o444) 29 | assert.throws(() => fs.writeFileSync(filePath, 'hi'), /EACCES|EPERM/) 30 | } 31 | 32 | fs.createReadStream(__filename) 33 | .pipe(fsAdmin.createWriteStream(filePath)) 34 | .on('finish', () => { 35 | assert.strictEqual(fs.readFileSync(filePath, 'utf8'), fs.readFileSync(__filename, 'utf8')) 36 | done() 37 | }) 38 | }) 39 | 40 | it('does not prompt multiple times when concurrent writes are requested', (done) => { 41 | fsAdmin.clearAuthorizationCache() 42 | 43 | const filePath2 = path.join(dirPath, 'file2') 44 | const filePath3 = path.join(dirPath, 'file3') 45 | 46 | fs.writeFileSync(filePath, '') 47 | fs.writeFileSync(filePath2, '') 48 | fs.writeFileSync(filePath3, '') 49 | 50 | if (!fsAdmin.testMode) { 51 | fs.chmodSync(filePath, 0o444) 52 | fs.chmodSync(filePath2, 0o444) 53 | fs.chmodSync(filePath3, 0o444) 54 | assert.throws(() => fs.writeFileSync(filePath, 'hi'), /EACCES|EPERM/) 55 | assert.throws(() => fs.writeFileSync(filePath2, 'hi'), /EACCES|EPERM/) 56 | assert.throws(() => fs.writeFileSync(filePath3, 'hi'), /EACCES|EPERM/) 57 | } 58 | 59 | Promise.all([filePath, filePath2, filePath3].map((filePath) => 60 | new Promise((resolve) => 61 | fs.createReadStream(__filename) 62 | .pipe(fsAdmin.createWriteStream(filePath)) 63 | .on('finish', () => { 64 | assert.strictEqual(fs.readFileSync(filePath, 'utf8'), fs.readFileSync(__filename, 'utf8')) 65 | resolve() 66 | }) 67 | ) 68 | )).then(() => done()) 69 | }) 70 | }) 71 | 72 | describe('makeTree', () => { 73 | if (process.platform === 'linux') return 74 | 75 | it('creates a directory at the given path as the admin user', (done) => { 76 | const pathToCreate = path.join(dirPath, 'dir1', 'dir2', 'dir3') 77 | 78 | fsAdmin.makeTree(pathToCreate, (error) => { 79 | assert.strictEqual(error, null) 80 | const stats = fs.statSync(pathToCreate) 81 | assert(stats.isDirectory()) 82 | 83 | if (process.platform === 'darwin' && !fsAdmin.testMode) { 84 | assert.strictEqual(stats.uid, 0) 85 | } 86 | 87 | done() 88 | }) 89 | }) 90 | }) 91 | 92 | describe('unlink', () => { 93 | if (process.platform === 'linux') return 94 | 95 | it('deletes the given file as the admin user', (done) => { 96 | fs.writeFileSync(filePath, '') 97 | 98 | if (!fsAdmin.testMode) { 99 | fs.chmodSync(filePath, 0o444) 100 | fs.chmodSync(path.dirname(filePath), 0o444) 101 | assert.throws(() => fs.unlinkSync(filePath), /EACCES|EPERM/) 102 | } 103 | 104 | fsAdmin.unlink(filePath, (error) => { 105 | assert.strictEqual(error, null) 106 | assert(!fs.existsSync(filePath)) 107 | done() 108 | }) 109 | }) 110 | 111 | it('deletes the given directory as the admin user', (done) => { 112 | fs.mkdirSync(filePath) 113 | 114 | if (!fsAdmin.testMode) { 115 | fs.chmodSync(filePath, 0o444) 116 | fs.chmodSync(path.dirname(filePath), 0o444) 117 | assert.throws(() => fs.unlinkSync(filePath), /EACCES|EPERM/) 118 | } 119 | 120 | fsAdmin.unlink(filePath, (error) => { 121 | assert.strictEqual(error, null) 122 | assert(!fs.existsSync(filePath)) 123 | done() 124 | }) 125 | }) 126 | }) 127 | 128 | describe('symlink', () => { 129 | // TODO: investigate why these tests are muted and how we could run them 130 | // in an Actions-based environment 131 | if (process.platform === 'linux') return 132 | if (process.platform === 'win32') return 133 | 134 | it('creates a symlink at the given path as the admin user', (done) => { 135 | fsAdmin.symlink(__filename, filePath, (error) => { 136 | assert.strictEqual(error, null) 137 | 138 | if (!fsAdmin.testMode) { 139 | assert.strictEqual(fs.lstatSync(filePath).uid, 0) 140 | } 141 | 142 | assert.strictEqual(fs.readFileSync(filePath, 'utf8'), fs.readFileSync(__filename, 'utf8')) 143 | done() 144 | }) 145 | }) 146 | }) 147 | 148 | describe('recursiveCopy', () => { 149 | if (process.platform === 'linux') return 150 | 151 | it('copies the given folder to the given location as the admin user', (done) => { 152 | const sourcePath = path.join(dirPath, 'src-dir') 153 | fs.mkdirSync(sourcePath) 154 | fs.mkdirSync(path.join(sourcePath, 'dir1')) 155 | fs.writeFileSync(path.join(sourcePath, 'dir1', 'file1.txt'), '1') 156 | fs.writeFileSync(path.join(sourcePath, 'dir1', 'file2.txt'), '2') 157 | 158 | const destinationPath = path.join(dirPath, 'dest-dir') 159 | fs.mkdirSync(destinationPath) 160 | fs.writeFileSync(path.join(destinationPath, 'other-file.txt'), '3') 161 | 162 | if (!fsAdmin.testMode) { 163 | fs.writeFileSync(path.join(destinationPath, 'something'), '') 164 | fs.chmodSync(path.join(destinationPath, 'something'), 0o444) 165 | assert.throws(() => fs.unlinkSync(destinationPath), /EACCES|EPERM/) 166 | } 167 | 168 | fsAdmin.recursiveCopy(sourcePath, destinationPath, (error) => { 169 | assert.strictEqual(fs.readFileSync(path.join(destinationPath, 'dir1', 'file1.txt'), 'utf8'), '1') 170 | assert.strictEqual(fs.readFileSync(path.join(destinationPath, 'dir1', 'file2.txt'), 'utf8'), '2') 171 | assert(!fs.existsSync(path.join(destinationPath, 'other-file.txt'))) 172 | assert.strictEqual(error, null) 173 | done() 174 | }) 175 | }) 176 | 177 | it('works when there is nothing at the destination path', (done) => { 178 | const sourcePath = path.join(dirPath, 'src-dir') 179 | fs.mkdirSync(sourcePath) 180 | fs.mkdirSync(path.join(sourcePath, 'dir1')) 181 | fs.writeFileSync(path.join(sourcePath, 'dir1', 'file1.txt'), '1') 182 | fs.writeFileSync(path.join(sourcePath, 'dir1', 'file2.txt'), '2') 183 | 184 | const destinationPath = path.join(dirPath, 'dest-dir') 185 | 186 | fsAdmin.recursiveCopy(sourcePath, destinationPath, (error) => { 187 | assert.strictEqual(fs.readFileSync(path.join(destinationPath, 'dir1', 'file1.txt'), 'utf8'), '1') 188 | assert.strictEqual(fs.readFileSync(path.join(destinationPath, 'dir1', 'file2.txt'), 'utf8'), '2') 189 | assert.strictEqual(error, null) 190 | done() 191 | }) 192 | }) 193 | }) 194 | }) 195 | --------------------------------------------------------------------------------