├── .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 | [](https://travis-ci.org/atom/fs-admin)
5 | [](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 |
--------------------------------------------------------------------------------