Skip to content

Commit a0789c4

Browse files
committed
feat: added secrets touch command
1 parent 566468b commit a0789c4

File tree

4 files changed

+287
-0
lines changed

4 files changed

+287
-0
lines changed

src/errors.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,11 @@ class ErrorPolykeyCLIEditSecret<T> extends ErrorPolykeyCLI<T> {
191191
exitCode = 1;
192192
}
193193

194+
class ErrorPolykeyCLITouchSecret<T> extends ErrorPolykeyCLI<T> {
195+
static description = 'Failed to touch one or more secret';
196+
exitCode = 1;
197+
}
198+
194199
export {
195200
ErrorPolykeyCLI,
196201
ErrorPolykeyCLIUncaughtException,
@@ -218,4 +223,5 @@ export {
218223
ErrorPolykeyCLIRemoveSecret,
219224
ErrorPolykeyCLICatSecret,
220225
ErrorPolykeyCLIEditSecret,
226+
ErrorPolykeyCLITouchSecret,
221227
};

src/secrets/CommandSecrets.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import CommandMkdir from './CommandMkdir';
88
import CommandRename from './CommandRename';
99
import CommandRemove from './CommandRemove';
1010
import CommandStat from './CommandStat';
11+
import CommandTouch from './CommandTouch';
1112
import CommandWrite from './CommandWrite';
1213
import CommandPolykey from '../CommandPolykey';
1314

@@ -26,6 +27,7 @@ class CommandSecrets extends CommandPolykey {
2627
this.addCommand(new CommandRename(...args));
2728
this.addCommand(new CommandRemove(...args));
2829
this.addCommand(new CommandStat(...args));
30+
this.addCommand(new CommandTouch(...args));
2931
this.addCommand(new CommandWrite(...args));
3032
}
3133
}

src/secrets/CommandTouch.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import type PolykeyClient from 'polykey/dist/PolykeyClient';
2+
import CommandPolykey from '../CommandPolykey';
3+
import * as binProcessors from '../utils/processors';
4+
import * as binParsers from '../utils/parsers';
5+
import * as binUtils from '../utils';
6+
import * as binOptions from '../utils/options';
7+
import * as errors from '../errors';
8+
9+
class CommandTouch extends CommandPolykey {
10+
constructor(...args: ConstructorParameters<typeof CommandPolykey>) {
11+
super(...args);
12+
this.name('touch');
13+
this.description('Create a secret if it does not exist');
14+
this.argument(
15+
'<secretPaths...>',
16+
'One or more paths, specified as <vaultName>:<secretPath>',
17+
);
18+
this.addOption(binOptions.nodeId);
19+
this.addOption(binOptions.clientHost);
20+
this.addOption(binOptions.clientPort);
21+
this.action(async (secretPaths, options) => {
22+
secretPaths = secretPaths.map((path: string) =>
23+
binParsers.parseSecretPath(path),
24+
);
25+
const { default: PolykeyClient } = await import(
26+
'polykey/dist/PolykeyClient'
27+
);
28+
const clientOptions = await binProcessors.processClientOptions(
29+
options.nodePath,
30+
options.nodeId,
31+
options.clientHost,
32+
options.clientPort,
33+
this.fs,
34+
this.logger.getChild(binProcessors.processClientOptions.name),
35+
);
36+
const meta = await binProcessors.processAuthentication(
37+
options.passwordFile,
38+
this.fs,
39+
);
40+
let pkClient: PolykeyClient;
41+
this.exitHandlers.handlers.push(async () => {
42+
if (pkClient != null) await pkClient.stop();
43+
});
44+
try {
45+
pkClient = await PolykeyClient.createPolykeyClient({
46+
nodeId: clientOptions.nodeId,
47+
host: clientOptions.clientHost,
48+
port: clientOptions.clientPort,
49+
options: {
50+
nodePath: options.nodePath,
51+
},
52+
logger: this.logger.getChild(PolykeyClient.name),
53+
});
54+
const hasErrored = await binUtils.retryAuthentication(async (auth) => {
55+
const response =
56+
await pkClient.rpcClient.methods.vaultsSecretsTouch();
57+
// Extract all unique vault names
58+
const uniqueVaultNames = new Set<string>();
59+
for (const [vaultName] of secretPaths) {
60+
uniqueVaultNames.add(vaultName);
61+
}
62+
const writer = response.writable.getWriter();
63+
// Send the header message first
64+
await writer.write({
65+
type: 'VaultNamesHeaderMessage',
66+
vaultNames: Array.from(uniqueVaultNames),
67+
metadata: auth,
68+
});
69+
// Then send all the paths in subsequent messages
70+
for (const [vaultName, secretPath] of secretPaths) {
71+
await writer.write({
72+
type: 'SecretIdentifierMessage',
73+
nameOrId: vaultName,
74+
secretName: secretPath,
75+
});
76+
}
77+
await writer.close();
78+
// Check if any errors were raised
79+
let hasErrored = false;
80+
for await (const result of response.readable) {
81+
if (result.type === 'ErrorMessage') {
82+
hasErrored = true;
83+
switch (result.code) {
84+
case 'ENOENT':
85+
// Attempt to touch a path which doesn't exist
86+
process.stderr.write(
87+
`touch: cannot touch '${result.reason}': No such file or directory\n`,
88+
);
89+
break;
90+
default:
91+
// No other code should be thrown
92+
throw result;
93+
}
94+
}
95+
}
96+
return hasErrored;
97+
}, meta);
98+
99+
if (hasErrored) {
100+
throw new errors.ErrorPolykeyCLITouchSecret(
101+
'Failed to touch one or more secrets',
102+
);
103+
}
104+
} finally {
105+
if (pkClient! != null) await pkClient.stop();
106+
}
107+
});
108+
}
109+
}
110+
111+
export default CommandTouch;

tests/secrets/touch.test.ts

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import path from 'path';
2+
import fs from 'fs';
3+
import Logger, { LogLevel, StreamHandler } from '@matrixai/logger';
4+
import PolykeyAgent from 'polykey/dist/PolykeyAgent';
5+
import { vaultOps } from 'polykey/dist/vaults';
6+
import * as keysUtils from 'polykey/dist/keys/utils';
7+
import * as testUtils from '../utils';
8+
9+
describe('commandTouch', () => {
10+
const password = 'password';
11+
const logger = new Logger('CLI Test', LogLevel.WARN, [new StreamHandler()]);
12+
let dataDir: string;
13+
let polykeyAgent: PolykeyAgent;
14+
15+
beforeEach(async () => {
16+
dataDir = await fs.promises.mkdtemp(
17+
path.join(globalThis.tmpDir, 'polykey-test-'),
18+
);
19+
polykeyAgent = await PolykeyAgent.createPolykeyAgent({
20+
password: password,
21+
options: {
22+
nodePath: dataDir,
23+
agentServiceHost: '127.0.0.1',
24+
clientServiceHost: '127.0.0.1',
25+
keys: {
26+
passwordOpsLimit: keysUtils.passwordOpsLimits.min,
27+
passwordMemLimit: keysUtils.passwordMemLimits.min,
28+
strictMemoryLock: false,
29+
},
30+
},
31+
logger: logger,
32+
});
33+
});
34+
afterEach(async () => {
35+
await polykeyAgent.stop();
36+
await fs.promises.rm(dataDir, {
37+
force: true,
38+
recursive: true,
39+
});
40+
});
41+
42+
test('should create a secret if it does not exist', async () => {
43+
const vaultName = 'vault';
44+
const vaultId = await polykeyAgent.vaultManager.createVault(vaultName);
45+
const secretName = 'secret';
46+
const command = [
47+
'secrets',
48+
'touch',
49+
'-np',
50+
dataDir,
51+
`${vaultName}:${secretName}`,
52+
];
53+
const result = await testUtils.pkStdio(command, {
54+
env: { PK_PASSWORD: password },
55+
cwd: dataDir,
56+
});
57+
expect(result.exitCode).toBe(0);
58+
await polykeyAgent.vaultManager.withVaults([vaultId], async (vault) => {
59+
await vault.readF(async (efs) => {
60+
await expect(efs.exists(secretName)).resolves.toBeTruthy();
61+
});
62+
});
63+
});
64+
test('should update mtime if secret exists', async () => {
65+
const vaultName = 'vault';
66+
const vaultId = await polykeyAgent.vaultManager.createVault(vaultName);
67+
const secretName = 'secret';
68+
let oldMtime: Date | undefined = undefined;
69+
await polykeyAgent.vaultManager.withVaults([vaultId], async (vault) => {
70+
await vaultOps.writeSecret(vault, secretName, secretName);
71+
oldMtime = await vault.readF(async (efs) => {
72+
return (await efs.stat(secretName)).mtime;
73+
});
74+
});
75+
if (oldMtime == null) fail('Mtime cannot be nullish');
76+
const command = [
77+
'secrets',
78+
'touch',
79+
'-np',
80+
dataDir,
81+
`${vaultName}:${secretName}`,
82+
];
83+
const startTime = new Date();
84+
const result = await testUtils.pkStdio(command, {
85+
env: { PK_PASSWORD: password },
86+
cwd: dataDir,
87+
});
88+
const endTime = new Date();
89+
expect(result.exitCode).toBe(0);
90+
await polykeyAgent.vaultManager.withVaults([vaultId], async (vault) => {
91+
await vault.readF(async (efs) => {
92+
await expect(efs.exists(secretName)).resolves.toBeTruthy();
93+
// File content isn't modified
94+
const content = await efs.readFile(secretName);
95+
expect(content.toString()).toEqual(secretName);
96+
// Timestamp has changed
97+
const stat = await efs.stat(secretName);
98+
expect(
99+
stat.mtime >= startTime &&
100+
stat.mtime <= endTime &&
101+
stat.mtime !== oldMtime,
102+
).toBeTruthy();
103+
});
104+
});
105+
});
106+
test('should update mtime if directory exists', async () => {
107+
const vaultName = 'vault';
108+
const vaultId = await polykeyAgent.vaultManager.createVault(vaultName);
109+
const dirName = 'dir';
110+
let oldMtime: Date | undefined = undefined;
111+
await polykeyAgent.vaultManager.withVaults([vaultId], async (vault) => {
112+
await vaultOps.mkdir(vault, dirName);
113+
oldMtime = await vault.readF(async (efs) => {
114+
return (await efs.stat(dirName)).mtime;
115+
});
116+
});
117+
if (oldMtime == null) fail('Mtime cannot be nullish');
118+
const command = [
119+
'secrets',
120+
'touch',
121+
'-np',
122+
dataDir,
123+
`${vaultName}:${dirName}`,
124+
];
125+
const startTime = new Date();
126+
const result = await testUtils.pkStdio(command, {
127+
env: { PK_PASSWORD: password },
128+
cwd: dataDir,
129+
});
130+
const endTime = new Date();
131+
expect(result.exitCode).toBe(0);
132+
await polykeyAgent.vaultManager.withVaults([vaultId], async (vault) => {
133+
await vault.readF(async (efs) => {
134+
await expect(efs.exists(dirName)).resolves.toBeTruthy();
135+
// Timestamp has changed
136+
const stat = await efs.stat(dirName);
137+
expect(
138+
stat.mtime >= startTime &&
139+
stat.mtime <= endTime &&
140+
stat.mtime !== oldMtime,
141+
).toBeTruthy();
142+
});
143+
});
144+
});
145+
test('should fail if parent directory does not exist', async () => {
146+
const vaultName = 'vault';
147+
const vaultId = await polykeyAgent.vaultManager.createVault(vaultName);
148+
const secretName = path.join('dir', 'secret');
149+
const command = [
150+
'secrets',
151+
'touch',
152+
'-np',
153+
dataDir,
154+
`${vaultName}:${secretName}`,
155+
];
156+
const result = await testUtils.pkStdio(command, {
157+
env: { PK_PASSWORD: password },
158+
cwd: dataDir,
159+
});
160+
expect(result.exitCode).not.toBe(0);
161+
expect(result.stderr).toInclude('No such file or directory');
162+
await polykeyAgent.vaultManager.withVaults([vaultId], async (vault) => {
163+
await vault.readF(async (efs) => {
164+
await expect(efs.exists(secretName)).resolves.toBeFalsy();
165+
});
166+
});
167+
});
168+
});

0 commit comments

Comments
 (0)