diff --git a/npmDepsHash b/npmDepsHash index c38b2ac0..02540328 100644 --- a/npmDepsHash +++ b/npmDepsHash @@ -1 +1 @@ -sha256-m1ZpBUrLygWELYlgShs1oWs+o3qf3e6G4rhd5ZzsOhE= +sha256-yH5mPEv7drHBiLeI+KzMWv4ZQqqqJyiEzDdl6ntMRvQ= diff --git a/package-lock.json b/package-lock.json index f59554ce..19a252da 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.18.0", + "polykey": "^1.19.0", "prettier": "^3.0.0", "shelljs": "^0.8.5", "shx": "^0.3.4", @@ -7602,9 +7602,9 @@ } }, "node_modules/polykey": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/polykey/-/polykey-1.18.0.tgz", - "integrity": "sha512-WV34AVOz4s+hG+wJ6unNTHFu8SwlXWo4yKdU4Uas6v9ykX0hTkS80x64pRdx+hYv1DlYp1Rp4AEmaowtjIlWtA==", + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/polykey/-/polykey-1.19.0.tgz", + "integrity": "sha512-IgXcuM7PN5wEJGiilesG7gZznmVkwojGj2ZLEEf0QomJedfi3S2/gNYtLs6LpoYfzeOJGqgRsfoNQfKUsI4Pqg==", "dev": true, "dependencies": { "@matrixai/async-cancellable": "^1.1.1", diff --git a/package.json b/package.json index 3fea8d96..e173fb93 100644 --- a/package.json +++ b/package.json @@ -153,7 +153,7 @@ "nexpect": "^0.6.0", "node-gyp-build": "^4.4.0", "nodemon": "^3.0.1", - "polykey": "^1.18.0", + "polykey": "^1.19.0", "prettier": "^3.0.0", "shelljs": "^0.8.5", "shx": "^0.3.4", diff --git a/src/errors.ts b/src/errors.ts index a524a9eb..a3854fc0 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -191,6 +191,11 @@ class ErrorPolykeyCLIEditSecret extends ErrorPolykeyCLI { exitCode = 1; } +class ErrorPolykeyCLITouchSecret extends ErrorPolykeyCLI { + static description = 'Failed to touch one or more secret'; + exitCode = 1; +} + export { ErrorPolykeyCLI, ErrorPolykeyCLIUncaughtException, @@ -218,4 +223,5 @@ export { ErrorPolykeyCLIRemoveSecret, ErrorPolykeyCLICatSecret, ErrorPolykeyCLIEditSecret, + ErrorPolykeyCLITouchSecret, }; diff --git a/src/secrets/CommandSecrets.ts b/src/secrets/CommandSecrets.ts index a6075be3..295f8177 100644 --- a/src/secrets/CommandSecrets.ts +++ b/src/secrets/CommandSecrets.ts @@ -8,6 +8,7 @@ import CommandMkdir from './CommandMkdir'; import CommandRename from './CommandRename'; import CommandRemove from './CommandRemove'; import CommandStat from './CommandStat'; +import CommandTouch from './CommandTouch'; import CommandWrite from './CommandWrite'; import CommandPolykey from '../CommandPolykey'; @@ -26,6 +27,7 @@ class CommandSecrets extends CommandPolykey { this.addCommand(new CommandRename(...args)); this.addCommand(new CommandRemove(...args)); this.addCommand(new CommandStat(...args)); + this.addCommand(new CommandTouch(...args)); this.addCommand(new CommandWrite(...args)); } } diff --git a/src/secrets/CommandTouch.ts b/src/secrets/CommandTouch.ts new file mode 100644 index 00000000..0aa2b6b9 --- /dev/null +++ b/src/secrets/CommandTouch.ts @@ -0,0 +1,111 @@ +import type PolykeyClient from 'polykey/dist/PolykeyClient'; +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 errors from '../errors'; + +class CommandTouch extends CommandPolykey { + constructor(...args: ConstructorParameters) { + super(...args); + this.name('touch'); + this.description('Create a secret if it does not exist'); + this.argument( + '', + 'One or more paths, 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.parseSecretPath(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), + }); + const hasErrored = await binUtils.retryAuthentication(async (auth) => { + const response = + await pkClient.rpcClient.methods.vaultsSecretsTouch(); + // Extract all unique vault names + const uniqueVaultNames = new Set(); + for (const [vaultName] of secretPaths) { + uniqueVaultNames.add(vaultName); + } + const writer = response.writable.getWriter(); + // Send the header message first + await writer.write({ + type: 'VaultNamesHeaderMessage', + vaultNames: Array.from(uniqueVaultNames), + metadata: auth, + }); + // Then send all the paths in subsequent messages + for (const [vaultName, secretPath] of secretPaths) { + await writer.write({ + type: 'SecretIdentifierMessage', + nameOrId: vaultName, + secretName: secretPath, + }); + } + await writer.close(); + // Check if any errors were raised + let hasErrored = false; + for await (const result of response.readable) { + if (result.type === 'ErrorMessage') { + hasErrored = true; + switch (result.code) { + case 'ENOENT': + // Attempt to touch a path which doesn't exist + process.stderr.write( + `touch: cannot touch '${result.reason}': No such file or directory\n`, + ); + break; + default: + // No other code should be thrown + throw result; + } + } + } + return hasErrored; + }, meta); + + if (hasErrored) { + throw new errors.ErrorPolykeyCLITouchSecret( + 'Failed to touch one or more secrets', + ); + } + } finally { + if (pkClient! != null) await pkClient.stop(); + } + }); + } +} + +export default CommandTouch; diff --git a/tests/secrets/touch.test.ts b/tests/secrets/touch.test.ts new file mode 100644 index 00000000..4362c88e --- /dev/null +++ b/tests/secrets/touch.test.ts @@ -0,0 +1,168 @@ +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('commandTouch', () => { + 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: 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 create a secret if it does not exist', async () => { + const vaultName = 'vault'; + const vaultId = await polykeyAgent.vaultManager.createVault(vaultName); + const secretName = 'secret'; + const command = [ + 'secrets', + 'touch', + '-np', + dataDir, + `${vaultName}:${secretName}`, + ]; + const result = await testUtils.pkStdio(command, { + env: { PK_PASSWORD: password }, + cwd: dataDir, + }); + expect(result.exitCode).toBe(0); + await polykeyAgent.vaultManager.withVaults([vaultId], async (vault) => { + await vault.readF(async (efs) => { + await expect(efs.exists(secretName)).resolves.toBeTruthy(); + }); + }); + }); + test('should update mtime if secret exists', async () => { + const vaultName = 'vault'; + const vaultId = await polykeyAgent.vaultManager.createVault(vaultName); + const secretName = 'secret'; + let oldMtime: Date | undefined = undefined; + await polykeyAgent.vaultManager.withVaults([vaultId], async (vault) => { + await vaultOps.writeSecret(vault, secretName, secretName); + oldMtime = await vault.readF(async (efs) => { + return (await efs.stat(secretName)).mtime; + }); + }); + if (oldMtime == null) fail('Mtime cannot be nullish'); + const command = [ + 'secrets', + 'touch', + '-np', + dataDir, + `${vaultName}:${secretName}`, + ]; + const startTime = new Date(); + const result = await testUtils.pkStdio(command, { + env: { PK_PASSWORD: password }, + cwd: dataDir, + }); + const endTime = new Date(); + expect(result.exitCode).toBe(0); + await polykeyAgent.vaultManager.withVaults([vaultId], async (vault) => { + await vault.readF(async (efs) => { + await expect(efs.exists(secretName)).resolves.toBeTruthy(); + // File content isn't modified + const content = await efs.readFile(secretName); + expect(content.toString()).toEqual(secretName); + // Timestamp has changed + const stat = await efs.stat(secretName); + expect( + stat.mtime >= startTime && + stat.mtime <= endTime && + stat.mtime !== oldMtime, + ).toBeTruthy(); + }); + }); + }); + test('should update mtime if directory exists', async () => { + const vaultName = 'vault'; + const vaultId = await polykeyAgent.vaultManager.createVault(vaultName); + const dirName = 'dir'; + let oldMtime: Date | undefined = undefined; + await polykeyAgent.vaultManager.withVaults([vaultId], async (vault) => { + await vaultOps.mkdir(vault, dirName); + oldMtime = await vault.readF(async (efs) => { + return (await efs.stat(dirName)).mtime; + }); + }); + if (oldMtime == null) fail('Mtime cannot be nullish'); + const command = [ + 'secrets', + 'touch', + '-np', + dataDir, + `${vaultName}:${dirName}`, + ]; + const startTime = new Date(); + const result = await testUtils.pkStdio(command, { + env: { PK_PASSWORD: password }, + cwd: dataDir, + }); + const endTime = new Date(); + expect(result.exitCode).toBe(0); + await polykeyAgent.vaultManager.withVaults([vaultId], async (vault) => { + await vault.readF(async (efs) => { + await expect(efs.exists(dirName)).resolves.toBeTruthy(); + // Timestamp has changed + const stat = await efs.stat(dirName); + expect( + stat.mtime >= startTime && + stat.mtime <= endTime && + stat.mtime !== oldMtime, + ).toBeTruthy(); + }); + }); + }); + test('should fail if parent directory does not exist', async () => { + const vaultName = 'vault'; + const vaultId = await polykeyAgent.vaultManager.createVault(vaultName); + const secretName = path.join('dir', 'secret'); + const command = [ + 'secrets', + 'touch', + '-np', + dataDir, + `${vaultName}:${secretName}`, + ]; + const result = await testUtils.pkStdio(command, { + env: { PK_PASSWORD: password }, + cwd: dataDir, + }); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toInclude('No such file or directory'); + await polykeyAgent.vaultManager.withVaults([vaultId], async (vault) => { + await vault.readF(async (efs) => { + await expect(efs.exists(secretName)).resolves.toBeFalsy(); + }); + }); + }); +});