Merge branch 'v3' into v3-t17

This commit is contained in:
2026-11-06 22:19:53 +00:00
171 changed files with 33754 additions and 481 deletions
Generated
+555 -44
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -33,7 +33,26 @@ wasmtime-wasi-http = "=49.0.3"
serde = { version = "1.0.229", features = ["derive"] }
serde_yaml_ng = "0.10.0"
rustls = "0.23"
chacha20poly1305 = "0.10"
zeroize = "1"
hex = "0.4"
http = "1"
url = "2"
thiserror = "2"
base64 = "0.22"
hmac = "0.12"
sha2 = "0.10"
p256 = { version = "0.13", features = ["ecdh", "ecdsa", "pem"] }
ed25519-dalek = { version = "2", features = ["pkcs8", "pem"] }
rsa = { version = "0.9", features = ["sha2"] }
aes-gcm = "0.10"
hkdf = "0.12"
ssh-key = { version = "0.6.7", features = ["encryption", "ed25519", "ecdsa", "rsa", "p256"] }
uuid = { version = "1.29.0", features = ["v4"] }
webauthn-rs = "0.5"
openssl = { version = "0.10.82", features = ["vendored"] }
[dev-dependencies]
tempfile = "3"
wat = "1"
webauthn-authenticator-rs = { version = "0.5", features = ["softpasskey"] }
+16 -2
View File
@@ -35,6 +35,20 @@ For Nix, use a separate npm derivation with `importNpmLock` and the checked-in l
Run the bundle script there. Pass its output to the Rust source derivation before Crane builds the host.
The Rust build must not execute npm or download JavaScript packages.
The console reads `PI_PROXY_BASE_URL` and `PI_PROXY_API_KEY` from the host environment.
It selects `openai/gpt-6.1-sol` with low reasoning effort.
The console reads the `llm-proxy` credentials item through Rust.
Its public `base_url` field contains the proxy URL. Its secret `api_key` field contains the token.
If the item does not exist, Rust creates it from `PI_PROXY_BASE_URL` and `PI_PROXY_API_KEY` once.
Rust encrypts the token before it commits the item. Later starts ignore these environment variables.
The token enters JavaScript only through an operation. No startup script, transcript, or world file stores it.
The console selects `openai/gpt-6.1-sol` with low reasoning effort.
This model rejects disabled reasoning. Tests use the faux provider without credentials.
Credential requests own background pi-durable tasks in their requesting conversations.
Filed notifications create tasks. Startup reconciliation also creates missing tasks, including requests decided during a host stop.
A conversation document records each request id in the same commit that creates its task.
Program requests create no task. A pending task sleeps durably and checks the decision each second.
The harness also expires requests during these checks.
A decided task admits one plain-language input and completes in one SQLite commit.
The input starts a new turn when the conversation is idle. Neither the input nor the task contains a secret.
The task and its input survive SIGKILL. The input uses a stable request id to prevent duplicate admission.
Hosts with a shared credentials handle can use `run_module_with_credentials`.
+102 -42
View File
@@ -33805,9 +33805,9 @@ async function createAgent(tx, conversation) {
function agentHooks(agent, taskName) {
const handlers = [];
for (const extension of agent.extensions) {
for (const hook of extension.hooks ?? []) {
if (hook.task === taskName)
handlers.push({ extension: extension.name, handlers: hook.handlers });
for (const hook2 of extension.hooks ?? []) {
if (hook2.task === taskName)
handlers.push({ extension: extension.name, handlers: hook2.handlers });
}
}
return handlers;
@@ -33815,7 +33815,7 @@ function agentHooks(agent, taskName) {
function sectionExtension(agent, key) {
let found;
for (const extension of agent.extensions) {
const provides = extension.sections?.some((section) => section.key === key) === true;
const provides = extension.sections?.some((section2) => section2.key === key) === true;
const wraps = extension.wraps?.some((wrap) => "section" in wrap && wrap.section === key) === true;
if (provides || wraps)
found = extension.name;
@@ -33840,8 +33840,8 @@ function resolveAgent(state2, snapshot, settings, report2) {
composed.set(tool.name, tool);
const sections = /* @__PURE__ */ new Map();
for (const extension of extensions) {
for (const section of extension.sections ?? [])
sections.set(section.key, section);
for (const section2 of extension.sections ?? [])
sections.set(section2.key, section2);
}
for (const extension of extensions) {
for (const wrap of extension.wraps ?? []) {
@@ -33849,7 +33849,7 @@ function resolveAgent(state2, snapshot, settings, report2) {
if ("tool" in wrap)
applyWrap(composed, wrap.tool, (tool) => wrap.wrap(tool), (tool) => tool.name, wrapReport);
else
applyWrap(sections, wrap.section, (section) => wrap.wrap(section), (section) => section.key, wrapReport);
applyWrap(sections, wrap.section, (section2) => wrap.wrap(section2), (section2) => section2.key, wrapReport);
}
}
const enabled = filterTools([...composed.values()], state2?.tools);
@@ -34685,24 +34685,24 @@ function replaySections(messages) {
}
async function renderSections(sections, input, shown, report2, context) {
const desired = /* @__PURE__ */ new Map();
for (const section of sections) {
for (const section2 of sections) {
let text2;
try {
text2 = await section.render(input, context);
text2 = await section2.render(input, context);
} catch (error) {
if (context.abortSignal?.aborted)
throw error;
report2(error, section);
const kept = shown.get(section.key);
report2(error, section2);
const kept = shown.get(section2.key);
if (kept !== void 0)
desired.set(section.key, kept);
desired.set(section2.key, kept);
continue;
}
if (text2 === void 0)
continue;
desired.set(section.key, section.tag === false ? text2 : `<${section.key}>
desired.set(section2.key, section2.tag === false ? text2 : `<${section2.key}>
${text2}
</${section.key}>`);
</${section2.key}>`);
}
return desired;
}
@@ -38706,11 +38706,11 @@ var ToolTask = defineTask({
return reject(runtime, task.input, call, tool, invalid(checked.error), context);
let args = checked.args;
let block;
await runtime.hooks.each("beforeTool", async (hook) => {
await runtime.hooks.each("beforeTool", async (hook2) => {
if (block !== void 0)
return;
try {
const decision = await hook({ ...call, arguments: args }, runtime, context);
const decision = await hook2({ ...call, arguments: args }, runtime, context);
if (decision?.block !== void 0)
block = decision.block;
else if (decision?.arguments !== void 0)
@@ -39052,8 +39052,8 @@ async function finalResult(runtime, input, call, tool, result, reported, context
details: result.details === void 0 ? reported.details : result.details,
diagnostics: [...reported.diagnostics, ...result.diagnostics ?? []]
};
await runtime.hooks.each("afterTool", async (hook) => {
final = await hook(call, final, runtime, context) ?? final;
await runtime.hooks.each("afterTool", async (hook2) => {
final = await hook2(call, final, runtime, context) ?? final;
});
if (retained !== void 0 && retained.droppedBytes > 0 && final.output === output) {
harness2.push(truncated(retained, reported.limits.retain));
@@ -39085,8 +39085,8 @@ async function finalResult(runtime, input, call, tool, result, reported, context
}
async function afterHarnessResult(runtime, input, call, tool, result, truncation, context) {
let final = result;
await runtime.hooks.each("afterTool", async (hook) => {
final = await hook(call, final, runtime, context) ?? final;
await runtime.hooks.each("afterTool", async (hook2) => {
final = await hook2(call, final, runtime, context) ?? final;
});
const harness2 = [];
if (truncation !== void 0 && final.output === result.output)
@@ -39406,9 +39406,9 @@ var GenerationTask = defineTask({
runtime.report(error, { kind: "host", callback: "env" });
}
const input = { conversationId, agent, env, shown: Object.fromEntries(shown), read: runtime };
const desired = await renderSections(agent.sections, input, shown, (error, section) => runtime.report(error, {
const desired = await renderSections(agent.sections, input, shown, (error, section2) => runtime.report(error, {
kind: "extension",
...optionalExtension(sectionExtension(agent, section.key))
...optionalExtension(sectionExtension(agent, section2.key))
}), context);
const entries2 = planSystemEntries(view2, desired, agent.tools, runtime.now());
const threshold = compacted === void 0 ? thresholdCompaction(view2, entries2, resolved.contextWindow, compactionPolicy(settings, model3)) : void 0;
@@ -39456,8 +39456,8 @@ var GenerationTask = defineTask({
return failNoModel(runtime, ref, context);
const view2 = await runtime.context(conversationId, context, { at: cutoff });
let messages = view2.messages;
await runtime.hooks.each("beforeRequest", async (hook) => {
const replaced = await hook({ messages }, runtime, context);
await runtime.hooks.each("beforeRequest", async (hook2) => {
const replaced = await hook2({ messages }, runtime, context);
if (replaced !== void 0)
messages = replaced.messages;
});
@@ -39545,8 +39545,8 @@ var GenerationTask = defineTask({
if (cutOffTooOften(task, runtime))
return endAnsweredCutOff(runtime, task, run2.answer, context);
const prepared = [];
await runtime.hooks.each("afterRun", async (hook) => {
const asked = await hook(run2, runtime, context);
await runtime.hooks.each("afterRun", async (hook2) => {
const asked = await hook2(run2, runtime, context);
if (asked !== void 0)
prepareRunEndTasks(runtime, asked.tasks, prepared);
});
@@ -39754,8 +39754,8 @@ async function convertPartial(tx, live, conversationId) {
}
async function providerPayload(runtime, model3, payload, context) {
let current = payload;
await runtime.hooks.each("beforeProviderRequest", async (hook) => {
const replaced = await hook({ model: model3, payload: current }, runtime, context);
await runtime.hooks.each("beforeProviderRequest", async (hook2) => {
const replaced = await hook2({ model: model3, payload: current }, runtime, context);
if (replaced !== void 0)
current = replaced.payload;
});
@@ -39839,7 +39839,7 @@ async function classify2(runtime, request2, message, context) {
}, context);
return;
}
await runtime.hooks.each("afterResponse", (hook) => hook(message, runtime, context));
await runtime.hooks.each("afterResponse", (hook2) => hook2(message, runtime, context));
const calls = message.content.filter((content) => content.type === "toolCall");
if (message.stopReason === "toolUse" && calls.length > 0) {
return startToolRound(runtime, request2, message, calls, context);
@@ -39892,10 +39892,10 @@ async function classify2(runtime, request2, message, context) {
}
async function answer(runtime, message, context) {
let continuation;
await runtime.hooks.each("onYield", async (hook) => {
await runtime.hooks.each("onYield", async (hook2) => {
if (continuation !== void 0)
return;
continuation = (await hook(message, runtime, context))?.continue;
continuation = (await hook2(message, runtime, context))?.continue;
});
const afterRun = await asksAfterRun(runtime, context);
const conversationId = runtime.conversationId;
@@ -40038,7 +40038,7 @@ async function finishToolRound(runtime, assistant, tools, context) {
});
const slots = (await runtime.snapshot(LiveDoc, conversationId, context))?.tools ?? [];
const results = slots.flatMap((slot) => slot.entry === void 0 ? [] : [slot.entry]);
await runtime.hooks.each("afterTools", (hook) => hook(assistant, results, runtime, context));
await runtime.hooks.each("afterTools", (hook2) => hook2(assistant, results, runtime, context));
const ordered = slots.map((slot) => slot.taskId === void 0 ? void 0 : controls.get(slot.taskId));
const terminate = slots.length > 0 && ordered.every((control) => control?.terminate === true);
const added = ordered.flatMap((control) => control?.addTools ?? []);
@@ -40583,9 +40583,9 @@ var CompactionTask = defineTask({
...instructions === void 0 ? {} : { instructions }
};
let decision;
await runtime.hooks.each("beforeCompact", async (hook) => {
await runtime.hooks.each("beforeCompact", async (hook2) => {
if (decision === void 0)
decision = await hook(compaction, runtime, context);
decision = await hook2(compaction, runtime, context);
});
if (decision !== void 0 && "decline" in decision)
return complete(runtime, context);
@@ -40919,7 +40919,7 @@ var RegistryState = class {
return this.#extensions.flatMap((extension) => (extension.tools ?? []).map((tool) => ({ extension, tool })));
}
sections() {
return this.#extensions.flatMap((extension) => (extension.sections ?? []).map((section) => ({ extension, section })));
return this.#extensions.flatMap((extension) => (extension.sections ?? []).map((section2) => ({ extension, section: section2 })));
}
tasks() {
return [...this.#tasks.values()];
@@ -44424,10 +44424,67 @@ function environment(cwd = "/", conversation) {
return env;
}
// main.ts
if (!process.env.PI_PROXY_BASE_URL || !process.env.PI_PROXY_API_KEY) {
throw new Error("PI_PROXY_BASE_URL and PI_PROXY_API_KEY are required");
// credentials.ts
var Requests = defineDoc({
kind: "sausage.credential.requests",
version: 1,
scope: "conversation",
history: "latest",
fork: "initial",
initial: () => ({ ids: [] }),
checkpointWhen: () => false
});
var Wait = defineTask({
name: "sausage.credential.wait",
version: 1,
initial: () => ({ phase: "wait" }),
phases: {
wait: async (task, runtime, context) => {
const request2 = Deno.core.ops.op_credential_requests().find((r) => r.id === task.input.id);
if (!request2 || request2.state === "pending") {
await runtime.sleepUntil(Date.now() + 1e3, { phase: "wait" }, context);
return;
}
const conversation = await runtime.conversation(runtime.conversationId, context);
await runtime.commit(async (tx) => {
await conversation.submit(tx, { type: "input", content: request2.message, requestId: `credential:${request2.id}` });
return { status: "terminal", outcome: { status: "completed", result: null } };
}, context);
}
},
abort: (_task, runtime, context) => runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted" } }), context)
});
var CredentialRequests = defineExtension({ name: "sausage.credentials", tasks: [Wait] });
async function discoverRequests(harness2, context) {
for (const request2 of Deno.core.ops.op_credential_requests()) {
const conversation = await harness2.conversation(Number(request2.conversation), context);
if (!conversation) continue;
await conversation.commit(async (tx) => {
const recorded = await tx.doc(Requests, conversation.id);
if (recorded.ids.includes(request2.id)) return;
await tx.createTask(Wait, { id: request2.id }, { ownership: { kind: "conversation" }, background: true });
recorded.ids.push(request2.id);
}, context);
}
}
async function startRequests(harness2, context) {
await discoverRequests(harness2, context);
let active = true;
const pending = (async () => {
while (active) {
await Deno.core.ops.op_credential_next();
if (active) await discoverRequests(harness2, context);
}
})();
return async () => {
active = false;
await pending;
};
}
// main.ts
var proxy = Deno.core.ops.op_model_proxy();
if (!proxy?.base_url || !proxy?.api_key) throw new Error("The llm-proxy credential is required");
var models = createModels();
var provider2 = openaiProvider();
var model2 = {
@@ -44435,7 +44492,7 @@ var model2 = {
name: "gpt-6.1-sol",
provider: "openai",
api: "openai-responses",
baseUrl: `${process.env.PI_PROXY_BASE_URL}/v1`,
baseUrl: `${proxy.base_url}/v1`,
reasoning: true,
input: ["text"],
thinkingLevelMap: { off: null, minimal: null },
@@ -44449,21 +44506,22 @@ models.setProvider({
getModels: () => [model2],
auth: { apiKey: { name: "Sausage proxy", resolve: async () => ({
auth: {
apiKey: process.env.PI_PROXY_API_KEY,
apiKey: proxy.api_key,
baseUrl: model2.baseUrl,
headers: { "Shopify-Usage-Tag": JSON.stringify(["pi", "sausage"]) }
},
source: "PI_PROXY_API_KEY"
source: "llm-proxy"
}) } }
});
var registry = createRegistry();
registry.install(CodingTools);
registry.install(CredentialRequests);
var harness = await Harness.open(await openStorage(globalThis.storagePath), {
models,
registry,
env: (target) => environment(target.cwd ?? "/", target.conversationId)
}, BACKGROUND_CONTEXT);
var root = await harness.root(BACKGROUND_CONTEXT, { agent: { model: { provider: "openai", modelId: model2.id }, thinkingLevel: "low", cwd: "/", extensions: [CodingTools] } });
var root = await harness.root(BACKGROUND_CONTEXT, { agent: { model: { provider: "openai", modelId: model2.id }, thinkingLevel: "low", cwd: "/", extensions: [CodingTools, CredentialRequests] } });
var watch = await root.watch(BACKGROUND_CONTEXT);
var emitted = /* @__PURE__ */ new Map();
var partial = "";
@@ -44488,6 +44546,7 @@ for (const entry2 of watch.value.entries) {
watch.start((event) => {
if (event.kind === "value") render(event.value);
});
var stopRequests = await startRequests(harness, BACKGROUND_CONTEXT);
harness.resume();
await harness.waitForIdle(BACKGROUND_CONTEXT);
while (true) {
@@ -44503,5 +44562,6 @@ while (true) {
Deno.core.ops.op_output("\n");
if (result.status !== "done") throw new Error(`Run ended: ${JSON.stringify(result)}`);
}
await stopRequests();
await watch.stop();
await harness.close(BACKGROUND_CONTEXT);
+49
View File
@@ -0,0 +1,49 @@
// Durable, background credential waits. Admission and completion share one SQLite commit.
import { defineDoc, defineTask, defineExtension } from '@earendil-works/pi-durable';
const Requests = defineDoc<{ ids: string[] }>({
kind: 'sausage.credential.requests', version: 1, scope: 'conversation',
history: 'latest', fork: 'initial', initial: () => ({ ids: [] }), checkpointWhen: () => false,
});
const Wait = defineTask<{ id: string }, { phase: 'wait' }, null>({
name: 'sausage.credential.wait', version: 1, initial: () => ({ phase: 'wait' }),
phases: {
wait: async (task, runtime, context) => {
const request = Deno.core.ops.op_credential_requests().find((r: any) => r.id === task.input.id);
if (!request || request.state === 'pending') {
await runtime.sleepUntil(Date.now() + 1000, { phase: 'wait' }, context);
return;
}
const conversation = (await runtime.conversation(runtime.conversationId, context))!;
await runtime.commit(async (tx) => {
await conversation.submit(tx, { type: 'input', content: request.message, requestId: `credential:${request.id}` });
return { status: 'terminal', outcome: { status: 'completed', result: null } };
}, context);
},
},
abort: (_task, runtime, context) => runtime.commit(() => ({ status: 'terminal', outcome: { status: 'aborted' } }), context),
});
export const CredentialRequests = defineExtension({ name: 'sausage.credentials', tasks: [Wait] });
export async function discoverRequests(harness: any, context: any) {
for (const request of Deno.core.ops.op_credential_requests()) {
const conversation = await harness.conversation(Number(request.conversation), context);
if (!conversation) continue;
await conversation.commit(async (tx: any) => {
const recorded = await tx.doc(Requests, conversation.id);
if (recorded.ids.includes(request.id)) return;
await tx.createTask(Wait, { id: request.id }, { ownership: { kind: 'conversation' }, background: true });
recorded.ids.push(request.id);
}, context);
}
}
export async function startRequests(harness: any, context: any) {
await discoverRequests(harness, context);
// Filed notifications start waits. The deadline reconciles other handles and lost notifications.
let active = true;
const pending = (async () => {
while (active) {
await Deno.core.ops.op_credential_next();
if (active) await discoverRequests(harness, context);
}
})();
return async () => { active = false; await pending; };
}
File diff suppressed because it is too large Load Diff
+47
View File
@@ -0,0 +1,47 @@
// Faux-model acceptance probe for credential wake-ups, including host restart.
import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context';
import { createModels } from '@earendil-works/pi-ai/models';
import { fauxProvider, fauxAssistantMessage, fauxText } from '@earendil-works/pi-ai/providers/faux';
import { Harness, createRegistry } from '@earendil-works/pi-durable';
import { openStorage } from './sqlite';
import { CredentialRequests, startRequests } from './credentials';
const models = createModels();
const faux = fauxProvider({ tokensPerSecond: 100000 });
models.setProvider(faux.provider);
faux.setResponses(Array.from({ length: 8 }, () => fauxAssistantMessage([fauxText('done')])));
const registry = createRegistry();
registry.install(CredentialRequests);
const harness = await Harness.open(await openStorage(globalThis.storagePath), { models, registry }, context);
const root = await harness.root(context, { agent: { model: { provider: 'faux', modelId: 'faux-1' }, extensions: [CredentialRequests] } });
await (await root.submit({ type: 'input', content: 'initial', requestId: 'initial' }, context)).wait(context);
if (globalThis.proxyOnly) {
await harness.close(context);
} else {
await Deno.core.ops.op_file('write', '/conversation', Array.from(new TextEncoder().encode(root.id)));
const stop = await startRequests(harness, context);
harness.resume();
const watch = await root.watch(context);
await new Promise<void>((resolve, reject) => {
const check = (event: any) => {
if (event.kind !== 'value') return;
const messages = event.value.entries.filter((e: any) => JSON.stringify(e).includes('Your request for'));
if (messages.length > 1) reject(new Error('Duplicate credential message'));
if (messages.length === 1) {
const message = JSON.stringify(messages);
if (!message.includes('`github`') || !message.includes(globalThis.expectedState)) reject(new Error('Wrong credential decision message'));
else resolve();
}
};
watch.start(check);
check({ kind: 'value', value: watch.value });
});
await harness.waitForIdle(context);
const final = await root.watch(context);
if (final.value.entries.filter((entry: any) => entry.kind === 'pi.assistant').length !== 2) {
throw new Error('The decision must start exactly one model turn');
}
await final.stop();
await stop();
await watch.stop();
await harness.close(context);
}
+10 -7
View File
@@ -6,30 +6,31 @@ import { Harness, createRegistry } from '@earendil-works/pi-durable';
import { CodingTools } from '@earendil-works/pi-durable/tools';
import { openStorage } from './sqlite';
import { environment } from './environment';
if (!process.env.PI_PROXY_BASE_URL || !process.env.PI_PROXY_API_KEY) {
throw new Error('PI_PROXY_BASE_URL and PI_PROXY_API_KEY are required');
}
import { CredentialRequests, startRequests } from './credentials';
const proxy = Deno.core.ops.op_model_proxy();
if (!proxy?.base_url || !proxy?.api_key) throw new Error('The llm-proxy credential is required');
const models = createModels();
const provider = openaiProvider();
const model = {
id: 'gpt-6.1-sol', name: 'gpt-6.1-sol', provider: 'openai', api: 'openai-responses',
baseUrl: `${process.env.PI_PROXY_BASE_URL}/v1`, reasoning: true, input: ['text'],
baseUrl: `${proxy.base_url}/v1`, reasoning: true, input: ['text'],
thinkingLevelMap: { off: null, minimal: null },
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 200000, maxTokens: 4096,
};
models.setProvider({ ...provider, baseUrl: model.baseUrl, getModels: () => [model],
auth: { apiKey: { name: 'Sausage proxy', resolve: async () => ({
auth: { apiKey: process.env.PI_PROXY_API_KEY, baseUrl: model.baseUrl,
auth: { apiKey: proxy.api_key, baseUrl: model.baseUrl,
headers: { 'Shopify-Usage-Tag': JSON.stringify(['pi', 'sausage']) } },
source: 'PI_PROXY_API_KEY',
source: 'llm-proxy',
}) } },
});
const registry = createRegistry();
registry.install(CodingTools);
registry.install(CredentialRequests);
const harness = await Harness.open(await openStorage(globalThis.storagePath), {
models, registry, env: (target: any) => environment(target.cwd ?? '/', target.conversationId),
}, context);
const root = await harness.root(context, { agent: { model: { provider: 'openai', modelId: model.id }, thinkingLevel: 'low', cwd: '/', extensions: [CodingTools] } });
const root = await harness.root(context, { agent: { model: { provider: 'openai', modelId: model.id }, thinkingLevel: 'low', cwd: '/', extensions: [CodingTools, CredentialRequests] } });
const watch = await root.watch(context);
const emitted = new Map<number, string>();
let partial = '';
@@ -53,6 +54,7 @@ for (const entry of watch.value.entries) {
if (entry.kind === 'pi.assistant') emitted.set(entry.id, text(entry.model?.[0]));
}
watch.start((event: any) => { if (event.kind === 'value') render(event.value); });
const stopRequests = await startRequests(harness, context);
harness.resume();
await harness.waitForIdle(context);
while (true) {
@@ -68,5 +70,6 @@ while (true) {
Deno.core.ops.op_output('\n');
if (result.status !== 'done') throw new Error(`Run ended: ${JSON.stringify(result)}`);
}
await stopRequests();
await watch.stop();
await harness.close(context);
+1 -1
View File
@@ -1,7 +1,7 @@
{
"private": true,
"type": "module",
"scripts": { "bundle": "esbuild main.ts --bundle --format=esm --platform=browser --outfile=bundle.js && esbuild test.ts --bundle --format=esm --platform=browser --outfile=test-bundle.js" },
"scripts": { "bundle": "esbuild main.ts --bundle --format=esm --platform=browser --outfile=bundle.js && esbuild test.ts --bundle --format=esm --platform=browser --outfile=test-bundle.js && esbuild durable-test.ts --bundle --format=esm --platform=browser --outfile=durable-test-bundle.js" },
"dependencies": {
"@earendil-works/pi-durable": "1.5.0",
"@earendil-works/pi-ai": "1.5.0",
+75
View File
@@ -0,0 +1,75 @@
//! Metadata-only audit ledger. Do not accept arbitrary script data.
use super::{
error::Result,
store::{Cred, now},
types::Caller,
};
use rusqlite::{Connection, params};
#[derive(Debug, serde::Serialize)]
pub struct Row {
pub id: i64,
pub time: i64,
pub event: String,
pub pid: u32,
pub program: String,
pub conversation: Option<String>,
pub item: Option<String>,
pub approver: Option<String>,
pub payload_sha256: Option<String>,
}
pub(crate) fn record(
db: &Connection,
event: &str,
caller: &Caller,
item: Option<&str>,
approver: Option<&str>,
) -> Result<()> {
db.execute(
"INSERT INTO audit(time,event,pid,conversation,item,approver,program) VALUES(?1,?2,?3,?4,?5,?6,?7)",
params![
now(),
event,
caller.pid,
caller.conversations.first(),
item,
approver,
caller.program
],
)?;
Ok(())
}
impl Cred {
/// Record a completed signature. The signing pipeline supplies the caller and item.
pub fn audit_sign(&self, item: &str, caller: &Caller, payload: &[u8]) -> Result<()> {
use sha2::{Digest, Sha256};
let mut s = self.access()?;
let tx = s.db.transaction()?;
record(&tx, "sign", caller, Some(item), None)?;
tx.execute(
"UPDATE audit SET payload_sha256=?1 WHERE id=?2",
params![hex::encode(Sha256::digest(payload)), tx.last_insert_rowid()],
)?;
tx.commit()?;
Ok(())
}
pub fn audit_list(&self) -> Result<Vec<Row>> {
let s = self.access()?;
let mut q = s.db.prepare(
"SELECT id,time,event,pid,conversation,item,approver,payload_sha256,program FROM audit ORDER BY id",
)?;
Ok(q.query_map([], |r| {
Ok(Row {
id: r.get(0)?,
time: r.get(1)?,
event: r.get(2)?,
pid: r.get(3)?,
program: r.get(8)?,
conversation: r.get(4)?,
item: r.get(5)?,
approver: r.get(6)?,
payload_sha256: r.get(7)?,
})
})?
.collect::<rusqlite::Result<_>>()?)
}
}
+622
View File
@@ -0,0 +1,622 @@
//! Reviewable full-configuration edits. Approval rechecks versions in its transaction.
//! Filing checks public values. Approval checks current secrets and preserves operation order.
use super::{
audit,
error::{Error, Result},
items::{self, Item},
seal,
types::Caller,
};
use rusqlite::{Connection, OptionalExtension, params};
/// Explicit public operations. No operation accepts a secret field value.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum Operation {
CreateItem {
config: Item,
},
GenerateEs256Item {
config: Item,
},
SetName {
name: String,
},
SetRules {
rules: Vec<items::Rule>,
},
SetFreeUse {
enabled: bool,
},
SetValidateJs {
value: Option<String>,
},
SetTransformJs {
value: String,
},
AddField {
field: items::Field,
},
SetFieldValue {
field: String,
value: String,
},
SetFieldType {
field: String,
field_type: super::types::FieldType,
key_type: Option<String>,
},
RenameField {
field: String,
name: String,
},
RemoveField {
field: String,
},
DeleteItem,
ItemRevert {
version: i64,
},
GrantCreate {
grant: super::grants::Grant,
},
GrantRevoke {
target: String,
},
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct OperationRequest {
pub item: Option<String>,
pub base_version: Option<i64>,
pub change: Vec<Operation>,
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct Edit {
pub item: String,
pub base_version: i64,
pub config: Item,
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct Diff {
pub old: Item,
pub new: Item,
pub discards_values: bool,
#[serde(default)]
pub stale: bool,
}
fn check_secret(
db: &Connection,
master: &[u8; 32],
item: &str,
field: &items::Field,
) -> Result<()> {
let cipher: Option<Vec<u8>> = db
.query_row(
"SELECT ciphertext FROM fields WHERE item=?1 AND id=?2",
params![item, field.field_id],
|r| r.get(0),
)
.optional()?;
if let Some(cipher) = cipher {
let value = seal::open(master, &format!("{item}|{}", field.field_id), &cipher)?;
super::types::validate(
&field.field_type,
field.key_type.as_deref(),
false,
Some(std::str::from_utf8(&value).map_err(|_| Error("invalid_value"))?),
)?;
}
Ok(())
}
pub(crate) fn plan(db: &Connection, master: Option<&[u8; 32]>, edit: &Edit) -> Result<Diff> {
let old = items::load(db, &edit.item)?;
if old.version != edit.base_version {
return Err(Error("stale"));
}
let mut new = edit.config.clone();
new.id = old.id.clone();
new.version = old
.version
.checked_add(1)
.ok_or(Error("version_exhausted"))?;
items::validate(&new)?;
items::unique_name(db, &new.name, &old.id)?;
for f in &mut new.fields {
if let Some(prior) = old.fields.iter().find(|p| p.field_id == f.field_id) {
if prior.public != f.public {
return Err(Error("immutable_visibility"));
}
if f.public {
f.value = f.value.clone().or_else(|| prior.value.clone());
f.needs_value = f.value.is_none();
} else {
f.needs_value = prior.needs_value;
if let Some(master) = master
&& (f.field_type != prior.field_type || f.key_type != prior.key_type)
{
check_secret(db, master, &old.id, f)?;
}
}
} else {
if !f.field_id.is_empty() {
return Err(Error("field_missing"));
}
f.needs_value = f.value.is_none();
}
}
items::validate(&new)?;
let discards_values = old
.fields
.iter()
.any(|f| !f.needs_value && !new.fields.iter().any(|n| n.field_id == f.field_id));
Ok(Diff {
old,
new,
discards_values,
stale: false,
})
}
pub(crate) fn apply(
db: &Connection,
master: &[u8; 32],
edit: &Edit,
caller: &Caller,
approver: Option<&str>,
) -> Result<()> {
let mut diff = plan(db, Some(master), edit)?;
db.execute(
"INSERT INTO versions(item,created,config) VALUES(?1,unixepoch(),?2)",
params![edit.item, serde_json::to_string(&diff.old)?],
)?;
for old in &diff.old.fields {
if !diff.new.fields.iter().any(|f| f.field_id == old.field_id) {
db.execute(
"DELETE FROM fields WHERE item=?1 AND id=?2",
params![edit.item, old.field_id],
)?;
}
}
for f in &mut diff.new.fields {
if f.field_id.is_empty() {
f.field_id = seal::identity();
}
}
items::save(db, &diff.new)?;
audit::record(db, "item.edit", caller, Some(&edit.item), approver)
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct Revert {
pub item: String,
pub base_version: i64,
pub version: i64,
}
pub(crate) fn revert_revision(db: &Connection, item: &str) -> Result<i64> {
if let Ok(item) = items::load(db, item) {
return Ok(item.version);
}
let latest: Option<i64> = db.query_row(
"SELECT max(json_extract(config,'$.version')) FROM versions WHERE item=?",
[item],
|r| r.get(0),
)?;
latest
.ok_or(Error("item_deleted"))?
.checked_add(1)
.ok_or(Error("version_exhausted"))
}
pub(crate) fn plan_revert(db: &Connection, master: Option<&[u8; 32]>, r: &Revert) -> Result<Diff> {
if revert_revision(db, &r.item)? != r.base_version {
return Err(Error("stale"));
}
let saved:String=db.query_row("SELECT config FROM versions WHERE item=?1 AND json_extract(config,'$.version')=?2 ORDER BY rowid DESC LIMIT 1",params![r.item,r.version],|row|row.get(0))?;
let mut new: Item = serde_json::from_str(&saved)?;
let current = items::load(db, &r.item).ok();
for f in &mut new.fields {
let prior = current
.as_ref()
.and_then(|i| i.fields.iter().find(|p| p.field_id == f.field_id));
if let Some(prior) = prior {
if prior.public != f.public {
return Err(Error("immutable_visibility"));
}
f.needs_value = prior.needs_value;
if f.public {
f.value = prior.value.clone();
} else if let Some(master) = master {
check_secret(db, master, &r.item, f)?;
}
} else {
f.needs_value = f.value.is_none();
}
}
items::validate(&new)?;
items::unique_name(db, &new.name, &new.id)?;
new.version = r
.base_version
.checked_add(1)
.ok_or(Error("version_exhausted"))?;
let old = current.unwrap_or_else(|| {
let mut old = new.clone();
old.version = r.base_version;
old.fields.clear();
old
});
let discards_values = old
.fields
.iter()
.any(|f| !f.needs_value && !new.fields.iter().any(|n| n.field_id == f.field_id));
Ok(Diff {
old,
new,
discards_values,
stale: false,
})
}
pub(crate) fn apply_revert(
db: &Connection,
master: &[u8; 32],
r: &Revert,
caller: &Caller,
approver: Option<&str>,
) -> Result<()> {
let diff = plan_revert(db, Some(master), r)?;
if items::load(db, &r.item).is_ok() {
db.execute(
"INSERT INTO versions(item,created,config) VALUES(?1,unixepoch(),?2)",
params![r.item, serde_json::to_string(&diff.old)?],
)?;
items::save(db, &diff.new)?;
} else {
db.execute(
"INSERT INTO items VALUES(?1,?2,?3)",
params![r.item, diff.new.name, serde_json::to_string(&diff.new)?],
)?;
}
for f in &diff.old.fields {
if !diff.new.fields.iter().any(|n| n.field_id == f.field_id) {
db.execute(
"DELETE FROM fields WHERE item=?1 AND id=?2",
params![r.item, f.field_id],
)?;
}
}
audit::record(db, "item.revert", caller, Some(&r.item), approver)
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct Delete {
pub item: String,
pub base_version: i64,
}
pub(crate) fn apply_delete(
db: &Connection,
delete: &Delete,
caller: &Caller,
approver: Option<&str>,
) -> Result<()> {
let item = items::load(db, &delete.item)?;
if item.version != delete.base_version {
return Err(Error("stale"));
}
db.execute(
"INSERT INTO versions(item,created,config) VALUES(?1,unixepoch(),?2)",
params![item.id, serde_json::to_string(&item)?],
)?;
db.execute("DELETE FROM items WHERE id=?", [&item.id])?;
db.execute("UPDATE grants SET revoked=1 WHERE EXISTS(SELECT 1 FROM json_each(grants.items) WHERE value=?)", [&item.id])?;
audit::record(db, "item.delete", caller, Some(&item.id), approver)
}
pub(crate) fn apply_changes(
mut config: Item,
change: &[Operation],
start: usize,
secrets: Option<(&Connection, &[u8; 32])>,
) -> Result<Item> {
for operation in &change[start..] {
let field = |id: &str| {
if id.is_empty() {
return Err(Error("field_missing"));
}
config
.fields
.iter()
.position(|f| f.field_id == id)
.ok_or(Error("field_missing"))
};
match operation {
Operation::SetName { name } => config.name = name.clone(),
Operation::SetRules { rules } => config.rules = rules.clone(),
Operation::SetFreeUse { enabled } => config.free_use = *enabled,
Operation::SetValidateJs { value } => config.validate_js = value.clone(),
Operation::SetTransformJs { value } => config.transform_js = value.clone(),
Operation::AddField { field } => {
let mut field = field.clone();
if !field.field_id.is_empty() {
return Err(Error("invalid_params"));
}
if !field.public && field.value.is_some() {
return Err(Error("secret_field"));
}
super::types::validate(
&field.field_type,
field.key_type.as_deref(),
field.public,
field.value.as_deref(),
)?;
field.needs_value = field.value.is_none();
config.fields.push(field);
}
Operation::SetFieldValue { field: id, value } => {
let n = field(id)?;
if !config.fields[n].public {
return Err(Error("secret_field"));
}
super::types::validate(
&config.fields[n].field_type,
config.fields[n].key_type.as_deref(),
true,
Some(value),
)?;
config.fields[n].value = Some(value.clone());
config.fields[n].needs_value = false;
}
Operation::SetFieldType {
field: id,
field_type,
key_type,
} => {
let n = field(id)?;
config.fields[n].field_type = field_type.clone();
config.fields[n].key_type = key_type.clone();
let f = &config.fields[n];
super::types::validate(
&f.field_type,
f.key_type.as_deref(),
f.public,
f.value.as_deref(),
)?;
if !f.public
&& let Some((db, master)) = secrets
{
check_secret(db, master, &config.id, f)?;
}
}
Operation::RenameField { field: id, name } => {
let n = field(id)?;
config.fields[n].name = name.clone();
}
Operation::RemoveField { field: id } => {
let n = field(id)?;
config.fields.remove(n);
}
_ => return Err(Error("invalid_params")),
}
}
Ok(config)
}
impl super::Cred {
pub fn file_operation_request(
&self,
caller: &Caller,
key: &str,
request: OperationRequest,
) -> Result<String> {
if request.change.is_empty() {
return Err(Error("invalid_params"));
}
if request.change.len() == 1 {
match &request.change[0] {
Operation::DeleteItem => {
return self.file_delete_request(
caller,
key,
Delete {
item: request.item.clone().ok_or(Error("invalid_params"))?,
base_version: request.base_version.ok_or(Error("invalid_params"))?,
},
);
}
Operation::ItemRevert { version } => {
return self.file_revert_request(
caller,
key,
request.item.as_deref().ok_or(Error("invalid_params"))?,
request.base_version.ok_or(Error("invalid_params"))?,
*version,
);
}
Operation::GrantCreate { grant } => {
let mut grant = grant.clone();
grant.id.clear();
grant.uses = 0;
grant.revoked = false;
grant.items.sort();
grant.items.dedup();
let payload = serde_json::json!({"grant_create":grant});
if let Some(id) = self.replay(caller, key, &payload)? {
return Ok(id);
}
super::grants::validate(&self.access()?.db, &grant)?;
return self.file_payload(
caller,
key,
serde_json::json!({"grant_create":grant}),
);
}
Operation::GrantRevoke { target } => {
self.grant_status(target)?;
return self.file_payload(
caller,
key,
serde_json::json!({"grant_revoke":target}),
);
}
_ => {}
}
}
let intent = serde_json::json!({"requested":request});
if let Some(id) = self.replay(caller, key, &intent)? {
return Ok(id);
}
let mut create = false;
let mut generate = false;
let config = if let Some(id) = &request.item {
self.item_get(id)?
} else {
create = true;
match &request.change[0] {
Operation::CreateItem { config } => config.clone(),
Operation::GenerateEs256Item { config } => {
generate = true;
config.clone()
}
_ => return Err(Error("invalid_params")),
}
};
if create {
items::validate(&config)?;
}
let config = apply_changes(config, &request.change, usize::from(create), None)?;
if create {
if request.base_version.is_some() {
return Err(Error("invalid_params"));
}
items::validate(&config)?;
if generate
&& !config.fields.iter().any(|f| {
!f.public
&& f.field_type == super::types::FieldType::SshPrivateKey
&& f.key_type.as_deref() == Some("es256")
})
{
return Err(Error("invalid_params"));
}
items::unique_name(&self.access()?.db, &config.name, "")?;
return self.file_payload(
caller,
key,
serde_json::json!({"create":config,"generate_es256":generate,"requested":request}),
);
}
let edit = Edit {
item: request.item.clone().ok_or(Error("invalid_params"))?,
base_version: request.base_version.ok_or(Error("invalid_params"))?,
config,
};
let diff = {
let s = self.access()?;
plan(&s.db, None, &edit)?
};
self.file_payload(
caller,
key,
serde_json::json!({"edit":edit,"diff":diff,"requested":request}),
)
}
pub fn file_delete_request(
&self,
caller: &Caller,
key: &str,
delete: Delete,
) -> Result<String> {
if let Some(id) = self.replay(caller, key, &serde_json::json!({"delete":delete}))? {
return Ok(id);
}
let item = self.item_get(&delete.item)?;
if item.version != delete.base_version {
return Err(Error("stale"));
}
self.file_payload(caller, key, serde_json::json!({"delete":delete,"old":item}))
}
/// Revert public configuration. Removed secrets remain absent.
pub fn file_revert_request(
&self,
caller: &Caller,
key: &str,
item: &str,
base_version: i64,
version: i64,
) -> Result<String> {
let revert = Revert {
item: item.into(),
base_version,
version,
};
if let Some(id) = self.replay(caller, key, &serde_json::json!({"revert":revert}))? {
return Ok(id);
}
let diff = {
let s = self.access()?;
plan_revert(&s.db, None, &revert)?
};
self.file_payload(
caller,
key,
serde_json::json!({"revert":revert,"diff":diff}),
)
}
pub fn file_edit_request(&self, caller: &Caller, key: &str, edit: Edit) -> Result<String> {
if let Some(id) = self.replay(caller, key, &serde_json::json!({"edit":edit}))? {
return Ok(id);
}
let diff = {
let s = self.access()?;
plan(&s.db, None, &edit)?
};
self.file_payload(caller, key, serde_json::json!({"edit":edit,"diff":diff}))
}
/// Replace a pending edit's base version after the UI reviews the current item.
pub fn resolve_stale(&self, id: &str, base_version: i64) -> Result<()> {
let mut s = self.access()?;
let tx =
s.db.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
let (payload, state): (String, String) =
tx.query_row("SELECT payload,state FROM requests WHERE id=?", [id], |r| {
Ok((r.get(0)?, r.get(1)?))
})?;
if state != "pending" {
return Err(Error("already_decided"));
}
let mut payload: serde_json::Value = serde_json::from_str(&payload)?;
if let Some(revert) = payload.get("revert") {
let mut revert: Revert = serde_json::from_value(revert.clone())?;
revert.base_version = base_version;
let diff = plan_revert(&tx, None, &revert)?;
payload["revert"] = serde_json::to_value(revert)?;
payload["diff"] = serde_json::to_value(diff)?;
} else {
let mut edit: Edit =
serde_json::from_value(payload.get("edit").ok_or(Error("not_edit"))?.clone())?;
edit.base_version = base_version;
if let Some(requested) = payload.get("requested") {
let requested: OperationRequest = serde_json::from_value(requested.clone())?;
edit.config =
apply_changes(items::load(&tx, &edit.item)?, &requested.change, 0, None)?;
}
let diff = plan(&tx, None, &edit)?;
payload["edit"] = serde_json::to_value(edit)?;
payload["diff"] = serde_json::to_value(diff)?;
}
tx.execute(
"UPDATE requests SET payload=?2 WHERE id=?1",
params![id, serde_json::to_string(&payload)?],
)?;
tx.commit()?;
Ok(())
}
pub fn edit_diff(&self, id: &str) -> Result<Diff> {
let payload = self.request_diff(id)?;
let mut diff: Diff =
serde_json::from_value(payload.get("diff").ok_or(Error("not_edit"))?.clone())?;
if let Some(revert) = payload.get("revert") {
let revert: Revert = serde_json::from_value(revert.clone())?;
diff.stale = revert_revision(&self.access()?.db, &revert.item)
.map_or(true, |v| v != revert.base_version);
} else {
let edit: Edit =
serde_json::from_value(payload.get("edit").ok_or(Error("not_edit"))?.clone())?;
diff.stale = self
.item_get(&edit.item)
.map_or(true, |i| i.version != edit.base_version);
}
Ok(diff)
}
}
+86
View File
@@ -0,0 +1,86 @@
//! Browser secret entry: ECDH P-256, HKDF-SHA256, AES-256-GCM.
//! JSON envelopes contain byte arrays: `ephemeral_public_key` (65-byte SEC1
//! uncompressed point), `iv` (12 bytes), and `ciphertext` (includes the GCM tag).
//! HKDF uses an empty salt and info `sausage/secret-entry/v1`. There is no AAD.
//! The binding hash is lowercase hex SHA-256(ephemeral_public_key || iv || ciphertext).
//! The public key uses the same SEC1 format for WebCrypto's `raw` import.
use aes_gcm::{Aes256Gcm, KeyInit, Nonce, aead::Aead};
use anyhow::{Result, bail};
use hkdf::Hkdf;
use p256::{PublicKey, SecretKey, ecdh::diffie_hellman, elliptic_curve::sec1::ToEncodedPoint};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::{fs::OpenOptions, io::Write, os::unix::fs::OpenOptionsExt, path::Path};
#[derive(Serialize, Deserialize)]
pub struct Envelope {
pub ephemeral_public_key: Vec<u8>,
pub iv: [u8; 12],
pub ciphertext: Vec<u8>,
}
impl Envelope {
pub fn sha256(&self) -> String {
let mut hash = Sha256::new();
hash.update(&self.ephemeral_public_key);
hash.update(self.iv);
hash.update(&self.ciphertext);
format!("{:x}", hash.finalize())
}
}
pub struct EntryKey(SecretKey);
impl EntryKey {
/// The caller supplies a path in the host folder, never in the world.
pub fn open(path: &Path) -> Result<Self> {
let bytes = match std::fs::read(path) {
Ok(bytes) => bytes,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
let key = SecretKey::random(&mut p256::elliptic_curve::rand_core::OsRng);
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(path)?;
file.write_all(&key.to_bytes())?;
file.sync_all()?;
return Ok(Self(key));
}
Err(e) => return Err(e.into()),
};
Ok(Self(
SecretKey::from_slice(&bytes).map_err(|_| anyhow::anyhow!("invalid entry key"))?,
))
}
pub fn public_key(&self) -> Vec<u8> {
self.0
.public_key()
.to_encoded_point(false)
.as_bytes()
.to_vec()
}
/// The caller must first verify a SecretEntry assertion and compare its
/// item, field, and envelope hash. This method does not authorize a write.
pub fn decrypt(&self, envelope: &Envelope) -> Result<Vec<u8>> {
if envelope.ephemeral_public_key.len() != 65 || envelope.ephemeral_public_key[0] != 4 {
bail!("invalid secret envelope");
}
let peer = PublicKey::from_sec1_bytes(&envelope.ephemeral_public_key)
.map_err(|_| anyhow::anyhow!("invalid secret envelope"))?;
let shared = diffie_hellman(self.0.to_nonzero_scalar(), peer.as_affine());
let mut key = [0u8; 32];
Hkdf::<Sha256>::new(None, shared.raw_secret_bytes())
.expand(b"sausage/secret-entry/v1", &mut key)
.map_err(|_| anyhow::anyhow!("invalid secret envelope"))?;
Aes256Gcm::new_from_slice(&key)
.expect("AES key length")
.decrypt(
Nonce::from_slice(&envelope.iv),
envelope.ciphertext.as_ref(),
)
.map_err(|_| anyhow::anyhow!("invalid secret envelope"))
}
}
+20
View File
@@ -0,0 +1,20 @@
//! Stable failures. Never include input values or script exception messages.
#[derive(Debug, thiserror::Error)]
#[error("credential operation failed: {0}")]
pub struct Error(pub &'static str);
pub type Result<T> = std::result::Result<T, Error>;
impl From<rusqlite::Error> for Error {
fn from(_: rusqlite::Error) -> Self {
Self("storage")
}
}
impl From<std::io::Error> for Error {
fn from(_: std::io::Error) -> Self {
Self("io")
}
}
impl From<serde_json::Error> for Error {
fn from(_: serde_json::Error) -> Self {
Self("json")
}
}
+170
View File
@@ -0,0 +1,170 @@
//! Conversation-subtree grant ledger. Reservations use an immediate transaction.
use super::{
audit,
error::{Error, Result},
seal,
store::{Cred, now},
types::{Caller, Subject},
};
use rusqlite::{Connection, TransactionBehavior, params};
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct Grant {
#[serde(default)]
pub id: String,
pub items: Vec<String>,
pub subject: Subject,
pub exp: Option<i64>,
pub max_uses: Option<u64>,
#[serde(default)]
pub uses: u64,
#[serde(default)]
pub revoked: bool,
pub validator: Option<String>,
pub note: Option<String>,
}
pub(crate) fn list(db: &Connection) -> Result<Vec<Grant>> {
let mut q=db.prepare("SELECT id,items,subject,exp,max_uses,uses,revoked,validator,note FROM grants ORDER BY rowid")?;
Ok(q.query_map([], |r| {
Ok(Grant {
id: r.get(0)?,
items: serde_json::from_str(&r.get::<_, String>(1)?)
.map_err(|_| rusqlite::Error::InvalidQuery)?,
subject: serde_json::from_str(&r.get::<_, String>(2)?)
.map_err(|_| rusqlite::Error::InvalidQuery)?,
exp: r.get(3)?,
max_uses: r
.get::<_, Option<i64>>(4)?
.map(u64::try_from)
.transpose()
.map_err(|_| rusqlite::Error::InvalidQuery)?,
uses: u64::try_from(r.get::<_, i64>(5)?).map_err(|_| rusqlite::Error::InvalidQuery)?,
revoked: r.get(6)?,
validator: r.get(7)?,
note: r.get(8)?,
})
})?
.collect::<rusqlite::Result<_>>()?)
}
pub(crate) fn insert(
db: &Connection,
grant: &Grant,
caller: &Caller,
approver: Option<&str>,
) -> Result<()> {
validate(db, grant)?;
db.execute(
"INSERT INTO grants VALUES(?1,?2,?3,?4,?5,0,0,?6,?7)",
params![
grant.id,
serde_json::to_string(&grant.items)?,
serde_json::to_string(&grant.subject)?,
grant.exp,
grant.max_uses.map(|m| m as i64),
grant.validator,
grant.note
],
)?;
audit::record(
db,
"grant.create",
caller,
grant.items.first().map(String::as_str),
approver,
)
}
pub(crate) fn validate(db: &Connection, grant: &Grant) -> Result<()> {
if grant.max_uses == Some(0) || grant.max_uses.is_some_and(|m| m > i64::MAX as u64) {
return Err(Error("invalid_uses"));
}
if !grant.subject.valid() {
return Err(Error("invalid_subject"));
}
if grant.items.is_empty() {
return Err(Error("invalid_items"));
}
for item in &grant.items {
super::items::load(db, item)?;
}
Ok(())
}
impl Cred {
pub fn grant_create(
&self,
mut grant: Grant,
caller: &Caller,
approver: Option<&str>,
) -> Result<Grant> {
grant.id = seal::identity();
grant.uses = 0;
grant.revoked = false;
grant.items.sort();
grant.items.dedup();
let mut s = self.access()?;
let tx =
s.db.transaction_with_behavior(TransactionBehavior::Immediate)?;
insert(&tx, &grant, caller, approver)?;
tx.commit()?;
Ok(grant)
}
pub fn grant_list(&self) -> Result<Vec<Grant>> {
list(&self.access()?.db)
}
pub fn grant_status(&self, id: &str) -> Result<Grant> {
self.grant_list()?
.into_iter()
.find(|g| g.id == id)
.ok_or(Error("grant_missing"))
}
pub fn revoke_subjects(
&self,
subjects: &[String],
caller: &Caller,
approver: Option<&str>,
) -> Result<()> {
let mut s = self.access()?;
let tx = s.db.transaction()?;
for subject in subjects {
tx.execute(
"UPDATE grants SET revoked=1 WHERE subject=?",
[serde_json::to_string(&Subject::Conversation(
subject.clone(),
))?],
)?;
}
audit::record(&tx, "grant.revoke", caller, None, approver)?;
tx.commit()?;
Ok(())
}
pub(crate) fn reserve(
&self,
item: &super::items::Item,
caller: &Caller,
) -> Result<Option<(Grant, serde_json::Value)>> {
let mut s = self.access()?;
let master = s.master.clone();
let tx =
s.db.transaction_with_behavior(TransactionBehavior::Immediate)?;
let grant = list(&tx)?.into_iter().find(|g| {
g.items.iter().any(|i| i == &item.id)
&& g.subject.covers(caller)
&& !g.revoked
&& g.exp.is_none_or(|e| e > now())
&& g.max_uses.is_none_or(|m| g.uses < m)
});
if let Some(mut g) = grant {
if super::items::load(&tx, &item.id)?.version != item.version {
return Err(Error("stale"));
}
let credential = super::items::credential(&tx, &master, item, true)?;
let changed=tx.execute("UPDATE grants SET uses=uses+1 WHERE id=?1 AND revoked=0 AND (exp IS NULL OR exp>?2) AND (max_uses IS NULL OR uses<max_uses)",params![g.id,now()])?;
if changed != 1 {
return Err(Error("grant_exhausted"));
}
g.uses += 1;
tx.commit()?;
Ok(Some((g, credential)))
} else {
Ok(None)
}
}
}
+173
View File
@@ -0,0 +1,173 @@
//! Transform one outgoing request. Never expose script exception text to the world.
use super::{
error::{Error, Result},
items::Item,
requests::GrantRequest,
script::{Limits, Script},
store::Cred,
types::Caller,
};
use http::request::Parts;
use serde_json::{Value, json};
pub enum Outcome {
Forward(Parts),
Denied { item: String, request: String },
}
fn matches(item: &Item, p: &Parts) -> bool {
item.rules.iter().any(|r| {
super::items::rule_matches(
r,
p.uri.scheme_str().unwrap_or(""),
p.uri.host().unwrap_or(""),
p.uri.path(),
p.method.as_str(),
)
})
}
fn request(p: &Parts) -> Result<Value> {
let mut headers = Vec::new();
for (name, value) in &p.headers {
headers.push(json!({"name":name.as_str(),"value":value.to_str().map_err(|_|Error("invalid_headers"))?}));
}
Ok(json!({"method":p.method.as_str(),"url":p.uri.to_string(),"headers":headers}))
}
fn evaluate(script: &dyn Script, source: &str, context: &Value) -> Result<Value> {
let input = serde_json::to_string(context)?;
let result = script
.evaluate(source, &input, None, Limits::default())
.map_err(|_| Error("script"))?;
serde_json::from_str(&result).map_err(|_| Error("script"))
}
impl Cred {
pub fn inject(
&self,
mut parts: Parts,
caller: &Caller,
script: &dyn Script,
) -> Result<Outcome> {
let candidates: Vec<_> = self
.item_list()?
.into_iter()
.filter(|i| matches(i, &parts))
.collect();
if candidates.len() > 1 {
return Err(Error("ambiguous_item"));
}
let Some(item) = candidates.into_iter().next() else {
return Ok(Outcome::Forward(parts));
};
let original_request = request(&parts)?;
if item
.fields
.iter()
.any(|f| f.field_type == super::types::FieldType::SshPrivateKey)
{
return Err(Error("ssh_item"));
}
let prepared = if item.free_use {
Some((None, self.credential(&item, true)?))
} else {
self.reserve(&item, caller)?
.map(|(grant, credential)| (Some(grant), credential))
};
let Some((grant, secret_credential)) = prepared else {
let request = self.file_request(
caller,
&format!("inject:{}", item.id),
GrantRequest {
items: vec![item.id.clone()],
duration_secs: None,
max_uses: None,
validator: None,
},
)?;
return Ok(Outcome::Denied {
item: item.name,
request,
});
};
let result = (|| {
let public = self.credential(&item, false)?;
let ctx = json!({"request":original_request,"credential":public,"item":{"id":item.id,"name":item.name},"grant":grant.as_ref().map(|g|json!({"conversation":match &g.subject {super::types::Subject::Conversation(id)=>Some(id),super::types::Subject::Program(_)=>None},"subject":g.subject,"uses_left":g.max_uses.map(|m|m-g.uses),"exp":g.exp}))});
for source in [
item.validate_js.as_deref(),
grant.as_ref().and_then(|g| g.validator.as_deref()),
]
.into_iter()
.flatten()
{
let value = evaluate(script, source, &ctx)?;
let allow = value
.as_bool()
.or_else(|| value.get("allow").and_then(Value::as_bool))
.ok_or(Error("script"))?;
if !allow {
return Err(Error("validator_denied"));
}
}
if !item.transform_js.is_empty() {
let mut ctx = json!({"request":original_request,"credential":secret_credential});
let url =
url::Url::parse(&parts.uri.to_string()).map_err(|_| Error("invalid_url"))?;
let mut base = url.clone();
base.set_query(None);
base.set_fragment(None);
ctx["url"] = json!({"base":base.as_str(),"query":url.query_pairs().collect::<Vec<_>>(),"fragment":url.fragment().map(|f|format!("#{f}")).unwrap_or_default()});
let output = evaluate(script, &item.transform_js, &ctx)?;
let object = output.as_object().ok_or(Error("transform"))?;
if object
.keys()
.any(|k| k != "url" && k != "headers" && k != "method")
|| output
.get("method")
.is_some_and(|m| m.as_str() != Some(parts.method.as_str()))
{
return Err(Error("transform"));
}
if let Some(url) = output.get("url") {
parts.uri = url
.as_str()
.ok_or(Error("transform"))?
.parse()
.map_err(|_| Error("transform"))?;
}
if let Some(headers) = output.get("headers") {
let mut new = http::HeaderMap::new();
for header in headers.as_array().ok_or(Error("transform"))? {
let name = header
.get("name")
.and_then(Value::as_str)
.ok_or(Error("transform"))?;
let value = header
.get("value")
.and_then(Value::as_str)
.ok_or(Error("transform"))?;
new.append(
http::header::HeaderName::from_bytes(name.as_bytes())
.map_err(|_| Error("transform"))?,
http::HeaderValue::from_str(value).map_err(|_| Error("transform"))?,
);
}
parts.headers = new;
}
}
if !matches(&item, &parts) {
return Err(Error("rule_recheck"));
}
Ok(())
})();
super::audit::record(
&self.access()?.db,
if result.is_ok() {
"fetch.use"
} else {
"fetch.denied"
},
caller,
Some(&item.id),
None,
)?;
result?;
Ok(Outcome::Forward(parts))
}
}
+352
View File
@@ -0,0 +1,352 @@
//! Public item snapshots and host-only field entry. Snapshots never contain secret values.
use super::{
error::{Error, Result},
seal,
store::Cred,
types::{Caller, FieldType},
};
use rusqlite::{Connection, OptionalExtension, params};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Rule {
pub scheme: String,
pub hosts: Vec<String>,
pub path_prefixes: Vec<String>,
pub methods: Option<Vec<String>>,
}
pub fn rule_matches(r: &Rule, scheme: &str, host: &str, path: &str, method: &str) -> bool {
r.scheme.eq_ignore_ascii_case(scheme)
&& r.hosts.iter().any(|p| {
if let Some(s) = p.strip_prefix("*.") {
host.to_ascii_lowercase()
.strip_suffix(&format!(".{}", s.to_ascii_lowercase()))
.is_some_and(|p| !p.is_empty())
} else {
p.eq_ignore_ascii_case(host)
}
})
&& r.path_prefixes.iter().any(|p| path.starts_with(p))
&& r.methods
.as_ref()
.is_none_or(|ms| ms.iter().any(|m| m.eq_ignore_ascii_case(method)))
}
#[derive(Clone, Serialize, Deserialize)]
pub struct Field {
#[serde(default)]
pub field_id: String,
pub name: String,
#[serde(default)]
pub public: bool,
pub value: Option<String>,
#[serde(default)]
pub needs_value: bool,
pub field_type: FieldType,
pub key_type: Option<String>,
}
impl std::fmt::Debug for Field {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Field")
.field("field_id", &self.field_id)
.field("name", &self.name)
.field("public", &self.public)
.field("needs_value", &self.needs_value)
.field("field_type", &self.field_type)
.field("key_type", &self.key_type)
.field(
"value",
&if self.public {
self.value.as_deref()
} else {
None
},
)
.finish()
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Item {
#[serde(default)]
pub id: String,
pub name: String,
#[serde(default)]
pub version: i64,
#[serde(default)]
pub free_use: bool,
pub rules: Vec<Rule>,
pub validate_js: Option<String>,
pub transform_js: String,
pub fields: Vec<Field>,
}
pub(crate) fn load(db: &Connection, id: &str) -> Result<Item> {
let json: Option<String> = db
.query_row("SELECT config FROM items WHERE id=?", [id], |r| r.get(0))
.optional()?;
Ok(serde_json::from_str(&json.ok_or(Error("item_deleted"))?)?)
}
pub(crate) fn save(db: &Connection, item: &Item) -> Result<()> {
db.execute(
"UPDATE items SET name=?2,config=?3 WHERE id=?1",
params![item.id, item.name, serde_json::to_string(item)?],
)?;
Ok(())
}
pub(crate) fn validate(item: &Item) -> Result<()> {
if item.name.trim().is_empty() {
return Err(Error("invalid_params"));
}
let mut names = std::collections::HashSet::new();
let mut ids = std::collections::HashSet::new();
for f in &item.fields {
if !f.public && f.value.is_some() {
return Err(Error("secret_field"));
}
super::types::validate(
&f.field_type,
f.key_type.as_deref(),
f.public,
f.value.as_deref(),
)?;
if f.name.trim().is_empty()
|| !names.insert(&f.name)
|| (!f.field_id.is_empty() && !ids.insert(&f.field_id))
{
return Err(Error("invalid_params"));
}
}
if item
.fields
.iter()
.filter(|f| f.field_type == FieldType::Oauth2RefreshToken)
.count()
> 1
{
return Err(Error("invalid_params"));
}
for r in &item.rules {
if !matches!(
r.scheme.to_ascii_lowercase().as_str(),
"http" | "https" | "ssh"
) || r.path_prefixes.iter().any(|p| !p.starts_with('/'))
|| r.methods.as_ref().is_some_and(|ms| {
ms.iter().any(|m| {
m.is_empty() || !m.bytes().all(|b| b.is_ascii_alphabetic() || b == b'-')
})
})
|| r.hosts.iter().any(|p| {
let h = p.strip_prefix("*.").unwrap_or(p);
h.is_empty()
|| h.starts_with('.')
|| h.ends_with('.')
|| h.contains("..")
|| h.bytes().any(|b| {
b.is_ascii_whitespace() || matches!(b, b'*' | b'/' | b'?' | b'#' | b'@')
})
|| (h.contains(':') && h.parse::<std::net::Ipv6Addr>().is_err())
})
{
return Err(Error("invalid_params"));
}
}
Ok(())
}
pub(crate) fn unique_name(db: &Connection, name: &str, except: &str) -> Result<()> {
let exists: bool = db.query_row(
"SELECT EXISTS(SELECT 1 FROM items WHERE name=?1 AND id<>?2)",
params![name, except],
|r| r.get(0),
)?;
if exists {
Err(Error("name_conflict"))
} else {
Ok(())
}
}
pub(crate) fn insert_new(
db: &Connection,
mut item: Item,
caller: &Caller,
approver: Option<&str>,
) -> Result<Item> {
validate(&item)?;
item.id = seal::identity();
item.version = 1;
for field in &mut item.fields {
field.field_id = seal::identity();
field.needs_value = field.value.is_none();
}
unique_name(db, &item.name, "")?;
db.execute(
"INSERT INTO items VALUES(?1,?2,?3)",
params![item.id, item.name, serde_json::to_string(&item)?],
)?;
super::audit::record(db, "item.create", caller, Some(&item.id), approver)?;
Ok(item)
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ItemVersion {
pub created: i64,
pub config: Item,
}
/// A secret-entry value with optional replacement metadata. Debug never includes the value.
pub struct FieldInput<'a> {
pub value: &'a str,
pub field_type: Option<FieldType>,
pub key_type: Option<String>,
}
impl std::fmt::Debug for FieldInput<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FieldInput")
.field("field_type", &self.field_type)
.field("key_type", &self.key_type)
.finish_non_exhaustive()
}
}
impl Cred {
/// Trusted host mutation. The route layer must verify a passkey before this call.
pub fn create_item(&self, item: Item, caller: &Caller, approver: &str) -> Result<Item> {
let mut s = self.access()?;
let tx =
s.db.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
let item = insert_new(&tx, item, caller, Some(approver))?;
tx.commit()?;
Ok(item)
}
pub fn item_get(&self, id: &str) -> Result<Item> {
load(&self.access()?.db, id)
}
pub fn item_list(&self) -> Result<Vec<Item>> {
let s = self.access()?;
let mut q = s.db.prepare("SELECT config FROM items ORDER BY name")?;
q.query_map([], |r| r.get::<_, String>(0))?
.map(|r| Ok(serde_json::from_str(&r?)?))
.collect()
}
pub fn item_versions(&self, id: &str) -> Result<Vec<ItemVersion>> {
let s = self.access()?;
let mut q =
s.db.prepare("SELECT created,config FROM versions WHERE item=? ORDER BY rowid")?;
q.query_map([id], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))?
.map(|r| {
let (created, config) = r?;
Ok(ItemVersion {
created,
config: serde_json::from_str(&config)?,
})
})
.collect()
}
/// Trusted secret entry core. Never put the value into a public snapshot.
pub fn set_field(
&self,
item: &str,
field: &str,
value: &str,
caller: &Caller,
approver: &str,
) -> Result<()> {
self.set_field_input(
item,
field,
&FieldInput {
value,
field_type: None,
key_type: None,
},
caller,
approver,
)
}
pub fn set_field_input(
&self,
item: &str,
field: &str,
input: &FieldInput<'_>,
caller: &Caller,
approver: &str,
) -> Result<()> {
let value = input.value;
let mut s = self.access()?;
let key = s.master.clone();
let tx = s.db.transaction()?;
let mut config = load(&tx, item)?;
let index = config
.fields
.iter()
.position(|f| f.field_id == field)
.ok_or(Error("field_missing"))?;
let f = &config.fields[index];
let kind = input.field_type.as_ref().unwrap_or(&f.field_type).clone();
let key_type = if input.field_type.is_some() {
input.key_type.clone()
} else {
input.key_type.clone().or_else(|| f.key_type.clone())
};
super::types::validate(&kind, key_type.as_deref(), f.public, Some(value))?;
let previous = serde_json::to_string(&config)?;
if config.fields[index].public {
config.fields[index].value = Some(value.to_owned());
} else {
let encrypted = seal::seal(&key, &format!("{item}|{field}"), value.as_bytes())?;
tx.execute(
"INSERT OR REPLACE INTO fields VALUES(?1,?2,?3)",
params![item, field, encrypted],
)?;
}
config.fields[index].needs_value = false;
config.fields[index].field_type = kind;
config.fields[index].key_type = key_type;
validate(&config)?;
tx.execute(
"INSERT INTO versions(item,created,config) VALUES(?1,unixepoch(),?2)",
params![item, previous],
)?;
config.version = config
.version
.checked_add(1)
.ok_or(Error("version_exhausted"))?;
save(&tx, &config)?;
super::audit::record(&tx, "field.set", caller, Some(item), Some(approver))?;
tx.commit()?;
Ok(())
}
pub(crate) fn credential(&self, item: &Item, secrets: bool) -> Result<serde_json::Value> {
let s = self.access()?;
if secrets && load(&s.db, &item.id)?.version != item.version {
return Err(Error("stale"));
}
credential(&s.db, &s.master, item, secrets)
}
}
pub(crate) fn credential(
db: &Connection,
master: &[u8; 32],
item: &Item,
secrets: bool,
) -> Result<serde_json::Value> {
if secrets && item.fields.iter().any(|f| f.needs_value) {
return Err(Error("needs_value"));
}
let mut values = serde_json::Map::new();
for f in &item.fields {
if f.public {
if let Some(v) = &f.value {
values.insert(f.name.clone(), v.clone().into());
}
} else if secrets {
let cipher: Option<Vec<u8>> = db
.query_row(
"SELECT ciphertext FROM fields WHERE item=?1 AND id=?2",
params![item.id, f.field_id],
|r| r.get(0),
)
.optional()?;
let cipher = cipher.ok_or(Error("needs_value"))?;
{
let bytes = seal::open(master, &format!("{}|{}", item.id, f.field_id), &cipher)?;
let value = std::str::from_utf8(&bytes).map_err(|_| Error("field_invalid"))?;
values.insert(f.name.clone(), value.into());
}
}
}
Ok(values.into())
}
+305
View File
@@ -0,0 +1,305 @@
//! Key readers and signature/public-key codecs. No grant or storage policy here.
use super::sign::{Result, SignResult, error, invalid_key};
use base64::{
Engine,
engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD},
};
use ed25519_dalek::{Signer, SigningKey};
use hmac::{Hmac, Mac};
use rsa::{
pkcs1::DecodeRsaPrivateKey,
pkcs8::{DecodePrivateKey, EncodePublicKey},
traits::PublicKeyParts,
};
use serde_json::json;
use sha2::{Digest, Sha256};
use sha2::{Sha384, Sha512};
use zeroize::Zeroizing;
pub(super) enum Key {
Ed(SigningKey),
Ec(p256::ecdsa::SigningKey),
Rsa(rsa::RsaPrivateKey),
Mac(Zeroizing<Vec<u8>>),
}
fn decode(s: &str) -> Result<Zeroizing<Vec<u8>>> {
Ok(Zeroizing::new(
STANDARD
.decode(s)
.or_else(|_| URL_SAFE_NO_PAD.decode(s))
.map_err(|_| invalid_key())?,
))
}
fn family(format: &str) -> &str {
match format {
"ed25519-raw" => "ed25519",
"rs256" | "rsa-sha2-256" | "rsa-sha2-512" => "rsa",
s if s.starts_with("hmac-") => "hmac",
s => s,
}
}
pub(super) fn read(value: &str, format: &str, passphrase: Option<&str>) -> Result<Key> {
let value = value.trim();
let family = family(format);
let key = if value.starts_with("-----BEGIN OPENSSH PRIVATE KEY-----") {
let key = ssh_key::PrivateKey::from_openssh(value).map_err(|_| invalid_key())?;
let key = if key.is_encrypted() {
key.decrypt(passphrase.ok_or_else(invalid_key)?)
.map_err(|_| invalid_key())?
} else {
key
};
match key.key_data() {
ssh_key::private::KeypairData::Ed25519(k) => {
Key::Ed(SigningKey::from_bytes(&k.private.to_bytes()))
}
ssh_key::private::KeypairData::Ecdsa(
k @ ssh_key::private::EcdsaKeypair::NistP256 { .. },
) => Key::Ec(
p256::ecdsa::SigningKey::from_slice(k.private_key_bytes())
.map_err(|_| invalid_key())?,
),
ssh_key::private::KeypairData::Rsa(k) => {
// ssh-key 0.6.7's TryFrom<&RsaKeypair> accidentally passes p twice.
// Convert the parsed components explicitly, preserving p and q.
let int = |n: &ssh_key::Mpint| rsa::BigUint::try_from(n).map_err(|_| invalid_key());
let key = rsa::RsaPrivateKey::from_components(
int(&k.public.n)?,
int(&k.public.e)?,
int(&k.private.d)?,
vec![int(&k.private.p)?, int(&k.private.q)?],
)
.map_err(|_| invalid_key())?;
key.validate().map_err(|_| invalid_key())?;
Key::Rsa(key)
}
_ => return Err(invalid_key()),
}
} else if value.starts_with("-----BEGIN ") {
match family {
"ed25519" => Key::Ed(SigningKey::from_pkcs8_pem(value).map_err(|_| invalid_key())?),
"es256" => {
let key = if value.starts_with("-----BEGIN EC PRIVATE KEY-----") {
p256::SecretKey::from_sec1_pem(value).map_err(|_| invalid_key())?
} else {
p256::SecretKey::from_pkcs8_pem(value).map_err(|_| invalid_key())?
};
Key::Ec(key.into())
}
"rsa" => Key::Rsa(if value.starts_with("-----BEGIN RSA PRIVATE KEY-----") {
rsa::RsaPrivateKey::from_pkcs1_pem(value).map_err(|_| invalid_key())?
} else {
rsa::RsaPrivateKey::from_pkcs8_pem(value).map_err(|_| invalid_key())?
}),
_ => return Err(invalid_key()),
}
} else if value.starts_with('{') {
let j: serde_json::Value = serde_json::from_str(value).map_err(|_| invalid_key())?;
let bytes = |name: &str| -> Result<Zeroizing<Vec<u8>>> {
decode(j[name].as_str().ok_or_else(invalid_key)?)
};
match j["kty"].as_str() {
Some("oct") => Key::Mac(bytes("k")?),
Some("OKP") if j["crv"] == "Ed25519" => {
let d = bytes("d")?;
let k = SigningKey::from_bytes(d.as_slice().try_into().map_err(|_| invalid_key())?);
if bytes("x")?.as_slice() != k.verifying_key().as_bytes() {
return Err(invalid_key());
}
Key::Ed(k)
}
Some("EC") if j["crv"] == "P-256" => {
let k =
p256::ecdsa::SigningKey::from_slice(&bytes("d")?).map_err(|_| invalid_key())?;
let point = k.verifying_key().to_encoded_point(false);
if bytes("x")?.as_slice() != point.x().ok_or_else(invalid_key)?.as_slice()
|| bytes("y")?.as_slice() != point.y().ok_or_else(invalid_key)?.as_slice()
{
return Err(invalid_key());
}
Key::Ec(k)
}
Some("RSA") => {
let int = |name| -> Result<rsa::BigUint> {
Ok(rsa::BigUint::from_bytes_be(&bytes(name)?))
};
let k = rsa::RsaPrivateKey::from_components(
int("n")?,
int("e")?,
int("d")?,
vec![int("p")?, int("q")?],
)
.map_err(|_| invalid_key())?;
k.validate().map_err(|_| invalid_key())?;
Key::Rsa(k)
}
_ => return Err(invalid_key()),
}
} else {
let bytes = decode(value)?;
match family {
"ed25519" => Key::Ed(SigningKey::from_bytes(
bytes.as_slice().try_into().map_err(|_| invalid_key())?,
)),
"es256" if bytes.len() == 32 => {
Key::Ec(p256::ecdsa::SigningKey::from_slice(&bytes).map_err(|_| invalid_key())?)
}
"hmac" => Key::Mac(bytes),
_ => return Err(invalid_key()),
}
};
if !matches!(
(&key, family),
(Key::Ed(_), "ed25519")
| (Key::Ec(_), "es256")
| (Key::Rsa(_), "rsa")
| (Key::Mac(_), "hmac")
) {
return Err(invalid_key());
}
Ok(key)
}
pub(super) fn check(format: &str, padding: Option<&str>, hash: Option<&str>) -> Result<()> {
match format {
"ed25519" | "ed25519-raw" | "es256" | "hmac-sha256" | "hmac-sha384" | "hmac-sha512"
if padding.is_none() && hash.is_none() =>
{
Ok(())
}
"rs256" | "rsa-sha2-256" | "rsa-sha2-512"
if padding == Some("pkcs1v15")
&& hash
== Some(if format == "rsa-sha2-512" {
"sha512"
} else {
"sha256"
}) =>
{
Ok(())
}
_ => Err(error(
"invalid_params",
"unknown algorithm or inconsistent padding/hash; RSA requires padding=pkcs1v15 and explicit sha256/sha512 matching the algorithm",
)),
}
}
pub(super) fn sign_key(key: &Key, format: &str, payload: &[u8]) -> Result<Vec<u8>> {
Ok(match key {
Key::Ed(k) => k.sign(payload).to_bytes().to_vec(),
Key::Ec(k) => {
let s: p256::ecdsa::Signature = k.sign(payload);
s.to_bytes().to_vec()
}
Key::Rsa(k) => if format == "rsa-sha2-512" {
k.sign(rsa::Pkcs1v15Sign::new::<Sha512>(), &Sha512::digest(payload))
} else {
k.sign(rsa::Pkcs1v15Sign::new::<Sha256>(), &Sha256::digest(payload))
}
.map_err(|_| invalid_key())?,
Key::Mac(k) => {
macro_rules! mac {
($h:ty) => {{
let mut mac = Hmac::<$h>::new_from_slice(k).map_err(|_| invalid_key())?;
mac.update(payload);
mac.finalize().into_bytes().to_vec()
}};
}
match format {
"hmac-sha384" => mac!(Sha384),
"hmac-sha512" => mac!(Sha512),
_ => mac!(Sha256),
}
}
})
}
fn string(out: &mut Vec<u8>, bytes: &[u8]) {
out.extend_from_slice(&(bytes.len() as u32).to_be_bytes());
out.extend_from_slice(bytes);
}
fn mpint(out: &mut Vec<u8>, bytes: &[u8]) {
let bytes = &bytes[bytes.iter().position(|b| *b != 0).unwrap_or(bytes.len())..];
let mut positive = Vec::new();
if bytes.first().is_some_and(|b| b & 128 != 0) {
positive.push(0);
}
positive.extend_from_slice(bytes);
string(out, &positive);
}
fn wire_signature(format: &str, signature: &[u8]) -> Result<Vec<u8>> {
let (algorithm, signature) = match format {
"ed25519" | "ed25519-raw" => ("ssh-ed25519", signature.to_vec()),
"rs256" | "rsa-sha2-256" => ("rsa-sha2-256", signature.to_vec()),
"rsa-sha2-512" => ("rsa-sha2-512", signature.to_vec()),
"es256" => {
let mut s = Vec::new();
mpint(&mut s, &signature[..32]);
mpint(&mut s, &signature[32..]);
("ecdsa-sha2-nistp256", s)
}
_ => {
return Err(error(
"invalid_params",
"SSH wire output requires an asymmetric key",
));
}
};
let mut out = Vec::new();
string(&mut out, algorithm.as_bytes());
string(&mut out, &signature);
Ok(out)
}
pub(super) fn output(format: &str, output: &str, signature: Vec<u8>) -> Result<SignResult> {
let (signature, bytes) = match output {
"raw" => (String::new(), Some(signature)),
"hex" => (hex::encode(signature), None),
"jws" => (URL_SAFE_NO_PAD.encode(signature), None),
"ssh" => (STANDARD.encode(wire_signature(format, &signature)?), None),
"base64" => (STANDARD.encode(signature), None),
_ => return Err(error("invalid_params", "unknown output format")),
};
Ok(SignResult { signature, bytes })
}
pub(super) fn public(key: &Key, output: &str) -> Result<String> {
let raw = match key {
Key::Ed(k) => k.verifying_key().to_bytes().to_vec(),
Key::Ec(k) => k
.verifying_key()
.to_encoded_point(false)
.as_bytes()
.to_vec(),
Key::Rsa(k) => k
.to_public_key()
.to_public_key_der()
.map_err(|_| invalid_key())?
.as_bytes()
.to_vec(),
Key::Mac(_) => return Err(error("invalid_params", "symmetric keys have no public key")),
};
Ok(match output {
"base64" => STANDARD.encode(&raw),
"hex" => hex::encode(&raw),
"pem" => match key {
Key::Ed(k) => k.verifying_key().to_public_key_pem(Default::default()),
Key::Ec(k) => k.verifying_key().to_public_key_pem(Default::default()),
Key::Rsa(k) => k.to_public_key().to_public_key_pem(Default::default()),
_ => unreachable!(),
}.map_err(|_| invalid_key())?,
"jwk" => match key {
Key::Ed(_) => json!({"kty":"OKP","crv":"Ed25519","x":URL_SAFE_NO_PAD.encode(&raw)}),
Key::Ec(_) => json!({"kty":"EC","crv":"P-256","x":URL_SAFE_NO_PAD.encode(&raw[1..33]),"y":URL_SAFE_NO_PAD.encode(&raw[33..])}),
Key::Rsa(k) => json!({"kty":"RSA","n":URL_SAFE_NO_PAD.encode(k.n().to_bytes_be()),"e":URL_SAFE_NO_PAD.encode(k.e().to_bytes_be())}),
_ => unreachable!(),
}.to_string(),
"ssh" => {
let mut out = Vec::new();
match key {
Key::Ed(_) => { string(&mut out, b"ssh-ed25519"); string(&mut out, &raw); },
Key::Ec(_) => { string(&mut out, b"ecdsa-sha2-nistp256"); string(&mut out, b"nistp256"); string(&mut out, &raw); },
Key::Rsa(k) => { string(&mut out, b"ssh-rsa"); mpint(&mut out, &k.e().to_bytes_be()); mpint(&mut out, &k.n().to_bytes_be()); },
_ => unreachable!(),
}
STANDARD.encode(out)
}
_ => return Err(error("invalid_params", "unknown public key output")),
})
}
+33
View File
@@ -1,3 +1,36 @@
//! The credentials manager (P2, plan §4.4): items, grants, requests and audit in the host,
//! outside the world. Programs in the world never see secret values.
//!
//! `Cred::open` loads `cred.sqlite` and the host master key. Read methods return public snapshots.
//! `Caller` carries a binary path and the nearest-first conversation ancestry.
//! `types::Subject` binds grants to a conversation subtree or a binary path.
//! `file_request` normalizes item identities and replays keyed requests. Edit requests carry public diffs.
//! `approve` and `deny` trust the route layer's verified approver identity.
//! That identity can be `None` during bootstrap, before any passkey registration.
//! Each decision commits one SQLite transaction. `subscribe` reports committed decisions across threads.
//! Subscribers must read SQLite after a lag or restart. The durable task layer calls `expire_requests`.
//! `inject` transforms one request's URL and headers. The body stays outside this library.
//! An unmatched request forwards unchanged. An unauthorized match names the item and its filed request.
//! `audit_sign` records signature metadata without the signed payload.
pub mod entry;
pub mod webauthn;
pub mod store;
pub mod schema;
pub mod seal;
pub mod items;
pub mod types;
pub mod grants;
pub mod requests;
pub mod edits;
pub mod summary;
pub mod audit;
pub mod inject;
pub mod error;
pub use store::Cred;
pub use error::{Error, Result};
pub use types::Caller;
pub mod script;
pub mod keys;
pub mod sign;
pub mod oauth2;
pub mod v8;
+119
View File
@@ -0,0 +1,119 @@
//! OAuth2 refresh exchange and a process-local cache keyed by item and version.
use std::{
collections::BTreeMap,
time::{Duration, Instant},
};
use zeroize::Zeroizing;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Error;
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("OAuth refresh failed or returned an invalid response")
}
}
impl std::error::Error for Error {}
/// HTTP adapter. Implementations must reject unsuccessful HTTP status codes.
pub trait Http: Sync {
fn send(
&self,
request: &Request,
deadline: Instant,
) -> impl std::future::Future<Output = Result<Zeroizing<Vec<u8>>, Error>> + Send;
}
/// Contains credentials. Deliberately does not implement Debug or Serialize.
pub struct Request {
pub url: String,
pub method: &'static str,
pub headers: Vec<(&'static str, String)>,
pub body: Zeroizing<String>,
}
pub fn request(
token_url: &str,
refresh_token: &str,
client_id: &str,
client_secret: &str,
) -> Result<Request, Error> {
let target = url::Url::parse(token_url).map_err(|_| Error)?;
if target.scheme() != "https"
|| !target.username().is_empty()
|| target.password().is_some()
|| target.fragment().is_some()
|| [refresh_token, client_id, client_secret].contains(&"")
{
return Err(Error);
}
let body = url::form_urlencoded::Serializer::new(String::new())
.append_pair("grant_type", "refresh_token")
.append_pair("refresh_token", refresh_token)
.append_pair("client_id", client_id)
.append_pair("client_secret", client_secret)
.finish();
Ok(Request {
url: token_url.into(),
method: "POST",
headers: vec![
("Content-Type", "application/x-www-form-urlencoded".into()),
("Content-Length", body.len().to_string()),
],
body: Zeroizing::new(body),
})
}
#[derive(Clone)]
pub struct Token {
pub value: Zeroizing<String>,
pub scopes: Option<Vec<String>>,
pub expires: Instant,
}
pub fn decode(bytes: &[u8]) -> Result<Token, Error> {
#[derive(serde::Deserialize)]
struct Response {
access_token: String,
token_type: String,
expires_in: u64,
scope: Option<String>,
}
let r: Response = serde_json::from_slice(bytes).map_err(|_| Error)?;
if r.access_token.is_empty()
|| !r.token_type.eq_ignore_ascii_case("bearer")
|| r.expires_in == 0
|| http::header::HeaderValue::from_str(&format!("Bearer {}", r.access_token)).is_err()
{
return Err(Error);
}
Ok(Token {
value: Zeroizing::new(r.access_token),
scopes: r
.scope
.map(|s| s.split_whitespace().map(str::to_owned).collect()),
expires: Instant::now() + Duration::from_secs(r.expires_in.min(86400).saturating_sub(30)),
})
}
/// The caller owns synchronization and endpoint policy. No lock crosses an HTTP call here.
#[derive(Default)]
pub struct Cache {
tokens: BTreeMap<(String, i64), Token>,
}
impl Cache {
pub fn clear(&mut self) {
self.tokens.clear();
}
pub async fn refresh(
&mut self,
item: &str,
version: i64,
request: &Request,
http: &impl Http,
deadline: Instant,
) -> Result<Token, Error> {
let key = (item.to_owned(), version);
if let Some(t) = self.tokens.get(&key).filter(|t| t.expires > Instant::now()) {
return Ok(t.clone());
}
self.tokens.retain(|(id, _), _| id != item);
let token = decode(&http.send(request, deadline).await?)?;
self.tokens.insert(key, token.clone());
Ok(token)
}
}
+437
View File
@@ -0,0 +1,437 @@
//! Keyed grant requests, transactional decisions, explicit expiry and decision notifications.
use super::{
audit,
error::{Error, Result},
grants::{self, Grant},
seal,
store::{Cred, now},
types::{Caller, Subject},
};
use rusqlite::{OptionalExtension, TransactionBehavior, params};
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq)]
pub struct GrantRequest {
pub items: Vec<String>,
pub duration_secs: Option<u64>,
pub max_uses: Option<u64>,
pub validator: Option<String>,
}
#[derive(Clone, Debug, serde::Serialize)]
pub struct Request {
pub id: String,
pub subject: Subject,
pub payload: serde_json::Value,
pub state: String,
pub expires: i64,
pub pid: u32,
pub program: String,
pub decision: Option<String>,
pub approver: Option<String>,
}
#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
pub struct Overrides {
#[serde(default)]
pub unlimited: bool,
pub duration_secs: Option<u64>,
pub max_uses: Option<u64>,
pub validator: Option<String>,
}
fn normalised(payload: &serde_json::Value) -> serde_json::Value {
let mut value = if let Some(requested) = payload.get("requested") {
serde_json::json!({"requested":requested})
} else {
payload.clone()
};
if let Some(object) = value.as_object_mut() {
object.remove("diff");
object.remove("old");
}
fn sets(value: &mut serde_json::Value, ordered: bool) {
match value {
serde_json::Value::Object(object) => {
for (key, value) in object {
sets(value, key == "change" || key == "fields");
}
}
serde_json::Value::Array(values) => {
for value in values.iter_mut() {
sets(value, false);
}
if !ordered {
values.sort_by_cached_key(|v| v.to_string());
values.dedup();
}
}
_ => {}
}
}
sets(&mut value, false);
value
}
impl Cred {
pub(crate) fn replay(
&self,
caller: &Caller,
key: &str,
payload: &serde_json::Value,
) -> Result<Option<String>> {
let s = self.access()?;
let subject = serde_json::to_string(&Subject::requester(caller))?;
let old:Option<(String,String,String,i64,Option<String>)>=s.db.query_row("SELECT id,payload,state,expires,grant_id FROM requests WHERE subject=?1 AND key=?2 ORDER BY rowid DESC LIMIT 1",params![subject,key],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?))).optional()?;
let Some((id, prior, state, expires, grant_id)) = old else {
return Ok(None);
};
if normalised(&serde_json::from_str(&prior)?) != normalised(payload) {
return Err(Error("key_conflict"));
}
let usable = match grant_id {
None => true,
Some(id) => grants::list(&s.db)?.iter().any(|g| {
g.id == id
&& !g.revoked
&& g.exp.is_none_or(|e| e > now())
&& g.max_uses.is_none_or(|m| g.uses < m)
}),
};
Ok(
((state == "pending" && expires > now()) || (state == "approved" && usable))
.then_some(id),
)
}
pub fn file_request(
&self,
caller: &Caller,
key: &str,
mut payload: GrantRequest,
) -> Result<String> {
if payload.items.is_empty() {
return Err(Error("invalid_items"));
}
{
let s = self.access()?;
for item in &mut payload.items {
*item =
s.db.query_row(
"SELECT id FROM items WHERE id=?1 OR name=?1 ORDER BY id=?1 DESC LIMIT 1",
[&*item],
|r| r.get(0),
)
.optional()?
.unwrap_or_else(|| item.clone());
}
}
payload.items.sort();
payload.items.dedup();
payload.duration_secs = Some(payload.duration_secs.unwrap_or(3600));
if payload.validator.as_deref() == Some("") {
payload.validator = None;
}
let value = serde_json::to_value(&payload)?;
if let Some(id) = self.replay(caller, key, &value)? {
return Ok(id);
}
{
let s = self.access()?;
for item in &payload.items {
super::items::load(&s.db, item)?;
}
}
self.file_payload(caller, key, value)
}
pub(crate) fn file_payload(
&self,
caller: &Caller,
key: &str,
payload: serde_json::Value,
) -> Result<String> {
let mut s = self.access()?;
let tx =
s.db.transaction_with_behavior(TransactionBehavior::Immediate)?;
let mut expired_id = None;
let subject = Subject::requester(caller);
let subject_json = serde_json::to_string(&subject)?;
let encoded = serde_json::to_string(&payload)?;
let old:Option<(String,String,String,i64,Option<String>)>=tx.query_row("SELECT id,payload,state,expires,grant_id FROM requests WHERE subject IS ?1 AND key=?2 ORDER BY rowid DESC LIMIT 1",params![subject_json,key],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?))).optional()?;
if let Some((id, prior, state, expires, grant_id)) = old {
if normalised(&serde_json::from_str(&prior)?) != normalised(&payload) {
return Err(Error("key_conflict"));
}
if state == "pending" && expires > now() {
return Ok(id);
}
if state == "approved"
&& match grant_id {
None => true,
Some(grant_id) => grants::list(&tx)?.iter().any(|g| {
g.id == grant_id
&& !g.revoked
&& g.exp.is_none_or(|e| e > now())
&& g.max_uses.is_none_or(|m| g.uses < m)
}),
}
{
return Ok(id);
}
if state == "pending" {
tx.execute("UPDATE requests SET state='expired' WHERE id=?", [&id])?;
expired_id = Some(id);
}
}
let id = seal::identity();
tx.execute("INSERT INTO requests(id,subject,key,payload,state,expires,pid,program) VALUES(?1,?2,?3,?4,'pending',?5,?6,?7)",params![id,subject_json,key,encoded,now()+86400,caller.pid,caller.program])?;
tx.commit()?;
if let Some(expired) = expired_id {
let _ = self.decided.send(expired);
}
let _ = self.filed.send(id.clone());
Ok(id)
}
pub fn request_list(&self) -> Result<Vec<Request>> {
let s = self.access()?;
let mut q=s.db.prepare("SELECT id,subject,payload,state,expires,pid,decision,approver,program FROM requests ORDER BY rowid")?;
let rows = q.query_map([], |r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, String>(1)?,
r.get::<_, String>(2)?,
r.get(3)?,
r.get(4)?,
r.get(5)?,
r.get(6)?,
r.get(7)?,
r.get(8)?,
))
})?;
rows.map(|r| {
let (id, subject, payload, state, expires, pid, decision, approver, program) = r?;
Ok(Request {
id,
subject: serde_json::from_str(&subject)?,
payload: serde_json::from_str(&payload)?,
state,
expires,
pid,
program,
decision,
approver,
})
})
.collect()
}
pub fn request_diff(&self, id: &str) -> Result<serde_json::Value> {
self.request_list()?
.into_iter()
.find(|r| r.id == id)
.map(|r| r.payload)
.ok_or(Error("request_missing"))
}
pub fn request_decisions(&self) -> Result<Vec<Request>> {
Ok(self
.request_list()?
.into_iter()
.filter(|r| r.state != "pending")
.collect())
}
pub fn approve(
&self,
id: &str,
decision: &str,
overrides: Overrides,
approver: Option<&str>,
) -> Result<()> {
self.decide(id, decision, Some(overrides), approver)
}
pub fn deny(&self, id: &str, decision: &str, approver: Option<&str>) -> Result<()> {
self.decide(id, decision, None, approver)
}
fn decide(
&self,
id: &str,
decision: &str,
overrides: Option<Overrides>,
approver: Option<&str>,
) -> Result<()> {
let mut s = self.access()?;
let master = s.master.clone();
let tx =
s.db.transaction_with_behavior(TransactionBehavior::Immediate)?;
let (subject, payload, state, expires, pid, program): (
String,
String,
String,
i64,
u32,
String,
) = tx.query_row(
"SELECT subject,payload,state,expires,pid,program FROM requests WHERE id=?",
[id],
|r| {
Ok((
r.get(0)?,
r.get(1)?,
r.get(2)?,
r.get(3)?,
r.get(4)?,
r.get(5)?,
))
},
)?;
let subject: Subject = serde_json::from_str(&subject)?;
if state == "expired" {
return Err(Error("request_expired"));
}
if state != "pending" {
return Err(Error("already_decided"));
}
if expires <= now() {
tx.execute("UPDATE requests SET state='expired' WHERE id=?", [id])?;
tx.commit()?;
let _ = self.decided.send(id.to_owned());
return Err(Error("request_expired"));
}
let caller = Caller {
pid,
program,
conversations: match &subject {
Subject::Conversation(id) => vec![id.clone()],
Subject::Program(_) => vec![],
},
};
let mut issued_grant = None;
let state = if let Some(o) = overrides {
let parsed: serde_json::Value = serde_json::from_str(&payload)?;
if let Some(edit) = parsed.get("edit") {
let mut edit: super::edits::Edit = serde_json::from_value(edit.clone())?;
if let Some(requested) = parsed.get("requested") {
let requested: super::edits::OperationRequest =
serde_json::from_value(requested.clone())?;
let current = super::items::load(&tx, &edit.item)?;
if current.version != edit.base_version {
return Err(Error("stale"));
}
edit.config = super::edits::apply_changes(
current,
&requested.change,
0,
Some((&tx, &master)),
)?;
}
super::edits::apply(&tx, &master, &edit, &caller, approver)?;
} else if let Some(revert) = parsed.get("revert") {
super::edits::apply_revert(
&tx,
&master,
&serde_json::from_value(revert.clone())?,
&caller,
approver,
)?;
} else if let Some(create) = parsed.get("create") {
let mut item = super::items::insert_new(
&tx,
serde_json::from_value(create.clone())?,
&caller,
approver,
)?;
if parsed.get("generate_es256").and_then(|v| v.as_bool()) == Some(true) {
let private =
super::sign::generate_es256().map_err(|_| Error("key_generation"))?;
let index = item
.fields
.iter()
.position(|f| {
!f.public
&& f.field_type == super::types::FieldType::SshPrivateKey
&& f.key_type.as_deref() == Some("es256")
})
.ok_or(Error("invalid_params"))?;
let field = &item.fields[index].field_id;
let cipher =
seal::seal(&master, &format!("{}|{field}", item.id), private.as_bytes())?;
tx.execute(
"INSERT INTO fields VALUES(?1,?2,?3)",
params![item.id, field, cipher],
)?;
item.fields[index].needs_value = false;
super::items::save(&tx, &item)?;
}
} else if let Some(grant) = parsed.get("grant_create") {
let mut grant: Grant = serde_json::from_value(grant.clone())?;
grant.id = seal::identity();
grant.uses = 0;
grant.revoked = false;
grants::insert(&tx, &grant, &caller, approver)?;
} else if let Some(target) = parsed.get("grant_revoke") {
let target = target.as_str().ok_or(Error("invalid_params"))?;
if tx.execute("UPDATE grants SET revoked=1 WHERE id=?", [target])? != 1 {
return Err(Error("grant_missing"));
}
audit::record(&tx, "grant.revoke", &caller, None, approver)?;
} else if let Some(delete) = parsed.get("delete") {
super::edits::apply_delete(
&tx,
&serde_json::from_value(delete.clone())?,
&caller,
approver,
)?;
} else {
let p: GrantRequest = serde_json::from_value(parsed)?;
let duration = o.duration_secs.or(p.duration_secs).unwrap_or(3600);
if duration == 0 {
return Err(Error("invalid_duration"));
}
let duration = i64::try_from(duration).map_err(|_| Error("invalid_duration"))?;
let g = Grant {
id: seal::identity(),
items: p.items,
subject,
exp: Some(
now()
.checked_add(duration)
.ok_or(Error("invalid_duration"))?,
),
max_uses: if o.unlimited {
None
} else {
o.max_uses.or(p.max_uses)
},
uses: 0,
revoked: false,
validator: o.validator.or(p.validator),
note: None,
};
grants::insert(&tx, &g, &caller, approver)?;
issued_grant = Some(g.id);
}
"approved"
} else {
"denied"
};
tx.execute(
"UPDATE requests SET state=?2,decision=?3,approver=?4,grant_id=?5 WHERE id=?1",
params![id, state, decision, approver, issued_grant],
)?;
audit::record(&tx, state, &caller, None, approver)?;
tx.commit()?;
let _ = self.decided.send(id.to_owned());
Ok(())
}
pub fn expire_requests(&self) -> Result<Vec<String>> {
let mut s = self.access()?;
let tx =
s.db.transaction_with_behavior(TransactionBehavior::Immediate)?;
let ids = {
let mut q =
tx.prepare("SELECT id FROM requests WHERE state='pending' AND expires<=?")?;
q.query_map([now()], |r| r.get::<_, String>(0))?
.collect::<rusqlite::Result<Vec<_>>>()?
};
tx.execute(
"UPDATE requests SET state='expired' WHERE state='pending' AND expires<=?",
[now()],
)?;
tx.commit()?;
for id in &ids {
let _ = self.decided.send(id.clone());
}
Ok(ids)
}
}
+12
View File
@@ -0,0 +1,12 @@
//! Fresh credentials schema. No v2 migrations or vault lifecycle.
pub(crate) const DDL: &str = "
PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA foreign_keys=ON;
CREATE TABLE IF NOT EXISTS items(id TEXT PRIMARY KEY, name TEXT UNIQUE NOT NULL, config TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS fields(item TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE, id TEXT NOT NULL, ciphertext BLOB NOT NULL, PRIMARY KEY(item,id));
CREATE TABLE IF NOT EXISTS versions(item TEXT NOT NULL, created INTEGER NOT NULL, config TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS grants(id TEXT PRIMARY KEY, items TEXT NOT NULL, subject TEXT NOT NULL, exp INTEGER, max_uses INTEGER, uses INTEGER NOT NULL DEFAULT 0, revoked INTEGER NOT NULL DEFAULT 0, validator TEXT, note TEXT);
CREATE TABLE IF NOT EXISTS requests(id TEXT PRIMARY KEY, subject TEXT, key TEXT NOT NULL, payload TEXT NOT NULL, state TEXT NOT NULL, expires INTEGER NOT NULL, pid INTEGER NOT NULL, program TEXT NOT NULL, decision TEXT, approver TEXT, grant_id TEXT);
CREATE TABLE IF NOT EXISTS audit(id INTEGER PRIMARY KEY, time INTEGER NOT NULL, event TEXT NOT NULL, pid INTEGER NOT NULL, program TEXT NOT NULL, conversation TEXT, item TEXT, approver TEXT, payload_sha256 TEXT);
CREATE INDEX IF NOT EXISTS request_keys ON requests(subject,key);
CREATE INDEX IF NOT EXISTS request_expiry ON requests(state,expires);
";
+57
View File
@@ -0,0 +1,57 @@
//! Authenticated field encryption. The field identity binds each ciphertext.
use super::error::{Error, Result};
use chacha20poly1305::{
XChaCha20Poly1305, XNonce,
aead::{Aead, KeyInit, OsRng, Payload, rand_core::RngCore},
};
pub(crate) fn random<const N: usize>() -> [u8; N] {
let mut bytes = [0; N];
OsRng.fill_bytes(&mut bytes);
bytes
}
pub(crate) fn identity() -> String {
hex::encode(random::<16>())
}
pub(crate) fn seal(key: &[u8; 32], aad: &str, value: &[u8]) -> Result<Vec<u8>> {
let nonce = random::<24>();
let cipher = XChaCha20Poly1305::new(key.into());
let ciphertext = cipher
.encrypt(
XNonce::from_slice(&nonce),
Payload {
msg: value,
aad: aad.as_bytes(),
},
)
.map_err(|_| Error("seal"))?;
Ok([nonce.to_vec(), ciphertext].concat())
}
pub(crate) fn open(key: &[u8; 32], aad: &str, value: &[u8]) -> Result<zeroize::Zeroizing<Vec<u8>>> {
if value.len() < 24 {
return Err(Error("seal"));
}
XChaCha20Poly1305::new(key.into())
.decrypt(
XNonce::from_slice(&value[..24]),
Payload {
msg: &value[24..],
aad: aad.as_bytes(),
},
)
.map(zeroize::Zeroizing::new)
.map_err(|_| Error("seal"))
}
#[cfg(test)]
mod tests {
#[test]
fn field_seal_rejects_tamper_and_wrong_identity() {
let key = super::random::<32>();
let mut cipher = super::seal(&key, "item|field", b"value").unwrap();
assert!(super::open(&key, "item|field", &cipher).unwrap().as_slice() == b"value");
assert!(super::open(&key, "other|field", &cipher).is_err());
assert!(super::open(&super::random::<32>(), "item|field", &cipher).is_err());
let end = cipher.len() - 1;
cipher[end] ^= 1;
assert!(super::open(&key, "item|field", &cipher).is_err());
}
}
+61
View File
@@ -0,0 +1,61 @@
//! Pure signing and public-key export over decrypted field values. No storage or grant policy.
use super::keys;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Error {
pub kind: &'static str,
pub message: &'static str,
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.message)
}
}
impl std::error::Error for Error {}
pub type Result<T> = std::result::Result<T, Error>;
pub(super) fn error(kind: &'static str, message: &'static str) -> Error {
Error { kind, message }
}
pub(super) fn invalid_key() -> Error {
error("invalid_key", "invalid signing key encoding or length")
}
#[derive(Debug, serde::Serialize)]
pub struct SignResult {
pub signature: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub bytes: Option<Vec<u8>>,
}
/// Sign bytes. RSA requires explicit, matching padding and digest parameters.
pub fn sign(
value: &str,
passphrase: Option<&str>,
format: &str,
payload: &[u8],
output: &str,
padding: Option<&str>,
hash: Option<&str>,
) -> Result<SignResult> {
keys::check(format, padding, hash)?;
let key = keys::read(value, format, passphrase)?;
keys::output(format, output, keys::sign_key(&key, format, payload)?)
}
/// Export only the public part of an asymmetric key.
pub fn public_key(
value: &str,
passphrase: Option<&str>,
format: &str,
output: &str,
) -> Result<String> {
keys::public(&keys::read(value, format, passphrase)?, output)
}
/// Generate a P-256 private key in PKCS#8 PEM format for ES256.
pub fn generate_es256() -> Result<zeroize::Zeroizing<String>> {
use p256::pkcs8::EncodePrivateKey;
p256::SecretKey::random(&mut rsa::rand_core::OsRng)
.to_pkcs8_pem(Default::default())
.map_err(|_| invalid_key())
}
+78
View File
@@ -0,0 +1,78 @@
//! Host-only database and master key. One mutex protects synchronous transactions.
use super::{
error::{Error, Result},
schema, seal,
};
use std::{
io::{Read, Write},
os::unix::fs::OpenOptionsExt,
path::Path,
sync::{Mutex, MutexGuard},
};
pub(crate) struct Store {
pub db: rusqlite::Connection,
pub master: zeroize::Zeroizing<[u8; 32]>,
}
pub struct Cred {
pub(crate) store: Mutex<Store>,
pub(crate) decided: tokio::sync::broadcast::Sender<String>,
pub(crate) filed: tokio::sync::broadcast::Sender<String>,
}
impl Cred {
pub fn open(host_dir: &Path) -> Result<Self> {
std::fs::create_dir_all(host_dir)?;
let path = std::env::var_os("CREDENTIALS_DIRECTORY")
.map(|p| std::path::PathBuf::from(p).join("sausage-cred-key"))
.unwrap_or_else(|| host_dir.join("cred.key"));
if std::env::var_os("CREDENTIALS_DIRECTORY").is_none() {
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&path)
{
Ok(mut file) => {
file.write_all(&seal::random::<32>())?;
file.sync_all()?;
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(e) => return Err(e.into()),
}
}
let mut bytes = zeroize::Zeroizing::new(Vec::new());
std::fs::File::open(path)?.read_to_end(&mut bytes)?;
let master = zeroize::Zeroizing::new(
bytes
.as_slice()
.try_into()
.map_err(|_| Error("master_key"))?,
);
let db = rusqlite::Connection::open(host_dir.join("cred.sqlite"))?;
db.busy_timeout(std::time::Duration::from_secs(5))?;
db.execute_batch(schema::DDL)?;
let (decided, _) = tokio::sync::broadcast::channel(64);
let (filed, _) = tokio::sync::broadcast::channel(64);
let cred = Self {
store: Mutex::new(Store { db, master }),
decided,
filed,
};
cred.expire_requests()?;
Ok(cred)
}
pub(crate) fn access(&self) -> Result<MutexGuard<'_, Store>> {
self.store.lock().map_err(|_| Error("storage"))
}
pub fn subscribe_filed(&self) -> tokio::sync::broadcast::Receiver<String> {
self.filed.subscribe()
}
pub fn subscribe(&self) -> tokio::sync::broadcast::Receiver<String> {
self.decided.subscribe()
}
}
pub(crate) fn now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock before epoch")
.as_secs() as i64
}
+111
View File
@@ -0,0 +1,111 @@
//! Public approval summaries. Do not interpolate field values or script sources.
pub fn human_duration(mut seconds: u64) -> String {
let mut parts = Vec::new();
for (size, unit) in [
(86400, "day"),
(3600, "hour"),
(60, "minute"),
(1, "second"),
] {
let count = seconds / size;
seconds %= size;
if count != 0 {
parts.push(format!(
"{count} {unit}{}",
if count == 1 { "" } else { "s" }
));
}
}
if parts.is_empty() {
"0 seconds".into()
} else {
parts.join(" ")
}
}
fn text(value: &str) -> String {
let mut out = String::new();
for c in value.chars() {
match c {
'\n' | '\r' => out.push(' '),
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'\\' | '`' | '*' | '_' | '[' | ']' | '#' | '|' => {
out.push('\\');
out.push(c);
}
_ => out.push(c),
}
}
out
}
pub fn operation_summary(operation: &super::edits::Operation) -> String {
use super::edits::Operation::*;
match operation {
CreateItem { config } => format!("Create item “{}”.", text(&config.name)),
GenerateEs256Item { config } => {
format!("Create item “{}” with a new ES256 key.", text(&config.name))
}
SetName { name } => format!("Rename the item to “{}”.", text(name)),
SetRules { .. } => "Replace the access rules. Review the rules in Details.".into(),
SetFreeUse { enabled: true } => {
"Allow anyone to use this item without a grant, within its rules.".into()
}
SetFreeUse { enabled: false } => "Require a grant to use this item.".into(),
SetValidateJs { value: None } => "Remove the validation script.".into(),
SetValidateJs { .. } => "Replace the validation script.".into(),
SetTransformJs { .. } => "Replace the transformation script.".into(),
AddField { field } => format!(
"Add {} field “{}”.",
if field.public { "public" } else { "secret" },
text(&field.name)
),
SetFieldValue { .. } => "Change a public field value.".into(),
SetFieldType { .. } => "Change a field type. Review the type in Details.".into(),
RenameField { name, .. } => format!("Rename a field to “{}”.", text(name)),
RemoveField { .. } => "Remove a field and permanently discard its value.".into(),
DeleteItem => "Delete the item and permanently discard its field values.".into(),
ItemRevert { version } => format!(
"Restore public configuration from revision {version}. Discarded secret values cannot be recovered."
),
GrantCreate { grant } => format!(
"Give access to {} credential items. Review the subject and limits in Details.",
grant.items.len()
),
GrantRevoke { .. } => "Revoke the grant. All access through this grant will stop.".into(),
}
}
/// UI presets. The runtime never interprets a preset name.
pub fn script(preset: &str) -> super::Result<String> {
let field = "const get = name => { if (typeof credential[name] !== 'string') throw Error('credential field is missing'); return credential[name]; };";
if let Some(name) = preset.strip_prefix("query:") {
let name = serde_json::to_string(name)?;
return Ok(format!(
r#"export default ({{request,credential,url}}) => {{
{field}
const name = {name};
const encode = value => encodeURIComponent(value).replace(/[!'()~]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase()).replace(/%20/g, '+');
const pairs = url.query.filter(([key]) => key !== name);
pairs.push([name,get('token')]);
return {{...request,url:url.base+'?'+pairs.map(([k,v])=>encode(k)+'='+encode(v)).join('&')+url.fragment}};
}}"#
));
}
let (name, value) = match preset {
"bearer" => ("Authorization", "'Bearer ' + get('token')"),
"basic" => (
"Authorization",
"'Basic ' + btoa(unescape(encodeURIComponent(get('username') + ':' + get('password'))))",
),
p if p.starts_with("header:") => (&p[7..], "get('token')"),
_ => return Err(super::Error("invalid_preset")),
};
let name = serde_json::to_string(name)?;
Ok(format!(
r#"export default ({{request,credential}}) => {{
{field}
const name = {name};
return {{...request,headers:[...request.headers.filter(h=>h.name.toLowerCase()!==name.toLowerCase()),{{name,value:{value}}}]}};
}}"#
))
}
+108
View File
@@ -0,0 +1,108 @@
//! Caller identity and mandatory field metadata. Reject mismatched key algorithms.
use super::error::{Error, Result};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct Caller {
pub program: String,
pub pid: u32,
pub conversations: Vec<String>,
}
/// Conversation subjects cover descendants. Program subjects cover the same binary path.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "id", rename_all = "snake_case")]
pub enum Subject {
Conversation(String),
Program(String),
}
impl Subject {
pub fn covers(&self, caller: &Caller) -> bool {
match self {
Self::Conversation(id) => caller.conversations.contains(id),
Self::Program(path) => caller.program == *path,
}
}
pub(crate) fn requester(caller: &Caller) -> Self {
caller.conversations.first().map_or_else(
|| Self::Program(caller.program.clone()),
|id| Self::Conversation(id.clone()),
)
}
pub(crate) fn valid(&self) -> bool {
match self {
Self::Conversation(id) => !id.is_empty(),
Self::Program(path) => path.starts_with('/'),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FieldType {
Text,
Note,
SshPublicKey,
SshPrivateKey,
Oauth2RefreshToken,
}
fn matches_algorithm(algorithm: ssh_key::Algorithm, format: &str) -> bool {
match algorithm {
ssh_key::Algorithm::Ed25519 => matches!(format, "ed25519" | "ed25519-raw"),
ssh_key::Algorithm::Ecdsa {
curve: ssh_key::EcdsaCurve::NistP256,
} => format == "es256",
ssh_key::Algorithm::Rsa { .. } => {
matches!(format, "rs256" | "rsa-sha2-256" | "rsa-sha2-512")
}
_ => false,
}
}
pub(crate) fn validate(
kind: &FieldType,
key_type: Option<&str>,
public: bool,
value: Option<&str>,
) -> Result<()> {
let key_format = key_type.is_some_and(|k| {
matches!(
k,
"ed25519" | "ed25519-raw" | "es256" | "rs256" | "rsa-sha2-256" | "rsa-sha2-512"
)
});
match kind {
FieldType::Text | FieldType::Note if key_type.is_none() => {}
FieldType::Oauth2RefreshToken if !public && key_type.is_none() => {}
FieldType::SshPublicKey if public && key_format => {}
FieldType::SshPrivateKey if !public && key_format => {}
_ => return Err(Error("invalid_type")),
}
if let Some(value) = value {
match kind {
FieldType::Text | FieldType::Oauth2RefreshToken if value.contains(['\r', '\n']) => {
return Err(Error("invalid_value"));
}
FieldType::SshPublicKey => {
let key =
ssh_key::PublicKey::from_openssh(value).map_err(|_| Error("invalid_value"))?;
if !matches_algorithm(key.algorithm(), key_type.unwrap_or("")) {
return Err(Error("invalid_value"));
}
}
FieldType::SshPrivateKey => {
if value
.trim()
.starts_with("-----BEGIN OPENSSH PRIVATE KEY-----")
{
let key = ssh_key::PrivateKey::from_openssh(value)
.map_err(|_| Error("invalid_value"))?;
if !matches_algorithm(key.algorithm(), key_type.unwrap_or("")) {
return Err(Error("invalid_value"));
}
} else {
super::keys::read(value, key_type.unwrap_or(""), None)
.map_err(|_| Error("invalid_value"))?;
}
}
_ => {}
}
}
Ok(())
}
+199
View File
@@ -0,0 +1,199 @@
//! Fresh, bounded V8 isolates for credential scripts. Each call owns a dedicated worker thread.
use super::script::{Error, ErrorKind, Limits, Script};
use deno_core::{JsRuntime, RuntimeOptions, v8};
use std::{
sync::{
Arc,
atomic::{AtomicBool, Ordering},
mpsc,
},
time::Duration,
};
#[derive(Default)]
pub struct V8;
fn failure(kind: ErrorKind) -> Error {
Error {
kind,
message: match kind {
ErrorKind::Limit => "script time, memory or stack limit exceeded",
ErrorKind::Syntax => "invalid script or JSON",
ErrorKind::Throw => "script threw or rejected",
ErrorKind::NonJson => "result is not JSON",
ErrorKind::Engine => "script engine failure",
}
.into(),
}
}
impl Script for V8 {
fn evaluate(
&self,
source: &str,
input: &str,
export: Option<&str>,
limits: Limits,
) -> Result<String, Error> {
let source = source.to_owned();
let input = input.to_owned();
let export = export.unwrap_or("default").to_owned();
std::thread::spawn(move || evaluate(source, input, export, limits))
.join()
.map_err(|_| failure(ErrorKind::Engine))?
}
}
fn evaluate(
source: String,
input: String,
export: String,
limits: Limits,
) -> Result<String, Error> {
let heap = limits
.max_memory_bytes
.unwrap_or(8 * 1024 * 1024)
.clamp(256 * 1024, 32 * 1024 * 1024);
// V8 uses Tokio for internal delayed tasks, even without guest extensions.
let executor = tokio::runtime::Builder::new_current_thread()
.enable_time()
.build()
.map_err(|_| failure(ErrorKind::Engine))?;
let _entered = executor.enter();
let mut runtime = JsRuntime::new(RuntimeOptions {
// Bootstrap before imposing the caller's budget: V8 cannot recover from
// bootstrap OOM because the runtime cannot install its callback yet.
create_params: Some(v8::CreateParams::default().heap_limits(0, 32 * 1024 * 1024)),
..Default::default()
});
if runtime.v8_isolate().get_heap_statistics().total_heap_size() > heap {
return Err(failure(ErrorKind::Limit));
}
// Removing a temporary callback sets the heap limit after bootstrap.
runtime.add_near_heap_limit_callback(|current, _| current);
runtime.remove_near_heap_limit_callback(heap);
let handle = runtime.v8_isolate().thread_safe_handle();
let limited = Arc::new(AtomicBool::new(false));
let flag = limited.clone();
let heap_handle = handle.clone();
runtime.add_near_heap_limit_callback(move |current, _| {
flag.store(true, Ordering::SeqCst);
heap_handle.terminate_execution();
// V8 needs emergency headroom to unwind after termination.
current + 16 * 1024 * 1024
});
let (done, wait) = mpsc::channel();
let flag = limited.clone();
let watchdog = std::thread::spawn(move || {
if wait
.recv_timeout(Duration::from_millis(
limits.max_time_ms.unwrap_or(100).clamp(1, 1000),
))
.is_err()
{
flag.store(true, Ordering::SeqCst);
handle.terminate_execution();
}
});
let task = async {
runtime
.execute_script("sandbox.js", BOOTSTRAP)
.map_err(|_| failure(ErrorKind::Engine))?;
let input = runtime
.execute_script(
"input.js",
format!("JSON.parse({})", serde_json::to_string(&input).unwrap()),
)
.map_err(|_| failure(ErrorKind::Syntax))?;
let spec = deno_core::ModuleSpecifier::parse("file:///credential.js").unwrap();
let module = runtime
.load_main_es_module_from_code(&spec, source)
.await
.map_err(|_| failure(ErrorKind::Syntax))?;
let evaluated = runtime.mod_evaluate(module);
runtime
.with_event_loop_promise(evaluated, Default::default())
.await
.map_err(|e| {
if e.to_string().contains("call stack") {
failure(ErrorKind::Limit)
} else {
failure(ErrorKind::Throw)
}
})?;
let ns = runtime
.get_module_namespace(module)
.map_err(|_| failure(ErrorKind::Engine))?;
{
deno_core::scope!(scope, &mut runtime);
let ns = v8::Local::new(scope, ns);
let global = scope.get_current_context().global(scope);
let key =
v8::String::new(scope, "__module").ok_or_else(|| failure(ErrorKind::Limit))?;
global.set(scope, key.into(), ns.into());
let input = v8::Local::new(scope, input);
let key = v8::String::new(scope, "__input").ok_or_else(|| failure(ErrorKind::Limit))?;
global.set(scope, key.into(), input);
}
let code = format!(
r#"(async()=>{{
const f=__module[{}];
if(typeof f!=='function') return JSON.stringify({{kind:'Engine'}});
let value;
try {{ value=await f(__input); }} catch(e) {{ const message=String(e?.message); return JSON.stringify({{kind: e instanceof RangeError && message.includes('call stack') ? 'Limit':'Throw'}}); }}
try {{ const text=JSON.stringify(value); return JSON.stringify(text===undefined?{{kind:'NonJson'}}:{{text}}); }} catch(e) {{ return JSON.stringify({{kind:e instanceof RangeError && String(e.message).includes('call stack') ? 'Limit':'NonJson'}}); }}
}})()"#,
serde_json::to_string(&export).unwrap()
);
let value = runtime
.execute_script("call.js", code)
.map_err(|_| failure(ErrorKind::Throw))?;
let resolve = runtime.resolve(value);
let value = runtime
.with_event_loop_promise(resolve, Default::default())
.await
.map_err(|_| failure(ErrorKind::Engine))?;
let text = {
deno_core::scope!(scope, &mut runtime);
v8::Local::new(scope, value).to_rust_string_lossy(scope)
};
let result: serde_json::Value =
serde_json::from_str(&text).map_err(|_| failure(ErrorKind::Engine))?;
match result["text"].as_str() {
Some(s) => Ok(s.to_owned()),
None => Err(failure(match result["kind"].as_str() {
Some("NonJson") => ErrorKind::NonJson,
Some("Throw") => ErrorKind::Throw,
Some("Limit") => ErrorKind::Limit,
_ => ErrorKind::Engine,
})),
}
};
let result = executor.block_on(task);
let _ = done.send(());
let _ = watchdog.join();
if limited.load(Ordering::SeqCst) {
Err(failure(ErrorKind::Limit))
} else {
result
}
}
const BOOTSTRAP: &str = r#"
delete globalThis.Date; delete Math.random;
for (const name of ['Deno','__bootstrap','console','performance']) delete globalThis[name];
const alphabet='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
globalThis.btoa = value => {
const s=String(value); let out='';
for(let i=0;i<s.length;i+=3){
const a=s.charCodeAt(i), b=s.charCodeAt(i+1), c=s.charCodeAt(i+2);
if(a>255||b>255||c>255) throw Error('invalid base64 input');
const n=(a<<16)|((b||0)<<8)|(c||0);
out+=alphabet[(n>>>18)&63]+alphabet[(n>>>12)&63]+(i+1<s.length?alphabet[(n>>>6)&63]:'=')+(i+2<s.length?alphabet[n&63]:'=');
} return out;
};
globalThis.atob = value => {
let s=String(value).replace(/[\t\n\f\r ]/g,'');
if(s.length%4===0) s=s.replace(/==?$/,'');
if(s.length%4===1||/[^A-Za-z0-9+/]/.test(s)) throw Error('invalid base64 input');
let out='', bits=0, n=0;
for(const c of s){n=(n<<6)|alphabet.indexOf(c);bits+=6;if(bits>=8){bits-=8;out+=String.fromCharCode((n>>>bits)&255);}}
return out;
};
"#;
+247
View File
@@ -0,0 +1,247 @@
//! Passkey enrollment and single-use assertions for host-owned bindings.
//! The caller supplies cred.sqlite and serializes access to this manager.
//! Browser responses and challenges use WebAuthn JSON. Enrollment assertions
//! must bind the exact new label. Neither decision nor secret-entry assertions
//! authorize enrollment. Without passkeys, the host is unlocked and permits
//! first registration without a gate. Restart drops all pending challenges.
use anyhow::{Result, bail};
use rusqlite::{Connection, params};
use serde::{Deserialize, Serialize};
use std::{
collections::HashMap,
time::{Duration, Instant},
};
use webauthn_rs::prelude::*;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum Binding {
Decision {
request_id: String,
decision: String,
overrides: serde_json::Value,
},
SecretEntry {
item: String,
field: String,
ciphertext_sha256: String,
},
Enrollment {
label: String,
},
}
#[derive(Serialize, Deserialize)]
pub struct Gate {
pub id: String,
pub response: PublicKeyCredential,
}
#[derive(Serialize, Deserialize)]
pub struct Challenge<T> {
pub id: String,
pub options: T,
}
struct Pending<T> {
deadline: Instant,
value: T,
}
pub struct Passkeys<'a> {
db: &'a Connection,
webauthn: Webauthn,
registrations: HashMap<String, Pending<(String, bool, PasskeyRegistration)>>,
assertions: HashMap<String, Pending<(Binding, PasskeyAuthentication)>>,
}
impl<'a> Passkeys<'a> {
pub fn new(db: &'a Connection, rp_id: &str, origin: &str) -> Result<Self> {
let origin = Url::parse(origin)?;
let webauthn = WebauthnBuilder::new(rp_id, &origin)?.build()?;
db.execute_batch(
"CREATE TABLE IF NOT EXISTS passkey_credentials (
id TEXT PRIMARY KEY, label TEXT NOT NULL, credential TEXT NOT NULL)",
)?;
Ok(Self {
db,
webauthn,
registrations: HashMap::new(),
assertions: HashMap::new(),
})
}
/// Database errors leave the manager locked.
pub fn is_unlocked(&self) -> bool {
self.db
.query_row(
"SELECT NOT EXISTS(SELECT 1 FROM passkey_credentials)",
[],
|row| row.get(0),
)
.unwrap_or(false)
}
fn credentials(&self) -> Result<Vec<Passkey>> {
let mut stmt = self
.db
.prepare("SELECT credential FROM passkey_credentials")?;
let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
rows.map(|row| Ok(serde_json::from_str(&row?)?)).collect()
}
pub fn start_registration(
&mut self,
label: String,
gate: Option<Gate>,
) -> Result<Challenge<CreationChallengeResponse>> {
let gated = gate.is_some();
match gate {
None if !self.is_unlocked() => bail!("enrollment assertion required"),
None => {}
Some(Gate { id, response }) => {
let (_, binding) = self.finish_assertion(&id, response)?;
if binding
!= (Binding::Enrollment {
label: label.clone(),
})
{
bail!("invalid enrollment gate");
}
}
}
let exclude = self
.credentials()?
.iter()
.map(|p| p.cred_id().clone())
.collect();
let (options, state) = self.webauthn.start_passkey_registration(
Uuid::new_v4(),
&label,
&label,
Some(exclude),
)?;
self.registrations
.retain(|_, p| p.deadline > Instant::now());
let id = Uuid::new_v4().to_string();
self.registrations.insert(
id.clone(),
Pending {
deadline: Instant::now() + Duration::from_secs(300),
value: (label, gated, state),
},
);
Ok(Challenge { id, options })
}
pub fn finish_registration(
&mut self,
id: &str,
response: RegisterPublicKeyCredential,
) -> Result<Passkey> {
let (label, gated, state) = take(&mut self.registrations, id)?;
if !gated && !self.is_unlocked() {
bail!("enrollment assertion required");
}
let credential = self
.webauthn
.finish_passkey_registration(&response, &state)?;
self.db.execute(
"INSERT INTO passkey_credentials (id, label, credential) VALUES (?1, ?2, ?3)",
params![
serde_json::to_string(credential.cred_id())?,
label,
serde_json::to_string(&credential)?
],
)?;
Ok(credential)
}
pub fn start_assertion(
&mut self,
binding: Binding,
) -> Result<Challenge<RequestChallengeResponse>> {
let (options, state) = self
.webauthn
.start_passkey_authentication(&self.credentials()?)?;
self.assertions.retain(|_, p| p.deadline > Instant::now());
let id = Uuid::new_v4().to_string();
self.assertions.insert(
id.clone(),
Pending {
deadline: Instant::now() + Duration::from_secs(300),
value: (binding, state),
},
);
Ok(Challenge { id, options })
}
pub fn finish_assertion(
&mut self,
id: &str,
response: PublicKeyCredential,
) -> Result<(CredentialID, Binding)> {
let (binding, state) = take(&mut self.assertions, id)?;
let result = self
.webauthn
.finish_passkey_authentication(&response, &state)?;
let mut credentials = self.credentials()?;
let credential = credentials
.iter_mut()
.find(|p| p.cred_id() == result.cred_id())
.ok_or_else(|| anyhow::anyhow!("unregistered passkey"))?;
// Check the latest stored counter too. Another outstanding assertion may
// contain an older snapshot of this credential.
let stored = serde_json::to_value(&*credential)?;
let counter = stored["cred"]["counter"]
.as_u64()
.ok_or_else(|| anyhow::anyhow!("invalid stored passkey counter"))?;
if (counter > 0 || result.counter() > 0) && u64::from(result.counter()) <= counter {
bail!("passkey counter did not increase");
}
credential.update_credential(&result);
self.db.execute(
"UPDATE passkey_credentials SET credential = ?1 WHERE id = ?2",
params![
serde_json::to_string(credential)?,
serde_json::to_string(result.cred_id())?
],
)?;
Ok((result.cred_id().clone(), binding))
}
}
fn take<T>(map: &mut HashMap<String, Pending<T>>, id: &str) -> Result<T> {
let pending = map
.remove(id)
.ok_or_else(|| anyhow::anyhow!("unknown or consumed challenge"))?;
if pending.deadline <= Instant::now() {
bail!("expired challenge");
}
Ok(pending.value)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn expired_assertion() -> Result<()> {
use webauthn_authenticator_rs::{WebauthnAuthenticator, softpasskey::SoftPasskey};
let db = Connection::open_in_memory()?;
let mut host = Passkeys::new(&db, "localhost", "http://localhost:8000")?;
let start = host.start_registration("test".into(), None)?;
let mut auth = WebauthnAuthenticator::new(SoftPasskey::new(true));
let origin = Url::parse("http://localhost:8000")?;
let response = auth.do_registration(origin.clone(), start.options)?;
host.finish_registration(&start.id, response)?;
let start = host.start_assertion(Binding::Enrollment {
label: "next".into(),
})?;
let response = auth.do_authentication(origin, start.options)?;
host.assertions.get_mut(&start.id).unwrap().deadline = Instant::now();
assert!(host.finish_assertion(&start.id, response).is_err());
assert!(!host.assertions.contains_key(&start.id));
Ok(())
}
}
+162
View File
@@ -0,0 +1,162 @@
//! Credential request snapshots and host-only model proxy configuration.
use crate::cred::{store::Cred, types::Subject};
use deno_core::{OpState, op2};
use deno_error::JsErrorBox;
use serde_json::{Value, json};
use std::{cell::RefCell, rc::Rc, sync::Arc};
pub(super) struct Notifications {
filed: tokio::sync::Mutex<tokio::sync::broadcast::Receiver<String>>,
}
impl Notifications {
pub(super) fn new(cred: &Cred) -> Rc<Self> {
Rc::new(Self {
filed: tokio::sync::Mutex::new(cred.subscribe_filed()),
})
}
}
#[op2]
pub(super) async fn op_credential_next(state: Rc<RefCell<OpState>>) {
let notifications = state.borrow().borrow::<Rc<Notifications>>().clone();
let mut filed = notifications.filed.lock().await;
let _ = tokio::time::timeout(std::time::Duration::from_secs(1), filed.recv()).await;
}
#[op2]
#[serde]
pub(super) fn op_credential_requests(state: &mut OpState) -> Result<serde_json::Value, JsErrorBox> {
let cred = state.borrow::<Arc<Cred>>();
let work = || -> anyhow::Result<Value> {
cred.expire_requests()?;
// TODO(T27): prune retained records once a day after the library adds Cred::prune.
let mut rows = Vec::new();
for request in cred.request_list()? {
let Subject::Conversation(conversation) = request.subject else {
continue;
};
let names = request
.payload
.get("items")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(Value::as_str)
.map(|id| {
cred.item_get(id)
.map(|item| format!("`{}`", item.name))
.unwrap_or_else(|_| "the credential".into())
})
.collect::<Vec<_>>()
.join(", ")
})
.unwrap_or_else(|| "the credential change".into());
let message = match request.state.as_str() {
"approved" => format!("Your request for {names} was approved. Retry the command."),
"denied" => format!("Your request for {names} was denied."),
"expired" => format!(
"Your request for {names} expired. File a new request if you still need it."
),
_ => String::new(),
};
rows.push(json!({"id":request.id,"conversation":conversation,"state":request.state,"message":message}));
}
Ok(json!(rows))
};
work().map_err(|_| JsErrorBox::generic("Cannot read credential requests"))
}
pub(super) fn proxy(cred: &Cred) -> anyhow::Result<Option<Value>> {
use crate::cred::{
items::{Field, Item},
types::{Caller, FieldType},
};
let caller = Caller {
pid: 0,
program: "host".into(),
conversations: vec![],
};
let item = cred
.item_list()?
.into_iter()
.find(|item| item.name == "llm-proxy");
let item = match item {
Some(item) => item,
None => {
let (Ok(base), Ok(key)) = (
std::env::var("PI_PROXY_BASE_URL"),
std::env::var("PI_PROXY_API_KEY"),
) else {
return Ok(None);
};
let mut store = cred.access()?;
let master = store.master.clone();
let tx = store
.db
.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
let mut item = crate::cred::items::insert_new(
&tx,
Item {
id: String::new(),
name: "llm-proxy".into(),
version: 0,
free_use: false,
rules: vec![],
validate_js: None,
transform_js: "export default (ctx) => ctx.request".into(),
fields: vec![
Field {
field_id: String::new(),
name: "base_url".into(),
public: true,
value: Some(base),
needs_value: false,
field_type: FieldType::Text,
key_type: None,
},
Field {
field_id: String::new(),
name: "api_key".into(),
public: false,
value: None,
needs_value: true,
field_type: FieldType::Text,
key_type: None,
},
],
},
&caller,
Some("host bootstrap"),
)?;
let field = &item
.fields
.iter()
.find(|f| f.name == "api_key")
.unwrap()
.field_id;
let sealed =
crate::cred::seal::seal(&master, &format!("{}|{field}", item.id), key.as_bytes())?;
tx.execute(
"INSERT INTO fields VALUES(?1,?2,?3)",
rusqlite::params![item.id, field, sealed],
)?;
item.fields
.iter_mut()
.find(|f| f.name == "api_key")
.unwrap()
.needs_value = false;
crate::cred::items::save(&tx, &item)?;
tx.commit()?;
item
}
};
Ok(Some(cred.credential(&item, true)?))
}
#[op2]
#[serde]
pub(super) fn op_model_proxy(state: &mut OpState) -> Result<serde_json::Value, JsErrorBox> {
proxy(state.borrow::<Arc<Cred>>())
.map(|v| v.unwrap_or(Value::Null))
.map_err(|_| JsErrorBox::generic("Cannot read llm-proxy credential"))
}
+41 -10
View File
@@ -1,4 +1,5 @@
//! Durable V8 harness, host-side SQLite storage, and world tool operations.
mod credentials;
mod environment;
use crate::{exec::Exec, world::World};
use deno_core::{JsRuntime, OpState, RuntimeOptions, op2};
@@ -78,6 +79,9 @@ deno_core::extension!(
storage,
ops = [
op_sql,
credentials::op_credential_requests,
credentials::op_model_proxy,
credentials::op_credential_next,
environment::op_file,
environment::op_exec,
environment::op_exec_next,
@@ -112,6 +116,18 @@ pub async fn run_with_exec(world: World, exec: Arc<dyn Exec>) -> anyhow::Result<
/// Evaluate a bundled harness module with the same storage and tool operations as the console.
/// The caller must use a current-thread Tokio runtime.
pub async fn run_module(world: World, exec: Arc<dyn Exec>, source: &str) -> anyhow::Result<()> {
let cred = Arc::new(crate::cred::store::Cred::open(&world.host_dir)?);
run_module_with_credentials(world, exec, cred, source).await
}
/// Run a harness with the credentials handle shared by the host API.
pub async fn run_module_with_credentials(
world: World,
exec: Arc<dyn Exec>,
cred: Arc<crate::cred::store::Cred>,
source: &str,
) -> anyhow::Result<()> {
let proxy = credentials::proxy(&cred)?;
let mut runtime = JsRuntime::new(RuntimeOptions {
extensions: vec![
deno_telemetry::deno_telemetry::init(),
@@ -162,11 +178,11 @@ pub async fn run_module(world: World, exec: Arc<dyn Exec>, source: &str) -> anyh
deno_permissions::RuntimePermissionDescriptorParser::new(sys_traits::impls::RealSys),
)));
runtime.execute_script("bootstrap.js", include_str!("../../js/bootstrap.js"))?;
let env: serde_json::Map<String, Value> = ["PI_PROXY_BASE_URL", "PI_PROXY_API_KEY"]
.into_iter()
.filter_map(|key| std::env::var(key).ok().map(|v| (key.to_owned(), json!(v))))
.collect();
runtime.execute_script("env.js", format!("process.env = {};", Value::Object(env)))?;
runtime
.op_state()
.borrow_mut()
.put(credentials::Notifications::new(&cred));
runtime.op_state().borrow_mut().put(cred);
runtime
.op_state()
.borrow_mut()
@@ -186,11 +202,26 @@ pub async fn run_module(world: World, exec: Arc<dyn Exec>, source: &str) -> anyh
.load_main_es_module_from_code(&specifier, source.to_owned())
.await?;
let result = runtime.mod_evaluate(module);
runtime
.with_event_loop_promise(result, Default::default())
.await?;
runtime.run_event_loop(Default::default()).await?;
Ok(())
let result = async {
runtime
.with_event_loop_promise(result, Default::default())
.await?;
runtime.run_event_loop(Default::default()).await?;
Ok::<_, anyhow::Error>(())
}
.await;
result.map_err(|error| {
let mut text = error.to_string();
if let Some(key) = proxy
.as_ref()
.and_then(|v| v.get("api_key"))
.and_then(Value::as_str)
&& !key.is_empty()
{
text = text.replace(key, "[redacted]");
}
anyhow::anyhow!(text)
})
}
/// Open a world and resume its durable root conversation.
+8 -1
View File
@@ -5,6 +5,9 @@ use std::path::PathBuf;
#[derive(Parser)]
#[command(version, about)]
struct Cli {
/// Shared host-user compile cache (otherwise SAUSAGE_CACHE or the XDG cache folder).
#[arg(long, global = true)]
cache: Option<PathBuf>,
#[command(subcommand)]
command: Command,
}
@@ -29,7 +32,11 @@ enum Command {
#[tokio::main]
async fn main() -> anyhow::Result<()> {
match Cli::parse().command {
let cli = Cli::parse();
if let Some(cache) = cli.cache {
sausage::process::configure_cache(cache)?;
}
match cli.command {
Command::Run { world, userland } => {
let bundle =
userland.or_else(|| std::env::var_os("SAUSAGE_USERLAND").map(PathBuf::from));
+236
View File
@@ -0,0 +1,236 @@
//! Shares compiled programs and single-flight compilation across worlds in one host process.
use super::{Compiled, State, adapter, bindings};
use std::{
collections::HashMap,
path::{Path, PathBuf},
sync::{Arc, Mutex, OnceLock},
};
use tokio::sync::watch;
type Compilation = watch::Receiver<Option<Result<Compiled, String>>>;
use wasmtime::{
Engine,
component::{Component, Linker},
};
use wasmtime_wasi::p2::bindings::CommandPre;
static DIRECTORY: OnceLock<PathBuf> = OnceLock::new();
static SHARED: Mutex<Option<Arc<Compiler>>> = Mutex::new(None);
/// Select the host user's cache before opening a world.
pub fn configure(directory: PathBuf) -> anyhow::Result<()> {
DIRECTORY
.set(directory)
.map_err(|_| anyhow::anyhow!("cache directory already configured"))
}
fn directory() -> anyhow::Result<PathBuf> {
if let Some(dir) = DIRECTORY.get() {
return Ok(dir.clone());
}
if let Some(dir) = std::env::var_os("SAUSAGE_CACHE") {
return Ok(dir.into());
}
if let Some(dir) = std::env::var_os("XDG_CACHE_HOME") {
return Ok(PathBuf::from(dir).join("sausage"));
}
Ok(
PathBuf::from(std::env::var_os("HOME").ok_or_else(|| anyhow::anyhow!("HOME is not set"))?)
.join(".cache/sausage"),
)
}
pub(super) fn shared() -> anyhow::Result<Arc<Compiler>> {
let mut shared = SHARED.lock().unwrap();
if shared.is_none() {
*shared = Some(Compiler::new(&directory()?)?);
}
Ok(shared.as_ref().unwrap().clone())
}
pub(super) struct Compiler {
pub engine: Engine,
programs: Mutex<HashMap<Vec<u8>, Compilation>>,
#[cfg(test)]
before_compile: Mutex<Option<Box<dyn FnOnce() + Send>>>,
#[cfg(test)]
compilations: std::sync::atomic::AtomicUsize,
}
impl Compiler {
pub fn new(directory: &Path) -> anyhow::Result<Arc<Self>> {
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
let mut config = wasmtime::Config::new();
config.epoch_interruption(true);
// Keep Cranelift's default optimization: compiles are cached per content, and guests run
// many times. OptLevel::None saved only 12.7% of compile time (T15) but slows every run.
let mut cache = wasmtime::CacheConfig::new();
cache.with_directory(directory);
config.cache(Some(wasmtime::Cache::new(cache)?));
Ok(Arc::new(Self {
engine: Engine::new(&config)?,
programs: Mutex::new(HashMap::new()),
#[cfg(test)]
before_compile: Mutex::new(None),
#[cfg(test)]
compilations: std::sync::atomic::AtomicUsize::new(0),
}))
}
pub async fn get(self: &Arc<Self>, bytes: Vec<u8>) -> anyhow::Result<Compiled> {
let mut result = self
.programs
.lock()
.unwrap()
.entry(bytes.clone())
.or_insert_with(|| {
let (tx, rx) = watch::channel(None);
let compiler = self.clone();
// The job owns completion even if a command or a precompile waiter is cancelled.
tokio::spawn(async move {
let result =
tokio::task::spawn_blocking(move || compiler.compile(&bytes)).await;
tx.send_replace(Some(
result
.map_err(anyhow::Error::from)
.and_then(|r| r)
.map_err(|e| format!("{e:#}")),
));
});
rx
})
.clone();
loop {
if let Some(result) = result.borrow().clone() {
return result.map_err(|e| anyhow::anyhow!(e));
}
result.changed().await?;
}
}
fn compile(&self, bytes: &[u8]) -> anyhow::Result<Compiled> {
#[cfg(test)]
{
self.compilations
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
if let Some(hook) = self.before_compile.lock().unwrap().take() {
hook();
}
}
if bytes.get(..8) == Some(b"\0asm\x01\0\0\0") {
let module = wasmtime::Module::new(&self.engine, bytes)?;
let mut linker = wasmtime::Linker::new(&self.engine);
sausage_vfs::preview1::add_to_linker(&mut linker, |s: &mut State| {
s.preview1.as_mut().unwrap()
})?;
Ok(Compiled::Module(linker.instantiate_pre(&module)?))
} else {
let component = Component::new(&self.engine, bytes)?;
let mut linker = Linker::new(&self.engine);
wasmtime_wasi::p2::add_to_linker_async(&mut linker)?;
wasmtime_wasi_http::p2::add_only_http_to_linker_async(&mut linker)?;
adapter::add_to_linker(&mut linker, |s: &mut State| adapter::View {
adapter: &mut s.fs,
table: &mut s.table,
})?;
bindings::sausage::process::host::add_to_linker::<_, wasmtime::component::HasSelf<_>>(
&mut linker,
|s| s,
)?;
Ok(Compiled::Component(CommandPre::new(
linker.instantiate_pre(&component)?,
)?))
}
}
}
/// Compile programs in both command folders without blocking world startup.
pub(super) fn precompile(
compiler: Arc<Compiler>,
root: Arc<dyn sausage_vfs::Layer>,
) -> tokio::task::JoinHandle<anyhow::Result<usize>> {
tokio::spawn(async move {
let mut tasks = tokio::task::JoinSet::new();
// Leave CPU and file bandwidth for commands during startup.
let slots = Arc::new(tokio::sync::Semaphore::new(2));
for folder in ["/bin", "/usr/local/bin"] {
let entries = match root.list(folder).await {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
Err(e) => return Err(e.into()),
};
for entry in entries.into_iter().filter(|entry| !entry.directory) {
let root = root.clone();
let compiler = compiler.clone();
let path = format!("{folder}/{}", entry.name);
let slots = slots.clone();
tasks.spawn(async move {
let _permit = slots.acquire().await?;
let file = root
.open(
&path,
sausage_vfs::Open {
follow: true,
..Default::default()
},
)
.await?;
let bytes = file.read(0, file.stat().await?.size as usize).await?;
if bytes.get(..4) != Some(b"\0asm") {
return Ok::<_, anyhow::Error>(false);
}
compiler.get(bytes).await?;
Ok(true)
});
}
}
let mut count = 0;
while let Some(result) = tasks.join_next().await {
if result?? {
count += 1;
}
}
Ok(count)
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::exec::{Command, Exec, Options};
use std::{future::Future, sync::atomic::Ordering};
#[tokio::test(flavor = "multi_thread")]
async fn command_joins_precompile_even_after_waiter_cancellation() {
let temp = tempfile::tempdir().unwrap();
let compiler = Compiler::new(&temp.path().join("cache")).unwrap();
let world = crate::world::World::open(&temp.path().join("world")).unwrap();
let bytes =
wat::parse_str(r#"(module (memory (export "memory") 1) (func (export "_start")))"#)
.unwrap();
std::fs::create_dir_all(temp.path().join("world/world/bin")).unwrap();
std::fs::write(temp.path().join("world/world/bin/program.wasm"), &bytes).unwrap();
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
let (finish_tx, finish_rx) = std::sync::mpsc::channel();
*compiler.before_compile.lock().unwrap() = Some(Box::new(move || {
started_tx.send(()).unwrap();
finish_rx.recv().unwrap();
}));
// Startup returns while the compiler remains blocked behind the test gate.
let runtime = super::super::Runtime::with_compiler(world, compiler.clone()).unwrap();
started_rx.await.unwrap();
let background = compiler.clone();
let precompile = tokio::spawn(async move { background.get(bytes).await });
let mut output = |_: crate::exec::Stream, _: &[u8]| {};
let command = runtime.exec(
Command::Argv(vec!["/bin/program".into()]),
Options::default(),
&mut output,
);
tokio::pin!(command);
let pending = std::future::poll_fn(|cx| {
std::task::Poll::Ready(command.as_mut().poll(cx).is_pending())
})
.await;
assert!(pending);
assert_eq!(compiler.compilations.load(Ordering::Relaxed), 1);
precompile.abort();
let _ = precompile.await;
finish_tx.send(()).unwrap();
assert_eq!(command.await.unwrap(), 0);
assert_eq!(compiler.compilations.load(Ordering::Relaxed), 1);
assert!(!temp.path().join("world/host/compile-cache").exists());
}
}
+60 -47
View File
@@ -5,7 +5,7 @@ use crate::{
};
use sausage_vfs::{
Mounts,
adapter::{self, Adapter, View},
adapter::{self, Adapter},
};
use std::{
collections::HashMap,
@@ -22,7 +22,7 @@ use tokio::{
};
use wasmtime::{
Engine, Store,
component::{Component, Linker, Resource, ResourceTable},
component::{Resource, ResourceTable},
};
use wasmtime_wasi::{
WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView,
@@ -45,6 +45,8 @@ mod bindings {
});
}
use bindings::sausage::process::host::{Host, HostChild, Request, Stdio};
mod cache;
pub use cache::configure as configure_cache;
mod services;
mod stdio;
pub use services::supervise;
@@ -77,7 +79,8 @@ struct Entry {
pub struct Runtime {
world: World,
engine: Engine,
cache: Mutex<HashMap<Vec<u8>, Compiled>>,
compiler: Arc<cache::Compiler>,
cache: Mutex<std::collections::HashSet<Vec<u8>>>,
entries: Mutex<HashMap<u32, Entry>>,
next: AtomicU32,
alive: Arc<AtomicBool>,
@@ -138,14 +141,14 @@ impl Runtime {
Self::new(World::open(path)?)
}
pub fn new(world: World) -> anyhow::Result<Arc<Self>> {
// Deno and WASI HTTP enable different providers. Select one before HTTPS creates a client.
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
let mut config = wasmtime::Config::new();
config.epoch_interruption(true);
let mut cache = wasmtime::CacheConfig::new();
cache.with_directory(world.host_dir.join("compile-cache"));
config.cache(Some(wasmtime::Cache::new(cache)?));
let engine = Engine::new(&config)?;
Self::with_compiler(world, cache::shared()?)
}
/// Open an independent engine using an explicit shared disk cache.
pub fn new_with_cache(world: World, directory: &Path) -> anyhow::Result<Arc<Self>> {
Self::with_compiler(world, cache::Compiler::new(directory)?)
}
fn with_compiler(world: World, compiler: Arc<cache::Compiler>) -> anyhow::Result<Arc<Self>> {
let engine = compiler.engine.clone();
let alive = Arc::new(AtomicBool::new(true));
let ticking = alive.clone();
let clock = engine.clone();
@@ -155,19 +158,57 @@ impl Runtime {
clock.increment_epoch();
}
});
Ok(Arc::new(Self {
let runtime = Arc::new(Self {
world,
engine,
alive,
cache: Mutex::new(HashMap::new()),
compiler,
cache: Mutex::new(Default::default()),
entries: Mutex::new(HashMap::new()),
next: AtomicU32::new(1),
}))
});
cache::precompile(runtime.compiler.clone(), runtime.world.root.clone());
Ok(runtime)
}
/// Number of distinct program contents compiled by this runtime.
pub fn compiled_programs(&self) -> usize {
self.cache.lock().unwrap().len()
}
async fn resolve_program(
&self,
cwd: &str,
name: &str,
env: &[(String, String)],
) -> anyhow::Result<String> {
let paths = if name.contains('/') {
vec![sausage_vfs::resolve(cwd, name)]
} else {
let path = env
.iter()
.find(|(key, _)| key == "PATH")
.map(|(_, value)| value.as_str())
.unwrap_or("/usr/local/bin:/bin");
path.split(':')
.map(|folder| {
sausage_vfs::resolve(
cwd,
&std::path::Path::new(folder).join(name).to_string_lossy(),
)
})
.collect()
};
for path in paths {
for candidate in [path.clone(), format!("{path}.wasm")] {
match self.world.root.stat(&candidate, true).await {
Ok(stat) if !stat.directory => return Ok(candidate),
Ok(_) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e.into()),
}
}
}
anyhow::bail!("program not found: {name}")
}
fn kill_command(&self, command: u32) {
for entry in self.entries.lock().unwrap().values() {
if entry.command == command {
@@ -222,7 +263,9 @@ impl Runtime {
request.cwd = sausage_vfs::resolve("/", &request.cwd);
request.env.retain(|(name, _)| name != "PWD");
request.env.push(("PWD".into(), request.cwd.clone()));
let executable = sausage_vfs::resolve(&request.cwd, &request.program);
let executable = self
.resolve_program(&request.cwd, &request.program, &request.env)
.await?;
let file = self
.world
.root
@@ -237,38 +280,8 @@ impl Runtime {
let bytes = file
.read(0, usize::try_from(file.stat().await?.size)?)
.await?;
let pre = {
let mut cache = self.cache.lock().unwrap();
if let Some(pre) = cache.get(&bytes) {
pre.clone()
} else {
let pre = if bytes.get(..8) == Some(b"\0asm\x01\0\0\0") {
let module = wasmtime::Module::new(&self.engine, &bytes)?;
let mut linker = wasmtime::Linker::new(&self.engine);
sausage_vfs::preview1::add_to_linker(&mut linker, |s: &mut State| {
s.preview1.as_mut().unwrap()
})?;
Compiled::Module(linker.instantiate_pre(&module)?)
} else {
let component = Component::new(&self.engine, &bytes)?;
let mut linker = Linker::new(&self.engine);
wasmtime_wasi::p2::add_to_linker_async(&mut linker)?;
// The library linker includes exit-with-code. Only Wasmtime's CLI gates it.
wasmtime_wasi_http::p2::add_only_http_to_linker_async(&mut linker)?;
adapter::add_to_linker(&mut linker, |s: &mut State| View {
adapter: &mut s.fs,
table: &mut s.table,
})?;
bindings::sausage::process::host::add_to_linker::<
_,
wasmtime::component::HasSelf<_>,
>(&mut linker, |s| s)?;
Compiled::Component(CommandPre::new(linker.instantiate_pre(&component)?)?)
};
cache.insert(bytes, pre.clone());
pre
}
};
let pre = self.compiler.get(bytes.clone()).await?;
self.cache.lock().unwrap().insert(bytes);
let pid = self.next.fetch_add(1, Ordering::Relaxed);
let command = if command == 0 { pid } else { command };
let group = match request.group {
+23 -9
View File
@@ -19,17 +19,37 @@ pub struct World {
pub processes: crate::process::Processes,
}
fn copy_tree(source: &Path, destination: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(destination)?;
for entry in std::fs::read_dir(source)? {
let entry = entry?;
let target = destination.join(entry.file_name());
if entry.file_type()?.is_dir() {
copy_tree(&entry.path(), &target)?;
} else {
std::fs::copy(entry.path(), target)?;
}
}
Ok(())
}
impl World {
/// Open (and create if missing) the world directory `dir`, including `/proc` and `/run` mounts.
pub fn open(dir: &Path) -> anyhow::Result<Self> {
Self::open_with_userland(dir, None)
}
/// Mount an optional host bundle read-only at `/bin` (D43).
/// Copy an optional bundle into a new world. Existing worlds retain their programs.
pub fn open_with_userland(dir: &Path, userland: Option<&Path>) -> anyhow::Result<Self> {
let root_dir = dir.join("world");
let host_dir = dir.join("host");
let fresh = !root_dir.exists();
std::fs::create_dir_all(root_dir.join("usr/local/bin"))?;
if fresh && let Some(bundle) = userland {
for folder in ["bin", "src"] {
copy_tree(&bundle.join(folder), &root_dir.join(folder))?;
}
}
std::fs::create_dir_all(&host_dir)?;
let mut mounts = Mounts::default();
mounts.mount(
@@ -55,17 +75,11 @@ impl World {
}
Ok(text.into_bytes())
});
if let Some(bundle) = userland {
mounts.mount(
"/bin",
Arc::new(Host::new(bundle, Process::default())?),
true,
);
}
mounts.mount("/proc", Arc::new(proc), true);
mounts.mount("/run", Arc::new(Memory::default()), false);
let root: Arc<dyn Layer> = Arc::new(mounts);
Ok(World {
root: Arc::new(mounts),
root,
host_dir,
processes,
})
+255
View File
@@ -0,0 +1,255 @@
//! Credential core acceptance tests with a script fake and host-only temporary storage.
use sausage::cred::{
grants::Grant,
inject::Outcome,
items::*,
requests::{GrantRequest, Overrides},
script::{self, Limits, Script},
*,
};
fn caller(id: &str) -> Caller {
Caller {
program: "/bin/tool.wasm".into(),
pid: 42,
conversations: vec![id.into()],
}
}
fn item(free_use: bool) -> Item {
Item {
id: String::new(),
name: "token".into(),
version: 0,
free_use,
rules: vec![Rule {
scheme: "https".into(),
hosts: vec!["api.example".into()],
path_prefixes: vec!["/".into()],
methods: None,
}],
validate_js: None,
transform_js: String::new(),
fields: vec![Field {
field_id: String::new(),
name: "token".into(),
public: false,
value: None,
needs_value: true,
field_type: types::FieldType::Text,
key_type: None,
}],
}
}
fn parts(host: &str) -> http::request::Parts {
http::Request::get(format!("https://{host}/path"))
.body(())
.unwrap()
.into_parts()
.0
}
struct Fake;
impl Script for Fake {
fn evaluate(
&self,
source: &str,
input: &str,
_: Option<&str>,
_: Limits,
) -> std::result::Result<String, script::Error> {
let ctx: serde_json::Value = serde_json::from_str(input).unwrap();
match source {
"deny"=>Ok("false".into()),
"validate"=>{assert!(ctx["credential"].get("token").is_none());Ok("true".into())},
"throw"=>Err(script::Error{kind:script::ErrorKind::Throw,message:ctx["credential"]["token"].as_str().unwrap().into()}),
"escape"=>Ok(r#"{"url":"https://evil.example/"}"#.into()),
_=>Ok(serde_json::json!({"headers":[{"name":"authorization","value":ctx["credential"]["token"]}]}).to_string())
}
}
}
#[test]
fn inject_selection_idempotency_and_subtree() {
let dir = tempfile::tempdir().unwrap();
let c = Cred::open(dir.path()).unwrap();
let root = caller("root");
assert!(matches!(
c.inject(parts("api.example"), &root, &Fake).unwrap(),
Outcome::Forward(_)
));
let i = c.create_item(item(false), &root, "passkey").unwrap();
let first = match c.inject(parts("api.example"), &root, &Fake).unwrap() {
Outcome::Denied { item, request } => {
assert_eq!(item, "token");
request
}
_ => panic!("expected denial"),
};
let second = match c.inject(parts("api.example"), &root, &Fake).unwrap() {
Outcome::Denied { request, .. } => request,
_ => panic!("expected denial"),
};
assert_eq!(first, second);
assert_eq!(c.request_list().unwrap().len(), 1);
c.set_field(&i.id, &i.fields[0].field_id, "opaque", &root, "passkey")
.unwrap();
c.approve(&first, "allow", Overrides::default(), Some("passkey"))
.unwrap();
let child = Caller {
program: "/bin/tool.wasm".into(),
pid: 43,
conversations: vec!["child".into(), "root".into()],
};
assert!(matches!(
c.inject(parts("api.example"), &child, &Fake).unwrap(),
Outcome::Forward(_)
));
assert!(matches!(
c.inject(parts("api.example"), &caller("sibling"), &Fake)
.unwrap(),
Outcome::Denied { .. }
));
let mut other = item(true);
other.name = "other".into();
c.create_item(other, &root, "passkey").unwrap();
assert_eq!(
c.inject(parts("api.example"), &root, &Fake)
.err()
.unwrap()
.0,
"ambiguous_item"
);
assert_eq!(c.grant_list().unwrap()[0].items, vec![i.id]);
}
#[test]
fn free_use_validators_transform_recheck_and_redaction() {
for (source, validator, expected) in [
("inject", Some("validate"), None),
("inject", Some("deny"), Some("validator_denied")),
("escape", None, Some("rule_recheck")),
("throw", None, Some("script")),
] {
let dir = tempfile::tempdir().unwrap();
let c = Cred::open(dir.path()).unwrap();
let caller = caller("root");
let mut config = item(true);
config.transform_js = source.into();
config.validate_js = validator.map(Into::into);
let i = c.create_item(config, &caller, "passkey").unwrap();
let secret = "SECRET_SENTINEL_19";
c.set_field(&i.id, &i.fields[0].field_id, secret, &caller, "passkey")
.unwrap();
let result = c.inject(parts("api.example"), &caller, &Fake);
if let Some(expected) = expected {
let error = result.err().unwrap();
assert_eq!(error.0, expected);
assert!(!format!("{error:?}").contains(secret));
} else {
let Outcome::Forward(p) = result.unwrap() else {
panic!("denied")
};
assert!(p.headers["authorization"] == secret);
}
c.audit_sign(&i.id, &caller, secret.as_bytes()).unwrap();
assert!(!format!("{:?}", c.item_get(&i.id).unwrap()).contains(secret));
assert!(
!serde_json::to_string(&c.audit_list().unwrap())
.unwrap()
.contains(secret)
);
let id = c
.file_request(
&caller,
"review",
GrantRequest {
items: vec![i.id.clone()],
duration_secs: None,
max_uses: None,
validator: None,
},
)
.unwrap();
assert!(!format!("{:?}", c.request_diff(&id).unwrap()).contains(secret));
}
}
#[test]
fn atomic_budget_and_decision_notification() {
let dir = tempfile::tempdir().unwrap();
let c = std::sync::Arc::new(Cred::open(dir.path()).unwrap());
let caller = caller("root");
let i = c.create_item(item(false), &caller, "passkey").unwrap();
c.set_field(&i.id, &i.fields[0].field_id, "opaque", &caller, "passkey")
.unwrap();
c.grant_create(
Grant {
id: String::new(),
items: vec![i.id],
subject: types::Subject::Conversation("root".into()),
exp: None,
max_uses: Some(1),
uses: 0,
revoked: false,
validator: None,
note: None,
},
&caller,
Some("passkey"),
)
.unwrap();
let handles: Vec<_> = (0..2)
.map(|_| {
let c = c.clone();
let caller = caller.clone();
std::thread::spawn(move || {
matches!(
c.inject(parts("api.example"), &caller, &Fake).unwrap(),
Outcome::Forward(_)
)
})
})
.collect();
assert_eq!(
handles
.into_iter()
.map(|h| usize::from(h.join().unwrap()))
.sum::<usize>(),
1
);
let id = c.request_list().unwrap()[0].id.clone();
let mut receiver = c.subscribe();
c.deny(&id, "no", Some("passkey")).unwrap();
assert_eq!(receiver.try_recv().unwrap(), id);
assert_eq!(
c.deny(&id, "again", Some("passkey")).unwrap_err().0,
"already_decided"
);
}
#[test]
fn key_and_secret_persist() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let c = Cred::open(dir.path()).unwrap();
let caller = caller("root");
let mut config = item(true);
config.transform_js = "inject".into();
let i = c.create_item(config, &caller, "passkey").unwrap();
c.set_field(
&i.id,
&i.fields[0].field_id,
"persistent",
&caller,
"passkey",
)
.unwrap();
drop(c);
assert_eq!(
std::fs::metadata(dir.path().join("cred.key"))
.unwrap()
.permissions()
.mode()
& 0o777,
0o600
);
let c = Cred::open(dir.path()).unwrap();
let Outcome::Forward(p) = c.inject(parts("api.example"), &caller, &Fake).unwrap() else {
panic!("denied")
};
assert!(p.headers["authorization"] == "persistent");
}
+205
View File
@@ -0,0 +1,205 @@
//! Ported rule boundaries and request/edit ledger regressions.
use sausage::cred::{
edits::Edit,
items::*,
requests::{GrantRequest, Overrides},
*,
};
fn fixture() -> (tempfile::TempDir, Cred, Caller, Item) {
let dir = tempfile::tempdir().unwrap();
let c = Cred::open(dir.path()).unwrap();
let caller = Caller {
program: "/bin/tool.wasm".into(),
pid: 7,
conversations: vec!["root".into()],
};
let i = c
.create_item(
Item {
id: String::new(),
name: "item".into(),
version: 0,
free_use: false,
rules: vec![],
validate_js: None,
transform_js: String::new(),
fields: vec![Field {
field_id: String::new(),
name: "secret".into(),
public: false,
value: None,
needs_value: true,
field_type: types::FieldType::Text,
key_type: None,
}],
},
&caller,
"pk",
)
.unwrap();
(dir, c, caller, i)
}
#[test]
fn rule_matching_table() {
let mut rule:Rule=serde_json::from_value(serde_json::json!({"scheme":"https","hosts":["api.example.test","*.service.test"],"path_prefixes":["/v1/","/health"],"methods":["GET","POST"]})).unwrap();
for (scheme, host, path, method, allowed) in [
("https", "api.example.test", "/v1/a", "GET", true),
("HTTPS", "API.EXAMPLE.TEST", "/v1/a", "get", true),
("https", "a.service.test", "/health", "POST", true),
("https", "a.b.service.test", "/healthz", "POST", true),
("https", "service.test", "/health", "GET", false),
("https", "evilservice.test", "/health", "GET", false),
("https", "a.service.test.evil", "/health", "GET", false),
("https", "api.example.test.evil", "/v1/a", "GET", false),
("http", "api.example.test", "/v1/a", "GET", false),
("https", "api.example.test", "/V1/a", "GET", false),
("https", "api.example.test", "/v1", "GET", false),
("https", "api.example.test", "/v1/a", "DELETE", false),
] {
assert_eq!(rule_matches(&rule, scheme, host, path, method), allowed);
}
rule.methods = None;
assert!(rule_matches(
&rule,
"https",
"api.example.test",
"/v1/a",
"DELETE"
));
rule.methods = Some(vec![]);
assert!(!rule_matches(
&rule,
"https",
"api.example.test",
"/v1/a",
"GET"
));
rule.methods = None;
rule.path_prefixes.clear();
assert!(!rule_matches(
&rule,
"https",
"api.example.test",
"/v1/a",
"GET"
));
rule.path_prefixes.push("/".into());
rule.hosts.clear();
assert!(!rule_matches(
&rule,
"https",
"api.example.test",
"/v1/a",
"GET"
));
}
#[test]
fn revisions_needs_value_and_rename_are_metadata_only() {
let (_dir, c, caller, i) = fixture();
c.set_field(&i.id, &i.fields[0].field_id, "opaque", &caller, "pk")
.unwrap();
let old = c.item_get(&i.id).unwrap();
assert_eq!(old.version, 2);
assert!(!old.fields[0].needs_value);
assert!(old.fields[0].value.is_none());
let mut new = old.clone();
new.name = "renamed".into();
new.fields[0].name = "renamed_field".into();
let id = c
.file_edit_request(
&caller,
"rename",
Edit {
item: i.id.clone(),
base_version: 2,
config: new,
},
)
.unwrap();
assert!(!c.edit_diff(&id).unwrap().discards_values);
c.approve(&id, "yes", Overrides::default(), Some("pk"))
.unwrap();
let current = c.item_get(&i.id).unwrap();
assert_eq!(current.version, 3);
assert_eq!(current.fields[0].field_id, old.fields[0].field_id);
assert_eq!(current.name, "renamed");
assert_eq!(c.item_versions(&i.id).unwrap().len(), 2);
}
#[test]
fn stale_edits_leave_history_and_values_untouched() {
let (_dir, c, caller, i) = fixture();
let id = c
.file_edit_request(
&caller,
"edit",
Edit {
item: i.id.clone(),
base_version: 1,
config: i.clone(),
},
)
.unwrap();
c.set_field(&i.id, &i.fields[0].field_id, "opaque", &caller, "pk")
.unwrap();
assert_eq!(
c.approve(&id, "yes", Overrides::default(), Some("pk"))
.unwrap_err()
.0,
"stale"
);
assert_eq!(c.request_list().unwrap()[0].state, "pending");
assert_eq!(c.item_versions(&i.id).unwrap().len(), 1);
}
#[test]
fn key_conflict_renewal_and_expiry() {
let (dir, c, caller, i) = fixture();
let p = GrantRequest {
items: vec![i.id.clone()],
duration_secs: None,
max_uses: None,
validator: None,
};
let id = c.file_request(&caller, "key", p.clone()).unwrap();
let mut other = p.clone();
other.max_uses = Some(1);
assert_eq!(
c.file_request(&caller, "key", other).unwrap_err().0,
"key_conflict"
);
c.deny(&id, "no", Some("pk")).unwrap();
let next = c.file_request(&caller, "key", p).unwrap();
assert_ne!(id, next);
assert_eq!(c.request_decisions().unwrap().len(), 1);
let db = rusqlite::Connection::open(dir.path().join("cred.sqlite")).unwrap();
db.execute("UPDATE requests SET expires=0 WHERE id=?", [&next])
.unwrap();
let mut notification = c.subscribe();
assert_eq!(c.expire_requests().unwrap(), vec![next.clone()]);
assert_eq!(notification.try_recv().unwrap(), next);
assert_eq!(
c.approve(&next, "yes", Overrides::default(), Some("pk"))
.unwrap_err()
.0,
"request_expired"
);
assert!(c.grant_list().unwrap().is_empty());
}
#[test]
fn typed_values_and_secret_configuration_fail_without_writes() {
let (_dir, c, caller, i) = fixture();
assert_eq!(
c.set_field(&i.id, &i.fields[0].field_id, "line\nbreak", &caller, "pk")
.unwrap_err()
.0,
"invalid_value"
);
assert_eq!(c.item_get(&i.id).unwrap().version, 1);
let mut config = i.clone();
config.fields[0].value = Some("forbidden".into());
assert_eq!(
c.create_item(config, &caller, "pk").unwrap_err().0,
"secret_field"
);
assert_eq!(c.item_list().unwrap().len(), 1);
assert!(c.item_versions(&i.id).unwrap().is_empty());
}
+266
View File
@@ -0,0 +1,266 @@
//! Explicit edit operations and atomic approval regression tests.
use sausage::cred::{
edits::{Operation, OperationRequest},
items::*,
requests::Overrides,
*,
};
fn config(name: &str) -> Item {
Item {
id: String::new(),
name: name.into(),
version: 0,
free_use: false,
rules: vec![],
validate_js: None,
transform_js: String::new(),
fields: vec![Field {
field_id: String::new(),
name: "private".into(),
public: false,
value: None,
needs_value: true,
field_type: types::FieldType::SshPrivateKey,
key_type: Some("es256".into()),
}],
}
}
#[test]
fn creation_key_generation_and_deletion_keep_secrets_out_of_history() {
let dir = tempfile::tempdir().unwrap();
let c = Cred::open(dir.path()).unwrap();
let caller = Caller {
program: "/bin/tool.wasm".into(),
pid: 3,
conversations: vec!["root".into()],
};
let id = c
.file_operation_request(
&caller,
"generate",
OperationRequest {
item: None,
base_version: None,
change: vec![Operation::GenerateEs256Item {
config: config("generated"),
}],
},
)
.unwrap();
assert!(c.item_list().unwrap().is_empty());
c.approve(&id, "yes", Overrides::default(), Some("pk"))
.unwrap();
let item = c.item_list().unwrap().remove(0);
assert!(!item.fields[0].needs_value);
assert!(item.fields[0].value.is_none());
assert!(
!serde_json::to_string(&c.request_diff(&id).unwrap())
.unwrap()
.contains("PRIVATE KEY")
);
let id = c
.file_operation_request(
&caller,
"delete",
OperationRequest {
item: Some(item.id.clone()),
base_version: Some(item.version),
change: vec![Operation::DeleteItem],
},
)
.unwrap();
c.approve(&id, "yes", Overrides::default(), Some("pk"))
.unwrap();
assert!(c.item_list().unwrap().is_empty());
assert_eq!(c.item_versions(&item.id).unwrap().len(), 1);
let db = rusqlite::Connection::open(dir.path().join("cred.sqlite")).unwrap();
assert_eq!(
db.query_row("SELECT COUNT(*) FROM fields", [], |r| r.get::<_, i64>(0))
.unwrap(),
0
);
}
#[test]
fn operation_order_name_conflicts_and_stale_resolution() {
let dir = tempfile::tempdir().unwrap();
let c = Cred::open(dir.path()).unwrap();
let caller = Caller {
program: "/bin/tool.wasm".into(),
pid: 3,
conversations: vec!["root".into()],
};
let item = c.create_item(config("first"), &caller, "pk").unwrap();
c.create_item(config("taken"), &caller, "pk").unwrap();
let request = |change| OperationRequest {
item: Some(item.id.clone()),
base_version: Some(item.version),
change,
};
assert_eq!(
c.file_operation_request(
&caller,
"conflict",
request(vec![Operation::SetName {
name: "taken".into()
}])
)
.unwrap_err()
.0,
"name_conflict"
);
let id = c
.file_operation_request(
&caller,
"rename",
request(vec![
Operation::SetName {
name: "intermediate".into(),
},
Operation::SetName {
name: "last".into(),
},
]),
)
.unwrap();
let other = c
.file_operation_request(
&caller,
"other",
request(vec![Operation::SetFreeUse { enabled: true }]),
)
.unwrap();
c.approve(&other, "yes", Overrides::default(), Some("pk"))
.unwrap();
assert!(c.edit_diff(&id).unwrap().stale);
assert_eq!(
c.approve(&id, "yes", Overrides::default(), Some("pk"))
.unwrap_err()
.0,
"stale"
);
c.resolve_stale(&id, 2).unwrap();
assert!(!c.edit_diff(&id).unwrap().stale);
c.approve(&id, "yes", Overrides::default(), Some("pk"))
.unwrap();
let current = c.item_get(&item.id).unwrap();
assert_eq!(current.name, "last");
assert!(current.free_use);
}
#[test]
fn field_removal_revert_and_public_visibility() {
let dir = tempfile::tempdir().unwrap();
let c = Cred::open(dir.path()).unwrap();
let caller = Caller {
program: "/bin/tool.wasm".into(),
pid: 3,
conversations: vec!["root".into()],
};
let mut initial = config("item");
initial.fields[0].field_type = types::FieldType::Text;
initial.fields[0].key_type = None;
initial.fields.push(Field {
field_id: String::new(),
name: "public".into(),
public: true,
value: Some("first".into()),
needs_value: false,
field_type: types::FieldType::Text,
key_type: None,
});
let item = c.create_item(initial, &caller, "pk").unwrap();
c.set_field(
&item.id,
&item.fields[0].field_id,
"SECRET_REMOVED",
&caller,
"pk",
)
.unwrap();
let current = c.item_get(&item.id).unwrap();
let request = |change| OperationRequest {
item: Some(item.id.clone()),
base_version: Some(current.version),
change,
};
let id = c
.file_operation_request(
&caller,
"remove",
request(vec![
Operation::RemoveField {
field: item.fields[0].field_id.clone(),
},
Operation::SetFieldValue {
field: item.fields[1].field_id.clone(),
value: "second".into(),
},
]),
)
.unwrap();
assert!(c.edit_diff(&id).unwrap().discards_values);
c.approve(&id, "yes", Overrides::default(), Some("pk"))
.unwrap();
let id = c
.file_revert_request(&caller, "revert", &item.id, 3, 2)
.unwrap();
c.approve(&id, "yes", Overrides::default(), Some("pk"))
.unwrap();
let restored = c.item_get(&item.id).unwrap();
assert!(restored.fields[0].needs_value);
assert_eq!(restored.fields[0].field_id, item.fields[0].field_id);
assert_eq!(restored.fields[1].value.as_deref(), Some("second"));
let mut changed = restored.clone();
changed.fields[1].public = false;
changed.fields[1].value = None;
assert_eq!(
c.file_edit_request(
&caller,
"visibility",
sausage::cred::edits::Edit {
item: item.id,
base_version: restored.version,
config: changed
}
)
.unwrap_err()
.0,
"immutable_visibility"
);
assert!(!format!("{:?}", c.item_list().unwrap()).contains("SECRET_REMOVED"));
}
#[test]
fn decision_failure_rolls_back_every_write() {
let dir = tempfile::tempdir().unwrap();
let c = Cred::open(dir.path()).unwrap();
let caller = Caller {
program: "/bin/tool.wasm".into(),
pid: 3,
conversations: vec!["root".into()],
};
let id = c
.file_operation_request(
&caller,
"create",
OperationRequest {
item: None,
base_version: None,
change: vec![Operation::CreateItem {
config: config("first"),
}],
},
)
.unwrap();
let db = rusqlite::Connection::open(dir.path().join("cred.sqlite")).unwrap();
db.execute_batch("CREATE TRIGGER fail_decision BEFORE UPDATE ON requests BEGIN SELECT RAISE(ABORT, 'forced'); END;").unwrap();
assert!(
c.approve(&id, "yes", Overrides::default(), Some("pk"))
.is_err()
);
assert!(c.item_list().unwrap().is_empty());
assert!(c.audit_list().unwrap().is_empty());
assert_eq!(c.request_list().unwrap()[0].state, "pending");
db.execute_batch("DROP TRIGGER fail_decision;").unwrap();
c.approve(&id, "yes", Overrides::default(), Some("pk"))
.unwrap();
assert_eq!(c.item_list().unwrap().len(), 1);
}
+62
View File
@@ -0,0 +1,62 @@
//! WebCrypto interoperability and host key persistence for secret entry.
use deno_core::{JsRuntime, RuntimeOptions};
use sausage::cred::entry::{EntryKey, Envelope};
use std::{os::unix::fs::PermissionsExt, sync::Arc};
#[tokio::test(flavor = "current_thread")]
async fn webcrypto_entry() -> anyhow::Result<()> {
let dir = tempfile::tempdir()?;
let path = dir.path().join("entry.key");
let key = EntryKey::open(&path)?;
assert_eq!(
std::fs::metadata(&path)?.permissions().mode() & 0o777,
0o600
);
assert_eq!(key.public_key(), EntryKey::open(&path)?.public_key());
let mut runtime = JsRuntime::new(RuntimeOptions {
extensions: vec![
deno_webidl::deno_webidl::init(),
deno_web::deno_web::init(
Arc::new(deno_web::BlobStore::default()),
None,
false,
Default::default(),
),
deno_crypto::deno_crypto::init(None),
],
..Default::default()
});
runtime.execute_script(
"crypto.js",
"globalThis.crypto = Deno.core.loadExtScript('ext:deno_crypto/00_crypto.js').crypto;",
)?;
runtime.execute_script("encrypt.js", format!(r#"
globalThis.done = (async () => {{
const host = await crypto.subtle.importKey('raw', new Uint8Array({}), {{name:'ECDH', namedCurve:'P-256'}}, false, []);
const pair = await crypto.subtle.generateKey({{name:'ECDH', namedCurve:'P-256'}}, true, ['deriveBits']);
const bits = await crypto.subtle.deriveBits({{name:'ECDH', public:host}}, pair.privateKey, 256);
const material = await crypto.subtle.importKey('raw', bits, 'HKDF', false, ['deriveKey']);
const info = new Uint8Array(Array.from('sausage/secret-entry/v1', c => c.charCodeAt(0)));
const aes = await crypto.subtle.deriveKey({{name:'HKDF', hash:'SHA-256', salt:new Uint8Array(), info}}, material, {{name:'AES-GCM', length:256}}, false, ['encrypt']);
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt({{name:'AES-GCM', iv}}, aes, new Uint8Array(Array.from('browser-only-value', c => c.charCodeAt(0))));
globalThis.envelope = JSON.stringify({{ephemeral_public_key:Array.from(new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey))), iv:Array.from(iv), ciphertext:Array.from(new Uint8Array(ciphertext))}});
}})();
"#, serde_json::to_string(&key.public_key())?))?;
runtime.run_event_loop(Default::default()).await?;
let value = runtime.execute_script("result.js", "envelope")?;
let text = {
deno_core::scope!(scope, &mut runtime);
let value = deno_core::v8::Local::new(scope, value);
value.to_rust_string_lossy(scope)
};
let mut envelope: Envelope = serde_json::from_str(&text)?;
let expected = b"browser-only-value";
assert!(key.decrypt(&envelope)? == expected);
let hash = envelope.sha256();
envelope.ciphertext[0] ^= 1;
assert_ne!(hash, envelope.sha256());
let error = key.decrypt(&envelope).unwrap_err().to_string();
assert!(!error.contains(std::str::from_utf8(expected)?));
Ok(())
}
+199
View File
@@ -0,0 +1,199 @@
//! Grant expiry, revocation, validators and issuance boundaries from v2.
use sausage::cred::{
grants::Grant,
inject::Outcome,
items::*,
script::{Limits, Script},
*,
};
struct Fake;
impl Script for Fake {
fn evaluate(
&self,
source: &str,
input: &str,
_: Option<&str>,
_: Limits,
) -> std::result::Result<String, script::Error> {
let ctx: serde_json::Value = serde_json::from_str(input).unwrap();
assert_eq!(ctx["grant"]["conversation"], "root");
Ok(if source == "deny" { "false" } else { "true" }.into())
}
}
fn parts() -> http::request::Parts {
http::Request::get("https://api.test/path")
.body(())
.unwrap()
.into_parts()
.0
}
fn fixture() -> (tempfile::TempDir, Cred, Caller, Item) {
let dir = tempfile::tempdir().unwrap();
let c = Cred::open(dir.path()).unwrap();
let caller = Caller {
program: "/bin/tool.wasm".into(),
pid: 1,
conversations: vec!["root".into()],
};
let i = c
.create_item(
Item {
id: String::new(),
name: "item".into(),
version: 0,
free_use: false,
rules: vec![Rule {
scheme: "https".into(),
hosts: vec!["api.test".into()],
path_prefixes: vec!["/".into()],
methods: None,
}],
validate_js: None,
transform_js: String::new(),
fields: vec![],
},
&caller,
"pk",
)
.unwrap();
(dir, c, caller, i)
}
fn grant(item: &Item) -> Grant {
Grant {
id: String::new(),
items: vec![item.id.clone()],
subject: types::Subject::Conversation("root".into()),
exp: None,
max_uses: Some(1),
uses: 0,
revoked: false,
validator: None,
note: Some("work".into()),
}
}
#[test]
fn program_grant_covers_service_and_agent_but_not_another_program() {
let (_dir, c, _caller, _item) = fixture();
let service = Caller {
pid: 10,
program: "/bin/service.wasm".into(),
conversations: vec![],
};
let Outcome::Denied { request, .. } = c.inject(parts(), &service, &Fake).unwrap() else {
panic!("expected request")
};
assert_eq!(
c.request_list().unwrap()[0].subject,
types::Subject::Program(service.program.clone())
);
c.approve(
&request,
"allow",
sausage::cred::requests::Overrides::default(),
None,
)
.unwrap();
let agent = Caller {
pid: 11,
program: service.program.clone(),
conversations: vec!["agent".into()],
};
for caller in [&service, &agent] {
assert!(matches!(
c.inject(parts(), caller, &Fake).unwrap(),
Outcome::Forward(_)
));
}
let other = Caller {
pid: 12,
program: "/bin/other.wasm".into(),
conversations: vec![],
};
assert!(matches!(
c.inject(parts(), &other, &Fake).unwrap(),
Outcome::Denied { .. }
));
let decisions = c.audit_list().unwrap();
let row = decisions.iter().find(|r| r.event == "approved").unwrap();
assert!(row.approver.is_none());
assert_eq!(row.program, service.program);
assert!(row.conversation.is_none());
}
#[test]
fn expiry_boundary_revoke_and_exhaustion() {
let (dir, c, caller, i) = fixture();
let g = c.grant_create(grant(&i), &caller, Some("pk")).unwrap();
assert!(matches!(
c.inject(parts(), &caller, &Fake).unwrap(),
Outcome::Forward(_)
));
assert_eq!(c.grant_status(&g.id).unwrap().uses, 1);
assert!(matches!(
c.inject(parts(), &caller, &Fake).unwrap(),
Outcome::Denied { .. }
));
assert_eq!(c.grant_status(&g.id).unwrap().uses, 1);
let mut unlimited = grant(&i);
unlimited.max_uses = None;
let g = c.grant_create(unlimited, &caller, Some("pk")).unwrap();
let db = rusqlite::Connection::open(dir.path().join("cred.sqlite")).unwrap();
db.execute("UPDATE grants SET exp=0 WHERE id=?", [&g.id])
.unwrap();
assert!(matches!(
c.inject(parts(), &caller, &Fake).unwrap(),
Outcome::Denied { .. }
));
assert_eq!(c.grant_status(&g.id).unwrap().uses, 0);
c.revoke_subjects(&caller.conversations, &caller, Some("pk"))
.unwrap();
assert!(c.grant_list().unwrap().iter().all(|g| g.revoked));
}
#[test]
fn reject_bad_issuance_without_rows() {
let (_dir, c, caller, i) = fixture();
let mut g = grant(&i);
g.max_uses = Some(0);
assert_eq!(
c.grant_create(g, &caller, Some("pk")).unwrap_err().0,
"invalid_uses"
);
let mut g = grant(&i);
g.subject = types::Subject::Conversation(String::new());
assert_eq!(
c.grant_create(g, &caller, Some("pk")).unwrap_err().0,
"invalid_subject"
);
assert!(c.grant_list().unwrap().is_empty());
assert_eq!(c.audit_list().unwrap().len(), 1);
}
#[test]
fn preflight_spends_nothing_and_validator_denial_spends_one() {
let (_dir, c, caller, i) = fixture();
let mut g = grant(&i);
g.validator = Some("deny".into());
let g = c.grant_create(g, &caller, Some("pk")).unwrap();
let mut p = parts();
p.headers
.insert("bad", http::HeaderValue::from_bytes(&[0xff]).unwrap());
assert_eq!(
c.inject(p, &caller, &Fake).err().unwrap().0,
"invalid_headers"
);
assert_eq!(c.grant_status(&g.id).unwrap().uses, 0);
assert_eq!(
c.inject(parts(), &caller, &Fake).err().unwrap().0,
"validator_denied"
);
assert_eq!(c.grant_status(&g.id).unwrap().uses, 1);
let audit = c.audit_list().unwrap();
let use_row = audit.last().unwrap();
assert_eq!(use_row.event, "fetch.denied");
assert_eq!(use_row.pid, caller.pid);
assert_eq!(use_row.conversation.as_deref(), Some("root"));
assert!(
audit
.iter()
.filter(|r| r.event == "grant.create")
.all(|r| r.approver.as_deref() == Some("pk"))
);
}
+100
View File
@@ -0,0 +1,100 @@
//! Refresh form encoding, response validation, and revision-aware cache behavior.
use sausage::cred::oauth2::*;
use std::{
sync::atomic::{AtomicUsize, Ordering},
time::{Duration, Instant},
};
use zeroize::Zeroizing;
struct Fake(AtomicUsize);
impl Http for Fake {
async fn send(&self, request: &Request, _: Instant) -> Result<Zeroizing<Vec<u8>>, Error> {
assert_eq!(request.method, "POST");
self.0.fetch_add(1, Ordering::SeqCst);
Ok(Zeroizing::new(br#"{"access_token":"SYNTHETIC","token_type":"Bearer","expires_in":3600,"scope":"one two"}"#.to_vec()))
}
}
#[tokio::test]
async fn encoding_and_cache() {
let req = request(
"https://tokens.example.test/token",
"REFRESH&=+",
"CLIENT",
"SECRET&=+",
)
.unwrap();
assert!(
req.body.as_str()
== "grant_type=refresh_token&refresh_token=REFRESH%26%3D%2B&client_id=CLIENT&client_secret=SECRET%26%3D%2B"
);
assert_eq!(req.headers[1].1, req.body.len().to_string());
let fake = Fake(AtomicUsize::new(0));
let mut cache = Cache::default();
let deadline = Instant::now() + Duration::from_secs(1);
let t = cache.refresh("id", 1, &req, &fake, deadline).await.unwrap();
assert_eq!(t.scopes.unwrap(), vec!["one", "two"]);
cache.refresh("id", 1, &req, &fake, deadline).await.unwrap();
assert_eq!(fake.0.load(Ordering::SeqCst), 1);
cache.refresh("id", 2, &req, &fake, deadline).await.unwrap();
assert_eq!(fake.0.load(Ordering::SeqCst), 2);
cache.clear();
cache.refresh("id", 2, &req, &fake, deadline).await.unwrap();
assert_eq!(fake.0.load(Ordering::SeqCst), 3);
for url in [
"http://tokens.example.test/token",
"https://user@tokens.example.test/token",
"https://tokens.example.test/token#x",
] {
assert!(request(url, "r", "c", "s").is_err());
}
}
#[tokio::test]
async fn expiry_and_failure() {
struct Expired(AtomicUsize);
impl Http for Expired {
async fn send(&self, _: &Request, _: Instant) -> Result<Zeroizing<Vec<u8>>, Error> {
self.0.fetch_add(1, Ordering::SeqCst);
Ok(Zeroizing::new(
br#"{"access_token":"SYNTHETIC","token_type":"Bearer","expires_in":1}"#.to_vec(),
))
}
}
struct Failed;
impl Http for Failed {
async fn send(&self, _: &Request, _: Instant) -> Result<Zeroizing<Vec<u8>>, Error> {
Err(Error)
}
}
let req = request("https://tokens.example.test/token", "r", "c", "s").unwrap();
let mut cache = Cache::default();
let http = Expired(AtomicUsize::new(0));
let deadline = Instant::now() + Duration::from_secs(1);
cache.refresh("id", 1, &req, &http, deadline).await.unwrap();
cache.refresh("id", 1, &req, &http, deadline).await.unwrap();
assert_eq!(http.0.load(Ordering::SeqCst), 2);
assert!(
cache
.refresh("id", 2, &req, &Failed, deadline)
.await
.is_err()
);
cache.refresh("id", 2, &req, &http, deadline).await.unwrap();
assert_eq!(http.0.load(Ordering::SeqCst), 3);
}
#[test]
fn validation() {
for data in [
br#"{"error":"invalid_grant","secret":"DO NOT ECHO"}"#.as_slice(),
br#"{"access_token":"","token_type":"Bearer","expires_in":3600}"#,
br#"{"access_token":"x","token_type":"MAC","expires_in":3600}"#,
br#"{"access_token":"x","token_type":"Bearer","expires_in":0}"#,
br#"{"access_token":"x\r\ny","token_type":"Bearer","expires_in":3600}"#,
] {
let e = decode(data).err().unwrap();
assert!(!e.to_string().contains("DO NOT ECHO"));
}
assert!(
decode(br#"{"access_token":"x","token_type":"Bearer","expires_in":18446744073709551615}"#)
.is_ok()
);
}
+319
View File
@@ -0,0 +1,319 @@
//! Retained v2 item, grant, and transaction regressions over native SQLite.
use sausage::cred::{
edits::{Delete, Edit},
grants::Grant,
items::*,
requests::{GrantRequest, Overrides},
script::{Limits, Script},
*,
};
struct Fake;
impl Script for Fake {
fn evaluate(
&self,
_: &str,
_: &str,
_: Option<&str>,
_: Limits,
) -> std::result::Result<String, script::Error> {
Ok("true".into())
}
}
fn fixture() -> (tempfile::TempDir, Cred, Caller, Item) {
let dir = tempfile::tempdir().unwrap();
let cred = Cred::open(dir.path()).unwrap();
let caller = Caller {
pid: 3,
program: "/bin/tool.wasm".into(),
conversations: vec!["root".into()],
};
let item = cred
.create_item(
Item {
id: String::new(),
name: "item".into(),
version: 0,
free_use: false,
rules: vec![Rule {
scheme: "https".into(),
hosts: vec!["api.test".into()],
path_prefixes: vec!["/".into()],
methods: None,
}],
validate_js: None,
transform_js: String::new(),
fields: vec![
Field {
field_id: String::new(),
name: "first".into(),
public: false,
value: None,
needs_value: true,
field_type: types::FieldType::Text,
key_type: None,
},
Field {
field_id: String::new(),
name: "second".into(),
public: false,
value: None,
needs_value: true,
field_type: types::FieldType::Text,
key_type: None,
},
],
},
&caller,
"pk",
)
.unwrap();
(dir, cred, caller, item)
}
fn parts() -> http::request::Parts {
http::Request::get("https://api.test/")
.body(())
.unwrap()
.into_parts()
.0
}
fn grant(item: &Item) -> Grant {
Grant {
id: String::new(),
items: vec![item.id.clone()],
subject: types::Subject::Conversation("root".into()),
exp: None,
max_uses: Some(1),
uses: 0,
revoked: false,
validator: None,
note: None,
}
}
#[test]
fn missing_values_and_wrong_master_spend_nothing() {
let (dir, cred, caller, item) = fixture();
let grant = cred
.grant_create(grant(&item), &caller, Some("pk"))
.unwrap();
assert_eq!(
cred.inject(parts(), &caller, &Fake).err().unwrap().0,
"needs_value"
);
assert_eq!(cred.grant_status(&grant.id).unwrap().uses, 0);
for field in &item.fields {
cred.set_field(&item.id, &field.field_id, "opaque", &caller, "pk")
.unwrap();
}
drop(cred);
std::fs::write(dir.path().join("cred.key"), [7u8; 32]).unwrap();
let cred = Cred::open(dir.path()).unwrap();
assert_eq!(
cred.inject(parts(), &caller, &Fake).err().unwrap().0,
"seal"
);
assert_eq!(cred.grant_status(&grant.id).unwrap().uses, 0);
}
#[test]
fn field_name_swaps_preserve_ciphertexts_and_failed_edits_roll_back() {
let (dir, cred, caller, item) = fixture();
for field in &item.fields {
cred.set_field(&item.id, &field.field_id, "opaque", &caller, "pk")
.unwrap();
}
let db = rusqlite::Connection::open(dir.path().join("cred.sqlite")).unwrap();
let cipher = || {
let mut q = db
.prepare("SELECT ciphertext FROM fields ORDER BY id")
.unwrap();
q.query_map([], |r| r.get::<_, Vec<u8>>(0))
.unwrap()
.collect::<rusqlite::Result<Vec<_>>>()
.unwrap()
};
let before = cipher();
let old = cred.item_get(&item.id).unwrap();
let mut config = old.clone();
config.fields[0].name = "second".into();
config.fields[1].name = "first".into();
let id = cred
.file_edit_request(
&caller,
"swap",
Edit {
item: item.id.clone(),
base_version: old.version,
config,
},
)
.unwrap();
cred.approve(&id, "yes", Overrides::default(), Some("pk"))
.unwrap();
assert!(cipher() == before);
let current = cred.item_get(&item.id).unwrap();
let mut config = current.clone();
config.fields.clear();
let id = cred
.file_edit_request(
&caller,
"remove",
Edit {
item: item.id.clone(),
base_version: current.version,
config,
},
)
.unwrap();
let history = cred.item_versions(&item.id).unwrap().len();
let audit = cred.audit_list().unwrap().len();
db.execute_batch("CREATE TRIGGER fail_decision BEFORE UPDATE ON requests BEGIN SELECT RAISE(ABORT,'forced'); END;").unwrap();
assert!(
cred.approve(&id, "yes", Overrides::default(), Some("pk"))
.is_err()
);
assert!(cipher() == before);
assert_eq!(cred.item_get(&item.id).unwrap().version, current.version);
assert_eq!(cred.item_versions(&item.id).unwrap().len(), history);
assert_eq!(cred.audit_list().unwrap().len(), audit);
}
#[test]
fn deleted_item_revert_preserves_identity_but_never_restores_secrets() {
let (_dir, cred, caller, item) = fixture();
for field in &item.fields {
cred.set_field(&item.id, &field.field_id, "opaque", &caller, "pk")
.unwrap();
}
let current = cred.item_get(&item.id).unwrap();
let id = cred
.file_delete_request(
&caller,
"delete",
Delete {
item: item.id.clone(),
base_version: current.version,
},
)
.unwrap();
cred.approve(&id, "yes", Overrides::default(), Some("pk"))
.unwrap();
let id = cred
.file_revert_request(
&caller,
"restore",
&item.id,
current.version + 1,
current.version,
)
.unwrap();
cred.approve(&id, "yes", Overrides::default(), Some("pk"))
.unwrap();
let restored = cred.item_get(&item.id).unwrap();
assert_eq!(restored.id, item.id);
assert_eq!(restored.version, current.version + 2);
assert!(
restored
.fields
.iter()
.all(|f| f.needs_value && f.value.is_none())
);
assert_eq!(restored.fields[0].field_id, item.fields[0].field_id);
}
#[test]
fn secret_entry_keeps_oauth_cardinality_and_rolls_back_ciphertext() {
let (dir, cred, caller, item) = fixture();
let input = FieldInput {
value: "opaque",
field_type: Some(types::FieldType::Oauth2RefreshToken),
key_type: None,
};
cred.set_field_input(&item.id, &item.fields[0].field_id, &input, &caller, "pk")
.unwrap();
cred.set_field(&item.id, &item.fields[1].field_id, "opaque", &caller, "pk")
.unwrap();
let current = cred.item_get(&item.id).unwrap();
let history = cred.item_versions(&item.id).unwrap().len();
let audit = cred.audit_list().unwrap().len();
let db = rusqlite::Connection::open(dir.path().join("cred.sqlite")).unwrap();
let cipher = || {
db.query_row(
"SELECT ciphertext FROM fields WHERE item=?1 AND id=?2",
rusqlite::params![item.id, item.fields[1].field_id],
|r| r.get::<_, Vec<u8>>(0),
)
.unwrap()
};
let before = cipher();
assert_eq!(
cred.set_field_input(&item.id, &item.fields[1].field_id, &input, &caller, "pk")
.err()
.unwrap()
.0,
"invalid_params"
);
assert!(cipher() == before);
assert_eq!(cred.item_get(&item.id).unwrap().version, current.version);
assert_eq!(cred.item_versions(&item.id).unwrap().len(), history);
assert_eq!(cred.audit_list().unwrap().len(), audit);
}
#[test]
fn typed_secret_entry_and_bundle_normalization() {
let (_dir, cred, caller, item) = fixture();
let mut metadata = item.clone();
metadata.fields[0].field_type = types::FieldType::SshPrivateKey;
metadata.fields[0].key_type = Some("es256".into());
let id = cred
.file_edit_request(
&caller,
"metadata",
Edit {
item: item.id.clone(),
base_version: item.version,
config: metadata,
},
)
.unwrap();
cred.approve(&id, "yes", Overrides::default(), Some("pk"))
.unwrap();
let input = FieldInput {
value: "two\nlines",
field_type: Some(types::FieldType::Note),
key_type: None,
};
cred.set_field_input(&item.id, &item.fields[0].field_id, &input, &caller, "pk")
.unwrap();
let current = cred.item_get(&item.id).unwrap();
assert_eq!(current.fields[0].field_type, types::FieldType::Note);
assert!(current.fields[0].key_type.is_none());
assert!(current.fields[0].value.is_none());
assert_eq!(
cred.item_versions(&item.id).unwrap()[0].config.fields[0].field_type,
types::FieldType::Text
);
let p = GrantRequest {
items: vec![item.name.clone(), item.id.clone()],
duration_secs: None,
max_uses: Some(1),
validator: None,
};
let id = cred.file_request(&caller, "bundle", p).unwrap();
let p = GrantRequest {
items: vec![item.id],
duration_secs: None,
max_uses: Some(1),
validator: None,
};
assert_eq!(cred.file_request(&caller, "bundle", p).unwrap(), id);
cred.approve(
&id,
"yes",
Overrides {
unlimited: true,
..Overrides::default()
},
None,
)
.unwrap();
let g = cred.grant_list().unwrap().remove(0);
assert_eq!(g.items.len(), 1);
assert!(g.max_uses.is_none());
assert!(g.exp.is_some());
}
+346
View File
@@ -0,0 +1,346 @@
//! v2 keyed replay compares intent before current item validation or edit planning.
use sausage::cred::{
edits::*,
items::*,
requests::{GrantRequest, Overrides},
types::FieldType,
*,
};
fn fixture() -> (tempfile::TempDir, Cred, Caller, Item) {
let dir = tempfile::tempdir().unwrap();
let cred = Cred::open(dir.path()).unwrap();
let caller = Caller {
pid: 1,
program: "/bin/tool".into(),
conversations: vec!["root".into()],
};
let item = cred
.create_item(
Item {
id: String::new(),
name: "item".into(),
version: 0,
free_use: false,
rules: vec![],
validate_js: None,
transform_js: String::new(),
fields: vec![Field {
field_id: String::new(),
name: "token".into(),
public: false,
value: None,
needs_value: true,
field_type: FieldType::Text,
key_type: None,
}],
},
&caller,
"pk",
)
.unwrap();
(dir, cred, caller, item)
}
#[test]
fn approved_operations_and_deletes_replay() {
let (_dir, cred, caller, item) = fixture();
let request = OperationRequest {
item: Some(item.id.clone()),
base_version: Some(item.version),
change: vec![Operation::SetName {
name: "renamed".into(),
}],
};
let id = cred
.file_operation_request(&caller, "edit", request.clone())
.unwrap();
cred.approve(&id, "yes", Overrides::default(), Some("pk"))
.unwrap();
assert_eq!(
cred.file_operation_request(&caller, "edit", request)
.unwrap(),
id
);
let current = cred.item_get(&item.id).unwrap();
let delete = Delete {
item: item.id,
base_version: current.version,
};
let id = cred
.file_delete_request(&caller, "delete", delete.clone())
.unwrap();
cred.approve(&id, "yes", Overrides::default(), Some("pk"))
.unwrap();
assert_eq!(
cred.file_delete_request(&caller, "delete", delete).unwrap(),
id
);
}
#[test]
fn approved_replacements_and_creations_replay() {
let (_dir, cred, caller, item) = fixture();
let mut config = item.clone();
config.name = "renamed".into();
let edit = Edit {
item: item.id,
base_version: item.version,
config: config.clone(),
};
let id = cred
.file_edit_request(&caller, "replace", edit.clone())
.unwrap();
cred.approve(&id, "yes", Overrides::default(), Some("pk"))
.unwrap();
assert_eq!(
cred.file_edit_request(&caller, "replace", edit).unwrap(),
id
);
config.name = "new".into();
let request = OperationRequest {
item: None,
base_version: None,
change: vec![Operation::CreateItem { config }],
};
let id = cred
.file_operation_request(&caller, "create", request.clone())
.unwrap();
cred.approve(&id, "yes", Overrides::default(), Some("pk"))
.unwrap();
assert_eq!(
cred.file_operation_request(&caller, "create", request)
.unwrap(),
id
);
assert_eq!(cred.item_list().unwrap().len(), 2);
}
#[test]
fn grant_defaults_normalize_and_pending_requests_replay_after_deletion() {
let (_dir, cred, caller, item) = fixture();
let payload = GrantRequest {
items: vec![item.id.clone()],
duration_secs: None,
max_uses: None,
validator: Some(String::new()),
};
let id = cred.file_request(&caller, "grant", payload).unwrap();
let payload = GrantRequest {
items: vec![item.id.clone()],
duration_secs: Some(3600),
max_uses: None,
validator: None,
};
assert_eq!(
cred.file_request(&caller, "grant", payload.clone())
.unwrap(),
id
);
let delete = cred
.file_delete_request(
&caller,
"delete",
Delete {
item: item.id,
base_version: item.version,
},
)
.unwrap();
cred.approve(&delete, "yes", Overrides::default(), Some("pk"))
.unwrap();
assert_eq!(cred.file_request(&caller, "grant", payload).unwrap(), id);
assert!(
cred.approve(&id, "yes", Overrides::default(), Some("pk"))
.is_err()
);
assert_eq!(
cred.request_list()
.unwrap()
.iter()
.find(|r| r.id == id)
.unwrap()
.state,
"pending"
);
}
#[test]
fn values_are_checked_at_each_public_operation() {
let (_dir, cred, caller, item) = fixture();
let mut config = item.clone();
config.fields.push(Field {
field_id: String::new(),
name: "public".into(),
public: true,
value: Some("old".into()),
needs_value: false,
field_type: FieldType::Text,
key_type: None,
});
let id = cred
.file_edit_request(
&caller,
"setup",
Edit {
item: item.id.clone(),
base_version: item.version,
config,
},
)
.unwrap();
cred.approve(&id, "yes", Overrides::default(), Some("pk"))
.unwrap();
let current = cred.item_get(&item.id).unwrap();
let field = current
.fields
.iter()
.find(|f| f.name == "public")
.unwrap()
.field_id
.clone();
let mut request = OperationRequest {
item: Some(item.id.clone()),
base_version: Some(current.version),
change: vec![
Operation::SetFieldValue {
field: field.clone(),
value: "bad\nvalue".into(),
},
Operation::SetFieldType {
field,
field_type: FieldType::Note,
key_type: None,
},
],
};
assert_eq!(
cred.file_operation_request(&caller, "invalid", request.clone())
.err()
.unwrap()
.0,
"invalid_value"
);
assert_eq!(cred.item_get(&item.id).unwrap().version, current.version);
request.change.reverse();
let id = cred
.file_operation_request(&caller, "ordered", request)
.unwrap();
cred.approve(&id, "yes", Overrides::default(), Some("pk"))
.unwrap();
assert_eq!(
cred.item_get(&item.id).unwrap().version,
current.version + 1
);
}
#[test]
fn secret_retyping_checks_each_operation_only_on_approval() {
let (_dir, cred, caller, item) = fixture();
let field = &item.fields[0].field_id;
cred.set_field_input(
&item.id,
field,
&FieldInput {
value: "VALUE_SENTINEL\nTAIL_SENTINEL",
field_type: Some(FieldType::Note),
key_type: None,
},
&caller,
"pk",
)
.unwrap();
let current = cred.item_get(&item.id).unwrap();
let request = OperationRequest {
item: Some(item.id.clone()),
base_version: Some(current.version),
change: vec![
Operation::SetFieldType {
field: field.clone(),
field_type: FieldType::Text,
key_type: None,
},
Operation::SetFieldType {
field: field.clone(),
field_type: FieldType::Note,
key_type: None,
},
],
};
let id = cred
.file_operation_request(&caller, "types", request)
.unwrap();
assert!(
!cred.request_list().unwrap()[0]
.payload
.to_string()
.contains("VALUE_SENTINEL")
);
assert_eq!(
cred.approve(&id, "yes", Overrides::default(), Some("pk"))
.err()
.unwrap()
.0,
"invalid_value"
);
assert_eq!(cred.item_get(&item.id).unwrap().version, current.version);
assert_eq!(cred.request_list().unwrap()[0].state, "pending");
let history = cred.item_versions(&item.id).unwrap().len();
cred.set_field(&item.id, field, "single-line", &caller, "pk")
.unwrap();
let next = cred.item_get(&item.id).unwrap();
assert_eq!(next.version, current.version + 1);
assert_eq!(cred.item_versions(&item.id).unwrap().len(), history + 1);
cred.resolve_stale(&id, next.version).unwrap();
cred.approve(&id, "yes", Overrides::default(), Some("pk"))
.unwrap();
}
#[test]
fn stale_rebases_keep_original_keyed_intent() {
let (_dir, cred, caller, item) = fixture();
let request = OperationRequest {
item: Some(item.id.clone()),
base_version: Some(item.version),
change: vec![Operation::SetName {
name: "renamed".into(),
}],
};
let id = cred
.file_operation_request(&caller, "rebase", request.clone())
.unwrap();
cred.set_field(&item.id, &item.fields[0].field_id, "opaque", &caller, "pk")
.unwrap();
let current = cred.item_get(&item.id).unwrap();
cred.resolve_stale(&id, current.version).unwrap();
assert_eq!(
cred.file_operation_request(&caller, "rebase", request.clone())
.unwrap(),
id
);
cred.approve(&id, "yes", Overrides::default(), Some("pk"))
.unwrap();
assert_eq!(
cred.file_operation_request(&caller, "rebase", request)
.unwrap(),
id
);
assert!(!cred.item_get(&item.id).unwrap().fields[0].needs_value);
}
#[test]
fn operation_order_is_part_of_key_identity() {
let (_dir, cred, caller, item) = fixture();
let mut request = OperationRequest {
item: Some(item.id),
base_version: Some(item.version),
change: vec![
Operation::SetName {
name: "renamed".into(),
},
Operation::SetFreeUse { enabled: true },
],
};
cred.file_operation_request(&caller, "ordered", request.clone())
.unwrap();
request.change.reverse();
assert_eq!(
cred.file_operation_request(&caller, "ordered", request)
.err()
.unwrap()
.0,
"key_conflict"
);
}
+283
View File
@@ -0,0 +1,283 @@
//! RFC vectors and public codecs for pure signing.
use base64::{
Engine,
engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD},
};
use sausage::cred::sign::*;
fn raw(key: &[u8], alg: &str, payload: &[u8]) -> Vec<u8> {
sign(&STANDARD.encode(key), None, alg, payload, "raw", None, None)
.unwrap()
.bytes
.unwrap()
}
#[test]
fn ed25519_readers_and_wire() {
use ed25519_dalek::pkcs8::EncodePrivateKey;
let key = ed25519_dalek::SigningKey::from_bytes(&[7; 32]);
let pem = key.to_pkcs8_pem(Default::default()).unwrap();
let jwk = serde_json::json!({"kty":"OKP","crv":"Ed25519","d":URL_SAFE_NO_PAD.encode(key.to_bytes()),"x":URL_SAFE_NO_PAD.encode(key.verifying_key().as_bytes())}).to_string();
let ssh = ssh_key::PrivateKey::new(
ssh_key::private::Ed25519Keypair::from(&key).into(),
"synthetic",
)
.unwrap()
.to_openssh(Default::default())
.unwrap();
let expected = raw(&key.to_bytes(), "ed25519", b"message");
for value in [pem.as_str(), jwk.as_str(), ssh.as_str()] {
let sig = sign(value, None, "ed25519", b"message", "ssh", None, None).unwrap();
let wire = STANDARD.decode(sig.signature).unwrap();
assert_eq!(&wire[..19], b"\x00\x00\x00\x0bssh-ed25519\x00\x00\x00\x40");
assert_eq!(&wire[19..], expected);
for output in ["pem", "jwk", "ssh", "hex", "base64"] {
assert!(public_key(value, None, "ed25519", output).is_ok());
}
}
assert!(public_key(&STANDARD.encode([7; 32]), None, "hmac-sha256", "hex").is_err());
}
#[test]
fn rfc8032_and_4231() {
let seed =
hex::decode("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60").unwrap();
assert_eq!(
hex::encode(raw(&seed, "ed25519", b"")),
concat!(
"e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e06522490155",
"5fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b"
)
);
for (alg, expected) in [
(
"hmac-sha256",
"b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7",
),
(
"hmac-sha384",
"afd03944d84895626b0825f4ab46907f15f9dadbe4101ec682aa034c7cebc59cfaea9ea9076ede7f4af152e8b2fa9cb6",
),
(
"hmac-sha512",
"87aa7cdea5ef619d4ff0b4241a1d6cb02379f4e2ce4ec2787ad0b30545e17cde daa833b7d6b8a702038b274eaea3f4e4be9d914eeb61f1702e696c203a126854",
),
] {
assert_eq!(
hex::encode(raw(&[0x0b; 20], alg, b"Hi There")),
expected.replace(' ', "")
);
}
}
#[test]
fn ec_readers() {
use p256::pkcs8::EncodePrivateKey;
let secret = p256::SecretKey::from_slice(&[7; 32]).unwrap();
let public = secret.public_key();
use p256::elliptic_curve::sec1::ToEncodedPoint;
let point = public.to_encoded_point(false);
let jwk = serde_json::json!({"kty":"EC","crv":"P-256","d":URL_SAFE_NO_PAD.encode(secret.to_bytes()),"x":URL_SAFE_NO_PAD.encode(point.x().unwrap()),"y":URL_SAFE_NO_PAD.encode(point.y().unwrap())}).to_string();
let pkcs8 = secret.to_pkcs8_pem(Default::default()).unwrap();
let sec1 = secret.to_sec1_pem(Default::default()).unwrap();
let expected = raw(&[7; 32], "es256", b"message");
let ssh = ssh_key::PrivateKey::from(ssh_key::private::EcdsaKeypair::NistP256 {
public: point,
private: ssh_key::private::EcdsaPrivateKey::from(secret.clone()),
})
.to_openssh(Default::default())
.unwrap();
for value in [jwk.as_str(), pkcs8.as_str(), sec1.as_str(), ssh.as_str()] {
assert_eq!(
sign(value, None, "es256", b"message", "raw", None, None)
.unwrap()
.bytes
.unwrap(),
expected
);
let public = ssh_key::PublicKey::from_bytes(
&STANDARD
.decode(public_key(value, None, "es256", "ssh").unwrap())
.unwrap(),
)
.unwrap();
let wire = STANDARD
.decode(
sign(value, None, "es256", b"message", "ssh", None, None)
.unwrap()
.signature,
)
.unwrap();
let signature = ssh_key::Signature::try_from(wire.as_slice()).unwrap();
ed25519_dalek::Verifier::verify(&public, b"message", &signature).unwrap();
}
let mut bad: serde_json::Value = serde_json::from_str(&jwk).unwrap();
bad["x"] = serde_json::json!(URL_SAFE_NO_PAD.encode([0; 32]));
assert!(
sign(
&bad.to_string(),
None,
"es256",
b"message",
"raw",
None,
None
)
.is_err()
);
}
#[test]
fn rfc6979_and_formats() {
let key =
hex::decode("c9afa9d845ba75166b5c215767b1d6934e50c3db36e89b127b8a622b120f6721").unwrap();
let expected = concat!(
"efd48b2aacb6a8fd1140dd9cd45e81d69d2c877b56aaf991c34d0ea84eaf3716",
"f7cb1c942d657c41d436c7a1b6e29f65f3e900dbb9aff4064dc4ab2f843acda8"
);
assert_eq!(hex::encode(raw(&key, "es256", b"sample")), expected);
let key = STANDARD.encode(key);
for output in ["hex", "base64", "jws", "ssh"] {
let result = sign(&key, None, "es256", b"sample", output, None, None).unwrap();
assert!(!result.signature.is_empty());
}
for output in ["hex", "base64", "pem", "jwk", "ssh"] {
assert!(!public_key(&key, None, "es256", output).unwrap().is_empty());
}
let pem = generate_es256().unwrap();
assert!(public_key(&pem, None, "es256", "jwk").is_ok());
for bad in [vec![], vec![1; 31], vec![0; 32], vec![255; 32]] {
assert!(sign(&STANDARD.encode(bad), None, "es256", b"", "raw", None, None).is_err());
}
}
#[test]
fn rsa_readers_and_ssh_wire() {
use rsa::{
pkcs1::EncodeRsaPrivateKey,
pkcs8::EncodePrivateKey,
traits::{PrivateKeyParts, PublicKeyParts},
};
use sha2::{Digest, Sha256, Sha512};
let key = rsa::RsaPrivateKey::new(&mut rsa::rand_core::OsRng, 1024).unwrap();
let pem = key.to_pkcs8_pem(Default::default()).unwrap();
let pkcs1 = key.to_pkcs1_pem(Default::default()).unwrap();
let ssh = ssh_key::PrivateKey::new(
ssh_key::private::RsaKeypair::try_from(&key).unwrap().into(),
"synthetic",
)
.unwrap();
let openssh = ssh.to_openssh(Default::default()).unwrap();
let encrypted = ssh
.encrypt(&mut rsa::rand_core::OsRng, "synthetic")
.unwrap()
.to_openssh(Default::default())
.unwrap();
let jwk = serde_json::json!({"kty":"RSA", "n":URL_SAFE_NO_PAD.encode(key.n().to_bytes_be()), "e":URL_SAFE_NO_PAD.encode(key.e().to_bytes_be()), "d":URL_SAFE_NO_PAD.encode(key.d().to_bytes_be()), "p":URL_SAFE_NO_PAD.encode(key.primes()[0].to_bytes_be()), "q":URL_SAFE_NO_PAD.encode(key.primes()[1].to_bytes_be())}).to_string();
for (value, pass) in [
(pem.as_str(), None),
(pkcs1.as_str(), None),
(openssh.as_str(), None),
(encrypted.as_str(), Some("synthetic")),
(jwk.as_str(), None),
] {
for (alg, hash) in [
("rs256", "sha256"),
("rsa-sha2-256", "sha256"),
("rsa-sha2-512", "sha512"),
] {
let result = sign(
value,
pass,
alg,
b"message",
"raw",
Some("pkcs1v15"),
Some(hash),
)
.unwrap();
let sig = result.bytes.unwrap();
if hash == "sha512" {
key.to_public_key()
.verify(
rsa::Pkcs1v15Sign::new::<Sha512>(),
&Sha512::digest(b"message"),
&sig,
)
.unwrap();
} else {
key.to_public_key()
.verify(
rsa::Pkcs1v15Sign::new::<Sha256>(),
&Sha256::digest(b"message"),
&sig,
)
.unwrap();
}
let wire = STANDARD
.decode(
sign(
value,
pass,
alg,
b"message",
"ssh",
Some("pkcs1v15"),
Some(hash),
)
.unwrap()
.signature,
)
.unwrap();
let len = u32::from_be_bytes(wire[..4].try_into().unwrap()) as usize;
assert_eq!(
&wire[4..4 + len],
if hash == "sha512" {
b"rsa-sha2-512"
} else {
b"rsa-sha2-256"
}
);
assert_eq!(&wire[8 + len..], sig);
}
for output in ["pem", "jwk", "ssh", "base64", "hex"] {
assert!(public_key(value, pass, "rs256", output).is_ok());
}
}
assert!(sign(&pem, None, "rs256", b"", "raw", None, None).is_err());
}
#[test]
fn malformed_and_mismatched_keys() {
for value in [
"not a key",
"{broken",
"{\"kty\":\"unknown\"}",
"-----BEGIN UNKNOWN PRIVATE KEY-----\nAAAA\n-----END UNKNOWN PRIVATE KEY-----",
"-----BEGIN OPENSSH PRIVATE KEY-----\ninvalid",
"AAAA",
] {
assert!(sign(value, None, "ed25519", b"", "raw", None, None).is_err());
}
let bad=serde_json::json!({"kty":"OKP","crv":"Ed25519","d":URL_SAFE_NO_PAD.encode([1;32]),"x":URL_SAFE_NO_PAD.encode([2;32])}).to_string();
assert!(sign(&bad, None, "ed25519", b"", "raw", None, None).is_err());
let oct = serde_json::json!({"kty":"oct","k":URL_SAFE_NO_PAD.encode([11;20])}).to_string();
assert!(sign(&oct, None, "hmac-sha384", b"Hi There", "raw", None, None).is_ok());
assert!(sign(&oct, None, "ed25519", b"", "raw", None, None).is_err());
assert!(sign(&oct, None, "hmac-sha384", b"", "ssh", None, None).is_err());
assert!(sign(&oct, None, "hmac-sha384", b"", "unknown", None, None).is_err());
}
#[test]
fn rfc8292() {
use p256::ecdsa::{Signature, VerifyingKey, signature::Verifier};
let public=URL_SAFE_NO_PAD.decode("BA1Hxzyi1RUM1b5wjxsn7nGxAszw2u61m164i3MrAIxHF6YK5h4SDYic-dRuU_RCPCfA5aq9ojSwk5Y2EmClBPs").unwrap();
let signature=URL_SAFE_NO_PAD.decode("i3CYb7t4xfxCDquptFOepC9GAu_HLGkMlMuCGSK2rpiUfnK9ojFwDXb1JrErtmysazNjjvW2L9OkSSHzvoD1oA").unwrap();
let input = concat!(
"eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.",
"eyJhdWQiOiJodHRwczovL3B1c2guZXhhbXBsZS5uZXQiLCJleHAiOjE0NTM1MjM3NjgsInN1YiI6Im1haWx0bzpwdXNoQGV4YW1wbGUuY29tIn0"
);
VerifyingKey::from_sec1_bytes(&public)
.unwrap()
.verify(
input.as_bytes(),
&Signature::from_slice(&signature).unwrap(),
)
.unwrap();
}
+84
View File
@@ -0,0 +1,84 @@
//! Public approval text and template presets from v2, without vault migrations.
use sausage::cred::{
edits::Operation,
items::{Field, FieldInput},
summary::*,
types::FieldType,
};
#[test]
fn durations_use_plain_language() {
for (seconds, expected) in [
(0, "0 seconds"),
(1, "1 second"),
(60, "1 minute"),
(3600, "1 hour"),
(93604, "1 day 2 hours 4 seconds"),
] {
assert_eq!(human_duration(seconds), expected);
}
}
#[test]
fn summaries_never_include_field_values_or_script_sources() {
let value = "SECRET_SUMMARY_SENTINEL";
for operation in [
Operation::SetFieldValue {
field: "id".into(),
value: value.into(),
},
Operation::SetTransformJs {
value: value.into(),
},
Operation::AddField {
field: Field {
field_id: String::new(),
name: "token".into(),
public: false,
value: Some(value.into()),
needs_value: true,
field_type: FieldType::Text,
key_type: None,
},
},
] {
assert!(!operation_summary(&operation).contains(value));
}
let input = FieldInput {
value,
field_type: None,
key_type: None,
};
assert!(!format!("{input:?}").contains(value));
}
#[test]
fn summary_names_are_inert_and_stay_on_one_line() {
let text = operation_summary(&Operation::SetName {
name: "<x>&*\nnext".into(),
});
assert!(!text.contains('<'));
assert!(!text.contains('\n'));
assert!(text.contains("&lt;x&gt;&amp;\\* next"));
}
#[test]
fn presets_preserve_headers_and_utf8_basic_encoding() {
assert!(
script("bearer")
.unwrap()
.contains("'Bearer ' + get('token')")
);
assert!(
script("basic")
.unwrap()
.contains("unescape(encodeURIComponent")
);
assert!(
script("header:x-api-key")
.unwrap()
.contains("const name = \"x-api-key\"")
);
assert!(
script("query:access_token")
.unwrap()
.contains("url.query.filter")
);
assert_eq!(script("unknown").unwrap_err().0, "invalid_preset");
}
+222
View File
@@ -0,0 +1,222 @@
//! Behavioral contract for the credential-only isolate.
use sausage::cred::{
script::{ErrorKind, Limits, Script},
v8::V8,
};
use std::time::{Duration, Instant};
fn run(s: &str) -> Result<String, sausage::cred::script::Error> {
V8.evaluate(s, "null", None, Limits::default())
}
#[test]
fn modules_promises_and_json() {
assert_eq!(
V8.evaluate(
"JSON.parse=()=>false; export default x=>x",
"42",
None,
Limits::default()
)
.unwrap(),
"42"
);
let error = run("export default ()=>{throw Error('DO NOT ECHO')}").unwrap_err();
assert!(!error.message.contains("DO NOT ECHO"));
assert_eq!(
V8.evaluate(
"export default x=>({value:x}); export const other=x=>x+1",
"42",
None,
Limits::default()
)
.unwrap(),
"{\"value\":42}"
);
assert_eq!(
V8.evaluate(
"export const other=x=>x+1",
"42",
Some("other"),
Limits::default()
)
.unwrap(),
"43"
);
assert_eq!(
run("await Promise.resolve(); export default async x=>{await Promise.resolve();return 42}")
.unwrap(),
"42"
);
for (s, k) in [
("export default (", ErrorKind::Syntax),
("export default ()=>{throw Error('oops')}", ErrorKind::Throw),
("export default ()=>Promise.reject('no')", ErrorKind::Throw),
("export default ()=>undefined", ErrorKind::NonJson),
("export default ()=>Symbol()", ErrorKind::NonJson),
("export default ()=>()=>1", ErrorKind::NonJson),
(
"export default ()=>{let a={};a.a=a;return a}",
ErrorKind::NonJson,
),
("export const x=1", ErrorKind::Engine),
("export default 1", ErrorKind::Engine),
("export default ()=>new Promise(()=>{})", ErrorKind::Engine),
] {
assert_eq!(run(s).unwrap_err().kind, k, "{s}");
}
assert_eq!(
V8.evaluate("export default x=>x", "invalid", None, Limits::default())
.unwrap_err()
.kind,
ErrorKind::Syntax
);
}
#[test]
fn sandbox_and_isolation() {
for name in [
"Date",
"Math.random",
"fetch",
"setTimeout",
"setInterval",
"WebSocket",
"XMLHttpRequest",
"std",
"os",
"fs",
"Deno",
"__bootstrap",
"console",
"performance",
"require",
"process",
"TextEncoder",
"Buffer",
"Blob",
"File",
"crypto",
] {
assert_eq!(
run(&format!("export default ()=>typeof {name}")).unwrap(),
"\"undefined\"",
"{name}"
);
}
assert!(run("import x from 'fs'; export default ()=>x").is_err());
assert!(run("import {core} from 'ext:core/mod.js'; export default ()=>typeof core").is_err());
assert_eq!(
run("export default ()=>import('fs')").unwrap_err().kind,
ErrorKind::Throw
);
run("export default ()=>{globalThis.leak=1;btoa.leak=1;return true}").unwrap();
assert_eq!(
run("export default ()=>[typeof leak,typeof btoa.leak]").unwrap(),
"[\"undefined\",\"undefined\"]"
);
for (expr, expected) in [
("btoa('user:pass')", "\"dXNlcjpwYXNz\""),
("atob('dXNlcjpwYXNz')", "\"user:pass\""),
("btoa('')", "\"\""),
("atob('AP+A') === '\\x00\\xff\\x80'", "true"),
] {
assert_eq!(
run(&format!("export default ()=>{expr}")).unwrap(),
expected
);
}
for expr in ["atob('!')", "btoa('雪')"] {
assert_eq!(
run(&format!("export default ()=>{expr}")).unwrap_err().kind,
ErrorKind::Throw
);
}
}
#[test]
fn independent_deadlines() {
// Exclude the process-wide platform initialization from the overlap check.
assert_eq!(run("export default ()=>true").unwrap(), "true");
let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
let other = barrier.clone();
let runaway = std::thread::spawn(move || {
other.wait();
assert_eq!(
V8.evaluate(
"export default ()=>{while(true){}}",
"null",
None,
Limits {
max_time_ms: Some(1000),
..Limits::default()
}
)
.unwrap_err()
.kind,
ErrorKind::Limit
);
});
barrier.wait();
// The quick call keeps its default 100 ms execution deadline.
assert_eq!(run("export default ()=>true").unwrap(), "true");
assert!(
!runaway.is_finished(),
"the quick call must finish before the runaway call"
);
runaway.join().unwrap();
}
#[test]
fn clamped_limits() {
for limits in [
Limits {
max_time_ms: Some(0),
max_memory_bytes: Some(0),
},
Limits {
max_time_ms: Some(u64::MAX),
max_memory_bytes: Some(usize::MAX),
},
] {
match V8.evaluate("export default ()=>true", "null", None, limits) {
Ok(value) => assert_eq!(value, "true"),
Err(error) => assert_eq!(error.kind, ErrorKind::Limit),
}
}
assert_eq!(
V8.evaluate(
"export default ()=>{let a=[];while(1)a.push('abcdefghijklmnopqrstuvwxyz'+a.length)}",
"null",
None,
Limits {
max_time_ms: Some(1000),
max_memory_bytes: Some(1024 * 1024)
}
)
.unwrap_err()
.kind,
ErrorKind::Limit
);
}
#[test]
fn limits_and_parallel_calls() {
let mut workers = Vec::new();
for s in [
"export default ()=>{while(true){}}",
"while(true){};export default x=>x",
"export default ()=>({toJSON(){while(true){}}})",
"export default ()=>{throw {get message(){while(true){}}}}",
"export default ()=>{function f(){return f()+1}return f()}",
"export default ()=>new Promise(()=>{function f(){Promise.resolve().then(f)}f()})",
"export default ()=>{let a=[];while(1)a.push('abcdefghijklmnopqrstuvwxyz'+a.length)}",
] {
workers.push(std::thread::spawn(move || {
let start = Instant::now();
assert_eq!(run(s).unwrap_err().kind, ErrorKind::Limit);
assert!(start.elapsed() < Duration::from_secs(2));
}));
}
let normal = std::thread::spawn(|| assert_eq!(run("export default ()=>true").unwrap(), "true"));
for worker in workers {
worker.join().unwrap();
}
normal.join().unwrap();
}
+124
View File
@@ -0,0 +1,124 @@
//! Software-authenticator coverage for enrollment gates and bound assertions.
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use sausage::cred::webauthn::{Binding, Gate, Passkeys};
use webauthn_authenticator_rs::{WebauthnAuthenticator, softpasskey::SoftPasskey};
use webauthn_rs::prelude::*;
fn binding() -> Binding {
Binding::Decision {
request_id: "request-1".into(),
decision: "approve".into(),
overrides: serde_json::json!({}),
}
}
#[test]
fn enrollment_and_assertions() -> anyhow::Result<()> {
let db = rusqlite::Connection::open_in_memory()?;
let origin = Url::parse("http://localhost:8000")?;
let mut host = Passkeys::new(&db, "localhost", origin.as_str())?;
let mut auth = WebauthnAuthenticator::new(SoftPasskey::new(true));
assert!(host.is_unlocked());
let start = host.start_registration("first".into(), None)?;
let racing = host.start_registration("racing".into(), None)?;
let response = auth.do_registration(origin.clone(), start.options)?;
host.finish_registration(&start.id, response)?;
assert!(!host.is_unlocked());
assert!(host.start_registration("second".into(), None).is_err());
let response = auth.do_registration(origin.clone(), racing.options)?;
assert!(host.finish_registration(&racing.id, response).is_err());
let gate = host.start_assertion(Binding::Enrollment {
label: "second".into(),
})?;
let response = auth.do_authentication(origin.clone(), gate.options)?;
let start = host.start_registration(
"second".into(),
Some(Gate {
id: gate.id,
response,
}),
)?;
let response = auth.do_registration(origin.clone(), start.options)?;
host.finish_registration(&start.id, response)?;
let start = host.start_assertion(binding())?;
let response = auth.do_authentication(origin.clone(), start.options)?;
let replay = serde_json::to_value(&response)?;
assert_eq!(host.finish_assertion(&start.id, response)?.1, binding());
assert!(
host.finish_assertion(&start.id, serde_json::from_value(replay)?)
.is_err()
);
let gate = host.start_assertion(binding())?;
let response = auth.do_authentication(origin.clone(), gate.options)?;
assert!(
host.start_registration(
"unauthorized".into(),
Some(Gate {
id: gate.id,
response
})
)
.is_err()
);
drop(host);
let mut host = Passkeys::new(&db, "localhost", origin.as_str())?;
let older = host.start_assertion(binding())?;
let newer = host.start_assertion(binding())?;
let old_response = auth.do_authentication(origin.clone(), older.options)?;
let new_response = auth.do_authentication(origin.clone(), newer.options)?;
host.finish_assertion(&newer.id, new_response)?;
assert!(host.finish_assertion(&older.id, old_response).is_err());
// Sign a different, valid random challenge with the same credential.
let a = host.start_assertion(binding())?;
let b = host.start_assertion(binding())?;
let response = auth.do_authentication(origin.clone(), b.options)?;
assert!(host.finish_assertion(&a.id, response).is_err());
let a = host.start_assertion(binding())?;
let response = auth.do_authentication(Url::parse("http://localhost:8001")?, a.options)?;
assert!(host.finish_assertion(&a.id, response).is_err());
let mut a = host.start_assertion(binding())?;
a.options.public_key.user_verification =
serde_json::from_value(serde_json::json!("discouraged"))?;
let response = auth.do_authentication(origin.clone(), a.options)?;
assert!(host.finish_assertion(&a.id, response).is_err());
for field in ["signature", "authenticatorData"] {
let a = host.start_assertion(binding())?;
let response = auth.do_authentication(origin.clone(), a.options)?;
let mut json = serde_json::to_value(response)?;
let mut bytes = URL_SAFE_NO_PAD.decode(json["response"][field].as_str().unwrap())?;
let index = if field == "signature" {
bytes.len() - 1
} else {
0
};
bytes[index] ^= 1; // Preserve signature encoding, or change rpIdHash.
json["response"][field] = URL_SAFE_NO_PAD.encode(bytes).into();
assert!(
host.finish_assertion(&a.id, serde_json::from_value(json)?)
.is_err()
);
}
let foreign_db = rusqlite::Connection::open_in_memory()?;
let mut foreign = Passkeys::new(&foreign_db, "localhost", origin.as_str())?;
let mut foreign_auth = WebauthnAuthenticator::new(SoftPasskey::new(true));
let registration = foreign.start_registration("foreign".into(), None)?;
let response = foreign_auth.do_registration(origin.clone(), registration.options)?;
let credential = foreign.finish_registration(&registration.id, response)?;
let mut a = host.start_assertion(binding())?;
a.options.public_key.allow_credentials[0].id = credential.cred_id().to_vec().into();
let response = foreign_auth.do_authentication(origin.clone(), a.options)?;
assert!(host.finish_assertion(&a.id, response).is_err());
let a = host.start_assertion(binding())?;
let response = auth.do_authentication(origin, a.options)?;
db.execute("DELETE FROM passkey_credentials", [])?;
assert!(host.finish_assertion(&a.id, response).is_err());
Ok(())
}
+325
View File
@@ -0,0 +1,325 @@
//! Credential decisions wake an idle faux-model conversation through durable tasks.
use sausage::{
cred::{
items::Item,
requests::{GrantRequest, Overrides},
store::Cred,
types::Caller,
},
harness::{run_module, run_module_with_credentials},
world::World,
};
use std::sync::Arc;
#[tokio::test(flavor = "current_thread")]
async fn restart_child() -> anyhow::Result<()> {
let Ok(dir) = std::env::var("DURABLE_RESTART_DIR") else {
return Ok(());
};
let dir = std::path::Path::new(&dir);
let source = format!(
"globalThis.expectedState = 'approved';\n{}",
include_str!("../js/durable-test-bundle.js")
);
run_module(
World::open(dir)?,
sausage::process::exec(World::open(dir)?)?,
&source,
)
.await
}
#[test]
fn sigkill_preserves_request_wait() -> anyhow::Result<()> {
use std::{
process::Command,
time::{Duration, Instant},
};
let dir = std::env::temp_dir().join(format!("sausage-durable-restart-{}", std::process::id()));
let world = World::open(&dir)?;
let cred = Cred::open(&world.host_dir)?;
let admin = Caller {
pid: 0,
program: "test".into(),
conversations: vec![],
};
let item = cred.create_item(
Item {
id: String::new(),
name: "github".into(),
version: 0,
free_use: false,
rules: vec![],
validate_js: None,
transform_js: "export default (ctx) => ctx.request".into(),
fields: vec![],
},
&admin,
"test",
)?;
let mut child = Command::new(std::env::current_exe()?)
.args(["--exact", "restart_child", "--nocapture"])
.env("DURABLE_RESTART_DIR", &dir)
.spawn()?;
let deadline = Instant::now() + Duration::from_secs(30);
let conversation = loop {
if let Ok(id) = std::fs::read_to_string(dir.join("world/conversation"))
&& !id.is_empty()
{
break id;
}
if Instant::now() > deadline {
child.kill()?;
child.wait()?;
anyhow::bail!("Conversation did not start");
}
std::thread::yield_now();
};
let caller = Caller {
conversations: vec![conversation],
..admin
};
let id = cred.file_request(
&caller,
"restart",
GrantRequest {
items: vec![item.id],
duration_secs: None,
max_uses: None,
validator: None,
},
)?;
let db = rusqlite::Connection::open(dir.join("host/harness.sqlite"))?;
loop {
let count: i64 = db.query_row(
"SELECT count(*) FROM tasks WHERE json_extract(kind, '$')='sausage.credential.wait'",
[],
|r| r.get(0),
)?;
if count == 1 {
break;
}
if Instant::now() > deadline {
child.kill()?;
child.wait()?;
anyhow::bail!("Durable wait did not start");
}
std::thread::yield_now();
}
child.kill()?;
child.wait()?;
cred.approve(&id, "yes", Overrides::default(), Some("test"))?;
for _ in 0..2 {
let mut resumed = Command::new(std::env::current_exe()?)
.args(["--exact", "restart_child", "--nocapture"])
.env("DURABLE_RESTART_DIR", &dir)
.spawn()?;
let deadline = Instant::now() + Duration::from_secs(30);
loop {
if let Some(status) = resumed.try_wait()? {
assert!(status.success());
break;
}
if Instant::now() > deadline {
resumed.kill()?;
resumed.wait()?;
anyhow::bail!("Restart did not finish");
}
std::thread::yield_now();
}
}
drop(db);
std::fs::remove_dir_all(dir)?;
Ok(())
}
#[tokio::test(flavor = "current_thread")]
async fn proxy_child() -> anyhow::Result<()> {
let Ok(dir) = std::env::var("DURABLE_PROXY_DIR") else {
return Ok(());
};
let dir = std::path::Path::new(&dir);
let source = format!(
"globalThis.proxyOnly = true; const proxy = Deno.core.ops.op_model_proxy(); if (proxy.base_url !== 'https://proxy.invalid' || proxy.api_key !== 'faux-private-token') throw new Error('Wrong proxy item');\n{}",
include_str!("../js/durable-test-bundle.js")
);
run_module(
World::open(dir)?,
sausage::process::exec(World::open(dir)?)?,
&source,
)
.await?;
let error = run_module(
World::open(dir)?,
sausage::process::exec(World::open(dir)?)?,
"throw new Error(Deno.core.ops.op_model_proxy().api_key)",
)
.await
.unwrap_err();
assert!(!error.to_string().contains("faux-private-token"));
assert!(error.to_string().contains("[redacted]"));
Ok(())
}
#[test]
fn proxy_bootstrap_once() -> anyhow::Result<()> {
let dir = std::env::temp_dir().join(format!("sausage-durable-proxy-{}", std::process::id()));
for start in 0..2 {
let status = std::process::Command::new(std::env::current_exe()?)
.args(["--exact", "proxy_child", "--nocapture"])
.env("DURABLE_PROXY_DIR", &dir)
.env(
"PI_PROXY_BASE_URL",
if start == 0 {
"https://proxy.invalid"
} else {
"https://ignored.invalid"
},
)
.env(
"PI_PROXY_API_KEY",
if start == 0 {
"faux-private-token"
} else {
"ignored-token"
},
)
.status()?;
assert!(status.success());
}
let cred = Cred::open(&dir.join("host"))?;
assert_eq!(
cred.item_list()?
.iter()
.filter(|i| i.name == "llm-proxy")
.count(),
1
);
for file in [
"host/cred.sqlite",
"host/cred.sqlite-wal",
"host/harness.sqlite",
"host/harness.sqlite-wal",
] {
if let Ok(bytes) = std::fs::read(dir.join(file)) {
assert!(
!bytes
.windows(b"faux-private-token".len())
.any(|w| w == b"faux-private-token")
);
}
}
assert!(!dir.join("world/conversation").exists());
drop(cred);
std::fs::remove_dir_all(dir)?;
Ok(())
}
#[tokio::test(flavor = "current_thread")]
async fn decisions_wake_idle_conversation() -> anyhow::Result<()> {
for state in ["approved", "denied", "expired"] {
let dir =
std::env::temp_dir().join(format!("sausage-durable-{}-{state}", std::process::id()));
let world = World::open(&dir)?;
let cred = Arc::new(Cred::open(&world.host_dir)?);
let admin = Caller {
pid: 0,
program: "test".into(),
conversations: vec![],
};
let item = cred.create_item(
Item {
id: String::new(),
name: "github".into(),
version: 0,
free_use: false,
rules: vec![],
validate_js: None,
transform_js: "export default (ctx) => ctx.request".into(),
fields: vec![],
},
&admin,
"test",
)?;
cred.file_request(
&admin,
"program",
GrantRequest {
items: vec![item.id.clone()],
duration_secs: None,
max_uses: None,
validator: None,
},
)?;
let source = format!(
"globalThis.expectedState = {state:?};\n{}",
include_str!("../js/durable-test-bundle.js")
);
let decide = async {
let conversation = loop {
if let Ok(id) = std::fs::read_to_string(dir.join("world/conversation"))
&& !id.is_empty()
{
break id;
}
tokio::task::yield_now().await;
};
let caller = Caller {
conversations: vec![conversation],
..admin
};
let id = cred.file_request(
&caller,
"test",
GrantRequest {
items: vec![item.id],
duration_secs: None,
max_uses: None,
validator: None,
},
)?;
let harness_db = rusqlite::Connection::open(dir.join("host/harness.sqlite"))?;
loop {
let count: i64 = harness_db.query_row(
"SELECT count(*) FROM tasks WHERE json_extract(kind, '$')='sausage.credential.wait'",
[],
|r| r.get(0),
)?;
if count == 1 {
break;
}
tokio::task::yield_now().await;
}
match state {
"approved" => cred.approve(&id, "yes", Overrides::default(), Some("test"))?,
"denied" => cred.deny(&id, "no", Some("test"))?,
_ => {
let db = rusqlite::Connection::open(dir.join("host/cred.sqlite"))?;
db.execute("UPDATE requests SET expires=0 WHERE id=?", [&id])?;
}
}
Ok::<_, anyhow::Error>(())
};
let exec = sausage::process::exec(World::open(&dir)?)?;
tokio::time::timeout(std::time::Duration::from_secs(20), async {
let (a, b) = tokio::join!(
run_module_with_credentials(world, Arc::clone(&exec), cred.clone(), &source),
decide
);
a?;
b?;
Ok::<_, anyhow::Error>(())
})
.await??;
let db = rusqlite::Connection::open(dir.join("host/harness.sqlite"))?;
let tasks: i64 = db.query_row(
"SELECT count(*) FROM tasks WHERE json_extract(kind, '$')='sausage.credential.wait'",
[],
|r| r.get(0),
)?;
assert_eq!(tasks, 1, "Program requests must create no durable task");
drop(db);
std::fs::remove_dir_all(dir)?;
}
Ok(())
}
+114 -8
View File
@@ -1,13 +1,13 @@
//! P1 integration: a read-only bundle and real shell calls from the durable harness.
//! P1 integration: independent bundle copies and real shell calls from the durable harness.
use sausage::{harness::run_module, world::World};
use sausage_vfs::Open;
use std::path::PathBuf;
#[tokio::test(flavor = "current_thread")]
async fn bundle_mount_and_real_shell() -> anyhow::Result<()> {
async fn bundle_copy_and_real_shell() -> anyhow::Result<()> {
let repo = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..");
let bundle = repo.join("target/userland/bin");
if !bundle.join("sh").exists() {
let bundle = repo.join("target/userland/bundle");
if !bundle.join("bin/sh.wasm").exists() {
assert!(
std::process::Command::new("nu")
.env_remove("CARGO_TARGET_DIR")
@@ -18,9 +18,16 @@ async fn bundle_mount_and_real_shell() -> anyhow::Result<()> {
}
let dir = tempfile::tempdir()?;
let world = World::open_with_userland(dir.path(), Some(&bundle))?;
assert!(world.root.stat("/bin/sh", true).await?.size > 0);
assert!(world.root.stat("/bin/sh.wasm", true).await?.size > 0);
use std::os::unix::fs::MetadataExt;
for entry in std::fs::read_dir(dir.path().join("world/bin"))? {
let entry = entry?;
assert_eq!(entry.path().extension().unwrap(), "wasm");
assert_eq!(entry.metadata()?.nlink(), 1);
assert!(!entry.file_type()?.is_symlink());
}
assert!(world.root.stat("/usr/local/bin", true).await?.directory);
let denied = world
let writable = world
.root
.open(
"/bin/forbidden",
@@ -31,17 +38,72 @@ async fn bundle_mount_and_real_shell() -> anyhow::Result<()> {
},
)
.await;
assert!(matches!(denied, Err(e) if e.kind() == std::io::ErrorKind::ReadOnlyFilesystem));
assert!(writable.is_ok());
let exec = sausage::process::exec(World {
root: world.root.clone(),
host_dir: world.host_dir.clone(),
processes: world.processes.clone(),
})?;
use sausage::exec::{Command, Options, Stream};
for (script, expected) in [
("command -v ls", "/bin/ls.wasm\n"),
("ls", "bin"),
("git --version", "git version"),
("printf 'a\\n' | xargs echo", "a\n"),
("env FOO=1 printenv FOO", "1\n"),
] {
let mut out = Vec::new();
let code = exec
.exec(
Command::Shell(script.into()),
Options::default(),
&mut |stream, bytes| {
if matches!(stream, Stream::Stdout) {
out.extend_from_slice(bytes);
}
},
)
.await?;
assert_eq!(code, 0, "{script}");
assert!(String::from_utf8(out)?.contains(expected), "{script}");
}
let mut history = Vec::new();
assert_eq!(
exec.exec(
Command::Shell("cd /src/sausage && git log --oneline".into()),
Options::default(),
&mut |stream, bytes| {
if matches!(stream, Stream::Stdout) {
history.extend_from_slice(bytes);
}
},
)
.await?,
0,
);
let history = String::from_utf8(history)?;
assert_eq!(history.lines().count(), 1);
assert!(history.contains("Bundle source "));
let mut status = Vec::new();
assert_eq!(
exec.exec(
Command::Shell("cd /src/sausage && git status --short".into()),
Options::default(),
&mut |stream, bytes| {
if matches!(stream, Stream::Stdout) {
status.extend_from_slice(bytes);
}
},
)
.await?,
0,
);
assert!(status.is_empty(), "{}", String::from_utf8_lossy(&status));
let source = format!(
"globalThis.testP1 = true;\n{}",
include_str!("../js/test-bundle.js")
);
run_module(world, exec, &source).await?;
run_module(world, exec.clone(), &source).await?;
assert_eq!(
std::fs::read_to_string(dir.path().join("world/test.txt"))?,
"after\n"
@@ -59,6 +121,50 @@ async fn bundle_mount_and_real_shell() -> anyhow::Result<()> {
let actual = std::fs::read_to_string(spills[0].path())?;
let expected: String = (1..=10000).map(|n| format!("{n}\n")).collect();
assert_eq!(actual, expected);
let source_cat = std::fs::read(bundle.join("bin/cat.wasm"))?;
let head = std::fs::read(dir.path().join("world/bin/head.wasm"))?;
let mut out = Vec::new();
assert_eq!(
exec.exec(
Command::Shell(
"cp /bin/true.wasm /bin/cat.wasm; cat /test.txt; head -n 1 /test.txt".into()
),
Options::default(),
&mut |stream, bytes| {
if matches!(stream, Stream::Stdout) {
out.extend_from_slice(bytes);
}
}
)
.await?,
0
);
assert_eq!(out, b"after\n");
assert_eq!(std::fs::read(bundle.join("bin/cat.wasm"))?, source_cat);
assert_eq!(std::fs::read(dir.path().join("world/bin/head.wasm"))?, head);
assert_eq!(
exec.exec(
Command::Shell("cp /bin/true.wasm /usr/local/bin/head.wasm; head /test.txt".into()),
Options::default(),
&mut |_, bytes| assert!(bytes.is_empty())
)
.await?,
0
);
let changed = std::fs::read(dir.path().join("world/bin/cat.wasm"))?;
let reopened = World::open_with_userland(
dir.path(),
Some(std::path::Path::new("/nonexistent-bundle")),
)?;
assert_eq!(
reopened
.root
.open("/bin/cat.wasm", Open::default())
.await?
.read(0, changed.len())
.await?,
changed
);
// An unbundled world remains usable by process tests and file-only callers.
let unbundled = tempfile::tempdir()?;
let world = World::open(unbundled.path())?;
+56 -11
View File
@@ -9,6 +9,39 @@ use std::{
sync::{Arc, Once},
time::Duration,
};
#[test]
fn world_copies_programs_and_nested_source_only_once() -> anyhow::Result<()> {
let temp = tempfile::tempdir()?;
let bundle = temp.path().join("bundle");
std::fs::create_dir_all(bundle.join("bin"))?;
std::fs::create_dir_all(bundle.join("src/sausage/.git"))?;
std::fs::write(bundle.join("bin/probe.wasm"), b"program")?;
std::fs::write(
bundle.join("src/sausage/.git/HEAD"),
b"ref: refs/heads/main\n",
)?;
std::fs::write(bundle.join("src/sausage/README.md"), b"source")?;
let path = temp.path().join("world");
let _world = World::open_with_userland(&path, Some(&bundle))?;
assert_eq!(
std::fs::read(path.join("world/bin/probe.wasm"))?,
b"program"
);
assert_eq!(
std::fs::read(path.join("world/src/sausage/README.md"))?,
b"source"
);
assert!(path.join("world/src/sausage/.git/HEAD").is_file());
std::fs::write(path.join("world/src/sausage/README.md"), b"agent patch")?;
let _world = World::open_with_userland(&path, Some(Path::new("/missing-bundle")))?;
assert_eq!(
std::fs::read(path.join("world/src/sausage/README.md"))?,
b"agent patch"
);
assert!(!path.join("host/compile-cache").exists());
Ok(())
}
mod features {
//! Acceptance coverage for preview1, identity, inherited IO, services, HTTP, and bundle aliases.
use super::*;
@@ -260,28 +293,32 @@ mod features {
empty(&processes).await;
}
#[tokio::test(flavor = "multi_thread")]
async fn relative_aliases_share_compilation() {
async fn independent_copies_share_compilation() {
let _serial = SERIAL.lock().await;
let (temp, runtime, _) = world();
let bin = temp.path().join("world/bin");
for name in ["true", "echo"] {
std::os::unix::fs::symlink("cat", bin.join(name)).unwrap();
for name in ["read-a.wasm", "read-b.wasm"] {
std::fs::copy(bin.join("cat.wasm"), bin.join(name)).unwrap();
}
assert_eq!(
run(&runtime, Command::Argv(vec!["/bin/true".into()]), "/")
.await
.0,
run(
&runtime,
Command::Argv(vec!["/bin/read-a".into(), "/f".into()]),
"/"
)
.await
.0,
0
);
assert_eq!(runtime.compiled_programs(), 1);
let (code, out, _) = run(
&runtime,
Command::Argv(vec!["/bin/echo".into(), "shared".into()]),
Command::Argv(vec!["/bin/read-b".into(), "/f".into()]),
"/",
)
.await;
assert_eq!(code, 0);
assert_eq!(out, b"shared\n");
assert_eq!(out, b"root\n");
assert_eq!(runtime.compiled_programs(), 1);
}
#[tokio::test(flavor = "multi_thread")]
@@ -464,13 +501,17 @@ fn world() -> (tempfile::TempDir, Arc<Runtime>, sausage::process::Processes) {
let world = World::open(temp.path()).unwrap();
let cache = Path::new(env!("CARGO_TARGET_TMPDIR")).join("process-host/compile-cache");
std::fs::create_dir_all(&cache).unwrap();
std::os::unix::fs::symlink(cache, world.host_dir.join("compile-cache")).unwrap();
let processes = world.processes.clone();
fixture(&temp.path().join("world"));
let artifacts = repo().join("userland/target/wasm32-wasip2/release");
let bin = temp.path().join("world/bin");
for tool in ["cat", "tr", "ls", "wc", "yes", "head", "sleep"] {
std::fs::copy(artifacts.join("sausage-coreutils.wasm"), bin.join(tool)).unwrap();
std::fs::copy(
artifacts.join(format!("{tool}.wasm")),
bin.join(format!("{tool}.wasm")),
)
.unwrap();
}
for tool in ["ps", "kill"] {
std::fs::copy(artifacts.join("sausage-procps.wasm"), bin.join(tool)).unwrap();
@@ -491,7 +532,11 @@ fn world() -> (tempfile::TempDir, Arc<Runtime>, sausage::process::Processes) {
bin.join("git"),
)
.unwrap();
(temp, Runtime::new(world).unwrap(), processes)
(
temp,
Runtime::new_with_cache(world, &cache).unwrap(),
processes,
)
}
async fn run(runtime: &Arc<Runtime>, command: Command, cwd: &str) -> (i32, Vec<u8>, Vec<u8>) {
let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
+571 -9
View File
@@ -8,6 +8,17 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures 0.2.17",
]
[[package]]
name = "aho-corasick"
version = "1.1.5"
@@ -163,6 +174,12 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "base16ct"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
[[package]]
name = "base64"
version = "0.22.1"
@@ -179,6 +196,12 @@ dependencies = [
"vsimd",
]
[[package]]
name = "base64ct"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b"
[[package]]
name = "bigdecimal"
version = "0.4.11"
@@ -251,7 +274,16 @@ dependencies = [
"cc",
"cfg-if",
"constant_time_eq",
"cpufeatures",
"cpufeatures 0.3.1",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
@@ -348,7 +380,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cpufeatures",
"cpufeatures 0.3.1",
"rand_core 0.10.1",
]
@@ -365,6 +397,16 @@ dependencies = [
"windows-link",
]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common 0.1.7",
"inout",
]
[[package]]
name = "clap"
version = "4.6.7"
@@ -541,6 +583,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "constant_time_eq"
version = "0.4.2"
@@ -568,6 +616,15 @@ dependencies = [
"libm",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.1"
@@ -584,7 +641,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e75b2483e97a5a7da73ac68a05b629f9c53cff58d8ed1c77866079e18b00dba5"
dependencies = [
"digest 0.10.7",
"spin",
"spin 0.10.1",
]
[[package]]
@@ -636,6 +693,18 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-bigint"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
dependencies = [
"generic-array",
"rand_core 0.6.4",
"subtle",
"zeroize",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
@@ -655,6 +724,15 @@ dependencies = [
"hybrid-array",
]
[[package]]
name = "ctr"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
dependencies = [
"cipher",
]
[[package]]
name = "ctrlc"
version = "3.5.2"
@@ -666,6 +744,33 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "curve25519-dalek"
version = "4.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"curve25519-dalek-derive",
"digest 0.10.7",
"fiat-crypto",
"rustc_version",
"subtle",
"zeroize",
]
[[package]]
name = "curve25519-dalek-derive"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "dashmap"
version = "6.2.1"
@@ -746,6 +851,16 @@ dependencies = [
"thiserror",
]
[[package]]
name = "der"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid",
"zeroize",
]
[[package]]
name = "diff"
version = "0.1.13"
@@ -782,7 +897,10 @@ version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer 0.10.4",
"const-oid",
"crypto-common 0.1.7",
"subtle",
]
[[package]]
@@ -791,7 +909,7 @@ version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer",
"block-buffer 0.12.1",
"crypto-common 0.2.2",
]
@@ -862,12 +980,66 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "ecdsa"
version = "0.16.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
dependencies = [
"der",
"digest 0.10.7",
"elliptic-curve",
"rfc6979",
"signature",
"spki",
]
[[package]]
name = "ed25519"
version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
dependencies = [
"signature",
]
[[package]]
name = "ed25519-dalek"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a3daa8e81a3963a60642bcc1f90a670680bd4a77535faa384e9d1c79d620871"
dependencies = [
"curve25519-dalek",
"ed25519",
"sha2 0.10.9",
"subtle",
]
[[package]]
name = "either"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
[[package]]
name = "elliptic-curve"
version = "0.13.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
dependencies = [
"base16ct",
"crypto-bigint",
"digest 0.10.7",
"ff",
"generic-array",
"group",
"pkcs8",
"rand_core 0.6.4",
"sec1",
"subtle",
"zeroize",
]
[[package]]
name = "encode_unicode"
version = "1.0.0"
@@ -1031,6 +1203,22 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "ff"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
dependencies = [
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "fiat-crypto"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]]
name = "filetime"
version = "0.2.29"
@@ -1202,6 +1390,19 @@ version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
[[package]]
name = "futures-lite"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
dependencies = [
"fastrand",
"futures-core",
"futures-io",
"parking",
"pin-project-lite",
]
[[package]]
name = "futures-macro"
version = "0.3.34"
@@ -1256,6 +1457,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
"zeroize",
]
[[package]]
@@ -1292,6 +1494,7 @@ dependencies = [
"gix-pack",
"gix-transport",
"gix-zlib",
"sausage-ssh",
"serde",
"serde_json",
"url",
@@ -2316,6 +2519,17 @@ dependencies = [
"memmap2",
]
[[package]]
name = "group"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
dependencies = [
"ff",
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "half"
version = "2.7.1"
@@ -2407,6 +2621,15 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a7763b98ba8a24f59e698bf9ab197e7676c640d6455d1580b4ce7dc560f0f0d"
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest 0.10.7",
]
[[package]]
name = "hostname"
version = "0.4.2"
@@ -2825,6 +3048,15 @@ dependencies = [
"libc",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]]
name = "intl-memoizer"
version = "0.5.3"
@@ -3039,7 +3271,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598"
dependencies = [
"cfg-if",
"cpufeatures",
"cpufeatures 0.3.1",
]
[[package]]
@@ -3062,6 +3294,15 @@ dependencies = [
"libc",
]
[[package]]
name = "lazy_static"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
dependencies = [
"spin 0.9.9",
]
[[package]]
name = "leb128fmt"
version = "0.1.0"
@@ -3325,6 +3566,22 @@ dependencies = [
"rand 0.8.8",
]
[[package]]
name = "num-bigint-dig"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7"
dependencies = [
"lazy_static",
"libm",
"num-integer",
"num-iter",
"num-traits",
"rand 0.8.8",
"smallvec",
"zeroize",
]
[[package]]
name = "num-integer"
version = "0.1.47"
@@ -3334,6 +3591,16 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-iter"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-modular"
version = "0.6.1"
@@ -3368,6 +3635,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
"libm",
]
[[package]]
@@ -3437,6 +3705,50 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e"
[[package]]
name = "p256"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
dependencies = [
"ecdsa",
"elliptic-curve",
"primeorder",
"sha2 0.10.9",
]
[[package]]
name = "p384"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6"
dependencies = [
"ecdsa",
"elliptic-curve",
"primeorder",
"sha2 0.10.9",
]
[[package]]
name = "p521"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2"
dependencies = [
"base16ct",
"ecdsa",
"elliptic-curve",
"primeorder",
"rand_core 0.6.4",
"sha2 0.10.9",
]
[[package]]
name = "parking"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
[[package]]
name = "parking_lot"
version = "0.12.5"
@@ -3493,6 +3805,15 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "pem-rfc7468"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
dependencies = [
"base64ct",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -3544,6 +3865,27 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pkcs1"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
dependencies = [
"der",
"pkcs8",
"spki",
]
[[package]]
name = "pkcs8"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
dependencies = [
"der",
"spki",
]
[[package]]
name = "pkg-config"
version = "0.3.34"
@@ -3645,6 +3987,15 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "primeorder"
version = "0.13.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
dependencies = [
"elliptic-curve",
]
[[package]]
name = "proc-macro-crate"
version = "3.5.0"
@@ -3848,6 +4199,16 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "rfc6979"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
dependencies = [
"hmac",
"subtle",
]
[[package]]
name = "ripgrep"
version = "15.2.0"
@@ -3873,12 +4234,42 @@ version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189"
[[package]]
name = "rsa"
version = "0.9.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
dependencies = [
"const-oid",
"digest 0.10.7",
"num-bigint-dig",
"num-integer",
"num-traits",
"pkcs1",
"pkcs8",
"rand_core 0.6.4",
"sha2 0.10.9",
"signature",
"spki",
"subtle",
"zeroize",
]
[[package]]
name = "rustc-hash"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]]
name = "rustix"
version = "1.1.6"
@@ -4066,12 +4457,47 @@ dependencies = [
"wit-bindgen 0.46.0",
]
[[package]]
name = "sausage-ssh"
version = "0.1.0"
dependencies = [
"aes",
"base64",
"ctr",
"futures-lite",
"hmac",
"rand_core 0.6.4",
"serde",
"serde_json",
"sha1 0.10.7",
"sha2 0.10.9",
"signature",
"ssh-key",
"url",
"wasi 0.14.7+wasi-0.2.4",
"x25519-dalek",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "sec1"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
dependencies = [
"base16ct",
"der",
"generic-array",
"pkcs8",
"subtle",
"zeroize",
]
[[package]]
name = "sed"
version = "0.3.0"
@@ -4151,6 +4577,17 @@ dependencies = [
"zmij",
]
[[package]]
name = "sha1"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest 0.10.7",
]
[[package]]
name = "sha1"
version = "0.11.0"
@@ -4158,7 +4595,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
dependencies = [
"cfg-if",
"cpufeatures",
"cpufeatures 0.3.1",
"digest 0.11.3",
]
@@ -4168,6 +4605,17 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5b0ebf6537e3e8db8bb7d21ecb6d7399738c953c757945c6b95e46c4e95df7b"
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest 0.10.7",
]
[[package]]
name = "sha2"
version = "0.11.1"
@@ -4175,7 +4623,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19"
dependencies = [
"cfg-if",
"cpufeatures",
"cpufeatures 0.3.1",
"digest 0.11.3",
]
@@ -4213,6 +4661,16 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "signature"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"digest 0.10.7",
"rand_core 0.6.4",
]
[[package]]
name = "simd-adler32"
version = "0.3.10"
@@ -4258,18 +4716,84 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100"
[[package]]
name = "spin"
version = "0.9.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
[[package]]
name = "spin"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3"
[[package]]
name = "spki"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
dependencies = [
"base64ct",
"der",
]
[[package]]
name = "sponge-cursor"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620"
[[package]]
name = "ssh"
version = "0.1.0"
dependencies = [
"clap",
"sausage-ssh",
]
[[package]]
name = "ssh-cipher"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f"
dependencies = [
"cipher",
"ssh-encoding",
]
[[package]]
name = "ssh-encoding"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15"
dependencies = [
"base64ct",
"pem-rfc7468",
"sha2 0.10.9",
]
[[package]]
name = "ssh-key"
version = "0.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3"
dependencies = [
"ed25519-dalek",
"p256",
"p384",
"p521",
"rand_core 0.6.4",
"rsa",
"sec1",
"sha2 0.10.9",
"signature",
"ssh-cipher",
"ssh-encoding",
"subtle",
"zeroize",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
@@ -4292,6 +4816,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "2.0.119"
@@ -5721,8 +6251,8 @@ dependencies = [
"procfs",
"rustc-hash",
"rustix",
"sha1",
"sha2",
"sha1 0.11.0",
"sha2 0.11.1",
"sha3",
"shake",
"sm3",
@@ -6214,6 +6744,18 @@ dependencies = [
"tap",
]
[[package]]
name = "x25519-dalek"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277"
dependencies = [
"curve25519-dalek",
"rand_core 0.6.4",
"serde",
"zeroize",
]
[[package]]
name = "xattr"
version = "1.7.0"
@@ -6300,6 +6842,26 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "zerotrie"
version = "0.2.5"
+1 -1
View File
@@ -1,7 +1,7 @@
# The default CLIs of a world, built for wasm32-wasip2. Its own workspace, outside the host's.
[workspace]
resolver = "3"
members = ["git", "coreutils", "procps", "cwd-init/probe", "ripgrep", "diffutils", "jaq", "sed", "grep", "fd", "spawn", "findutils", "exec-tools"]
members = ["git", "coreutils", "procps", "cwd-init/probe", "ripgrep", "diffutils", "jaq", "sed", "grep", "fd", "spawn", "findutils", "exec-tools", "ssh-client", "ssh"]
# WASI compatibility patches: git/vendor/PATCHES.md.
[patch.crates-io]
+6
View File
@@ -18,6 +18,12 @@ def main [] {
if (^git -C $source rev-parse HEAD | str trim) != $revision { error make {msg: 'unexpected brush revision'} }
let core = ($source | path join brush-core)
copy-if-changed ($here | path join process.rs) ($core | path join src sys wasm process.rs)
copy-if-changed ($here | path join lookup.rs) ($core | path join src sys wasm sausage-lookup.rs)
let fs = ($core | path join src sys wasm fs.rs)
let fs_text = (open --raw $fs)
if not ($fs_text | str contains 'include!("sausage-lookup.rs")') {
$fs_text + "\ninclude!(\"sausage-lookup.rs\");\n" | save --force $fs
}
mkdir ($core | path join sausage-process)
let wit = ($here | path join .. .. host wit | path expand)
copy-if-changed ($wit | path join process.wit) ($core | path join sausage-process process.wit)
+28
View File
@@ -0,0 +1,28 @@
/// Resolve ordinary files before the WASI suffix in each PATH folder.
pub fn resolve_executable(path: std::path::PathBuf) -> Option<std::path::PathBuf> {
if path.is_file() {
return Some(path);
}
let mut name = path.into_os_string();
name.push(".wasm");
let path = std::path::PathBuf::from(name);
path.is_file().then_some(path)
}
pub(crate) fn resolve_executable_path(
path: std::path::PathBuf,
working_dir: &std::path::Path,
) -> std::path::PathBuf {
let absolute = working_dir.join(&path);
if absolute.is_file() {
return path;
}
let mut name = absolute.into_os_string();
name.push(".wasm");
if std::path::PathBuf::from(name).is_file() {
let mut name = path.into_os_string();
name.push(".wasm");
name.into()
} else {
path
}
}
+31 -7
View File
@@ -1,8 +1,9 @@
#!/usr/bin/env nu
# Build every workspace CLI and assemble the read-only /bin bundle.
# Build independent commands and a committed source snapshot of their repository.
def main [destination?: path] {
let here = $env.FILE_PWD
let destination = ($destination | default ($here | path join .. target userland bin) | path expand)
let destination = ($destination | default ($here | path join .. target userland bundle) | path expand)
let bin = ($destination | path join bin)
let object = ($here | path join cwd-init init.o)
let sources = [($here | path join cwd-init init.rs) ($here | path join cwd-init exit.wit) ($here | path join cwd-init build.nu)]
if not ($object | path exists) or ($sources | any {|source| (ls $source | get modified.0) > (ls $object | get modified.0) }) {
@@ -14,23 +15,46 @@ def main [destination?: path] {
if $env.LAST_EXIT_CODE != 0 { error make {msg: 'userland build failed'} }
^nu ($here | path join brush build.nu)
if $env.LAST_EXIT_CODE != 0 { error make {msg: 'brush build failed'} }
mkdir $destination
mkdir $bin
for package in ($metadata.packages | where {|p| $p.id in $metadata.workspace_members }) {
let tools = ($package.manifest_path | path dirname | path join tools.txt)
for binary in ($package.targets | where {|t| 'bin' in $t.kind }) {
let source = ($metadata.target_directory | path join wasm32-wasip2 release $'($binary.name).wasm')
let names = if ($tools | path exists) { open --raw $tools | lines | where {|s| ($s | str trim) != '' } } else { [$binary.name] }
for name in $names {
^ln -f $source ($destination | path join $name)
if $env.LAST_EXIT_CODE != 0 { error make {msg: $'bundle link failed: ($name)'} }
^cp --remove-destination $source ($bin | path join $'($name).wasm')
if $env.LAST_EXIT_CODE != 0 { error make {msg: $'bundle copy failed: ($name)'} }
}
}
}
let brush_metadata = (^cargo metadata --manifest-path ($here | path join brush source Cargo.toml) --no-deps --format-version 1 | from json)
let brush = ($brush_metadata.target_directory | path join wasm32-wasip2 release brush.wasm)
for name in [sh bash] {
^ln -f $brush ($destination | path join $name)
if $env.LAST_EXIT_CODE != 0 { error make {msg: 'brush link failed'} }
^cp --remove-destination $brush ($bin | path join $'($name).wasm')
if $env.LAST_EXIT_CODE != 0 { error make {msg: 'brush copy failed'} }
}
let repo = ($here | path join .. | path expand)
let revision = (^git -C $repo rev-parse HEAD | str trim)
let snapshot = ($destination | path join src sausage)
if ($snapshot | path exists) { rm --recursive --force $snapshot }
mkdir $snapshot
let archive = ($destination | path join source.tar)
^git -C $repo archive --output $archive HEAD host vfs userland Cargo.toml Cargo.lock flake.nix flake.lock README.md AGENTS.md .gitignore
if $env.LAST_EXIT_CODE != 0 { error make {msg: 'source archive failed'} }
^tar -xf $archive -C $snapshot
if $env.LAST_EXIT_CODE != 0 { error make {msg: 'source extraction failed'} }
rm $archive
^git -C $snapshot init --quiet --initial-branch=main
if $env.LAST_EXIT_CODE != 0 { error make {msg: 'source git init failed'} }
^git -C $snapshot add host vfs userland Cargo.toml Cargo.lock flake.nix flake.lock README.md AGENTS.md .gitignore
if $env.LAST_EXIT_CODE != 0 { error make {msg: 'source git add failed'} }
with-env {
GIT_AUTHOR_NAME: 'Sausage', GIT_AUTHOR_EMAIL: 'sausage@localhost',
GIT_COMMITTER_NAME: 'Sausage', GIT_COMMITTER_EMAIL: 'sausage@localhost',
GIT_AUTHOR_DATE: '2000-01-01T00:00:00+0000', GIT_COMMITTER_DATE: '2000-01-01T00:00:00+0000'
} {
^git -C $snapshot commit --no-gpg-sign --quiet -m $'Bundle source ($revision)'
if $env.LAST_EXIT_CODE != 0 { error make {msg: 'source git commit failed'} }
}
print $destination
}
+5
View File
@@ -0,0 +1,5 @@
//! Implements the arch command as an independent WASI binary.
fn main() {
uucore::locale::setup_localization("arch").expect("utility supplies localization");
std::process::exit(uu_arch::uumain(uucore::args_os()));
}
+5
View File
@@ -0,0 +1,5 @@
//! Implements the b2sum command as an independent WASI binary.
fn main() {
uucore::locale::setup_localization("b2sum").expect("utility supplies localization");
std::process::exit(uu_b2sum::uumain(uucore::args_os()));
}
+5
View File
@@ -0,0 +1,5 @@
//! Implements the base32 command as an independent WASI binary.
fn main() {
uucore::locale::setup_localization("base32").expect("utility supplies localization");
std::process::exit(uu_base32::uumain(uucore::args_os()));
}
+5
View File
@@ -0,0 +1,5 @@
//! Implements the base64 command as an independent WASI binary.
fn main() {
uucore::locale::setup_localization("base64").expect("utility supplies localization");
std::process::exit(uu_base64::uumain(uucore::args_os()));
}
+5
View File
@@ -0,0 +1,5 @@
//! Implements the basename command as an independent WASI binary.
fn main() {
uucore::locale::setup_localization("basename").expect("utility supplies localization");
std::process::exit(uu_basename::uumain(uucore::args_os()));
}
+5
View File
@@ -0,0 +1,5 @@
//! Implements the basenc command as an independent WASI binary.
fn main() {
uucore::locale::setup_localization("basenc").expect("utility supplies localization");
std::process::exit(uu_basenc::uumain(uucore::args_os()));
}
+5
View File
@@ -0,0 +1,5 @@
//! Implements the cat command as an independent WASI binary.
fn main() {
uucore::locale::setup_localization("cat").expect("utility supplies localization");
std::process::exit(uu_cat::uumain(uucore::args_os()));
}
+5
View File
@@ -0,0 +1,5 @@
//! Implements the cksum command as an independent WASI binary.
fn main() {
uucore::locale::setup_localization("cksum").expect("utility supplies localization");
std::process::exit(uu_cksum::uumain(uucore::args_os()));
}
+5
View File
@@ -0,0 +1,5 @@
//! Implements the comm command as an independent WASI binary.
fn main() {
uucore::locale::setup_localization("comm").expect("utility supplies localization");
std::process::exit(uu_comm::uumain(uucore::args_os()));
}
+5
View File
@@ -0,0 +1,5 @@
//! Implements the cp command as an independent WASI binary.
fn main() {
uucore::locale::setup_localization("cp").expect("utility supplies localization");
std::process::exit(uu_cp::uumain(uucore::args_os()));
}
+5
View File
@@ -0,0 +1,5 @@
//! Implements the csplit command as an independent WASI binary.
fn main() {
uucore::locale::setup_localization("csplit").expect("utility supplies localization");
std::process::exit(uu_csplit::uumain(uucore::args_os()));
}
+5
View File
@@ -0,0 +1,5 @@
//! Implements the cut command as an independent WASI binary.
fn main() {
uucore::locale::setup_localization("cut").expect("utility supplies localization");
std::process::exit(uu_cut::uumain(uucore::args_os()));
}
+5
View File
@@ -0,0 +1,5 @@
//! Implements the date command as an independent WASI binary.
fn main() {
uucore::locale::setup_localization("date").expect("utility supplies localization");
std::process::exit(uu_date::uumain(uucore::args_os()));
}
+5
View File
@@ -0,0 +1,5 @@
//! Implements the dd command as an independent WASI binary.
fn main() {
uucore::locale::setup_localization("dd").expect("utility supplies localization");
std::process::exit(uu_dd::uumain(uucore::args_os()));
}
+5
View File
@@ -0,0 +1,5 @@
//! Implements the dir command as an independent WASI binary.
fn main() {
uucore::locale::setup_localization("ls").expect("utility supplies localization");
std::process::exit(uu_dir::uumain(uucore::args_os()));
}
+5
View File
@@ -0,0 +1,5 @@
//! Implements the dircolors command as an independent WASI binary.
fn main() {
uucore::locale::setup_localization("dircolors").expect("utility supplies localization");
std::process::exit(uu_dircolors::uumain(uucore::args_os()));
}
+5
View File
@@ -0,0 +1,5 @@
//! Implements the dirname command as an independent WASI binary.
fn main() {
uucore::locale::setup_localization("dirname").expect("utility supplies localization");
std::process::exit(uu_dirname::uumain(uucore::args_os()));
}
+5
View File
@@ -0,0 +1,5 @@
//! Implements the echo command as an independent WASI binary.
fn main() {
uucore::locale::setup_localization("echo").expect("utility supplies localization");
std::process::exit(uu_echo::uumain(uucore::args_os()));
}
+5
View File
@@ -0,0 +1,5 @@
//! Implements the expand command as an independent WASI binary.
fn main() {
uucore::locale::setup_localization("expand").expect("utility supplies localization");
std::process::exit(uu_expand::uumain(uucore::args_os()));
}
+5
View File
@@ -0,0 +1,5 @@
//! Implements the expr command as an independent WASI binary.
fn main() {
uucore::locale::setup_localization("expr").expect("utility supplies localization");
std::process::exit(uu_expr::uumain(uucore::args_os()));
}
+5
View File
@@ -0,0 +1,5 @@
//! Implements the factor command as an independent WASI binary.
fn main() {
uucore::locale::setup_localization("factor").expect("utility supplies localization");
std::process::exit(uu_factor::uumain(uucore::args_os()));
}
+5
View File
@@ -0,0 +1,5 @@
//! Implements the false command as an independent WASI binary.
fn main() {
uucore::locale::setup_localization("false").expect("utility supplies localization");
std::process::exit(uu_false::uumain(uucore::args_os()));
}
+5
View File
@@ -0,0 +1,5 @@
//! Implements the fmt command as an independent WASI binary.
fn main() {
uucore::locale::setup_localization("fmt").expect("utility supplies localization");
std::process::exit(uu_fmt::uumain(uucore::args_os()));
}
+5
View File
@@ -0,0 +1,5 @@
//! Implements the fold command as an independent WASI binary.
fn main() {
uucore::locale::setup_localization("fold").expect("utility supplies localization");
std::process::exit(uu_fold::uumain(uucore::args_os()));
}
+5
View File
@@ -0,0 +1,5 @@
//! Implements the head command as an independent WASI binary.
fn main() {
uucore::locale::setup_localization("head").expect("utility supplies localization");
std::process::exit(uu_head::uumain(uucore::args_os()));
}
+5
View File
@@ -0,0 +1,5 @@
//! Implements the hostid command as an independent WASI binary.
fn main() {
uucore::locale::setup_localization("hostid").expect("utility supplies localization");
std::process::exit(uu_hostid::uumain(uucore::args_os()));
}
+5
View File
@@ -0,0 +1,5 @@
//! Implements the join command as an independent WASI binary.
fn main() {
uucore::locale::setup_localization("join").expect("utility supplies localization");
std::process::exit(uu_join::uumain(uucore::args_os()));
}
+5
View File
@@ -0,0 +1,5 @@
//! Implements the link command as an independent WASI binary.
fn main() {
uucore::locale::setup_localization("link").expect("utility supplies localization");
std::process::exit(uu_link::uumain(uucore::args_os()));
}
+5
View File
@@ -0,0 +1,5 @@
//! Implements the ln command as an independent WASI binary.
fn main() {
uucore::locale::setup_localization("ln").expect("utility supplies localization");
std::process::exit(uu_ln::uumain(uucore::args_os()));
}
+5
View File
@@ -0,0 +1,5 @@
//! Implements the ls command as an independent WASI binary.
fn main() {
uucore::locale::setup_localization("ls").expect("utility supplies localization");
std::process::exit(uu_ls::uumain(uucore::args_os()));
}
+5
View File
@@ -0,0 +1,5 @@
//! Implements the md5sum command as an independent WASI binary.
fn main() {
uucore::locale::setup_localization("md5sum").expect("utility supplies localization");
std::process::exit(uu_md5sum::uumain(uucore::args_os()));
}
+5
View File
@@ -0,0 +1,5 @@
//! Implements the mkdir command as an independent WASI binary.
fn main() {
uucore::locale::setup_localization("mkdir").expect("utility supplies localization");
std::process::exit(uu_mkdir::uumain(uucore::args_os()));
}
+5
View File
@@ -0,0 +1,5 @@
//! Implements the mktemp command as an independent WASI binary.
fn main() {
uucore::locale::setup_localization("mktemp").expect("utility supplies localization");
std::process::exit(uu_mktemp::uumain(uucore::args_os()));
}
+5
View File
@@ -0,0 +1,5 @@
//! Implements the mv command as an independent WASI binary.
fn main() {
uucore::locale::setup_localization("mv").expect("utility supplies localization");
std::process::exit(uu_mv::uumain(uucore::args_os()));
}
+5
View File
@@ -0,0 +1,5 @@
//! Implements the nice command as an independent WASI binary.
fn main() {
uucore::locale::setup_localization("nice").expect("utility supplies localization");
std::process::exit(uu_nice::uumain(uucore::args_os()));
}
+5
View File
@@ -0,0 +1,5 @@
//! Implements the nl command as an independent WASI binary.
fn main() {
uucore::locale::setup_localization("nl").expect("utility supplies localization");
std::process::exit(uu_nl::uumain(uucore::args_os()));
}
+5
View File
@@ -0,0 +1,5 @@
//! Implements the nproc command as an independent WASI binary.
fn main() {
uucore::locale::setup_localization("nproc").expect("utility supplies localization");
std::process::exit(uu_nproc::uumain(uucore::args_os()));
}
+5
View File
@@ -0,0 +1,5 @@
//! Implements the numfmt command as an independent WASI binary.
fn main() {
uucore::locale::setup_localization("numfmt").expect("utility supplies localization");
std::process::exit(uu_numfmt::uumain(uucore::args_os()));
}
+5
View File
@@ -0,0 +1,5 @@
//! Implements the od command as an independent WASI binary.
fn main() {
uucore::locale::setup_localization("od").expect("utility supplies localization");
std::process::exit(uu_od::uumain(uucore::args_os()));
}
+5
View File
@@ -0,0 +1,5 @@
//! Implements the paste command as an independent WASI binary.
fn main() {
uucore::locale::setup_localization("paste").expect("utility supplies localization");
std::process::exit(uu_paste::uumain(uucore::args_os()));
}
+5
View File
@@ -0,0 +1,5 @@
//! Implements the pathchk command as an independent WASI binary.
fn main() {
uucore::locale::setup_localization("pathchk").expect("utility supplies localization");
std::process::exit(uu_pathchk::uumain(uucore::args_os()));
}
+5
View File
@@ -0,0 +1,5 @@
//! Implements the pr command as an independent WASI binary.
fn main() {
uucore::locale::setup_localization("pr").expect("utility supplies localization");
std::process::exit(uu_pr::uumain(uucore::args_os()));
}
+5
View File
@@ -0,0 +1,5 @@
//! Implements the printenv command as an independent WASI binary.
fn main() {
uucore::locale::setup_localization("printenv").expect("utility supplies localization");
std::process::exit(uu_printenv::uumain(uucore::args_os()));
}
+5
View File
@@ -0,0 +1,5 @@
//! Implements the printf command as an independent WASI binary.
fn main() {
uucore::locale::setup_localization("printf").expect("utility supplies localization");
std::process::exit(uu_printf::uumain(uucore::args_os()));
}
+5
View File
@@ -0,0 +1,5 @@
//! Implements the ptx command as an independent WASI binary.
fn main() {
uucore::locale::setup_localization("ptx").expect("utility supplies localization");
std::process::exit(uu_ptx::uumain(uucore::args_os()));
}
+5
View File
@@ -0,0 +1,5 @@
//! Implements the pwd command as an independent WASI binary.
fn main() {
uucore::locale::setup_localization("pwd").expect("utility supplies localization");
std::process::exit(uu_pwd::uumain(uucore::args_os()));
}

Some files were not shown because too many files have changed in this diff Show More