Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion npmDepsHash
Original file line number Diff line number Diff line change
@@ -1 +1 @@
sha256-m1ZpBUrLygWELYlgShs1oWs+o3qf3e6G4rhd5ZzsOhE=
sha256-yH5mPEv7drHBiLeI+KzMWv4ZQqqqJyiEzDdl6ntMRvQ=
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,11 @@ class ErrorPolykeyCLIEditSecret<T> extends ErrorPolykeyCLI<T> {
exitCode = 1;
}

class ErrorPolykeyCLITouchSecret<T> extends ErrorPolykeyCLI<T> {
static description = 'Failed to touch one or more secret';
exitCode = 1;
}

export {
ErrorPolykeyCLI,
ErrorPolykeyCLIUncaughtException,
Expand Down Expand Up @@ -218,4 +223,5 @@ export {
ErrorPolykeyCLIRemoveSecret,
ErrorPolykeyCLICatSecret,
ErrorPolykeyCLIEditSecret,
ErrorPolykeyCLITouchSecret,
};
2 changes: 2 additions & 0 deletions src/secrets/CommandSecrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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));
}
}
Expand Down
111 changes: 111 additions & 0 deletions src/secrets/CommandTouch.ts
Original file line number Diff line number Diff line change
@@ -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<typeof CommandPolykey>) {
super(...args);
this.name('touch');
this.description('Create a secret if it does not exist');
this.argument(
'<secretPaths...>',
'One or more paths, specified as <vaultName>:<secretPath>',
);
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<string>();
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;
168 changes: 168 additions & 0 deletions tests/secrets/touch.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
});