diff --git a/npmDepsHash b/npmDepsHash index c11566ea..9a90d8de 100644 --- a/npmDepsHash +++ b/npmDepsHash @@ -1 +1 @@ -sha256-lbtIQDruaGlF/lyCqRQLatL6n26VzaF0ezJXn8RgUGg= +sha256-8rBOwTKsBhskBLbHJJrIwwtiW6FRXD8sOVvaGSW8I48= diff --git a/package-lock.json b/package-lock.json index 8b523a02..2a40b2e4 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.13.0", + "polykey": "^1.14.0", "prettier": "^3.0.0", "shelljs": "^0.8.5", "shx": "^0.3.4", @@ -7602,9 +7602,9 @@ } }, "node_modules/polykey": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/polykey/-/polykey-1.13.0.tgz", - "integrity": "sha512-iyRBlNfDNPlyxRUhwne5sPQRH3YeN01fQ0L5diO2nz5/hBrtgekFhptv30mht3CTIy3N5xoAJ3hTZBsDuZ6nxg==", + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/polykey/-/polykey-1.14.0.tgz", + "integrity": "sha512-XHQ2h5VdcXhLpq6qL9FwHAP1k5WfezAipTRQmkLICujfkXz9wDTgu8Mn+Ubr4FfOnu267VQxsygqevHx+eJGBA==", "dev": true, "dependencies": { "@matrixai/async-cancellable": "^1.1.1", diff --git a/package.json b/package.json index cb98a7bf..4e34e698 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,9 @@ }, { "name": "Aditya Varma" + }, + { + "name": "Aryan Jassal" } ], "description": "Polykey CLI", @@ -150,7 +153,7 @@ "nexpect": "^0.6.0", "node-gyp-build": "^4.4.0", "nodemon": "^3.0.1", - "polykey": "^1.13.0", + "polykey": "^1.14.0", "prettier": "^3.0.0", "shelljs": "^0.8.5", "shx": "^0.3.4", diff --git a/src/secrets/CommandEdit.ts b/src/secrets/CommandEdit.ts index 2371df51..7a615d1e 100644 --- a/src/secrets/CommandEdit.ts +++ b/src/secrets/CommandEdit.ts @@ -12,6 +12,7 @@ class CommandEdit extends CommandPolykey { constructor(...args: ConstructorParameters) { super(...args); this.name('edit'); + this.alias('ed'); this.description('Edit a Secret'); this.argument( '', @@ -62,21 +63,18 @@ class CommandEdit extends CommandPolykey { const tmpFile = path.join(tmpDir, path.basename(secretPath[1])); 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(); - })(); + let exists = true; + const res = await pkClient.rpcClient.methods.vaultsSecretsGet(); + const writer = res.writable.getWriter(); + await writer.write({ + nameOrId: secretPath[0], + secretName: secretPath[1], + metadata: auth, + }); + await writer.close(); try { let rawSecretContent: string = ''; - for await (const chunk of response.readable) { + for await (const chunk of res.readable) { rawSecretContent += chunk.secretContent; } const secretContent = Buffer.from(rawSecretContent, 'binary'); @@ -99,9 +97,8 @@ class CommandEdit extends CommandPolykey { execSync(`${process.env.EDITOR} \"${tmpFile}\"`, { stdio: 'inherit' }); let content: string; try { - content = (await this.fs.promises.readFile(tmpFile)).toString( - 'binary', - ); + const buffer = await this.fs.promises.readFile(tmpFile); + content = buffer.toString('binary'); } catch (e) { if (e.code === 'ENOENT') { // If the secret exists but the file doesn't, then something went @@ -125,26 +122,17 @@ class CommandEdit extends CommandPolykey { } throw e; } - await binUtils.retryAuthentication(async (auth) => { - // This point will never be reached if the temp file doesn't exist. - // As such, if the secret didn't exist before, then we want to make it. - // Otherwise, if the secret existed before, then we want to edit it. - if (secretExists) { - await pkClient.rpcClient.methods.vaultsSecretsEdit({ - metadata: auth, - nameOrId: secretPath[0], - secretName: secretPath[1], - secretContent: content, - }); - } else { - await pkClient.rpcClient.methods.vaultsSecretsNew({ + // We will reach here only when the user wants to write a new secret. + await binUtils.retryAuthentication( + async (auth) => + await pkClient.rpcClient.methods.vaultsSecretsWriteFile({ metadata: auth, nameOrId: secretPath[0], secretName: secretPath[1], secretContent: content, - }); - } - }, meta); + }), + meta, + ); // Windows // TODO: complete windows impl } finally { diff --git a/src/secrets/CommandList.ts b/src/secrets/CommandList.ts index ba88511d..ef0c1f8f 100644 --- a/src/secrets/CommandList.ts +++ b/src/secrets/CommandList.ts @@ -9,7 +9,7 @@ class CommandList extends CommandPolykey { constructor(...args: ConstructorParameters) { super(...args); this.name('ls'); - this.aliases(['list']); + this.alias('list'); this.description('List all secrets for a vault within a directory'); this.argument( '', diff --git a/src/secrets/CommandRemove.ts b/src/secrets/CommandRemove.ts index 11e8616b..9fa527c3 100644 --- a/src/secrets/CommandRemove.ts +++ b/src/secrets/CommandRemove.ts @@ -5,10 +5,11 @@ import * as binOptions from '../utils/options'; import * as binParsers from '../utils/parsers'; import * as binProcessors from '../utils/processors'; -class CommandDelete extends CommandPolykey { +class CommandRemove extends CommandPolykey { constructor(...args: ConstructorParameters) { super(...args); this.name('rm'); + this.alias('remove'); this.description('Delete a Secret from a specified Vault'); this.argument( '', @@ -78,4 +79,4 @@ class CommandDelete extends CommandPolykey { } } -export default CommandDelete; +export default CommandRemove; diff --git a/src/secrets/CommandSecrets.ts b/src/secrets/CommandSecrets.ts index 0b9644ae..a6075be3 100644 --- a/src/secrets/CommandSecrets.ts +++ b/src/secrets/CommandSecrets.ts @@ -7,8 +7,8 @@ import CommandList from './CommandList'; import CommandMkdir from './CommandMkdir'; import CommandRename from './CommandRename'; import CommandRemove from './CommandRemove'; -import CommandUpdate from './CommandUpdate'; import CommandStat from './CommandStat'; +import CommandWrite from './CommandWrite'; import CommandPolykey from '../CommandPolykey'; class CommandSecrets extends CommandPolykey { @@ -25,8 +25,8 @@ class CommandSecrets extends CommandPolykey { this.addCommand(new CommandMkdir(...args)); this.addCommand(new CommandRename(...args)); this.addCommand(new CommandRemove(...args)); - this.addCommand(new CommandUpdate(...args)); this.addCommand(new CommandStat(...args)); + this.addCommand(new CommandWrite(...args)); } } diff --git a/src/secrets/CommandUpdate.ts b/src/secrets/CommandWrite.ts similarity index 60% rename from src/secrets/CommandUpdate.ts rename to src/secrets/CommandWrite.ts index e8ca4fc2..76bbad22 100644 --- a/src/secrets/CommandUpdate.ts +++ b/src/secrets/CommandWrite.ts @@ -1,29 +1,24 @@ import type PolykeyClient from 'polykey/dist/PolykeyClient'; -import * as errors from '../errors'; import CommandPolykey from '../CommandPolykey'; +import * as binProcessors from '../utils/processors'; +import * as binParsers from '../utils/parsers'; import * as binUtils from '../utils'; import * as binOptions from '../utils/options'; -import * as binParsers from '../utils/parsers'; -import * as binProcessors from '../utils/processors'; -class CommandUpdate extends CommandPolykey { +class CommandWrite extends CommandPolykey { constructor(...args: ConstructorParameters) { super(...args); - this.name('update'); - this.description('Update a Secret'); - this.argument( - '', - 'On disk path to the secret file with the contents of the updated secret', - ); + this.name('write'); + this.description('Write data into a secret from standard in'); this.argument( '', - 'Path to where the secret to be updated, specified as :', + 'Path to the secret, specified as :', binParsers.parseSecretPathValue, ); this.addOption(binOptions.nodeId); this.addOption(binOptions.clientHost); this.addOption(binOptions.clientPort); - this.action(async (directoryPath, secretPath, options) => { + this.action(async (secretPath, options) => { const { default: PolykeyClient } = await import( 'polykey/dist/PolykeyClient' ); @@ -54,27 +49,36 @@ class CommandUpdate extends CommandPolykey { }, logger: this.logger.getChild(PolykeyClient.name), }); - let content: Buffer; - try { - content = await this.fs.promises.readFile(directoryPath); - } catch (e) { - throw new errors.ErrorPolykeyCLIFileRead(e.message, { - data: { - errno: e.errno, - syscall: e.syscall, - code: e.code, - path: e.path, - }, - cause: e, - }); - } + + let stdin: string = ''; + 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) => { + stdin += data.toString(); + }; + 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); + }); await binUtils.retryAuthentication( - (auth) => - pkClient.rpcClient.methods.vaultsSecretsEdit({ + async (auth) => + await pkClient.rpcClient.methods.vaultsSecretsWriteFile({ metadata: auth, nameOrId: secretPath[0], secretName: secretPath[1], - secretContent: content.toString('binary'), + secretContent: stdin, }), meta, ); @@ -85,4 +89,4 @@ class CommandUpdate extends CommandPolykey { } } -export default CommandUpdate; +export default CommandWrite; diff --git a/tests/secrets/update.test.ts b/tests/secrets/update.test.ts deleted file mode 100644 index 2adaefe7..00000000 --- a/tests/secrets/update.test.ts +++ /dev/null @@ -1,81 +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('commandUpdateSecret', () => { - 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 update secrets', async () => { - const vaultName = 'Vault7' as VaultName; - const vaultId = await polykeyAgent.vaultManager.createVault(vaultName); - - const secretPath = path.join(dataDir, 'secret'); - await fs.promises.writeFile(secretPath, 'updated-content'); - - await polykeyAgent.vaultManager.withVaults([vaultId], async (vault) => { - await vaultOps.addSecret(vault, 'MySecret', 'original-content'); - expect( - (await vaultOps.getSecret(vault, 'MySecret')).toString(), - ).toStrictEqual('original-content'); - }); - - command = [ - 'secrets', - 'update', - '-np', - dataDir, - secretPath, - `${vaultName}:MySecret`, - ]; - - const result2 = await testUtils.pkStdio([...command], { - env: { PK_PASSWORD: password }, - cwd: dataDir, - }); - expect(result2.exitCode).toBe(0); - - await polykeyAgent.vaultManager.withVaults([vaultId], async (vault) => { - const list = await vaultOps.listSecrets(vault); - expect(list.sort()).toStrictEqual(['MySecret']); - expect( - (await vaultOps.getSecret(vault, 'MySecret')).toString(), - ).toStrictEqual('updated-content'); - }); - }); -}); diff --git a/tests/secrets/write.test.ts b/tests/secrets/write.test.ts new file mode 100644 index 00000000..696a8ec1 --- /dev/null +++ b/tests/secrets/write.test.ts @@ -0,0 +1,136 @@ +import type { VaultName } from 'polykey/dist/vaults/types'; +import path from 'path'; +import fs from 'fs'; +import fc from 'fast-check'; +import { test } from '@fast-check/jest'; +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('commandWriteFile', () => { + const password = 'password'; + const logger = new Logger('CLI Test', LogLevel.WARN, [new StreamHandler()]); + const stdinArb = fc.string({ minLength: 0, maxLength: 100 }); + const contentArb = fc.constantFrom('content', ''); + let dataDir: string; + let polykeyAgent: PolykeyAgent; + let command: Array; + let vaultNumber: number = 0; + + // Helper function to generate unique vault names + function genVaultName() { + vaultNumber++; + return `vault-${vaultNumber}` as VaultName; + } + + 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.prop([stdinArb, contentArb], { numRuns: 1 })( + 'should write secret', + async (stdinData, secretContent) => { + const vaultName = genVaultName(); + const vaultId = await polykeyAgent.vaultManager.createVault(vaultName); + const secretName = 'secret'; + await polykeyAgent.vaultManager.withVaults([vaultId], async (vault) => { + await vaultOps.addSecret(vault, secretName, secretContent); + }); + command = [ + 'secrets', + 'write', + '-np', + dataDir, + `${vaultName}:${secretName}`, + ]; + + const childProcess = await testUtils.pkSpawn( + command, + { + env: { PK_PASSWORD: password }, + cwd: dataDir, + }, + logger, + ); + // The conditions of stdin 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(); + const exitCode = await new Promise((resolve) => { + childProcess.once('exit', (code) => { + const exitCode = code ?? -255; + childProcess.removeAllListeners('data'); + resolve(exitCode); + }); + }); + expect(exitCode).toStrictEqual(0); + await polykeyAgent.vaultManager.withVaults([vaultId], async (vault) => { + const contents = await vaultOps.getSecret(vault, secretName); + expect(contents.toString()).toStrictEqual(stdinData); + }); + }, + ); + test('should overwrite secret', async () => { + const vaultName = 'vault' as VaultName; + const vaultId = await polykeyAgent.vaultManager.createVault(vaultName); + const secretName = 'secret'; + const newContent = 'new contents'; + command = [ + 'secrets', + 'write', + '-np', + dataDir, + `${vaultName}:${secretName}`, + ]; + + const childProcess = await testUtils.pkSpawn( + command, + { + env: { PK_PASSWORD: password }, + cwd: dataDir, + }, + logger, + ); + // The conditions of stdin being null will not be met in the test, so we + // don't have to worry about the fields being null. + childProcess.stdin!.write(newContent); + childProcess.stdin!.end(); + const exitCode = await new Promise((resolve) => { + childProcess.once('exit', (code) => { + const exitCode = code ?? -255; + childProcess.removeAllListeners('data'); + resolve(exitCode); + }); + }); + expect(exitCode).toStrictEqual(0); + await polykeyAgent.vaultManager.withVaults([vaultId], async (vault) => { + const contents = await vaultOps.getSecret(vault, secretName); + expect(contents.toString()).toStrictEqual(newContent); + }); + }); +});