Skip to content

Commit

Permalink
feat(extension): allow to run admin tasks from extensions
Browse files Browse the repository at this point in the history
I noticed several extensions have some code to run admin tasks
using like sudo-prompt, or pkexec

here it brings a new parameter isAdmin in the exec API
so extensions can run admin tasks

it uses for macOS osascript allowing to use touch ID

pre-req of
containers#2998

Signed-off-by: Florent Benoit <fbenoit@redhat.com>
  • Loading branch information
benoitf committed Sep 22, 2023
1 parent e82916b commit 957c6c5
Show file tree
Hide file tree
Showing 4 changed files with 205 additions and 1 deletion.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@
"moment": "^2.29.4",
"os-locale": "^6.0.2",
"stream-json": "^1.8.0",
"sudo-prompt": "^9.2.1",
"tar": "^6.2.0",
"tar-fs": "^3.0.4",
"win-ca": "^3.5.0",
Expand Down
5 changes: 5 additions & 0 deletions packages/extension-api/src/extension-api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2014,6 +2014,11 @@ declare module '@podman-desktop/api' {
* custom directory
*/
cwd?: string;

/**
* admin privileges required
*/
isAdmin?: boolean;
}

/**
Expand Down
140 changes: 140 additions & 0 deletions packages/main/src/plugin/util/exec.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,43 @@
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/

import type { Mock } from 'vitest';
import { expect, describe, test, vi, beforeEach, afterEach } from 'vitest';
import { getInstallationPath, macosExtraPath, Exec } from './exec.js';
import * as util from '../../util.js';
import type { ChildProcessWithoutNullStreams } from 'child_process';
import { spawn } from 'child_process';
import type { Readable } from 'node:stream';
import type { Proxy } from '../proxy.js';
import * as sudo from 'sudo-prompt';

/* eslint-disable @typescript-eslint/no-explicit-any */

// Mock sudo-prompt exec to resolve everytime.
vi.mock('sudo-prompt', async () => {
return {
exec: vi.fn(),
};
});

vi.mock('../../util', async () => {
return {
isWindows: vi.fn(),
isMac: vi.fn(),
isLinux: vi.fn(),
};
});

describe('exec', () => {
const proxy: Proxy = {
isEnabled: vi.fn().mockReturnValue(false),
} as unknown as Proxy;

beforeEach(() => {
vi.resetAllMocks();
vi.clearAllMocks();
});

const exec = new Exec(proxy);

test('should run the command and resolve with the result', async () => {
Expand Down Expand Up @@ -290,6 +312,124 @@ describe('exec', () => {
expect(stdout).toBeDefined();
expect(stdout).toContain('Hello, World!');
});

test('should run the command with privileges on macOS', async () => {
const command = 'echo';
const args = ['Hello, World!'];

(util.isMac as Mock).mockReturnValue(true);

vi.mock('child_process', () => {
return {
spawn: vi.fn(),
};
});

const on: any = vi.fn().mockImplementationOnce((event: string, cb: (arg0: string) => string) => {
if (event === 'data') {
cb('Hello, World!');
}
}) as unknown as Readable;
const spawnMock = vi.mocked(spawn).mockReturnValue({
stdout: { on, setEncoding: vi.fn() },
stderr: { on, setEncoding: vi.fn() },
on: vi.fn().mockImplementation((event: string, cb: (arg0: number) => void) => {
if (event === 'exit') {
cb(0);
}
}),
} as any);

const { stdout } = await exec.exec(command, args, { isAdmin: true });

// caller should contains the cwd provided
expect(spawnMock).toHaveBeenCalledWith(
'osascript',
expect.arrayContaining([
'-e',
'do shell script "echo Hello, World!" with prompt "Podman Desktop requires admin privileges " with administrator privileges',
]),
expect.anything(),
);
expect(stdout).toBeDefined();
expect(stdout).toContain('Hello, World!');
});

test('should run the command with privileges on Linux', async () => {
const command = 'echo';
const args = ['Hello, World!'];

(util.isLinux as Mock).mockReturnValue(true);

vi.mock('child_process', () => {
return {
spawn: vi.fn(),
};
});

const on: any = vi.fn().mockImplementationOnce((event: string, cb: (arg0: string) => string) => {
if (event === 'data') {
cb('Hello, World!');
}
}) as unknown as Readable;
const spawnMock = vi.mocked(spawn).mockReturnValue({
stdout: { on, setEncoding: vi.fn() },
stderr: { on, setEncoding: vi.fn() },
on: vi.fn().mockImplementation((event: string, cb: (arg0: number) => void) => {
if (event === 'exit') {
cb(0);
}
}),
} as any);

const { stdout } = await exec.exec(command, args, { isAdmin: true });

// caller should contains the cwd provided
expect(spawnMock).toHaveBeenCalledWith(
'pkexec',
expect.arrayContaining(['echo', 'Hello, World!']),
expect.anything(),
);
expect(stdout).toBeDefined();
expect(stdout).toContain('Hello, World!');
});

test('should run the command with privileges on Windows', async () => {
const command = 'echo';
const args = ['Hello, World!'];
(util.isWindows as Mock).mockReturnValue(true);

(sudo.exec as Mock).mockImplementation((_command, _options, callback) => {
callback(undefined);
});

vi.mock('child_process', () => {
return {
spawn: vi.fn(),
};
});

const on: any = vi.fn().mockImplementationOnce((event: string, cb: (arg0: string) => string) => {
if (event === 'data') {
cb('Hello, World!');
}
}) as unknown as Readable;
const spawnMock = vi.mocked(spawn).mockReturnValue({
stdout: { on, setEncoding: vi.fn() },
stderr: { on, setEncoding: vi.fn() },
on: vi.fn().mockImplementation((event: string, cb: (arg0: number) => void) => {
if (event === 'exit') {
cb(0);
}
}),
} as any);

await exec.exec(command, args, { isAdmin: true });

// caller should not have called spawn but the sudo.exec api
expect(spawnMock).not.toHaveBeenCalled();
expect(sudo.exec).toBeCalledWith('echo Hello, World!', expect.anything(), expect.anything());
});
});

vi.mock('./util', () => {
Expand Down
60 changes: 59 additions & 1 deletion packages/main/src/plugin/util/exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@
import type { ChildProcessWithoutNullStreams } from 'child_process';
import { spawn } from 'child_process';
import type { RunError, RunOptions, RunResult } from '@podman-desktop/api';
import { isMac, isWindows } from '../../util.js';
import { isLinux, isMac, isWindows } from '../../util.js';
import type { Proxy } from '../proxy.js';
import * as sudo from 'sudo-prompt';

export const macosExtraPath = '/usr/local/bin:/opt/homebrew/bin:/opt/local/bin:/opt/podman/bin';

Expand Down Expand Up @@ -53,6 +54,63 @@ export class Exec {
command = 'flatpak-spawn';
}

// do we have an admin task ?
// if yes, will use sudo-prompt on windows and osascript on mac and pkexec on linux
if (options?.isAdmin) {
if (isWindows()) {
return new Promise<RunResult>((resolve, reject) => {
// Convert the command array to a string for sudo prompt
// the name is used for the prompt

// convert process.env to { [key: string]: string; }'
const sudoEnv = env as { [key: string]: string };
const sudoOptions = {
name: 'Admin usage',
env: sudoEnv,
};
const sudoCommand = `${command} ${args || [].join(' ')}`;

const callback = (error?: Error, stdout?: string | Buffer, stderr?: string | Buffer) => {
if (error) {
// need to return a RunError
const errResult: RunError = {
name: error.name,
message: `Failed to execute command: ${error.message}`,
exitCode: 1,
command: sudoCommand,
stdout: stdout?.toString() || '',
stderr: stderr?.toString() || '',
cancelled: false,
killed: false,
};

reject(errResult);
}
const result: RunResult = {
command,
stdout: stdout?.toString() || '',
stderr: stderr?.toString() || '',
};
// in case of success
resolve(result);
};

sudo.exec(sudoCommand, sudoOptions, callback);
});
} else if (isMac()) {
args = [
'-e',
`do shell script "${command} ${(args || []).join(
' ',
)}" with prompt "Podman Desktop requires admin privileges " with administrator privileges`,
];
command = 'osascript';
} else if (isLinux()) {
args = [command, ...(args || [])];
command = 'pkexec';
}
}

let cwd: string;
if (options?.cwd) {
cwd = options.cwd;
Expand Down

0 comments on commit 957c6c5

Please sign in to comment.