diff --git a/npmDepsHash b/npmDepsHash index 1d79a248..a8ee26f4 100644 --- a/npmDepsHash +++ b/npmDepsHash @@ -1 +1 @@ -sha256-bKqMvZvc//Fo63MbPtkkXyLpCKNHyA0SKFy/Ts/fJLw= +sha256-Wy7kocLYL/MMhybl1I5plId1SNe6Jxwyd4hsyh0YkZ0= diff --git a/package-lock.json b/package-lock.json index 1ae01136..f1ea312d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,7 +42,7 @@ "nexpect": "^0.6.0", "node-gyp-build": "^4.4.0", "nodemon": "^3.0.1", - "polykey": "^1.11.1", + "polykey": "^1.12.0", "prettier": "^3.0.0", "shelljs": "^0.8.5", "shx": "^0.3.4", @@ -7602,9 +7602,9 @@ } }, "node_modules/polykey": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/polykey/-/polykey-1.11.1.tgz", - "integrity": "sha512-2OEvd7LG4JP/YLPh6d3JQWbIlFChBRTgUupwkKqVyJBQCQ+E38gmJYeHD3RclqI0riT0vLlE+3Kosm7K8htC7w==", + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/polykey/-/polykey-1.13.0.tgz", + "integrity": "sha512-iyRBlNfDNPlyxRUhwne5sPQRH3YeN01fQ0L5diO2nz5/hBrtgekFhptv30mht3CTIy3N5xoAJ3hTZBsDuZ6nxg==", "dev": true, "dependencies": { "@matrixai/async-cancellable": "^1.1.1", diff --git a/package.json b/package.json index 969c64a1..3b2efef1 100644 --- a/package.json +++ b/package.json @@ -150,7 +150,7 @@ "nexpect": "^0.6.0", "node-gyp-build": "^4.4.0", "nodemon": "^3.0.1", - "polykey": "^1.11.1", + "polykey": "^1.12.0", "prettier": "^3.0.0", "shelljs": "^0.8.5", "shx": "^0.3.4", diff --git a/src/secrets/CommandCat.ts b/src/secrets/CommandCat.ts new file mode 100644 index 00000000..96b65b7a --- /dev/null +++ b/src/secrets/CommandCat.ts @@ -0,0 +1,109 @@ +import type PolykeyClient from 'polykey/dist/PolykeyClient'; +import CommandPolykey from '../CommandPolykey'; +import * as binUtils from '../utils'; +import * as binOptions from '../utils/options'; +import * as binParsers from '../utils/parsers'; +import * as binProcessors from '../utils/processors'; + +class CommandGet extends CommandPolykey { + constructor(...args: ConstructorParameters) { + super(...args); + this.name('cat'); + this.description( + 'Concatenates secrets and prints them on the standard output', + ); + this.argument( + '[secretPaths...]', + 'Path to a secret to be retrieved, specified as :', + ); + this.addOption(binOptions.nodeId); + this.addOption(binOptions.clientHost); + this.addOption(binOptions.clientPort); + this.action(async (secretPaths, options) => { + secretPaths = secretPaths.map((path: string) => + binParsers.parseSecretPathValue(path), + ); + const { default: PolykeyClient } = await import( + 'polykey/dist/PolykeyClient' + ); + const clientOptions = await binProcessors.processClientOptions( + options.nodePath, + options.nodeId, + options.clientHost, + options.clientPort, + this.fs, + this.logger.getChild(binProcessors.processClientOptions.name), + ); + const meta = await binProcessors.processAuthentication( + options.passwordFile, + this.fs, + ); + + let pkClient: PolykeyClient; + this.exitHandlers.handlers.push(async () => { + if (pkClient != null) await pkClient.stop(); + }); + try { + pkClient = await PolykeyClient.createPolykeyClient({ + nodeId: clientOptions.nodeId, + host: clientOptions.clientHost, + port: clientOptions.clientPort, + options: { + nodePath: options.nodePath, + }, + logger: this.logger.getChild(PolykeyClient.name), + }); + if (secretPaths.length === 0) { + await new Promise((resolve, reject) => { + const cleanup = () => { + process.stdin.removeListener('data', dataHandler); + process.stdin.removeListener('error', errorHandler); + process.stdin.removeListener('end', endHandler); + }; + const dataHandler = (data: Buffer) => { + process.stdout.write(data); + }; + const errorHandler = (err: Error) => { + cleanup(); + reject(err); + }; + const endHandler = () => { + cleanup(); + resolve(); + }; + process.stdin.on('data', dataHandler); + process.stdin.once('error', errorHandler); + process.stdin.once('end', endHandler); + }); + return; + } + await binUtils.retryAuthentication(async (auth) => { + const response = await pkClient.rpcClient.methods.vaultsSecretsGet(); + await (async () => { + const writer = response.writable.getWriter(); + let first = true; + for (const [vaultName, secretPath] of secretPaths) { + await writer.write({ + nameOrId: vaultName, + secretName: secretPath, + metadata: first + ? { ...auth, options: { continueOnError: true } } + : undefined, + }); + first = false; + } + await writer.close(); + })(); + for await (const chunk of response.readable) { + if (chunk.error) process.stderr.write(chunk.error); + else process.stdout.write(chunk.secretContent); + } + }, meta); + } finally { + if (pkClient! != null) await pkClient.stop(); + } + }); + } +} + +export default CommandGet; diff --git a/src/secrets/CommandEdit.ts b/src/secrets/CommandEdit.ts index 8705d14e..2371df51 100644 --- a/src/secrets/CommandEdit.ts +++ b/src/secrets/CommandEdit.ts @@ -63,14 +63,24 @@ class CommandEdit extends CommandPolykey { const secretExists = await binUtils.retryAuthentication( async (auth) => { let exists: boolean = true; + const response = + await pkClient.rpcClient.methods.vaultsSecretsGet(); + await (async () => { + const writer = response.writable.getWriter(); + await writer.write({ + nameOrId: secretPath[0], + secretName: secretPath[1], + metadata: auth, + }); + await writer.close(); + })(); try { - const response = - await pkClient.rpcClient.methods.vaultsSecretsGet({ - metadata: auth, - nameOrId: secretPath[0], - secretName: secretPath[1], - }); - await this.fs.promises.writeFile(tmpFile, response.secretContent); + let rawSecretContent: string = ''; + for await (const chunk of response.readable) { + rawSecretContent += chunk.secretContent; + } + const secretContent = Buffer.from(rawSecretContent, 'binary'); + await this.fs.promises.writeFile(tmpFile, secretContent); } catch (e) { const [cause, _] = binUtils.remoteErrorCause(e); if (cause instanceof vaultsErrors.ErrorSecretsSecretUndefined) { diff --git a/src/secrets/CommandGet.ts b/src/secrets/CommandGet.ts deleted file mode 100644 index 69493135..00000000 --- a/src/secrets/CommandGet.ts +++ /dev/null @@ -1,86 +0,0 @@ -import type PolykeyClient from 'polykey/dist/PolykeyClient'; -import CommandPolykey from '../CommandPolykey'; -import * as binUtils from '../utils'; -import * as binOptions from '../utils/options'; -import * as binParsers from '../utils/parsers'; -import * as binProcessors from '../utils/processors'; - -class CommandGet extends CommandPolykey { - constructor(...args: ConstructorParameters) { - super(...args); - this.name('get'); - this.description('Retrieve a Secret from the Given Vault'); - this.argument( - '', - 'Path to where the secret to be retrieved, specified as :', - binParsers.parseSecretPathValue, - ); - this.addOption(binOptions.nodeId); - this.addOption(binOptions.clientHost); - this.addOption(binOptions.clientPort); - this.action(async (secretPath, options) => { - const { default: PolykeyClient } = await import( - 'polykey/dist/PolykeyClient' - ); - const clientOptions = await binProcessors.processClientOptions( - options.nodePath, - options.nodeId, - options.clientHost, - options.clientPort, - this.fs, - this.logger.getChild(binProcessors.processClientOptions.name), - ); - const meta = await binProcessors.processAuthentication( - options.passwordFile, - this.fs, - ); - - let pkClient: PolykeyClient; - this.exitHandlers.handlers.push(async () => { - if (pkClient != null) await pkClient.stop(); - }); - try { - pkClient = await PolykeyClient.createPolykeyClient({ - nodeId: clientOptions.nodeId, - host: clientOptions.clientHost, - port: clientOptions.clientPort, - options: { - nodePath: options.nodePath, - }, - logger: this.logger.getChild(PolykeyClient.name), - }); - const response = await binUtils.retryAuthentication( - (auth) => - pkClient.rpcClient.methods.vaultsSecretsGet({ - metadata: auth, - nameOrId: secretPath[0], - secretName: secretPath[1], - }), - meta, - ); - const secretContent = Buffer.from(response.secretContent, 'binary'); - if (options.format === 'json') { - process.stdout.write( - binUtils.outputFormatter({ - type: 'json', - data: { - secretContent: secretContent.toString(), - }, - }), - ); - } else { - process.stdout.write( - binUtils.outputFormatter({ - type: 'raw', - data: secretContent, - }), - ); - } - } finally { - if (pkClient! != null) await pkClient.stop(); - } - }); - } -} - -export default CommandGet; diff --git a/src/secrets/CommandRemove.ts b/src/secrets/CommandRemove.ts index 2768b9d5..11e8616b 100644 --- a/src/secrets/CommandRemove.ts +++ b/src/secrets/CommandRemove.ts @@ -49,13 +49,28 @@ class CommandDelete extends CommandPolykey { options: { nodePath: options.nodePath }, logger: this.logger.getChild(PolykeyClient.name), }); - await binUtils.retryAuthentication(async (auth) => { - await pkClient.rpcClient.methods.vaultsSecretsRemove({ - metadata: auth, - secretNames: secretPaths, - options: { recursive: options.recursive }, - }); + const response = await binUtils.retryAuthentication(async (auth) => { + const response = + await pkClient.rpcClient.methods.vaultsSecretsRemove(); + await (async () => { + const writer = response.writable.getWriter(); + let first = true; + for (const [vault, path] of secretPaths) { + await writer.write({ + nameOrId: vault, + secretName: path, + metadata: first + ? { ...auth, options: { recursive: options.recursive } } + : undefined, + }); + first = false; + } + await writer.close(); + })(); + return response; }, meta); + // Wait for the program to generate a response (complete processing). + await response.output; } finally { if (pkClient! != null) await pkClient.stop(); } diff --git a/src/secrets/CommandSecrets.ts b/src/secrets/CommandSecrets.ts index 7925ceb5..0b9644ae 100644 --- a/src/secrets/CommandSecrets.ts +++ b/src/secrets/CommandSecrets.ts @@ -1,8 +1,8 @@ import CommandCreate from './CommandCreate'; +import CommandCat from './CommandCat'; import CommandDir from './CommandDir'; import CommandEdit from './CommandEdit'; import CommandEnv from './CommandEnv'; -import CommandGet from './CommandGet'; import CommandList from './CommandList'; import CommandMkdir from './CommandMkdir'; import CommandRename from './CommandRename'; @@ -17,10 +17,10 @@ class CommandSecrets extends CommandPolykey { this.name('secrets'); this.description('Secrets Operations'); this.addCommand(new CommandCreate(...args)); + this.addCommand(new CommandCat(...args)); this.addCommand(new CommandDir(...args)); this.addCommand(new CommandEdit(...args)); this.addCommand(new CommandEnv(...args)); - this.addCommand(new CommandGet(...args)); this.addCommand(new CommandList(...args)); this.addCommand(new CommandMkdir(...args)); this.addCommand(new CommandRename(...args)); diff --git a/tests/secrets/cat.test.ts b/tests/secrets/cat.test.ts new file mode 100644 index 00000000..77c1cf54 --- /dev/null +++ b/tests/secrets/cat.test.ts @@ -0,0 +1,175 @@ +import type { VaultName } from 'polykey/dist/vaults/types'; +import path from 'path'; +import fs from 'fs'; +import Logger, { LogLevel, StreamHandler } from '@matrixai/logger'; +import PolykeyAgent from 'polykey/dist/PolykeyAgent'; +import { vaultOps } from 'polykey/dist/vaults'; +import * as keysUtils from 'polykey/dist/keys/utils'; +import * as testUtils from '../utils'; + +describe('commandCatSecret', () => { + const password = 'password'; + const logger = new Logger('CLI Test', LogLevel.WARN, [new StreamHandler()]); + let dataDir: string; + let polykeyAgent: PolykeyAgent; + + beforeEach(async () => { + dataDir = await fs.promises.mkdtemp( + path.join(globalThis.tmpDir, 'polykey-test-'), + ); + polykeyAgent = await PolykeyAgent.createPolykeyAgent({ + password, + options: { + nodePath: dataDir, + agentServiceHost: '127.0.0.1', + clientServiceHost: '127.0.0.1', + keys: { + passwordOpsLimit: keysUtils.passwordOpsLimits.min, + passwordMemLimit: keysUtils.passwordMemLimits.min, + strictMemoryLock: false, + }, + }, + logger: logger, + }); + }); + afterEach(async () => { + await polykeyAgent.stop(); + await fs.promises.rm(dataDir, { + force: true, + recursive: true, + }); + }); + test('should retrieve a secret', async () => { + const vaultName = 'Vault3' as VaultName; + const vaultId = await polykeyAgent.vaultManager.createVault(vaultName); + const secretName = 'secret-name'; + const secretContent = 'this is the contents of the secret'; + await polykeyAgent.vaultManager.withVaults([vaultId], async (vault) => { + await vaultOps.addSecret(vault, secretName, secretContent); + }); + const command = [ + 'secrets', + 'cat', + '-np', + dataDir, + `${vaultName}:${secretName}`, + ]; + const result = await testUtils.pkStdio(command, { + env: { PK_PASSWORD: password }, + cwd: dataDir, + }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe(secretContent); + }); + test('should concatenate multiple secrets', async () => { + const vaultName = 'Vault3' as VaultName; + const vaultId = await polykeyAgent.vaultManager.createVault(vaultName); + const secretName1 = 'secret-name1'; + const secretName2 = 'secret-name2'; + await polykeyAgent.vaultManager.withVaults([vaultId], async (vault) => { + await vaultOps.addSecret(vault, secretName1, secretName1); + await vaultOps.addSecret(vault, secretName2, secretName2); + }); + const command = [ + 'secrets', + 'cat', + '-np', + dataDir, + `${vaultName}:${secretName1}`, + `${vaultName}:${secretName2}`, + ]; + const result = await testUtils.pkStdio(command, { + env: { PK_PASSWORD: password }, + cwd: dataDir, + }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe(`${secretName1}${secretName2}`); + }); + test('should concatenate secrets from multiple vaults', async () => { + const vaultName1 = 'Vault3-1'; + const vaultName2 = 'Vault3-2'; + const vaultId1 = await polykeyAgent.vaultManager.createVault(vaultName1); + const vaultId2 = await polykeyAgent.vaultManager.createVault(vaultName2); + const secretName1 = 'secret-name1'; + const secretName2 = 'secret-name2'; + const secretName3 = 'secret-name3'; + await polykeyAgent.vaultManager.withVaults( + [vaultId1, vaultId2], + async (vault1, vault2) => { + await vaultOps.addSecret(vault1, secretName1, secretName1); + await vaultOps.addSecret(vault2, secretName2, secretName2); + await vaultOps.addSecret(vault1, secretName3, secretName3); + }, + ); + const command = [ + 'secrets', + 'cat', + '-np', + dataDir, + `${vaultName1}:${secretName1}`, + `${vaultName2}:${secretName2}`, + `${vaultName1}:${secretName3}`, + ]; + const result = await testUtils.pkStdio(command, { + env: { PK_PASSWORD: password }, + cwd: dataDir, + }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe(`${secretName1}${secretName2}${secretName3}`); + }); + test('should ignore missing files when concatenating multiple files', async () => { + const vaultName = 'Vault3'; + const vaultId = await polykeyAgent.vaultManager.createVault(vaultName); + const secretName1 = 'secret-name1'; + const secretName2 = 'secret-name2'; + await polykeyAgent.vaultManager.withVaults([vaultId], async (vault) => { + await vaultOps.addSecret(vault, secretName1, secretName1); + await vaultOps.addSecret(vault, secretName2, secretName2); + }); + const command = [ + 'secrets', + 'cat', + '-np', + dataDir, + `${vaultName}:${secretName1}`, + `${vaultName}:doesnt-exist-file`, + `${vaultName}:${secretName2}`, + ]; + const result = await testUtils.pkStdio(command, { + env: { PK_PASSWORD: password }, + cwd: dataDir, + }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe(`${secretName1}${secretName2}`); + expect(result.stderr).not.toBe(''); + }); + test('should return stdin if no arguments are passed', async () => { + const command = ['secrets', 'cat', '-np', dataDir]; + const stdinData = 'this will go to stdin and come out of stdout'; + const childProcess = await testUtils.pkSpawn( + command, + { + env: { PK_PASSWORD: password }, + cwd: dataDir, + }, + logger, + ); + let stdout: string = ''; + // The conditions of stdin/stdout being null will not be met in the test, + // so we don't have to worry about the fields being null. + childProcess.stdin!.write(stdinData); + childProcess.stdin!.end(); + childProcess.stdout!.on('data', (data) => { + stdout += data.toString(); + }); + const exitCode = await new Promise((resolve) => { + childProcess.once('exit', (code) => { + const exitCode = code ?? -255; + childProcess.removeAllListeners('data'); + resolve(exitCode); + }); + }); + expect(exitCode).toStrictEqual(0); + expect(stdout).toBe(stdinData); + }); +}); diff --git a/tests/secrets/get.test.ts b/tests/secrets/get.test.ts deleted file mode 100644 index ca2171fa..00000000 --- a/tests/secrets/get.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { VaultName } from 'polykey/dist/vaults/types'; -import path from 'path'; -import fs from 'fs'; -import Logger, { LogLevel, StreamHandler } from '@matrixai/logger'; -import PolykeyAgent from 'polykey/dist/PolykeyAgent'; -import { vaultOps } from 'polykey/dist/vaults'; -import * as keysUtils from 'polykey/dist/keys/utils'; -import * as testUtils from '../utils'; - -describe('commandGetSecret', () => { - const password = 'password'; - const logger = new Logger('CLI Test', LogLevel.WARN, [new StreamHandler()]); - let dataDir: string; - let polykeyAgent: PolykeyAgent; - let command: Array; - - beforeEach(async () => { - dataDir = await fs.promises.mkdtemp( - path.join(globalThis.tmpDir, 'polykey-test-'), - ); - polykeyAgent = await PolykeyAgent.createPolykeyAgent({ - password, - options: { - nodePath: dataDir, - agentServiceHost: '127.0.0.1', - clientServiceHost: '127.0.0.1', - keys: { - passwordOpsLimit: keysUtils.passwordOpsLimits.min, - passwordMemLimit: keysUtils.passwordMemLimits.min, - strictMemoryLock: false, - }, - }, - logger: logger, - }); - }); - afterEach(async () => { - await polykeyAgent.stop(); - await fs.promises.rm(dataDir, { - force: true, - recursive: true, - }); - }); - - test('should retrieve secrets', async () => { - const vaultName = 'Vault3' as VaultName; - const vaultId = await polykeyAgent.vaultManager.createVault(vaultName); - - await polykeyAgent.vaultManager.withVaults([vaultId], async (vault) => { - await vaultOps.addSecret(vault, 'MySecret', 'this is the secret'); - }); - - command = ['secrets', 'get', '-np', dataDir, `${vaultName}:MySecret`]; - - const result = await testUtils.pkStdio([...command], { - env: { PK_PASSWORD: password }, - cwd: dataDir, - }); - expect(result.stdout).toBe('this is the secret'); - expect(result.exitCode).toBe(0); - }); -});