Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(extension): allow to run admin tasks from extensions #4049

Merged
merged 1 commit into from Sep 25, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
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
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
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
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