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
5 changes: 5 additions & 0 deletions .bumpy/proton-pass-batch-reads.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@varlock/proton-pass-plugin": patch
---

batch proton pass reads to reduce repeated prompts and add regression tests
116 changes: 113 additions & 3 deletions packages/plugins/proton-pass/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ function extractJsonFieldValue(
}

class ProtonPassPluginInstance {
private static readonly BATCH_READ_TIMEOUT_MS = 50;

private username?: string;
private password?: string;
private totp?: string;
Expand All @@ -98,6 +100,12 @@ class ProtonPassPluginInstance {
// Cache decrypted values for the current resolution session.
private cache = new Map<string, string>();

// Batch pending secret reads to reduce repeated interactive prompts.
private readBatch: Record<string, { deferredPromises: Array<{
resolve: (v: string) => void;
reject: (e: unknown) => void;
}> }> | undefined;

// Login batching / deduping for parallel resolutions.
private loginInFlight: Promise<void> | undefined;

Expand Down Expand Up @@ -220,7 +228,7 @@ class ProtonPassPluginInstance {
}
}

async getSecret(secretRef: string): Promise<string> {
private async getSecretDirect(secretRef: string): Promise<string> {
const cached = this.cache.get(secretRef);
if (cached !== undefined) return cached;

Expand Down Expand Up @@ -262,6 +270,110 @@ class ProtonPassPluginInstance {
return plain;
}
}

private async executeReadBatch(
batchToExecute: NonNullable<ProtonPassPluginInstance['readBatch']>,
): Promise<void> {
const batchSecretRefs = Object.keys(batchToExecute);
debug('executing proton pass batch read', batchSecretRefs);
try {
await this.ensureCliLoggedIn();

const envMap: Record<string, string> = {};
let i = 1;
for (const secretRef of batchSecretRefs) {
envMap[`VARLOCK_PROTON_PASS_INJECT_${i++}`] = secretRef;
}

const result = await spawnAsync(
'pass-cli',
['run', '--no-masking', '--', 'env', '-0'],
{
env: {
...(process.env as Record<string, string>),
...(this.loginEnv || {}),
...envMap,
},
},
);

const unresolvedRefs = new Set(batchSecretRefs);
const lines = result.split('\0');
for (const line of lines) {
const eqPos = line.indexOf('=');
if (eqPos <= 0) continue;
const key = line.substring(0, eqPos);
const secretRef = envMap[key];
if (!secretRef) continue;

const val = line.substring(eqPos + 1);
unresolvedRefs.delete(secretRef);
this.cache.set(secretRef, val);
batchToExecute[secretRef].deferredPromises.forEach((p) => p.resolve(val));
}

// Any unresolved refs are retried individually to preserve useful per-secret errors.
if (unresolvedRefs.size) {
debug('batch did not resolve all refs, retrying direct reads', [...unresolvedRefs]);
await Promise.all([...unresolvedRefs].map(async (secretRef) => {
try {
const val = await this.getSecretDirect(secretRef);
batchToExecute[secretRef].deferredPromises.forEach((p) => p.resolve(val));
} catch (err) {
batchToExecute[secretRef].deferredPromises.forEach((p) => p.reject(err));
}
}));
}
} catch (err) {
// Retry each ref individually on batch failure so allowMissing + per-ref errors still work.
debug('proton pass batch read failed, retrying per-ref', err instanceof Error ? err.message : String(err));
await Promise.all(batchSecretRefs.map(async (secretRef) => {
try {
const val = await this.getSecretDirect(secretRef);
batchToExecute[secretRef].deferredPromises.forEach((p) => p.resolve(val));
} catch (refErr) {
batchToExecute[secretRef].deferredPromises.forEach((p) => p.reject(refErr));
}
}));
}
}

async getSecret(secretRef: string): Promise<string> {
const cached = this.cache.get(secretRef);
if (cached !== undefined) return cached;

let shouldExecuteBatch = false;
if (!this.readBatch) {
this.readBatch = {};
shouldExecuteBatch = true;
}
this.readBatch[secretRef] ||= { deferredPromises: [] };

const deferred = {} as {
promise: Promise<string>;
resolve: (value: string) => void;
reject: (error: unknown) => void;
};
deferred.promise = new Promise<string>((resolve, reject) => {
deferred.resolve = resolve;
deferred.reject = reject;
});
this.readBatch[secretRef].deferredPromises.push({
resolve: deferred.resolve,
reject: deferred.reject,
});

if (shouldExecuteBatch) {
setTimeout(async () => {
if (!this.readBatch) return;
const batchToExecute = this.readBatch;
this.readBatch = undefined;
await this.executeReadBatch(batchToExecute);
}, ProtonPassPluginInstance.BATCH_READ_TIMEOUT_MS);
}

return deferred.promise;
}
}

const pluginInstances: Record<string, ProtonPassPluginInstance> = {};
Expand Down Expand Up @@ -487,5 +599,3 @@ plugin.registerResolverFunction({
}
},
});


116 changes: 116 additions & 0 deletions packages/plugins/proton-pass/test/fake-pass-cli.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
#!/usr/bin/env node
/* eslint-disable @typescript-eslint/no-require-imports */

const fs = require('node:fs');
const path = require('node:path');

function loadJson(filePath, fallback) {
try {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
} catch {
return fallback;
}
}

function writeJson(filePath, value) {
fs.writeFileSync(filePath, JSON.stringify(value), 'utf8');
}

function appendLog(logPath, args) {
if (!logPath) return;
fs.appendFileSync(logPath, `${JSON.stringify(args)}\n`, 'utf8');
}

function fail(message, code = 1) {
process.stderr.write(`${message}\n`);
process.exit(code);
}

const mockDir = process.env.FAKE_PASS_CLI_DIR;
if (!mockDir) fail('fake pass-cli misconfigured: FAKE_PASS_CLI_DIR is required');

const configPath = path.join(mockDir, 'config.json');
const statePath = path.join(mockDir, 'state.json');
const logPath = path.join(mockDir, 'calls.log');

const config = loadJson(configPath, {});
const state = loadJson(statePath, { loggedIn: false });
const args = process.argv.slice(2);

appendLog(logPath, args);

const ensureLoggedIn = () => {
if (!state.loggedIn) {
fail(config.notLoggedMessage || 'not authenticated');
}
};

const resolveRefsInText = (val) => {
if (typeof val !== 'string') return val;
return val.replace(/pass:\/\/[^\s"'`]+/g, (ref) => {
if (config.refs && Object.prototype.hasOwnProperty.call(config.refs, ref)) {
return String(config.refs[ref]);
}
if (config.missingRefs && config.missingRefs.includes(ref)) {
throw new Error(config.notFoundMessage || `secret not found: ${ref}`);
}
throw new Error(config.notFoundMessage || `secret not found: ${ref}`);
});
};

if (!args.length) fail('missing command');

if (args[0] === 'info') {
if (state.loggedIn) process.exit(0);
fail(config.notLoggedMessage || 'not authenticated');
}

if (args[0] === 'login') {
const needsPassword = config.requirePassword !== false;
if (needsPassword && !process.env.PROTON_PASS_PASSWORD) {
fail('password missing');
}
state.loggedIn = true;
writeJson(statePath, state);
process.exit(0);
}

if (args[0] === 'run') {
ensureLoggedIn();
if (config.runErrorMessage) fail(config.runErrorMessage);

const runSkipRefs = new Set(config.runSkipRefs || []);
const outputPairs = [];
for (const [key, rawVal] of Object.entries(process.env)) {
if (!key.startsWith('VARLOCK_PROTON_PASS_INJECT_')) continue;
if (runSkipRefs.has(rawVal)) continue;
try {
const resolved = resolveRefsInText(rawVal);
outputPairs.push(`${key}=${resolved}`);
} catch (err) {
fail((err && err.message) || 'run resolution failed');
}
}

process.stdout.write(outputPairs.join('\0'));
if (outputPairs.length) process.stdout.write('\0');
process.exit(0);
}

if (args[0] === 'item' && args[1] === 'view') {
ensureLoggedIn();
const secretRef = args[args.length - 1];
const parts = String(secretRef).replace(/^pass:\/\//, '').split('/');
const fieldName = parts[parts.length - 1] || 'value';
const itemErrors = config.itemViewErrors || {};
if (itemErrors[secretRef]) fail(itemErrors[secretRef]);

if (config.refs && Object.prototype.hasOwnProperty.call(config.refs, secretRef)) {
process.stdout.write(JSON.stringify({ [fieldName]: String(config.refs[secretRef]) }));
process.exit(0);
}

fail(config.notFoundMessage || `secret not found: ${secretRef}`);
}

fail(`unsupported fake pass-cli command: ${args.join(' ')}`);
Loading
Loading