Skip to content

Commit

Permalink
Merge pull request #4619 from mook-as/credentials-extract
Browse files Browse the repository at this point in the history
CredentialServer: extract code dealing with credential helpers
  • Loading branch information
ericpromislow committed May 9, 2023
2 parents 9d099ce + 5e0b410 commit 9b2ad4a
Show file tree
Hide file tree
Showing 6 changed files with 326 additions and 142 deletions.
3 changes: 3 additions & 0 deletions package.json
Expand Up @@ -186,6 +186,9 @@
"^@pkg/(.*)$": "<rootDir>/pkg/rancher-desktop/$1"
},
"preset": "ts-jest/presets/js-with-babel",
"setupFiles": [
"<rootDir>/pkg/rancher-desktop/utils/testUtils/setupElectron.ts"
],
"testEnvironment": "jsdom"
},
"browserslist": [
Expand Down
@@ -0,0 +1,165 @@
/** @jest-environment node */

import fs from 'fs';
import path from 'path';
import stream from 'stream';

import { findHomeDir } from '@kubernetes/client-node';

import runCommand, { list } from '@pkg/main/credentialServer/credentialUtils';
import { spawnFile } from '@pkg/utils/childProcess';
import paths from '@pkg/utils/paths';

jest.mock('@pkg/utils/childProcess');

describe('runCommand', () => {
afterEach(() => {
jest.restoreAllMocks();
jest.resetAllMocks();
});

it('runs the command', async() => {
const expected = `Some output`;

jest.spyOn(fs.promises, 'readFile').mockImplementation((filepath) => {
const home = findHomeDir() ?? '';

expect(filepath).toEqual(path.join(home, '.docker', 'config.json'));

return Promise.resolve(JSON.stringify({ credsStore: 'pikachu' }));
});
jest.mocked(spawnFile).mockImplementation((command, args, options) => {
const resourcesPath = path.join(paths.resources, process.platform, 'bin');

expect(command).toEqual('docker-credential-pikachu');
expect(args).toEqual(['pika']);
expect(options).toMatchObject({
env: { PATH: expect.stringContaining(resourcesPath) },
stdio: [expect.anything(), 'pipe', expect.anything()],
});

return Promise.resolve({ stdout: expected }) as any;
});
await expect(runCommand('pika')).resolves.toEqual(expected);
});

it('errors out on failing to read config', async() => {
const error = new Error('Some error');

jest.spyOn(fs.promises, 'readFile').mockImplementation((filepath) => {
const home = findHomeDir() ?? '';

expect(filepath).toEqual(path.join(home, '.docker', 'config.json'));

return Promise.reject(error);
});

jest.mocked(spawnFile).mockImplementation(() => Promise.resolve({}));

await expect(runCommand('pika')).rejects.toBe(error);
expect(jest.mocked(spawnFile)).not.toHaveBeenCalled();
});

// Check managing credentials, for the case where there's a per-host override
// in the `credHelpers` key, as well as the case where there is no such
// override.
describe.each([
{
description: 'overridden', host: 'override.test', executable: 'bulbasaur',
},
{
description: 'not overridden', host: 'default.test', executable: 'pikachu',
},
])('helper $description', ({ host, executable }) => {
beforeEach(() => {
jest.spyOn(fs.promises, 'readFile').mockImplementation((filepath) => {
const home = findHomeDir() ?? '';

expect(filepath).toEqual(path.join(home, '.docker', 'config.json'));

return Promise.resolve(JSON.stringify({
credsStore: 'pikachu',
credHelpers: { 'override.test': 'bulbasaur' },
}));
});
});

// Check each action, `get`, `erase`, `store`, and an unknown action.
// We need per-command checks here as our logic varies per command.
test.each([
{ command: 'get', input: host },
{ command: 'erase', input: host },
{ command: 'store', input: JSON.stringify({ ServerURL: host, arg: 'x' }) },
{
command: 'unknown command', input: host, override: 'pikachu',
},
])('on $command', async({ command, input, override }) => {
const expected = 'password';

jest.mocked(spawnFile).mockImplementation((file, args, options) => {
expect(file).toEqual(`docker-credential-${ override ?? executable }`);
expect(args).toEqual([command]);
expect(options).toMatchObject({ stdio: [expect.any(stream.Readable), expect.anything(), expect.anything()] });

return Promise.resolve({ stdout: expected }) as any;
});

await expect(runCommand(command, input)).resolves.toEqual(expected);
});
});
});

describe('list', () => {
let config: { credsStore: string, credHelpers?: Record<string, string>} = { credsStore: 'unset' };
let helpers: Record<string, any> = {};

beforeEach(() => {
jest.spyOn(fs.promises, 'readFile').mockImplementation((filepath) => {
const home = findHomeDir() ?? '';

expect(filepath).toEqual(path.join(home, '.docker', 'config.json'));

return Promise.resolve(JSON.stringify(config));
});
jest.mocked(spawnFile).mockImplementation((file, args) => {
const helper = file.replace(/^docker-credential-/, '');

expect(file).toMatch(/^docker-credential-/);
expect(args).toEqual(['list']);

return Promise.resolve({ stdout: JSON.stringify(helpers[helper] ?? {}) }) as any;
});
});

it('uses the default helper', async() => {
config = { credsStore: 'pikachu' };
helpers = { pikachu: { 'host.test': 'stuff' } };
await expect(list()).resolves.toEqual({ 'host.test': 'stuff' });
});

it('runs additional helpers', async() => {
config = { credsStore: 'pikachu', credHelpers: { 'example.test': 'bulbasaur' } };
helpers = {
pikachu: { 'host.test': 'stuff' },
bulbasaur: { 'example.test': 'moar stuff' },
};
await expect(list()).resolves.toEqual({
'host.test': 'stuff',
'example.test': 'moar stuff',
});
});

it('only returns matching results', async() => {
config = { credsStore: 'pikachu', credHelpers: { 'example.test': 'bulbasaur' } };
helpers = {
pikachu: { 'host.test': 'stuff' },
bulbasaur: {
'example.test': 'moar stuff', 'host.test': 'ignored', 'extra.test': 'also ignored',
},
};
await expect(list()).resolves.toEqual({
'host.test': 'stuff',
'example.test': 'moar stuff',
});
});
});
130 changes: 130 additions & 0 deletions pkg/rancher-desktop/main/credentialServer/credentialUtils.ts
@@ -0,0 +1,130 @@
import fs from 'fs';
import path from 'path';
import stream from 'stream';

import { findHomeDir } from '@kubernetes/client-node';

import { spawnFile } from '@pkg/utils/childProcess';
import Logging from '@pkg/utils/logging';
import paths from '@pkg/utils/paths';

type credHelperInfo = {
/** The name of the credential helper to use (a suffix of `docker-credential-`) */
credsStore: string;
/** hash of URLs to credential-helper-name */
credHelpers: Record<string, string>
};

const console = Logging.server;

/**
* Run the credential helper with the given command.
* @param command The one-word command to run.
* @param input Any input to be provided to the command (as standard input).
*/
export default async function runCommand(command: string, input?: string): Promise<string> {
if (command === 'list') {
// List requires special treatment.
return JSON.stringify(await list());
}

const { credsStore } = await getCredentialHelperInfo(command, input ?? '');

try {
return runCredHelper(credsStore, command, input);
} catch (ex: any) {
ex.helper = credsStore;
throw ex;
}
}

/**
* Run the `list` command.
* This command needs special treatment as we need information from multiple
* cred helpers, based on the settings found in the `credHelpers` section of
* the configuration.
*
* Modeled after https://github.com/docker/cli/blob/d0bd373986b6678bfe1a0eb6989ce13907247a85/cli/config/configfile/file.go#L285
*/
export async function list(): Promise<Record<string, string>> {
// Return the creds list from the default helper, plus any data from
// additional credential helpers as listed in the `credHelpers` section.
const { credsStore, credHelpers } = await getCredentialHelperInfo('list', '');
const results = JSON.parse(await runCredHelper(credsStore, 'list'));
const helperNames = new Set(Object.values(credHelpers ?? {}));

for (const helperName of helperNames) {
try {
const additionalResults = JSON.parse(await runCredHelper(helperName, 'list'));

for (const [url, username] of Object.entries(additionalResults)) {
if (credHelpers[url] === helperName) {
results[url] = username;
}
}
} catch (err) {
console.debug(`Failed to get credential list for helper ${ helperName }: ${ err }`);
}
}

return results;
}

/**
* Returns the name of the credential-helper to use (which is a suffix of the helper `docker-credential-`).
*
* Note that callers are responsible for catching exceptions, which usually happens if the
* `$HOME/docker/config.json` doesn't exist, its JSON is corrupt, or it doesn't have a `credsStore` field.
*/
async function getCredentialHelperInfo(command: string, payload: string): Promise<credHelperInfo> {
const home = findHomeDir();
const dockerConfig = path.join(home ?? '', '.docker', 'config.json');
const contents = JSON.parse(await fs.promises.readFile(dockerConfig, { encoding: 'utf-8' }));
const credHelpers = contents.credHelpers;
const credsStore = contents.credsStore;

if (credHelpers) {
let credsStoreOverride = '';

switch (command) {
case 'erase':
case 'get':
credsStoreOverride = credHelpers[payload.trim()];
break;
case 'store': {
const obj = JSON.parse(payload);

credsStoreOverride = obj.ServerURL ? credHelpers[obj.ServerURL] : '';
}
}
if (credsStoreOverride) {
return { credsStore: credsStoreOverride, credHelpers: { } };
}
}

return { credsStore, credHelpers };
}

/**
* Run the credential helper, with minimal checking.
* @param helper The name of the credential helper to use (a suffix of `docker-credential-`)
* @param command The one-word command to run
* @param input Any input to the helper, to be sent as standard input.
*/
async function runCredHelper(helper: string, command: string, input?: string): Promise<string> {
// The PATH needs to contain our resources directory (on macOS that would
// not be in the application's PATH).
// NOTE: This needs to match DockerDirManager.spawnFileWithExtraPath
const pathVar = (process.env.PATH ?? '').split(path.delimiter).filter(x => x);

pathVar.push(path.join(paths.resources, process.platform, 'bin'));

const helperName = `docker-credential-${ helper }`;
const body = stream.Readable.from(input ?? '');
const { stdout } = await spawnFile(helperName, [command], {
env: { ...process.env, PATH: pathVar.join(path.delimiter) },
stdio: [body, 'pipe', console],
});

return stdout;
}

0 comments on commit 9b2ad4a

Please sign in to comment.