Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #4619 from mook-as/credentials-extract
CredentialServer: extract code dealing with credential helpers
- Loading branch information
Showing
6 changed files
with
326 additions
and
142 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
165 changes: 165 additions & 0 deletions
165
pkg/rancher-desktop/main/credentialServer/__tests__/credentialUtils.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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
130
pkg/rancher-desktop/main/credentialServer/credentialUtils.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; | ||
} |
Oops, something went wrong.