diff --git a/src/client/callers/index.ts b/src/client/callers/index.ts index fe2ec3c7f..21b77d9af 100644 --- a/src/client/callers/index.ts +++ b/src/client/callers/index.ts @@ -71,6 +71,7 @@ import vaultsSecretsNewDir from './vaultsSecretsNewDir'; import vaultsSecretsRename from './vaultsSecretsRename'; import vaultsSecretsRemove from './vaultsSecretsRemove'; import vaultsSecretsStat from './vaultsSecretsStat'; +import vaultsSecretsTouch from './vaultsSecretsTouch'; import vaultsSecretsWriteFile from './vaultsSecretsWriteFile'; import vaultsVersion from './vaultsVersion'; @@ -151,6 +152,7 @@ const clientManifest = { vaultsSecretsRename, vaultsSecretsRemove, vaultsSecretsStat, + vaultsSecretsTouch, vaultsSecretsWriteFile, vaultsVersion, }; @@ -230,6 +232,7 @@ export { vaultsSecretsRename, vaultsSecretsRemove, vaultsSecretsStat, + vaultsSecretsTouch, vaultsSecretsWriteFile, vaultsVersion, }; diff --git a/src/client/callers/vaultsSecretsTouch.ts b/src/client/callers/vaultsSecretsTouch.ts new file mode 100644 index 000000000..c45a830e7 --- /dev/null +++ b/src/client/callers/vaultsSecretsTouch.ts @@ -0,0 +1,12 @@ +import type { HandlerTypes } from '@matrixai/rpc'; +import type VaultsSecretsTouch from '../handlers/VaultsSecretsTouch'; +import { DuplexCaller } from '@matrixai/rpc'; + +type CallerTypes = HandlerTypes; + +const vaultsSecretsTouch = new DuplexCaller< + CallerTypes['input'], + CallerTypes['output'] +>(); + +export default vaultsSecretsTouch; diff --git a/src/client/handlers/VaultsSecretsTouch.ts b/src/client/handlers/VaultsSecretsTouch.ts new file mode 100644 index 000000000..27b9b7cc8 --- /dev/null +++ b/src/client/handlers/VaultsSecretsTouch.ts @@ -0,0 +1,139 @@ +import type { ContextTimed } from '@matrixai/contexts'; +import type { DB } from '@matrixai/db'; +import type { ResourceAcquire } from '@matrixai/resources'; +import type { JSONValue } from '@matrixai/rpc'; +import type { + ClientRPCRequestParams, + ClientRPCResponseResult, + SecretIdentifierMessageTagged, + SuccessOrErrorMessageTagged, + VaultNamesHeaderMessageTagged, +} from '../types'; +import type VaultManager from '../../vaults/VaultManager'; +import type { FileSystemWritable } from '../../vaults/types'; +import { withG } from '@matrixai/resources'; +import { DuplexHandler } from '@matrixai/rpc'; +import * as vaultsUtils from '../../vaults/utils'; +import * as vaultsErrors from '../../vaults/errors'; +import * as clientErrors from '../errors'; + +class VaultsSecretsTouch extends DuplexHandler< + { + db: DB; + vaultManager: VaultManager; + }, + ClientRPCRequestParams< + VaultNamesHeaderMessageTagged | SecretIdentifierMessageTagged + >, + ClientRPCResponseResult +> { + public handle = async function* ( + input: AsyncIterableIterator< + ClientRPCRequestParams< + VaultNamesHeaderMessageTagged | SecretIdentifierMessageTagged + > + >, + _cancel: (reason?: any) => void, + _meta: Record, + ctx: ContextTimed, + ): AsyncGenerator> { + const { db, vaultManager }: { db: DB; vaultManager: VaultManager } = + this.container; + // Extract the header message from the iterator + const headerMessagePair = await input.next(); + const headerMessage: + | VaultNamesHeaderMessageTagged + | SecretIdentifierMessageTagged = headerMessagePair.value; + // Testing if the header is of the expected format + if ( + headerMessagePair.done || + headerMessage.type !== 'VaultNamesHeaderMessage' + ) { + throw new clientErrors.ErrorClientInvalidHeader(); + } + // Create an array of write acquires + const vaultAcquires = await db.withTransactionF(async (tran) => { + const vaultAcquires: Array> = []; + for (const vaultName of headerMessage.vaultNames) { + ctx.signal.throwIfAborted(); + const vaultIdFromName = await vaultManager.getVaultId(vaultName, tran); + const vaultId = vaultIdFromName ?? vaultsUtils.decodeVaultId(vaultName); + if (vaultId == null) { + throw new vaultsErrors.ErrorVaultsVaultUndefined( + `Vault "${vaultName}" does not exist`, + ); + } + // The resource acquisition will automatically create a transaction and + // release it when cleaning up. + const acquire = await vaultManager.withVaults( + [vaultId], + async (vault) => vault.acquireWrite(undefined, ctx), + ); + vaultAcquires.push(acquire); + } + return vaultAcquires; + }); + // Acquire all locks in parallel and perform all operations at once + yield* withG( + vaultAcquires, + async function* (efses): AsyncGenerator { + // Creating the vault name to efs map for easy access + const vaultMap = new Map(); + for (let i = 0; i < efses.length; i++) { + vaultMap.set(headerMessage!.vaultNames[i], efses[i]); + } + let loopRan = false; + for await (const message of input) { + ctx.signal.throwIfAborted(); + loopRan = true; + // Header messages should not be seen anymore + if (message.type === 'VaultNamesHeaderMessage') { + throw new clientErrors.ErrorClientProtocolError( + 'The header message cannot be sent multiple times', + ); + } + const efs = vaultMap.get(message.nameOrId); + if (efs == null) { + throw new vaultsErrors.ErrorVaultsVaultUndefined( + `Vault ${message.nameOrId} was not present in the header message`, + ); + } + try { + // If the file exists, update its timestamps. Otherwise, create the + // file. Note that this can throw errors, which are handled later. + if (await efs.exists(message.secretName)) { + const now = new Date(); + await efs.utimes(message.secretName, now, now); + } else { + await efs.writeFile(message.secretName); + } + yield { + type: 'SuccessMessage', + success: true, + }; + } catch (e) { + switch (e.code) { + case 'ENOENT': + yield { + type: 'ErrorMessage', + code: e.code, + reason: message.secretName, + }; + break; + default: + throw e; + } + } + } + // Content messages must follow header messages + if (!loopRan) { + throw new clientErrors.ErrorClientProtocolError( + 'No content messages followed header message', + ); + } + }, + ); + }; +} + +export default VaultsSecretsTouch; diff --git a/src/client/handlers/index.ts b/src/client/handlers/index.ts index 4a7bd1af8..472bdcb7d 100644 --- a/src/client/handlers/index.ts +++ b/src/client/handlers/index.ts @@ -88,6 +88,7 @@ import VaultsSecretsNewDir from './VaultsSecretsNewDir'; import VaultsSecretsRename from './VaultsSecretsRename'; import VaultsSecretsRemove from './VaultsSecretsRemove'; import VaultsSecretsStat from './VaultsSecretsStat'; +import VaultsSecretsTouch from './VaultsSecretsTouch'; import VaultsSecretsWriteFile from './VaultsSecretsWriteFile'; import VaultsVersion from './VaultsVersion'; @@ -191,6 +192,7 @@ const serverManifest = (container: { vaultsSecretsRename: new VaultsSecretsRename(container), vaultsSecretsRemove: new VaultsSecretsRemove(container), vaultsSecretsStat: new VaultsSecretsStat(container), + vaultsSecretsTouch: new VaultsSecretsTouch(container), vaultsSecretsWriteFile: new VaultsSecretsWriteFile(container), vaultsVersion: new VaultsVersion(container), }; @@ -272,6 +274,7 @@ export { VaultsSecretsRename, VaultsSecretsRemove, VaultsSecretsStat, + VaultsSecretsTouch, VaultsSecretsWriteFile, VaultsVersion, }; diff --git a/src/vaults/VaultOps.ts b/src/vaults/VaultOps.ts index 3328abd32..a8dbdc805 100644 --- a/src/vaults/VaultOps.ts +++ b/src/vaults/VaultOps.ts @@ -299,6 +299,43 @@ async function writeSecret( } } +/** + * Performs a touch operation on a secret by updating all it's timestamps to the + * current time. + */ +async function touchSecret( + vault: Vault, + secretName: string, + ctx?: ContextTimed, +): Promise { + const now = new Date(); + try { + await vault.writeF( + async (efs) => { + // If the file exists, update its timestamps. Otherwise, create the + // file. Note that this can throw errors, which are handled later. + if (await efs.exists(secretName)) { + await efs.utimes(secretName, now, now); + } else { + await efs.writeFile(secretName); + } + }, + undefined, + ctx, + ); + } catch (e) { + switch (e.code) { + case 'ENOENT': + throw new vaultsErrors.ErrorSecretsSecretUndefined( + `One or more parent directories for '${secretName}' do not exist`, + { cause: e }, + ); + default: + throw e; + } + } +} + export { addSecret, renameSecret, @@ -309,4 +346,5 @@ export { addSecretDirectory, listSecrets, writeSecret, + touchSecret, }; diff --git a/tests/client/handlers/vaults.test.ts b/tests/client/handlers/vaults.test.ts index 9ba1765cf..f69b365a1 100644 --- a/tests/client/handlers/vaults.test.ts +++ b/tests/client/handlers/vaults.test.ts @@ -12,6 +12,7 @@ import type { SecretRenameMessage, SecretsRemoveHeaderMessage, VaultListMessage, + VaultNamesHeaderMessageTagged, VaultPermissionMessage, VaultsLogMessage, } from '@/client/types'; @@ -49,6 +50,7 @@ import { VaultsSecretsNewDir, VaultsSecretsRename, VaultsSecretsStat, + VaultsSecretsTouch, VaultsVersion, } from '@/client/handlers'; import { @@ -69,6 +71,7 @@ import { vaultsSecretsNewDir, vaultsSecretsRename, vaultsSecretsStat, + vaultsSecretsTouch, vaultsVersion, } from '@/client/callers'; import * as keysUtils from '@/keys/utils'; @@ -1833,7 +1836,7 @@ describe('vaultsSecretsMkdir', () => { }; await expect(consumeP()).rejects.toThrow(cancelMessage); - // await vaultManager.stop(); + // Await vaultManager.stop(); // await vaultManager.start({ fresh: true}); }, ); @@ -3539,6 +3542,676 @@ describe('vaultsSecretsStat', () => { expect(stat.blocks).toBe(1); }); }); +describe('vaultsSecretsTouch', () => { + const logger = new Logger('vaultsSecretsTouch test', LogLevel.WARN, [ + new StreamHandler( + formatting.format`${formatting.level}:${formatting.keys}:${formatting.msg}`, + ), + ]); + const password = 'helloWorld'; + const localhost = '127.0.0.1'; + let dataDir: string; + let db: DB; + let keyRing: KeyRing; + let tlsConfig: TLSConfig; + let clientService: ClientService; + let webSocketClient: WebSocketClient; + let rpcClient: RPCClient<{ + vaultsSecretsTouch: typeof vaultsSecretsTouch; + }>; + let vaultManager: VaultManager; + beforeEach(async () => { + dataDir = await fs.promises.mkdtemp( + path.join(os.tmpdir(), 'polykey-test-'), + ); + const keysPath = path.join(dataDir, 'keys'); + keyRing = await KeyRing.createKeyRing({ + password: password, + keysPath: keysPath, + passwordOpsLimit: keysUtils.passwordOpsLimits.min, + passwordMemLimit: keysUtils.passwordMemLimits.min, + strictMemoryLock: false, + logger: logger, + }); + tlsConfig = await testsUtils.createTLSConfig(keyRing.keyPair); + const dbPath = path.join(dataDir, 'db'); + db = await DB.createDB({ dbPath, logger }); + const vaultsPath = path.join(dataDir, 'vaults'); + vaultManager = await VaultManager.createVaultManager({ + vaultsPath: vaultsPath, + db: db, + acl: {} as ACL, + keyRing: keyRing, + nodeManager: {} as NodeManager, + gestaltGraph: {} as GestaltGraph, + notificationsManager: {} as NotificationsManager, + logger: logger, + }); + clientService = new ClientService({ + tlsConfig: tlsConfig, + logger: logger.getChild(ClientService.name), + }); + await clientService.start({ + manifest: { + vaultsSecretsTouch: new VaultsSecretsTouch({ + db: db, + vaultManager: vaultManager, + }), + }, + host: localhost, + }); + webSocketClient = await WebSocketClient.createWebSocketClient({ + config: { + verifyPeer: false, + }, + host: localhost, + logger: logger.getChild(WebSocketClient.name), + port: clientService.port, + }); + rpcClient = new RPCClient({ + manifest: { + vaultsSecretsTouch, + }, + streamFactory: () => webSocketClient.connection.newStream(), + toError: networkUtils.toError, + logger: logger.getChild(RPCClient.name), + }); + }); + afterEach(async () => { + await clientService?.stop({ force: true }); + await webSocketClient.destroy({ force: true }); + await vaultManager.stop(); + await db.stop(); + await keyRing.stop(); + await fs.promises.rm(dataDir, { + force: true, + recursive: true, + }); + }); + test('fails when header is not sent', async () => { + // Write paths + const response = await rpcClient.methods.vaultsSecretsTouch(); + const writer = response.writable.getWriter(); + // Not sending the header message + // Content messages + await writer.write({ + type: 'SecretIdentifierMessage', + nameOrId: 'invalid', + secretName: 'invalid', + }); + await writer.close(); + // Read response + const consumeP = async () => { + for await (const _ of response.readable) { + // Consume values + } + }; + await testsUtils.expectRemoteError( + consumeP(), + clientErrors.ErrorClientInvalidHeader, + ); + }); + test('fails when only the header is sent', async () => { + const vaultId = await vaultManager.createVault('test-vault'); + const vaultIdEncoded = vaultsUtils.encodeVaultId(vaultId); + // Write paths + const response = await rpcClient.methods.vaultsSecretsTouch(); + const writer = response.writable.getWriter(); + // Header message + await writer.write({ + type: 'VaultNamesHeaderMessage', + vaultNames: [vaultIdEncoded], + }); + // Not sending the content messages + await writer.close(); + // Read response + const consumeP = async () => { + for await (const _ of response.readable) { + // Consume values + } + }; + await testsUtils.expectRemoteError( + consumeP(), + clientErrors.ErrorClientProtocolError, + ); + }); + test('fails when the header is sent multiple times', async () => { + const vaultId = await vaultManager.createVault('test-vault'); + const vaultIdEncoded = vaultsUtils.encodeVaultId(vaultId); + // Write paths + const response = await rpcClient.methods.vaultsSecretsTouch(); + const writer = response.writable.getWriter(); + // Header message + await writer.write({ + type: 'VaultNamesHeaderMessage', + vaultNames: [vaultIdEncoded], + }); + await writer.write({ + type: 'VaultNamesHeaderMessage', + vaultNames: [vaultIdEncoded], + }); + await writer.close(); + // Read response + const consumeP = async () => { + for await (const _ of response.readable) { + // Consume values + } + }; + await testsUtils.expectRemoteError( + consumeP(), + clientErrors.ErrorClientProtocolError, + ); + }); + test('fails with invalid vault name', async () => { + // Write paths + const response = await rpcClient.methods.vaultsSecretsTouch(); + const writer = response.writable.getWriter(); + // Header message + await writer.write({ + type: 'VaultNamesHeaderMessage', + vaultNames: ['invalid'], + }); + // Content messages + await writer.write({ + type: 'SecretIdentifierMessage', + nameOrId: 'invalid', + secretName: 'invalid', + }); + await writer.close(); + // Read response + const consumeP = async () => { + for await (const _ of response.readable) { + // Consume values + } + }; + await testsUtils.expectRemoteError( + consumeP(), + vaultsErrors.ErrorVaultsVaultUndefined, + ); + }); + test('creates a file if it does not exist', async () => { + // Create secrets + const secretName = 'test-secret1'; + const vaultId = await vaultManager.createVault('test-vault'); + const vaultIdEncoded = vaultsUtils.encodeVaultId(vaultId); + // Touch secrets + const response = await rpcClient.methods.vaultsSecretsTouch(); + const writer = response.writable.getWriter(); + // Header message + await writer.write({ + type: 'VaultNamesHeaderMessage', + vaultNames: [vaultIdEncoded], + }); + // Content messages + await writer.write({ + type: 'SecretIdentifierMessage', + nameOrId: vaultIdEncoded, + secretName: secretName, + }); + await writer.close(); + let loopRun = false; + for await (const data of response.readable) { + if (loopRun) { + fail('Only one iteration should run'); + } + if (data.type !== 'SuccessMessage') { + fail('Type should be "SuccessMessage"'); + } + loopRun = true; + } + // Check + expect(loopRun).toBeTruthy(); + await vaultManager.withVaults([vaultId], async (vault) => { + await vault.readF(async (efs) => { + // Ensure file exists + expect(await efs.exists(secretName)).toBeTruthy(); + }); + }); + }); + test('updates the timestamps of a file if it exists', async () => { + // Create secrets + const secretName = 'test-secret1'; + const vaultId = await vaultManager.createVault('test-vault'); + const vaultIdEncoded = vaultsUtils.encodeVaultId(vaultId); + let oldMtime: Date | undefined = undefined; + await vaultManager.withVaults([vaultId], async (vault) => { + await vault.writeF(async (efs) => { + await efs.writeFile(secretName); + const stat = await efs.stat(secretName); + oldMtime = stat.mtime; + }); + }); + if (oldMtime == null) fail('Mtime cannot be nullish'); + // Touch secrets + const response = await rpcClient.methods.vaultsSecretsTouch(); + const writer = response.writable.getWriter(); + const startTime = new Date(); + // Header message + await writer.write({ + type: 'VaultNamesHeaderMessage', + vaultNames: [vaultIdEncoded], + }); + // Content messages + await writer.write({ + type: 'SecretIdentifierMessage', + nameOrId: vaultIdEncoded, + secretName: secretName, + }); + await writer.close(); + let loopRun = false; + for await (const data of response.readable) { + loopRun = true; + if (data.type !== 'SuccessMessage') { + fail('Type should be "SuccessMessage"'); + } + } + const endTime = new Date(); + // Check + expect(loopRun).toBeTruthy(); + await vaultManager.withVaults([vaultId], async (vault) => { + await vault.readF(async (efs) => { + expect(await efs.exists(secretName)).toBeTruthy(); + // Ensure the timestamps have changed + const stat = await efs.stat(secretName); + expect( + stat.mtime >= startTime && + stat.mtime <= endTime && + stat.mtime !== oldMtime, + ).toBeTruthy(); + }); + }); + }); + test('creates multiple files', async () => { + // Create secrets + const secretName1 = 'test-secret1'; + const secretName2 = 'test-secret2'; + const vaultId = await vaultManager.createVault('test-vault'); + const vaultIdEncoded = vaultsUtils.encodeVaultId(vaultId); + // Touch secrets + const response = await rpcClient.methods.vaultsSecretsTouch(); + const writer = response.writable.getWriter(); + // Header message + await writer.write({ + type: 'VaultNamesHeaderMessage', + vaultNames: [vaultIdEncoded], + }); + // Content messages + await writer.write({ + type: 'SecretIdentifierMessage', + nameOrId: vaultIdEncoded, + secretName: secretName1, + }); + await writer.write({ + type: 'SecretIdentifierMessage', + nameOrId: vaultIdEncoded, + secretName: secretName2, + }); + await writer.close(); + let loopRun = false; + for await (const data of response.readable) { + loopRun = true; + if (data.type !== 'SuccessMessage') { + fail('Type should be "SuccessMessage"'); + } + } + // Check + expect(loopRun).toBeTruthy(); + await vaultManager.withVaults([vaultId], async (vault) => { + await vault.readF(async (efs) => { + expect(await efs.exists(secretName1)).toBeTruthy(); + expect(await efs.exists(secretName2)).toBeTruthy(); + }); + }); + }); + test('should continue on error', async () => { + // Create secrets + const secretName1 = 'test-secret1'; + const secretName2 = 'test-secret2'; + const invalidName = 'invalid/path'; + const vaultId = await vaultManager.createVault('test-vault'); + const vaultIdEncoded = vaultsUtils.encodeVaultId(vaultId); + // Create secrets + const response = await rpcClient.methods.vaultsSecretsTouch(); + const writer = response.writable.getWriter(); + // Header message + await writer.write({ + type: 'VaultNamesHeaderMessage', + vaultNames: [vaultIdEncoded], + }); + // Content messages + await writer.write({ + type: 'SecretIdentifierMessage', + nameOrId: vaultIdEncoded, + secretName: secretName1, + }); + await writer.write({ + type: 'SecretIdentifierMessage', + nameOrId: vaultIdEncoded, + secretName: invalidName, + }); + await writer.write({ + type: 'SecretIdentifierMessage', + nameOrId: vaultIdEncoded, + secretName: secretName2, + }); + await writer.close(); + let errorCount = 0; + for await (const data of response.readable) { + if (data.type === 'ErrorMessage') { + // No other file name should raise this error + expect(data.reason).toEqual(invalidName); + errorCount++; + continue; + } + expect(data.type).toEqual('SuccessMessage'); + } + // Only one error should have happened + expect(errorCount).toEqual(1); + // Check each secret was created + await vaultManager.withVaults([vaultId], async (vault) => { + await vault.readF(async (efs) => { + expect(await efs.exists(secretName1)).toBeTruthy(); + expect(await efs.exists(secretName2)).toBeTruthy(); + }); + }); + }); + test('should mix file creation and touching', async () => { + // Create secrets + const secretName1 = 'test-secret1'; + const secretName2 = 'test-secret2'; + const vaultId = await vaultManager.createVault('test-vault'); + const vaultIdEncoded = vaultsUtils.encodeVaultId(vaultId); + let oldMtime: Date | undefined = undefined; + await vaultManager.withVaults([vaultId], async (vault) => { + await vault.writeF(async (efs) => { + await efs.writeFile(secretName1); + const stat = await efs.stat(secretName1); + oldMtime = stat.mtime; + }); + }); + if (oldMtime == null) fail('Mtime cannot be nullish'); + // Create secrets + const response = await rpcClient.methods.vaultsSecretsTouch(); + const writer = response.writable.getWriter(); + // Header message + const startTime = new Date(); + await writer.write({ + type: 'VaultNamesHeaderMessage', + vaultNames: [vaultIdEncoded], + }); + // Content messages + await writer.write({ + type: 'SecretIdentifierMessage', + nameOrId: vaultIdEncoded, + secretName: secretName1, + }); + await writer.write({ + type: 'SecretIdentifierMessage', + nameOrId: vaultIdEncoded, + secretName: secretName2, + }); + await writer.close(); + for await (const data of response.readable) { + if (data.type !== 'SuccessMessage') { + fail('Type should be "SuccessMessage"'); + } + } + const endTime = new Date(); + // Check each secret was created or modified + await vaultManager.withVaults([vaultId], async (vault) => { + await vault.readF(async (efs) => { + // Ensure both files exist + expect(await efs.exists(secretName1)).toBeTruthy(); + expect(await efs.exists(secretName2)).toBeTruthy(); + // Ensure the timestamps are correct for both files + const stat1 = await efs.stat(secretName1); + expect( + stat1.mtime >= startTime && + stat1.mtime <= endTime && + stat1.mtime !== oldMtime, + ).toBeTruthy(); + const stat2 = await efs.stat(secretName2); + expect( + stat2.mtime >= startTime && + stat2.mtime <= endTime && + stat2.mtime !== oldMtime, + ).toBeTruthy(); + }); + }); + }); + test('touches multiple secrets in one log message', async () => { + // Create secret + const secretName1 = 'test-secret1'; + const secretName2 = 'test-secret2'; + const vaultId = await vaultManager.createVault('test-vault'); + const vaultIdEncoded = vaultsUtils.encodeVaultId(vaultId); + // Get log size + let logLength = 0; + await vaultManager.withVaults([vaultId], async (vault) => { + logLength = (await vault.log()).length; + }); + // Touch secrets + const response = await rpcClient.methods.vaultsSecretsTouch(); + const writer = response.writable.getWriter(); + // Header message + await writer.write({ + type: 'VaultNamesHeaderMessage', + vaultNames: [vaultIdEncoded], + }); + // Content messages + await writer.write({ + type: 'SecretIdentifierMessage', + nameOrId: vaultIdEncoded, + secretName: secretName1, + }); + await writer.write({ + type: 'SecretIdentifierMessage', + nameOrId: vaultIdEncoded, + secretName: secretName2, + }); + await writer.close(); + let loopRun = false; + for await (const data of response.readable) { + loopRun = true; + expect(data.type).toEqual('SuccessMessage'); + } + expect(loopRun).toBeTruthy(); + // Ensure single log message for creating the secrets + await vaultManager.withVaults([vaultId], async (vault) => { + expect((await vault.log()).length).toEqual(logLength + 1); + }); + }); + test('touches secrets from multiple vaults', async () => { + // Create secret + const secretName1 = 'test-secret1'; + const secretName2 = 'test-secret2'; + const secretName3 = 'test-secret3'; + const vaultId1 = await vaultManager.createVault('test-vault1'); + const vaultId2 = await vaultManager.createVault('test-vault2'); + const vaultIdEncoded1 = vaultsUtils.encodeVaultId(vaultId1); + const vaultIdEncoded2 = vaultsUtils.encodeVaultId(vaultId2); + // Write files + await vaultManager.withVaults( + [vaultId1, vaultId2], + async (vault1, vault2) => { + await vault1.writeF(async (efs) => { + await efs.writeFile(secretName1, secretName1); + await efs.writeFile(secretName3, secretName3); + }); + await vault2.writeF(async (efs) => { + await efs.writeFile(secretName2, secretName2); + }); + }, + ); + // Delete secret + const response = await rpcClient.methods.vaultsSecretsTouch(); + const writer = response.writable.getWriter(); + // Header message + await writer.write({ + type: 'VaultNamesHeaderMessage', + vaultNames: [vaultIdEncoded1, vaultIdEncoded2], + }); + // Content messages + await writer.write({ + type: 'SecretIdentifierMessage', + nameOrId: vaultIdEncoded1, + secretName: secretName1, + }); + await writer.write({ + type: 'SecretIdentifierMessage', + nameOrId: vaultIdEncoded2, + secretName: secretName2, + }); + await writer.write({ + type: 'SecretIdentifierMessage', + nameOrId: vaultIdEncoded1, + secretName: secretName3, + }); + await writer.close(); + let loopRun = false; + for await (const data of response.readable) { + loopRun = true; + expect(data.type).toEqual('SuccessMessage'); + } + // Ensure single log message for deleting the secrets + expect(loopRun).toBeTruthy(); + await vaultManager.withVaults( + [vaultId1, vaultId2], + async (vault1, vault2) => { + await vault1.readF(async (efs) => { + expect(await efs.exists(secretName1)).toBeTruthy(); + expect(await efs.exists(secretName3)).toBeTruthy(); + }); + await vault2.readF(async (efs) => { + expect(await efs.exists(secretName2)).toBeTruthy(); + }); + }, + ); + }); + test('should update timestamp of directory', async () => { + const vaultId = await vaultManager.createVault('test-vault'); + const vaultIdEncoded = vaultsUtils.encodeVaultId(vaultId); + const dirName = 'dir'; + let oldMtime: Date | undefined = undefined; + // Create secrets + await vaultManager.withVaults([vaultId], async (vault) => { + await vault.writeF(async (efs) => { + await efs.mkdir(dirName); + const stat = await efs.stat(dirName); + oldMtime = stat.mtime; + }); + }); + if (oldMtime == null) fail('Mtime cannot be nullish'); + // Touch secrets + const response = await rpcClient.methods.vaultsSecretsTouch(); + const writer = response.writable.getWriter(); + const startTime = new Date(); + // Header message + await writer.write({ + type: 'VaultNamesHeaderMessage', + vaultNames: [vaultIdEncoded], + }); + // Content messages + await writer.write({ + type: 'SecretIdentifierMessage', + nameOrId: vaultIdEncoded, + secretName: dirName, + }); + await writer.close(); + for await (const data of response.readable) { + expect(data.type).toEqual('SuccessMessage'); + } + const endTime = new Date(); + // Check each secret and the secret directory were deleted + await vaultManager.withVaults([vaultId], async (vault) => { + await vault.readF(async (efs) => { + expect(await efs.exists(dirName)).toBeTruthy(); + const stat = await efs.stat(dirName); + expect( + stat.mtime >= startTime && + stat.mtime <= endTime && + stat.mtime !== oldMtime, + ).toBeTruthy(); + }); + }); + }); + test.prop([testsUtils.vaultNameArb(), testsUtils.fileNameLengthSampleArb()], { + numRuns: 10, + })( + 'cancellation should abort the handler', + async (vaultName, [fileNames, maxLogicalSteps]) => { + // Skip if the vault already exists + fc.pre((await vaultManager.getVaultId(vaultName)) == null); + const cancelMessage = new Error('cancel message'); + const vaultId = await vaultManager.createVault(vaultName); + const vaultIdEncoded = vaultsUtils.encodeVaultId(vaultId); + await vaultManager.withVaults([vaultId], async (vault) => { + await vault.writeF(async (efs) => { + for (const file of fileNames) { + await efs.writeFile(file, file); + } + }); + }); + + const inputGen = async function* (): AsyncGenerator< + VaultNamesHeaderMessageTagged | SecretIdentifierMessageTagged, + void, + void + > { + // Header message + yield { + type: 'VaultNamesHeaderMessage', + vaultNames: [vaultIdEncoded], + }; + // Content messages + for (const file of fileNames) { + yield { + type: 'SecretIdentifierMessage', + nameOrId: vaultIdEncoded, + secretName: file, + }; + } + }; + + // Instantiate the handler + let logicalStepsCounter = 0; + const handler = new VaultsSecretsTouch({ + db: db, + vaultManager: vaultManager, + }); + + // Create a dummy context object to be used for cancellation + const abortController = new AbortController(); + const ctx = { signal: abortController.signal } as ContextTimed; + + // The `cancel` and `meta` aren't being used here, so dummy values can be + // passed. + const result = handler.handle(inputGen(), () => {}, {}, ctx); + + // Create a promise which consumes data from the handler and advances the + // logical step counter. If the count matches a randomly selected value, + // then abort the handler, which would reject the promise. + const consumeP = async () => { + let aborted = false; + for await (const _ of result) { + // If we have already aborted, then the handler should not be sending + // any further information. + if (aborted) { + fail('The handler should not continue after cancellation'); + } + // If we are on a logical step that matches what we have to abort on, + // then send an abort signal. Next loop should throw an error. + if (logicalStepsCounter === maxLogicalSteps) { + abortController.abort(cancelMessage); + aborted = true; + } + logicalStepsCounter++; + } + }; + await expect(consumeP()).rejects.toThrow(cancelMessage); + }, + ); +}); describe('vaultsVersion', () => { const logger = new Logger('vaultsVersion test', LogLevel.WARN, [ new StreamHandler( diff --git a/tests/utils/fastCheck.ts b/tests/utils/fastCheck.ts index 21b409606..c2b6168db 100644 --- a/tests/utils/fastCheck.ts +++ b/tests/utils/fastCheck.ts @@ -32,7 +32,7 @@ const scheduleCall = (s: fc.Scheduler, f: () => Promise) => const fileNameArb = () => fc .stringMatching(/^[^<>.:"/\\|?* ]{2,10}$/) - .filter((name) => name.trim().length > 0) // Filter out all-space values + .filter((name) => name.trim().length > 0 && name !== '__proto__') .noShrink(); /** diff --git a/tests/vaults/VaultOps/touchSecret.test.ts b/tests/vaults/VaultOps/touchSecret.test.ts new file mode 100644 index 000000000..79d3c8c69 --- /dev/null +++ b/tests/vaults/VaultOps/touchSecret.test.ts @@ -0,0 +1,137 @@ +import type { VaultId } from '@/vaults/types'; +import type { Vault } from '@/vaults/Vault'; +import type KeyRing from '@/keys/KeyRing'; +import type { LevelPath } from '@matrixai/db'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import { EncryptedFS } from 'encryptedfs'; +import Logger, { LogLevel, StreamHandler } from '@matrixai/logger'; +import { DB } from '@matrixai/db'; +import VaultInternal from '@/vaults/VaultInternal'; +import * as vaultOps from '@/vaults/VaultOps'; +import * as vaultsErrors from '@/vaults/errors'; +import * as vaultsUtils from '@/vaults/utils'; +import * as keysUtils from '@/keys/utils'; +import * as testNodesUtils from '../../nodes/utils'; +import * as testVaultsUtils from '../utils'; + +describe('touchSecret', () => { + const logger = new Logger('VaultOps', LogLevel.WARN, [new StreamHandler()]); + + const secretName = 'secret'; + const secretContent = 'secret-content'; + const dirName = 'dir'; + + let dataDir: string; + let baseEfs: EncryptedFS; + let vaultId: VaultId; + let vaultInternal: VaultInternal; + let vault: Vault; + let db: DB; + let vaultsDbPath: LevelPath; + const vaultIdGenerator = vaultsUtils.createVaultIdGenerator(); + const dummyKeyRing = { + getNodeId: () => { + return testNodesUtils.generateRandomNodeId(); + }, + } as KeyRing; + + beforeEach(async () => { + dataDir = await fs.promises.mkdtemp( + path.join(os.tmpdir(), 'polykey-test-'), + ); + const dbPath = path.join(dataDir, 'efsDb'); + const dbKey = keysUtils.generateKey(); + baseEfs = await EncryptedFS.createEncryptedFS({ dbKey, dbPath, logger }); + await baseEfs.start(); + + vaultId = vaultIdGenerator(); + await baseEfs.mkdir( + path.join(vaultsUtils.encodeVaultId(vaultId), 'contents'), + { recursive: true }, + ); + db = await DB.createDB({ + dbPath: path.join(dataDir, 'db'), + logger: logger, + }); + vaultsDbPath = ['vaults']; + vaultInternal = await VaultInternal.createVaultInternal({ + keyRing: dummyKeyRing, + vaultId: vaultId, + efs: baseEfs, + logger: logger.getChild(VaultInternal.name), + fresh: true, + db: db, + vaultsDbPath: vaultsDbPath, + vaultName: 'VaultName', + }); + vault = vaultInternal as Vault; + }); + afterEach(async () => { + await vaultInternal.stop(); + await vaultInternal.destroy(); + await db.stop(); + await db.destroy(); + await baseEfs.stop(); + await baseEfs.destroy(); + await fs.promises.rm(dataDir, { + force: true, + recursive: true, + }); + }); + + test('creating a secret', async () => { + await vaultOps.touchSecret(vault, secretName); + await testVaultsUtils.expectSecret(vault, secretName); + }); + test('creating a secret in a directory', async () => { + const secretPath = path.join(dirName, secretName); + await testVaultsUtils.mkdir(vault, dirName); + await vaultOps.touchSecret(vault, secretPath); + await testVaultsUtils.expectDirExists(vault, dirName); + await testVaultsUtils.expectSecret(vault, secretPath); + }); + test('fails without parent directory', async () => { + const secretPath = path.join(dirName, secretName); + await expect(vaultOps.touchSecret(vault, secretPath)).rejects.toThrow( + vaultsErrors.ErrorSecretsSecretUndefined, + ); + await testVaultsUtils.expectDirExistsNot(vault, dirName); + await testVaultsUtils.expectSecretNot(vault, secretPath); + }); + test('touching an existing file should update its mtime', async () => { + await testVaultsUtils.writeSecret(vault, secretName, secretContent); + const oldMtime = ( + await vault.readF(async (efs) => await efs.stat(secretName)) + ).mtime; + const startTime = new Date(); + await vaultOps.touchSecret(vault, secretName); + const endTime = new Date(); + await vault.readF(async (efs) => + expect((await efs.readFile(secretName)).toString()).toEqual( + secretContent, + ), + ); + const stat = await vault.readF(async (efs) => await efs.stat(secretName)); + expect( + stat.mtime >= startTime && + stat.mtime <= endTime && + stat.mtime !== oldMtime, + ).toBeTruthy(); + }); + test('touching a directory should update its mtime', async () => { + await testVaultsUtils.mkdir(vault, dirName); + const oldMtime = (await vault.readF(async (efs) => await efs.stat(dirName))) + .mtime; + const startTime = new Date(); + await vaultOps.touchSecret(vault, dirName); + const endTime = new Date(); + const stat = await vault.readF(async (efs) => await efs.stat(dirName)); + expect( + stat.mtime >= startTime && + stat.mtime <= endTime && + stat.mtime !== oldMtime, + ).toBeTruthy(); + }); +}); diff --git a/tests/vaults/utils.ts b/tests/vaults/utils.ts index 30b210034..717c8215d 100644 --- a/tests/vaults/utils.ts +++ b/tests/vaults/utils.ts @@ -74,12 +74,14 @@ async function readSecret(vault: Vault, path: string) { async function expectSecret( vault: Vault, path: string, - contentsExpected: string, + contentsExpected?: string, ) { const contentsSecretP = readSecret(vault, path); await expect(contentsSecretP).resolves.toBeDefined(); - const contentsSecretValue = (await contentsSecretP).toString(); - expect(contentsSecretValue).toBe(contentsExpected); + if (contentsExpected != null) { + const contentsSecretValue = (await contentsSecretP).toString(); + expect(contentsSecretValue).toBe(contentsExpected); + } } async function expectSecretNot(vault: Vault, path: string) {