diff --git a/src/secrets/CommandList.ts b/src/secrets/CommandList.ts index 641d071c..561dc360 100644 --- a/src/secrets/CommandList.ts +++ b/src/secrets/CommandList.ts @@ -3,18 +3,23 @@ import CommandPolykey from '../CommandPolykey'; import * as binUtils from '../utils'; import * as binOptions from '../utils/options'; import * as binProcessors from '../utils/processors'; +import * as binParsers from '../utils/parsers'; class CommandList extends CommandPolykey { constructor(...args: ConstructorParameters) { super(...args); - this.name('list'); - this.aliases(['ls']); - this.description('List all Available Secrets for a Vault'); - this.argument('', 'Name of the vault to list secrets from'); + this.name('ls'); + this.aliases(['list']); + this.description('List all secrets for a vault within a directory'); + this.argument( + '', + 'Directory to list files from, specified as [:]', + binParsers.parseSecretName, + ); this.addOption(binOptions.nodeId); this.addOption(binOptions.clientHost); this.addOption(binOptions.clientPort); - this.action(async (vaultName, options) => { + this.action(async (vaultPattern, options) => { const { default: PolykeyClient } = await import( 'polykey/dist/PolykeyClient' ); @@ -40,39 +45,38 @@ class CommandList extends CommandPolykey { nodeId: clientOptions.nodeId, host: clientOptions.clientHost, port: clientOptions.clientPort, - options: { - nodePath: options.nodePath, - }, + options: { nodePath: options.nodePath }, logger: this.logger.getChild(PolykeyClient.name), }); - const data = await binUtils.retryAuthentication(async (auth) => { - const data: Array<{ secretName: string }> = []; + const secretPaths = await binUtils.retryAuthentication(async (auth) => { + const secretPaths: Array = []; const stream = await pkClient.rpcClient.methods.vaultsSecretsList({ metadata: auth, - nameOrId: vaultName, + nameOrId: vaultPattern[0], + secretName: vaultPattern[1] ?? '/', }); for await (const secret of stream) { - data.push({ - secretName: secret.secretName, - }); + // Remove leading slashes + if (secret.path.startsWith('/')) { + secret.path = secret.path.substring(1); + } + secretPaths.push(secret.path); } - return data; + return secretPaths; }, auth); if (options.format === 'json') { process.stdout.write( binUtils.outputFormatter({ type: 'json', - data: data, + data: secretPaths, }), ); } else { process.stdout.write( binUtils.outputFormatter({ type: 'list', - data: data.map( - (secretsListMessage) => secretsListMessage.secretName, - ), + data: secretPaths, }), ); } diff --git a/src/secrets/CommandSecrets.ts b/src/secrets/CommandSecrets.ts index fe24384a..183fbf5f 100644 --- a/src/secrets/CommandSecrets.ts +++ b/src/secrets/CommandSecrets.ts @@ -8,7 +8,7 @@ import CommandList from './CommandList'; import CommandMkdir from './CommandMkdir'; import CommandRename from './CommandRename'; import CommandUpdate from './CommandUpdate'; -import commandStat from './CommandStat'; +import CommandStat from './CommandStat'; import CommandPolykey from '../CommandPolykey'; class CommandSecrets extends CommandPolykey { @@ -26,7 +26,7 @@ class CommandSecrets extends CommandPolykey { this.addCommand(new CommandMkdir(...args)); this.addCommand(new CommandRename(...args)); this.addCommand(new CommandUpdate(...args)); - this.addCommand(new commandStat(...args)); + this.addCommand(new CommandStat(...args)); } } diff --git a/src/utils/parsers.ts b/src/utils/parsers.ts index 376499d1..719affa2 100644 --- a/src/utils/parsers.ts +++ b/src/utils/parsers.ts @@ -8,7 +8,8 @@ import * as gestaltsUtils from 'polykey/dist/gestalts/utils'; import * as networkUtils from 'polykey/dist/network/utils'; import * as nodesUtils from 'polykey/dist/nodes/utils'; -const secretPathRegex = /^([\w-]+)(?::)([^\0\\=]+)$/; +const vaultNameRegex = /^[\w.-]+$/; +const secretPathNameRegex = /^([\w-]+)(?::([^\0\\=]+))?$/; const secretPathValueRegex = /^([a-zA-Z_][\w]+)?$/; const environmentVariableRegex = /^([a-zA-Z_]+[a-zA-Z0-9_]*)?$/; @@ -65,27 +66,47 @@ function parseCoreCount(v: string): number | undefined { } } +function parseVaultName(vaultName: string): string { + // E.g. If 'vault1, 'vault1' is returned + // If 'vault1:a/b/c', an error is thrown + if (!vaultNameRegex.test(vaultName)) { + throw new commander.InvalidArgumentError( + `${vaultName} is not of the format `, + ); + } + // Returns match[1], or the parsed vaultName + return vaultName.match(secretPathNameRegex)![1]; +} + +function parseSecretName(secretPath: string): [string, string?] { + // E.g. If 'vault1:a/b/c', ['vault1', 'a/b/c'] is returned + // If 'vault1', ['vault1, undefined] is returned + if (!secretPathNameRegex.test(secretPath)) { + throw new commander.InvalidArgumentError( + `${secretPath} is not of the format [:]`, + ); + } + // Returns [vaultName, secretName?] + const match = secretPath.match(secretPathNameRegex)!; + return [match[1], match[2] || undefined]; +} + function parseSecretPath(secretPath: string): [string, string, string?] { // E.g. If 'vault1:a/b/c', ['vault1', 'a/b/c'] is returned - // If 'vault1:a/b/c=VARIABLE', ['vault1, 'a/b/c', 'VARIABLE'] is returned - const lastEqualIndex = secretPath.lastIndexOf('='); - const splitSecretPath = - lastEqualIndex === -1 - ? secretPath - : secretPath.substring(0, lastEqualIndex); - const value = - lastEqualIndex === -1 ? '' : secretPath.substring(lastEqualIndex + 1); - if (!secretPathRegex.test(splitSecretPath)) { + // If 'vault1', an error is thrown + const [vaultName, secretName] = parseSecretName(secretPath); + if (secretName === undefined) { throw new commander.InvalidArgumentError( - `${splitSecretPath} is not of the format :`, + `${secretPath} is not of the format :`, ); } - const [, vaultName, directoryPath] = splitSecretPath.match(secretPathRegex)!; - return [vaultName, directoryPath, value]; + return [vaultName, secretName]; } function parseSecretPathValue(secretPath: string): [string, string, string?] { - const [vaultName, directoryPath, value] = parseSecretPath(secretPath); + const [vaultName, directoryPath] = parseSecretPath(secretPath); + const lastEqualIndex = secretPath.lastIndexOf('='); + const value = lastEqualIndex === -1 ? '' : secretPath.substring(lastEqualIndex + 1); if (value != null && !secretPathValueRegex.test(value)) { throw new commander.InvalidArgumentError( `${value} is not a valid value name`, @@ -198,12 +219,13 @@ function parseEnvArgs( } export { - secretPathRegex, secretPathValueRegex, environmentVariableRegex, validateParserToArgParser, validateParserToArgListParser, parseCoreCount, + parseVaultName, + parseSecretName, parseSecretPath, parseSecretPathValue, parseSecretPathEnv, diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 2e97340e..b2ac620d 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -254,7 +254,7 @@ function outputFormatterList(items: Array): string { * If it is `Record`, the `number` values will be used as the initial padding lengths. * The object is also mutated if any cells exceed the initial padding lengths. * This parameter can also be supplied to filter the columns that will be displayed. - * @param options.includeHeaders - Defaults to `True` + * @param options.includeHeaders - Defaults to `True`. * @param options.includeRowCount - Defaults to `False`. * @returns */ diff --git a/src/vaults/CommandCreate.ts b/src/vaults/CommandCreate.ts index 4624955b..163b37ac 100644 --- a/src/vaults/CommandCreate.ts +++ b/src/vaults/CommandCreate.ts @@ -4,6 +4,7 @@ import CommandPolykey from '../CommandPolykey'; import * as binUtils from '../utils'; import * as binOptions from '../utils/options'; import * as binProcessors from '../utils/processors'; +import * as binParsers from '../utils/parsers'; class CommandCreate extends CommandPolykey { constructor(...args: ConstructorParameters) { @@ -11,7 +12,11 @@ class CommandCreate extends CommandPolykey { this.name('create'); this.aliases(['touch']); this.description('Create a new Vault'); - this.argument('', 'Name of the new vault to be created'); + this.argument( + '', + 'Name of the new vault to be created', + binParsers.parseVaultName, + ); this.addOption(binOptions.nodeId); this.addOption(binOptions.clientHost); this.addOption(binOptions.clientPort); diff --git a/tests/secrets/list.test.ts b/tests/secrets/list.test.ts index 406fd008..648aed70 100644 --- a/tests/secrets/list.test.ts +++ b/tests/secrets/list.test.ts @@ -41,6 +41,21 @@ describe('commandListSecrets', () => { }); }); + test( + 'should fail when vault does not exist', + async () => { + command = ['secrets', 'ls', '-np', dataDir, 'DoesntExist']; + const result = await testUtils.pkStdio([...command], { + env: { + PK_PASSWORD: password, + }, + cwd: dataDir, + }); + expect(result.exitCode).toBe(64); // Sysexits.USAGE + }, + globalThis.defaultTimeout * 2, + ); + test( 'should list secrets', async () => { @@ -48,13 +63,12 @@ describe('commandListSecrets', () => { const vaultId = await polykeyAgent.vaultManager.createVault(vaultName); await polykeyAgent.vaultManager.withVaults([vaultId], async (vault) => { - await vaultOps.addSecret(vault, 'MySecret1', 'this is the secret 1'); - await vaultOps.addSecret(vault, 'MySecret2', 'this is the secret 2'); - await vaultOps.addSecret(vault, 'MySecret3', 'this is the secret 3'); + await vaultOps.addSecret(vault, 'MySecret1', ''); + await vaultOps.addSecret(vault, 'MySecret2', ''); + await vaultOps.addSecret(vault, 'MySecret3', ''); }); - command = ['secrets', 'list', '-np', dataDir, vaultName]; - + command = ['secrets', 'ls', '-np', dataDir, vaultName]; const result = await testUtils.pkStdio([...command], { env: { PK_PASSWORD: password, @@ -62,6 +76,88 @@ describe('commandListSecrets', () => { cwd: dataDir, }); expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('MySecret1\nMySecret2\nMySecret3\n'); + }, + globalThis.defaultTimeout * 2, + ); + + test( + 'should fail when path is not a directory', + async () => { + const vaultName = 'Vault5' as VaultName; + const vaultId = await polykeyAgent.vaultManager.createVault(vaultName); + + await polykeyAgent.vaultManager.withVaults([vaultId], async (vault) => { + await vaultOps.mkdir(vault, 'SecretDir'); + await vaultOps.addSecret(vault, 'SecretDir/MySecret1', ''); + await vaultOps.addSecret(vault, 'SecretDir/MySecret2', ''); + await vaultOps.addSecret(vault, 'SecretDir/MySecret3', ''); + }); + + command = ['secrets', 'ls', '-np', dataDir, `${vaultName}:WrongDirName`]; + let result = await testUtils.pkStdio([...command], { + env: { + PK_PASSWORD: password, + }, + cwd: dataDir, + }); + expect(result.exitCode).toBe(64); + + command = [ + 'secrets', + 'ls', + '-np', + dataDir, + `${vaultName}:SecretDir/MySecret1`, + ]; + result = await testUtils.pkStdio([...command], { + env: { + PK_PASSWORD: password, + }, + cwd: dataDir, + }); + expect(result.exitCode).toBe(64); + }, + globalThis.defaultTimeout * 2, + ); + + test( + 'should list secrets within directories', + async () => { + const vaultName = 'Vault6' as VaultName; + const vaultId = await polykeyAgent.vaultManager.createVault(vaultName); + + await polykeyAgent.vaultManager.withVaults([vaultId], async (vault) => { + await vaultOps.mkdir(vault, 'SecretDir/NestedDir', { recursive: true }); + await vaultOps.addSecret(vault, 'SecretDir/MySecret1', ''); + await vaultOps.addSecret(vault, 'SecretDir/NestedDir/MySecret2', ''); + }); + + command = ['secrets', 'ls', '-np', dataDir, `${vaultName}:SecretDir`]; + let result = await testUtils.pkStdio([...command], { + env: { + PK_PASSWORD: password, + }, + cwd: dataDir, + }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('SecretDir/MySecret1\nSecretDir/NestedDir\n'); + + command = [ + 'secrets', + 'ls', + '-np', + dataDir, + `${vaultName}:SecretDir/NestedDir`, + ]; + result = await testUtils.pkStdio([...command], { + env: { + PK_PASSWORD: password, + }, + cwd: dataDir, + }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('SecretDir/NestedDir/MySecret2\n'); }, globalThis.defaultTimeout * 2, );