diff --git a/Cargo.lock b/Cargo.lock index b6a5115..8c8cc49 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -869,7 +869,7 @@ checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] name = "pd-host-function" version = "0.1.0" -source = "git+https://github.com/rustscript-lang/rustscript.git?rev=f9ca4143f8ba2f486e270347504c49f5ea846097#f9ca4143f8ba2f486e270347504c49f5ea846097" +source = "git+https://github.com/rustscript-lang/rustscript.git?rev=b1d6cffede77f49410bf63525f30b9a46b02dc01#b1d6cffede77f49410bf63525f30b9a46b02dc01" dependencies = [ "pd-host-schema", "proc-macro2", @@ -880,7 +880,7 @@ dependencies = [ [[package]] name = "pd-host-schema" version = "0.1.0" -source = "git+https://github.com/rustscript-lang/rustscript.git?rev=f9ca4143f8ba2f486e270347504c49f5ea846097#f9ca4143f8ba2f486e270347504c49f5ea846097" +source = "git+https://github.com/rustscript-lang/rustscript.git?rev=b1d6cffede77f49410bf63525f30b9a46b02dc01#b1d6cffede77f49410bf63525f30b9a46b02dc01" dependencies = [ "proc-macro2", "syn 2.0.119", @@ -889,7 +889,7 @@ dependencies = [ [[package]] name = "pd-vm" version = "0.1.0" -source = "git+https://github.com/rustscript-lang/rustscript.git?rev=f9ca4143f8ba2f486e270347504c49f5ea846097#f9ca4143f8ba2f486e270347504c49f5ea846097" +source = "git+https://github.com/rustscript-lang/rustscript.git?rev=b1d6cffede77f49410bf63525f30b9a46b02dc01#b1d6cffede77f49410bf63525f30b9a46b02dc01" dependencies = [ "base64", "futures-channel", @@ -1133,6 +1133,7 @@ dependencies = [ "jsonschema", "libc", "parking_lot", + "pd-host-function", "pd-vm", "ring", "rustls", diff --git a/Cargo.toml b/Cargo.toml index 619aacc..6f9041a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,8 @@ hyper = { version = "1", features = ["client", "http1"] } hyper-util = { version = "0.1", features = ["client-legacy", "http1", "tokio"] } parking_lot = "0.12" rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } -rustscript-vm = { package = "pd-vm", git = "https://github.com/rustscript-lang/rustscript.git", rev = "f9ca4143f8ba2f486e270347504c49f5ea846097", default-features = false, features = ["runtime", "http-client", "sqlite"] } +rustscript-vm = { package = "pd-vm", git = "https://github.com/rustscript-lang/rustscript.git", rev = "b1d6cffede77f49410bf63525f30b9a46b02dc01", default-features = false, features = ["runtime", "http-client", "sqlite"] } +pd-host-function = { git = "https://github.com/rustscript-lang/rustscript.git", rev = "b1d6cffede77f49410bf63525f30b9a46b02dc01" } serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" diff --git a/examples/http_get.rss b/examples/http_get.rss index b9c1a8c..d765337 100644 --- a/examples/http_get.rss +++ b/examples/http_get.rss @@ -3,4 +3,6 @@ use http; http::client::request({ method: "GET", url: "https://api.example.com/health", + headers: [], + body: { kind: "none" } }); diff --git a/rss/llm/openai_chat.rss b/rss/llm/openai_chat.rss index 29930a1..c5178d1 100644 --- a/rss/llm/openai_chat.rss +++ b/rss/llm/openai_chat.rss @@ -152,16 +152,16 @@ fn chat_send_complete( chat_splice_user_parts(body_text, types::request_array(request, "messages")), types::request_array(request, "tools") ); - let response: map = http::client::request({ + let response = http::client::request({ method: "POST", url: url, - headers: { - "content-type": "application/json", - "authorization": "Bearer " + api_key - }, - body: body + headers: [ + { name: "content-type", value: "application/json" }, + { name: "authorization", value: "Bearer " + api_key } + ], + body: { kind: "text", text: body } }); - let status: int = response["status"].copy(); + let status: int = response.status.copy(); if status >= 200 && status < 300 => { chat_parse_response(response, status, provider) } else => { @@ -463,8 +463,8 @@ fn chat_splice_user_parts(body: string, messages: array) -> string { // Response parsing // --------------------------------------------------------------------------- -fn chat_parse_response(response: map, status: int, provider: string) -> map { - let body_bytes: bytes = response["body"]; +fn chat_parse_response(response: HttpResponse, status: int, provider: string) -> map { + let body_bytes: bytes = response.body; let body_text: string = bytes::to_utf8(body_bytes); let body: map = json::decode(body_text); chat_parse_response_body(body, status, provider) @@ -580,8 +580,8 @@ fn chat_parse_tool_calls(tool_calls: array) -> array { calls } -fn chat_parse_error(response: map, status: int) -> map { - let body_bytes: bytes = response["body"]; +fn chat_parse_error(response: HttpResponse, status: int) -> map { + let body_bytes: bytes = response.body; let body_text: string = bytes::to_utf8(body_bytes); let payload: map = chat_error_payload(body_text); let mut error_type: string = "api_error"; @@ -694,16 +694,16 @@ fn chat_send_stream( let mut total_tokens: int = 0; let mut tools: array = []; let mut done = false; - let stream_result: map = http::client::sse({ + let stream_result = http::client::sse({ method: "POST", url: url, - headers: { - "content-type": "application/json", - "authorization": "Bearer " + api_key - }, - body: body - }, |item| if item["kind"] == "event" => { - let data: string = item["data"].copy(); + headers: [ + { name: "content-type", value: "application/json" }, + { name: "authorization", value: "Bearer " + api_key } + ], + body: { kind: "text", text: body } + }, |item| if item.kind == "event" => { + let data: string = item.data.copy(); if data == "[DONE]" => { done = true; { action: "stop" } @@ -739,8 +739,8 @@ fn chat_send_stream( } else => { { action: "continue" } }); - let outcome: string = types::request_string(stream_result, "outcome"); - let status: int = types::request_int(stream_result, "status", 0); + let outcome: string = stream_result.outcome.copy(); + let status: int = stream_result.status.copy(); if done && outcome == "stopped" => { types::ok(types::response_new( text, diff --git a/rss/storage/admission.rss b/rss/storage/admission.rss index 3f78551..90139f2 100644 --- a/rss/storage/admission.rss +++ b/rss/storage/admission.rss @@ -5,6 +5,39 @@ // disk, so a restart can never resurrect a half-admitted run. use json; use sqlite; + +fn sql_text(value: string) -> SqliteValue { + { kind: "text", int_value: null, float_value: null, text_value: value, blob_value: null } +} + +fn sql_int(value: int) -> SqliteValue { + { kind: "int", int_value: value, float_value: null, text_value: null, blob_value: null } +} + +fn sql_limits(max_rows: int, max_bytes: int) -> SqliteLimits { + { + max_connections: 16, + max_statements: 128, + max_rows: max_rows, + max_columns: 128, + max_result_bytes: max_bytes, + max_statement_bytes: 1048576, + max_parameters: 128, + max_parameter_bytes: 1048576, + max_pending_operations: 32, + max_transaction_ms: 5000, + busy_timeout_ms: 5000 + } +} + +fn sqlite_query_map(result: SqliteQueryResult) -> map { + { + columns: result.columns, + rows: result.rows, + truncated: result.truncated + } +} + use self::existence as existence; use self::messages as messages; @@ -37,14 +70,14 @@ pub fn storage_admission_create(db_id: resource, payload_json let mut failure_code: string = ""; let mut failure_message: string = ""; if input.session_new.copy() == 0 { - if !existence::session_exists(db_id, input.session_id.copy()) { + if !(sqlite::query(&db_id, existence::session_exists_sql(), [sql_text(input.session_id.copy())], sql_limits(1, 4096)).rows.length > 0) { failed = true; failure_code = "session_not_found"; failure_message = "admission session does not exist"; } } if failed == false { - if input.parent_run_id.copy() != "" && !existence::run_exists(db_id, input.parent_run_id.copy()) { + if input.parent_run_id.copy() != "" && !(sqlite::query(&db_id, existence::run_exists_sql(), [sql_text(input.parent_run_id.copy())], sql_limits(1, 4096)).rows.length > 0) { failed = true; failure_code = "parent_not_found"; failure_message = "parent run does not exist"; @@ -55,17 +88,13 @@ pub fn storage_admission_create(db_id: resource, payload_json let mut existing_run_id: string = ""; if failed == false { if input.idempotency_key.copy() != "" { - let existing: map = sqlite::query( - &db_id, - "SELECT scope, key, request_hash, resource_type, resource_id, state, response_json FROM idempotency_records WHERE scope = ? AND key = ? LIMIT 1", - [&input.idempotency_scope, &input.idempotency_key], - { max_rows: 1, max_result_bytes: 8192 } - ); - let rows: array = existing["rows"]; + let existing = sqlite::query(&db_id, "SELECT scope, key, request_hash, resource_type, resource_id, state, response_json FROM idempotency_records WHERE scope = ? AND key = ? LIMIT 1", [sql_text(input.idempotency_scope.copy()), sql_text(input.idempotency_key.copy())], sql_limits(1, 8192)); + let rows = existing.rows; if rows.length > 0 { - let row: array = rows[0]; - let existing_hash: string = row[2]; - let existing_resource: string = row[4]; + let row: SqliteRow = rows[0].copy(); + let cells: array = row.cells.copy(); + let existing_hash: string = cells[2].text_value; + let existing_resource: string = cells[4].text_value; existing_run_id = existing_resource; if existing_hash == input.request_hash.copy() { replay = true; @@ -77,58 +106,71 @@ pub fn storage_admission_create(db_id: resource, payload_json } if failed == false { if replay == false && conflict == false { - let mut statements = []; + let mut statements: array = []; if input.session_new.copy() == 1 { statements[statements.length] = { sql: "INSERT INTO sessions (id, profile, platform, account_id, chat_id, thread_id, user_id, generation, system_prompt, model, provider, toolset_hash, metadata_json, created_at_ms, updated_at_ms) SELECT ?, ?, ?, ?, '', '', '', 1, ?, ?, ?, '', '{}', ?, ? WHERE NOT EXISTS (SELECT 1 FROM sessions existing WHERE existing.id = ?)", - params: [&input.session_id, &input.profile, &input.platform, &input.account_id, &input.system_prompt, &input.model, &input.provider, input.now_ms.copy(), input.now_ms.copy(), &input.session_id] - }; + params: [sql_text(input.session_id.copy()), sql_text(input.profile.copy()), sql_text(input.platform.copy()), sql_text(input.account_id.copy()), sql_text(input.system_prompt.copy()), sql_text(input.model.copy()), sql_text(input.provider.copy()), sql_int(input.now_ms.copy()), sql_int(input.now_ms.copy()), sql_text(input.session_id.copy())], + query: false, + limits: sql_limits(1000, 4194304) + }; } else { statements[statements.length] = { sql: "UPDATE sessions SET model = CASE WHEN ? = '' THEN model ELSE ? END, provider = CASE WHEN ? = '' THEN provider ELSE ? END, system_prompt = CASE WHEN ? = '' THEN system_prompt ELSE ? END, updated_at_ms = ? WHERE id = ?", - params: [&input.model, &input.model, &input.provider, &input.provider, &input.system_prompt, &input.system_prompt, input.now_ms.copy(), &input.session_id] - }; + params: [sql_text(input.model.copy()), sql_text(input.model.copy()), sql_text(input.provider.copy()), sql_text(input.provider.copy()), sql_text(input.system_prompt.copy()), sql_text(input.system_prompt.copy()), sql_int(input.now_ms.copy()), sql_text(input.session_id.copy())], + query: false, + limits: sql_limits(1000, 4194304) + }; } + let user_content = storage_admission_user_content(input.input_json.copy()); + let encoded_content = messages::storage_message_encode_content(user_content); statements[statements.length] = { sql: "INSERT INTO messages (id, session_id, ordinal, role, content_json, metadata_json, run_id, finish_reason, created_at_ms) SELECT ?, ?, COALESCE(MAX(ordinal), 0) + 1, 'user', ?, '{}', ?, '', ? FROM messages WHERE session_id = ? AND NOT EXISTS (SELECT 1 FROM messages existing WHERE existing.id = ? AND existing.session_id <> ?)", - params: [&input.message_id, &input.session_id, messages::storage_message_encode_content(db_id, storage_admission_user_content(db_id, input.input_json.copy())), &input.message_run_id, input.now_ms.copy(), &input.session_id, &input.message_id, &input.session_id] - }; + params: [sql_text(input.message_id.copy()), sql_text(input.session_id.copy()), sql_text(encoded_content), sql_text(input.message_run_id.copy()), sql_int(input.now_ms.copy()), sql_text(input.session_id.copy()), sql_text(input.message_id.copy()), sql_text(input.session_id.copy())], + query: false, + limits: sql_limits(1000, 4194304) + }; statements[statements.length] = { sql: "INSERT INTO runs (id, session_id, parent_run_id, status, input_json, provider, model, script_hash, idempotency_scope, idempotency_key, created_at_ms, started_at_ms, updated_at_ms) SELECT ?, ?, ?, 'running', ?, ?, ?, ?, ?, ?, ?, ?, ? WHERE NOT EXISTS (SELECT 1 FROM runs existing WHERE existing.id = ? AND existing.session_id <> ?) AND EXISTS (SELECT 1 FROM sessions WHERE id = ?) AND (? = '' OR EXISTS (SELECT 1 FROM runs parent WHERE parent.id = ?))", - params: [&input.run_id, &input.session_id, &input.parent_run_id, &input.input_json, &input.provider, &input.model, &input.script_hash, &input.idempotency_scope, &input.idempotency_key, input.now_ms.copy(), input.now_ms.copy(), input.now_ms.copy(), &input.run_id, &input.session_id, &input.session_id, &input.parent_run_id, &input.parent_run_id] - }; + params: [sql_text(input.run_id.copy()), sql_text(input.session_id.copy()), sql_text(input.parent_run_id.copy()), sql_text(input.input_json.copy()), sql_text(input.provider.copy()), sql_text(input.model.copy()), sql_text(input.script_hash.copy()), sql_text(input.idempotency_scope.copy()), sql_text(input.idempotency_key.copy()), sql_int(input.now_ms.copy()), sql_int(input.now_ms.copy()), sql_int(input.now_ms.copy()), sql_text(input.run_id.copy()), sql_text(input.session_id.copy()), sql_text(input.session_id.copy()), sql_text(input.parent_run_id.copy()), sql_text(input.parent_run_id.copy())], + query: false, + limits: sql_limits(1000, 4194304) + }; if input.parent_run_id.copy() != "" { statements[statements.length] = { sql: "INSERT INTO child_run_links (parent_run_id, child_run_id, ordinal, relation, state, created_at_ms) SELECT ?, ?, COALESCE(MAX(ordinal), -1) + 1, 'subagent', 'pending', ? FROM child_run_links WHERE parent_run_id = ?", - params: [&input.parent_run_id, &input.run_id, input.now_ms.copy(), &input.parent_run_id] - }; + params: [sql_text(input.parent_run_id.copy()), sql_text(input.run_id.copy()), sql_int(input.now_ms.copy()), sql_text(input.parent_run_id.copy())], + query: false, + limits: sql_limits(1000, 4194304) + }; } if input.idempotency_key.copy() != "" { statements[statements.length] = { sql: "INSERT OR IGNORE INTO idempotency_records (scope, key, request_hash, resource_type, resource_id, state, response_json, created_at_ms, expires_at_ms) SELECT ?, ?, ?, 'run', ?, 'completed', '{\"run_id\":\"' || ? || '\",\"status\":\"running\"}', ?, ? WHERE ? <> ''", - params: [&input.idempotency_scope, &input.idempotency_key, &input.request_hash, &input.run_id, &input.run_id, input.now_ms.copy(), input.expires_at_ms.copy(), &input.idempotency_key] - }; + params: [sql_text(input.idempotency_scope.copy()), sql_text(input.idempotency_key.copy()), sql_text(input.request_hash.copy()), sql_text(input.run_id.copy()), sql_text(input.run_id.copy()), sql_int(input.now_ms.copy()), sql_int(input.expires_at_ms.copy()), sql_text(input.idempotency_key.copy())], + query: false, + limits: sql_limits(1000, 4194304) + }; } statements[statements.length] = { sql: "INSERT INTO run_events (run_id, seq, event_id, event_type, payload_json, created_at_ms) SELECT ?, COALESCE((SELECT MAX(events.seq) FROM run_events events WHERE events.run_id = ?), 0) + 1, ?, 'run.started', '{\"status\":\"running\",\"session_id\":\"' || ? || '\"}', ? WHERE EXISTS (SELECT 1 FROM runs WHERE id = ?)", - params: [&input.run_id, &input.run_id, &input.event_id, &input.session_id, input.now_ms.copy(), &input.run_id] - }; + params: [sql_text(input.run_id.copy()), sql_text(input.run_id.copy()), sql_text(input.event_id.copy()), sql_text(input.session_id.copy()), sql_int(input.now_ms.copy()), sql_text(input.run_id.copy())], + query: false, + limits: sql_limits(1000, 4194304) + }; statements[statements.length] = { sql: "INSERT INTO run_retention (run_id, first_seq, high_water_seq, updated_at_ms) SELECT ?, COALESCE((SELECT MIN(seq) FROM run_events WHERE run_id = ?), 0), COALESCE((SELECT MAX(seq) FROM run_events WHERE run_id = ?), 0), ? WHERE EXISTS (SELECT 1 FROM runs WHERE id = ?) ON CONFLICT (run_id) DO UPDATE SET first_seq = excluded.first_seq, high_water_seq = MAX(run_retention.high_water_seq, excluded.high_water_seq), updated_at_ms = excluded.updated_at_ms", - params: [&input.run_id, &input.run_id, &input.run_id, input.now_ms.copy(), &input.run_id] - }; + params: [sql_text(input.run_id.copy()), sql_text(input.run_id.copy()), sql_text(input.run_id.copy()), sql_int(input.now_ms.copy()), sql_text(input.run_id.copy())], + query: false, + limits: sql_limits(1000, 4194304) + }; sqlite::transaction(&db_id, statements); } } let query_run_id: string = if replay => { existing_run_id } else => { input.run_id.copy() }; - let run_result: map = sqlite::query( - &db_id, - "SELECT id, session_id, parent_run_id, status, input_json, provider, model, script_hash, idempotency_scope, idempotency_key, turn_count, input_tokens, output_tokens, error_code, error_message, recovery_reason, created_at_ms, started_at_ms, finished_at_ms, updated_at_ms FROM runs WHERE id = ? LIMIT 1", - [&query_run_id], - { max_rows: 1, max_result_bytes: 65536 } - ); - let run_result_copy: map = run_result.copy(); - let run_rows: array = run_result["rows"]; + let run_result = sqlite::query(&db_id, "SELECT id, session_id, parent_run_id, status, input_json, provider, model, script_hash, idempotency_scope, idempotency_key, turn_count, input_tokens, output_tokens, error_code, error_message, recovery_reason, created_at_ms, started_at_ms, finished_at_ms, updated_at_ms FROM runs WHERE id = ? LIMIT 1", [sql_text(query_run_id.copy())], sql_limits(1, 65536)); + let run_result_copy: map = sqlite_query_map(run_result); + let run_rows = run_result.rows; if failed == false { if run_rows.length == 0 && replay == false { failed = true; @@ -141,27 +183,12 @@ pub fn storage_admission_create(db_id: resource, payload_json failure_message = "replayed idempotency key references a missing run"; } } - let session_result: map = sqlite::query( - &db_id, - "SELECT id, profile, platform, account_id, chat_id, thread_id, user_id, generation, status, system_prompt, model, provider, toolset_hash, metadata_json, last_message_seq, created_at_ms, updated_at_ms FROM sessions WHERE id = ? LIMIT 1", - [&input.session_id], - { max_rows: 1, max_result_bytes: 65536 } - ); - let message_result: map = sqlite::query( - &db_id, - "SELECT id, session_id, ordinal, role, content_json, name, tool_call_id, parent_message_id, token_estimate, compacted, metadata_json, run_id, finish_reason, created_at_ms FROM messages WHERE id = ? AND session_id = ? LIMIT 1", - [&input.message_id, &input.session_id], - { max_rows: 1, max_result_bytes: 65536 } - ); + let session_result = sqlite::query(&db_id, "SELECT id, profile, platform, account_id, chat_id, thread_id, user_id, generation, status, system_prompt, model, provider, toolset_hash, metadata_json, last_message_seq, created_at_ms, updated_at_ms FROM sessions WHERE id = ? LIMIT 1", [sql_text(input.session_id.copy())], sql_limits(1, 65536)); + let message_result = sqlite::query(&db_id, "SELECT id, session_id, ordinal, role, content_json, name, tool_call_id, parent_message_id, token_estimate, compacted, metadata_json, run_id, finish_reason, created_at_ms FROM messages WHERE id = ? AND session_id = ? LIMIT 1", [sql_text(input.message_id.copy()), sql_text(input.session_id.copy())], sql_limits(1, 65536)); let idempotency_result: map = if input.idempotency_key.copy() == "" => { { columns: [], rows: [] } } else => { - sqlite::query( - &db_id, - "SELECT scope, key, request_hash, resource_type, resource_id, state, response_json, created_at_ms, expires_at_ms, completed_at_ms FROM idempotency_records WHERE scope = ? AND key = ? LIMIT 1", - [&input.idempotency_scope, &input.idempotency_key], - { max_rows: 1, max_result_bytes: 8192 } - ) + sqlite::query(&db_id, "SELECT scope, key, request_hash, resource_type, resource_id, state, response_json, created_at_ms, expires_at_ms, completed_at_ms FROM idempotency_records WHERE scope = ? AND key = ? LIMIT 1", [sql_text(input.idempotency_scope.copy()), sql_text(input.idempotency_key.copy())], sql_limits(1, 8192)) }; let mut result = { ok: true, @@ -212,18 +239,20 @@ pub fn storage_admission_create(db_id: resource, payload_json // Prefer the compact envelope's `run_context.input` as the user message body // so conversation rows stay canonical; fall back to the raw payload. -fn storage_admission_user_content(db_id: resource, input_json: string) -> string { - let extracted: map = sqlite::query( - &db_id, - "SELECT CASE WHEN json_valid(?) AND json_extract(?, '$.run_context.input') IS NOT NULL THEN CAST(json_extract(?, '$.run_context.input') AS TEXT) ELSE ? END AS content", - [&input_json, &input_json, &input_json, &input_json], - { max_rows: 1, max_result_bytes: 1048576 } - ); - let rows: array = extracted["rows"]; +fn storage_admission_user_content(input_json: string) -> string { + let db_id = sqlite::open({ + path: ":memory:", + mode: "memory", + root: null, + limits: sql_limits(1, 1048576) + }); + let extracted = sqlite::query(&db_id, "SELECT CASE WHEN json_valid(?) AND json_extract(?, '$.run_context.input') IS NOT NULL THEN CAST(json_extract(?, '$.run_context.input') AS TEXT) ELSE ? END AS content", [sql_text(input_json), sql_text(input_json), sql_text(input_json), sql_text(input_json)], sql_limits(1, 1048576)); + let rows = extracted.rows; let mut content: string = input_json.copy(); if rows.length > 0 { - let row: array = rows[0].copy(); - content = row[0]; + let row = rows[0]; + content = row.cells[0].text_value; } + sqlite::close(db_id); content } diff --git a/rss/storage/approvals.rss b/rss/storage/approvals.rss index ef78548..66ab562 100644 --- a/rss/storage/approvals.rss +++ b/rss/storage/approvals.rss @@ -1,10 +1,35 @@ use json; use sqlite; + +fn sql_text(value: string) -> SqliteValue { + { kind: "text", int_value: null, float_value: null, text_value: value, blob_value: null } +} + +fn sql_int(value: int) -> SqliteValue { + { kind: "int", int_value: value, float_value: null, text_value: null, blob_value: null } +} + +fn sql_limits(max_rows: int, max_bytes: int) -> SqliteLimits { + { + max_connections: 16, + max_statements: 128, + max_rows: max_rows, + max_columns: 128, + max_result_bytes: max_bytes, + max_statement_bytes: 1048576, + max_parameters: 128, + max_parameter_bytes: 1048576, + max_pending_operations: 32, + max_transaction_ms: 5000, + busy_timeout_ms: 5000 + } +} + use self::schema as schema; use self::existence as existence; -fn approvals_query_limits(max_rows: int, max_bytes: int) -> map { - { max_rows: max_rows, max_result_bytes: max_bytes } +fn approvals_query_limits(max_rows: int, max_bytes: int) { + sql_limits(max_rows, max_bytes) } @@ -37,57 +62,35 @@ struct ApprovalExpireInput { pub fn storage_approval_request(db_id: resource, payload_json: string, max_rows: int, max_bytes: int) -> map { let input: ApprovalRequestInput = json::decode::(payload_json); let mut result = { ok: true, code: "ok", message: "", result: { columns: [], rows: [] } }; - if !existence::run_exists(db_id, input.run_id.copy()) { + if !(sqlite::query(&db_id, existence::run_exists_sql(), [sql_text(input.run_id.copy())], sql_limits(1, 4096)).rows.length > 0) { result = { ok: false, code: "run_not_found", message: "approval request references an unknown run or session", result: { columns: [], rows: [] } }; } else { - if !existence::session_exists(db_id, input.session_id.copy()) { + if !(sqlite::query(&db_id, existence::session_exists_sql(), [sql_text(input.session_id.copy())], sql_limits(1, 4096)).rows.length > 0) { result = { ok: false, code: "run_not_found", message: "approval request references an unknown run or session", result: { columns: [], rows: [] } }; } else { - sqlite::execute( - &db_id, - "INSERT OR IGNORE INTO approvals (id, run_id, session_id, tool_call_id, tool_name, arguments_json, risk_class, state, decision_scope, one_time, requested_at_ms, expires_at_ms) SELECT ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ? WHERE EXISTS (SELECT 1 FROM runs WHERE runs.id = ? AND runs.session_id = ? AND runs.status IN ('running', 'waiting_approval')) AND NOT EXISTS (SELECT 1 FROM approvals existing JOIN runs owner ON owner.id = existing.run_id WHERE existing.id = ? AND owner.session_id <> ?)", - [&input.id, &input.run_id, &input.session_id, &input.tool_call_id, &input.tool_name, &input.arguments_json, &input.risk_class, &input.decision_scope, input.one_time.copy(), input.requested_at_ms.copy(), input.expires_at_ms.copy(), &input.run_id, &input.session_id, &input.id, &input.session_id] - ); + sqlite::execute(&db_id, "INSERT OR IGNORE INTO approvals (id, run_id, session_id, tool_call_id, tool_name, arguments_json, risk_class, state, decision_scope, one_time, requested_at_ms, expires_at_ms) SELECT ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ? WHERE EXISTS (SELECT 1 FROM runs WHERE runs.id = ? AND runs.session_id = ? AND runs.status IN ('running', 'waiting_approval')) AND NOT EXISTS (SELECT 1 FROM approvals existing JOIN runs owner ON owner.id = existing.run_id WHERE existing.id = ? AND owner.session_id <> ?)", [sql_text(input.id.copy()), sql_text(input.run_id.copy()), sql_text(input.session_id.copy()), sql_text(input.tool_call_id.copy()), sql_text(input.tool_name.copy()), sql_text(input.arguments_json.copy()), sql_text(input.risk_class.copy()), sql_text(input.decision_scope.copy()), sql_int(input.one_time.copy()), sql_int(input.requested_at_ms.copy()), sql_int(input.expires_at_ms.copy()), sql_text(input.run_id.copy()), sql_text(input.session_id.copy()), sql_text(input.id.copy()), sql_text(input.session_id.copy())]); result = { ok: true, code: "ok", message: "", - result: sqlite::query( - &db_id, - "SELECT id, run_id, session_id, tool_call_id, tool_name, arguments_json, risk_class, state, decision_scope, one_time, requested_at_ms, expires_at_ms, resolved_at_ms, resolver, decision_reason FROM approvals WHERE id = ? AND session_id = ? LIMIT ?", - [&input.id, &input.session_id, (max_rows)], - approvals_query_limits(max_rows, max_bytes) - ) + result: sqlite::query(&db_id, "SELECT id, run_id, session_id, tool_call_id, tool_name, arguments_json, risk_class, state, decision_scope, one_time, requested_at_ms, expires_at_ms, resolved_at_ms, resolver, decision_reason FROM approvals WHERE id = ? AND session_id = ? LIMIT ?", [sql_text(input.id.copy()), sql_text(input.session_id.copy()), sql_int(max_rows)], approvals_query_limits(max_rows, max_bytes)) }; } } result } -pub fn storage_approval_get(db_id: resource, approval_id: string, max_rows: int, max_bytes: int) -> map { - sqlite::query( - &db_id, - "SELECT id, run_id, session_id, tool_call_id, tool_name, arguments_json, risk_class, state, decision_scope, one_time, requested_at_ms, expires_at_ms, resolved_at_ms, resolver, decision_reason FROM approvals WHERE id = ? LIMIT ?", - [approval_id, (max_rows)], - approvals_query_limits(max_rows, max_bytes) - ) +pub fn storage_approval_get(db_id: resource, approval_id: string, max_rows: int, max_bytes: int) -> SqliteQueryResult { + sqlite::query(&db_id, "SELECT id, run_id, session_id, tool_call_id, tool_name, arguments_json, risk_class, state, decision_scope, one_time, requested_at_ms, expires_at_ms, resolved_at_ms, resolver, decision_reason FROM approvals WHERE id = ? LIMIT ?", [sql_text(approval_id), sql_int(max_rows)], approvals_query_limits(max_rows, max_bytes)) } -pub fn storage_approval_resolve(db_id: resource, payload_json: string) -> map { +pub fn storage_approval_resolve(db_id: resource, payload_json: string) -> SqliteExecuteResult { let input: ApprovalResolveInput = json::decode::(payload_json); assert(input.state.copy() == "approved" || input.state.copy() == "denied"); - sqlite::execute( - &db_id, - "UPDATE approvals SET state = ?, resolver = ?, decision_reason = ?, resolved_at_ms = ? WHERE id = ? AND state = 'pending' AND (expires_at_ms <= 0 OR expires_at_ms > ?) AND EXISTS (SELECT 1 FROM runs WHERE runs.id = approvals.run_id AND runs.status IN ('running', 'waiting_approval'))", - [&input.state, &input.resolver, &input.decision_reason, input.resolved_at_ms.copy(), &input.id, input.resolved_at_ms.copy()] - ) + sqlite::execute(&db_id, "UPDATE approvals SET state = ?, resolver = ?, decision_reason = ?, resolved_at_ms = ? WHERE id = ? AND state = 'pending' AND (expires_at_ms <= 0 OR expires_at_ms > ?) AND EXISTS (SELECT 1 FROM runs WHERE runs.id = approvals.run_id AND runs.status IN ('running', 'waiting_approval'))", [sql_text(input.state.copy()), sql_text(input.resolver.copy()), sql_text(input.decision_reason.copy()), sql_int(input.resolved_at_ms.copy()), sql_text(input.id.copy()), sql_int(input.resolved_at_ms.copy())]) } -pub fn storage_approval_expire(db_id: resource, payload_json: string) -> map { +pub fn storage_approval_expire(db_id: resource, payload_json: string) -> SqliteExecuteResult { let input: ApprovalExpireInput = json::decode::(payload_json); - sqlite::execute( - &db_id, - "UPDATE approvals SET state = 'expired', resolved_at_ms = ?, resolver = 'storage-expiry' WHERE state = 'pending' AND expires_at_ms > 0 AND expires_at_ms <= ?", - [input.now_ms.copy(), input.now_ms.copy()] - ) + sqlite::execute(&db_id, "UPDATE approvals SET state = 'expired', resolved_at_ms = ?, resolver = 'storage-expiry' WHERE state = 'pending' AND expires_at_ms > 0 AND expires_at_ms <= ?", [sql_int(input.now_ms.copy()), sql_int(input.now_ms.copy())]) } diff --git a/rss/storage/compactions.rss b/rss/storage/compactions.rss index f46f2cf..91297f9 100644 --- a/rss/storage/compactions.rss +++ b/rss/storage/compactions.rss @@ -1,10 +1,43 @@ use json; use sqlite; + +fn sql_text(value: string) -> SqliteValue { + { kind: "text", int_value: null, float_value: null, text_value: value, blob_value: null } +} + +fn sql_int(value: int) -> SqliteValue { + { kind: "int", int_value: value, float_value: null, text_value: null, blob_value: null } +} + +fn sql_limits(max_rows: int, max_bytes: int) -> SqliteLimits { + { + max_connections: 16, + max_statements: 128, + max_rows: max_rows, + max_columns: 128, + max_result_bytes: max_bytes, + max_statement_bytes: 1048576, + max_parameters: 128, + max_parameter_bytes: 1048576, + max_pending_operations: 32, + max_transaction_ms: 5000, + busy_timeout_ms: 5000 + } +} + +fn sqlite_query_map(result: SqliteQueryResult) -> map { + { + columns: result.columns, + rows: result.rows, + truncated: result.truncated + } +} + use self::schema as schema; use self::existence as existence; -fn compactions_query_limits(max_rows: int, max_bytes: int) -> map { - { max_rows: max_rows, max_result_bytes: max_bytes } +fn compactions_query_limits(max_rows: int, max_bytes: int) { + sql_limits(max_rows, max_bytes) } @@ -41,20 +74,16 @@ struct CompactionFailInput { /// DIFFERENT session or generation: reusing an id across sessions or /// generations is a typed conflict, never a SQLite constraint error. fn compaction_id_conflict(db_id: resource, input_id: string, input_session_id: string, input_generation: int) -> bool { - let by_id: map = sqlite::query( - &db_id, - "SELECT id, session_id, generation FROM compactions WHERE id = ? LIMIT 1", - [input_id], - { max_rows: 1, max_result_bytes: 4096 } - ); - let by_id_rows: array = by_id["rows"]; + let by_id: SqliteQueryResult = sqlite::query(&db_id, "SELECT id, session_id, generation FROM compactions WHERE id = ? LIMIT 1", [sql_text(input_id)], sql_limits(1, 4096)); + let by_id_rows = by_id.rows; let mut conflict = false; if by_id_rows.length > 0 { - let by_id_row: array = by_id_rows[0]; - if by_id_row[1] != input_session_id { + let by_id_row: SqliteRow = by_id_rows[0].copy(); + let by_id_cells: array = by_id_row.cells.copy(); + if by_id_cells[1].text_value != input_session_id { conflict = true; } - if by_id_row[2] != input_generation { + if by_id_cells[2].int_value != input_generation { conflict = true; } } @@ -63,13 +92,8 @@ fn compaction_id_conflict(db_id: resource, input_id: string, /// The existing compaction row for the target (session_id, generation), if /// any (the unique key of the compactions table). -fn compaction_existing_row(db_id: resource, session_id: string, generation: int, max_rows: int, max_bytes: int) -> map { - sqlite::query( - &db_id, - "SELECT id, session_id, run_id, generation, source_start_ordinal, source_end_ordinal, retained_tail_ordinal, summary_json, token_estimate, model, state, error_message, created_at_ms, completed_at_ms FROM compactions WHERE session_id = ? AND generation = ? LIMIT 1", - [session_id, generation], - compactions_query_limits(max_rows, max_bytes) - ) +fn compaction_existing_row(db_id: resource, session_id: string, generation: int, max_rows: int, max_bytes: int) -> SqliteQueryResult { + sqlite::query(&db_id, "SELECT id, session_id, run_id, generation, source_start_ordinal, source_end_ordinal, retained_tail_ordinal, summary_json, token_estimate, model, state, error_message, created_at_ms, completed_at_ms FROM compactions WHERE session_id = ? AND generation = ? LIMIT 1", [sql_text(session_id.copy()), sql_int(generation)], compactions_query_limits(max_rows, max_bytes)) } /// True when the existing pending row carries the exact same plan payload as @@ -144,24 +168,14 @@ fn compaction_row_matches( /// typed `compaction_start_rejected`, never a silent success. fn compaction_start_insert(db_id: resource, payload_json: string, max_rows: int, max_bytes: int) -> map { let input: CompactionStartInput = json::decode::(payload_json); - sqlite::execute( - &db_id, - "INSERT INTO compactions (id, session_id, run_id, generation, source_start_ordinal, source_end_ordinal, retained_tail_ordinal, summary_json, token_estimate, model, state, created_at_ms) SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ? FROM sessions JOIN runs ON runs.session_id = sessions.id WHERE sessions.id = ? AND sessions.generation + 1 = ? AND runs.id = ? AND runs.session_id = ? AND runs.status = 'compacting' AND ? >= 0 AND ? >= ? AND ? >= ? AND ? <= ? AND EXISTS (SELECT 1 FROM messages WHERE messages.session_id = sessions.id AND messages.ordinal = ?) AND EXISTS (SELECT 1 FROM messages WHERE messages.session_id = sessions.id AND messages.ordinal = ?) AND NOT EXISTS (SELECT 1 FROM compactions existing WHERE existing.id = ? AND existing.session_id <> ?) ON CONFLICT (session_id, generation) DO UPDATE SET id = excluded.id, run_id = excluded.run_id, source_start_ordinal = excluded.source_start_ordinal, source_end_ordinal = excluded.source_end_ordinal, retained_tail_ordinal = excluded.retained_tail_ordinal, summary_json = excluded.summary_json, token_estimate = excluded.token_estimate, model = excluded.model, state = 'pending', error_message = '', created_at_ms = excluded.created_at_ms, completed_at_ms = 0 WHERE compactions.state = 'failed' AND compactions.id = excluded.id", - [&input.id, &input.session_id, &input.run_id, input.generation.copy(), input.source_start_ordinal.copy(), input.source_end_ordinal.copy(), input.retained_tail_ordinal.copy(), &input.summary_json, input.token_estimate.copy(), &input.model, input.now_ms.copy(), &input.session_id, input.generation.copy(), &input.run_id, &input.session_id, input.source_start_ordinal.copy(), input.source_end_ordinal.copy(), input.source_start_ordinal.copy(), input.retained_tail_ordinal.copy(), input.source_start_ordinal.copy(), input.retained_tail_ordinal.copy(), input.source_end_ordinal.copy(), input.source_start_ordinal.copy(), input.source_end_ordinal.copy(), &input.id, &input.session_id] - ); - let inserted: map = sqlite::query( - &db_id, - "SELECT id, session_id, run_id, generation, source_start_ordinal, source_end_ordinal, retained_tail_ordinal, summary_json, token_estimate, model, state, error_message, created_at_ms, completed_at_ms FROM compactions WHERE id = ? AND session_id = ? LIMIT ?", - [&input.id, &input.session_id, (max_rows)], - compactions_query_limits(max_rows, max_bytes) - ); - let inserted_copy: map = inserted.copy(); - let inserted_rows: array = inserted["rows"]; + sqlite::execute(&db_id, "INSERT INTO compactions (id, session_id, run_id, generation, source_start_ordinal, source_end_ordinal, retained_tail_ordinal, summary_json, token_estimate, model, state, created_at_ms) SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ? FROM sessions JOIN runs ON runs.session_id = sessions.id WHERE sessions.id = ? AND sessions.generation + 1 = ? AND runs.id = ? AND runs.session_id = ? AND runs.status = 'compacting' AND ? >= 0 AND ? >= ? AND ? >= ? AND ? <= ? AND EXISTS (SELECT 1 FROM messages WHERE messages.session_id = sessions.id AND messages.ordinal = ?) AND EXISTS (SELECT 1 FROM messages WHERE messages.session_id = sessions.id AND messages.ordinal = ?) AND NOT EXISTS (SELECT 1 FROM compactions existing WHERE existing.id = ? AND existing.session_id <> ?) ON CONFLICT (session_id, generation) DO UPDATE SET id = excluded.id, run_id = excluded.run_id, source_start_ordinal = excluded.source_start_ordinal, source_end_ordinal = excluded.source_end_ordinal, retained_tail_ordinal = excluded.retained_tail_ordinal, summary_json = excluded.summary_json, token_estimate = excluded.token_estimate, model = excluded.model, state = 'pending', error_message = '', created_at_ms = excluded.created_at_ms, completed_at_ms = 0 WHERE compactions.state = 'failed' AND compactions.id = excluded.id", [sql_text(input.id.copy()), sql_text(input.session_id.copy()), sql_text(input.run_id.copy()), sql_int(input.generation.copy()), sql_int(input.source_start_ordinal.copy()), sql_int(input.source_end_ordinal.copy()), sql_int(input.retained_tail_ordinal.copy()), sql_text(input.summary_json.copy()), sql_int(input.token_estimate.copy()), sql_text(input.model.copy()), sql_int(input.now_ms.copy()), sql_text(input.session_id.copy()), sql_int(input.generation.copy()), sql_text(input.run_id.copy()), sql_text(input.session_id.copy()), sql_int(input.source_start_ordinal.copy()), sql_int(input.source_end_ordinal.copy()), sql_int(input.source_start_ordinal.copy()), sql_int(input.retained_tail_ordinal.copy()), sql_int(input.source_start_ordinal.copy()), sql_int(input.retained_tail_ordinal.copy()), sql_int(input.source_end_ordinal.copy()), sql_int(input.source_start_ordinal.copy()), sql_int(input.source_end_ordinal.copy()), sql_text(input.id.copy()), sql_text(input.session_id.copy())]); + let inserted: SqliteQueryResult = sqlite::query(&db_id, "SELECT id, session_id, run_id, generation, source_start_ordinal, source_end_ordinal, retained_tail_ordinal, summary_json, token_estimate, model, state, error_message, created_at_ms, completed_at_ms FROM compactions WHERE id = ? AND session_id = ? LIMIT ?", [sql_text(input.id.copy()), sql_text(input.session_id.copy()), sql_int(max_rows)], compactions_query_limits(max_rows, max_bytes)); + let inserted_empty: bool = inserted.rows.copy().length == 0; let mut result = { ok: true, code: "ok", message: "", result: { columns: [], rows: [] } }; - if inserted_rows.length == 0 { + if inserted_empty { result = { ok: false, code: "compaction_start_rejected", message: "compaction start guard conditions were not met (run status, session generation, range ordering, or message endpoints)", result: { columns: [], rows: [] } }; } else { - result = { ok: true, code: "ok", message: "", result: inserted_copy }; + result = { ok: true, code: "ok", message: "", result: sqlite_query_map(inserted) }; } result } @@ -169,10 +183,10 @@ fn compaction_start_insert(db_id: resource, payload_json: str pub fn storage_compaction_start(db_id: resource, payload_json: string, max_rows: int, max_bytes: int) -> map { let input: CompactionStartInput = json::decode::(payload_json); let mut result = { ok: true, code: "ok", message: "", result: { columns: [], rows: [] } }; - if !existence::session_exists(db_id, input.session_id.copy()) { + if !(sqlite::query(&db_id, existence::session_exists_sql(), [sql_text(input.session_id.copy())], sql_limits(1, 4096)).rows.length > 0) { result = { ok: false, code: "run_not_found", message: "compaction start references an unknown session or run", result: { columns: [], rows: [] } }; } else { - if !existence::run_exists(db_id, input.run_id.copy()) { + if !(sqlite::query(&db_id, existence::run_exists_sql(), [sql_text(input.run_id.copy())], sql_limits(1, 4096)).rows.length > 0) { result = { ok: false, code: "run_not_found", message: "compaction start references an unknown session or run", result: { columns: [], rows: [] } }; } else { if compaction_id_conflict(db_id, input.id.copy(), input.session_id.copy(), input.generation.copy()) { @@ -182,12 +196,13 @@ pub fn storage_compaction_start(db_id: resource, payload_json // (session_id, generation) decides the outcome BEFORE any // insert, so a crash between start and commit can never // leave the session stuck and no rejection is ever silent. - let existing: map = compaction_existing_row(db_id, input.session_id.copy(), input.generation.copy(), max_rows, max_bytes); - let existing_copy: map = existing.copy(); - let existing_rows: array = existing["rows"]; + let existing = compaction_existing_row(db_id, input.session_id.copy(), input.generation.copy(), max_rows, max_bytes); + let existing_copy: map = sqlite_query_map(existing); + let existing_rows = existing.rows; if existing_rows.length > 0 { - let existing_row: array = existing_rows[0]; - let existing_state: string = existing_row[10]; + let existing_row: SqliteRow = existing_rows[0].copy(); + let existing_cells: array = existing_row.cells.copy(); + let existing_state: string = existing_cells[10].text_value; if existing_state == "committed" { result = { ok: false, code: "compaction_already_committed", message: "a compaction for this session+generation is already committed", result: { columns: [], rows: [] } }; } else { @@ -198,15 +213,15 @@ pub fn storage_compaction_start(db_id: resource, payload_json // Different payload -> typed conflict: never a // silent ok with an empty row set, never a // clobber of the pending record. - let existing_id: string = existing_row[0]; - let existing_run_id: string = existing_row[2]; - let existing_generation: int = existing_row[3]; - let existing_source_start: int = existing_row[4]; - let existing_source_end: int = existing_row[5]; - let existing_retained_tail: int = existing_row[6]; - let existing_summary: string = existing_row[7]; - let existing_token_estimate: int = existing_row[8]; - let existing_model: string = existing_row[9]; + let existing_id: string = existing_cells[0].text_value; + let existing_run_id: string = existing_cells[2].text_value; + let existing_generation: int = existing_cells[3].int_value; + let existing_source_start: int = existing_cells[4].int_value; + let existing_source_end: int = existing_cells[5].int_value; + let existing_retained_tail: int = existing_cells[6].int_value; + let existing_summary: string = existing_cells[7].text_value; + let existing_token_estimate: int = existing_cells[8].int_value; + let existing_model: string = existing_cells[9].text_value; if compaction_row_matches( existing_id, existing_run_id, @@ -239,7 +254,7 @@ pub fn storage_compaction_start(db_id: resource, payload_json // row's audit identity, so it is a typed // conflict: the caller must resume with the // original id. - let existing_failed_id: string = existing_row[0]; + let existing_failed_id: string = existing_cells[0].text_value; if existing_failed_id == input.id.copy() { result = compaction_start_insert(db_id, payload_json, max_rows, max_bytes); } else { @@ -256,22 +271,12 @@ pub fn storage_compaction_start(db_id: resource, payload_json result } -pub fn storage_compaction_get(db_id: resource, compaction_id: string, max_rows: int, max_bytes: int) -> map { - sqlite::query( - &db_id, - "SELECT id, session_id, run_id, generation, source_start_ordinal, source_end_ordinal, retained_tail_ordinal, summary_json, token_estimate, model, state, error_message, created_at_ms, completed_at_ms FROM compactions WHERE id = ? LIMIT ?", - [compaction_id, (max_rows)], - compactions_query_limits(max_rows, max_bytes) - ) +pub fn storage_compaction_get(db_id: resource, compaction_id: string, max_rows: int, max_bytes: int) -> SqliteQueryResult { + sqlite::query(&db_id, "SELECT id, session_id, run_id, generation, source_start_ordinal, source_end_ordinal, retained_tail_ordinal, summary_json, token_estimate, model, state, error_message, created_at_ms, completed_at_ms FROM compactions WHERE id = ? LIMIT ?", [sql_text(compaction_id), sql_int(max_rows)], compactions_query_limits(max_rows, max_bytes)) } -pub fn storage_compaction_latest(db_id: resource, session_id: string, max_rows: int, max_bytes: int) -> map { - sqlite::query( - &db_id, - "SELECT id, session_id, run_id, generation, source_start_ordinal, source_end_ordinal, retained_tail_ordinal, summary_json, token_estimate, model, state, error_message, created_at_ms, completed_at_ms FROM compactions WHERE session_id = ? ORDER BY generation DESC, created_at_ms DESC LIMIT ?", - [session_id, (max_rows)], - compactions_query_limits(max_rows, max_bytes) - ) +pub fn storage_compaction_latest(db_id: resource, session_id: string, max_rows: int, max_bytes: int) -> SqliteQueryResult { + sqlite::query(&db_id, "SELECT id, session_id, run_id, generation, source_start_ordinal, source_end_ordinal, retained_tail_ordinal, summary_json, token_estimate, model, state, error_message, created_at_ms, completed_at_ms FROM compactions WHERE session_id = ? ORDER BY generation DESC, created_at_ms DESC LIMIT ?", [sql_text(session_id.copy()), sql_int(max_rows)], compactions_query_limits(max_rows, max_bytes)) } pub fn storage_compaction_commit(db_id: resource, payload_json: string) -> array { @@ -285,24 +290,30 @@ pub fn storage_compaction_commit(db_id: resource, payload_jso // transition), so a no-op or conflicting commit can never sweep // messages. Statement 3 re-verifies the committed row directly // (EXISTS ... state = 'committed') instead of relying on changes(). - let statements = [ + let statements: array = [ { sql: "UPDATE compactions SET state = 'committed', completed_at_ms = ? WHERE id = ? AND state = 'pending' AND session_id = ? AND source_start_ordinal = ? AND source_end_ordinal = ? AND generation = ? AND ? >= 0 AND ? >= ? AND ? <= ? AND EXISTS (SELECT 1 FROM messages WHERE messages.session_id = compactions.session_id AND messages.ordinal = ?) AND EXISTS (SELECT 1 FROM messages WHERE messages.session_id = compactions.session_id AND messages.ordinal = ?) AND EXISTS (SELECT 1 FROM runs WHERE runs.id = compactions.run_id AND runs.session_id = ? AND runs.status = 'compacting') AND EXISTS (SELECT 1 FROM sessions WHERE sessions.id = ? AND sessions.generation = ? - 1)", - params: [input.completed_at_ms.copy(), &input.id, &input.session_id, input.start_ordinal.copy(), input.end_ordinal.copy(), input.generation.copy(), input.start_ordinal.copy(), input.end_ordinal.copy(), input.start_ordinal.copy(), input.start_ordinal.copy(), input.end_ordinal.copy(), input.start_ordinal.copy(), input.end_ordinal.copy(), &input.session_id, &input.session_id, input.generation.copy()] - }, + params: [sql_int(input.completed_at_ms.copy()), sql_text(input.id.copy()), sql_text(input.session_id.copy()), sql_int(input.start_ordinal.copy()), sql_int(input.end_ordinal.copy()), sql_int(input.generation.copy()), sql_int(input.start_ordinal.copy()), sql_int(input.end_ordinal.copy()), sql_int(input.start_ordinal.copy()), sql_int(input.start_ordinal.copy()), sql_int(input.end_ordinal.copy()), sql_int(input.start_ordinal.copy()), sql_int(input.end_ordinal.copy()), sql_text(input.session_id.copy()), sql_text(input.session_id.copy()), sql_int(input.generation.copy())], + query: false, + limits: sql_limits(1000, 4194304) + }, { sql: "UPDATE messages SET compacted = 1 WHERE session_id = ? AND ordinal >= ? AND ordinal <= ? AND changes() = 1 AND EXISTS (SELECT 1 FROM compactions WHERE id = ? AND session_id = ? AND source_start_ordinal = ? AND source_end_ordinal = ? AND generation = ? AND state = 'committed')", - params: [&input.session_id, input.start_ordinal.copy(), input.end_ordinal.copy(), &input.id, &input.session_id, input.start_ordinal.copy(), input.end_ordinal.copy(), input.generation.copy()] - }, + params: [sql_text(input.session_id.copy()), sql_int(input.start_ordinal.copy()), sql_int(input.end_ordinal.copy()), sql_text(input.id.copy()), sql_text(input.session_id.copy()), sql_int(input.start_ordinal.copy()), sql_int(input.end_ordinal.copy()), sql_int(input.generation.copy())], + query: false, + limits: sql_limits(1000, 4194304) + }, { sql: "UPDATE sessions SET generation = MAX(generation, ?), updated_at_ms = ? WHERE id = ? AND generation < ? AND EXISTS (SELECT 1 FROM compactions WHERE id = ? AND session_id = ? AND generation = ? AND state = 'committed')", - params: [input.generation.copy(), input.completed_at_ms.copy(), &input.session_id, input.generation.copy(), &input.id, &input.session_id, input.generation.copy()] - } + params: [sql_int(input.generation.copy()), sql_int(input.completed_at_ms.copy()), sql_text(input.session_id.copy()), sql_int(input.generation.copy()), sql_text(input.id.copy()), sql_text(input.session_id.copy()), sql_int(input.generation.copy())], + query: false, + limits: sql_limits(1000, 4194304) + } ]; sqlite::transaction(&db_id, statements) } -pub fn storage_compaction_fail(db_id: resource, payload_json: string) -> map { +pub fn storage_compaction_fail(db_id: resource, payload_json: string) -> SqliteExecuteResult { let input: CompactionFailInput = json::decode::(payload_json); // Deliberately a silent no-op when no pending row matches (P3 // evaluation: every caller treats fail as best-effort — the compact @@ -311,9 +322,5 @@ pub fn storage_compaction_fail(db_id: resource, payload_json: // means the failure was already recorded or the row was never // created; surfacing a typed error would break those callers without // adding safety). - sqlite::execute( - &db_id, - "UPDATE compactions SET state = 'failed', error_message = ?, completed_at_ms = ? WHERE id = ? AND state = 'pending'", - [&input.error_message, input.completed_at_ms.copy(), &input.id] - ) + sqlite::execute(&db_id, "UPDATE compactions SET state = 'failed', error_message = ?, completed_at_ms = ? WHERE id = ? AND state = 'pending'", [sql_text(input.error_message.copy()), sql_int(input.completed_at_ms.copy()), sql_text(input.id.copy())]) } diff --git a/rss/storage/debug.rss b/rss/storage/debug.rss index 0f2cd4b..e57e74d 100644 --- a/rss/storage/debug.rss +++ b/rss/storage/debug.rss @@ -1,5 +1,30 @@ use json; use sqlite; + +fn sql_text(value: string) -> SqliteValue { + { kind: "text", int_value: null, float_value: null, text_value: value, blob_value: null } +} + +fn sql_int(value: int) -> SqliteValue { + { kind: "int", int_value: value, float_value: null, text_value: null, blob_value: null } +} + +fn sql_limits(max_rows: int, max_bytes: int) -> SqliteLimits { + { + max_connections: 16, + max_statements: 128, + max_rows: max_rows, + max_columns: 128, + max_result_bytes: max_bytes, + max_statement_bytes: 1048576, + max_parameters: 128, + max_parameter_bytes: 1048576, + max_pending_operations: 32, + max_transaction_ms: 5000, + busy_timeout_ms: 5000 + } +} + use self::schema as schema; use self::sessions as sessions; use self::messages as messages; @@ -86,18 +111,12 @@ struct CompactionIdInput { } -fn storage_open_options(db_path: string, db_mode: string, busy_timeout_ms: int, max_rows: int, max_bytes: int) -> StorageOpenOptions { +fn storage_open_options(db_path: string, db_mode: string, busy_timeout_ms: int, max_rows: int, max_bytes: int) -> SqliteOpenOptions { { path: db_path, mode: db_mode, - busy_timeout_ms: busy_timeout_ms, - max_connections: 1, - limits: { - max_rows: schema::max_rows_limit(max_rows), - max_bytes: schema::max_bytes_limit(max_bytes), - max_statements: 64, - max_transaction_ms: 5000 - } + root: null, + limits: sql_limits(schema::max_rows_limit(max_rows), schema::max_bytes_limit(max_bytes)) } } @@ -105,24 +124,23 @@ fn storage_apply_migrations(db_id: resource, now_ms: int) { sqlite::execute(&db_id, schema::schema_migrations_table_sql(), []); let mut migration_index = 0; while migration_index < schema::schema_migration_count() { - let mut statements = []; + let mut statements: array = []; let mut statement_index = 0; while statement_index < schema::schema_migration_statement_count(migration_index) { statements[statements.length] = { sql: schema::schema_migration_statement(migration_index, statement_index), - params: [] - }; + params: [], + query: false, + limits: sql_limits(1000, 4194304) + }; statement_index += 1; } statements[statements.length] = { sql: schema::schema_migration_record_sql(), - params: [ - schema::schema_migration_version(migration_index), - schema::schema_migration_name(migration_index), - schema::schema_migration_checksum(migration_index), - now_ms - ] - }; + params: [sql_int(schema::schema_migration_version(migration_index)), sql_text(schema::schema_migration_name(migration_index)), sql_text(schema::schema_migration_checksum(migration_index)), sql_int(now_ms)], + query: false, + limits: sql_limits(1000, 4194304) + }; sqlite::transaction(&db_id, statements); migration_index += 1; } @@ -143,11 +161,20 @@ fn storage_ok(request_id: string, op: string, data: map, rows_affected: int, tru } } -fn storage_raw_ok(request_id: string, op: string, raw_result: map) -> StorageResult { - let rows_affected: int = sqlite::rows_affected(raw_result); - let truncated: bool = sqlite::truncated(raw_result); - let next_cursor: int = sqlite::next_cursor(raw_result); - storage_ok(request_id, op, raw_result, rows_affected, truncated, next_cursor) +fn storage_query_data(result: SqliteQueryResult) -> map { + { + columns: result.columns, + rows: result.rows, + truncated: result.truncated + } +} + +fn storage_raw_ok(request_id: string, op: string, raw_result: SqliteQueryResult) -> StorageResult { + storage_ok(request_id, op, storage_query_data(raw_result), 0, raw_result.truncated, 0) +} + +fn storage_execute_ok(request_id: string, op: string, raw_result: SqliteExecuteResult) -> StorageResult { + storage_ok(request_id, op, { rows_affected: raw_result.rows_affected, last_insert_rowid: raw_result.last_insert_rowid }, raw_result.rows_affected, false, 0) } fn storage_host_contract() -> string { @@ -168,6 +195,20 @@ fn storage_error(request_id: string, op: string, code: string, message: string) } } +fn storage_unwrap(request_id: string, op: string, wrapped: map) -> StorageResult { + let ok_flag: bool = wrapped["ok"]; + let mut result = storage_error(request_id, op, "unwrap_missing", "unwrap result missing"); + if ok_flag == true { + let raw: map = wrapped["result"]; + result = storage_ok(request_id, op, raw, 0, false, 0); + } else { + let code: string = wrapped["code"]; + let message: string = wrapped["message"]; + result = storage_error(request_id, op, code, message); + } + result +} + fn storage_dispatch(db_id: resource, command: StorageCommand) -> StorageResult { let request_id = command.request_id.copy(); let op = command.op.copy(); @@ -201,7 +242,7 @@ fn storage_dispatch(db_id: resource, command: StorageCommand) } else => { null }; if command.op == "message.append" => { - result = storage_raw_ok(request_id, op, messages::storage_message_append(db_id, command.payload_json, command.max_rows, command.max_bytes)); + result = storage_unwrap(request_id, op, messages::storage_message_append(db_id, command.payload_json, command.max_rows, command.max_bytes)); result } else => { null }; @@ -218,7 +259,7 @@ fn storage_dispatch(db_id: resource, command: StorageCommand) } else => { null }; if command.op == "message.compact" => { - result = storage_raw_ok(request_id, op, messages::storage_message_mark_compacted(db_id, command.payload_json)); + result = storage_execute_ok(request_id, op, messages::storage_message_mark_compacted(db_id, command.payload_json)); result } else => { null }; diff --git a/rss/storage/events.rss b/rss/storage/events.rss index 9c9cb82..0ee0f2f 100644 --- a/rss/storage/events.rss +++ b/rss/storage/events.rss @@ -1,11 +1,36 @@ use json; use sqlite; + +fn sql_text(value: string) -> SqliteValue { + { kind: "text", int_value: null, float_value: null, text_value: value, blob_value: null } +} + +fn sql_int(value: int) -> SqliteValue { + { kind: "int", int_value: value, float_value: null, text_value: null, blob_value: null } +} + +fn sql_limits(max_rows: int, max_bytes: int) -> SqliteLimits { + { + max_connections: 16, + max_statements: 128, + max_rows: max_rows, + max_columns: 128, + max_result_bytes: max_bytes, + max_statement_bytes: 1048576, + max_parameters: 128, + max_parameter_bytes: 1048576, + max_pending_operations: 32, + max_transaction_ms: 5000, + busy_timeout_ms: 5000 + } +} + use self::schema as schema; use self::existence as existence; use self::messages as messages; -fn events_query_limits(max_rows: int, max_bytes: int) -> map { - { max_rows: max_rows, max_result_bytes: if max_bytes < 4096 => { 4096 } else => { max_bytes } } +fn events_query_limits(max_rows: int, max_bytes: int) { + sql_limits(max_rows, if max_bytes < 4096 => { 4096 } else => { max_bytes }) } @@ -46,27 +71,35 @@ pub fn storage_event_append(db_id: resource, payload_json: st reserved_seq = raw_payload["seq"].copy(); } let mut result = { ok: true, code: "ok", message: "", result: [] }; - if !existence::run_exists(db_id, input.run_id.copy()) { + if !(sqlite::query(&db_id, existence::run_exists_sql(), [sql_text(input.run_id.copy())], sql_limits(1, 4096)).rows.length > 0) { result = { ok: false, code: "run_not_found", message: "event append targets an unknown run", result: [] }; } else { let max_events: int = schema::max_events_limit(input.max_events.copy()); - let statements = [ + let statements: array = [ { sql: "INSERT INTO run_events (run_id, seq, event_id, event_type, payload_json, created_at_ms) SELECT ?, CASE WHEN ? > 0 THEN ? ELSE COALESCE((SELECT MAX(events.seq) FROM run_events events WHERE events.run_id = ?), 0) + 1 END, ?, ?, ?, ? WHERE NOT EXISTS (SELECT 1 FROM run_events existing WHERE existing.event_id = ?)", - params: [&input.run_id, reserved_seq.copy(), reserved_seq.copy(), &input.run_id, &input.event_id, &input.event_type, &input.payload_json, input.now_ms.copy(), input.event_id.copy()] - }, + params: [sql_text(input.run_id.copy()), sql_int(reserved_seq.copy()), sql_int(reserved_seq.copy()), sql_text(input.run_id.copy()), sql_text(input.event_id.copy()), sql_text(input.event_type.copy()), sql_text(input.payload_json.copy()), sql_int(input.now_ms.copy()), sql_text(input.event_id.copy())], + query: false, + limits: sql_limits(1000, 4194304) + }, { sql: "DELETE FROM run_events WHERE rowid IN (SELECT rowid FROM run_events WHERE run_id = ? ORDER BY seq ASC LIMIT CASE WHEN (SELECT COUNT(*) FROM run_events WHERE run_id = ?) > ? THEN (SELECT COUNT(*) FROM run_events WHERE run_id = ?) - ? ELSE 0 END)", - params: [&input.run_id, &input.run_id, max_events.copy(), &input.run_id, max_events.copy()] - }, + params: [sql_text(input.run_id.copy()), sql_text(input.run_id.copy()), sql_int(max_events.copy()), sql_text(input.run_id.copy()), sql_int(max_events.copy())], + query: false, + limits: sql_limits(1000, 4194304) + }, { sql: "INSERT INTO run_retention (run_id, first_seq, high_water_seq, updated_at_ms) SELECT ?, COALESCE((SELECT MIN(seq) FROM run_events WHERE run_id = ?), 0), COALESCE((SELECT MAX(seq) FROM run_events WHERE run_id = ?), 0), ? ON CONFLICT (run_id) DO UPDATE SET first_seq = excluded.first_seq, high_water_seq = MAX(run_retention.high_water_seq, excluded.high_water_seq), updated_at_ms = excluded.updated_at_ms", - params: [&input.run_id, &input.run_id, &input.run_id, input.now_ms.copy()] - }, + params: [sql_text(input.run_id.copy()), sql_text(input.run_id.copy()), sql_text(input.run_id.copy()), sql_int(input.now_ms.copy())], + query: false, + limits: sql_limits(1000, 4194304) + }, { sql: "UPDATE runs SET updated_at_ms = ? WHERE id = ?", - params: [input.now_ms.copy(), &input.run_id] - } + params: [sql_int(input.now_ms.copy()), sql_text(input.run_id.copy())], + query: false, + limits: sql_limits(1000, 4194304) + } ]; result = { ok: true, code: "ok", message: "", result: sqlite::transaction(&db_id, statements) }; } @@ -78,27 +111,17 @@ pub fn storage_event_append(db_id: resource, payload_json: st /// events report floor 1 / high-water 0. The query always yields exactly one /// row (COALESCE), so extraction is unconditional. pub fn storage_event_retention(db_id: resource, run_id: string) -> map { - let result: map = sqlite::query( - &db_id, - "SELECT COALESCE((SELECT first_seq FROM run_retention WHERE run_id = ?), 1) AS first_seq, COALESCE((SELECT high_water_seq FROM run_retention WHERE run_id = ?), 0) AS high_water_seq", - [&run_id, &run_id], - { max_rows: 1, max_result_bytes: 4096 } - ); - let rows: array = result["rows"]; - let row: array = rows[0]; - let first_seq: int = row[0]; - let high_water_seq: int = row[1]; + let result: SqliteQueryResult = sqlite::query(&db_id, "SELECT COALESCE((SELECT first_seq FROM run_retention WHERE run_id = ?), 1) AS first_seq, COALESCE((SELECT high_water_seq FROM run_retention WHERE run_id = ?), 0) AS high_water_seq", [sql_text(run_id.copy()), sql_text(run_id.copy())], sql_limits(1, 4096)); + let retention_row: SqliteRow = result.rows[0].copy(); + let retention_cells: array = retention_row.cells.copy(); + let first_seq: int = retention_cells[0].int_value; + let high_water_seq: int = retention_cells[1].int_value; { first_seq: first_seq, high_water_seq: high_water_seq } } -pub fn storage_event_replay(db_id: resource, payload_json: string) -> map { +pub fn storage_event_replay(db_id: resource, payload_json: string) -> SqliteQueryResult { let input: EventReplayInput = json::decode::(payload_json); - sqlite::query( - &db_id, - "SELECT events.seq, events.run_id, events.event_id, events.event_type, CASE WHEN length(CAST(events.payload_json AS BLOB)) > CASE WHEN ? <= 0 THEN 16384 WHEN ? > 32768 THEN 16384 WHEN ? < 128 THEN 64 ELSE ? / 2 END THEN '{\"truncated\":true,\"original_bytes\":' || length(CAST(events.payload_json AS BLOB)) || '}' ELSE events.payload_json END AS payload_json, events.created_at_ms FROM run_events events WHERE events.run_id = ? AND events.seq >= ? ORDER BY events.seq ASC LIMIT (CASE WHEN ? <= 0 THEN 128 WHEN ? > 256 THEN 256 ELSE ? END) + 1", - [input.max_bytes.copy(), input.max_bytes.copy(), input.max_bytes.copy(), input.max_bytes.copy(), &input.run_id, input.after_seq.copy(), input.max_events.copy(), input.max_events.copy(), input.max_events.copy()], - events_query_limits(input.max_events.copy(), input.max_bytes.copy()) - ) + sqlite::query(&db_id, "SELECT events.seq, events.run_id, events.event_id, events.event_type, CASE WHEN length(CAST(events.payload_json AS BLOB)) > CASE WHEN ? <= 0 THEN 16384 WHEN ? > 32768 THEN 16384 WHEN ? < 128 THEN 64 ELSE ? / 2 END THEN '{\"truncated\":true,\"original_bytes\":' || length(CAST(events.payload_json AS BLOB)) || '}' ELSE events.payload_json END AS payload_json, events.created_at_ms FROM run_events events WHERE events.run_id = ? AND events.seq >= ? ORDER BY events.seq ASC LIMIT (CASE WHEN ? <= 0 THEN 128 WHEN ? > 256 THEN 256 ELSE ? END) + 1", [sql_int(input.max_bytes.copy()), sql_int(input.max_bytes.copy()), sql_int(input.max_bytes.copy()), sql_int(input.max_bytes.copy()), sql_text(input.run_id.copy()), sql_int(input.after_seq.copy()), sql_int(input.max_events.copy()), sql_int(input.max_events.copy()), sql_int(input.max_events.copy())], events_query_limits(input.max_events.copy(), input.max_bytes.copy())) } /// Prunes a run's retained events down to `max_events` and updates the @@ -106,26 +129,25 @@ pub fn storage_event_replay(db_id: resource, payload_json: st /// floor always matches what replay can actually serve. pub fn storage_event_prune(db_id: resource, payload_json: string) -> array { let input: EventPruneInput = json::decode::(payload_json); - let statements = [ + let statements: array = [ { sql: "DELETE FROM run_events WHERE rowid IN (SELECT rowid FROM run_events WHERE run_id = ? ORDER BY seq ASC LIMIT CASE WHEN (SELECT COUNT(*) FROM run_events WHERE run_id = ?) > CASE WHEN ? <= 0 THEN 128 WHEN ? > 256 THEN 256 ELSE ? END THEN (SELECT COUNT(*) FROM run_events WHERE run_id = ?) - CASE WHEN ? <= 0 THEN 128 WHEN ? > 256 THEN 256 ELSE ? END ELSE 0 END)", - params: [&input.run_id, &input.run_id, input.max_events.copy(), input.max_events.copy(), input.max_events.copy(), &input.run_id, input.max_events.copy(), input.max_events.copy(), input.max_events.copy()] - }, + params: [sql_text(input.run_id.copy()), sql_text(input.run_id.copy()), sql_int(input.max_events.copy()), sql_int(input.max_events.copy()), sql_int(input.max_events.copy()), sql_text(input.run_id.copy()), sql_int(input.max_events.copy()), sql_int(input.max_events.copy()), sql_int(input.max_events.copy())], + query: false, + limits: sql_limits(1000, 4194304) + }, { sql: "INSERT INTO run_retention (run_id, first_seq, high_water_seq, updated_at_ms) SELECT ?, COALESCE((SELECT MIN(seq) FROM run_events WHERE run_id = ?), 0), COALESCE((SELECT MAX(seq) FROM run_events WHERE run_id = ?), 0), ? ON CONFLICT (run_id) DO UPDATE SET first_seq = excluded.first_seq, high_water_seq = MAX(run_retention.high_water_seq, excluded.high_water_seq), updated_at_ms = excluded.updated_at_ms", - params: [&input.run_id, &input.run_id, &input.run_id, input.now_ms.copy()] - } + params: [sql_text(input.run_id.copy()), sql_text(input.run_id.copy()), sql_text(input.run_id.copy()), sql_int(input.now_ms.copy())], + query: false, + limits: sql_limits(1000, 4194304) + } ]; sqlite::transaction(&db_id, statements) } -pub fn storage_delivery_cursor_get(db_id: resource, session_id: string, consumer: string, max_rows: int, max_bytes: int) -> map { - sqlite::query( - &db_id, - "SELECT session_id, consumer, last_event_seq, updated_at_ms FROM delivery_cursors WHERE session_id = ? AND consumer = ? LIMIT ?", - [&session_id, &consumer, (max_rows)], - events_query_limits(max_rows, max_bytes) - ) +pub fn storage_delivery_cursor_get(db_id: resource, session_id: string, consumer: string, max_rows: int, max_bytes: int) -> SqliteQueryResult { + sqlite::query(&db_id, "SELECT session_id, consumer, last_event_seq, updated_at_ms FROM delivery_cursors WHERE session_id = ? AND consumer = ? LIMIT ?", [sql_text(session_id.copy()), sql_text(consumer.copy()), sql_int(max_rows)], events_query_limits(max_rows, max_bytes)) } /// Monotonic unvalidated cursor upsert for values that are not run-event @@ -133,22 +155,14 @@ pub fn storage_delivery_cursor_get(db_id: resource, session_i /// stays the validated path for per-run event delivery; `set` is the /// sibling command for transport-level cursors whose values are unrelated /// to `run_events.seq`. -pub fn storage_delivery_cursor_set(db_id: resource, payload_json: string) -> map { +pub fn storage_delivery_cursor_set(db_id: resource, payload_json: string) -> SqliteExecuteResult { let input: CursorInput = json::decode::(payload_json); - sqlite::execute( - &db_id, - "INSERT INTO delivery_cursors (session_id, consumer, last_event_seq, updated_at_ms) VALUES (?, ?, ?, ?) ON CONFLICT (session_id, consumer) DO UPDATE SET last_event_seq = MAX(delivery_cursors.last_event_seq, excluded.last_event_seq), updated_at_ms = excluded.updated_at_ms", - [&input.session_id, &input.consumer, input.event_seq.copy(), input.now_ms.copy()] - ) + sqlite::execute(&db_id, "INSERT INTO delivery_cursors (session_id, consumer, last_event_seq, updated_at_ms) VALUES (?, ?, ?, ?) ON CONFLICT (session_id, consumer) DO UPDATE SET last_event_seq = MAX(delivery_cursors.last_event_seq, excluded.last_event_seq), updated_at_ms = excluded.updated_at_ms", [sql_text(input.session_id.copy()), sql_text(input.consumer.copy()), sql_int(input.event_seq.copy()), sql_int(input.now_ms.copy())]) } -pub fn storage_delivery_cursor_advance(db_id: resource, payload_json: string) -> map { +pub fn storage_delivery_cursor_advance(db_id: resource, payload_json: string) -> SqliteExecuteResult { let input: CursorInput = json::decode::(payload_json); - sqlite::execute( - &db_id, - "INSERT INTO delivery_cursors (session_id, consumer, last_event_seq, updated_at_ms) SELECT ?, ?, ?, ? WHERE ? >= 0 AND ? <= COALESCE((SELECT MAX(events.seq) FROM run_events events JOIN runs event_runs ON event_runs.id = events.run_id WHERE event_runs.session_id = ?), 0) ON CONFLICT (session_id, consumer) DO UPDATE SET last_event_seq = MAX(delivery_cursors.last_event_seq, excluded.last_event_seq), updated_at_ms = excluded.updated_at_ms", - [&input.session_id, &input.consumer, input.event_seq.copy(), input.now_ms.copy(), input.event_seq.copy(), input.event_seq.copy(), &input.session_id] - ) + sqlite::execute(&db_id, "INSERT INTO delivery_cursors (session_id, consumer, last_event_seq, updated_at_ms) SELECT ?, ?, ?, ? WHERE ? >= 0 AND ? <= COALESCE((SELECT MAX(events.seq) FROM run_events events JOIN runs event_runs ON event_runs.id = events.run_id WHERE event_runs.session_id = ?), 0) ON CONFLICT (session_id, consumer) DO UPDATE SET last_event_seq = MAX(delivery_cursors.last_event_seq, excluded.last_event_seq), updated_at_ms = excluded.updated_at_ms", [sql_text(input.session_id.copy()), sql_text(input.consumer.copy()), sql_int(input.event_seq.copy()), sql_int(input.now_ms.copy()), sql_int(input.event_seq.copy()), sql_int(input.event_seq.copy()), sql_text(input.session_id.copy())]) } struct StepCommitInput { @@ -190,13 +204,13 @@ pub fn storage_step_commit(db_id: resource, payload_json: str if input.payload_json.copy().length > 65536 { result = { ok: false, code: "payload_too_large", message: "step event payload exceeds 65536 bytes", result: [] }; } else { - if !existence::run_exists(db_id, input.run_id.copy()) { + if !(sqlite::query(&db_id, existence::run_exists_sql(), [sql_text(input.run_id.copy())], sql_limits(1, 4096)).rows.length > 0) { result = { ok: false, code: "run_not_found", message: "step commit targets an unknown run", result: [] }; } else { - if input.message_id.copy() != "" && !existence::session_exists(db_id, input.session_id.copy()) { + if input.message_id.copy() != "" && !(sqlite::query(&db_id, existence::session_exists_sql(), [sql_text(input.session_id.copy())], sql_limits(1, 4096)).rows.length > 0) { result = { ok: false, code: "session_not_found", message: "step commit targets an unknown session", result: [] }; } else { - let encoded: string = messages::storage_message_encode_content(db_id, input.content_json.copy()); + let encoded: string = messages::storage_message_encode_content(input.content_json.copy()); let max_events: int = schema::max_events_limit(input.max_events.copy()); let mut reserved_seq: int = 0; let mut reserved_ordinal: int = 0; @@ -209,32 +223,46 @@ pub fn storage_step_commit(db_id: resource, payload_json: str let mut statements = [ { sql: "INSERT INTO run_events (run_id, seq, event_id, event_type, payload_json, created_at_ms) SELECT ?, CASE WHEN ? > 0 THEN ? ELSE COALESCE((SELECT MAX(events.seq) FROM run_events events WHERE events.run_id = ?), 0) + 1 END, ?, ?, ?, ? WHERE NOT EXISTS (SELECT 1 FROM run_events existing WHERE existing.event_id = ?)", - params: [&input.run_id, reserved_seq.copy(), reserved_seq.copy(), &input.run_id, &input.event_id, &input.event_type, &input.payload_json, input.now_ms.copy(), input.event_id.copy()] - }, + params: [sql_text(input.run_id.copy()), sql_int(reserved_seq.copy()), sql_int(reserved_seq.copy()), sql_text(input.run_id.copy()), sql_text(input.event_id.copy()), sql_text(input.event_type.copy()), sql_text(input.payload_json.copy()), sql_int(input.now_ms.copy()), sql_text(input.event_id.copy())], + query: false, + limits: sql_limits(1000, 4194304) + }, { sql: "DELETE FROM run_events WHERE rowid IN (SELECT rowid FROM run_events WHERE run_id = ? ORDER BY seq ASC LIMIT CASE WHEN (SELECT COUNT(*) FROM run_events WHERE run_id = ?) > ? THEN (SELECT COUNT(*) FROM run_events WHERE run_id = ?) - ? ELSE 0 END)", - params: [&input.run_id, &input.run_id, max_events.copy(), &input.run_id, max_events.copy()] - }, + params: [sql_text(input.run_id.copy()), sql_text(input.run_id.copy()), sql_int(max_events.copy()), sql_text(input.run_id.copy()), sql_int(max_events.copy())], + query: false, + limits: sql_limits(1000, 4194304) + }, { sql: "INSERT INTO run_retention (run_id, first_seq, high_water_seq, updated_at_ms) SELECT ?, COALESCE((SELECT MIN(seq) FROM run_events WHERE run_id = ?), 0), COALESCE((SELECT MAX(seq) FROM run_events WHERE run_id = ?), 0), ? ON CONFLICT (run_id) DO UPDATE SET first_seq = excluded.first_seq, high_water_seq = MAX(run_retention.high_water_seq, excluded.high_water_seq), updated_at_ms = excluded.updated_at_ms", - params: [&input.run_id, &input.run_id, &input.run_id, input.now_ms.copy()] - }, + params: [sql_text(input.run_id.copy()), sql_text(input.run_id.copy()), sql_text(input.run_id.copy()), sql_int(input.now_ms.copy())], + query: false, + limits: sql_limits(1000, 4194304) + }, { sql: "INSERT OR IGNORE INTO messages (id, session_id, ordinal, role, content_json, name, tool_call_id, parent_message_id, token_estimate, metadata_json, run_id, finish_reason, created_at_ms) SELECT ?, ?, CASE WHEN ? > 0 THEN ? ELSE COALESCE((SELECT MAX(ordinal) FROM messages WHERE session_id = ?), 0) + 1 END, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? WHERE ? != ''", - params: [&input.message_id, &input.session_id, reserved_ordinal.copy(), reserved_ordinal.copy(), &input.session_id, &input.role, &encoded, &input.name, &input.tool_call_id, &input.parent_message_id, input.token_estimate.copy(), &input.metadata_json, &input.run_id, &input.finish_reason, input.now_ms.copy(), &input.message_id] - }, + params: [sql_text(input.message_id.copy()), sql_text(input.session_id.copy()), sql_int(reserved_ordinal.copy()), sql_int(reserved_ordinal.copy()), sql_text(input.session_id.copy()), sql_text(input.role.copy()), sql_text(encoded), sql_text(input.name.copy()), sql_text(input.tool_call_id.copy()), sql_text(input.parent_message_id.copy()), sql_int(input.token_estimate.copy()), sql_text(input.metadata_json.copy()), sql_text(input.run_id.copy()), sql_text(input.finish_reason.copy()), sql_int(input.now_ms.copy()), sql_text(input.message_id.copy())], + query: false, + limits: sql_limits(1000, 4194304) + }, { sql: "UPDATE sessions SET last_message_seq = COALESCE((SELECT MAX(ordinal) FROM messages WHERE session_id = ?), 0), updated_at_ms = ? WHERE id = ? AND ? != ''", - params: [&input.session_id, input.now_ms.copy(), &input.session_id, &input.message_id] - }, + params: [sql_text(input.session_id.copy()), sql_int(input.now_ms.copy()), sql_text(input.session_id.copy()), sql_text(input.message_id.copy())], + query: false, + limits: sql_limits(1000, 4194304) + }, { sql: "UPDATE runs SET updated_at_ms = ? WHERE id = ?", - params: [input.now_ms.copy(), &input.run_id] - }, + params: [sql_int(input.now_ms.copy()), sql_text(input.run_id.copy())], + query: false, + limits: sql_limits(1000, 4194304) + }, { sql: "UPDATE messages SET compacted = 2 WHERE ? = 'after_partial_write' AND id = ?", - params: [&failpoint, &input.message_id] - } + params: [sql_text(failpoint), sql_text(input.message_id.copy())], + query: false, + limits: sql_limits(1000, 4194304) + } ]; result = { ok: true, code: "ok", message: "", result: sqlite::transaction(&db_id, statements) }; if failpoint == "after_commit_before_publish" { @@ -255,23 +283,31 @@ pub fn storage_effect_reconcile(db_id: resource, payload_json let limit: int = if input.max_rows.copy() <= 0 => { 64 } else => { if input.max_rows.copy() > 256 => { 256 } else => { input.max_rows.copy() } }; - let statements = [ + let statements: array = [ { sql: "INSERT OR IGNORE INTO run_events (run_id, seq, event_id, event_type, payload_json, created_at_ms) SELECT pending.run_id, COALESCE((SELECT MAX(events.seq) FROM run_events events WHERE events.run_id = pending.run_id), 0) + pending.rn, substr('recovery-effect:' || pending.run_id || ':' || pending.tool_call_id, 1, 128), 'tool.failed', CAST(json_object('status', 'failed', 'error_code', 'interrupted_effect', 'tool_call_id', pending.tool_call_id, 'reason', 'interrupted_effect') AS TEXT), ? FROM (SELECT grouped.run_id AS run_id, grouped.tool_call_id AS tool_call_id, ROW_NUMBER() OVER (PARTITION BY grouped.run_id ORDER BY grouped.min_seq) AS rn FROM (SELECT events.run_id AS run_id, json_extract(events.payload_json, '$.tool_call_id') AS tool_call_id, MIN(events.seq) AS min_seq FROM run_events events WHERE events.event_type IN ('tool.requested', 'tool.started') AND json_extract(events.payload_json, '$.tool_call_id') IS NOT NULL AND json_extract(events.payload_json, '$.tool_call_id') != '' AND NOT EXISTS (SELECT 1 FROM run_events done WHERE done.run_id = events.run_id AND done.event_type IN ('tool.output', 'tool.completed', 'tool.failed') AND json_extract(done.payload_json, '$.tool_call_id') = json_extract(events.payload_json, '$.tool_call_id')) GROUP BY events.run_id, json_extract(events.payload_json, '$.tool_call_id') LIMIT ?) grouped) pending", - params: [input.now_ms.copy(), limit.copy()] - }, + params: [sql_int(input.now_ms.copy()), sql_int(limit.copy())], + query: false, + limits: sql_limits(1000, 4194304) + }, { sql: "INSERT OR IGNORE INTO messages (id, session_id, ordinal, role, content_json, name, tool_call_id, parent_message_id, token_estimate, metadata_json, run_id, finish_reason, created_at_ms) SELECT substr('recovery-msg:' || pending.run_id || ':' || pending.tool_call_id, 1, 128), pending.session_id, COALESCE((SELECT MAX(ordinal) FROM messages existing WHERE existing.session_id = pending.session_id), 0) + pending.rn, 'user', CAST(json_array(json_object('type', 'tool_result', 'tool_call_id', pending.tool_call_id, 'content', '', 'is_error', json('true'), 'error', json_object('code', 'interrupted_effect', 'message', 'effect interrupted by restart'), 'truncated', json('false'))) AS TEXT), '', pending.tool_call_id, '', 0, CAST(json_object('interrupted_effect', json('true')) AS TEXT), pending.run_id, '', ? FROM (SELECT grouped.run_id AS run_id, grouped.session_id AS session_id, grouped.tool_call_id AS tool_call_id, ROW_NUMBER() OVER (PARTITION BY grouped.session_id ORDER BY grouped.min_seq) AS rn FROM (SELECT events.run_id AS run_id, runs.session_id AS session_id, json_extract(events.payload_json, '$.tool_call_id') AS tool_call_id, MIN(events.seq) AS min_seq FROM run_events events JOIN runs ON runs.id = events.run_id WHERE events.event_type = 'tool.failed' AND json_extract(events.payload_json, '$.error_code') = 'interrupted_effect' GROUP BY events.run_id, json_extract(events.payload_json, '$.tool_call_id') LIMIT ?) grouped) pending", - params: [input.now_ms.copy(), limit.copy()] - }, + params: [sql_int(input.now_ms.copy()), sql_int(limit.copy())], + query: false, + limits: sql_limits(1000, 4194304) + }, { sql: "UPDATE sessions SET last_message_seq = COALESCE((SELECT MAX(ordinal) FROM messages WHERE session_id = sessions.id), last_message_seq), updated_at_ms = ? WHERE id IN (SELECT runs.session_id FROM runs JOIN run_events events ON events.run_id = runs.id WHERE events.event_type = 'tool.failed' AND json_extract(events.payload_json, '$.error_code') = 'interrupted_effect')", - params: [input.now_ms.copy()] - }, + params: [sql_int(input.now_ms.copy())], + query: false, + limits: sql_limits(1000, 4194304) + }, { sql: "INSERT INTO run_retention (run_id, first_seq, high_water_seq, updated_at_ms) SELECT runs.id, COALESCE((SELECT MIN(seq) FROM run_events events WHERE events.run_id = runs.id), 0), COALESCE((SELECT MAX(seq) FROM run_events events WHERE events.run_id = runs.id), 0), ? FROM runs WHERE EXISTS (SELECT 1 FROM run_events events WHERE events.run_id = runs.id AND events.event_type = 'tool.failed' AND json_extract(events.payload_json, '$.error_code') = 'interrupted_effect') ON CONFLICT (run_id) DO UPDATE SET first_seq = excluded.first_seq, high_water_seq = MAX(run_retention.high_water_seq, excluded.high_water_seq), updated_at_ms = excluded.updated_at_ms", - params: [input.now_ms.copy()] - } + params: [sql_int(input.now_ms.copy())], + query: false, + limits: sql_limits(1000, 4194304) + } ]; let results: array = sqlite::transaction(&db_id, statements); { ok: true, code: "ok", message: "", result: results } diff --git a/rss/storage/existence.rss b/rss/storage/existence.rss index 5d47c36..7761daa 100644 --- a/rss/storage/existence.rss +++ b/rss/storage/existence.rss @@ -3,16 +3,15 @@ // blocker), so every typed command that references a parent record must // verify the parent exists before writing; agent correctness never depends // on FK enforcement. -use sqlite; +// +// These helpers return SQL only. Guest functions cannot take +// `resource` arguments (bound type is unknown), so +// callers run `sqlite::query(&db_id, ...)` themselves. -pub fn run_exists(db_id: resource, run_id: string) -> bool { - let result: map = sqlite::query(&db_id, "SELECT 1 FROM runs WHERE id = ? LIMIT 1", [run_id], { max_rows: 1, max_result_bytes: 4096 }); - let rows: array = result["rows"]; - rows.length > 0 +pub fn run_exists_sql() -> string { + "SELECT 1 FROM runs WHERE id = ? LIMIT 1" } -pub fn session_exists(db_id: resource, session_id: string) -> bool { - let result: map = sqlite::query(&db_id, "SELECT 1 FROM sessions WHERE id = ? LIMIT 1", [session_id], { max_rows: 1, max_result_bytes: 4096 }); - let rows: array = result["rows"]; - rows.length > 0 +pub fn session_exists_sql() -> string { + "SELECT 1 FROM sessions WHERE id = ? LIMIT 1" } diff --git a/rss/storage/gateway.rss b/rss/storage/gateway.rss index c41ba8f..8807c75 100644 --- a/rss/storage/gateway.rss +++ b/rss/storage/gateway.rss @@ -11,6 +11,31 @@ use json; use sqlite; +fn sql_text(value: string) -> SqliteValue { + { kind: "text", int_value: null, float_value: null, text_value: value, blob_value: null } +} + +fn sql_int(value: int) -> SqliteValue { + { kind: "int", int_value: value, float_value: null, text_value: null, blob_value: null } +} + +fn sql_limits(max_rows: int, max_bytes: int) -> SqliteLimits { + { + max_connections: 16, + max_statements: 128, + max_rows: max_rows, + max_columns: 128, + max_result_bytes: max_bytes, + max_statement_bytes: 1048576, + max_parameters: 128, + max_parameter_bytes: 1048576, + max_pending_operations: 32, + max_transaction_ms: 5000, + busy_timeout_ms: 5000 + } +} + + struct GatewayCommand { op: string, kind: string, @@ -26,22 +51,16 @@ struct GatewayCommand { payload_json: string } -fn gateway_limits(command: GatewayCommand) -> map { - {max_rows: command.max_rows, max_result_bytes: command.max_bytes} +fn gateway_limits(command: GatewayCommand) -> SqliteLimits { + sql_limits(command.max_rows, command.max_bytes) } -fn gateway_open(command: GatewayCommand) -> int { +fn gateway_open(command: GatewayCommand) -> resource { sqlite::open({ path: command.db_path, mode: command.db_mode, - limits: { - busy_timeout_ms: command.busy_timeout_ms, - max_connections: 1, - max_rows: command.max_rows, - max_result_bytes: command.max_bytes, - max_statements: 1024, - max_transaction_ms: 5000 - } + root: null, + limits: sql_limits(command.max_rows, command.max_bytes) }) } @@ -50,28 +69,32 @@ fn gateway_open(command: GatewayCommand) -> int { /// configured `max_events` so durable history matches in-memory retention. fn gateway_put(db_id: resource, command: GatewayCommand) -> map { if command.kind.copy() == "session" => { - sqlite::execute(&db_id, "INSERT INTO gateway_sessions (record_id, payload_json, updated_at_ms) VALUES (?, ?, ?) ON CONFLICT (record_id) DO UPDATE SET payload_json = excluded.payload_json, updated_at_ms = excluded.updated_at_ms", [command.record_id.copy(), command.payload_json.copy(), command.now_ms.copy()]) + sqlite::execute(&db_id, "INSERT INTO gateway_sessions (record_id, payload_json, updated_at_ms) VALUES (?, ?, ?) ON CONFLICT (record_id) DO UPDATE SET payload_json = excluded.payload_json, updated_at_ms = excluded.updated_at_ms", [sql_text(command.record_id.copy()), sql_text(command.payload_json.copy()), sql_int(command.now_ms.copy())]) } else => { if command.kind.copy() == "run" => { - sqlite::execute(&db_id, "INSERT INTO gateway_runs (record_id, payload_json, updated_at_ms) VALUES (?, ?, ?) ON CONFLICT (record_id) DO UPDATE SET payload_json = excluded.payload_json, updated_at_ms = excluded.updated_at_ms", [command.record_id.copy(), command.payload_json.copy(), command.now_ms.copy()]) + sqlite::execute(&db_id, "INSERT INTO gateway_runs (record_id, payload_json, updated_at_ms) VALUES (?, ?, ?) ON CONFLICT (record_id) DO UPDATE SET payload_json = excluded.payload_json, updated_at_ms = excluded.updated_at_ms", [sql_text(command.record_id.copy()), sql_text(command.payload_json.copy()), sql_int(command.now_ms.copy())]) } else => { if command.kind.copy() == "job" => { - sqlite::execute(&db_id, "INSERT INTO gateway_jobs (record_id, payload_json, updated_at_ms) VALUES (?, ?, ?) ON CONFLICT (record_id) DO UPDATE SET payload_json = excluded.payload_json, updated_at_ms = excluded.updated_at_ms", [command.record_id.copy(), command.payload_json.copy(), command.now_ms.copy()]) + sqlite::execute(&db_id, "INSERT INTO gateway_jobs (record_id, payload_json, updated_at_ms) VALUES (?, ?, ?) ON CONFLICT (record_id) DO UPDATE SET payload_json = excluded.payload_json, updated_at_ms = excluded.updated_at_ms", [sql_text(command.record_id.copy()), sql_text(command.payload_json.copy()), sql_int(command.now_ms.copy())]) } else => { if command.kind.copy() == "event" => { { results: sqlite::transaction(&db_id, [ { sql: "INSERT INTO gateway_events (record_id, payload_json, updated_at_ms) VALUES (?, ?, ?) ON CONFLICT (record_id) DO UPDATE SET payload_json = excluded.payload_json, updated_at_ms = excluded.updated_at_ms", - params: [command.record_id.copy(), command.payload_json.copy(), command.now_ms.copy()] + params: [sql_text(command.record_id.copy()), sql_text(command.payload_json.copy()), sql_int(command.now_ms.copy())], + query: false, + limits: sql_limits(1000, 4194304) }, { sql: "DELETE FROM gateway_events WHERE substr(record_id, 1, instr(record_id, ':') - 1) = substr(?, 1, instr(?, ':') - 1) AND record_id NOT IN (SELECT record_id FROM gateway_events WHERE substr(record_id, 1, instr(record_id, ':') - 1) = substr(?, 1, instr(?, ':') - 1) ORDER BY record_id DESC LIMIT CASE WHEN ? <= 0 THEN 128 WHEN ? > 256 THEN 256 ELSE ? END)", - params: [command.record_id.copy(), command.record_id.copy(), command.record_id.copy(), command.record_id.copy(), command.max_events.copy(), command.max_events.copy(), command.max_events.copy()] + params: [sql_text(command.record_id.copy()), sql_text(command.record_id.copy()), sql_text(command.record_id.copy()), sql_text(command.record_id.copy()), sql_int(command.max_events.copy()), sql_int(command.max_events.copy()), sql_int(command.max_events.copy())], + query: false, + limits: sql_limits(1000, 4194304) } ]) } } else => { if command.kind.copy() == "idempotency" => { - sqlite::execute(&db_id, "INSERT INTO gateway_idempotency (record_id, payload_json, updated_at_ms) VALUES (?, ?, ?) ON CONFLICT (record_id) DO UPDATE SET payload_json = excluded.payload_json, updated_at_ms = excluded.updated_at_ms", [command.record_id.copy(), command.payload_json.copy(), command.now_ms.copy()]) + sqlite::execute(&db_id, "INSERT INTO gateway_idempotency (record_id, payload_json, updated_at_ms) VALUES (?, ?, ?) ON CONFLICT (record_id) DO UPDATE SET payload_json = excluded.payload_json, updated_at_ms = excluded.updated_at_ms", [sql_text(command.record_id.copy()), sql_text(command.payload_json.copy()), sql_int(command.now_ms.copy())]) } else => { sqlite::execute(&db_id, "SELECT 1", []) } @@ -85,19 +108,19 @@ fn gateway_put(db_id: resource, command: GatewayCommand) -> m /// session/run cascade can remove a run's whole retained history. fn gateway_delete(db_id: resource, command: GatewayCommand) -> map { if command.kind.copy() == "event" => { - sqlite::execute(&db_id, "DELETE FROM gateway_events WHERE record_id = ? OR record_id LIKE ? || ':%'", [command.record_id.copy(), command.record_id.copy()]) + sqlite::execute(&db_id, "DELETE FROM gateway_events WHERE record_id = ? OR record_id LIKE ? || ':%'", [sql_text(command.record_id.copy()), sql_text(command.record_id.copy())]) } else => { if command.kind.copy() == "session" => { - sqlite::execute(&db_id, "DELETE FROM gateway_sessions WHERE record_id = ?", [command.record_id.copy()]) + sqlite::execute(&db_id, "DELETE FROM gateway_sessions WHERE record_id = ?", [sql_text(command.record_id.copy())]) } else => { if command.kind.copy() == "run" => { - sqlite::execute(&db_id, "DELETE FROM gateway_runs WHERE record_id = ?", [command.record_id.copy()]) + sqlite::execute(&db_id, "DELETE FROM gateway_runs WHERE record_id = ?", [sql_text(command.record_id.copy())]) } else => { if command.kind.copy() == "job" => { - sqlite::execute(&db_id, "DELETE FROM gateway_jobs WHERE record_id = ?", [command.record_id.copy()]) + sqlite::execute(&db_id, "DELETE FROM gateway_jobs WHERE record_id = ?", [sql_text(command.record_id.copy())]) } else => { if command.kind.copy() == "idempotency" => { - sqlite::execute(&db_id, "DELETE FROM gateway_idempotency WHERE record_id = ?", [command.record_id.copy()]) + sqlite::execute(&db_id, "DELETE FROM gateway_idempotency WHERE record_id = ?", [sql_text(command.record_id.copy())]) } else => { sqlite::execute(&db_id, "SELECT 1", []) } @@ -115,7 +138,7 @@ sqlite::execute(&db_id, "CREATE TABLE IF NOT EXISTS gateway_jobs (record_id TEXT sqlite::execute(&db_id, "CREATE TABLE IF NOT EXISTS gateway_events (record_id TEXT PRIMARY KEY, payload_json TEXT NOT NULL, updated_at_ms INTEGER NOT NULL)", []); sqlite::execute(&db_id, "CREATE TABLE IF NOT EXISTS gateway_idempotency (record_id TEXT PRIMARY KEY, payload_json TEXT NOT NULL, updated_at_ms INTEGER NOT NULL)", []); let raw_result: map = if command.op.copy() == "load" => { - sqlite::query(&db_id, "SELECT 'session' AS kind, record_id, payload_json FROM gateway_sessions UNION ALL SELECT 'run', record_id, payload_json FROM gateway_runs UNION ALL SELECT 'job', record_id, payload_json FROM gateway_jobs UNION ALL SELECT 'event', record_id, payload_json FROM gateway_events UNION ALL SELECT 'idempotency', record_id, payload_json FROM gateway_idempotency ORDER BY kind, record_id LIMIT ?", [command.max_rows.copy()], gateway_limits(command.copy())) + sqlite::query(&db_id, "SELECT 'session' AS kind, record_id, payload_json FROM gateway_sessions UNION ALL SELECT 'run', record_id, payload_json FROM gateway_runs UNION ALL SELECT 'job', record_id, payload_json FROM gateway_jobs UNION ALL SELECT 'event', record_id, payload_json FROM gateway_events UNION ALL SELECT 'idempotency', record_id, payload_json FROM gateway_idempotency ORDER BY kind, record_id LIMIT ?", [sql_int(command.max_rows.copy())], gateway_limits(command.copy())) } else => { if command.op.copy() == "put" => { gateway_put(db_id, command.copy()) diff --git a/rss/storage/jobs.rss b/rss/storage/jobs.rss index 94676d6..765a035 100644 --- a/rss/storage/jobs.rss +++ b/rss/storage/jobs.rss @@ -4,8 +4,41 @@ use json; use sqlite; -fn jobs_query_limits(max_rows: int, max_bytes: int) -> map { - { max_rows: max_rows, max_result_bytes: max_bytes } +fn sql_text(value: string) -> SqliteValue { + { kind: "text", int_value: null, float_value: null, text_value: value, blob_value: null } +} + +fn sql_int(value: int) -> SqliteValue { + { kind: "int", int_value: value, float_value: null, text_value: null, blob_value: null } +} + +fn sql_limits(max_rows: int, max_bytes: int) -> SqliteLimits { + { + max_connections: 16, + max_statements: 128, + max_rows: max_rows, + max_columns: 128, + max_result_bytes: max_bytes, + max_statement_bytes: 1048576, + max_parameters: 128, + max_parameter_bytes: 1048576, + max_pending_operations: 32, + max_transaction_ms: 5000, + busy_timeout_ms: 5000 + } +} + +fn sqlite_query_map(result: SqliteQueryResult) -> map { + { + columns: result.columns, + rows: result.rows, + truncated: result.truncated + } +} + + +fn jobs_query_limits(max_rows: int, max_bytes: int) { + sql_limits(max_rows, max_bytes) } struct JobCreateInput { @@ -38,56 +71,31 @@ struct JobIdInput { pub fn storage_job_create(db_id: resource, payload_json: string, max_rows: int, max_bytes: int) -> map { let input: JobCreateInput = json::decode::(payload_json); - sqlite::execute( - &db_id, - "INSERT INTO jobs (id, name, schedule_json, prompt, deliver_json, skills_json, repeat_count, enabled, created_at_ms, updated_at_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET name = excluded.name, schedule_json = excluded.schedule_json, prompt = excluded.prompt, deliver_json = excluded.deliver_json, skills_json = excluded.skills_json, repeat_count = excluded.repeat_count, enabled = excluded.enabled, updated_at_ms = excluded.updated_at_ms", - [&input.id, &input.name, &input.schedule_json, &input.prompt, &input.deliver_json, &input.skills_json, input.repeat_count.copy(), input.enabled.copy(), input.now_ms.copy(), input.now_ms.copy()] - ); - { ok: true, result: storage_job_query(db_id, input.id, max_rows, max_bytes) } + sqlite::execute(&db_id, "INSERT INTO jobs (id, name, schedule_json, prompt, deliver_json, skills_json, repeat_count, enabled, created_at_ms, updated_at_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET name = excluded.name, schedule_json = excluded.schedule_json, prompt = excluded.prompt, deliver_json = excluded.deliver_json, skills_json = excluded.skills_json, repeat_count = excluded.repeat_count, enabled = excluded.enabled, updated_at_ms = excluded.updated_at_ms", [sql_text(input.id.copy()), sql_text(input.name.copy()), sql_text(input.schedule_json.copy()), sql_text(input.prompt.copy()), sql_text(input.deliver_json.copy()), sql_text(input.skills_json.copy()), sql_int(input.repeat_count.copy()), sql_int(input.enabled.copy()), sql_int(input.now_ms.copy()), sql_int(input.now_ms.copy())]); + { ok: true, result: sqlite_query_map(storage_job_query(db_id, input.id, max_rows, max_bytes)) } } pub fn storage_job_update(db_id: resource, payload_json: string, max_rows: int, max_bytes: int) -> map { let input: JobUpdateInput = json::decode::(payload_json); - sqlite::execute( - &db_id, - "UPDATE jobs SET name = ?, schedule_json = ?, prompt = ?, deliver_json = ?, skills_json = ?, repeat_count = ?, enabled = ?, updated_at_ms = ? WHERE id = ?", - [&input.name, &input.schedule_json, &input.prompt, &input.deliver_json, &input.skills_json, input.repeat_count.copy(), input.enabled.copy(), input.now_ms.copy(), &input.id] - ); - { ok: true, result: storage_job_query(db_id, input.id, max_rows, max_bytes) } + sqlite::execute(&db_id, "UPDATE jobs SET name = ?, schedule_json = ?, prompt = ?, deliver_json = ?, skills_json = ?, repeat_count = ?, enabled = ?, updated_at_ms = ? WHERE id = ?", [sql_text(input.name.copy()), sql_text(input.schedule_json.copy()), sql_text(input.prompt.copy()), sql_text(input.deliver_json.copy()), sql_text(input.skills_json.copy()), sql_int(input.repeat_count.copy()), sql_int(input.enabled.copy()), sql_int(input.now_ms.copy()), sql_text(input.id.copy())]); + { ok: true, result: sqlite_query_map(storage_job_query(db_id, input.id, max_rows, max_bytes)) } } -pub fn storage_job_delete(db_id: resource, payload_json: string) -> map { +pub fn storage_job_delete(db_id: resource, payload_json: string) -> SqliteExecuteResult { let input: JobIdInput = json::decode::(payload_json); - // Report the REAL rows affected: deleting a missing job is a typed - // zero-row result, never a hardcoded success. - let result: map = sqlite::execute( - &db_id, - "DELETE FROM jobs WHERE id = ?", - [&input.job_id] - ); - { ok: true, result: result } + sqlite::execute(&db_id, "DELETE FROM jobs WHERE id = ?", [sql_text(input.job_id.copy())]) } pub fn storage_job_get(db_id: resource, payload_json: string, max_rows: int, max_bytes: int) -> map { let input: JobIdInput = json::decode::(payload_json); - { ok: true, result: storage_job_query(db_id, input.job_id, max_rows, max_bytes) } + { ok: true, result: sqlite_query_map(storage_job_query(db_id, input.job_id, max_rows, max_bytes)) } } pub fn storage_job_list(db_id: resource, max_rows: int, max_bytes: int) -> map { - let result: map = sqlite::query( - &db_id, - "SELECT id, name, schedule_json, prompt, deliver_json, skills_json, repeat_count, enabled, output_json, created_at_ms, updated_at_ms, last_run_at_ms FROM jobs ORDER BY created_at_ms ASC, id ASC LIMIT ?", - [(max_rows)], - jobs_query_limits(max_rows, max_bytes) - ); - { ok: true, result: result } + let result = sqlite::query(&db_id, "SELECT id, name, schedule_json, prompt, deliver_json, skills_json, repeat_count, enabled, output_json, created_at_ms, updated_at_ms, last_run_at_ms FROM jobs ORDER BY created_at_ms ASC, id ASC LIMIT ?", [sql_int(max_rows)], jobs_query_limits(max_rows, max_bytes)); + { ok: true, result: sqlite_query_map(result) } } -fn storage_job_query(db_id: resource, job_id: string, max_rows: int, max_bytes: int) -> map { - sqlite::query( - &db_id, - "SELECT id, name, schedule_json, prompt, deliver_json, skills_json, repeat_count, enabled, output_json, created_at_ms, updated_at_ms, last_run_at_ms FROM jobs WHERE id = ? LIMIT ?", - [&job_id, (max_rows)], - jobs_query_limits(max_rows, max_bytes) - ) +fn storage_job_query(db_id: resource, job_id: string, max_rows: int, max_bytes: int) -> SqliteQueryResult { + sqlite::query(&db_id, "SELECT id, name, schedule_json, prompt, deliver_json, skills_json, repeat_count, enabled, output_json, created_at_ms, updated_at_ms, last_run_at_ms FROM jobs WHERE id = ? LIMIT ?", [sql_text(job_id), sql_int(max_rows)], jobs_query_limits(max_rows, max_bytes)) } diff --git a/rss/storage/load.rss b/rss/storage/load.rss index 2fe9db1..8d6bfe2 100644 --- a/rss/storage/load.rss +++ b/rss/storage/load.rss @@ -8,6 +8,39 @@ use json; use sqlite; +fn sql_text(value: string) -> SqliteValue { + { kind: "text", int_value: null, float_value: null, text_value: value, blob_value: null } +} + +fn sql_int(value: int) -> SqliteValue { + { kind: "int", int_value: value, float_value: null, text_value: null, blob_value: null } +} + +fn sql_limits(max_rows: int, max_bytes: int) -> SqliteLimits { + { + max_connections: 16, + max_statements: 128, + max_rows: max_rows, + max_columns: 128, + max_result_bytes: max_bytes, + max_statement_bytes: 1048576, + max_parameters: 128, + max_parameter_bytes: 1048576, + max_pending_operations: 32, + max_transaction_ms: 5000, + busy_timeout_ms: 5000 + } +} + +fn sqlite_query_map(result: SqliteQueryResult) -> map { + { + columns: result.columns, + rows: result.rows, + truncated: result.truncated + } +} + + /// The `load.all` command payload: page bounds and the hard total-row cap. /// `load_cap` is what turns an absurd state into the typed `load_too_large` /// error (the production gateway sends 1,000,000); tests parameterize it. @@ -17,29 +50,35 @@ struct LoadAllInput { load_cap: int } +struct LoadPage { + columns: array, + rows: array>, + overload: bool +} + pub fn storage_load_all(db_id: resource, payload_json: string) -> map { let input: LoadAllInput = json::decode::(payload_json); let page_size: int = 512; let load_cap: int = input.load_cap.copy(); - let sessions = load_page( + let sessions: LoadPage = load_page( db_id, "SELECT rowid AS _cursor, id, profile, platform, account_id, chat_id, thread_id, user_id, generation, status, system_prompt, model, provider, toolset_hash, metadata_json, last_message_seq, created_at_ms, updated_at_ms, title, end_reason FROM sessions WHERE rowid > ? ORDER BY rowid ASC LIMIT ?", page_size, load_cap ); - let runs = load_page( + let runs: LoadPage = load_page( db_id, "SELECT rowid AS _cursor, id, session_id, parent_run_id, status, input_json, provider, model, script_hash, idempotency_scope, idempotency_key, turn_count, input_tokens, output_tokens, error_code, error_message, recovery_reason, created_at_ms, started_at_ms, finished_at_ms, updated_at_ms FROM runs WHERE rowid > ? ORDER BY rowid ASC LIMIT ?", page_size, load_cap ); - let jobs = load_page( + let jobs: LoadPage = load_page( db_id, "SELECT rowid AS _cursor, id, name, schedule_json, prompt, deliver_json, skills_json, repeat_count, enabled, output_json, created_at_ms, updated_at_ms, last_run_at_ms FROM jobs WHERE rowid > ? ORDER BY rowid ASC LIMIT ?", page_size, load_cap ); - let idempotency = load_page( + let idempotency: LoadPage = load_page( db_id, "SELECT rowid AS _cursor, scope, key, request_hash, resource_type, resource_id, state, response_json, created_at_ms, expires_at_ms, completed_at_ms FROM idempotency_records WHERE rowid > ? ORDER BY rowid ASC LIMIT ?", page_size, @@ -47,10 +86,10 @@ pub fn storage_load_all(db_id: resource, payload_json: string ); let mut failed: bool = false; let mut failure_message: string = ""; - let sessions_overload: bool = sessions["overload"]; - let runs_overload: bool = runs["overload"]; - let jobs_overload: bool = jobs["overload"]; - let idempotency_overload: bool = idempotency["overload"]; + let sessions_overload: bool = sessions.overload; + let runs_overload: bool = runs.overload; + let jobs_overload: bool = jobs.overload; + let idempotency_overload: bool = idempotency.overload; if sessions_overload == true { failed = true; failure_message = "gateway session state exceeds the load cap"; @@ -73,38 +112,33 @@ pub fn storage_load_all(db_id: resource, payload_json: string failure_message = "gateway idempotency state exceeds the load cap"; } } - let sessions_rows: array = sessions["rows"]; - let runs_rows: array = runs["rows"]; - let jobs_rows: array = jobs["rows"]; - let idempotency_rows: array = idempotency["rows"]; - let mut messages = []; + let sessions_rows: array> = sessions.rows; + let runs_rows: array> = runs.rows; + let jobs_rows: array> = jobs.rows; + let idempotency_rows: array> = idempotency.rows; + let mut messages: array> = []; if failed == false { - let session_rows: array = sessions_rows; + let session_rows: array> = sessions_rows; let session_count: int = session_rows.length; let mut session_index = 0; while session_index < session_count { - let session_row: array = session_rows[session_index].copy(); - let session_id: string = session_row[0]; + let session_row: array = session_rows[session_index].copy(); + let session_id: string = session_row.copy()[0].text_value; let mut ordinal_cursor: int = 0; let mut page_done = false; while page_done == false { - let page: map = sqlite::query( - &db_id, - "SELECT ordinal, id, session_id, role, content_json, name, tool_call_id, parent_message_id, token_estimate, compacted, metadata_json, run_id, finish_reason, created_at_ms FROM messages WHERE session_id = ? AND ordinal > ? ORDER BY ordinal ASC LIMIT ?", - [&session_id, ordinal_cursor, page_size + 1], - { max_rows: page_size, max_result_bytes: 2097152 } - ); - let page_rows: array = page["rows"]; - let truncated: bool = page["truncated"]; + let page: SqliteQueryResult = sqlite::query(&db_id, "SELECT ordinal, id, session_id, role, content_json, name, tool_call_id, parent_message_id, token_estimate, compacted, metadata_json, run_id, finish_reason, created_at_ms FROM messages WHERE session_id = ? AND ordinal > ? ORDER BY ordinal ASC LIMIT ?", [sql_text(session_id.copy()), sql_int(ordinal_cursor), sql_int(page_size + 1)], sql_limits(page_size, 2097152)); + let page_rows: array = page.rows.copy(); + let truncated: bool = page.truncated; let page_count: int = page_rows.length; - if page_count > 0 { - let last_row: array = page_rows[page_count - 1].copy(); - let last_cursor: int = last_row[0]; - ordinal_cursor = last_cursor; - } let mut row_index = 0; while row_index < page_count { - messages[messages.length] = page_rows[row_index].copy(); + let row: SqliteRow = page_rows[row_index].copy(); + let cells: array = row.cells.copy(); + if row_index == page_count - 1 { + ordinal_cursor = cells.copy()[0].int_value; + } + messages[messages.length] = cells; row_index += 1; } if truncated == false { @@ -122,34 +156,29 @@ pub fn storage_load_all(db_id: resource, payload_json: string session_index += 1; } } - let mut events = []; + let mut events: array> = []; if failed == false { - let run_rows: array = runs_rows; + let run_rows: array> = runs_rows; let run_count: int = run_rows.length; let mut run_index = 0; while run_index < run_count { - let run_row: array = run_rows[run_index].copy(); - let run_id: string = run_row[0]; + let run_row: array = run_rows[run_index].copy(); + let run_id: string = run_row.copy()[0].text_value; let mut seq_cursor: int = 0; let mut event_page_done = false; while event_page_done == false { - let page: map = sqlite::query( - &db_id, - "SELECT seq, run_id, event_id, event_type, payload_json, created_at_ms FROM run_events WHERE run_id = ? AND seq > ? ORDER BY seq ASC LIMIT ?", - [&run_id, seq_cursor, page_size + 1], - { max_rows: page_size, max_result_bytes: 2097152 } - ); - let page_rows: array = page["rows"]; - let truncated: bool = page["truncated"]; + let page: SqliteQueryResult = sqlite::query(&db_id, "SELECT seq, run_id, event_id, event_type, payload_json, created_at_ms FROM run_events WHERE run_id = ? AND seq > ? ORDER BY seq ASC LIMIT ?", [sql_text(run_id.copy()), sql_int(seq_cursor), sql_int(page_size + 1)], sql_limits(page_size, 2097152)); + let page_rows: array = page.rows.copy(); + let truncated: bool = page.truncated; let page_count: int = page_rows.length; - if page_count > 0 { - let last_row: array = page_rows[page_count - 1].copy(); - let last_cursor: int = last_row[0]; - seq_cursor = last_cursor; - } let mut row_index = 0; while row_index < page_count { - events[events.length] = page_rows[row_index].copy(); + let row: SqliteRow = page_rows[row_index].copy(); + let cells: array = row.cells.copy(); + if row_index == page_count - 1 { + seq_cursor = cells.copy()[0].int_value; + } + events[events.length] = cells; row_index += 1; } if truncated == false { @@ -198,9 +227,9 @@ pub fn storage_load_all(db_id: resource, payload_json: string result } -fn load_page(db_id: resource, sql: string, page_size: int, load_cap: int) -> map { - let mut rows = []; - let mut columns = []; +fn load_page(db_id: resource, sql: string, page_size: int, load_cap: int) -> LoadPage { + let mut rows: array> = []; + let mut columns: array = []; let mut last_rowid: int = 0; let mut done = false; let mut first = true; @@ -209,27 +238,24 @@ fn load_page(db_id: resource, sql: string, page_size: int, lo // Fetch page_size + 1 rows: the SQL LIMIT alone would exhaust // exactly at the host's max_rows cap without setting `truncated`, // silently losing the rest of the table. - let result: map = sqlite::query( - &db_id, - sql, - [last_rowid, page_size + 1], - { max_rows: page_size, max_result_bytes: 2097152 } - ); + let result: SqliteQueryResult = sqlite::query(&db_id, sql, [sql_int(last_rowid), sql_int(page_size + 1)], sql_limits(page_size, 2097152)); if first { - let cols: array = result["columns"]; + let cols: array = result.columns; columns = drop_first_column(cols); first = false; } - let page: array = result["rows"]; - let truncated: bool = result["truncated"]; + let page: array = result.rows.copy(); + let truncated: bool = result.truncated; let page_count: int = page.length; if page_count > 0 { - let last_row: array = page[page_count - 1].copy(); - let last_cursor: int = last_row[0]; - last_rowid = last_cursor; let mut index = 0; while index < page_count { - rows[rows.length] = drop_first_column(page[index].copy()); + let row: SqliteRow = page[index].copy(); + let cells: array = row.cells.copy(); + if index == page_count - 1 { + last_rowid = cells.copy()[0].int_value; + } + rows[rows.length] = drop_first_cells_from(cells); index += 1; } } @@ -244,8 +270,8 @@ fn load_page(db_id: resource, sql: string, page_size: int, lo { columns: columns, rows: rows, overload: overload } } -fn drop_first_column(row: array) -> array { - let mut out = []; +fn drop_first_column(row: array) -> array { + let mut out: array = []; let mut index = 1; while index < row.length { out[out.length] = row[index]; @@ -253,3 +279,24 @@ fn drop_first_column(row: array) -> array { } out } + + +fn drop_first_cells_keep_all(row: SqliteRow) -> array { + let cells: array = row.cells.copy(); + let mut out: array = []; + let mut index = 0; + while index < cells.length { + out[out.length] = cells[index].copy(); + index += 1; + } + out +} +fn drop_first_cells_from(cells: array) -> array { + let mut out: array = []; + let mut index = 1; + while index < cells.length { + out[out.length] = cells[index].copy(); + index += 1; + } + out +} diff --git a/rss/storage/main.rss b/rss/storage/main.rss index 529d60c..263dbca 100644 --- a/rss/storage/main.rss +++ b/rss/storage/main.rss @@ -1,5 +1,30 @@ use json; use sqlite; + +fn sql_text(value: string) -> SqliteValue { + { kind: "text", int_value: null, float_value: null, text_value: value, blob_value: null } +} + +fn sql_int(value: int) -> SqliteValue { + { kind: "int", int_value: value, float_value: null, text_value: null, blob_value: null } +} + +fn sql_limits(max_rows: int, max_bytes: int, busy_timeout_ms: int) -> SqliteLimits { + { + max_connections: 16, + max_statements: 128, + max_rows: max_rows, + max_columns: 128, + max_result_bytes: max_bytes, + max_statement_bytes: 1048576, + max_parameters: 128, + max_parameter_bytes: 1048576, + max_pending_operations: 32, + max_transaction_ms: 5000, + busy_timeout_ms: busy_timeout_ms + } +} + use self::schema as schema; use self::sessions as sessions; use self::messages as messages; @@ -104,18 +129,12 @@ struct CompactionIdInput { -fn storage_open_options(db_path: string, db_mode: string, busy_timeout_ms: int, max_rows: int, max_bytes: int) -> StorageOpenOptions { +fn storage_open_options(db_path: string, db_mode: string, busy_timeout_ms: int, max_rows: int, max_bytes: int) -> SqliteOpenOptions { { path: db_path, mode: db_mode, - limits: { - busy_timeout_ms: busy_timeout_ms, - max_connections: 1, - max_rows: max_rows, - max_result_bytes: max_bytes, - max_statements: 64, - max_transaction_ms: 5000 - } + root: null, + limits: sql_limits(max_rows, max_bytes, busy_timeout_ms) } } @@ -126,24 +145,23 @@ fn storage_apply_migrations(db_id: resource, now_ms: int) { while migration_index < schema::schema_migration_count() { let migration_version: int = schema::schema_migration_version(migration_index); if migration_version > applied_version { - let mut statements = []; + let mut statements: array = []; let mut statement_index = 0; while statement_index < schema::schema_migration_statement_count(migration_index) { statements[statements.length] = { sql: schema::schema_migration_statement(migration_index, statement_index), - params: [] - }; + params: [], + query: false, + limits: sql_limits(1000, 4194304, 5000) + }; statement_index += 1; } statements[statements.length] = { sql: schema::schema_migration_record_sql(), - params: [ - schema::schema_migration_version(migration_index), - schema::schema_migration_name(migration_index), - schema::schema_migration_checksum(migration_index), - now_ms - ] - }; + params: [sql_int(schema::schema_migration_version(migration_index)), sql_text(schema::schema_migration_name(migration_index)), sql_text(schema::schema_migration_checksum(migration_index)), sql_int(now_ms)], + query: false, + limits: sql_limits(1000, 4194304, 5000) + }; sqlite::transaction(&db_id, statements); applied_version = migration_version; } @@ -156,15 +174,8 @@ fn storage_apply_migrations(db_id: resource, now_ms: int) { /// The query always yields exactly one row (COALESCE), so extraction is /// unconditional. fn storage_applied_version(db_id: resource) -> int { - let result: map = sqlite::query( - &db_id, - "SELECT COALESCE(MAX(version), 0) AS version FROM schema_migrations", - [], - { max_rows: 1, max_result_bytes: 4096 } - ); - let rows: array = result["rows"]; - let row: array = rows[0]; - let version: int = row[0]; + let result: SqliteQueryResult = sqlite::query(&db_id, "SELECT COALESCE(MAX(version), 0) AS version FROM schema_migrations", [], sql_limits(1, 4096, 5000)); + let version: int = result.rows[0].cells[0].int_value; version } @@ -191,11 +202,20 @@ fn storage_array_ok(request_id: string, op: string, results: array) -> StorageRe storage_ok(request_id, op, { results: results }, 0, false, 0) } -fn storage_raw_ok(request_id: string, op: string, raw_result: map) -> StorageResult { - let rows_affected: int = sqlite::rows_affected(raw_result); - let truncated: bool = sqlite::truncated(raw_result); - let next_cursor: int = sqlite::next_cursor(raw_result); - storage_ok(request_id, op, raw_result, rows_affected, truncated, next_cursor) +fn storage_query_data(result: SqliteQueryResult) -> map { + { + columns: result.columns, + rows: result.rows, + truncated: result.truncated + } +} + +fn storage_raw_ok(request_id: string, op: string, raw_result: SqliteQueryResult) -> StorageResult { + storage_ok(request_id, op, storage_query_data(raw_result), 0, raw_result.truncated, 0) +} + +fn storage_execute_ok(request_id: string, op: string, raw_result: SqliteExecuteResult) -> StorageResult { + storage_ok(request_id, op, { rows_affected: raw_result.rows_affected, last_insert_rowid: raw_result.last_insert_rowid }, raw_result.rows_affected, false, 0) } fn storage_host_contract() -> string { @@ -226,7 +246,7 @@ fn storage_unwrap(request_id: string, op: string, wrapped: map) -> StorageResult let mut result = storage_error(request_id, op, "unwrap_missing", "unwrap result missing"); if ok_flag == true { let raw: map = wrapped["result"]; - result = storage_raw_ok(request_id, op, raw); + result = storage_ok(request_id, op, raw, 0, false, 0); } else { let code: string = wrapped["code"]; let message: string = wrapped["message"]; @@ -253,12 +273,19 @@ fn storage_unwrap_array(request_id: string, op: string, wrapped: map) -> Storage /// Returns the `rows_affected` count of the first statement result of an /// atomic transaction results array. +fn storage_tx_rows_affected(entry: SqliteTransactionResult) -> int { + let mut affected: int = 0; + if entry.kind == "execute" { + let exec: SqliteExecuteResult = entry.execute; + affected = exec.rows_affected; + } + affected +} + fn storage_first_rows_affected(results: array) -> int { let mut affected: int = 0; if results.length > 0 { - let first: map = results[0]; - let count: int = first["rows_affected"]; - affected = count; + affected = storage_tx_rows_affected(results[0]); } affected } @@ -267,9 +294,7 @@ fn storage_first_rows_affected(results: array) -> int { fn storage_nth_rows_affected(results: array, index: int) -> int { let mut affected: int = 0; if results.length > index { - let first: map = results[index]; - let count: int = first["rows_affected"]; - affected = count; + affected = storage_tx_rows_affected(results[index]); } affected } @@ -301,7 +326,7 @@ fn storage_dispatch(db_id: resource, command: StorageCommand) let input: MessageListInput = json::decode::(command.payload_json); result = storage_raw_ok(request_id, op, messages::storage_message_list(db_id, input.session_id, input.after_ordinal.copy(), command.max_messages, command.max_bytes)); } else if command.op == "message.compact" { - result = storage_raw_ok(request_id, op, messages::storage_message_mark_compacted(db_id, command.payload_json)); + result = storage_execute_ok(request_id, op, messages::storage_message_mark_compacted(db_id, command.payload_json)); } else if command.op == "run.create" { result = storage_unwrap(request_id, op, runs::storage_run_create(db_id, command.payload_json, command.max_rows, command.max_bytes)); } else if command.op == "run.get" { @@ -340,7 +365,7 @@ fn storage_dispatch(db_id: resource, command: StorageCommand) } else if command.op == "idempotency.begin" { result = storage_raw_ok(request_id, op, runs::storage_run_idempotency_begin(db_id, command.payload_json, command.max_rows, command.max_bytes)); } else if command.op == "idempotency.complete" { - result = storage_raw_ok(request_id, op, runs::storage_run_idempotency_complete(db_id, command.payload_json)); + result = storage_execute_ok(request_id, op, runs::storage_run_idempotency_complete(db_id, command.payload_json)); } else if command.op == "recovery.recover_active" { let recovery_results: array = runs::storage_run_recover_active(db_id, command.payload_json); let recovered: int = storage_first_rows_affected(recovery_results); @@ -384,9 +409,10 @@ fn storage_dispatch(db_id: resource, command: StorageCommand) high_water_seq: floor_high }; } else { - let replay_data: map = events::storage_event_replay(db_id, command.payload_json); - let replay_truncated: bool = sqlite::truncated(replay_data); - let replay_cursor: int = sqlite::next_cursor(replay_data); + let replay_query = events::storage_event_replay(db_id, command.payload_json); + let replay_data: map = storage_query_data(replay_query); + let replay_truncated: bool = replay_query.truncated; + let replay_cursor: int = 0; let mut next_cursor_value: int = 0; if replay_truncated { next_cursor_value = replay_cursor + 1; @@ -411,18 +437,18 @@ fn storage_dispatch(db_id: resource, command: StorageCommand) let input: DeliveryGetInput = json::decode::(command.payload_json); result = storage_raw_ok(request_id, op, events::storage_delivery_cursor_get(db_id, input.session_id, input.consumer, command.max_rows, command.max_bytes)); } else if command.op == "delivery.advance" { - result = storage_raw_ok(request_id, op, events::storage_delivery_cursor_advance(db_id, command.payload_json)); + result = storage_execute_ok(request_id, op, events::storage_delivery_cursor_advance(db_id, command.payload_json)); } else if command.op == "delivery.set" { - result = storage_raw_ok(request_id, op, events::storage_delivery_cursor_set(db_id, command.payload_json)); + result = storage_execute_ok(request_id, op, events::storage_delivery_cursor_set(db_id, command.payload_json)); } else if command.op == "approval.request" { result = storage_unwrap(request_id, op, approvals::storage_approval_request(db_id, command.payload_json, command.max_rows, command.max_bytes)); } else if command.op == "approval.get" { let input: ApprovalIdInput = json::decode::(command.payload_json); result = storage_raw_ok(request_id, op, approvals::storage_approval_get(db_id, input.approval_id, command.max_rows, command.max_bytes)); } else if command.op == "approval.resolve" { - result = storage_raw_ok(request_id, op, approvals::storage_approval_resolve(db_id, command.payload_json)); + result = storage_execute_ok(request_id, op, approvals::storage_approval_resolve(db_id, command.payload_json)); } else if command.op == "approval.expire" { - result = storage_raw_ok(request_id, op, approvals::storage_approval_expire(db_id, command.payload_json)); + result = storage_execute_ok(request_id, op, approvals::storage_approval_expire(db_id, command.payload_json)); } else if command.op == "compaction.start" { result = storage_unwrap(request_id, op, compactions::storage_compaction_start(db_id, command.payload_json, command.max_rows, command.max_bytes)); } else if command.op == "compaction.get" { @@ -434,13 +460,13 @@ fn storage_dispatch(db_id: resource, command: StorageCommand) } else if command.op == "compaction.commit" { result = storage_array_ok(request_id, op, compactions::storage_compaction_commit(db_id, command.payload_json)); } else if command.op == "compaction.fail" { - result = storage_raw_ok(request_id, op, compactions::storage_compaction_fail(db_id, command.payload_json)); + result = storage_execute_ok(request_id, op, compactions::storage_compaction_fail(db_id, command.payload_json)); } else if command.op == "job.create" { result = storage_unwrap(request_id, op, jobs::storage_job_create(db_id, command.payload_json, command.max_rows, command.max_bytes)); } else if command.op == "job.update" { result = storage_unwrap(request_id, op, jobs::storage_job_update(db_id, command.payload_json, command.max_rows, command.max_bytes)); } else if command.op == "job.delete" { - result = storage_unwrap(request_id, op, jobs::storage_job_delete(db_id, command.payload_json)); + result = storage_execute_ok(request_id, op, jobs::storage_job_delete(db_id, command.payload_json)); } else if command.op == "job.get" { result = storage_unwrap(request_id, op, jobs::storage_job_get(db_id, command.payload_json, command.max_rows, command.max_bytes)); } else if command.op == "job.list" { diff --git a/rss/storage/messages.rss b/rss/storage/messages.rss index 191f57d..a05b06b 100644 --- a/rss/storage/messages.rss +++ b/rss/storage/messages.rss @@ -1,10 +1,35 @@ use json; use sqlite; + +fn sql_text(value: string) -> SqliteValue { + { kind: "text", int_value: null, float_value: null, text_value: value, blob_value: null } +} + +fn sql_int(value: int) -> SqliteValue { + { kind: "int", int_value: value, float_value: null, text_value: null, blob_value: null } +} + +fn sql_limits(max_rows: int, max_bytes: int) -> SqliteLimits { + { + max_connections: 16, + max_statements: 128, + max_rows: max_rows, + max_columns: 128, + max_result_bytes: max_bytes, + max_statement_bytes: 1048576, + max_parameters: 128, + max_parameter_bytes: 1048576, + max_pending_operations: 32, + max_transaction_ms: 5000, + busy_timeout_ms: 5000 + } +} + use self::schema as schema; use self::existence as existence; -fn messages_query_limits(max_rows: int, max_bytes: int) -> map { - { max_rows: max_rows, max_result_bytes: if max_bytes < 4096 => { 4096 } else => { max_bytes } } +fn messages_query_limits(max_rows: int, max_bytes: int) { + sql_limits(max_rows, if max_bytes < 4096 => { 4096 } else => { max_bytes }) } /// Canonical content_json is always a JSON array of LlmContentBlock objects. @@ -12,18 +37,20 @@ fn messages_query_limits(max_rows: int, max_bytes: int) -> map { /// rewritten with the same block schema; already-canonical arrays pass /// through losslessly. Text fields are UTF-8-safe truncated to 65536 /// characters so the 1 MiB CHECK cannot split a multi-byte scalar. -pub fn storage_message_encode_content(db_id: resource, content_json: string) -> string { +pub fn storage_message_encode_content(content_json: string) -> string { let source: string = content_json.copy(); - let classified: map = sqlite::query( - &db_id, - "SELECT CASE WHEN json_valid(payload) AND json_type(payload) = 'array' THEN 1 ELSE 0 END, CASE WHEN json_valid(payload) AND json_type(payload) = 'object' AND json_extract(payload, '$.type') IS NOT NULL THEN 1 ELSE 0 END FROM (SELECT ? AS payload)", - [&source], - { max_rows: 1, max_result_bytes: 4096 } - ); - let class_rows: array = classified["rows"]; - let class_row: array = class_rows[0]; - let is_array: int = class_row[0]; - let is_block: int = class_row[1]; + let db_id = sqlite::open({ + path: ":memory:", + mode: "memory", + root: null, + limits: sql_limits(1, 1048576) + }); + let classified: SqliteQueryResult = sqlite::query(&db_id, "SELECT CASE WHEN json_valid(payload) AND json_type(payload) = 'array' THEN 1 ELSE 0 END, CASE WHEN json_valid(payload) AND json_type(payload) = 'object' AND json_extract(payload, '$.type') IS NOT NULL THEN 1 ELSE 0 END FROM (SELECT ? AS payload)", [sql_text(source.copy())], sql_limits(1, 4096)); + let class_rows: array = classified.rows; + let class_row: SqliteRow = class_rows[0].copy(); + let class_cells: array = class_row.cells.copy(); + let is_array: int = class_cells[0].int_value; + let is_block: int = class_cells[1].int_value; let mut encoded: string = source.copy(); if is_array.copy() == 1 { encoded = source.copy(); @@ -31,17 +58,13 @@ pub fn storage_message_encode_content(db_id: resource, conten if is_block.copy() == 1 { encoded = "[" + source.copy() + "]"; } else { - let cut: map = sqlite::query( - &db_id, - "SELECT CASE WHEN length(extracted) > 65536 THEN CAST(json_array(json_object('type', 'text', 'text', substr(extracted, 1, 65536), 'truncated', json('true'))) AS TEXT) ELSE CAST(json_array(json_object('type', 'text', 'text', extracted)) AS TEXT) END AS encoded FROM (SELECT CASE WHEN json_valid(payload) AND json_type(payload) = 'object' AND json_extract(payload, '$.text') IS NOT NULL THEN json_extract(payload, '$.text') WHEN json_valid(payload) AND json_type(payload) = 'text' THEN json_extract(payload, '$') ELSE payload END AS extracted FROM (SELECT ? AS payload))", - [&source], - { max_rows: 1, max_result_bytes: 1048576 } - ); - let cut_rows: array = cut["rows"]; - let cut_row: array = cut_rows[0]; - encoded = cut_row[0]; + let cut: SqliteQueryResult = sqlite::query(&db_id, "SELECT CASE WHEN length(extracted) > 65536 THEN CAST(json_array(json_object('type', 'text', 'text', substr(extracted, 1, 65536), 'truncated', json('true'))) AS TEXT) ELSE CAST(json_array(json_object('type', 'text', 'text', extracted)) AS TEXT) END AS encoded FROM (SELECT CASE WHEN json_valid(payload) AND json_type(payload) = 'object' AND json_extract(payload, '$.text') IS NOT NULL THEN json_extract(payload, '$.text') WHEN json_valid(payload) AND json_type(payload) = 'text' THEN json_extract(payload, '$') ELSE payload END AS extracted FROM (SELECT ? AS payload))", [sql_text(source.copy())], sql_limits(1, 1048576)); + let cut_rows: array = cut.rows; + let cut_row: SqliteRow = cut_rows[0].copy(); + encoded = cut_row.cells[0].text_value; } } + sqlite::close(db_id); encoded } @@ -78,59 +101,44 @@ struct MessageCompactInput { pub fn storage_message_append(db_id: resource, payload_json: string, max_rows: int, max_bytes: int) -> map { let input: MessageAppendInput = json::decode::(payload_json); let mut result = { ok: true, code: "ok", message: "", result: {} }; - if !existence::session_exists(db_id, input.session_id.copy()) { + if !(sqlite::query(&db_id, existence::session_exists_sql(), [sql_text(input.session_id.copy())], sql_limits(1, 4096)).rows.length > 0) { result = { ok: false, code: "session_not_found", message: "message append targets an unknown session", result: {} }; } else { - let encoded: string = storage_message_encode_content(db_id, input.content_json.copy()); - let statements = [ + let encoded: string = storage_message_encode_content(input.content_json.copy()); + let statements: array = [ { sql: "INSERT OR IGNORE INTO messages (id, session_id, ordinal, role, content_json, name, tool_call_id, parent_message_id, token_estimate, metadata_json, run_id, finish_reason, created_at_ms) SELECT ?, ?, COALESCE(MAX(ordinal), 0) + 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? FROM messages WHERE session_id = ? AND NOT EXISTS (SELECT 1 FROM messages existing WHERE existing.id = ? AND existing.session_id <> ?)", - params: [&input.id, &input.session_id, &input.role, &encoded, &input.name, &input.tool_call_id, &input.parent_message_id, input.token_estimate.copy(), &input.metadata_json, &input.run_id, &input.finish_reason, input.now_ms.copy(), &input.session_id, &input.id, &input.session_id] - }, + params: [sql_text(input.id.copy()), sql_text(input.session_id.copy()), sql_text(input.role.copy()), sql_text(encoded), sql_text(input.name.copy()), sql_text(input.tool_call_id.copy()), sql_text(input.parent_message_id.copy()), sql_int(input.token_estimate.copy()), sql_text(input.metadata_json.copy()), sql_text(input.run_id.copy()), sql_text(input.finish_reason.copy()), sql_int(input.now_ms.copy()), sql_text(input.session_id.copy()), sql_text(input.id.copy()), sql_text(input.session_id.copy())], + query: false, + limits: sql_limits(1000, 4194304) + }, { sql: "UPDATE sessions SET last_message_seq = COALESCE((SELECT MAX(ordinal) FROM messages WHERE session_id = ?), 0), updated_at_ms = ? WHERE id = ?", - params: [&input.session_id, input.now_ms.copy(), &input.session_id] - } + params: [sql_text(input.session_id.copy()), sql_int(input.now_ms.copy()), sql_text(input.session_id.copy())], + query: false, + limits: sql_limits(1000, 4194304) + } ]; sqlite::transaction(&db_id, statements); result = { ok: true, code: "ok", message: "", - result: sqlite::query( - &db_id, - storage_message_select_sql("WHERE id = ? AND session_id = ? LIMIT ?"), - [&input.id, &input.session_id, (max_rows)], - messages_query_limits(max_rows, max_bytes) - ) + result: sqlite::query(&db_id, storage_message_select_sql("WHERE id = ? AND session_id = ? LIMIT ?"), [sql_text(input.id.copy()), sql_text(input.session_id.copy()), sql_int(max_rows)], messages_query_limits(max_rows, max_bytes)) }; } result } -pub fn storage_message_get(db_id: resource, message_id: string, max_rows: int, max_bytes: int) -> map { - sqlite::query( - &db_id, - storage_message_select_sql("WHERE id = ? LIMIT ?"), - [message_id, (max_rows)], - messages_query_limits(max_rows, max_bytes) - ) +pub fn storage_message_get(db_id: resource, message_id: string, max_rows: int, max_bytes: int) -> SqliteQueryResult { + sqlite::query(&db_id, storage_message_select_sql("WHERE id = ? LIMIT ?"), [sql_text(message_id), sql_int(max_rows)], messages_query_limits(max_rows, max_bytes)) } -pub fn storage_message_list(db_id: resource, session_id: string, after_ordinal: int, max_rows: int, max_bytes: int) -> map { - sqlite::query( - &db_id, - storage_message_select_sql("WHERE session_id = ? AND ordinal > ? ORDER BY ordinal ASC LIMIT ?"), - [session_id, after_ordinal, max_rows], - messages_query_limits(max_rows, max_bytes) - ) +pub fn storage_message_list(db_id: resource, session_id: string, after_ordinal: int, max_rows: int, max_bytes: int) -> SqliteQueryResult { + sqlite::query(&db_id, storage_message_select_sql("WHERE session_id = ? AND ordinal > ? ORDER BY ordinal ASC LIMIT ?"), [sql_text(session_id.copy()), sql_int(after_ordinal), sql_int(max_rows)], messages_query_limits(max_rows, max_bytes)) } -pub fn storage_message_mark_compacted(db_id: resource, payload_json: string) -> map { +pub fn storage_message_mark_compacted(db_id: resource, payload_json: string) -> SqliteExecuteResult { let input: MessageCompactInput = json::decode::(payload_json); - sqlite::execute( - &db_id, - "UPDATE messages SET compacted = 1 WHERE session_id = ? AND ordinal >= ? AND ordinal <= ? AND EXISTS (SELECT 1 FROM compactions WHERE session_id = ? AND source_start_ordinal = ? AND source_end_ordinal = ? AND state = 'committed')", - [&input.session_id, input.start_ordinal.copy(), input.end_ordinal.copy(), &input.session_id, input.start_ordinal.copy(), input.end_ordinal.copy()] - ) + sqlite::execute(&db_id, "UPDATE messages SET compacted = 1 WHERE session_id = ? AND ordinal >= ? AND ordinal <= ? AND EXISTS (SELECT 1 FROM compactions WHERE session_id = ? AND source_start_ordinal = ? AND source_end_ordinal = ? AND state = 'committed')", [sql_text(input.session_id.copy()), sql_int(input.start_ordinal.copy()), sql_int(input.end_ordinal.copy()), sql_text(input.session_id.copy()), sql_int(input.start_ordinal.copy()), sql_int(input.end_ordinal.copy())]) } diff --git a/rss/storage/runs.rss b/rss/storage/runs.rss index d1f010e..39c149a 100644 --- a/rss/storage/runs.rss +++ b/rss/storage/runs.rss @@ -1,10 +1,43 @@ use json; use sqlite; + +fn sql_text(value: string) -> SqliteValue { + { kind: "text", int_value: null, float_value: null, text_value: value, blob_value: null } +} + +fn sql_int(value: int) -> SqliteValue { + { kind: "int", int_value: value, float_value: null, text_value: null, blob_value: null } +} + +fn sql_limits(max_rows: int, max_bytes: int) -> SqliteLimits { + { + max_connections: 16, + max_statements: 128, + max_rows: max_rows, + max_columns: 128, + max_result_bytes: max_bytes, + max_statement_bytes: 1048576, + max_parameters: 128, + max_parameter_bytes: 1048576, + max_pending_operations: 32, + max_transaction_ms: 5000, + busy_timeout_ms: 5000 + } +} + +fn sqlite_query_map(result: SqliteQueryResult) -> map { + { + columns: result.columns, + rows: result.rows, + truncated: result.truncated + } +} + use self::schema as schema; use self::existence as existence; -fn runs_query_limits(max_rows: int, max_bytes: int) -> map { - { max_rows: max_rows, max_result_bytes: max_bytes } +fn runs_query_limits(max_rows: int, max_bytes: int) { + sql_limits(max_rows, max_bytes) } @@ -112,49 +145,30 @@ struct RecoveryBatchResult { pub fn storage_run_create(db_id: resource, payload_json: string, max_rows: int, max_bytes: int) -> map { let input: RunCreateInput = json::decode::(payload_json); let mut result = { ok: true, code: "ok", message: "", result: { columns: [], rows: [] } }; - if !existence::session_exists(db_id, input.session_id.copy()) { + if !(sqlite::query(&db_id, existence::session_exists_sql(), [sql_text(input.session_id.copy())], sql_limits(1, 4096)).rows.length > 0) { result = { ok: false, code: "session_not_found", message: "run create targets an unknown session", result: { columns: [], rows: [] } }; } else { - if input.parent_run_id.copy() != "" && !existence::run_exists(db_id, input.parent_run_id.copy()) { + if input.parent_run_id.copy() != "" && !(sqlite::query(&db_id, existence::run_exists_sql(), [sql_text(input.parent_run_id.copy())], sql_limits(1, 4096)).rows.length > 0) { result = { ok: false, code: "parent_not_found", message: "parent run does not exist", result: { columns: [], rows: [] } }; } else { - sqlite::execute( - &db_id, - "INSERT OR IGNORE INTO runs (id, session_id, parent_run_id, status, input_json, provider, model, script_hash, idempotency_scope, idempotency_key, created_at_ms, updated_at_ms) SELECT ?, ?, ?, 'queued', ?, ?, ?, ?, ?, ?, ?, ? WHERE NOT EXISTS (SELECT 1 FROM runs existing WHERE existing.id = ? AND existing.session_id <> ?) AND EXISTS (SELECT 1 FROM sessions WHERE id = ?) AND (? = '' OR EXISTS (SELECT 1 FROM runs parent WHERE parent.id = ?))", - [&input.id, &input.session_id, &input.parent_run_id, &input.input_json, &input.provider, &input.model, &input.script_hash, &input.idempotency_scope, &input.idempotency_key, input.now_ms.copy(), input.now_ms.copy(), &input.id, &input.session_id, &input.session_id, &input.parent_run_id, &input.parent_run_id] - ); + sqlite::execute(&db_id, "INSERT OR IGNORE INTO runs (id, session_id, parent_run_id, status, input_json, provider, model, script_hash, idempotency_scope, idempotency_key, created_at_ms, updated_at_ms) SELECT ?, ?, ?, 'queued', ?, ?, ?, ?, ?, ?, ?, ? WHERE NOT EXISTS (SELECT 1 FROM runs existing WHERE existing.id = ? AND existing.session_id <> ?) AND EXISTS (SELECT 1 FROM sessions WHERE id = ?) AND (? = '' OR EXISTS (SELECT 1 FROM runs parent WHERE parent.id = ?))", [sql_text(input.id.copy()), sql_text(input.session_id.copy()), sql_text(input.parent_run_id.copy()), sql_text(input.input_json.copy()), sql_text(input.provider.copy()), sql_text(input.model.copy()), sql_text(input.script_hash.copy()), sql_text(input.idempotency_scope.copy()), sql_text(input.idempotency_key.copy()), sql_int(input.now_ms.copy()), sql_int(input.now_ms.copy()), sql_text(input.id.copy()), sql_text(input.session_id.copy()), sql_text(input.session_id.copy()), sql_text(input.parent_run_id.copy()), sql_text(input.parent_run_id.copy())]); result = { ok: true, code: "ok", message: "", - result: sqlite::query( - &db_id, - "SELECT id, session_id, parent_run_id, status, input_json, provider, model, script_hash, idempotency_scope, idempotency_key, turn_count, input_tokens, output_tokens, error_code, error_message, recovery_reason, created_at_ms, started_at_ms, finished_at_ms, updated_at_ms FROM runs WHERE id = ? AND session_id = ? LIMIT ?", - [&input.id, &input.session_id, (max_rows)], - runs_query_limits(max_rows, max_bytes) - ) + result: sqlite::query(&db_id, "SELECT id, session_id, parent_run_id, status, input_json, provider, model, script_hash, idempotency_scope, idempotency_key, turn_count, input_tokens, output_tokens, error_code, error_message, recovery_reason, created_at_ms, started_at_ms, finished_at_ms, updated_at_ms FROM runs WHERE id = ? AND session_id = ? LIMIT ?", [sql_text(input.id.copy()), sql_text(input.session_id.copy()), sql_int(max_rows)], runs_query_limits(max_rows, max_bytes)) }; } } result } -pub fn storage_run_get(db_id: resource, run_id: string, max_rows: int, max_bytes: int) -> map { - sqlite::query( - &db_id, - "SELECT id, session_id, parent_run_id, status, input_json, provider, model, script_hash, idempotency_scope, idempotency_key, turn_count, input_tokens, output_tokens, error_code, error_message, recovery_reason, created_at_ms, started_at_ms, finished_at_ms, updated_at_ms FROM runs WHERE id = ? LIMIT ?", - [run_id, (max_rows)], - runs_query_limits(max_rows, max_bytes) - ) +pub fn storage_run_get(db_id: resource, run_id: string, max_rows: int, max_bytes: int) -> SqliteQueryResult { + sqlite::query(&db_id, "SELECT id, session_id, parent_run_id, status, input_json, provider, model, script_hash, idempotency_scope, idempotency_key, turn_count, input_tokens, output_tokens, error_code, error_message, recovery_reason, created_at_ms, started_at_ms, finished_at_ms, updated_at_ms FROM runs WHERE id = ? LIMIT ?", [sql_text(run_id.copy()), sql_int(max_rows)], runs_query_limits(max_rows, max_bytes)) } -pub fn storage_run_list(db_id: resource, session_id: string, status: string, max_rows: int, max_bytes: int) -> map { - sqlite::query( - &db_id, - "SELECT id, session_id, parent_run_id, status, input_json, provider, model, script_hash, idempotency_scope, idempotency_key, turn_count, input_tokens, output_tokens, error_code, error_message, recovery_reason, created_at_ms, started_at_ms, finished_at_ms, updated_at_ms FROM runs WHERE session_id = ? AND (? = '' OR status = ?) ORDER BY created_at_ms DESC, id DESC LIMIT ?", - [session_id, status, status, (max_rows)], - runs_query_limits(max_rows, max_bytes) - ) +pub fn storage_run_list(db_id: resource, session_id: string, status: string, max_rows: int, max_bytes: int) -> SqliteQueryResult { + sqlite::query(&db_id, "SELECT id, session_id, parent_run_id, status, input_json, provider, model, script_hash, idempotency_scope, idempotency_key, turn_count, input_tokens, output_tokens, error_code, error_message, recovery_reason, created_at_ms, started_at_ms, finished_at_ms, updated_at_ms FROM runs WHERE session_id = ? AND (? = '' OR status = ?) ORDER BY created_at_ms DESC, id DESC LIMIT ?", [sql_text(session_id.copy()), sql_text(status), sql_text(status), sql_int(max_rows)], runs_query_limits(max_rows, max_bytes)) } pub fn storage_run_status_allowed(status: string) -> bool { @@ -164,48 +178,46 @@ pub fn storage_run_status_allowed(status: string) -> bool { pub fn storage_run_transition(db_id: resource, payload_json: string) -> array { let input: RunTransitionInput = json::decode::(payload_json); assert(storage_run_status_allowed(input.to_status.copy())); - sqlite::transaction(&db_id, [ + let statements: array = [ { sql: "UPDATE runs SET status = ?, error_code = ?, error_message = ?, recovery_reason = ?, started_at_ms = CASE WHEN ? = 'running' AND started_at_ms = 0 THEN ? ELSE started_at_ms END, finished_at_ms = CASE WHEN ? IN ('completed', 'failed', 'cancelled') THEN ? ELSE finished_at_ms END, updated_at_ms = ? WHERE id = ? AND status = ? AND ((status = 'queued' AND ? IN ('running', 'cancelled', 'failed')) OR (status = 'running' AND ? IN ('waiting_approval', 'compacting', 'completed', 'failed', 'cancelled')) OR (status = 'waiting_approval' AND ? IN ('running', 'compacting', 'completed', 'failed', 'cancelled')) OR (status = 'compacting' AND ? IN ('running', 'completed', 'failed', 'cancelled')))", - params: [&input.to_status, &input.error_code, &input.error_message, &input.recovery_reason, &input.to_status, input.now_ms.copy(), &input.to_status, input.now_ms.copy(), input.now_ms.copy(), &input.run_id, &input.from_status, &input.to_status, &input.to_status, &input.to_status, &input.to_status] - }, + params: [sql_text(input.to_status.copy()), sql_text(input.error_code.copy()), sql_text(input.error_message.copy()), sql_text(input.recovery_reason.copy()), sql_text(input.to_status.copy()), sql_int(input.now_ms.copy()), sql_text(input.to_status.copy()), sql_int(input.now_ms.copy()), sql_int(input.now_ms.copy()), sql_text(input.run_id.copy()), sql_text(input.from_status.copy()), sql_text(input.to_status.copy()), sql_text(input.to_status.copy()), sql_text(input.to_status.copy()), sql_text(input.to_status.copy())], + query: false, + limits: sql_limits(1000, 4194304) + }, { sql: "INSERT OR IGNORE INTO run_events (run_id, seq, event_id, event_type, payload_json, created_at_ms) SELECT id, COALESCE((SELECT MAX(seq) FROM run_events e WHERE e.run_id = runs.id), 0) + 1, 'run-transition:' || id || ':' || ? || ':' || ? || ':' || ? || ':' || (COALESCE((SELECT MAX(seq) FROM run_events e WHERE e.run_id = runs.id), 0) + 1), 'run.status_changed', '{}', ? FROM runs WHERE id = ? AND status = ? AND updated_at_ms = ? AND changes() = 1", - params: [input.now_ms.copy(), &input.from_status, &input.to_status, input.now_ms.copy(), &input.run_id, &input.to_status, input.now_ms.copy()] - }, + params: [sql_int(input.now_ms.copy()), sql_text(input.from_status.copy()), sql_text(input.to_status.copy()), sql_int(input.now_ms.copy()), sql_text(input.run_id.copy()), sql_text(input.to_status.copy()), sql_int(input.now_ms.copy())], + query: false, + limits: sql_limits(1000, 4194304) + }, { sql: "INSERT INTO run_retention (run_id, first_seq, high_water_seq, updated_at_ms) SELECT id, COALESCE((SELECT MIN(seq) FROM run_events e WHERE e.run_id = runs.id), 0), COALESCE((SELECT MAX(seq) FROM run_events e WHERE e.run_id = runs.id), 0), ? FROM runs WHERE id = ? AND changes() = 1 ON CONFLICT (run_id) DO UPDATE SET first_seq = excluded.first_seq, high_water_seq = MAX(run_retention.high_water_seq, excluded.high_water_seq), updated_at_ms = excluded.updated_at_ms", - params: [input.now_ms.copy(), &input.run_id] - } - ]) + params: [sql_int(input.now_ms.copy()), sql_text(input.run_id.copy())], + query: false, + limits: sql_limits(1000, 4194304) + } + ]; + sqlite::transaction(&db_id, statements) } pub fn storage_run_link_child(db_id: resource, payload_json: string) -> map { let input: ChildLinkInput = json::decode::(payload_json); let mut result = { ok: true, code: "ok", message: "", result: { columns: [], rows: [] } }; - if !existence::run_exists(db_id, input.parent_run_id.copy()) { + if !(sqlite::query(&db_id, existence::run_exists_sql(), [sql_text(input.parent_run_id.copy())], sql_limits(1, 4096)).rows.length > 0) { result = { ok: false, code: "run_not_found", message: "parent or child run does not exist", result: { columns: [], rows: [] } }; } else { - if !existence::run_exists(db_id, input.child_run_id.copy()) { + if !(sqlite::query(&db_id, existence::run_exists_sql(), [sql_text(input.child_run_id.copy())], sql_limits(1, 4096)).rows.length > 0) { result = { ok: false, code: "run_not_found", message: "parent or child run does not exist", result: { columns: [], rows: [] } }; } else { // Idempotent: re-linking an existing pair is a no-op success, // never an orphan write or a duplicate error. - sqlite::execute( - &db_id, - "INSERT OR IGNORE INTO child_run_links (parent_run_id, child_run_id, ordinal, relation, state, created_at_ms) SELECT ?, ?, ?, ?, ?, ? WHERE EXISTS (SELECT 1 FROM runs WHERE id = ?) AND EXISTS (SELECT 1 FROM runs WHERE id = ?)", - [&input.parent_run_id, &input.child_run_id, input.ordinal.copy(), &input.relation, &input.state, input.now_ms.copy(), &input.parent_run_id, &input.child_run_id] - ); + sqlite::execute(&db_id, "INSERT OR IGNORE INTO child_run_links (parent_run_id, child_run_id, ordinal, relation, state, created_at_ms) SELECT ?, ?, ?, ?, ?, ? WHERE EXISTS (SELECT 1 FROM runs WHERE id = ?) AND EXISTS (SELECT 1 FROM runs WHERE id = ?)", [sql_text(input.parent_run_id.copy()), sql_text(input.child_run_id.copy()), sql_int(input.ordinal.copy()), sql_text(input.relation.copy()), sql_text(input.state.copy()), sql_int(input.now_ms.copy()), sql_text(input.parent_run_id.copy()), sql_text(input.child_run_id.copy())]); result = { ok: true, code: "ok", message: "", - result: sqlite::query( - &db_id, - "SELECT parent_run_id, child_run_id, ordinal, relation, state, created_at_ms FROM child_run_links WHERE parent_run_id = ? AND child_run_id = ? LIMIT 1", - [&input.parent_run_id, &input.child_run_id], - { max_rows: 1, max_result_bytes: 4096 } - ) + result: sqlite::query(&db_id, "SELECT parent_run_id, child_run_id, ordinal, relation, state, created_at_ms FROM child_run_links WHERE parent_run_id = ? AND child_run_id = ? LIMIT 1", [sql_text(input.parent_run_id.copy()), sql_text(input.child_run_id.copy())], sql_limits(1, 4096)) }; } } @@ -226,46 +238,53 @@ pub fn storage_run_terminal(db_id: resource, payload_json: st if raw_payload.has("message_ordinal") { reserved_ordinal = raw_payload["message_ordinal"].copy(); } - let mut statements = []; + let mut statements: array = []; if input.event_count.copy() >= 1 { statements[statements.length] = { sql: "INSERT INTO run_events (run_id, seq, event_id, event_type, payload_json, created_at_ms) SELECT ?, COALESCE((SELECT MAX(events.seq) FROM run_events events WHERE events.run_id = ?), 0) + 1, ?, ?, ?, ? WHERE EXISTS (SELECT 1 FROM runs WHERE id = ? AND status = 'running')", - params: [&input.run_id, &input.run_id, &input.event_1_id, &input.event_1_type, &input.event_1_payload, input.now_ms.copy(), &input.run_id] - }; + params: [sql_text(input.run_id.copy()), sql_text(input.run_id.copy()), sql_text(input.event_1_id.copy()), sql_text(input.event_1_type.copy()), sql_text(input.event_1_payload.copy()), sql_int(input.now_ms.copy()), sql_text(input.run_id.copy())], + query: false, + limits: sql_limits(1000, 4194304) + }; } if input.event_count.copy() >= 2 { statements[statements.length] = { sql: "INSERT INTO run_events (run_id, seq, event_id, event_type, payload_json, created_at_ms) SELECT ?, COALESCE((SELECT MAX(events.seq) FROM run_events events WHERE events.run_id = ?), 0) + 1, ?, ?, ?, ? WHERE EXISTS (SELECT 1 FROM runs WHERE id = ? AND status = 'running')", - params: [&input.run_id, &input.run_id, &input.event_2_id, &input.event_2_type, &input.event_2_payload, input.now_ms.copy(), &input.run_id] - }; + params: [sql_text(input.run_id.copy()), sql_text(input.run_id.copy()), sql_text(input.event_2_id.copy()), sql_text(input.event_2_type.copy()), sql_text(input.event_2_payload.copy()), sql_int(input.now_ms.copy()), sql_text(input.run_id.copy())], + query: false, + limits: sql_limits(1000, 4194304) + }; } if input.message_id.copy() != "" { statements[statements.length] = { sql: "INSERT INTO messages (id, session_id, ordinal, role, content_json, metadata_json, run_id, finish_reason, created_at_ms) SELECT ?, ?, CASE WHEN ? > 0 THEN ? ELSE COALESCE(MAX(ordinal), 0) + 1 END, ?, ?, '{}', ?, ?, ? FROM messages WHERE session_id = ? AND EXISTS (SELECT 1 FROM runs WHERE id = ? AND status = 'running') AND NOT EXISTS (SELECT 1 FROM messages existing WHERE existing.id = ? AND existing.session_id <> ?)", - params: [&input.message_id, &input.message_session_id, reserved_ordinal.copy(), reserved_ordinal.copy(), &input.message_role, &input.message_content_json, &input.message_run_id, &input.message_finish_reason, input.now_ms.copy(), &input.message_session_id, &input.run_id, &input.message_id, &input.message_session_id] - }; + params: [sql_text(input.message_id.copy()), sql_text(input.message_session_id.copy()), sql_int(reserved_ordinal.copy()), sql_int(reserved_ordinal.copy()), sql_text(input.message_role.copy()), sql_text(input.message_content_json.copy()), sql_text(input.message_run_id.copy()), sql_text(input.message_finish_reason.copy()), sql_int(input.now_ms.copy()), sql_text(input.message_session_id.copy()), sql_text(input.run_id.copy()), sql_text(input.message_id.copy()), sql_text(input.message_session_id.copy())], + query: false, + limits: sql_limits(1000, 4194304) + }; statements[statements.length] = { sql: "UPDATE sessions SET last_message_seq = COALESCE((SELECT MAX(ordinal) FROM messages WHERE session_id = ?), 0), updated_at_ms = ? WHERE id = ?", - params: [&input.message_session_id, input.now_ms.copy(), &input.message_session_id] - }; + params: [sql_text(input.message_session_id.copy()), sql_int(input.now_ms.copy()), sql_text(input.message_session_id.copy())], + query: false, + limits: sql_limits(1000, 4194304) + }; } statements[statements.length] = { sql: "UPDATE runs SET status = ?, error_code = ?, error_message = ?, recovery_reason = '', finished_at_ms = ?, updated_at_ms = ? WHERE id = ? AND status = 'running'", - params: [&input.to_status, &input.error_code, &input.error_message, input.now_ms.copy(), input.now_ms.copy(), &input.run_id] - }; + params: [sql_text(input.to_status.copy()), sql_text(input.error_code.copy()), sql_text(input.error_message.copy()), sql_int(input.now_ms.copy()), sql_int(input.now_ms.copy()), sql_text(input.run_id.copy())], + query: false, + limits: sql_limits(1000, 4194304) + }; statements[statements.length] = { sql: "INSERT INTO run_retention (run_id, first_seq, high_water_seq, updated_at_ms) SELECT ?, COALESCE((SELECT MIN(seq) FROM run_events WHERE run_id = ?), 0), COALESCE((SELECT MAX(seq) FROM run_events WHERE run_id = ?), 0), ? WHERE EXISTS (SELECT 1 FROM runs WHERE id = ? AND status = ?) ON CONFLICT (run_id) DO UPDATE SET first_seq = excluded.first_seq, high_water_seq = MAX(run_retention.high_water_seq, excluded.high_water_seq), updated_at_ms = excluded.updated_at_ms", - params: [&input.run_id, &input.run_id, &input.run_id, input.now_ms.copy(), &input.run_id, &input.to_status] - }; + params: [sql_text(input.run_id.copy()), sql_text(input.run_id.copy()), sql_text(input.run_id.copy()), sql_int(input.now_ms.copy()), sql_text(input.run_id.copy()), sql_text(input.to_status.copy())], + query: false, + limits: sql_limits(1000, 4194304) + }; sqlite::transaction(&db_id, statements); - let run_result: map = sqlite::query( - &db_id, - "SELECT id, session_id, parent_run_id, status, input_json, provider, model, script_hash, idempotency_scope, idempotency_key, turn_count, input_tokens, output_tokens, error_code, error_message, recovery_reason, created_at_ms, started_at_ms, finished_at_ms, updated_at_ms FROM runs WHERE id = ? LIMIT 1", - [&input.run_id], - { max_rows: 1, max_result_bytes: 65536 } - ); - let run_result_copy: map = run_result.copy(); - let run_rows: array = run_result["rows"]; + let run_result = sqlite::query(&db_id, "SELECT id, session_id, parent_run_id, status, input_json, provider, model, script_hash, idempotency_scope, idempotency_key, turn_count, input_tokens, output_tokens, error_code, error_message, recovery_reason, created_at_ms, started_at_ms, finished_at_ms, updated_at_ms FROM runs WHERE id = ? LIMIT 1", [sql_text(input.run_id.copy())], sql_limits(1, 65536)); + let run_result_copy: map = sqlite_query_map(run_result); + let run_rows = run_result.rows; let mut result = { ok: true, code: "ok", message: "", result: { run: run_result_copy, events: { columns: [], rows: [] } } }; if run_rows.length == 0 { result = { ok: false, code: "run_not_found", message: "terminal commit targets an unknown run", result: { run: run_result_copy, events: { columns: [], rows: [] } } }; @@ -273,108 +292,93 @@ pub fn storage_run_terminal(db_id: resource, payload_json: st // A terminal commit that matched no transition (the run was already // terminal, or never running) is a typed conflict, never a silent // success: the run row's status tells whether the transition landed. - let run_row: array = run_rows[0]; - let run_status: string = run_row[3]; + let run_row = run_rows[0]; + let run_status: string = run_row.cells[3].text_value; if run_status != input.to_status.copy() { result = { ok: false, code: "transition_conflict", message: "run terminal transition matched no row", result: { run: run_result_copy, events: { columns: [], rows: [] } } }; } else { - let event_result: map = sqlite::query( - &db_id, - "SELECT seq, run_id, event_id, event_type, payload_json, created_at_ms FROM run_events WHERE run_id = ? ORDER BY seq ASC LIMIT ?", - [&input.run_id, (512)], - { max_rows: 512, max_result_bytes: 2097152 } - ); + let event_result: map = sqlite_query_map(sqlite::query(&db_id, "SELECT seq, run_id, event_id, event_type, payload_json, created_at_ms FROM run_events WHERE run_id = ? ORDER BY seq ASC LIMIT ?", [sql_text(input.run_id.copy()), sql_int(512)], sql_limits(512, 2097152))); result = { ok: true, code: "ok", message: "", result: { run: run_result_copy, events: event_result } }; } } result } -pub fn storage_run_list_children(db_id: resource, parent_run_id: string, max_rows: int, max_bytes: int) -> map { - sqlite::query( - &db_id, - "SELECT parent_run_id, child_run_id, ordinal, relation, state, created_at_ms FROM child_run_links WHERE parent_run_id = ? ORDER BY ordinal ASC, child_run_id ASC LIMIT ?", - [&parent_run_id, (max_rows)], - runs_query_limits(max_rows, max_bytes) - ) +pub fn storage_run_list_children(db_id: resource, parent_run_id: string, max_rows: int, max_bytes: int) -> SqliteQueryResult { + sqlite::query(&db_id, "SELECT parent_run_id, child_run_id, ordinal, relation, state, created_at_ms FROM child_run_links WHERE parent_run_id = ? ORDER BY ordinal ASC, child_run_id ASC LIMIT ?", [sql_text(parent_run_id), sql_int(max_rows)], runs_query_limits(max_rows, max_bytes)) } -pub fn storage_run_record_usage(db_id: resource, payload_json: string) -> map { +pub fn storage_run_record_usage(db_id: resource, payload_json: string) -> SqliteQueryResult { let input: UsageInput = json::decode::(payload_json); - sqlite::execute( - &db_id, - "INSERT INTO provider_usage (id, run_id, provider, model, request_count, input_tokens, output_tokens, reasoning_tokens, cached_tokens, created_at_ms, usage_ids) SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '|' || ? || '|' WHERE ? >= 0 AND ? >= 0 AND ? >= 0 AND ? >= 0 AND ? >= 0 AND instr(?, '|') = 0 ON CONFLICT (run_id, provider, model) DO UPDATE SET request_count = provider_usage.request_count + excluded.request_count, input_tokens = provider_usage.input_tokens + excluded.input_tokens, output_tokens = provider_usage.output_tokens + excluded.output_tokens, reasoning_tokens = provider_usage.reasoning_tokens + excluded.reasoning_tokens, cached_tokens = provider_usage.cached_tokens + excluded.cached_tokens, usage_ids = provider_usage.usage_ids || excluded.id || '|' WHERE instr(provider_usage.usage_ids, '|' || excluded.id || '|') = 0", - [&input.id, &input.run_id, &input.provider, &input.model, input.request_count.copy(), input.input_tokens.copy(), input.output_tokens.copy(), input.reasoning_tokens.copy(), input.cached_tokens.copy(), input.now_ms.copy(), &input.id, input.request_count.copy(), input.input_tokens.copy(), input.output_tokens.copy(), input.reasoning_tokens.copy(), input.cached_tokens.copy(), &input.id] - ); - sqlite::execute( - &db_id, - "UPDATE runs SET input_tokens = COALESCE((SELECT SUM(input_tokens) FROM provider_usage WHERE run_id = ?), 0), output_tokens = COALESCE((SELECT SUM(output_tokens) FROM provider_usage WHERE run_id = ?), 0), updated_at_ms = ? WHERE id = ?", - [&input.run_id, &input.run_id, input.now_ms.copy(), &input.run_id] - ); - sqlite::query( - &db_id, - "SELECT id, input_tokens, output_tokens FROM runs WHERE id = ? LIMIT 1", - [&input.run_id], - { max_rows: 1, max_result_bytes: 4096 } - ) + sqlite::execute(&db_id, "INSERT INTO provider_usage (id, run_id, provider, model, request_count, input_tokens, output_tokens, reasoning_tokens, cached_tokens, created_at_ms, usage_ids) SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '|' || ? || '|' WHERE ? >= 0 AND ? >= 0 AND ? >= 0 AND ? >= 0 AND ? >= 0 AND instr(?, '|') = 0 ON CONFLICT (run_id, provider, model) DO UPDATE SET request_count = provider_usage.request_count + excluded.request_count, input_tokens = provider_usage.input_tokens + excluded.input_tokens, output_tokens = provider_usage.output_tokens + excluded.output_tokens, reasoning_tokens = provider_usage.reasoning_tokens + excluded.reasoning_tokens, cached_tokens = provider_usage.cached_tokens + excluded.cached_tokens, usage_ids = provider_usage.usage_ids || excluded.id || '|' WHERE instr(provider_usage.usage_ids, '|' || excluded.id || '|') = 0", [sql_text(input.id.copy()), sql_text(input.run_id.copy()), sql_text(input.provider.copy()), sql_text(input.model.copy()), sql_int(input.request_count.copy()), sql_int(input.input_tokens.copy()), sql_int(input.output_tokens.copy()), sql_int(input.reasoning_tokens.copy()), sql_int(input.cached_tokens.copy()), sql_int(input.now_ms.copy()), sql_text(input.id.copy()), sql_int(input.request_count.copy()), sql_int(input.input_tokens.copy()), sql_int(input.output_tokens.copy()), sql_int(input.reasoning_tokens.copy()), sql_int(input.cached_tokens.copy()), sql_text(input.id.copy())]); + sqlite::execute(&db_id, "UPDATE runs SET input_tokens = COALESCE((SELECT SUM(input_tokens) FROM provider_usage WHERE run_id = ?), 0), output_tokens = COALESCE((SELECT SUM(output_tokens) FROM provider_usage WHERE run_id = ?), 0), updated_at_ms = ? WHERE id = ?", [sql_text(input.run_id.copy()), sql_text(input.run_id.copy()), sql_int(input.now_ms.copy()), sql_text(input.run_id.copy())]); + sqlite::query(&db_id, "SELECT id, input_tokens, output_tokens FROM runs WHERE id = ? LIMIT 1", [sql_text(input.run_id.copy())], sql_limits(1, 4096)) } -pub fn storage_run_idempotency_begin(db_id: resource, payload_json: string, max_rows: int, max_bytes: int) -> map { +pub fn storage_run_idempotency_begin(db_id: resource, payload_json: string, max_rows: int, max_bytes: int) -> SqliteQueryResult { let input: IdempotencyBeginInput = json::decode::(payload_json); - sqlite::transaction(&db_id, [ + let statements: array = [ { sql: "DELETE FROM idempotency_records WHERE scope = ? AND key = ? AND ((state = 'claimed' AND expires_at_ms <= ?) OR state = 'expired')", - params: [&input.scope, &input.key, input.now_ms.copy()] - }, + params: [sql_text(input.scope.copy()), sql_text(input.key.copy()), sql_int(input.now_ms.copy())], + query: false, + limits: sql_limits(1000, 4194304) + }, { sql: "INSERT OR IGNORE INTO idempotency_records (scope, key, request_hash, resource_type, resource_id, state, created_at_ms, expires_at_ms) SELECT ?, ?, ?, ?, ?, 'claimed', ?, ? WHERE ? <> ''", - params: [&input.scope, &input.key, &input.request_hash, &input.resource_type, &input.resource_id, input.now_ms.copy(), input.expires_at_ms.copy(), &input.claim_token] - }, + params: [sql_text(input.scope.copy()), sql_text(input.key.copy()), sql_text(input.request_hash.copy()), sql_text(input.resource_type.copy()), sql_text(input.resource_id.copy()), sql_int(input.now_ms.copy()), sql_int(input.expires_at_ms.copy()), sql_text(input.claim_token.copy())], + query: false, + limits: sql_limits(1000, 4194304) + }, { sql: "INSERT INTO idempotency_claim_history (claim_token, scope, key, claimed_at_ms) SELECT ?, ?, ?, ? WHERE changes() = 1 AND ? <> ''", - params: [&input.claim_token, &input.scope, &input.key, input.now_ms.copy(), &input.claim_token] - }, + params: [sql_text(input.claim_token.copy()), sql_text(input.scope.copy()), sql_text(input.key.copy()), sql_int(input.now_ms.copy()), sql_text(input.claim_token.copy())], + query: false, + limits: sql_limits(1000, 4194304) + }, { sql: "INSERT OR REPLACE INTO idempotency_claims (scope, key, request_hash, claim_token, claimed_at_ms) SELECT ?, ?, ?, ?, ? WHERE changes() = 1", - params: [&input.scope, &input.key, &input.request_hash, &input.claim_token, input.now_ms.copy()] - } - ]); - sqlite::query( - &db_id, - "SELECT scope, key, request_hash, resource_type, resource_id, CASE WHEN request_hash = ? THEN state ELSE 'failed' END AS state, CASE WHEN request_hash = ? THEN response_json ELSE '{\"error_code\":\"idempotency_key_conflict\"}' END AS response_json, CASE WHEN changes() = 1 AND EXISTS (SELECT 1 FROM idempotency_claims WHERE scope = ? AND key = ? AND claim_token = ?) THEN 1 ELSE 0 END AS acquired, CASE WHEN changes() = 1 AND EXISTS (SELECT 1 FROM idempotency_claims WHERE scope = ? AND key = ? AND claim_token = ?) THEN ? ELSE '' END AS claim_token, created_at_ms, expires_at_ms, completed_at_ms FROM idempotency_records WHERE scope = ? AND key = ? LIMIT ?", - [&input.request_hash, &input.request_hash, &input.scope, &input.key, &input.claim_token, &input.scope, &input.key, &input.claim_token, &input.claim_token, &input.scope, &input.key, (max_rows)], - runs_query_limits(max_rows, max_bytes) - ) + params: [sql_text(input.scope.copy()), sql_text(input.key.copy()), sql_text(input.request_hash.copy()), sql_text(input.claim_token.copy()), sql_int(input.now_ms.copy())], + query: false, + limits: sql_limits(1000, 4194304) + } + ]; + sqlite::transaction(&db_id, statements); + sqlite::query(&db_id, "SELECT scope, key, request_hash, resource_type, resource_id, CASE WHEN request_hash = ? THEN state ELSE 'failed' END AS state, CASE WHEN request_hash = ? THEN response_json ELSE '{\"error_code\":\"idempotency_key_conflict\"}' END AS response_json, CASE WHEN changes() = 1 AND EXISTS (SELECT 1 FROM idempotency_claims WHERE scope = ? AND key = ? AND claim_token = ?) THEN 1 ELSE 0 END AS acquired, CASE WHEN changes() = 1 AND EXISTS (SELECT 1 FROM idempotency_claims WHERE scope = ? AND key = ? AND claim_token = ?) THEN ? ELSE '' END AS claim_token, created_at_ms, expires_at_ms, completed_at_ms FROM idempotency_records WHERE scope = ? AND key = ? LIMIT ?", [sql_text(input.request_hash.copy()), sql_text(input.request_hash.copy()), sql_text(input.scope.copy()), sql_text(input.key.copy()), sql_text(input.claim_token.copy()), sql_text(input.scope.copy()), sql_text(input.key.copy()), sql_text(input.claim_token.copy()), sql_text(input.claim_token.copy()), sql_text(input.scope.copy()), sql_text(input.key.copy()), sql_int(max_rows)], runs_query_limits(max_rows, max_bytes)) } -pub fn storage_run_idempotency_complete(db_id: resource, payload_json: string) -> map { +pub fn storage_run_idempotency_complete(db_id: resource, payload_json: string) -> SqliteExecuteResult { let input: IdempotencyCompleteInput = json::decode::(payload_json); - sqlite::execute( - &db_id, - "UPDATE idempotency_records SET state = ?, response_json = ?, completed_at_ms = ? WHERE scope = ? AND key = ? AND request_hash = ? AND state = 'claimed' AND ? IN ('completed', 'failed') AND expires_at_ms > ? AND ? <> '' AND EXISTS (SELECT 1 FROM idempotency_claims WHERE scope = ? AND key = ? AND request_hash = ? AND claim_token = ?)", - [&input.state, &input.response_json, input.now_ms.copy(), &input.scope, &input.key, &input.request_hash, &input.state, input.now_ms.copy(), &input.claim_token, &input.scope, &input.key, &input.request_hash, &input.claim_token] - ) + sqlite::execute(&db_id, "UPDATE idempotency_records SET state = ?, response_json = ?, completed_at_ms = ? WHERE scope = ? AND key = ? AND request_hash = ? AND state = 'claimed' AND ? IN ('completed', 'failed') AND expires_at_ms > ? AND ? <> '' AND EXISTS (SELECT 1 FROM idempotency_claims WHERE scope = ? AND key = ? AND request_hash = ? AND claim_token = ?)", [sql_text(input.state.copy()), sql_text(input.response_json.copy()), sql_int(input.now_ms.copy()), sql_text(input.scope.copy()), sql_text(input.key.copy()), sql_text(input.request_hash.copy()), sql_text(input.state.copy()), sql_int(input.now_ms.copy()), sql_text(input.claim_token.copy()), sql_text(input.scope.copy()), sql_text(input.key.copy()), sql_text(input.request_hash.copy()), sql_text(input.claim_token.copy())]) } pub fn storage_run_recover_active(db_id: resource, payload_json: string) -> array { let input: RecoveryInput = json::decode::(payload_json); - let statements = [ + let statements: array = [ { sql: "INSERT OR IGNORE INTO recovery_records (id, run_id, from_status, to_status, reason, details_json, recovered_at_ms) SELECT 'recovery:' || id || ':' || ?, id, status, 'failed', ?, ?, ? FROM runs WHERE id IN (SELECT id FROM runs WHERE status IN ('queued', 'running', 'waiting_approval', 'compacting') ORDER BY updated_at_ms ASC, id ASC LIMIT CASE WHEN ? <= 0 THEN 64 WHEN ? > 512 THEN 512 ELSE ? END)", - params: [input.now_ms.copy(), &input.reason, &input.details_json, input.now_ms.copy(), input.max_rows.copy(), input.max_rows.copy(), input.max_rows.copy()] - }, + params: [sql_int(input.now_ms.copy()), sql_text(input.reason.copy()), sql_text(input.details_json.copy()), sql_int(input.now_ms.copy()), sql_int(input.max_rows.copy()), sql_int(input.max_rows.copy()), sql_int(input.max_rows.copy())], + query: false, + limits: sql_limits(1000, 4194304) + }, { sql: "INSERT OR IGNORE INTO run_events (run_id, seq, event_id, event_type, payload_json, created_at_ms) SELECT id, COALESCE((SELECT MAX(e.seq) FROM run_events e WHERE e.run_id = runs.id), 0) + ROW_NUMBER() OVER (PARTITION BY runs.id ORDER BY runs.updated_at_ms ASC, runs.id ASC), 'recovery-event:' || id || ':' || ?, 'run.failed', '{\"status\":\"failed\",\"error_code\":\"gateway_restart\",\"recovery_reason\":\"gateway_restart\"}', ? FROM runs WHERE id IN (SELECT id FROM runs WHERE status IN ('queued', 'running', 'waiting_approval', 'compacting') ORDER BY updated_at_ms ASC, id ASC LIMIT CASE WHEN ? <= 0 THEN 64 WHEN ? > 512 THEN 512 ELSE ? END)", - params: [input.now_ms.copy(), input.now_ms.copy(), input.max_rows.copy(), input.max_rows.copy(), input.max_rows.copy()] - }, + params: [sql_int(input.now_ms.copy()), sql_int(input.now_ms.copy()), sql_int(input.max_rows.copy()), sql_int(input.max_rows.copy()), sql_int(input.max_rows.copy())], + query: false, + limits: sql_limits(1000, 4194304) + }, { sql: "UPDATE runs SET status = 'failed', error_code = 'gateway_restart', error_message = 'run interrupted during gateway restart', recovery_reason = ?, finished_at_ms = ?, updated_at_ms = ? WHERE id IN (SELECT id FROM runs WHERE status IN ('queued', 'running', 'waiting_approval', 'compacting') ORDER BY updated_at_ms ASC, id ASC LIMIT CASE WHEN ? <= 0 THEN 64 WHEN ? > 512 THEN 512 ELSE ? END)", - params: [&input.reason, input.now_ms.copy(), input.now_ms.copy(), input.max_rows.copy(), input.max_rows.copy(), input.max_rows.copy()] - }, + params: [sql_text(input.reason.copy()), sql_int(input.now_ms.copy()), sql_int(input.now_ms.copy()), sql_int(input.max_rows.copy()), sql_int(input.max_rows.copy()), sql_int(input.max_rows.copy())], + query: false, + limits: sql_limits(1000, 4194304) + }, { sql: "UPDATE approvals SET state = 'expired', resolved_at_ms = ? WHERE state = 'pending' AND run_id IN (SELECT run_id FROM recovery_records WHERE recovered_at_ms = ? AND reason = ?)", - params: [input.now_ms.copy(), input.now_ms.copy(), &input.reason] - }, + params: [sql_int(input.now_ms.copy()), sql_int(input.now_ms.copy()), sql_text(input.reason.copy())], + query: false, + limits: sql_limits(1000, 4194304) + }, { // After a restart EVERY pending compaction is an interrupted // leftover: a crash between the run terminal commit and @@ -382,24 +386,34 @@ pub fn storage_run_recover_active(db_id: resource, payload_js // terminal (never in the recovered set), so the fail-sweep is // unconditional instead of being limited to recovered runs. sql: "UPDATE compactions SET state = 'failed', error_message = 'run interrupted during gateway restart', completed_at_ms = ? WHERE state = 'pending'", - params: [input.now_ms.copy()] - }, + params: [sql_int(input.now_ms.copy())], + query: false, + limits: sql_limits(1000, 4194304) + }, { sql: "UPDATE idempotency_records SET state = 'expired', completed_at_ms = ? WHERE state = 'claimed' AND resource_type = 'run' AND resource_id IN (SELECT run_id FROM recovery_records WHERE recovered_at_ms = ? AND reason = ?)", - params: [input.now_ms.copy(), input.now_ms.copy(), &input.reason] - }, + params: [sql_int(input.now_ms.copy()), sql_int(input.now_ms.copy()), sql_text(input.reason.copy())], + query: false, + limits: sql_limits(1000, 4194304) + }, { sql: "DELETE FROM idempotency_claims WHERE EXISTS (SELECT 1 FROM idempotency_records WHERE idempotency_records.scope = idempotency_claims.scope AND idempotency_records.key = idempotency_claims.key AND idempotency_records.state = 'expired')", - params: [] - }, + params: [], + query: false, + limits: sql_limits(1000, 4194304) + }, { sql: "DELETE FROM run_events WHERE rowid IN (SELECT rowid FROM (SELECT rowid, ROW_NUMBER() OVER (PARTITION BY run_id ORDER BY seq ASC) AS rn, COUNT(*) OVER (PARTITION BY run_id) AS total FROM run_events WHERE run_id IN (SELECT run_id FROM recovery_records WHERE recovered_at_ms = ? AND reason = ?)) WHERE rn <= total - CASE WHEN ? <= 0 THEN 128 WHEN ? > 256 THEN 256 ELSE ? END)", - params: [input.now_ms.copy(), &input.reason, input.max_events.copy(), input.max_events.copy(), input.max_events.copy()] - }, + params: [sql_int(input.now_ms.copy()), sql_text(input.reason.copy()), sql_int(input.max_events.copy()), sql_int(input.max_events.copy()), sql_int(input.max_events.copy())], + query: false, + limits: sql_limits(1000, 4194304) + }, { sql: "INSERT INTO run_retention (run_id, first_seq, high_water_seq, updated_at_ms) SELECT id, COALESCE((SELECT MIN(seq) FROM run_events e WHERE e.run_id = runs.id), 0), COALESCE((SELECT MAX(seq) FROM run_events e WHERE e.run_id = runs.id), 0), ? FROM runs WHERE id IN (SELECT run_id FROM recovery_records WHERE recovered_at_ms = ? AND reason = ?) ON CONFLICT (run_id) DO UPDATE SET first_seq = excluded.first_seq, high_water_seq = MAX(run_retention.high_water_seq, excluded.high_water_seq), updated_at_ms = excluded.updated_at_ms", - params: [input.now_ms.copy(), input.now_ms.copy(), &input.reason] - } + params: [sql_int(input.now_ms.copy()), sql_int(input.now_ms.copy()), sql_text(input.reason.copy())], + query: false, + limits: sql_limits(1000, 4194304) + } ]; sqlite::transaction(&db_id, statements) } \ No newline at end of file diff --git a/rss/storage/sessions.rss b/rss/storage/sessions.rss index 0fcf33b..05e41ae 100644 --- a/rss/storage/sessions.rss +++ b/rss/storage/sessions.rss @@ -1,9 +1,34 @@ use json; use sqlite; + +fn sql_text(value: string) -> SqliteValue { + { kind: "text", int_value: null, float_value: null, text_value: value, blob_value: null } +} + +fn sql_int(value: int) -> SqliteValue { + { kind: "int", int_value: value, float_value: null, text_value: null, blob_value: null } +} + +fn sql_limits(max_rows: int, max_bytes: int) -> SqliteLimits { + { + max_connections: 16, + max_statements: 128, + max_rows: max_rows, + max_columns: 128, + max_result_bytes: max_bytes, + max_statement_bytes: 1048576, + max_parameters: 128, + max_parameter_bytes: 1048576, + max_pending_operations: 32, + max_transaction_ms: 5000, + busy_timeout_ms: 5000 + } +} + use self::schema as schema; -fn sessions_query_limits(max_rows: int, max_bytes: int) -> map { - { max_rows: max_rows, max_result_bytes: max_bytes } +fn sessions_query_limits(max_rows: int, max_bytes: int) { + sql_limits(max_rows, max_bytes) } @@ -44,57 +69,42 @@ struct SessionIdInput { session_id: string } -pub fn storage_session_create(db_id: resource, payload_json: string, max_rows: int, max_bytes: int) -> map { +pub fn storage_session_create(db_id: resource, payload_json: string, max_rows: int, max_bytes: int) -> SqliteQueryResult { let input: SessionCreateInput = json::decode::(payload_json); - let statements = [ + let statements: array = [ { sql: "INSERT OR IGNORE INTO sessions (id, profile, platform, account_id, chat_id, thread_id, user_id, generation, system_prompt, model, provider, toolset_hash, metadata_json, title, end_reason, created_at_ms, updated_at_ms) SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? WHERE NOT EXISTS (SELECT 1 FROM sessions WHERE profile = ? AND platform = ? AND account_id = ? AND chat_id = ? AND thread_id = ? AND id <> ?)", - params: [&input.id, &input.profile, &input.platform, &input.account_id, &input.chat_id, &input.thread_id, &input.user_id, input.generation.copy(), &input.system_prompt, &input.model, &input.provider, &input.toolset_hash, &input.metadata_json, &input.title, &input.end_reason, input.now_ms.copy(), input.now_ms.copy(), &input.profile, &input.platform, &input.account_id, &input.chat_id, &input.thread_id, &input.id] - }, + params: [sql_text(input.id.copy()), sql_text(input.profile.copy()), sql_text(input.platform.copy()), sql_text(input.account_id.copy()), sql_text(input.chat_id.copy()), sql_text(input.thread_id.copy()), sql_text(input.user_id.copy()), sql_int(input.generation.copy()), sql_text(input.system_prompt.copy()), sql_text(input.model.copy()), sql_text(input.provider.copy()), sql_text(input.toolset_hash.copy()), sql_text(input.metadata_json.copy()), sql_text(input.title.copy()), sql_text(input.end_reason.copy()), sql_int(input.now_ms.copy()), sql_int(input.now_ms.copy()), sql_text(input.profile.copy()), sql_text(input.platform.copy()), sql_text(input.account_id.copy()), sql_text(input.chat_id.copy()), sql_text(input.thread_id.copy()), sql_text(input.id.copy())], + query: false, + limits: sql_limits(1000, 4194304) + }, { sql: "UPDATE sessions SET updated_at_ms = ? WHERE id = ? AND profile = ? AND platform = ? AND account_id = ? AND chat_id = ? AND thread_id = ?", - params: [input.now_ms.copy(), &input.id, &input.profile, &input.platform, &input.account_id, &input.chat_id, &input.thread_id] - } + params: [sql_int(input.now_ms.copy()), sql_text(input.id.copy()), sql_text(input.profile.copy()), sql_text(input.platform.copy()), sql_text(input.account_id.copy()), sql_text(input.chat_id.copy()), sql_text(input.thread_id.copy())], + query: false, + limits: sql_limits(1000, 4194304) + } ]; sqlite::transaction(&db_id, statements); - sqlite::query( - &db_id, - "SELECT id, profile, platform, account_id, chat_id, thread_id, user_id, generation, status, system_prompt, model, provider, toolset_hash, metadata_json, last_message_seq, created_at_ms, updated_at_ms, title, end_reason FROM sessions WHERE id = ? AND profile = ? AND platform = ? AND account_id = ? AND chat_id = ? AND thread_id = ? LIMIT ?", - [&input.id, &input.profile, &input.platform, &input.account_id, &input.chat_id, &input.thread_id, (max_rows)], - sessions_query_limits(max_rows, max_bytes) - ) + sqlite::query(&db_id, "SELECT id, profile, platform, account_id, chat_id, thread_id, user_id, generation, status, system_prompt, model, provider, toolset_hash, metadata_json, last_message_seq, created_at_ms, updated_at_ms, title, end_reason FROM sessions WHERE id = ? AND profile = ? AND platform = ? AND account_id = ? AND chat_id = ? AND thread_id = ? LIMIT ?", [sql_text(input.id.copy()), sql_text(input.profile.copy()), sql_text(input.platform.copy()), sql_text(input.account_id.copy()), sql_text(input.chat_id.copy()), sql_text(input.thread_id.copy()), sql_int(max_rows)], sessions_query_limits(max_rows, max_bytes)) } -pub fn storage_session_get(db_id: resource, session_id: string, max_rows: int, max_bytes: int) -> map { - sqlite::query( - &db_id, - "SELECT id, profile, platform, account_id, chat_id, thread_id, user_id, generation, status, system_prompt, model, provider, toolset_hash, metadata_json, last_message_seq, created_at_ms, updated_at_ms, title, end_reason FROM sessions WHERE id = ? LIMIT ?", - [session_id, (max_rows)], - sessions_query_limits(max_rows, max_bytes) - ) +pub fn storage_session_get(db_id: resource, session_id: string, max_rows: int, max_bytes: int) -> SqliteQueryResult { + sqlite::query(&db_id, "SELECT id, profile, platform, account_id, chat_id, thread_id, user_id, generation, status, system_prompt, model, provider, toolset_hash, metadata_json, last_message_seq, created_at_ms, updated_at_ms, title, end_reason FROM sessions WHERE id = ? LIMIT ?", [sql_text(session_id.copy()), sql_int(max_rows)], sessions_query_limits(max_rows, max_bytes)) } -pub fn storage_session_list(db_id: resource, profile: string, platform: string, max_rows: int, max_bytes: int) -> map { - sqlite::query( - &db_id, - "SELECT id, profile, platform, account_id, chat_id, thread_id, user_id, generation, status, system_prompt, model, provider, toolset_hash, metadata_json, last_message_seq, created_at_ms, updated_at_ms, title, end_reason FROM sessions WHERE profile = ? AND platform = ? ORDER BY updated_at_ms DESC, id ASC LIMIT ?", - [profile, platform, (max_rows)], - sessions_query_limits(max_rows, max_bytes) - ) +pub fn storage_session_list(db_id: resource, profile: string, platform: string, max_rows: int, max_bytes: int) -> SqliteQueryResult { + sqlite::query(&db_id, "SELECT id, profile, platform, account_id, chat_id, thread_id, user_id, generation, status, system_prompt, model, provider, toolset_hash, metadata_json, last_message_seq, created_at_ms, updated_at_ms, title, end_reason FROM sessions WHERE profile = ? AND platform = ? ORDER BY updated_at_ms DESC, id ASC LIMIT ?", [sql_text(profile), sql_text(platform), sql_int(max_rows)], sessions_query_limits(max_rows, max_bytes)) } -pub fn storage_session_touch(db_id: resource, payload_json: string, max_rows: int, max_bytes: int) -> map { +pub fn storage_session_touch(db_id: resource, payload_json: string, max_rows: int, max_bytes: int) -> SqliteQueryResult { let input: SessionTouchInput = json::decode::(payload_json); // The touch is keyed by session id only: a caller that carries a stale // `generation` (for example after a compaction bumped the durable // generation) must never silently no-op. `generation` stays in the // input contract for payload compatibility but does not gate the // update; compaction owns generation bumps. - sqlite::execute( - &db_id, - "UPDATE sessions SET status = ?, system_prompt = ?, model = ?, provider = ?, toolset_hash = ?, metadata_json = ?, title = ?, end_reason = ?, updated_at_ms = ? WHERE id = ?", - [&input.status, &input.system_prompt, &input.model, &input.provider, &input.toolset_hash, &input.metadata_json, &input.title, &input.end_reason, input.now_ms.copy(), &input.session_id] - ); + sqlite::execute(&db_id, "UPDATE sessions SET status = ?, system_prompt = ?, model = ?, provider = ?, toolset_hash = ?, metadata_json = ?, title = ?, end_reason = ?, updated_at_ms = ? WHERE id = ?", [sql_text(input.status.copy()), sql_text(input.system_prompt.copy()), sql_text(input.model.copy()), sql_text(input.provider.copy()), sql_text(input.toolset_hash.copy()), sql_text(input.metadata_json.copy()), sql_text(input.title.copy()), sql_text(input.end_reason.copy()), sql_int(input.now_ms.copy()), sql_text(input.session_id.copy())]); storage_session_get(db_id, input.session_id, max_rows, max_bytes) } @@ -108,47 +118,67 @@ pub fn storage_session_touch(db_id: resource, payload_json: s /// whether the session existed (0 -> session_not_found). pub fn storage_session_delete(db_id: resource, payload_json: string) -> array { let input: SessionIdInput = json::decode::(payload_json); - let statements = [ + let statements: array = [ { sql: "DELETE FROM idempotency_records WHERE resource_type = 'run' AND resource_id IN (SELECT id FROM runs WHERE session_id = ?)", - params: [&input.session_id] - }, + params: [sql_text(input.session_id.copy())], + query: false, + limits: sql_limits(1000, 4194304) + }, { sql: "DELETE FROM sessions WHERE id = ?", - params: [&input.session_id] - }, + params: [sql_text(input.session_id.copy())], + query: false, + limits: sql_limits(1000, 4194304) + }, { sql: "DELETE FROM run_retention WHERE run_id IN (SELECT id FROM runs WHERE session_id = ?)", - params: [&input.session_id] - }, + params: [sql_text(input.session_id.copy())], + query: false, + limits: sql_limits(1000, 4194304) + }, { sql: "DELETE FROM run_events WHERE run_id IN (SELECT id FROM runs WHERE session_id = ?)", - params: [&input.session_id] - }, + params: [sql_text(input.session_id.copy())], + query: false, + limits: sql_limits(1000, 4194304) + }, { sql: "DELETE FROM child_run_links WHERE parent_run_id IN (SELECT id FROM runs WHERE session_id = ?) OR child_run_id IN (SELECT id FROM runs WHERE session_id = ?)", - params: [&input.session_id, &input.session_id] - }, + params: [sql_text(input.session_id.copy()), sql_text(input.session_id.copy())], + query: false, + limits: sql_limits(1000, 4194304) + }, { sql: "DELETE FROM approvals WHERE session_id = ? OR run_id IN (SELECT id FROM runs WHERE session_id = ?)", - params: [&input.session_id, &input.session_id] - }, + params: [sql_text(input.session_id.copy()), sql_text(input.session_id.copy())], + query: false, + limits: sql_limits(1000, 4194304) + }, { sql: "DELETE FROM compactions WHERE session_id = ? OR run_id IN (SELECT id FROM runs WHERE session_id = ?)", - params: [&input.session_id, &input.session_id] - }, + params: [sql_text(input.session_id.copy()), sql_text(input.session_id.copy())], + query: false, + limits: sql_limits(1000, 4194304) + }, { sql: "DELETE FROM runs WHERE session_id = ?", - params: [&input.session_id] - }, + params: [sql_text(input.session_id.copy())], + query: false, + limits: sql_limits(1000, 4194304) + }, { sql: "DELETE FROM messages WHERE session_id = ?", - params: [&input.session_id] - }, + params: [sql_text(input.session_id.copy())], + query: false, + limits: sql_limits(1000, 4194304) + }, { sql: "DELETE FROM delivery_cursors WHERE session_id = ?", - params: [&input.session_id] - } + params: [sql_text(input.session_id.copy())], + query: false, + limits: sql_limits(1000, 4194304) + } ]; sqlite::transaction(&db_id, statements) } diff --git a/rss/tools/search_files.rss b/rss/tools/search_files.rss index cccc4f6..cd66c19 100644 --- a/rss/tools/search_files.rss +++ b/rss/tools/search_files.rss @@ -6,7 +6,8 @@ fn map_int(value: map, key: string, fallback: int) -> int { let mut result: int = fallback; if value.has(key) { if type(value[key]) == "int" { - result = value[key]; + let coerced: int = value[key]; + result = coerced; } } result @@ -69,8 +70,9 @@ fn glob_match_bytes(pat: array, text: array) -> bool { let mut advanced: bool = false; if pi < pat.length { let p: int = pat[pi]; + let t: int = text[ti]; if p != 42 { - if p == 63 || p == text[ti] { + if p == 63 || p == t { pi = pi + 1; ti = ti + 1; advanced = true; @@ -185,6 +187,39 @@ fn join_lines(lines: array) -> string { out } +fn string_le(a: string, b: string) -> bool { + let a_bytes: bytes = bytes::from_utf8(a); + let b_bytes: bytes = bytes::from_utf8(b); + let a_arr: array = bytes::to_array_u8(a_bytes); + let b_arr: array = bytes::to_array_u8(b_bytes); + let mut i: int = 0; + let mut decided: bool = false; + let mut result: bool = true; + let mut limit: int = a_arr.length; + if b_arr.length < limit { + limit = b_arr.length; + } + while decided == false && i < limit { + let av: int = a_arr[i].copy(); + let bv: int = b_arr[i].copy(); + if av < bv { + decided = true; + result = true; + } else { + if bv < av { + decided = true; + result = false; + } else { + i = i + 1; + } + } + } + if decided == false { + result = a_arr.length <= b_arr.length; + } + result +} + fn sort_strings(items: array) -> array { merge_sort_strings(copy_array(items)) } @@ -282,7 +317,7 @@ fn merge_string_arrays(left: array, right: array) -> array { while i < left.length && j < right.length { let a: string = left[i].copy(); let b: string = right[j].copy(); - if a <= b { + if string_le(a, b) { out[out.length] = a; i = i + 1; } else { @@ -310,7 +345,7 @@ fn merge_map_arrays(left: array, right: array) -> array { let right_item: map = right[j].copy(); let a: string = types::map_string(left_item, "name", ""); let b: string = types::map_string(right_item, "name", ""); - if a <= b { + if string_le(a, b) { out[out.length] = left_item; i = i + 1; } else { @@ -1006,13 +1041,10 @@ pub fn execute(context: map, arguments: map) -> map { file_glob_active = true; } } - let mut offset: int = 0; - if arguments.has("offset") { - offset = arguments.offset; - } + let mut offset: int = map_int(arguments, "offset", 0); let mut limit: int = map_int(config, "max_search_matches", 10000); - if arguments.has("limit") { - limit = arguments.limit; + if arguments.has("limit") && type(arguments.limit) == "int" { + limit = map_int(arguments, "limit", limit); } if limit > map_int(config, "max_search_matches", 10000) { limit = map_int(config, "max_search_matches", 10000); diff --git a/rss/tools/terminal.rss b/rss/tools/terminal.rss index 7867d8e..8eab23e 100644 --- a/rss/tools/terminal.rss +++ b/rss/tools/terminal.rss @@ -497,7 +497,7 @@ fn execute(context: map) -> map { let error: map = types::map_map(control, "error"); outcome = fail(types::map_string(error, "code", "cancelled"), types::map_string(error, "message", "process was cancelled"), unpublished()); } else { - let limits: map = { + let limits: AgentProcessLimits = { timeout_ms: timeout_ms, stdout_limit: stream_limit, stderr_limit: stream_limit, diff --git a/src/auth/oauth_host.rs b/src/auth/oauth_host.rs index acb06e3..7ba0198 100644 --- a/src/auth/oauth_host.rs +++ b/src/auth/oauth_host.rs @@ -8,10 +8,10 @@ use std::sync::{Arc, OnceLock}; use std::time::{Duration, Instant}; use rustscript_vm::{ - CallOutcome, CallReturn, CompileSourceFileOptions, HostApiBuilder, HostApiCatalog, - HostFunctionRegistry, HostFunctionSchema, HostParamSchema, HostTypeSchema, Program, - SourceFlavor, Value, Vm, VmResult, VmStatus, catalog_import_schemas, - compile_source_at_path_with_flavor_and_options, standard_host_catalog, + CallOutcome, CallReturn, CompileSourceFileOptions, HostApiCatalog, HostFunctionDescriptor, + HostFunctionRegistry, HostFunctionSchema, HostModuleDescriptor, HostParamSchema, + HostTypeSchema, Program, SourceFlavor, Value, Vm, VmResult, VmStatus, + compile_source_at_path_with_flavor_and_options, }; use serde_json::{Value as JsonValue, json}; @@ -205,7 +205,7 @@ impl OAuthFixtureHost { let mut registry = HostFunctionRegistry::restricted(); register_host_functions(&mut registry, catalog.as_ref()) .map_err(|error| error.to_string())?; - let mut vm = Vm::try_new_shared(program).map_err(|error| error.to_string())?; + let mut vm = Vm::new_shared(program); registry .bind_vm_cached(&mut vm) .map_err(|error| error.to_string())?; @@ -335,144 +335,145 @@ impl Drop for OAuthFixtureHost { pub fn oauth_fixture_catalog() -> Arc { static CATALOG: OnceLock> = OnceLock::new(); Arc::clone(CATALOG.get_or_init(|| { - let standard = standard_host_catalog(); - let mut builder = HostApiBuilder::new(); - for resource in standard.resources() { - builder.resource(resource.clone()); - } - for function in standard.functions() { - builder.function(function.clone()); - } - let response = HostTypeSchema::Map(Box::new(HostTypeSchema::Unknown)); - let unknown = HostTypeSchema::Unknown; - builder.function(HostFunctionSchema::with_return( - OAUTH_PKCE_BEGIN, - vec![ - HostParamSchema::value("policy_handle", unknown.clone()), - HostParamSchema::value("public_intent", unknown.clone()), - ], - response.clone(), - )); - builder.function(HostFunctionSchema::with_return( - OAUTH_CALLBACK_WAIT, - vec![HostParamSchema::value("callback_handle", unknown.clone())], - response.clone(), - )); - builder.function(HostFunctionSchema::with_return( - OAUTH_TRANSPORT, - vec![ - HostParamSchema::value("request", unknown.clone()), - HostParamSchema::value("credential_use", unknown.clone()), - ], - response.clone(), - )); - builder.function(HostFunctionSchema::with_return( - OAUTH_WAIT_MS, - vec![HostParamSchema::value("millis", HostTypeSchema::Int)], - response.clone(), - )); - builder.function(HostFunctionSchema::with_return( - OAUTH_TERMINAL, - vec![HostParamSchema::value("handle", unknown.clone())], - response.clone(), - )); - builder.function(HostFunctionSchema::with_return( - AUTH_LOAD_METADATA, - vec![HostParamSchema::value("request", unknown.clone())], - response.clone(), - )); - builder.function(HostFunctionSchema::with_return( - AUTH_SAVE_IF_GENERATION, - vec![HostParamSchema::value("request", unknown.clone())], - response.clone(), - )); - builder.function(HostFunctionSchema::with_return( - AUTH_REFRESH_HANDLE, - vec![HostParamSchema::value("request", unknown.clone())], - response.clone(), - )); - builder.function(HostFunctionSchema::with_return( - AUTH_ACCESS_HANDLE, - vec![HostParamSchema::value("request", unknown.clone())], - response.clone(), - )); - builder.function(HostFunctionSchema::with_return( - AUTH_CHECK_HANDLE, - vec![ - HostParamSchema::value("handle", unknown.clone()), - HostParamSchema::value("intent", unknown), - ], - response, - )); - Arc::new(builder.build().expect("oauth fixture catalog must build")) + crate::runtime::host_compose::compose_with_standard(&[oauth_fixture_module()]) })) } -fn register_host_functions( - registry: &mut HostFunctionRegistry, - catalog: &HostApiCatalog, -) -> VmResult<()> { - register_named(registry, catalog, OAUTH_PKCE_BEGIN, 2, pkce_begin_adapter)?; - register_named( - registry, - catalog, +const OAUTH_FIXTURE_FUNCTIONS: &[fn() -> HostFunctionDescriptor] = &[ + oauth_pkce_begin_descriptor, + oauth_callback_wait_descriptor, + oauth_transport_descriptor, + oauth_wait_ms_descriptor, + oauth_terminal_descriptor, + auth_load_metadata_descriptor, + auth_save_if_generation_descriptor, + auth_refresh_handle_descriptor, + auth_access_handle_descriptor, + auth_check_handle_descriptor, +]; + +pub fn oauth_fixture_module() -> HostModuleDescriptor { + HostModuleDescriptor { + name: "oauth_fixture", + functions: OAUTH_FIXTURE_FUNCTIONS, + resources: &[], + } +} + +fn fixture_map() -> HostTypeSchema { + HostTypeSchema::Map(Box::new(HostTypeSchema::Unknown)) +} + +fn stack_desc( + name: &'static str, + params: Vec, + ret: HostTypeSchema, + adapter: fn(&mut Vm, &[Value]) -> VmResult, +) -> HostFunctionDescriptor { + crate::runtime::host_compose::static_stack_descriptor( + HostFunctionSchema::with_return(name, params, ret), + adapter, + ) +} + +fn oauth_pkce_begin_descriptor() -> HostFunctionDescriptor { + stack_desc( + OAUTH_PKCE_BEGIN, + vec![ + HostParamSchema::value("policy_handle", HostTypeSchema::Unknown), + HostParamSchema::value("public_intent", HostTypeSchema::Unknown), + ], + fixture_map(), + pkce_begin_adapter, + ) +} +fn oauth_callback_wait_descriptor() -> HostFunctionDescriptor { + stack_desc( OAUTH_CALLBACK_WAIT, - 1, + vec![HostParamSchema::value( + "callback_handle", + HostTypeSchema::Unknown, + )], + fixture_map(), callback_wait_adapter, - )?; - register_named(registry, catalog, OAUTH_TRANSPORT, 2, transport_adapter)?; - register_named(registry, catalog, OAUTH_WAIT_MS, 1, wait_ms_adapter)?; - register_named(registry, catalog, OAUTH_TERMINAL, 1, terminal_adapter)?; - register_named( - registry, - catalog, + ) +} +fn oauth_transport_descriptor() -> HostFunctionDescriptor { + stack_desc( + OAUTH_TRANSPORT, + vec![ + HostParamSchema::value("request", HostTypeSchema::Unknown), + HostParamSchema::value("credential_use", HostTypeSchema::Unknown), + ], + fixture_map(), + transport_adapter, + ) +} +fn oauth_wait_ms_descriptor() -> HostFunctionDescriptor { + stack_desc( + OAUTH_WAIT_MS, + vec![HostParamSchema::value("millis", HostTypeSchema::Int)], + fixture_map(), + wait_ms_adapter, + ) +} +fn oauth_terminal_descriptor() -> HostFunctionDescriptor { + stack_desc( + OAUTH_TERMINAL, + vec![HostParamSchema::value("handle", HostTypeSchema::Unknown)], + fixture_map(), + terminal_adapter, + ) +} +fn auth_load_metadata_descriptor() -> HostFunctionDescriptor { + stack_desc( AUTH_LOAD_METADATA, - 1, + vec![HostParamSchema::value("request", HostTypeSchema::Unknown)], + fixture_map(), load_metadata_adapter, - )?; - register_named( - registry, - catalog, + ) +} +fn auth_save_if_generation_descriptor() -> HostFunctionDescriptor { + stack_desc( AUTH_SAVE_IF_GENERATION, - 1, + vec![HostParamSchema::value("request", HostTypeSchema::Unknown)], + fixture_map(), save_if_generation_adapter, - )?; - register_named( - registry, - catalog, + ) +} +fn auth_refresh_handle_descriptor() -> HostFunctionDescriptor { + stack_desc( AUTH_REFRESH_HANDLE, - 1, + vec![HostParamSchema::value("request", HostTypeSchema::Unknown)], + fixture_map(), refresh_handle_adapter, - )?; - register_named( - registry, - catalog, + ) +} +fn auth_access_handle_descriptor() -> HostFunctionDescriptor { + stack_desc( AUTH_ACCESS_HANDLE, - 1, + vec![HostParamSchema::value("request", HostTypeSchema::Unknown)], + fixture_map(), access_handle_adapter, - )?; - register_named( - registry, - catalog, + ) +} +fn auth_check_handle_descriptor() -> HostFunctionDescriptor { + stack_desc( AUTH_CHECK_HANDLE, - 2, + vec![ + HostParamSchema::value("handle", HostTypeSchema::Unknown), + HostParamSchema::value("intent", HostTypeSchema::Unknown), + ], + fixture_map(), check_handle_adapter, - )?; - Ok(()) + ) } -fn register_named( +fn register_host_functions( registry: &mut HostFunctionRegistry, catalog: &HostApiCatalog, - name: &'static str, - arity: u8, - adapter: fn(&mut Vm, &[Value]) -> VmResult, ) -> VmResult<()> { - for schema in catalog_import_schemas(catalog, name) { - registry.register_exact_static(name, arity, schema, adapter)?; - } - registry.register_static(name, arity, adapter); - registry.allow_builtin(name)?; + oauth_fixture_module().install_from_catalog(registry, catalog)?; Ok(()) } @@ -1452,9 +1453,10 @@ fn drive_root_frame(vm: &mut Vm) -> Result<(), String> { loop { match vm.run() { Ok(VmStatus::Halted) => return Ok(()), - Ok(VmStatus::Waiting(_)) => vm - .wait_for_host_op_blocking_with_cancel(|| false) - .map_err(|error| error.to_string())?, + Ok(VmStatus::Waiting(_)) => { + crate::runtime::host_wait::wait_for_host_op_blocking_with_cancel(vm, || false) + .map_err(|error| error.to_string())?; + } Ok(VmStatus::Yielded) => { return Err("oauth fixture root frame yielded unexpectedly".to_string()); } diff --git a/src/auth/store_host.rs b/src/auth/store_host.rs index d2e5d25..bb6118b 100644 --- a/src/auth/store_host.rs +++ b/src/auth/store_host.rs @@ -10,10 +10,10 @@ use std::sync::{Arc, OnceLock}; use std::time::{Duration, Instant}; use rustscript_vm::{ - CallOutcome, CallReturn, CompileSourceFileOptions, HostApiBuilder, HostApiCatalog, - HostFunctionRegistry, HostFunctionSchema, HostParamSchema, HostTypeSchema, Program, - SourceFlavor, Value, Vm, VmResult, VmStatus, catalog_import_schemas, - compile_source_at_path_with_flavor_and_options, standard_host_catalog, + CallOutcome, CallReturn, CompileSourceFileOptions, HostApiCatalog, HostFunctionDescriptor, + HostFunctionRegistry, HostFunctionSchema, HostModuleDescriptor, HostParamSchema, + HostTypeSchema, Program, SourceFlavor, Value, Vm, VmResult, VmStatus, + compile_source_at_path_with_flavor_and_options, }; use serde_json::{Value as JsonValue, json}; @@ -266,7 +266,7 @@ impl AuthFixtureHost { let mut registry = HostFunctionRegistry::restricted(); register_host_functions(&mut registry, catalog.as_ref()) .map_err(|error| error.to_string())?; - let mut vm = Vm::try_new_shared(program).map_err(|error| error.to_string())?; + let mut vm = Vm::new_shared(program); registry .bind_vm_cached(&mut vm) .map_err(|error| error.to_string())?; @@ -294,116 +294,93 @@ impl Drop for AuthFixtureHost { pub fn auth_store_fixture_catalog() -> Arc { static CATALOG: OnceLock> = OnceLock::new(); Arc::clone(CATALOG.get_or_init(|| { - let standard = standard_host_catalog(); - let mut builder = HostApiBuilder::new(); - for resource in standard.resources() { - builder.resource(resource.clone()); - } - for function in standard.functions() { - builder.function(function.clone()); - } - let response = HostTypeSchema::Map(Box::new(HostTypeSchema::Unknown)); - register_catalog_functions(&mut builder, response); - Arc::new( - builder - .build() - .expect("auth store fixture catalog must build"), - ) + crate::runtime::host_compose::compose_with_standard(&[auth_store_fixture_module()]) })) } -fn register_catalog_functions(builder: &mut HostApiBuilder, response: HostTypeSchema) { - let request = vec![HostParamSchema::value("request", HostTypeSchema::Unknown)]; - builder.function(HostFunctionSchema::with_return( - AUTH_LOAD_METADATA, - request.clone(), - response.clone(), - )); - builder.function(HostFunctionSchema::with_return( - AUTH_SAVE_IF_GENERATION, - request.clone(), - response.clone(), - )); - builder.function(HostFunctionSchema::with_return( - AUTH_ACCESS_HANDLE, - request.clone(), - response.clone(), - )); - builder.function(HostFunctionSchema::with_return( - AUTH_REFRESH_HANDLE, - request.clone(), - response.clone(), - )); - builder.function(HostFunctionSchema::with_return( - AUTH_DELETE, - request, - response.clone(), - )); - builder.function(HostFunctionSchema::with_return( - AUTH_CHECK_HANDLE, - vec![ - HostParamSchema::value("handle", HostTypeSchema::Unknown), - HostParamSchema::value("intent", HostTypeSchema::Unknown), - ], - response, - )); +const AUTH_STORE_FUNCTIONS: &[fn() -> HostFunctionDescriptor] = &[ + auth_load_metadata_descriptor, + auth_save_if_generation_descriptor, + auth_access_handle_descriptor, + auth_refresh_handle_descriptor, + auth_delete_descriptor, + auth_check_handle_descriptor, +]; + +pub fn auth_store_fixture_module() -> HostModuleDescriptor { + HostModuleDescriptor { + name: "auth_store_fixture", + functions: AUTH_STORE_FUNCTIONS, + resources: &[], + } } -fn register_host_functions( - registry: &mut HostFunctionRegistry, - catalog: &HostApiCatalog, -) -> VmResult<()> { - register_named( - registry, - catalog, +fn fixture_map() -> HostTypeSchema { + HostTypeSchema::Map(Box::new(HostTypeSchema::Unknown)) +} + +fn stack_desc( + name: &'static str, + params: Vec, + adapter: fn(&mut Vm, &[Value]) -> VmResult, +) -> HostFunctionDescriptor { + crate::runtime::host_compose::static_stack_descriptor( + HostFunctionSchema::with_return(name, params, fixture_map()), + adapter, + ) +} + +fn auth_load_metadata_descriptor() -> HostFunctionDescriptor { + stack_desc( AUTH_LOAD_METADATA, - 1, + vec![HostParamSchema::value("request", HostTypeSchema::Unknown)], load_metadata_adapter, - )?; - register_named( - registry, - catalog, + ) +} +fn auth_save_if_generation_descriptor() -> HostFunctionDescriptor { + stack_desc( AUTH_SAVE_IF_GENERATION, - 1, + vec![HostParamSchema::value("request", HostTypeSchema::Unknown)], save_if_generation_adapter, - )?; - register_named( - registry, - catalog, + ) +} +fn auth_access_handle_descriptor() -> HostFunctionDescriptor { + stack_desc( AUTH_ACCESS_HANDLE, - 1, + vec![HostParamSchema::value("request", HostTypeSchema::Unknown)], access_handle_adapter, - )?; - register_named( - registry, - catalog, + ) +} +fn auth_refresh_handle_descriptor() -> HostFunctionDescriptor { + stack_desc( AUTH_REFRESH_HANDLE, - 1, + vec![HostParamSchema::value("request", HostTypeSchema::Unknown)], refresh_handle_adapter, - )?; - register_named(registry, catalog, AUTH_DELETE, 1, delete_adapter)?; - register_named( - registry, - catalog, + ) +} +fn auth_delete_descriptor() -> HostFunctionDescriptor { + stack_desc( + AUTH_DELETE, + vec![HostParamSchema::value("request", HostTypeSchema::Unknown)], + delete_adapter, + ) +} +fn auth_check_handle_descriptor() -> HostFunctionDescriptor { + stack_desc( AUTH_CHECK_HANDLE, - 2, + vec![ + HostParamSchema::value("handle", HostTypeSchema::Unknown), + HostParamSchema::value("intent", HostTypeSchema::Unknown), + ], check_handle_adapter, - )?; - Ok(()) + ) } -fn register_named( +fn register_host_functions( registry: &mut HostFunctionRegistry, catalog: &HostApiCatalog, - name: &'static str, - arity: u8, - adapter: fn(&mut Vm, &[Value]) -> VmResult, ) -> VmResult<()> { - for schema in catalog_import_schemas(catalog, name) { - registry.register_exact_static(name, arity, schema, adapter)?; - } - registry.register_static(name, arity, adapter); - registry.allow_builtin(name)?; + auth_store_fixture_module().install_from_catalog(registry, catalog)?; Ok(()) } @@ -1034,9 +1011,10 @@ fn drive_root_frame(vm: &mut Vm) -> Result<(), String> { loop { match vm.run() { Ok(VmStatus::Halted) => return Ok(()), - Ok(VmStatus::Waiting(_)) => vm - .wait_for_host_op_blocking_with_cancel(|| false) - .map_err(|error| error.to_string())?, + Ok(VmStatus::Waiting(_)) => { + crate::runtime::host_wait::wait_for_host_op_blocking_with_cancel(vm, || false) + .map_err(|error| error.to_string())?; + } Ok(VmStatus::Yielded) => { return Err("auth store fixture root frame yielded unexpectedly".to_string()); } diff --git a/src/capabilities/filesystem.rs b/src/capabilities/filesystem.rs index 73f1b68..b49392f 100644 --- a/src/capabilities/filesystem.rs +++ b/src/capabilities/filesystem.rs @@ -8,7 +8,7 @@ use std::{ sync::{Arc, Mutex}, }; -use rustscript_vm::{ +use crate::capabilities::vm_io::{ ConfinedFileType, ConfinedFsError, ConfinedFsErrorKind, ConfinedFsLimits, ConfinedFsRoot, ConfinedMetadata, ConfinedPublicationState, MAX_COMPONENT_BYTES, MAX_ENUM_ENTRIES, MAX_READ_BYTES, MAX_WRITE_BYTES, diff --git a/src/capabilities/mod.rs b/src/capabilities/mod.rs index 6ea4a06..0ed32cd 100644 --- a/src/capabilities/mod.rs +++ b/src/capabilities/mod.rs @@ -9,6 +9,7 @@ pub mod types; mod confined_io; mod hash; +pub(crate) mod vm_io; pub(crate) use hash::sha256_hex; diff --git a/src/capabilities/process.rs b/src/capabilities/process.rs index 93179e2..e699ee4 100644 --- a/src/capabilities/process.rs +++ b/src/capabilities/process.rs @@ -14,7 +14,7 @@ use std::{ time::{Duration, Instant}, }; -use rustscript_vm::{ +use crate::capabilities::vm_io::{ BoundedProcess, BoundedProcessError, BoundedProcessHandle, BoundedProcessRequest, CancellationToken as ProcessCancel, ConfinedFsLimits, ConfinedFsRoot, LogSnapshot, MAX_COMPONENT_BYTES, MAX_ENUM_ENTRIES, MAX_READ_BYTES, MAX_WRITE_BYTES, ProcessStatus, diff --git a/src/capabilities/vm_io/bounded_process.rs b/src/capabilities/vm_io/bounded_process.rs new file mode 100644 index 0000000..951708a --- /dev/null +++ b/src/capabilities/vm_io/bounded_process.rs @@ -0,0 +1,2549 @@ +//! Vendored from pd-vm `f9ca4143f8ba2f486e270347504c49f5ea846097` after the +//! frozen core SHA dropped the public confined-FS / bounded-process surface. +//! Guest ABI is unchanged; these types are agent capability backends only. + +//! Bounded, argv-only child-process execution. +//! +//! This module owns the process lifecycle needed by foreground and background +//! callers. It deliberately uses `std::process::Command` with an argv vector; +//! command strings and shell expansion are outside this API. + +#[cfg(all(test, unix))] +use std::cell::Cell; +use std::collections::{BTreeMap, VecDeque}; +use std::fmt; +use std::io::{Read, Write}; +use std::ops::Deref; +use std::path::PathBuf; +use std::process::{Child, ExitStatus, Stdio}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Condvar, Mutex, MutexGuard, OnceLock}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +use super::confined_fs::ConfinedDirectory; + +#[cfg(windows)] +use super::windows_process_tree::ProcessJob; +#[cfg(unix)] +use std::os::unix::process::CommandExt; +#[cfg(unix)] +use std::os::unix::process::ExitStatusExt; +#[cfg(windows)] +use std::os::windows::process::CommandExt; + +/// Maximum number of argv entries accepted by [`BoundedProcessRequest`]. +pub const MAX_ARG_COUNT: usize = 256; +/// Maximum byte length of one argv entry. +pub const MAX_ARG_ITEM_BYTES: usize = 16 * 1024; +/// Maximum combined byte length of all argv entries. +pub const MAX_ARG_TOTAL_BYTES: usize = 256 * 1024; +/// Maximum number of explicitly supplied environment entries. +pub const MAX_ENV_COUNT: usize = 128; +/// Maximum byte length of an environment key. +pub const MAX_ENV_KEY_BYTES: usize = 256; +/// Maximum byte length of an environment value. +pub const MAX_ENV_VALUE_BYTES: usize = 16 * 1024; +/// Maximum combined byte length of environment keys and values. +pub const MAX_ENV_TOTAL_BYTES: usize = 256 * 1024; +/// Maximum initial stdin payload. +pub const MAX_STDIN_BYTES: usize = 16 * 1024 * 1024; +/// Maximum one-shot stdin write payload. +pub const MAX_STDIN_WRITE_BYTES: usize = 16 * 1024 * 1024; +/// Maximum accepted timeout or relative deadline. +pub const MAX_TIMEOUT: Duration = Duration::from_secs(60 * 60); +/// Maximum retained output across both streams. +pub const MAX_OUTPUT_BYTES: usize = 16 * 1024 * 1024; +/// Default request timeout. +pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); +/// Default per-stream and total output limit. +pub const DEFAULT_OUTPUT_BYTES: usize = 1024 * 1024; +/// Polling slice used by cancellable stdin/drainer/wait loops. +const WAIT_SLICE: Duration = Duration::from_millis(5); +/// Bound for joining workers and reaping a killed child after SIGKILL/Job. +const CLEANUP_GRACE: Duration = Duration::from_millis(200); + +static NEXT_PROCESS_HANDLE: AtomicU64 = AtomicU64::new(1); + +/// Cooperative cancellation shared by a request and its owner. +#[derive(Clone, Debug)] +pub struct CancellationToken { + cancelled: Arc, +} + +impl Default for CancellationToken { + fn default() -> Self { + Self::new() + } +} + +impl CancellationToken { + /// Creates a clear cancellation token. + pub fn new() -> Self { + Self { + cancelled: Arc::new(AtomicBool::new(false)), + } + } + + /// Requests cancellation. Repeated calls are harmless. + pub fn cancel(&self) { + self.cancelled.store(true, Ordering::Release); + } + + /// Returns whether cancellation has been requested. + pub fn is_cancelled(&self) -> bool { + self.cancelled.load(Ordering::Acquire) + } +} + +/// A validated request for a native argv-only child process. +#[derive(Clone)] +pub struct BoundedProcessRequest { + /// Program followed by its literal arguments. + pub argv: Vec, + /// Optional explicit working directory. Must be absolute when present. + pub cwd: Option, + /// Optional retained confined directory used as the child cwd. + /// + /// Mutually exclusive with [`Self::cwd`] and [`Self::workspace_root`]. + pub confined_cwd: Option, + /// Workspace root used as the child cwd when `cwd` is omitted. + pub workspace_root: Option, + /// Explicit environment entries. They are allowlisted; inheritance is + /// forbidden. + pub env: BTreeMap, + /// Whether the child inherits the host environment in addition to `env`. + /// Must remain false; [`ValidationError::InheritEnvForbidden`] rejects it. + pub inherit_env: bool, + /// Initial stdin bytes. The foreground helper closes stdin after writing. + pub stdin: Vec, + /// Relative execution timeout. At least one of `timeout` or `deadline` is + /// required; `new` supplies the bounded default. + pub timeout: Option, + /// Absolute execution deadline. When both are present, the earlier one is + /// used. + pub deadline: Option, + /// Maximum retained stdout bytes. + pub stdout_limit: usize, + /// Maximum retained stderr bytes. + pub stderr_limit: usize, + /// Maximum retained bytes across stdout and stderr. + pub total_limit: usize, + /// Optional owner cancellation token. + pub cancellation_token: Option, +} + +impl fmt::Debug for BoundedProcessRequest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("BoundedProcessRequest") + .field("argv_count", &self.argv.len()) + .field("cwd_present", &self.cwd.is_some()) + .field("confined_cwd_present", &self.confined_cwd.is_some()) + .field("workspace_root_present", &self.workspace_root.is_some()) + .field("env_count", &self.env.len()) + .field("inherit_env", &self.inherit_env) + .field("stdin_len", &self.stdin.len()) + .field("timeout", &self.timeout) + .field("deadline", &self.deadline) + .field("stdout_limit", &self.stdout_limit) + .field("stderr_limit", &self.stderr_limit) + .field("total_limit", &self.total_limit) + .field("cancellation_present", &self.cancellation_token.is_some()) + .finish() + } +} + +impl Default for BoundedProcessRequest { + fn default() -> Self { + Self::new(Vec::new()) + } +} + +pub(crate) fn validate_argv(argv: &[String]) -> Result<(), ValidationError> { + if argv.is_empty() { + return Err(ValidationError::EmptyArgv); + } + if argv.len() > MAX_ARG_COUNT { + return Err(ValidationError::ArgCountExceeded); + } + if argv[0].is_empty() { + return Err(ValidationError::EmptyProgram); + } + let mut argv_total = 0usize; + for (index, item) in argv.iter().enumerate() { + if item.as_bytes().contains(&0) { + return Err(ValidationError::ArgContainsNul { index }); + } + if item.len() > MAX_ARG_ITEM_BYTES { + return Err(ValidationError::ArgItemTooLong { index }); + } + argv_total = argv_total + .checked_add(item.len()) + .ok_or(ValidationError::ArgTotalTooLarge)?; + if argv_total > MAX_ARG_TOTAL_BYTES { + return Err(ValidationError::ArgTotalTooLarge); + } + } + Ok(()) +} + +impl BoundedProcessRequest { + /// Creates a request with bounded defaults. + pub fn new(argv: Vec) -> Self { + Self { + argv, + cwd: None, + confined_cwd: None, + workspace_root: None, + env: BTreeMap::new(), + inherit_env: false, + stdin: Vec::new(), + timeout: Some(DEFAULT_TIMEOUT), + deadline: None, + stdout_limit: DEFAULT_OUTPUT_BYTES, + stderr_limit: DEFAULT_OUTPUT_BYTES, + total_limit: DEFAULT_OUTPUT_BYTES, + cancellation_token: None, + } + } + + pub fn with_cwd(mut self, cwd: impl Into) -> Self { + self.cwd = Some(cwd.into()); + self + } + + pub fn with_confined_cwd(mut self, cwd: ConfinedDirectory) -> Self { + self.confined_cwd = Some(cwd); + self + } + + pub fn with_workspace_root(mut self, root: impl Into) -> Self { + self.workspace_root = Some(root.into()); + self + } + + pub fn with_env(mut self, key: impl Into, value: impl Into) -> Self { + self.env.insert(key.into(), value.into()); + self + } + + pub fn with_env_map(mut self, env: BTreeMap) -> Self { + self.env = env; + self + } + + pub fn with_inherit_env(mut self, inherit_env: bool) -> Self { + self.inherit_env = inherit_env; + self + } + + pub fn with_stdin(mut self, stdin: impl Into>) -> Self { + self.stdin = stdin.into(); + self + } + + pub fn with_timeout(mut self, timeout: Duration) -> Self { + self.timeout = Some(timeout); + self + } + + pub fn without_timeout(mut self) -> Self { + self.timeout = None; + self + } + + pub fn with_deadline(mut self, deadline: Instant) -> Self { + self.deadline = Some(deadline); + self + } + + pub fn with_cancellation_token(mut self, token: CancellationToken) -> Self { + self.cancellation_token = Some(token); + self + } + + pub fn with_output_limits( + mut self, + stdout_limit: usize, + stderr_limit: usize, + total_limit: usize, + ) -> Self { + self.stdout_limit = stdout_limit; + self.stderr_limit = stderr_limit; + self.total_limit = total_limit; + self + } + + /// Validates every bounded request field without spawning a process. + pub fn validate(&self) -> Result<(), ValidationError> { + validate_argv(&self.argv)?; + + let has_path_cwd = self.cwd.is_some() || self.workspace_root.is_some(); + if self.confined_cwd.is_some() && has_path_cwd { + return Err(ValidationError::ConflictingCwd); + } + if self.confined_cwd.is_none() { + if let Some(cwd) = self.resolved_cwd() { + let cwd_len = os_string_len(cwd.as_os_str()); + if cwd_len == 0 { + return Err(ValidationError::EmptyCwd); + } + if cwd_len > MAX_ARG_TOTAL_BYTES { + return Err(ValidationError::CwdTooLong); + } + if os_string_has_nul(cwd.as_os_str()) { + return Err(ValidationError::CwdContainsNul); + } + if !cwd.is_absolute() { + return Err(ValidationError::CwdNotAbsolute); + } + } else { + return Err(ValidationError::CwdRequired); + } + } + #[cfg(not(unix))] + if self.confined_cwd.is_some() { + return Err(ValidationError::ConfinedCwdUnsupported); + } + + if self.inherit_env { + return Err(ValidationError::InheritEnvForbidden); + } + + if self.env.len() > MAX_ENV_COUNT { + return Err(ValidationError::EnvCountExceeded); + } + let mut env_total = 0usize; + for (key, value) in &self.env { + if !valid_env_key(key) { + return Err(ValidationError::InvalidEnvKey); + } + if key.len() > MAX_ENV_KEY_BYTES { + return Err(ValidationError::EnvKeyTooLong); + } + if value.as_bytes().contains(&0) { + return Err(ValidationError::EnvValueContainsNul); + } + if value.len() > MAX_ENV_VALUE_BYTES { + return Err(ValidationError::EnvValueTooLong); + } + env_total = env_total + .checked_add(key.len()) + .and_then(|total| total.checked_add(value.len())) + .ok_or(ValidationError::EnvTotalTooLarge)?; + if env_total > MAX_ENV_TOTAL_BYTES { + return Err(ValidationError::EnvTotalTooLarge); + } + } + + if self.stdin.len() > MAX_STDIN_BYTES { + return Err(ValidationError::StdinTooLarge); + } + match self.timeout { + Some(timeout) if timeout.is_zero() => return Err(ValidationError::TimeoutNonPositive), + Some(timeout) if timeout > MAX_TIMEOUT => return Err(ValidationError::TimeoutTooLarge), + Some(_) | None => {} + } + if self.timeout.is_none() && self.deadline.is_none() { + return Err(ValidationError::TimeoutMissing); + } + if let Some(deadline) = self.deadline { + let now = Instant::now(); + if deadline <= now { + return Err(ValidationError::DeadlineElapsed); + } + if deadline > now + MAX_TIMEOUT { + return Err(ValidationError::DeadlineTooFar); + } + } + + validate_output_limit(self.stdout_limit, "stdout")?; + validate_output_limit(self.stderr_limit, "stderr")?; + validate_output_limit(self.total_limit, "total")?; + Ok(()) + } + + fn effective_deadline(&self, now: Instant) -> Result { + self.validate() + .map_err(BoundedProcessError::InvalidRequest)?; + let timeout_deadline = self.timeout.map(|timeout| now + timeout); + let deadline = match (timeout_deadline, self.deadline) { + (Some(timeout), Some(deadline)) => timeout.min(deadline), + (Some(timeout), None) => timeout, + (None, Some(deadline)) => deadline, + (None, None) => return Err(BoundedProcessError::DeadlineElapsed), + }; + if deadline <= now { + return Err(BoundedProcessError::DeadlineElapsed); + } + Ok(deadline) + } + + fn resolved_cwd(&self) -> Option<&PathBuf> { + self.cwd.as_ref().or(self.workspace_root.as_ref()) + } +} + +fn validate_output_limit(value: usize, name: &'static str) -> Result<(), ValidationError> { + if value == 0 { + return Err(ValidationError::OutputLimitNonPositive { name }); + } + if value > MAX_OUTPUT_BYTES { + return Err(ValidationError::OutputLimitTooLarge { name }); + } + Ok(()) +} + +fn valid_env_key(value: &str) -> bool { + let mut chars = value.bytes(); + let Some(first) = chars.next() else { + return false; + }; + if !(first.is_ascii_alphabetic() || first == b'_') { + return false; + } + chars.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') +} + +#[cfg(unix)] +fn os_string_len(value: &std::ffi::OsStr) -> usize { + use std::os::unix::ffi::OsStrExt; + value.as_bytes().len() +} + +#[cfg(windows)] +fn os_string_len(value: &std::ffi::OsStr) -> usize { + use std::os::windows::ffi::OsStrExt; + value.encode_wide().count().saturating_mul(2) +} + +#[cfg(not(any(unix, windows)))] +fn os_string_len(value: &std::ffi::OsStr) -> usize { + value.to_string_lossy().len() +} + +#[cfg(unix)] +fn os_string_has_nul(value: &std::ffi::OsStr) -> bool { + use std::os::unix::ffi::OsStrExt; + value.as_bytes().contains(&0) +} + +#[cfg(windows)] +fn os_string_has_nul(value: &std::ffi::OsStr) -> bool { + use std::os::windows::ffi::OsStrExt; + value.encode_wide().any(|unit| unit == 0) +} + +#[cfg(not(any(unix, windows)))] +fn os_string_has_nul(value: &std::ffi::OsStr) -> bool { + value.to_string_lossy().contains('\0') +} + +/// Validation failures do not retain user-supplied argv, environment, or stdin. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ValidationError { + EmptyArgv, + EmptyProgram, + ArgCountExceeded, + ArgContainsNul { index: usize }, + ArgItemTooLong { index: usize }, + ArgTotalTooLarge, + EmptyCwd, + CwdRequired, + CwdNotAbsolute, + CwdTooLong, + CwdContainsNul, + ConflictingCwd, + ConfinedCwdUnsupported, + EnvCountExceeded, + InvalidEnvKey, + EnvKeyTooLong, + EnvValueContainsNul, + EnvValueTooLong, + EnvTotalTooLarge, + InheritEnvForbidden, + StdinTooLarge, + TimeoutMissing, + TimeoutNonPositive, + TimeoutTooLarge, + DeadlineElapsed, + DeadlineTooFar, + OutputLimitNonPositive { name: &'static str }, + OutputLimitTooLarge { name: &'static str }, +} + +pub type ProcessValidationError = ValidationError; + +impl fmt::Display for ValidationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let text = match self { + Self::EmptyArgv => "argv must contain a program", + Self::EmptyProgram => "program must not be empty", + Self::ArgCountExceeded => "argv count exceeds the configured bound", + Self::ArgContainsNul { .. } => "argv contains a NUL byte", + Self::ArgItemTooLong { .. } => "argv item exceeds the configured bound", + Self::ArgTotalTooLarge => "argv total length exceeds the configured bound", + Self::EmptyCwd => "cwd must not be empty", + Self::CwdRequired => "an explicit cwd or workspace root is required", + Self::CwdNotAbsolute => "cwd must be an absolute path", + Self::CwdTooLong => "cwd exceeds the configured bound", + Self::CwdContainsNul => "cwd contains a NUL byte", + Self::ConflictingCwd => "cwd path and confined cwd cannot both be specified", + Self::ConfinedCwdUnsupported => "confined cwd is unavailable on this target", + Self::EnvCountExceeded => "environment entry count exceeds the configured bound", + Self::InvalidEnvKey => "environment key has invalid grammar", + Self::EnvKeyTooLong => "environment key exceeds the configured bound", + Self::EnvValueContainsNul => "environment value contains a NUL byte", + Self::EnvValueTooLong => "environment value exceeds the configured bound", + Self::EnvTotalTooLarge => "environment total length exceeds the configured bound", + Self::InheritEnvForbidden => "inheriting the ambient environment is forbidden", + Self::StdinTooLarge => "stdin exceeds the configured bound", + Self::TimeoutMissing => "a timeout or absolute deadline is required", + Self::TimeoutNonPositive => "timeout must be positive", + Self::TimeoutTooLarge => "timeout exceeds the configured bound", + Self::DeadlineElapsed => "absolute deadline has elapsed", + Self::DeadlineTooFar => "absolute deadline exceeds the configured bound", + Self::OutputLimitNonPositive { name } => { + return write!(formatter, "{name} output limit must be positive"); + } + Self::OutputLimitTooLarge { name } => { + return write!( + formatter, + "{name} output limit exceeds the configured bound" + ); + } + }; + formatter.write_str(text) + } +} + +impl std::error::Error for ValidationError {} + +/// Coarse spawn failure classification with an optional OS error number. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SpawnErrorKind { + NotFound, + PermissionDenied, + InvalidInput, + ResourceExhausted, + Other, +} + +/// Bounded spawn failure. It never contains the attempted argv. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SpawnError { + pub kind: SpawnErrorKind, + pub os_code: Option, +} + +impl fmt::Display for SpawnError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "spawn failed ({:?}", self.kind)?; + if let Some(code) = self.os_code { + write!(formatter, ", os error {code}")?; + } + formatter.write_str(")") + } +} + +impl std::error::Error for SpawnError {} + +/// Process lifecycle failures. Error variants retain only bounded metadata. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum BoundedProcessError { + InvalidRequest(ValidationError), + Spawn(SpawnError), + DeadlineElapsed, + Cancelled, + WaitFailed { os_code: Option }, + StdinClosed, + StdinTooLarge, + StdinWriteFailed { os_code: Option }, + DrainFailed { stream: LogStream }, + WorkerFailed, +} + +impl fmt::Display for BoundedProcessError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidRequest(error) => write!(formatter, "invalid process request: {error}"), + Self::Spawn(error) => error.fmt(formatter), + Self::DeadlineElapsed => formatter.write_str("process deadline elapsed"), + Self::Cancelled => formatter.write_str("process was cancelled"), + Self::WaitFailed { os_code } => write_os_error(formatter, "wait failed", *os_code), + Self::StdinClosed => formatter.write_str("process stdin is closed"), + Self::StdinTooLarge => formatter.write_str("stdin write exceeds the configured bound"), + Self::StdinWriteFailed { os_code } => { + write_os_error(formatter, "stdin write failed", *os_code) + } + Self::DrainFailed { stream } => write!(formatter, "{} drain failed", stream.name()), + Self::WorkerFailed => formatter.write_str("process worker failed"), + } + } +} + +impl std::error::Error for BoundedProcessError {} + +fn write_os_error( + formatter: &mut fmt::Formatter<'_>, + label: &str, + os_code: Option, +) -> fmt::Result { + write!(formatter, "{label}")?; + if let Some(code) = os_code { + write!(formatter, " (os error {code})")?; + } + Ok(()) +} + +fn spawn_error(error: &std::io::Error) -> SpawnError { + let kind = match error.kind() { + std::io::ErrorKind::NotFound => SpawnErrorKind::NotFound, + std::io::ErrorKind::PermissionDenied => SpawnErrorKind::PermissionDenied, + std::io::ErrorKind::InvalidInput => SpawnErrorKind::InvalidInput, + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::ResourceBusy => { + SpawnErrorKind::ResourceExhausted + } + _ => SpawnErrorKind::Other, + }; + SpawnError { + kind, + os_code: error.raw_os_error(), + } +} + +/// Terminal status retained by a [`BoundedProcess`] after reaping. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ProcessStatus { + Exited { code: Option }, + Signaled { signal: i32 }, + Unknown, +} + +impl ProcessStatus { + fn from_exit_status(status: ExitStatus) -> Self { + #[cfg(unix)] + if let Some(signal) = status.signal() { + return Self::Signaled { signal }; + } + Self::Exited { + code: status.code(), + } + } + + pub fn is_success(self) -> bool { + matches!(self, Self::Exited { code: Some(0) }) + } + + pub fn exit_code(self) -> Option { + match self { + Self::Exited { code } => code, + Self::Signaled { .. } | Self::Unknown => None, + } + } + + pub fn signal(self) -> Option { + match self { + Self::Signaled { signal } => Some(signal), + Self::Exited { .. } | Self::Unknown => None, + } + } +} + +/// Which bounded output stream a log snapshot describes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LogStream { + Stdout, + Stderr, +} + +impl LogStream { + fn name(self) -> &'static str { + match self { + Self::Stdout => "stdout", + Self::Stderr => "stderr", + } + } +} + +/// A bounded byte snapshot with offsets into the complete stream. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LogSnapshot { + /// Retained bytes, at most the stream and total limits. + pub bytes: Vec, + /// Absolute offset of the first retained byte. + pub offset: u64, + /// Absolute offset immediately after all bytes observed so far. + pub next_offset: u64, + /// Whether any bytes from this stream were discarded. + pub truncated: bool, + /// Whether a requested offset preceded the retained range. + pub gap: bool, + /// Whether the reader reached EOF. + pub eof: bool, +} + +impl LogSnapshot { + pub fn len(&self) -> usize { + self.bytes.len() + } + + pub fn is_empty(&self) -> bool { + self.bytes.is_empty() + } + + pub fn as_bytes(&self) -> &[u8] { + &self.bytes + } + + pub fn data(&self) -> &[u8] { + self.as_bytes() + } + + pub fn start_offset(&self) -> u64 { + self.offset + } + + pub fn end_offset(&self) -> u64 { + self.next_offset + } +} + +struct RingLog { + limit: usize, + bytes: VecDeque, + next_offset: u64, + truncated: bool, + eof: bool, + oldest_seq: u64, +} + +impl RingLog { + fn new(limit: usize) -> Self { + Self { + limit, + bytes: VecDeque::with_capacity(limit.min(8192)), + next_offset: 0, + truncated: false, + eof: false, + oldest_seq: 0, + } + } + + fn append(&mut self, bytes: &[u8], seq_start: u64) { + let added = u64::try_from(bytes.len()).unwrap_or(u64::MAX); + self.next_offset = self.next_offset.saturating_add(added); + if self.bytes.is_empty() { + self.oldest_seq = seq_start; + } + self.bytes.extend(bytes.iter().copied()); + while self.bytes.len() > self.limit { + self.evict_one(); + } + } + + fn evict_one(&mut self) -> bool { + let removed = self.bytes.pop_front().is_some(); + if removed { + self.truncated = true; + self.oldest_seq = self.oldest_seq.saturating_add(1); + } + removed + } + + fn start_offset(&self) -> u64 { + self.next_offset + .saturating_sub(u64::try_from(self.bytes.len()).unwrap_or(u64::MAX)) + } + + fn snapshot_from(&self, requested_offset: Option) -> LogSnapshot { + let start = self.start_offset(); + let gap = requested_offset.is_some_and(|offset| offset < start); + let copy_from = requested_offset + .unwrap_or(start) + .max(start) + .min(self.next_offset); + let skip = usize::try_from(copy_from.saturating_sub(start)).unwrap_or(self.bytes.len()); + let bytes = self.bytes.iter().skip(skip).copied().collect(); + LogSnapshot { + bytes, + offset: copy_from, + next_offset: self.next_offset, + truncated: self.truncated, + gap, + eof: self.eof, + } + } +} + +struct LogStore { + stdout: RingLog, + stderr: RingLog, + total_limit: usize, + stdout_error: bool, + stderr_error: bool, + next_seq: u64, +} + +impl LogStore { + fn new(stdout_limit: usize, stderr_limit: usize, total_limit: usize) -> Self { + Self { + stdout: RingLog::new(stdout_limit), + stderr: RingLog::new(stderr_limit), + total_limit, + stdout_error: false, + stderr_error: false, + next_seq: 0, + } + } + + fn append(&mut self, stream: LogStream, bytes: &[u8]) { + let seq_start = self.next_seq; + self.next_seq = self + .next_seq + .saturating_add(u64::try_from(bytes.len()).unwrap_or(0)); + match stream { + LogStream::Stdout => self.stdout.append(bytes, seq_start), + LogStream::Stderr => self.stderr.append(bytes, seq_start), + } + while self.stdout.bytes.len() + self.stderr.bytes.len() > self.total_limit { + let evict_stdout = match (self.stdout.bytes.front(), self.stderr.bytes.front()) { + (Some(_), None) => true, + (None, Some(_)) => false, + (Some(_), Some(_)) => self.stdout.oldest_seq <= self.stderr.oldest_seq, + (None, None) => break, + }; + if evict_stdout { + self.stdout.evict_one(); + } else { + self.stderr.evict_one(); + } + } + } + + fn mark_eof(&mut self, stream: LogStream) { + match stream { + LogStream::Stdout => self.stdout.eof = true, + LogStream::Stderr => self.stderr.eof = true, + } + } + + fn mark_error(&mut self, stream: LogStream) { + match stream { + LogStream::Stdout => self.stdout_error = true, + LogStream::Stderr => self.stderr_error = true, + } + } + + fn snapshot(&self, stream: LogStream, requested_offset: Option) -> LogSnapshot { + match stream { + LogStream::Stdout => self.stdout.snapshot_from(requested_offset), + LogStream::Stderr => self.stderr.snapshot_from(requested_offset), + } + } + + fn has_error(&self, stream: LogStream) -> bool { + match stream { + LogStream::Stdout => self.stdout_error, + LogStream::Stderr => self.stderr_error, + } + } +} + +fn retryable_write(error: &std::io::Error) -> bool { + matches!( + error.kind(), + std::io::ErrorKind::WouldBlock + | std::io::ErrorKind::Interrupted + | std::io::ErrorKind::TimedOut + ) +} + +struct DrainFinish(Arc); + +impl Drop for DrainFinish { + fn drop(&mut self) { + self.0.signal(); + } +} + +struct StdinState { + writer: InterruptibleWriter, + #[cfg(windows)] + windows_write: super::windows_stdio::CancellableWrite, + closed: AtomicBool, + initial_payload: bool, + close_after_initial: bool, + initial_writer: Mutex>>, + initial_error: Mutex>, + cancellation: CancellationToken, + deadline: Instant, + done: Arc, +} + +#[cfg(not(windows))] +type StdinPipe = std::process::ChildStdin; +#[cfg(windows)] +type StdinPipe = super::windows_stdio::CancellableWrite; + +impl StdinState { + fn new( + writer: std::process::ChildStdin, + initial: Vec, + close_after_initial: bool, + cancellation: CancellationToken, + deadline: Instant, + ) -> Result, std::io::Error> { + #[cfg(unix)] + super::shared::set_pipe_nonblocking(&writer)?; + #[cfg(windows)] + let windows_write = super::windows_stdio::CancellableWrite::from_stdin(writer); + #[cfg(windows)] + let writer = windows_write.clone(); + #[cfg(not(windows))] + let writer = writer; + let initial_payload = !initial.is_empty(); + let done = DrainDone::new(); + let state = Arc::new(Self { + writer: InterruptibleWriter::new(writer), + #[cfg(windows)] + windows_write, + closed: AtomicBool::new(false), + initial_payload, + close_after_initial, + initial_writer: Mutex::new(None), + initial_error: Mutex::new(None), + cancellation, + deadline, + done, + }); + if !initial.is_empty() { + let worker_state = Arc::clone(&state); + let handle = thread::Builder::new() + .name("bounded-process-stdin".to_owned()) + .spawn(move || { + let _done = DrainFinish(Arc::clone(&worker_state.done)); + let result = worker_state.write_payload(&initial); + if worker_state.close_after_initial { + worker_state.force_close_writer(); + } + if let Err(error) = result + && !matches!(error, BoundedProcessError::StdinClosed) + { + *worker_state + .initial_error + .lock() + .unwrap_or_else(|error| error.into_inner()) = Some(error); + } + })?; + *state + .initial_writer + .lock() + .unwrap_or_else(|error| error.into_inner()) = Some(handle); + } else { + state.done.signal(); + } + Ok(state) + } + + fn force_close_writer(&self) { + self.writer.close(); + #[cfg(windows)] + self.windows_write.close(); + } + + fn has_initial_payload(&self) -> bool { + self.initial_payload + } + + fn write(&self, bytes: &[u8]) -> Result { + if bytes.len() > MAX_STDIN_WRITE_BYTES { + return Err(BoundedProcessError::StdinTooLarge); + } + self.write_payload(bytes)?; + Ok(bytes.len()) + } + + fn write_payload(&self, bytes: &[u8]) -> Result<(), BoundedProcessError> { + let mut written = 0; + while written < bytes.len() { + if self.closed.load(Ordering::Acquire) { + return Err(BoundedProcessError::StdinClosed); + } + if self.cancellation.is_cancelled() { + return Err(BoundedProcessError::Cancelled); + } + let now = Instant::now(); + if now >= self.deadline { + return Err(BoundedProcessError::DeadlineElapsed); + } + let result = self.writer.write_bytes(&bytes[written..]); + match result { + Ok(0) => { + return Err(BoundedProcessError::StdinWriteFailed { os_code: None }); + } + Ok(count) => written += count, + Err(error) if retryable_write(&error) => { + thread::sleep(self.deadline.saturating_duration_since(now).min(WAIT_SLICE)); + } + Err(error) => { + return Err(BoundedProcessError::StdinWriteFailed { + os_code: error.raw_os_error(), + }); + } + } + } + Ok(()) + } + + fn close(&self) -> Result<(), BoundedProcessError> { + self.closed.store(true, Ordering::Release); + self.force_close_writer(); + self.done.wait_until(Instant::now() + CLEANUP_GRACE); + let _ = self + .initial_writer + .lock() + .unwrap_or_else(|error| error.into_inner()) + .take(); + if let Some(error) = self + .initial_error + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clone() + { + return Err(error); + } + Ok(()) + } +} + +impl Drop for StdinState { + fn drop(&mut self) { + self.closed.store(true, Ordering::Release); + self.force_close_writer(); + self.done.wait_until(Instant::now() + CLEANUP_GRACE); + let _ = self + .initial_writer + .get_mut() + .unwrap_or_else(|error| error.into_inner()) + .take(); + } +} + +#[cfg(target_os = "linux")] +mod linux_pidfd { + use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; + + use super::ProcessStatus; + + pub(super) struct PidFd { + fd: OwnedFd, + } + + impl PidFd { + pub(super) fn open(pid: u32) -> std::io::Result { + let pid = libc::pid_t::try_from(pid).map_err(|_| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, "pid out of range") + })?; + // SAFETY: `pid` is a kernel pid we just spawned or observed; flags 0 + // request a new pidfd. On success the syscall returns a fresh fd + // exclusively owned by the caller. + let fd = unsafe { libc::syscall(libc::SYS_pidfd_open, pid, 0) }; + if fd < 0 { + return Err(std::io::Error::last_os_error()); + } + let fd = i32::try_from(fd).map_err(|_| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, "pidfd out of range") + })?; + Ok(Self { + // SAFETY: `fd` is a newly opened pidfd; from_raw_fd takes exclusive + // ownership and will close it on Drop. + fd: unsafe { OwnedFd::from_raw_fd(fd) }, + }) + } + + pub(super) fn send_signal(&self, signal: libc::c_int) -> std::io::Result<()> { + // SAFETY: `self.fd` is a live pidfd we exclusively own. A null + // siginfo with flags 0 sends `signal` to that process. + let rc = unsafe { + libc::syscall( + libc::SYS_pidfd_send_signal, + self.fd.as_raw_fd(), + signal, + std::ptr::null::(), + 0, + ) + }; + if rc < 0 { + Err(std::io::Error::last_os_error()) + } else { + Ok(()) + } + } + + pub(super) fn observe_exit_wnowait(&self) -> std::io::Result> { + super::waitid_wnowait(libc::P_PIDFD, self.fd.as_raw_fd() as libc::id_t) + } + } +} + +#[cfg(unix)] +fn waitid_wnowait( + idtype: libc::idtype_t, + id: libc::id_t, +) -> std::io::Result> { + // SAFETY: siginfo_t is a C union POD; zeroing is the documented + // pre-waitid initialization so kernel-filled fields can be distinguished. + let mut info: libc::siginfo_t = unsafe { std::mem::zeroed() }; + // SAFETY: `info` is a writable zeroed siginfo. `idtype`/`id` name a live + // pid or pidfd. WNOHANG|WNOWAIT observe without reaping. + let rc = unsafe { + libc::waitid( + idtype, + id, + &mut info, + libc::WEXITED | libc::WNOHANG | libc::WNOWAIT, + ) + }; + if rc < 0 { + return Err(std::io::Error::last_os_error()); + } + // SAFETY: waitid succeeded. si_pid is defined for WEXITED results; 0 means + // no child changed state. + let pid = unsafe { info.si_pid() }; + if pid <= 0 { + return Ok(None); + } + // SAFETY: si_pid > 0 so the kernel filled CLD_* status; si_status is the + // exit code or signal. + let status = unsafe { info.si_status() }; + Ok(Some(match info.si_code { + libc::CLD_EXITED => ProcessStatus::Exited { code: Some(status) }, + libc::CLD_KILLED | libc::CLD_DUMPED => ProcessStatus::Signaled { signal: status }, + _ => ProcessStatus::Unknown, + })) +} + +#[cfg(all(unix, not(target_os = "linux")))] +fn observe_pid_wnowait(pid: u32) -> std::io::Result> { + let pid = libc::pid_t::try_from(pid) + .map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidInput, "pid out of range"))?; + waitid_wnowait(libc::P_PID, pid as libc::id_t) +} + +#[cfg(windows)] +fn observe_handle_without_reaping(child: &Child) -> std::io::Result> { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Foundation::{HANDLE, WAIT_OBJECT_0, WAIT_TIMEOUT}; + use windows_sys::Win32::System::Threading::{GetExitCodeProcess, WaitForSingleObject}; + + let handle = child.as_raw_handle() as HANDLE; + let waited = unsafe { WaitForSingleObject(handle, 0) }; + match waited { + WAIT_OBJECT_0 => { + let mut code = 0u32; + if unsafe { GetExitCodeProcess(handle, &mut code) } == 0 { + return Err(std::io::Error::last_os_error()); + } + // WAIT_OBJECT_0 means the process is signaled/exited. 259 is a + // valid exit code (STILL_ACTIVE is only meaningful before wait). + Ok(Some(ProcessStatus::Exited { + code: Some(code as i32), + })) + } + WAIT_TIMEOUT => Ok(None), + _ => Err(std::io::Error::last_os_error()), + } +} + +fn retryable_read(error: &std::io::Error) -> bool { + retryable_write(error) +} + +struct DrainDone { + completed: AtomicBool, + lock: Mutex, + cv: Condvar, +} + +impl DrainDone { + fn new() -> Arc { + Arc::new(Self { + completed: AtomicBool::new(false), + lock: Mutex::new(false), + cv: Condvar::new(), + }) + } + + fn signal(&self) { + self.completed.store(true, Ordering::Release); + let mut done = self.lock.lock().unwrap_or_else(|error| error.into_inner()); + *done = true; + self.cv.notify_all(); + } + + fn is_completed(&self) -> bool { + self.completed.load(Ordering::Acquire) + } + + fn wait_until(&self, deadline: Instant) -> bool { + if self.is_completed() { + return true; + } + let mut done = self.lock.lock().unwrap_or_else(|error| error.into_inner()); + while !*done { + let now = Instant::now(); + if now >= deadline { + return false; + } + let (guard, timed) = self + .cv + .wait_timeout(done, deadline.saturating_duration_since(now)) + .unwrap_or_else(|error| error.into_inner()); + done = guard; + if timed.timed_out() && Instant::now() >= deadline { + return *done; + } + } + true + } +} + +struct InterruptibleReader { + inner: Mutex>, + closed: AtomicBool, +} + +impl InterruptibleReader { + fn new(reader: R) -> Arc { + Arc::new(Self { + inner: Mutex::new(Some(reader)), + closed: AtomicBool::new(false), + }) + } + + fn take_io(&self) -> Option { + if self.closed.load(Ordering::Acquire) { + return None; + } + self.inner + .lock() + .unwrap_or_else(|error| error.into_inner()) + .take() + } + + fn put_io(&self, io: R) { + if self.closed.load(Ordering::Acquire) { + return; + } + let mut guard = self.inner.lock().unwrap_or_else(|error| error.into_inner()); + if self.closed.load(Ordering::Acquire) { + return; + } + *guard = Some(io); + } + + fn read_bytes(&self, buffer: &mut [u8]) -> std::io::Result { + let Some(mut reader) = self.take_io() else { + return Ok(0); + }; + let result = reader.read(buffer); + self.put_io(reader); + result + } + + fn close(&self) { + self.closed.store(true, Ordering::Release); + let _ = self + .inner + .lock() + .unwrap_or_else(|error| error.into_inner()) + .take(); + } +} + +struct InterruptibleWriter { + inner: Mutex>, + closed: AtomicBool, +} + +impl InterruptibleWriter { + fn new(writer: W) -> Self { + Self { + inner: Mutex::new(Some(writer)), + closed: AtomicBool::new(false), + } + } + + fn take_io(&self) -> Option { + if self.closed.load(Ordering::Acquire) { + return None; + } + self.inner + .lock() + .unwrap_or_else(|error| error.into_inner()) + .take() + } + + fn put_io(&self, io: W) { + if self.closed.load(Ordering::Acquire) { + return; + } + let mut guard = self.inner.lock().unwrap_or_else(|error| error.into_inner()); + if self.closed.load(Ordering::Acquire) { + return; + } + *guard = Some(io); + } + + fn write_bytes(&self, buffer: &[u8]) -> std::io::Result { + let Some(mut writer) = self.take_io() else { + return Err(std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "stdin is closed", + )); + }; + let result = writer.write(buffer); + self.put_io(writer); + result + } + + fn close(&self) { + self.closed.store(true, Ordering::Release); + let _ = self + .inner + .lock() + .unwrap_or_else(|error| error.into_inner()) + .take(); + } +} + +struct StreamDrainer { + join: Option>, + done: Arc, + closer: Arc, +} + +impl StreamDrainer { + fn close(&self) { + (self.closer)(); + } +} + +fn spawn_drainer( + name: &'static str, + reader: R, + stream: LogStream, + logs: Arc>, + stop: Arc, + cancellation: CancellationToken, + deadline: Instant, +) -> Result { + let reader = InterruptibleReader::new(reader); + let done = DrainDone::new(); + let thread_reader = Arc::clone(&reader); + let thread_done = Arc::clone(&done); + let join = thread::Builder::new() + .name(name.to_owned()) + .spawn(move || { + let mut buffer = [0u8; 8192]; + let finish = |error: bool| { + let mut logs = logs.lock().unwrap_or_else(|error| error.into_inner()); + if error { + logs.mark_error(stream); + } + logs.mark_eof(stream); + }; + let mut stop_deadline = None; + loop { + if stop_deadline.is_none() + && (stop.load(Ordering::Acquire) + || cancellation.is_cancelled() + || Instant::now() >= deadline) + { + stop_deadline = Some(Instant::now() + CLEANUP_GRACE); + } + if let Some(stop_deadline) = stop_deadline + && Instant::now() >= stop_deadline + { + finish(false); + thread_done.signal(); + return; + } + match thread_reader.read_bytes(&mut buffer) { + Ok(0) => { + finish(false); + thread_done.signal(); + return; + } + Ok(read) => { + logs.lock() + .unwrap_or_else(|error| error.into_inner()) + .append(stream, &buffer[..read]); + } + Err(error) if retryable_read(&error) => { + if stop_deadline.is_some() { + finish(false); + thread_done.signal(); + return; + } + thread::sleep( + deadline + .saturating_duration_since(Instant::now()) + .min(WAIT_SLICE), + ); + } + Err(_) => { + finish(true); + thread_done.signal(); + return; + } + } + } + })?; + Ok(StreamDrainer { + join: Some(join), + done, + closer: Arc::new(move || reader.close()), + }) +} + +fn join_stream_drainers(drainers: &mut [StreamDrainer]) -> Result<(), BoundedProcessError> { + let wait_deadline = Instant::now() + CLEANUP_GRACE; + let mut result = Ok(()); + for drainer in drainers.iter_mut() { + if !drainer.done.wait_until(wait_deadline) { + drainer.close(); + if !drainer.done.wait_until(Instant::now() + CLEANUP_GRACE) && result.is_ok() { + result = Err(BoundedProcessError::WorkerFailed); + continue; + } + } + if drainer.done.is_completed() + && let Some(join) = drainer.join.take() + && join.join().is_err() + && result.is_ok() + { + result = Err(BoundedProcessError::WorkerFailed); + } + } + result +} + +fn wait_child_until( + child: &mut Child, + deadline: Instant, +) -> Result { + loop { + match child.try_wait() { + Ok(Some(status)) => return Ok(ProcessStatus::from_exit_status(status)), + Ok(None) => { + let now = Instant::now(); + if now >= deadline { + return Err(BoundedProcessError::DeadlineElapsed); + } + thread::sleep(deadline.saturating_duration_since(now).min(WAIT_SLICE)); + } + Err(error) => { + return Err(BoundedProcessError::WaitFailed { + os_code: error.raw_os_error(), + }); + } + } + } +} + +fn abort_spawned_child(mut child: Child, pid: u32) { + terminate_process_tree(pid); + let _ = child.kill(); + match wait_child_until(&mut child, Instant::now() + CLEANUP_GRACE) { + Ok(_) => {} + Err(_) => std::mem::forget(child), + } +} + +fn allocate_process_handle() -> ProcessHandle { + loop { + let id = NEXT_PROCESS_HANDLE.fetch_add(1, Ordering::Relaxed); + if id != 0 { + return ProcessHandle { id, generation: id }; + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum TreeCleanup { + Pending, + Cleaning, + Cleaned, +} + +struct ChildState { + child: Option, + terminal: Option, + reaping: bool, + tree_cleanup: TreeCleanup, +} + +#[cfg(all(test, unix))] +#[derive(Default)] +struct TreeCleanupTestHooks { + before_group_kill: Mutex>>, + on_wait_for_cleanup: Mutex>>, + on_reap: Mutex>>, + last_tree_kill_retained_zombie: AtomicBool, +} + +struct ProcessInner { + handle: ProcessHandle, + pid: u32, + #[cfg(windows)] + job: Option, + #[cfg(target_os = "linux")] + pidfd: Option, + deadline: Instant, + child: Mutex, + child_wake: Condvar, + logs: Arc>, + stdin: Arc, + drainers: Mutex>, + drainers_result: OnceLock>, + drain_stop: Arc, + cancellation: CancellationToken, + #[cfg(all(test, unix))] + test_hooks: TreeCleanupTestHooks, +} + +impl ProcessInner { + fn map_wait_error(error: std::io::Error) -> BoundedProcessError { + BoundedProcessError::WaitFailed { + os_code: error.raw_os_error(), + } + } + + fn observe_root_exit_locked( + &self, + child: Option<&Child>, + ) -> Result, BoundedProcessError> { + #[cfg(target_os = "linux")] + { + let _ = child; + let pidfd = self + .pidfd + .as_ref() + .ok_or(BoundedProcessError::WaitFailed { os_code: None })?; + pidfd.observe_exit_wnowait().map_err(Self::map_wait_error) + } + #[cfg(all(unix, not(target_os = "linux")))] + { + let _ = child; + observe_pid_wnowait(self.pid).map_err(Self::map_wait_error) + } + #[cfg(windows)] + { + let child = child.ok_or(BoundedProcessError::WaitFailed { os_code: None })?; + observe_handle_without_reaping(child).map_err(Self::map_wait_error) + } + #[cfg(not(any(unix, windows)))] + { + let _ = child; + Ok(None) + } + } + + fn lock_child(&self) -> MutexGuard<'_, ChildState> { + self.child.lock().unwrap_or_else(|error| error.into_inner()) + } + + fn wait_while_cleaning<'a>( + &'a self, + mut state: MutexGuard<'a, ChildState>, + ) -> MutexGuard<'a, ChildState> { + if state.tree_cleanup == TreeCleanup::Cleaning { + #[cfg(all(test, unix))] + self.run_on_wait_for_cleanup_hook(); + } + while state.tree_cleanup == TreeCleanup::Cleaning { + let remaining = self.deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + break; + } + let (guard, wait_result) = self + .child_wake + .wait_timeout(state, remaining) + .unwrap_or_else(|error| error.into_inner()); + state = guard; + if wait_result.timed_out() && Instant::now() >= self.deadline { + break; + } + } + state + } + + fn cleanup_tree_once(&self) { + { + let mut state = self.lock_child(); + loop { + match state.tree_cleanup { + TreeCleanup::Cleaned => return, + TreeCleanup::Cleaning => { + state = self.wait_while_cleaning(state); + if state.tree_cleanup != TreeCleanup::Cleaned { + return; + } + } + TreeCleanup::Pending => { + state.tree_cleanup = TreeCleanup::Cleaning; + break; + } + } + } + } + self.drain_stop.store(true, Ordering::Release); + #[cfg(all(test, unix))] + self.run_before_group_kill_hook(); + #[cfg(target_os = "linux")] + if let Some(pidfd) = &self.pidfd { + let _ = pidfd.send_signal(libc::SIGKILL); + } + #[cfg(windows)] + if let Some(job) = &self.job { + job.terminate(); + } + #[cfg(unix)] + { + #[cfg(test)] + { + record_tree_kill_identity(self.pid); + self.test_hooks + .last_tree_kill_retained_zombie + .store(proc_is_our_zombie(self.pid), Ordering::Release); + } + super::shared::terminate_process_group(self.pid); + } + let mut state = self.lock_child(); + if let Some(child) = state.child.as_mut() { + let _ = child.kill(); + } + state.tree_cleanup = TreeCleanup::Cleaned; + self.child_wake.notify_all(); + } + + fn harvest_if_exited(&self) -> Result, BoundedProcessError> { + { + let state = self.lock_child(); + if let Some(status) = state.terminal { + return Ok(Some(status)); + } + if state.reaping { + return Ok(None); + } + if self + .observe_root_exit_locked(state.child.as_ref())? + .is_none() + { + return Ok(None); + } + } + self.cleanup_tree_once(); + let mut state = self.lock_child(); + if let Some(status) = state.terminal { + return Ok(Some(status)); + } + if state.reaping { + return Ok(None); + } + state = self.wait_while_cleaning(state); + if state.tree_cleanup != TreeCleanup::Cleaned { + return Ok(None); + } + if let Some(status) = state.terminal { + return Ok(Some(status)); + } + if state.reaping { + return Ok(None); + } + #[cfg(all(test, unix))] + self.run_on_reap_hook(); + let reaped = match state.child.as_mut() { + Some(child) => child.try_wait().map_err(Self::map_wait_error)?, + None => return Ok(state.terminal), + }; + let Some(status) = reaped.map(ProcessStatus::from_exit_status) else { + return Ok(None); + }; + state.child.take(); + state.terminal = Some(status); + self.child_wake.notify_all(); + Ok(Some(status)) + } + + fn try_wait(&self) -> Result, BoundedProcessError> { + let status = self.harvest_if_exited()?; + if status.is_some() { + let _ = self.join_drainers(); + } + Ok(status) + } + + fn poll(&self) -> Result, BoundedProcessError> { + if self.cancellation.is_cancelled() { + self.kill_process_tree()?; + let _ = self.reap(); + return Err(BoundedProcessError::Cancelled); + } + if Instant::now() >= self.deadline { + self.kill_process_tree()?; + let _ = self.reap(); + return Err(BoundedProcessError::DeadlineElapsed); + } + if let Some(status) = self.try_wait()? { + return Ok(Some(status)); + } + Ok(None) + } + + fn wait_until_with_hook( + &self, + deadline: Instant, + is_cancelled: F, + ) -> Result + where + F: Fn() -> bool, + { + loop { + if self.cancellation.is_cancelled() || is_cancelled() { + self.kill_process_tree()?; + let _ = self.reap(); + return Err(BoundedProcessError::Cancelled); + } + let now = Instant::now(); + if now >= deadline { + self.kill_process_tree()?; + let _ = self.reap(); + return Err(BoundedProcessError::DeadlineElapsed); + } + if let Some(status) = self.try_wait()? { + self.reap()?; + return Ok(status); + } + thread::sleep(deadline.saturating_duration_since(now).min(WAIT_SLICE)); + } + } + + fn reap(&self) -> Result { + self.cleanup_tree_once(); + let status = loop { + { + let state = self.child.lock().unwrap_or_else(|error| error.into_inner()); + if let Some(status) = state.terminal { + break status; + } + if state.reaping { + let remaining = self.deadline.saturating_duration_since(Instant::now()); + let (guard, wait_result) = self + .child_wake + .wait_timeout(state, remaining.max(WAIT_SLICE)) + .unwrap_or_else(|error| error.into_inner()); + drop(guard); + if wait_result.timed_out() && Instant::now() >= self.deadline { + return Err(BoundedProcessError::DeadlineElapsed); + } + continue; + } + } + + if self.cancellation.is_cancelled() { + self.kill_process_tree()?; + } + if Instant::now() >= self.deadline { + self.kill_process_tree()?; + } + + if let Some(status) = self.harvest_if_exited()? { + break status; + } + + if Instant::now() >= self.deadline { + let child = { + let mut state = self.lock_child(); + if let Some(status) = state.terminal { + break status; + } + if state.tree_cleanup == TreeCleanup::Cleaning { + state = self.wait_while_cleaning(state); + } + if let Some(status) = state.terminal { + break status; + } + if state.tree_cleanup != TreeCleanup::Cleaned { + return Err(BoundedProcessError::DeadlineElapsed); + } + if state.reaping { + let remaining = self.deadline.saturating_duration_since(Instant::now()); + let (guard, wait_result) = self + .child_wake + .wait_timeout(state, remaining.max(WAIT_SLICE)) + .unwrap_or_else(|error| error.into_inner()); + drop(guard); + if wait_result.timed_out() && Instant::now() >= self.deadline { + return Err(BoundedProcessError::DeadlineElapsed); + } + continue; + } + state.reaping = true; + state.child.take() + }; + if let Some(mut child) = child { + let result = wait_child_until(&mut child, Instant::now() + CLEANUP_GRACE); + let mut state = self.child.lock().unwrap_or_else(|error| error.into_inner()); + state.reaping = false; + match result { + Ok(status) => { + state.terminal = Some(status); + self.child_wake.notify_all(); + break status; + } + Err(error) => { + state.child = Some(child); + self.child_wake.notify_all(); + return Err(error); + } + } + } + } + + thread::sleep( + self.deadline + .saturating_duration_since(Instant::now()) + .min(WAIT_SLICE), + ); + }; + + let stdin_result = self.stdin.close(); + self.drain_stop.store(true, Ordering::Release); + let drain_result = self.join_drainers(); + match (stdin_result, drain_result) { + (Err(error), _) => Err(error), + (Ok(()), Err(error)) => Err(error), + (Ok(()), Ok(())) => Ok(status), + } + } + + fn join_drainers(&self) -> Result<(), BoundedProcessError> { + self.drain_stop.store(true, Ordering::Release); + self.drainers_result + .get_or_init(|| { + let mut drainers = self + .drainers + .lock() + .unwrap_or_else(|error| error.into_inner()) + .drain(..) + .collect::>(); + let mut result = join_stream_drainers(&mut drainers); + if result.is_ok() { + for stream in [LogStream::Stdout, LogStream::Stderr] { + if self + .logs + .lock() + .unwrap_or_else(|error| error.into_inner()) + .has_error(stream) + { + result = Err(BoundedProcessError::DrainFailed { stream }); + break; + } + } + } + result + }) + .clone() + } + + fn kill_process_tree(&self) -> Result<(), BoundedProcessError> { + self.cleanup_tree_once(); + Ok(()) + } + + fn snapshot(&self, stream: LogStream, offset: Option) -> LogSnapshot { + self.logs + .lock() + .unwrap_or_else(|error| error.into_inner()) + .snapshot(stream, offset) + } + + #[cfg(all(test, unix))] + fn run_hook(hook: &Mutex>>) { + let hook = hook + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clone(); + if let Some(hook) = hook { + hook(); + } + } + + #[cfg(all(test, unix))] + fn run_before_group_kill_hook(&self) { + Self::run_hook(&self.test_hooks.before_group_kill); + } + + #[cfg(all(test, unix))] + fn run_on_wait_for_cleanup_hook(&self) { + Self::run_hook(&self.test_hooks.on_wait_for_cleanup); + } + + #[cfg(all(test, unix))] + fn run_on_reap_hook(&self) { + Self::run_hook(&self.test_hooks.on_reap); + } +} + +impl Drop for ProcessInner { + fn drop(&mut self) { + self.cancellation.cancel(); + self.drain_stop.store(true, Ordering::Release); + let _ = self.kill_process_tree(); + let _ = self.stdin.close(); + let _ = self.reap(); + let state = self + .child + .get_mut() + .unwrap_or_else(|error| error.into_inner()); + if let Some(mut child) = state.child.take() { + match child.try_wait() { + Ok(Some(_)) => {} + _ => std::mem::forget(child), + } + } + let _ = self.join_drainers(); + } +} + +/// Stable opaque process identity containing a non-reused generation. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ProcessHandle { + id: u64, + generation: u64, +} + +impl ProcessHandle { + pub fn id(self) -> u64 { + self.id + } + + pub fn generation(self) -> u64 { + self.generation + } +} + +#[cfg_attr(not(test), allow(dead_code))] +pub(crate) fn run_job_attach_and_resume( + attach: A, + resume: R, + terminate_on_failure: T, +) -> std::io::Result<()> +where + A: FnOnce() -> std::io::Result<()>, + R: FnOnce() -> std::io::Result<()>, + T: FnOnce(), +{ + if let Err(error) = attach() { + terminate_on_failure(); + return Err(error); + } + if let Err(error) = resume() { + terminate_on_failure(); + return Err(error); + } + Ok(()) +} + +fn unbounded_deadline() -> Instant { + Instant::now() + Duration::from_secs(365 * 24 * 60 * 60) +} + +/// A background-capable bounded process object. +pub struct BoundedProcess { + handle: BoundedProcessHandle, +} + +impl Deref for BoundedProcess { + type Target = BoundedProcessHandle; + fn deref(&self) -> &BoundedProcessHandle { + &self.handle + } +} + +impl fmt::Debug for BoundedProcess { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("BoundedProcess") + .field("handle", &self.handle.process.handle) + .field("pid", &self.handle.process.pid) + .finish_non_exhaustive() + } +} + +impl BoundedProcess { + /// Spawns a process with native argv semantics and bounded piped stdio. + pub fn spawn(request: BoundedProcessRequest) -> Result { + Self::spawn_internal(request, false) + } + + fn spawn_for_exec(request: BoundedProcessRequest) -> Result { + Self::spawn_internal(request, true) + } + + fn spawn_internal( + request: BoundedProcessRequest, + close_after_initial: bool, + ) -> Result { + request + .validate() + .map_err(BoundedProcessError::InvalidRequest)?; + let now = Instant::now(); + let deadline = request.effective_deadline(now)?; + if request + .cancellation_token + .as_ref() + .is_some_and(CancellationToken::is_cancelled) + { + return Err(BoundedProcessError::Cancelled); + } + + let confined_cwd = request.confined_cwd.clone(); + let mut command = std::process::Command::new(&request.argv[0]); + command.args(&request.argv[1..]); + if let Some(directory) = &confined_cwd { + #[cfg(unix)] + { + let fd = directory.as_raw_fd(); + // SAFETY: `fchdir` is async-signal-safe. The retained directory + // descriptor stays alive in `confined_cwd` until `spawn` returns, + // and close-on-exec remains set so the child does not inherit it + // across `exec`. + unsafe { + command.pre_exec(move || { + if libc::fchdir(fd) != 0 { + Err(std::io::Error::last_os_error()) + } else { + Ok(()) + } + }); + } + } + #[cfg(not(unix))] + { + let _ = directory; + return Err(BoundedProcessError::InvalidRequest( + ValidationError::ConfinedCwdUnsupported, + )); + } + } else { + let cwd = + request + .resolved_cwd() + .cloned() + .ok_or(BoundedProcessError::InvalidRequest( + ValidationError::CwdRequired, + ))?; + command.current_dir(cwd); + } + command.env_clear(); + command.envs(&request.env); + command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + #[cfg(unix)] + command.process_group(0); + #[cfg(windows)] + command.creation_flags(windows_sys::Win32::System::Threading::CREATE_SUSPENDED); + + let mut child = command + .spawn() + .map_err(|error| BoundedProcessError::Spawn(spawn_error(&error)))?; + drop(confined_cwd); + let pid = child.id(); + #[cfg(target_os = "linux")] + let pidfd = match linux_pidfd::PidFd::open(pid) { + Ok(pidfd) => Some(pidfd), + Err(error) => { + abort_spawned_child(child, pid); + return Err(BoundedProcessError::Spawn(spawn_error(&error))); + } + }; + #[cfg(windows)] + let job = match ProcessJob::attach_and_resume(&child) { + Ok(job) => Some(job), + Err(error) => { + abort_spawned_child(child, pid); + return Err(BoundedProcessError::Spawn(spawn_error(&error))); + } + }; + let stdin = match child.stdin.take() { + Some(stdin) => stdin, + None => { + abort_spawned_child(child, pid); + return Err(BoundedProcessError::Spawn(SpawnError { + kind: SpawnErrorKind::Other, + os_code: None, + })); + } + }; + let stdout = match child.stdout.take() { + Some(stdout) => stdout, + None => { + abort_spawned_child(child, pid); + return Err(BoundedProcessError::Spawn(SpawnError { + kind: SpawnErrorKind::Other, + os_code: None, + })); + } + }; + let stderr = match child.stderr.take() { + Some(stderr) => stderr, + None => { + abort_spawned_child(child, pid); + return Err(BoundedProcessError::Spawn(SpawnError { + kind: SpawnErrorKind::Other, + os_code: None, + })); + } + }; + #[cfg(unix)] + if let Err(error) = super::shared::set_pipe_nonblocking(&stdout) { + abort_spawned_child(child, pid); + return Err(BoundedProcessError::Spawn(spawn_error(&error))); + } + #[cfg(unix)] + if let Err(error) = super::shared::set_pipe_nonblocking(&stderr) { + abort_spawned_child(child, pid); + return Err(BoundedProcessError::Spawn(spawn_error(&error))); + } + #[cfg(windows)] + let stdout = match super::windows_stdio::CancellableRead::from_stdout(stdout) { + Ok(stdout) => stdout, + Err(error) => { + abort_spawned_child(child, pid); + return Err(BoundedProcessError::Spawn(spawn_error(&error))); + } + }; + #[cfg(windows)] + let stderr = match super::windows_stdio::CancellableRead::from_stderr(stderr) { + Ok(stderr) => stderr, + Err(error) => { + abort_spawned_child(child, pid); + return Err(BoundedProcessError::Spawn(spawn_error(&error))); + } + }; + #[cfg(not(any(unix, windows)))] + { + abort_spawned_child(child, pid); + return Err(BoundedProcessError::Spawn(SpawnError { + kind: SpawnErrorKind::Other, + os_code: None, + })); + } + + let cancellation = request.cancellation_token.unwrap_or_default(); + let drain_stop = Arc::new(AtomicBool::new(false)); + let stdin_state = match StdinState::new( + stdin, + request.stdin, + close_after_initial, + cancellation.clone(), + deadline, + ) { + Ok(state) => state, + Err(error) => { + abort_spawned_child(child, pid); + return Err(BoundedProcessError::Spawn(spawn_error(&error))); + } + }; + let logs = Arc::new(Mutex::new(LogStore::new( + request.stdout_limit, + request.stderr_limit, + request.total_limit, + ))); + let mut drainers = Vec::with_capacity(2); + match spawn_drainer( + "bounded-process-stdout", + stdout, + LogStream::Stdout, + Arc::clone(&logs), + Arc::clone(&drain_stop), + cancellation.clone(), + deadline, + ) { + Ok(drainer) => drainers.push(drainer), + Err(error) => { + abort_spawned_child(child, pid); + let _ = stdin_state.close(); + return Err(BoundedProcessError::Spawn(spawn_error(&error))); + } + } + match spawn_drainer( + "bounded-process-stderr", + stderr, + LogStream::Stderr, + Arc::clone(&logs), + Arc::clone(&drain_stop), + cancellation.clone(), + deadline, + ) { + Ok(drainer) => drainers.push(drainer), + Err(error) => { + drain_stop.store(true, Ordering::Release); + abort_spawned_child(child, pid); + let _ = stdin_state.close(); + let _ = join_stream_drainers(&mut drainers); + return Err(BoundedProcessError::Spawn(spawn_error(&error))); + } + } + + let inner = Arc::new(ProcessInner { + handle: allocate_process_handle(), + pid, + #[cfg(windows)] + job, + #[cfg(target_os = "linux")] + pidfd, + deadline, + child: Mutex::new(ChildState { + child: Some(child), + terminal: None, + reaping: false, + tree_cleanup: TreeCleanup::Pending, + }), + child_wake: Condvar::new(), + logs, + stdin: stdin_state, + drainers: Mutex::new(drainers), + drainers_result: OnceLock::new(), + drain_stop, + cancellation, + #[cfg(all(test, unix))] + test_hooks: TreeCleanupTestHooks::default(), + }); + Ok(Self { + handle: BoundedProcessHandle { process: inner }, + }) + } + + fn inner(&self) -> &ProcessInner { + &self.handle.process + } +} + +/// A cloneable lifecycle reference that keeps a background process alive. +#[derive(Clone)] +pub struct BoundedProcessHandle { + process: Arc, +} + +impl fmt::Debug for BoundedProcessHandle { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("BoundedProcessHandle") + .field("handle", &self.process.handle) + .finish_non_exhaustive() + } +} + +impl BoundedProcess { + pub fn lifecycle_handle(&self) -> BoundedProcessHandle { + self.handle.clone() + } +} + +impl BoundedProcessHandle { + pub fn handle(&self) -> ProcessHandle { + self.process.handle + } + + pub fn pid(&self) -> u32 { + self.process.pid + } + + pub fn deadline(&self) -> Instant { + self.process.deadline + } + + pub fn try_wait(&self) -> Result, BoundedProcessError> { + self.process.try_wait() + } + + pub fn poll(&self) -> Result, BoundedProcessError> { + self.process.poll() + } + + pub fn wait_until(&self, deadline: Instant) -> Result { + self.process + .wait_until_with_hook(deadline.min(self.process.deadline), || false) + } + + pub fn wait(&self, deadline: Option) -> Result { + match deadline { + Some(deadline) => self.wait_until(deadline), + None => self.wait_until(self.process.deadline), + } + } + + pub fn wait_forever(&self) -> Result { + // No caller deadline: wait until the child exits or is cancelled. + self.process + .wait_until_with_hook(unbounded_deadline(), || false) + } + + pub fn reap(&self) -> Result { + self.process.reap() + } + + pub fn terminal_status(&self) -> Option { + self.process + .child + .lock() + .unwrap_or_else(|error| error.into_inner()) + .terminal + } + + pub fn stdout_snapshot(&self) -> LogSnapshot { + self.process.snapshot(LogStream::Stdout, None) + } + + pub fn stderr_snapshot(&self) -> LogSnapshot { + self.process.snapshot(LogStream::Stderr, None) + } + + pub fn stdout_snapshot_from(&self, offset: u64) -> LogSnapshot { + self.process.snapshot(LogStream::Stdout, Some(offset)) + } + + pub fn stderr_snapshot_from(&self, offset: u64) -> LogSnapshot { + self.process.snapshot(LogStream::Stderr, Some(offset)) + } + + pub fn write_stdin(&self, bytes: &[u8]) -> Result { + self.process.stdin.write(bytes) + } + + pub fn close_stdin(&self) -> Result<(), BoundedProcessError> { + self.process.stdin.close() + } + + pub fn kill_process_tree(&self) -> Result<(), BoundedProcessError> { + self.process.kill_process_tree() + } + + pub fn cancel(&self) { + self.process.cancellation.cancel(); + let _ = self.process.kill_process_tree(); + } + + pub fn shutdown(&self) -> Result<(), BoundedProcessError> { + self.cancel(); + let close_result = self.close_stdin(); + let reap_result = self.reap(); + match (close_result, reap_result) { + (_, Err(error)) => Err(error), + (Err(error), Ok(_)) => Err(error), + (Ok(()), Ok(_)) => Ok(()), + } + } +} + +/// Bounded foreground execution result. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BoundedExecOutput { + pub status: ProcessStatus, + pub stdout: Vec, + pub stderr: Vec, + pub stdout_offset: u64, + pub stdout_next_offset: u64, + pub stdout_truncated: bool, + pub stdout_gap: bool, + pub stderr_offset: u64, + pub stderr_next_offset: u64, + pub stderr_truncated: bool, + pub stderr_gap: bool, +} + +/// Foreground failures preserve bounded output for timeout and cancellation. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum BoundedExecError { + Spawn(BoundedProcessError), + TimedOut(BoundedExecOutput), + Cancelled(BoundedExecOutput), + Failed(BoundedProcessError), +} + +impl fmt::Display for BoundedExecError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Spawn(error) => write!(formatter, "process spawn error: {error}"), + Self::TimedOut(_) => formatter.write_str("process timed out"), + Self::Cancelled(_) => formatter.write_str("process was cancelled"), + Self::Failed(error) => error.fmt(formatter), + } + } +} + +impl std::error::Error for BoundedExecError {} + +fn make_exec_output(process: &BoundedProcess, status: ProcessStatus) -> BoundedExecOutput { + let stdout = process.stdout_snapshot(); + let stderr = process.stderr_snapshot(); + BoundedExecOutput { + status, + stdout: stdout.bytes, + stderr: stderr.bytes, + stdout_offset: stdout.offset, + stdout_next_offset: stdout.next_offset, + stdout_truncated: stdout.truncated, + stdout_gap: stdout.gap, + stderr_offset: stderr.offset, + stderr_next_offset: stderr.next_offset, + stderr_truncated: stderr.truncated, + stderr_gap: stderr.gap, + } +} + +fn empty_exec_output() -> BoundedExecOutput { + BoundedExecOutput { + status: ProcessStatus::Unknown, + stdout: Vec::new(), + stderr: Vec::new(), + stdout_offset: 0, + stdout_next_offset: 0, + stdout_truncated: false, + stdout_gap: false, + stderr_offset: 0, + stderr_next_offset: 0, + stderr_truncated: false, + stderr_gap: false, + } +} + +fn exec_bounded_with_cancel_hook( + request: BoundedProcessRequest, + is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + let process = match BoundedProcess::spawn_for_exec(request) { + Ok(process) => process, + Err(BoundedProcessError::Cancelled) => { + return Err(BoundedExecError::Cancelled(empty_exec_output())); + } + Err(BoundedProcessError::DeadlineElapsed) => { + return Err(BoundedExecError::TimedOut(empty_exec_output())); + } + Err(error) => return Err(BoundedExecError::Spawn(error)), + }; + if !process.inner().stdin.has_initial_payload() + && let Err(error) = process.close_stdin() + { + return Err(BoundedExecError::Failed(error)); + } + let status = match process + .inner() + .wait_until_with_hook(process.deadline(), is_cancelled) + { + Ok(status) => status, + Err(BoundedProcessError::DeadlineElapsed) => { + let status = process.terminal_status().unwrap_or(ProcessStatus::Unknown); + return Err(BoundedExecError::TimedOut(make_exec_output( + &process, status, + ))); + } + Err(BoundedProcessError::Cancelled) => { + let status = process.terminal_status().unwrap_or(ProcessStatus::Unknown); + return Err(BoundedExecError::Cancelled(make_exec_output( + &process, status, + ))); + } + Err(error) => return Err(BoundedExecError::Failed(error)), + }; + if let Err(error) = process.reap() { + return Err(BoundedExecError::Failed(error)); + } + Ok(make_exec_output(&process, status)) +} + +/// Executes a bounded request in the foreground without invoking a shell. +pub fn exec_bounded(request: BoundedProcessRequest) -> Result { + exec_bounded_with_cancel_hook(request, || false) +} + +/// Executes a request while an embedding-owned cancellation hook is polled. +pub(crate) fn exec_bounded_with_cancel_hook_for_host( + request: BoundedProcessRequest, + is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + exec_bounded_with_cancel_hook(request, is_cancelled) +} + +fn terminate_process_tree(process_id: u32) { + #[cfg(all(test, unix))] + record_tree_kill_identity(process_id); + super::shared::terminate_process_group(process_id); +} + +#[cfg(all(test, unix))] +fn proc_is_our_zombie(pid: u32) -> bool { + let text = match std::fs::read_to_string(format!("/proc/{pid}/stat")) { + Ok(text) => text, + Err(_) => return false, + }; + let Some(close) = text.rfind(')') else { + return false; + }; + let mut fields = text[close + 1..].split_whitespace(); + let Some(state) = fields.next() else { + return false; + }; + let Some(ppid) = fields.next().and_then(|value| value.parse::().ok()) else { + return false; + }; + state == "Z" && ppid == std::process::id() +} + +#[cfg(all(test, unix))] +fn record_tree_kill_identity(pid: u32) { + LAST_TREE_KILL_RETAINED_ZOMBIE.with(|flag| flag.set(proc_is_our_zombie(pid))); +} + +#[cfg(all(test, unix))] +thread_local! { + static LAST_TREE_KILL_RETAINED_ZOMBIE: Cell = const { Cell::new(false) }; +} + +#[cfg(all(test, unix))] +pub struct TreeKillZombieProbe { + _private: (), +} + +#[cfg(all(test, unix))] +impl TreeKillZombieProbe { + pub fn install() -> Self { + LAST_TREE_KILL_RETAINED_ZOMBIE.with(|flag| flag.set(false)); + Self { _private: () } + } + + pub fn retained(&self) -> bool { + LAST_TREE_KILL_RETAINED_ZOMBIE.with(|flag| flag.get()) + } +} + +#[cfg(all(test, unix))] +impl Drop for TreeKillZombieProbe { + fn drop(&mut self) { + LAST_TREE_KILL_RETAINED_ZOMBIE.with(|flag| flag.set(false)); + } +} diff --git a/src/capabilities/vm_io/confined_fs.rs b/src/capabilities/vm_io/confined_fs.rs new file mode 100644 index 0000000..1ba7a28 --- /dev/null +++ b/src/capabilities/vm_io/confined_fs.rs @@ -0,0 +1,3076 @@ +//! Vendored from pd-vm `f9ca4143f8ba2f486e270347504c49f5ea846097` after the +//! frozen core SHA dropped the public confined-FS / bounded-process surface. +//! Guest ABI is unchanged; these types are agent capability backends only. + +//! Root-confined filesystem capability for host integrations. +//! +//! [`ConfinedFsRoot`] retains an operating-system directory handle and resolves +//! every later path relative to that handle. Relative paths are validated +//! before they reach the operating system. On Unix, Linux uses `openat2` with +//! `RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS | RESOLVE_NO_SYMLINKS` when the +//! kernel provides it; the fallback walks each component with `openat`, +//! `O_DIRECTORY | O_NOFOLLOW`, and never canonicalizes a path before opening +//! it. The root handle is never reopened through a path, including through +//! `/proc/self/fd`. +//! +//! Regular files with more than one hard link are rejected. This deliberately +//! conservative policy prevents a capability path from reaching an inode that +//! also has an unrelated directory entry. Atomic replacement is a Linux-only +//! `renameat2` capability (`RENAME_NOREPLACE` / identity-checked +//! `RENAME_EXCHANGE`). Other Unix targets fail closed with +//! [`ConfinedFsErrorKind::UnsupportedPlatform`] rather than creating a +//! temporary that can never be published. Publication returns a +//! [`ConfinedPublication`] once the destination name contains the retained +//! inode; parent-directory durability and staging cleanup are recorded on that +//! outcome instead of being reported as pre-publication failures. +//! +//! Windows and targets without a Unix descriptor API fail closed with +//! [`ConfinedFsErrorKind::UnsupportedPlatform`]. Reparse-point-safe handle +//! operations are not silently emulated by path-based calls. + +use std::ffi::{OsStr, OsString}; +use std::fmt; +use std::path::Path; + +#[cfg(all(unix, test))] +use std::cell::Cell; +#[cfg(unix)] +use std::ffi::{CStr, CString}; +#[cfg(unix)] +use std::fs::File; +#[cfg(unix)] +use std::io::{self, Read, Write}; +#[cfg(unix)] +use std::os::fd::{AsRawFd, FromRawFd, IntoRawFd, OwnedFd, RawFd}; +#[cfg(unix)] +use std::os::unix::ffi::{OsStrExt, OsStringExt}; +#[cfg(unix)] +use std::sync::Arc; +#[cfg(unix)] +use std::sync::atomic::{AtomicU64, Ordering}; +#[cfg(unix)] +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Maximum accepted relative path length in bytes. +pub const MAX_PATH_BYTES: usize = 4096; +/// Maximum accepted single-component length in bytes. +pub const MAX_COMPONENT_BYTES: usize = 255; +/// Maximum temporary-file prefix length, leaving room for a generated suffix. +pub const MAX_TEMP_PREFIX_BYTES: usize = 192; +/// Hard upper bound for a single read budget. +pub const MAX_READ_BYTES: usize = 64 * 1024 * 1024; +/// Hard upper bound for a single write budget. +pub const MAX_WRITE_BYTES: usize = 64 * 1024 * 1024; +/// Hard upper bound for directory enumeration entries. +pub const MAX_ENUM_ENTRIES: usize = 1_000_000; +/// Hard upper bound for temporary-file name attempts. +pub const MAX_TEMP_ATTEMPTS: u32 = 128; + +#[cfg(all(unix, test))] +thread_local! { + static TEST_PARTIAL_WRITE_FAIL_AFTER: Cell = const { Cell::new(u64::MAX) }; +} + +#[cfg(all(unix, test))] +struct PartialWriteFailGuard { + previous: u64, +} + +#[cfg(all(unix, test))] +impl PartialWriteFailGuard { + fn new(fail_after: u64) -> Self { + let previous = TEST_PARTIAL_WRITE_FAIL_AFTER.with(|slot| slot.replace(fail_after)); + Self { previous } + } +} + +#[cfg(all(unix, test))] +impl Drop for PartialWriteFailGuard { + fn drop(&mut self) { + TEST_PARTIAL_WRITE_FAIL_AFTER.with(|slot| slot.set(self.previous)); + } +} + +/// Stable categories returned by the confined filesystem API. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ConfinedFsErrorKind { + /// The operation received a path or configuration that is not permitted. + InvalidPath, + /// The path was empty where a file path was required. + EmptyPath, + /// The path is absolute or has a host-specific root. + AbsolutePath, + /// The path contains a parent traversal component. + ParentTraversal, + /// The path contains an embedded NUL byte. + NulByte, + /// The path exceeds the configured hard bound. + PathTooLong, + /// A path component exceeds the configured hard bound. + ComponentTooLong, + /// A platform separator or drive-prefix character was supplied. + InvalidSeparator, + /// A path prefix such as a drive or UNC prefix was supplied. + PathPrefix, + /// The requested operation is unavailable on this target. + UnsupportedPlatform, + /// The retained root or a requested entry could not be found. + NotFound, + /// The operating system denied the operation. + PermissionDenied, + /// A symlink or reparse-like indirection was encountered. + SymlinkDenied, + /// A regular file has more than one hard link. + HardlinkDenied, + /// An entry is not the type required by the operation. + WrongType, + /// A bounded operation would exceed its byte or entry budget. + BudgetExceeded, + /// The bounded temporary-name retry budget was exhausted. + TempCollision, + /// A checked directory entry changed before the operation completed. + RaceDetected, + /// A destination or other entry already exists where it cannot be used. + AlreadyExists, + /// A temporary file was used after it was published or cleaned up. + TempCompleted, + /// The supplied limits are not valid. + InvalidConfiguration, + /// A temporary file belongs to a different retained root capability. + CapabilityMismatch, + /// The content is not valid for the requested representation. + InvalidData, + /// An operating-system error did not fit a more specific category. + Io, +} + +impl ConfinedFsErrorKind { + /// Returns the stable machine-readable spelling of this category. + pub const fn as_str(self) -> &'static str { + match self { + Self::InvalidPath => "invalid_path", + Self::EmptyPath => "empty_path", + Self::AbsolutePath => "absolute_path", + Self::ParentTraversal => "parent_traversal", + Self::NulByte => "nul_byte", + Self::PathTooLong => "path_too_long", + Self::ComponentTooLong => "component_too_long", + Self::InvalidSeparator => "invalid_separator", + Self::PathPrefix => "path_prefix", + Self::UnsupportedPlatform => "unsupported_platform", + Self::NotFound => "not_found", + Self::PermissionDenied => "permission_denied", + Self::SymlinkDenied => "symlink_denied", + Self::HardlinkDenied => "hardlink_denied", + Self::WrongType => "wrong_type", + Self::BudgetExceeded => "budget_exceeded", + Self::TempCollision => "temp_collision", + Self::RaceDetected => "race_detected", + Self::AlreadyExists => "already_exists", + Self::TempCompleted => "temp_completed", + Self::InvalidConfiguration => "invalid_configuration", + Self::CapabilityMismatch => "capability_mismatch", + Self::InvalidData => "invalid_data", + Self::Io => "io", + } + } +} + +/// Whether this target can atomically publish a confined temporary file. +/// +/// Atomic publication requires Linux `renameat2`. Other Unix descriptor APIs +/// can open and read through a retained root, but they cannot complete the +/// publication protocol implemented here. +pub const fn publication_supported() -> bool { + cfg!(target_os = "linux") +} + +/// Identity and type of a directory entry observed after a publication race. +/// +/// Recorded on [`ConfinedPublicationState::Indeterminate`] so a caller can +/// inspect the destination and staging names without treating the race as a +/// successful publication. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ConfinedObservedIdentity { + file_type: ConfinedFileType, + device: u64, + inode: u64, +} + +impl ConfinedObservedIdentity { + #[cfg_attr(not(target_os = "linux"), allow(dead_code))] + const fn new(file_type: ConfinedFileType, device: u64, inode: u64) -> Self { + Self { + file_type, + device, + inode, + } + } + + /// Returns the observed entry type. + pub const fn file_type(self) -> ConfinedFileType { + self.file_type + } + + /// Returns the observed device identity. + pub const fn device(self) -> u64 { + self.device + } + + /// Returns the observed inode identity. + pub const fn inode(self) -> u64 { + self.inode + } +} + +/// Confirmed publication of a retained temporary inode to a destination name. +/// +/// This value is returned only after the destination directory entry contains +/// the retained inode. Parent-directory `fsync` and staging-name cleanup are +/// recorded separately so a durability or cleanup issue cannot be mistaken for +/// a pre-publication failure. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ConfinedPublication { + durable: bool, + staging_cleaned: bool, +} + +impl ConfinedPublication { + #[cfg_attr(not(target_os = "linux"), allow(dead_code))] + const fn new(durable: bool, staging_cleaned: bool) -> Self { + Self { + durable, + staging_cleaned, + } + } + + /// Destination contains the retained inode. + pub const fn is_published(self) -> bool { + true + } + + /// Parent directory contents were synchronized after publication. + pub const fn is_durable(self) -> bool { + self.durable + } + + /// The same-directory staging name was unlinked after publication. + pub const fn staging_cleaned(self) -> bool { + self.staging_cleaned + } + + /// Returns the corresponding publication state. + pub const fn state(self) -> ConfinedPublicationState { + ConfinedPublicationState::Published { + durable: self.durable, + staging_cleaned: self.staging_cleaned, + } + } +} + +/// Publication state carried by a replace outcome or error. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ConfinedPublicationState { + /// The destination name does not contain the retained inode. + NotPublished, + /// The destination name contains the retained inode. + Published { + /// Parent directory `fsync` completed after publication. + durable: bool, + /// Same-directory staging cleanup completed after publication. + staging_cleaned: bool, + }, + /// Destination could not be classified as the retained inode or as the + /// restored directory. Observed identities are recorded when available. + Indeterminate { + /// Observed destination identity, if the entry could be read. + destination: Option, + /// Observed staging identity, if the entry could be read. + staging: Option, + }, +} + +impl ConfinedPublicationState { + /// Returns whether the destination contains the retained inode. + pub const fn is_published(self) -> bool { + matches!(self, Self::Published { .. }) + } + + /// Returns whether parent-directory durability completed after publication. + pub const fn is_durable(self) -> bool { + matches!(self, Self::Published { durable: true, .. }) + } + + /// Returns whether staging cleanup completed after publication. + pub const fn staging_cleaned(self) -> bool { + matches!( + self, + Self::Published { + staging_cleaned: true, + .. + } + ) + } + + /// Returns whether publication raced into an unclassified destination. + pub const fn is_indeterminate(self) -> bool { + matches!(self, Self::Indeterminate { .. }) + } +} + +/// Typed, bounded error from a root-confined filesystem operation. +/// +/// The error intentionally contains no root path or caller-supplied path. +/// [`Self::raw_os_error`] exposes only a numeric operating-system code when +/// one exists. [`Self::publication_state`] distinguishes unpublished failures +/// from post-publication issues. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ConfinedFsError { + kind: ConfinedFsErrorKind, + operation: &'static str, + message: &'static str, + limit: Option, + value: Option, + raw_os_error: Option, + publication: ConfinedPublicationState, +} + +impl ConfinedFsError { + fn new(kind: ConfinedFsErrorKind, operation: &'static str, message: &'static str) -> Self { + Self { + kind, + operation, + message, + limit: None, + value: None, + raw_os_error: None, + publication: ConfinedPublicationState::NotPublished, + } + } + + #[cfg(unix)] + fn os(operation: &'static str, error: &io::Error) -> Self { + let raw_os_error = error.raw_os_error(); + let kind = classify_os_error(raw_os_error); + Self { + kind, + operation, + message: "operating-system operation failed", + limit: None, + value: None, + raw_os_error, + publication: ConfinedPublicationState::NotPublished, + } + } + + #[cfg(unix)] + fn budget(operation: &'static str, message: &'static str, limit: usize, value: usize) -> Self { + Self { + kind: ConfinedFsErrorKind::BudgetExceeded, + operation, + message, + limit: Some(limit), + value: Some(value), + raw_os_error: None, + publication: ConfinedPublicationState::NotPublished, + } + } + + fn invalid_configuration(message: &'static str) -> Self { + Self::new( + ConfinedFsErrorKind::InvalidConfiguration, + "fs::configure", + message, + ) + } + + #[cfg_attr(not(target_os = "linux"), allow(dead_code))] + fn with_publication(mut self, publication: ConfinedPublicationState) -> Self { + self.publication = publication; + self + } + + /// Returns the stable error category. + pub fn kind(&self) -> ConfinedFsErrorKind { + self.kind + } + + /// Returns the stable operation label. + pub fn operation(&self) -> &'static str { + self.operation + } + + /// Returns a bounded, path-free message. + pub fn message(&self) -> &'static str { + self.message + } + + /// Returns the configured bound involved in the error, if applicable. + pub fn limit(&self) -> Option { + self.limit + } + + /// Returns the observed value involved in the error, if applicable. + pub fn value(&self) -> Option { + self.value + } + + /// Returns the numeric operating-system error, if one was available. + pub fn raw_os_error(&self) -> Option { + self.raw_os_error + } + + /// Returns whether the destination was published when this error was + /// produced. + pub fn publication_state(&self) -> ConfinedPublicationState { + self.publication + } +} + +impl fmt::Display for ConfinedFsError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "confined filesystem error [{}] in {}: {}", + self.kind.as_str(), + self.operation, + self.message + )?; + if let Some(limit) = self.limit { + write!(formatter, " (limit: {limit})")?; + } + if let Some(value) = self.value { + write!(formatter, " (value: {value})")?; + } + if let Some(raw_os_error) = self.raw_os_error { + write!(formatter, " (os error: {raw_os_error})")?; + } + Ok(()) + } +} + +impl std::error::Error for ConfinedFsError {} + +/// Limits applied to every bounded operation on a [`ConfinedFsRoot`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ConfinedFsLimits { + /// Maximum bytes returned by one file read. + pub max_read_bytes: usize, + /// Maximum cumulative bytes written through one temporary file. + pub max_write_bytes: usize, + /// Maximum entries returned by one directory enumeration. + pub max_entries: usize, + /// Maximum name bytes accepted by one enumeration. + pub max_entry_name_bytes: usize, + /// Maximum exclusive-create attempts for one temporary file. + pub max_temp_attempts: u32, +} + +impl Default for ConfinedFsLimits { + fn default() -> Self { + Self { + max_read_bytes: 8 * 1024 * 1024, + max_write_bytes: 8 * 1024 * 1024, + max_entries: 4096, + max_entry_name_bytes: MAX_COMPONENT_BYTES, + max_temp_attempts: 32, + } + } +} + +impl ConfinedFsLimits { + fn validate(self) -> Result { + if self.max_read_bytes > MAX_READ_BYTES { + return Err(ConfinedFsError::invalid_configuration( + "read budget exceeds the hard bound", + )); + } + if self.max_write_bytes > MAX_WRITE_BYTES { + return Err(ConfinedFsError::invalid_configuration( + "write budget exceeds the hard bound", + )); + } + if self.max_entries > MAX_ENUM_ENTRIES { + return Err(ConfinedFsError::invalid_configuration( + "enumeration budget exceeds the hard bound", + )); + } + if self.max_entry_name_bytes > MAX_COMPONENT_BYTES { + return Err(ConfinedFsError::invalid_configuration( + "entry-name budget exceeds the hard bound", + )); + } + if self.max_temp_attempts == 0 || self.max_temp_attempts > MAX_TEMP_ATTEMPTS { + return Err(ConfinedFsError::invalid_configuration( + "temporary retry budget is outside the hard bound", + )); + } + Ok(self) + } +} + +/// Per-call directory enumeration bounds. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct EnumerationBudget { + /// Maximum entries to return. + pub max_entries: usize, + /// Maximum bytes in one entry name. + pub max_name_bytes: usize, +} + +impl Default for EnumerationBudget { + fn default() -> Self { + Self { + max_entries: 4096, + max_name_bytes: MAX_COMPONENT_BYTES, + } + } +} + +/// The type of an entry observed without following a symlink. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ConfinedFileType { + /// A regular file. + File, + /// A directory. + Directory, + /// A symlink. It is reported by metadata and never followed by opens. + Symlink, + /// Any other operating-system entry type. + Other, +} + +/// Metadata observed relative to a confined root. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ConfinedMetadata { + file_type: ConfinedFileType, + len: u64, + link_count: u64, +} + +impl ConfinedMetadata { + /// Returns the entry type. + pub fn file_type(&self) -> ConfinedFileType { + self.file_type + } + + /// Returns the byte length reported by the operating system. + pub fn len(&self) -> u64 { + self.len + } + + /// Returns whether the entry reports a zero byte length. + pub fn is_empty(&self) -> bool { + self.len == 0 + } + + /// Returns the observed hard-link count. + pub fn link_count(&self) -> u64 { + self.link_count + } + + /// Returns whether the entry is a regular file. + pub fn is_file(&self) -> bool { + self.file_type == ConfinedFileType::File + } + + /// Returns whether the entry is a directory. + pub fn is_dir(&self) -> bool { + self.file_type == ConfinedFileType::Directory + } +} + +/// One bounded directory entry with no path outside the retained root. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ConfinedDirEntry { + name: String, + name_os: OsString, + metadata: ConfinedMetadata, +} + +impl ConfinedDirEntry { + /// Returns a lossy UTF-8 display form of the entry's name. + /// + /// Use [`Self::name_os`] or [`Self::name_bytes`] when the exact name must + /// be retained. Invalid UTF-8 is represented with replacement characters + /// here and never causes enumeration to fail. + pub fn name(&self) -> &str { + &self.name + } + + /// Returns the exact operating-system entry name. + pub fn name_os(&self) -> &OsStr { + &self.name_os + } + + /// Returns the exact entry name bytes on Unix. + #[cfg(unix)] + pub fn name_bytes(&self) -> &[u8] { + self.name_os.as_bytes() + } + + /// Returns metadata collected without following a symlink. + pub fn metadata(&self) -> ConfinedMetadata { + self.metadata + } +} + +/// An open regular file reached through a [`ConfinedFsRoot`]. +#[derive(Debug)] +pub struct ConfinedFile { + #[cfg(unix)] + file: File, + #[cfg(unix)] + max_read_bytes: usize, +} + +impl ConfinedFile { + /// Reads at most the root's configured read budget plus one byte. + /// + /// Reading one extra byte allows the method to report a budget violation + /// without allocating an unbounded buffer. + pub fn read_to_end(&mut self) -> Result, ConfinedFsError> { + #[cfg(unix)] + { + let mut output = Vec::with_capacity(self.max_read_bytes.min(8192)); + let mut limited = (&mut self.file).take(self.max_read_bytes as u64 + 1); + limited + .read_to_end(&mut output) + .map_err(|error| ConfinedFsError::os("fs::read", &error))?; + if output.len() > self.max_read_bytes { + return Err(ConfinedFsError::budget( + "fs::read", + "read budget exceeded", + self.max_read_bytes, + output.len(), + )); + } + Ok(output) + } + #[cfg(not(unix))] + { + Err(unsupported_error("fs::read")) + } + } + + /// Reads a UTF-8 file under the same byte budget as [`Self::read_to_end`]. + pub fn read_to_string(&mut self) -> Result { + String::from_utf8(self.read_to_end()?).map_err(|_| { + ConfinedFsError::new( + ConfinedFsErrorKind::InvalidData, + "fs::read", + "file is not valid UTF-8", + ) + }) + } + + /// Returns metadata for this already-open handle. + pub fn metadata(&self) -> Result { + #[cfg(unix)] + { + let metadata = unix::metadata_from_fd(self.file.as_raw_fd()) + .map_err(|error| ConfinedFsError::os("fs::metadata", &error))?; + enforce_hardlink_policy("fs::metadata", metadata) + } + #[cfg(not(unix))] + { + Err(unsupported_error("fs::metadata")) + } + } +} + +/// An opaque retained directory handle opened through a [`ConfinedFsRoot`]. +/// +/// The capability owns the directory descriptor and exposes no public path or +/// raw-fd accessor. Cloning retains the same directory through an `Arc`. +#[derive(Clone)] +pub struct ConfinedDirectory { + #[cfg(unix)] + inner: Arc, + #[cfg(not(unix))] + _private: (), +} + +#[cfg(unix)] +struct ConfinedDirectoryInner { + fd: OwnedFd, +} + +impl fmt::Debug for ConfinedDirectory { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ConfinedDirectory") + .finish_non_exhaustive() + } +} + +impl ConfinedDirectory { + #[cfg(unix)] + fn from_fd(fd: OwnedFd) -> Self { + Self { + inner: Arc::new(ConfinedDirectoryInner { fd }), + } + } + + #[cfg(unix)] + pub(crate) fn as_raw_fd(&self) -> RawFd { + self.inner.fd.as_raw_fd() + } +} + +/// A securely created temporary file and its retained parent directory. +#[derive(Debug)] +pub struct ConfinedTempFile { + #[cfg(unix)] + parent: OwnedFd, + #[cfg(unix)] + root_identity: unix::FileIdentity, + #[cfg(unix)] + file: File, + #[cfg(unix)] + name: String, + #[cfg(unix)] + initial_identity: unix::FileIdentity, + #[cfg(unix)] + max_write_bytes: usize, + #[cfg(unix)] + written: usize, + #[cfg(unix)] + completed: bool, +} + +impl ConfinedTempFile { + /// Returns the generated temporary basename, never an absolute path. + pub fn name(&self) -> &str { + #[cfg(unix)] + { + &self.name + } + #[cfg(not(unix))] + { + "" + } + } + + /// Returns the cumulative bytes written through this handle. + pub fn bytes_written(&self) -> usize { + #[cfg(unix)] + { + self.written + } + #[cfg(not(unix))] + { + 0 + } + } + + /// Writes data while enforcing the root's cumulative write budget. + pub fn write_all(&mut self, data: &[u8]) -> Result<(), ConfinedFsError> { + #[cfg(unix)] + { + if self.completed { + return Err(ConfinedFsError::new( + ConfinedFsErrorKind::TempCompleted, + "fs::temp_write", + "temporary file has already been completed", + )); + } + let new_total = self.written.checked_add(data.len()).ok_or_else(|| { + ConfinedFsError::budget( + "fs::temp_write", + "write budget exceeded", + self.max_write_bytes, + usize::MAX, + ) + })?; + if new_total > self.max_write_bytes { + return Err(ConfinedFsError::budget( + "fs::temp_write", + "write budget exceeded", + self.max_write_bytes, + new_total, + )); + } + let mut offset = 0; + while offset < data.len() { + let end = { + #[cfg(test)] + { + let limit = TEST_PARTIAL_WRITE_FAIL_AFTER.with(Cell::get); + if limit != u64::MAX { + offset.saturating_add(1).min(data.len()).min(limit as usize) + } else { + data.len() + } + } + #[cfg(not(test))] + { + data.len() + } + }; + if end <= offset { + return Err(ConfinedFsError::os( + "fs::temp_write", + &io::Error::other("temporary write test hook stopped progress"), + )); + } + match self + .file + .write(&data[offset..end]) + .map_err(|error| ConfinedFsError::os("fs::temp_write", &error)) + { + Ok(0) => { + return Err(ConfinedFsError::os( + "fs::temp_write", + &io::Error::new( + io::ErrorKind::WriteZero, + "temporary write made no progress", + ), + )); + } + Ok(written) => { + offset += written; + self.written += written; + } + Err(error) => return Err(error), + } + } + Ok(()) + } + #[cfg(not(unix))] + { + let _ = data; + Err(unsupported_error("fs::temp_write")) + } + } + + /// Flushes buffered data to the operating-system file descriptor. + pub fn flush(&mut self) -> Result<(), ConfinedFsError> { + #[cfg(unix)] + { + if self.completed { + return Err(ConfinedFsError::new( + ConfinedFsErrorKind::TempCompleted, + "fs::temp_flush", + "temporary file has already been completed", + )); + } + self.file + .flush() + .map_err(|error| ConfinedFsError::os("fs::temp_flush", &error)) + } + #[cfg(not(unix))] + { + Err(unsupported_error("fs::temp_flush")) + } + } + + /// Requests synchronization of the temporary file's contents. + pub fn sync_all(&self) -> Result<(), ConfinedFsError> { + #[cfg(unix)] + { + if self.completed { + return Err(ConfinedFsError::new( + ConfinedFsErrorKind::TempCompleted, + "fs::temp_sync", + "temporary file has already been completed", + )); + } + self.file + .sync_all() + .map_err(|error| ConfinedFsError::os("fs::temp_sync", &error)) + } + #[cfg(not(unix))] + { + Err(unsupported_error("fs::temp_sync")) + } + } + + /// Unlinks the temporary file relative to its retained parent directory. + /// + /// Cleanup is idempotent. Dropping an uncommitted temporary also attempts + /// this operation, while suppressing errors because `Drop` cannot report + /// them. + pub fn cleanup(&mut self) -> Result<(), ConfinedFsError> { + #[cfg(unix)] + { + if self.completed { + return Ok(()); + } + let metadata = match unix::metadata_at(self.parent.as_raw_fd(), self.name.as_bytes()) { + Ok(metadata) => metadata, + Err(error) if error.raw_os_error() == Some(libc::ENOENT) => { + self.completed = true; + return Ok(()); + } + Err(error) => return Err(ConfinedFsError::os("fs::temp_cleanup", &error)), + }; + let identity = + match unix::file_identity_at(self.parent.as_raw_fd(), self.name.as_bytes()) { + Ok(identity) => identity, + Err(error) if error.raw_os_error() == Some(libc::ENOENT) => { + self.completed = true; + return Ok(()); + } + Err(error) => return Err(ConfinedFsError::os("fs::temp_cleanup", &error)), + }; + if identity != self.initial_identity + || !metadata.is_file() + || metadata.link_count() != 1 + { + self.completed = true; + return Err(ConfinedFsError::new( + ConfinedFsErrorKind::RaceDetected, + "fs::temp_cleanup", + "temporary cleanup entry is not the retained inode", + )); + } + match unix::unlink_at(self.parent.as_raw_fd(), self.name.as_bytes()) { + Ok(()) => { + self.completed = true; + Ok(()) + } + Err(error) if error.raw_os_error() == Some(libc::ENOENT) => { + self.completed = true; + Ok(()) + } + Err(error) => Err(ConfinedFsError::os("fs::temp_cleanup", &error)), + } + } + #[cfg(not(unix))] + { + Err(unsupported_error("fs::temp_cleanup")) + } + } + + /// Replaces a same-directory destination atomically. + /// + /// Atomic publication is Linux-only (`renameat2`). The destination must be + /// one basename, so the operation cannot select a second parent directory. + /// The destination is checked without following symlinks, the retained + /// source inode is linked to a private same-directory staging name, and + /// only that staging name is published. Synchronization of the file is + /// performed before publication. Once the destination name contains the + /// retained inode, this method returns [`ConfinedPublication`] recording + /// whether parent-directory durability and staging cleanup succeeded. + pub fn replace(&mut self, destination: &str) -> Result { + let destination = validate_component(destination, "fs::replace")?; + #[cfg(unix)] + { + if self.completed { + return Err(ConfinedFsError::new( + ConfinedFsErrorKind::TempCompleted, + "fs::replace", + "temporary file has already been completed", + )); + } + unix::replace_temp(self, destination) + } + #[cfg(not(unix))] + { + let _ = destination; + Err(unsupported_error("fs::replace")) + } + } +} + +impl Drop for ConfinedTempFile { + fn drop(&mut self) { + let _ = self.cleanup(); + } +} + +/// A capability rooted at one existing directory. +/// +/// Construction opens and retains the directory itself. Every operation after +/// construction uses only that retained descriptor and validated relative +/// components; it does not depend on the process working directory and never +/// performs canonicalize-then-open. Parent and leaf symlinks are rejected for +/// opens, directory traversal, temporary creation, and replacement. Metadata +/// may report a leaf symlink as [`ConfinedFileType::Symlink`] without following +/// it. +/// +/// On Linux, `openat2` is used with beneath/no-magic-link/no-symlink resolution +/// when available. The component-wise `openat` fallback retains the same +/// no-follow guarantees. Atomic temporary publication requires Linux +/// `renameat2` and is unavailable on other Unix targets. Windows and +/// unsupported targets return a typed unsupported error rather than using +/// path-based reparse-point-unsafe calls. +#[derive(Debug)] +pub struct ConfinedFsRoot { + #[cfg(unix)] + fd: OwnedFd, + #[cfg(unix)] + root_identity: unix::FileIdentity, + #[cfg(unix)] + binding: unix::RootBinding, + limits: ConfinedFsLimits, +} + +impl ConfinedFsRoot { + /// Opens an existing directory as a retained root capability. + pub fn new(path: impl AsRef) -> Result { + Self::with_limits(path, ConfinedFsLimits::default()) + } + + /// Opens an existing directory with explicit bounded-operation limits. + pub fn with_limits( + path: impl AsRef, + limits: ConfinedFsLimits, + ) -> Result { + let limits = limits.validate()?; + let path = path.as_ref(); + #[cfg(unix)] + { + let opened = unix::open_root(path)?; + Ok(Self { + fd: opened.fd, + root_identity: opened.root_identity, + binding: opened.binding, + limits, + }) + } + #[cfg(not(unix))] + { + let _ = path; + let _ = limits; + Err(unsupported_error("fs::root")) + } + } + + /// Returns the limits retained by this capability. + pub fn limits(&self) -> ConfinedFsLimits { + self.limits + } + + #[cfg(unix)] + fn ensure_bound(&self, operation: &'static str) -> Result<(), ConfinedFsError> { + unix::verify_root_binding(&self.binding, self.root_identity).map_err(|error| { + if matches!( + error.raw_os_error(), + Some(libc::ESTALE) | Some(libc::ENOENT) | Some(libc::ENOTDIR) + ) { + ConfinedFsError::new( + ConfinedFsErrorKind::RaceDetected, + operation, + "the retained root path entry changed", + ) + } else { + ConfinedFsError::os(operation, &error) + } + }) + } + + /// Opens a regular file read-only relative to the retained root. + pub fn open_read(&self, path: &str) -> Result { + let path = validate_relative_path(path, "fs::open_read")?; + #[cfg(unix)] + { + self.ensure_bound("fs::open_read")?; + let fd = unix::open_relative( + self.fd.as_raw_fd(), + &path.components, + libc::O_RDONLY | libc::O_NONBLOCK, + 0, + ) + .map_err(|error| ConfinedFsError::os("fs::open_read", &error))?; + let file = File::from(fd); + let metadata = unix::metadata_from_fd(file.as_raw_fd()) + .map_err(|error| ConfinedFsError::os("fs::open_read", &error))?; + enforce_hardlink_policy("fs::open_read", metadata)?; + if !metadata.is_file() { + return Err(ConfinedFsError::new( + ConfinedFsErrorKind::WrongType, + "fs::open_read", + "read-only open requires a regular file", + )); + } + unix::clear_nonblock(file.as_raw_fd()) + .map_err(|error| ConfinedFsError::os("fs::open_read", &error))?; + self.ensure_bound("fs::open_read")?; + Ok(ConfinedFile { + file, + max_read_bytes: self.limits.max_read_bytes, + }) + } + #[cfg(not(unix))] + { + let _ = path; + Err(unsupported_error("fs::open_read")) + } + } + + /// Reads one regular file under the root's configured byte budget. + pub fn read_file(&self, path: &str) -> Result, ConfinedFsError> { + let mut file = self.open_read(path)?; + file.read_to_end() + } + + /// Reads metadata without following the leaf entry. + pub fn metadata(&self, path: &str) -> Result { + let path = validate_relative_path(path, "fs::metadata")?; + #[cfg(unix)] + { + self.ensure_bound("fs::metadata")?; + let parent = unix::open_directory( + self.fd.as_raw_fd(), + &path.components[..path.components.len() - 1], + ) + .map_err(|error| ConfinedFsError::os("fs::metadata", &error))?; + let leaf = path.components.last().expect("validated path is nonempty"); + let metadata = unix::metadata_at(parent.as_raw_fd(), leaf.as_bytes()) + .map_err(|error| ConfinedFsError::os("fs::metadata", &error))?; + let metadata = enforce_hardlink_policy("fs::metadata", metadata)?; + self.ensure_bound("fs::metadata")?; + Ok(metadata) + } + #[cfg(not(unix))] + { + let _ = path; + Err(unsupported_error("fs::metadata")) + } + } + + /// Opens a directory relative to the retained root and keeps the handle. + /// + /// Passing an empty path selects the retained root itself. Traversal uses + /// the same no-follow component walk and root-binding verification as other + /// confined operations. The returned capability owns the directory handle + /// and does not expose a path or raw descriptor. + pub fn open_directory(&self, path: &str) -> Result { + let path = validate_directory_path(path, "fs::open_directory")?; + #[cfg(unix)] + { + self.ensure_bound("fs::open_directory")?; + let fd = unix::open_directory(self.fd.as_raw_fd(), &path.components) + .map_err(|error| ConfinedFsError::os("fs::open_directory", &error))?; + self.ensure_bound("fs::open_directory")?; + Ok(ConfinedDirectory::from_fd(fd)) + } + #[cfg(not(unix))] + { + let _ = path; + Err(unsupported_error("fs::open_directory")) + } + } + + /// Enumerates a directory relative to the root with the default budget. + /// + /// Passing an empty directory path selects the retained root itself. Empty + /// paths remain invalid for file operations. + pub fn enumerate(&self, path: &str) -> Result, ConfinedFsError> { + self.enumerate_with_budget(path, EnumerationBudget::default()) + } + + /// Enumerates a directory with a per-call budget further bounded by the + /// root limits. An entry that would exceed the effective bound returns a + /// typed budget error instead of silently returning an incomplete result. + pub fn enumerate_with_budget( + &self, + path: &str, + budget: EnumerationBudget, + ) -> Result, ConfinedFsError> { + let path = validate_directory_path(path, "fs::enumerate")?; + let max_entries = budget.max_entries.min(self.limits.max_entries); + let max_name_bytes = budget.max_name_bytes.min(self.limits.max_entry_name_bytes); + #[cfg(unix)] + { + self.ensure_bound("fs::enumerate")?; + let directory = unix::open_directory(self.fd.as_raw_fd(), &path.components) + .map_err(|error| ConfinedFsError::os("fs::enumerate", &error))?; + let entries = unix::enumerate_directory(directory, max_entries, max_name_bytes)?; + self.ensure_bound("fs::enumerate")?; + Ok(entries) + } + #[cfg(not(unix))] + { + let _ = (path, max_entries, max_name_bytes); + Err(unsupported_error("fs::enumerate")) + } + } + + /// Returns whether this target can atomically publish temporary files. + /// + /// Publication uses Linux `renameat2`. Other Unix targets can still open a + /// confined root for reads, metadata, and enumeration. + pub const fn supports_atomic_publication() -> bool { + publication_supported() + } + + /// Creates an exclusive temporary regular file in a confined directory. + /// + /// An empty `parent` selects the retained root itself. The returned object + /// retains the opened parent descriptor, and its basename is relative-only. + /// Temporary creation is refused on targets that cannot publish with + /// Linux `renameat2`, so an unpublished exclusive file cannot be left + /// behind. + pub fn create_temp( + &self, + parent: &str, + prefix: &str, + ) -> Result { + let parent = validate_directory_path(parent, "fs::temp_create")?; + let prefix = validate_component(prefix, "fs::temp_create")?; + self.create_temp_in_components(&parent.components, prefix) + } + + fn create_temp_in_components( + &self, + components: &[&str], + prefix: &str, + ) -> Result { + if prefix.len() > MAX_TEMP_PREFIX_BYTES { + return Err(ConfinedFsError::new( + ConfinedFsErrorKind::ComponentTooLong, + "fs::temp_create", + "temporary prefix leaves insufficient room for a generated suffix", + )); + } + #[cfg(target_os = "linux")] + { + self.ensure_bound("fs::temp_create")?; + let parent_fd = unix::open_directory(self.fd.as_raw_fd(), components) + .map_err(|error| ConfinedFsError::os("fs::temp_create", &error))?; + for _ in 0..self.limits.max_temp_attempts { + let name = next_temp_name(prefix); + match unix::create_exclusive_temp( + parent_fd.as_raw_fd(), + self.root_identity, + &name, + self.limits.max_write_bytes, + ) { + Ok(mut file) => { + if let Err(error) = self.ensure_bound("fs::temp_create") { + let _ = file.cleanup(); + return Err(error); + } + return Ok(file); + } + Err(error) if error.raw_os_error() == Some(libc::EEXIST) => continue, + Err(error) => { + return Err(ConfinedFsError::os("fs::temp_create", &error)); + } + } + } + Err(ConfinedFsError::new( + ConfinedFsErrorKind::TempCollision, + "fs::temp_create", + "temporary name retry budget exhausted", + )) + } + #[cfg(not(target_os = "linux"))] + { + let _ = (self, components, prefix); + Err(unsupported_error("fs::temp_create")) + } + } + + /// Atomically replaces a same-directory destination with `temp`. + /// + /// This is a Linux `renameat2` publication. Binding checks that can fail + /// before the destination is published run first. After the destination + /// contains the retained inode, durability and staging cleanup are + /// reported on [`ConfinedPublication`]. + pub fn atomic_replace( + &self, + temp: ConfinedTempFile, + destination: &str, + ) -> Result { + let mut temp = temp; + #[cfg(unix)] + { + self.ensure_bound("fs::replace")?; + if temp.root_identity != self.root_identity { + return Err(ConfinedFsError::new( + ConfinedFsErrorKind::CapabilityMismatch, + "fs::replace", + "temporary file belongs to a different root capability", + )); + } + temp.replace(destination) + } + #[cfg(not(unix))] + temp.replace(destination) + } + + /// Writes a regular file by creating a confined temporary and atomically + /// replacing the destination in its exact parent directory. + /// + /// This convenience path requires Linux publication. On other targets it + /// fails closed before creating a temporary. + pub fn write_file( + &self, + path: &str, + data: &[u8], + ) -> Result { + let path = validate_relative_path(path, "fs::write_file")?; + let parent = &path.components[..path.components.len() - 1]; + let destination = path.components.last().expect("validated path is nonempty"); + let mut temp = self.create_temp_in_components(parent, ".rustscript-tmp")?; + temp.write_all(data)?; + temp.flush()?; + temp.sync_all()?; + self.atomic_replace(temp, destination) + } +} + +#[cfg(unix)] +fn classify_os_error(raw_os_error: Option) -> ConfinedFsErrorKind { + if let Some(raw_os_error) = raw_os_error { + if raw_os_error == libc::ENOENT { + return ConfinedFsErrorKind::NotFound; + } + if raw_os_error == libc::EACCES || raw_os_error == libc::EPERM { + return ConfinedFsErrorKind::PermissionDenied; + } + if raw_os_error == libc::ELOOP { + return ConfinedFsErrorKind::SymlinkDenied; + } + if raw_os_error == libc::ENOTDIR { + return ConfinedFsErrorKind::WrongType; + } + if raw_os_error == libc::EEXIST { + return ConfinedFsErrorKind::AlreadyExists; + } + if raw_os_error == libc::EOPNOTSUPP || raw_os_error == libc::ENOSYS { + return ConfinedFsErrorKind::UnsupportedPlatform; + } + } + ConfinedFsErrorKind::Io +} + +#[cfg(unix)] +fn enforce_hardlink_policy( + operation: &'static str, + metadata: ConfinedMetadata, +) -> Result { + if metadata.is_file() && metadata.link_count > 1 { + return Err(ConfinedFsError::new( + ConfinedFsErrorKind::HardlinkDenied, + operation, + "regular files with multiple hard links are not permitted", + )); + } + Ok(metadata) +} + +#[cfg(unix)] +fn stat_u64(value: T) -> u64 +where + T: TryInto, +{ + value.try_into().ok().unwrap_or(0) +} + +fn unsupported_error(operation: &'static str) -> ConfinedFsError { + ConfinedFsError::new( + ConfinedFsErrorKind::UnsupportedPlatform, + operation, + "secure descriptor-relative filesystem operations are unavailable on this target", + ) +} + +#[cfg(test)] +fn classify_readdir_end(errno: Option) -> ConfinedFsError { + classify_readdir_end_or_eof(errno) + .expect_err("a non-EOF readdir status must be reported as an error") +} + +#[cfg(test)] +fn classify_readdir_end_or_eof(errno: Option) -> Result<(), ConfinedFsError> { + match errno { + Some(0) => Ok(()), + Some(code) => { + #[cfg(unix)] + { + Err(ConfinedFsError::os( + "fs::enumerate", + &io::Error::from_raw_os_error(code), + )) + } + #[cfg(not(unix))] + { + let _ = code; + Err(unsupported_error("fs::enumerate")) + } + } + None => Err(unsupported_error("fs::enumerate")), + } +} + +struct ValidatedPath<'a> { + components: Vec<&'a str>, +} + +fn validate_relative_path<'a>( + path: &'a str, + operation: &'static str, +) -> Result, ConfinedFsError> { + validate_path(path, false, operation) +} + +fn validate_directory_path<'a>( + path: &'a str, + operation: &'static str, +) -> Result, ConfinedFsError> { + validate_path(path, true, operation) +} + +fn validate_path<'a>( + path: &'a str, + allow_empty: bool, + operation: &'static str, +) -> Result, ConfinedFsError> { + if path.is_empty() { + if allow_empty { + return Ok(ValidatedPath { + components: Vec::new(), + }); + } + return Err(ConfinedFsError::new( + ConfinedFsErrorKind::EmptyPath, + operation, + "empty paths are not valid file paths", + )); + } + if path.len() > MAX_PATH_BYTES { + return Err(ConfinedFsError::new( + ConfinedFsErrorKind::PathTooLong, + operation, + "relative path exceeds the hard bound", + )); + } + if path.as_bytes().contains(&0) { + return Err(ConfinedFsError::new( + ConfinedFsErrorKind::NulByte, + operation, + "path contains a NUL byte", + )); + } + if path.starts_with('/') || path.ends_with('/') { + return Err(ConfinedFsError::new( + ConfinedFsErrorKind::AbsolutePath, + operation, + "rooted or trailing-separator paths are not permitted", + )); + } + if path.contains('\\') { + return Err(ConfinedFsError::new( + ConfinedFsErrorKind::InvalidSeparator, + operation, + "backslash is not a permitted path separator", + )); + } + if path.contains(':') { + return Err(ConfinedFsError::new( + ConfinedFsErrorKind::PathPrefix, + operation, + "drive and prefix syntax is not permitted", + )); + } + + let mut components = Vec::new(); + for component in path.split('/') { + if component.is_empty() { + return Err(ConfinedFsError::new( + ConfinedFsErrorKind::InvalidPath, + operation, + "empty path components are not permitted", + )); + } + if component == "." || component == ".." { + return Err(ConfinedFsError::new( + ConfinedFsErrorKind::ParentTraversal, + operation, + "dot and parent components are not permitted", + )); + } + if component.ends_with('.') { + return Err(ConfinedFsError::new( + ConfinedFsErrorKind::InvalidPath, + operation, + "trailing-dot components are not permitted", + )); + } + if component.len() > MAX_COMPONENT_BYTES { + return Err(ConfinedFsError::new( + ConfinedFsErrorKind::ComponentTooLong, + operation, + "path component exceeds the hard bound", + )); + } + components.push(component); + } + Ok(ValidatedPath { components }) +} + +fn validate_component<'a>( + component: &'a str, + operation: &'static str, +) -> Result<&'a str, ConfinedFsError> { + if component.is_empty() { + return Err(ConfinedFsError::new( + ConfinedFsErrorKind::EmptyPath, + operation, + "empty names are not permitted", + )); + } + if component.len() > MAX_COMPONENT_BYTES { + return Err(ConfinedFsError::new( + ConfinedFsErrorKind::ComponentTooLong, + operation, + "name exceeds the hard bound", + )); + } + if component == "." || component == ".." { + return Err(ConfinedFsError::new( + ConfinedFsErrorKind::ParentTraversal, + operation, + "dot and parent names are not permitted", + )); + } + if component.ends_with('.') { + return Err(ConfinedFsError::new( + ConfinedFsErrorKind::InvalidPath, + operation, + "trailing-dot names are not permitted", + )); + } + if component.contains('/') || component.contains('\\') { + return Err(ConfinedFsError::new( + ConfinedFsErrorKind::InvalidSeparator, + operation, + "path separators are not permitted in one name", + )); + } + if component.contains(':') { + return Err(ConfinedFsError::new( + ConfinedFsErrorKind::PathPrefix, + operation, + "drive and prefix syntax is not permitted", + )); + } + if component.as_bytes().contains(&0) { + return Err(ConfinedFsError::new( + ConfinedFsErrorKind::NulByte, + operation, + "name contains a NUL byte", + )); + } + Ok(component) +} + +#[cfg(unix)] +static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0); + +#[cfg(unix)] +fn next_temp_name(prefix: &str) -> String { + let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |duration| duration.as_nanos() as u64); + format!("{prefix}.{}.{}-{counter:x}", std::process::id(), nanos) +} + +#[cfg(unix)] +mod unix { + use super::*; + #[cfg(test)] + use std::cell::{Cell, RefCell}; + use std::mem::MaybeUninit; + + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub(super) struct FileIdentity { + pub(super) device: u64, + pub(super) inode: u64, + } + + #[derive(Debug)] + pub(super) struct RootBinding { + anchor: OwnedFd, + components: Vec>, + identities: Vec, + } + + pub(super) struct OpenedRoot { + pub(super) fd: OwnedFd, + pub(super) root_identity: FileIdentity, + pub(super) binding: RootBinding, + } + + #[cfg(all(target_os = "linux", test))] + thread_local! { + static FORCE_OPENAT2_FALLBACK: Cell = const { Cell::new(false) }; + } + + #[cfg(all(target_os = "linux", test))] + pub(super) fn set_force_openat2_fallback(force: bool) { + FORCE_OPENAT2_FALLBACK.with(|flag| flag.set(force)); + } + + #[cfg(all(target_os = "linux", test))] + pub(super) fn force_openat2_fallback_enabled() -> bool { + FORCE_OPENAT2_FALLBACK.with(Cell::get) + } + + #[cfg(target_os = "linux")] + fn force_openat2_fallback() -> bool { + #[cfg(test)] + { + FORCE_OPENAT2_FALLBACK.with(Cell::get) + } + #[cfg(not(test))] + { + false + } + } + + pub(super) fn open_root(path: &Path) -> Result { + let (absolute, components) = parse_root_path(path)?; + let anchor = + open_anchor(absolute).map_err(|error| ConfinedFsError::os("fs::root", &error))?; + let mut current = duplicate_fd(anchor.as_raw_fd()) + .map_err(|error| ConfinedFsError::os("fs::root", &error))?; + let mut identities = Vec::with_capacity(components.len()); + for component in &components { + let next = open_directory_component(current.as_raw_fd(), component) + .map_err(|error| ConfinedFsError::os("fs::root", &error))?; + let identity = file_identity(next.as_raw_fd()) + .map_err(|error| ConfinedFsError::os("fs::root", &error))?; + identities.push(identity); + current = next; + } + let root_identity = file_identity(current.as_raw_fd()) + .map_err(|error| ConfinedFsError::os("fs::root", &error))?; + Ok(OpenedRoot { + fd: current, + root_identity, + binding: RootBinding { + anchor, + components, + identities, + }, + }) + } + + fn parse_root_path(path: &Path) -> Result<(bool, Vec>), ConfinedFsError> { + let bytes = path.as_os_str().as_bytes(); + if bytes.is_empty() { + return Err(ConfinedFsError::new( + ConfinedFsErrorKind::EmptyPath, + "fs::root", + "empty paths are not valid roots", + )); + } + if bytes.len() > MAX_PATH_BYTES { + return Err(ConfinedFsError::new( + ConfinedFsErrorKind::PathTooLong, + "fs::root", + "root path exceeds the hard bound", + )); + } + if bytes.contains(&0) { + return Err(ConfinedFsError::new( + ConfinedFsErrorKind::NulByte, + "fs::root", + "root path contains a NUL byte", + )); + } + if bytes.contains(&b'\\') { + return Err(ConfinedFsError::new( + ConfinedFsErrorKind::InvalidSeparator, + "fs::root", + "backslash is not a permitted path separator", + )); + } + if bytes.contains(&b':') { + return Err(ConfinedFsError::new( + ConfinedFsErrorKind::PathPrefix, + "fs::root", + "drive and prefix syntax is not permitted", + )); + } + + let absolute = bytes[0] == b'/'; + if absolute && bytes.get(1) == Some(&b'/') { + return Err(ConfinedFsError::new( + ConfinedFsErrorKind::InvalidPath, + "fs::root", + "repeated leading separators are not permitted", + )); + } + let mut body = if absolute { &bytes[1..] } else { bytes }; + if body.last() == Some(&b'/') { + body = &body[..body.len() - 1]; + } + if !absolute && body == b"." { + return Ok((false, Vec::new())); + } + if body.is_empty() { + return Ok((absolute, Vec::new())); + } + + let mut components = Vec::new(); + for component in body.split(|byte| *byte == b'/') { + if component.is_empty() { + return Err(ConfinedFsError::new( + ConfinedFsErrorKind::InvalidPath, + "fs::root", + "empty root components are not permitted", + )); + } + if component == b"." || component == b".." { + return Err(ConfinedFsError::new( + ConfinedFsErrorKind::ParentTraversal, + "fs::root", + "dot and parent root components are not permitted", + )); + } + if component.ends_with(b".") { + return Err(ConfinedFsError::new( + ConfinedFsErrorKind::InvalidPath, + "fs::root", + "trailing-dot root components are not permitted", + )); + } + if component.len() > MAX_COMPONENT_BYTES { + return Err(ConfinedFsError::new( + ConfinedFsErrorKind::ComponentTooLong, + "fs::root", + "root component exceeds the hard bound", + )); + } + components.push(component.to_vec()); + } + Ok((absolute, components)) + } + + fn open_anchor(absolute: bool) -> Result { + let anchor = if absolute { "/" } else { "." }; + let anchor = CString::new(anchor).expect("fixed anchor contains no NUL"); + let fd = unsafe { + libc::open( + anchor.as_ptr(), + libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW, + 0, + ) + }; + if fd < 0 { + Err(io::Error::last_os_error()) + } else { + Ok(unsafe { OwnedFd::from_raw_fd(fd) }) + } + } + + fn open_directory_component(parent_fd: RawFd, component: &[u8]) -> Result { + let component = CString::new(component).expect("validated component contains no NUL"); + let mut stat = MaybeUninit::::uninit(); + let stat_result = unsafe { + libc::fstatat( + parent_fd, + component.as_ptr(), + stat.as_mut_ptr(), + libc::AT_SYMLINK_NOFOLLOW, + ) + }; + if stat_result < 0 { + return Err(io::Error::last_os_error()); + } + let stat = unsafe { stat.assume_init() }; + if (stat.st_mode as libc::mode_t) & libc::S_IFMT == libc::S_IFLNK { + return Err(io::Error::from_raw_os_error(libc::ELOOP)); + } + let fd = unsafe { + libc::openat( + parent_fd, + component.as_ptr(), + libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW, + 0, + ) + }; + if fd < 0 { + let error = io::Error::last_os_error(); + if error.raw_os_error() == Some(libc::ENOTDIR) { + let mut stat = MaybeUninit::::uninit(); + let result = unsafe { + libc::fstatat( + parent_fd, + component.as_ptr(), + stat.as_mut_ptr(), + libc::AT_SYMLINK_NOFOLLOW, + ) + }; + if result == 0 { + let stat = unsafe { stat.assume_init() }; + if (stat.st_mode as libc::mode_t) & libc::S_IFMT == libc::S_IFLNK { + return Err(io::Error::from_raw_os_error(libc::ELOOP)); + } + } + } + Err(error) + } else { + Ok(unsafe { OwnedFd::from_raw_fd(fd) }) + } + } + + pub(super) fn verify_root_binding( + binding: &RootBinding, + root_identity: FileIdentity, + ) -> Result<(), io::Error> { + let mut current = duplicate_fd(binding.anchor.as_raw_fd())?; + if binding.components.is_empty() { + if file_identity(current.as_raw_fd())? != root_identity { + return Err(io::Error::from_raw_os_error(libc::ESTALE)); + } + return Ok(()); + } + for (component, expected) in binding.components.iter().zip(&binding.identities) { + let next = open_directory_component(current.as_raw_fd(), component)?; + if file_identity(next.as_raw_fd())? != *expected { + return Err(io::Error::from_raw_os_error(libc::ESTALE)); + } + current = next; + } + if file_identity(current.as_raw_fd())? != root_identity { + return Err(io::Error::from_raw_os_error(libc::ESTALE)); + } + Ok(()) + } + + pub(super) fn open_directory( + root_fd: RawFd, + components: &[&str], + ) -> Result { + open_relative(root_fd, components, libc::O_RDONLY | libc::O_DIRECTORY, 0) + } + + pub(super) fn open_relative( + root_fd: RawFd, + components: &[&str], + flags: libc::c_int, + mode: libc::mode_t, + ) -> Result { + #[cfg(target_os = "linux")] + { + if !force_openat2_fallback() { + match openat2(root_fd, components, flags, mode) { + Ok(fd) => return Ok(fd), + Err(error) if is_openat2_unavailable(&error) => {} + Err(error) => return Err(error), + } + } + } + open_component_walk(root_fd, components, flags, mode) + } + + #[cfg(target_os = "linux")] + fn is_openat2_unavailable(error: &io::Error) -> bool { + matches!( + error.raw_os_error(), + Some(libc::ENOSYS) | Some(libc::EINVAL) | Some(libc::EOPNOTSUPP) + ) + } + + #[cfg(target_os = "linux")] + fn openat2( + root_fd: RawFd, + components: &[&str], + flags: libc::c_int, + mode: libc::mode_t, + ) -> Result { + #[repr(C)] + struct OpenHow { + flags: u64, + mode: u64, + resolve: u64, + } + + const RESOLVE_NO_MAGICLINKS: u64 = 0x02; + const RESOLVE_NO_SYMLINKS: u64 = 0x04; + const RESOLVE_BENEATH: u64 = 0x08; + + if components.is_empty() { + return duplicate_fd(root_fd); + } + let mut relative_path = Vec::new(); + for (index, component) in components.iter().enumerate() { + if index != 0 { + relative_path.push(b'/'); + } + relative_path.extend_from_slice(component.as_bytes()); + } + let path = CString::new(relative_path).expect("validated components contain no NUL"); + let how = OpenHow { + flags: (flags | libc::O_CLOEXEC | libc::O_NOFOLLOW) as u64, + mode: mode as u64, + resolve: RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS | RESOLVE_NO_SYMLINKS, + }; + let fd = unsafe { + libc::syscall( + libc::SYS_openat2, + root_fd, + path.as_ptr(), + &how, + std::mem::size_of::(), + ) as libc::c_int + }; + if fd < 0 { + Err(io::Error::last_os_error()) + } else { + Ok(unsafe { OwnedFd::from_raw_fd(fd) }) + } + } + + fn open_component_walk( + root_fd: RawFd, + components: &[&str], + flags: libc::c_int, + mode: libc::mode_t, + ) -> Result { + if components.is_empty() { + return duplicate_fd(root_fd); + } + let mut current = duplicate_fd(root_fd)?; + for component in &components[..components.len() - 1] { + current = open_directory_component(current.as_raw_fd(), component.as_bytes())?; + } + let leaf = CString::new( + *components + .last() + .expect("nonempty component list has a last item"), + ) + .expect("validated component contains no NUL"); + let fd = unsafe { + libc::openat( + current.as_raw_fd(), + leaf.as_ptr(), + flags | libc::O_CLOEXEC | libc::O_NOFOLLOW, + mode as libc::c_uint, + ) + }; + if fd < 0 { + return Err(io::Error::last_os_error()); + } + Ok(unsafe { OwnedFd::from_raw_fd(fd) }) + } + + fn duplicate_fd(fd: RawFd) -> Result { + let duplicate = unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 3) }; + if duplicate >= 0 { + return Ok(unsafe { OwnedFd::from_raw_fd(duplicate) }); + } + let error = io::Error::last_os_error(); + if !matches!( + error.raw_os_error(), + Some(libc::EINVAL) | Some(libc::ENOSYS) + ) { + return Err(error); + } + let duplicate = unsafe { libc::fcntl(fd, libc::F_DUPFD, 3) }; + if duplicate < 0 { + return Err(io::Error::last_os_error()); + } + let descriptor_flags = unsafe { libc::fcntl(duplicate, libc::F_GETFD) }; + if descriptor_flags < 0 { + let error = io::Error::last_os_error(); + unsafe { libc::close(duplicate) }; + return Err(error); + } + if unsafe { + libc::fcntl( + duplicate, + libc::F_SETFD, + descriptor_flags | libc::FD_CLOEXEC, + ) + } < 0 + { + let error = io::Error::last_os_error(); + unsafe { libc::close(duplicate) }; + return Err(error); + } + Ok(unsafe { OwnedFd::from_raw_fd(duplicate) }) + } + + pub(super) fn clear_nonblock(fd: RawFd) -> Result<(), io::Error> { + let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) }; + if flags < 0 { + return Err(io::Error::last_os_error()); + } + if flags & libc::O_NONBLOCK == 0 { + return Ok(()); + } + if unsafe { libc::fcntl(fd, libc::F_SETFL, flags & !libc::O_NONBLOCK) } < 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) + } + + pub(super) fn metadata_from_fd(fd: RawFd) -> Result { + let mut stat = MaybeUninit::::uninit(); + let result = unsafe { libc::fstat(fd, stat.as_mut_ptr()) }; + if result < 0 { + return Err(io::Error::last_os_error()); + } + Ok(metadata_from_stat(unsafe { stat.assume_init() })) + } + + pub(super) fn metadata_at( + directory_fd: RawFd, + name: &[u8], + ) -> Result { + let name = CString::new(name).expect("validated component contains no NUL"); + let mut stat = MaybeUninit::::uninit(); + let result = unsafe { + libc::fstatat( + directory_fd, + name.as_ptr(), + stat.as_mut_ptr(), + libc::AT_SYMLINK_NOFOLLOW, + ) + }; + if result < 0 { + return Err(io::Error::last_os_error()); + } + Ok(metadata_from_stat(unsafe { stat.assume_init() })) + } + + fn metadata_from_stat(stat: libc::stat) -> ConfinedMetadata { + let mode = stat.st_mode as libc::mode_t; + let file_type = match mode & libc::S_IFMT { + libc::S_IFREG => ConfinedFileType::File, + libc::S_IFDIR => ConfinedFileType::Directory, + libc::S_IFLNK => ConfinedFileType::Symlink, + _ => ConfinedFileType::Other, + }; + ConfinedMetadata { + file_type, + len: stat_u64(stat.st_size), + link_count: stat_u64(stat.st_nlink), + } + } + + pub(super) fn create_exclusive_temp( + parent_fd: RawFd, + root_identity: FileIdentity, + name: &str, + max_write_bytes: usize, + ) -> Result { + let name_c = CString::new(name).expect("generated temporary name contains no NUL"); + let parent = duplicate_fd(parent_fd)?; + let fd = unsafe { + libc::openat( + parent_fd, + name_c.as_ptr(), + libc::O_RDWR | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW, + 0o600, + ) + }; + if fd < 0 { + return Err(io::Error::last_os_error()); + } + let file = File::from(unsafe { OwnedFd::from_raw_fd(fd) }); + let initial_identity = match file_identity(file.as_raw_fd()) { + Ok(identity) => identity, + Err(error) => { + drop(file); + let _ = unlink_at(parent.as_raw_fd(), name.as_bytes()); + return Err(error); + } + }; + Ok(ConfinedTempFile { + parent, + root_identity, + file, + name: name.to_owned(), + initial_identity, + max_write_bytes, + written: 0, + completed: false, + }) + } + + pub(super) fn file_identity(fd: RawFd) -> Result { + let mut stat = MaybeUninit::::uninit(); + let result = unsafe { libc::fstat(fd, stat.as_mut_ptr()) }; + if result < 0 { + return Err(io::Error::last_os_error()); + } + let stat = unsafe { stat.assume_init() }; + Ok(FileIdentity { + device: stat_u64(stat.st_dev), + inode: stat_u64(stat.st_ino), + }) + } + + pub(super) fn replace_temp( + temp: &mut ConfinedTempFile, + destination: &str, + ) -> Result { + if temp.completed { + return Err(ConfinedFsError::new( + ConfinedFsErrorKind::TempCompleted, + "fs::replace", + "temporary file has already been completed", + )); + } + #[cfg(target_os = "linux")] + { + replace_temp_linux(temp, destination) + } + #[cfg(not(target_os = "linux"))] + { + let _ = (temp, destination); + Err(unsupported_error("fs::replace")) + } + } + + #[cfg(target_os = "linux")] + fn replace_temp_linux( + temp: &mut ConfinedTempFile, + destination: &str, + ) -> Result { + temp.file + .sync_all() + .map_err(|error| map_unsupported("fs::replace", error))?; + let current_identity = file_identity(temp.file.as_raw_fd()) + .map_err(|error| ConfinedFsError::os("fs::replace", &error))?; + if current_identity != temp.initial_identity { + return Err(ConfinedFsError::new( + ConfinedFsErrorKind::RaceDetected, + "fs::replace", + "temporary file identity changed", + )); + } + let source = match metadata_at(temp.parent.as_raw_fd(), temp.name.as_bytes()) { + Ok(source) => source, + Err(error) if error.raw_os_error() == Some(libc::ENOENT) => { + return Err(ConfinedFsError::new( + ConfinedFsErrorKind::RaceDetected, + "fs::replace", + "temporary source disappeared before replacement", + )); + } + Err(error) => return Err(ConfinedFsError::os("fs::replace", &error)), + }; + if !source.is_file() || source.link_count() != 1 { + return Err(ConfinedFsError::new( + ConfinedFsErrorKind::RaceDetected, + "fs::replace", + "temporary directory entry changed", + )); + } + let source_identity = file_identity_at(temp.parent.as_raw_fd(), temp.name.as_bytes()) + .map_err(|error| ConfinedFsError::os("fs::replace", &error))?; + if source_identity != current_identity { + return Err(ConfinedFsError::new( + ConfinedFsErrorKind::RaceDetected, + "fs::replace", + "temporary directory entry was swapped", + )); + } + #[cfg(test)] + run_replace_test_hook( + temp.parent.as_raw_fd(), + temp.name.as_bytes(), + destination.as_bytes(), + ); + + let destination_identity = + match metadata_at(temp.parent.as_raw_fd(), destination.as_bytes()) { + Ok(destination_metadata) => { + if destination_metadata.file_type == ConfinedFileType::Symlink { + return Err(ConfinedFsError::new( + ConfinedFsErrorKind::SymlinkDenied, + "fs::replace", + "destination symlinks are not permitted", + )); + } + if !destination_metadata.is_file() { + return Err(ConfinedFsError::new( + ConfinedFsErrorKind::WrongType, + "fs::replace", + "atomic replacement requires a regular-file destination", + )); + } + enforce_hardlink_policy("fs::replace", destination_metadata)?; + Some( + file_identity_at(temp.parent.as_raw_fd(), destination.as_bytes()) + .map_err(|error| ConfinedFsError::os("fs::replace", &error))?, + ) + } + Err(error) if error.raw_os_error() == Some(libc::ENOENT) => None, + Err(error) => return Err(ConfinedFsError::os("fs::replace", &error)), + }; + + let publish_mode = match destination_identity { + None => ReplacePublishMode::NoReplace, + Some(expected_destination) => ReplacePublishMode::Exchange { + expected_destination, + }, + }; + + let staging_name = link_temp_inode(temp, current_identity)?; + let staging_bytes = staging_name.as_bytes(); + let source_identity_after_link = + file_identity_at(temp.parent.as_raw_fd(), temp.name.as_bytes()); + if !matches!( + source_identity_after_link, + Ok(identity) if identity == current_identity + ) { + let _ = unlink_exact(temp.parent.as_raw_fd(), staging_bytes, current_identity); + return Err(ConfinedFsError::new( + ConfinedFsErrorKind::RaceDetected, + "fs::replace", + "temporary source changed before publication", + )); + } + if let Err(error) = unlink_exact( + temp.parent.as_raw_fd(), + temp.name.as_bytes(), + current_identity, + ) { + let _ = unlink_exact(temp.parent.as_raw_fd(), staging_bytes, current_identity); + if error.raw_os_error() == Some(libc::ESTALE) + || error.raw_os_error() == Some(libc::ENOENT) + { + return Err(ConfinedFsError::new( + ConfinedFsErrorKind::RaceDetected, + "fs::replace", + "temporary source changed before publication", + )); + } + return Err(ConfinedFsError::os("fs::replace", &error)); + } + + #[cfg(test)] + run_destination_replace_test_hook(temp.parent.as_raw_fd(), destination.as_bytes()); + let publish_result = rename_at2( + temp.parent.as_raw_fd(), + staging_bytes, + temp.parent.as_raw_fd(), + destination.as_bytes(), + publish_mode.flags(), + ); + if let Err(error) = publish_result { + let _ = unlink_exact(temp.parent.as_raw_fd(), staging_bytes, current_identity); + if error.raw_os_error() == Some(libc::EEXIST) + || error.raw_os_error() == Some(libc::ENOENT) + { + return Err(ConfinedFsError::new( + ConfinedFsErrorKind::RaceDetected, + "fs::replace", + "replacement destination changed before publication", + )); + } + return Err(map_unsupported("fs::replace", error)); + } + + #[cfg(test)] + run_post_rename_test_hook( + temp.parent.as_raw_fd(), + staging_bytes, + destination.as_bytes(), + ); + + let destination_entry = observe_entry(temp.parent.as_raw_fd(), destination.as_bytes()) + .map_err(|error| ConfinedFsError::os("fs::replace", &error))?; + let staging_entry = observe_entry(temp.parent.as_raw_fd(), staging_bytes) + .map_err(|error| ConfinedFsError::os("fs::replace", &error))?; + let dest_is_ours = destination_entry + .as_ref() + .is_some_and(|entry| entry.identity == current_identity && entry.metadata.is_file()); + if !dest_is_ours { + cleanup_owned_staging_link( + temp.parent.as_raw_fd(), + staging_bytes, + current_identity, + publish_mode, + ); + let _ = fsync_fd(temp.parent.as_raw_fd()); + return Err(ConfinedFsError::new( + ConfinedFsErrorKind::RaceDetected, + "fs::replace", + "replacement destination does not contain the retained inode", + )); + } + + let staging_cleaned = match publish_mode { + ReplacePublishMode::NoReplace => match staging_entry { + None => true, + Some(staging) + if staging.identity == current_identity && staging.metadata.is_file() => + { + unlink_exact(temp.parent.as_raw_fd(), staging_bytes, current_identity).is_ok() + } + Some(_) => false, + }, + ReplacePublishMode::Exchange { + expected_destination, + } => match staging_entry { + Some(staging) + if staging.identity == expected_destination && staging.metadata.is_file() => + { + match unlink_exact(temp.parent.as_raw_fd(), staging_bytes, expected_destination) + { + Ok(()) => true, + Err(error) if error.raw_os_error() == Some(libc::ENOENT) => true, + Err(_) => false, + } + } + Some(staging) if staging.metadata.is_dir() => { + match reverse_directory_exchange( + temp.parent.as_raw_fd(), + staging_bytes, + destination.as_bytes(), + current_identity, + staging.identity, + ) { + ReverseDirectoryOutcome::DirectoryRestored => { + let _ = unlink_exact( + temp.parent.as_raw_fd(), + staging_bytes, + current_identity, + ); + temp.completed = true; + return Err(ConfinedFsError::new( + ConfinedFsErrorKind::RaceDetected, + "fs::replace", + "replacement destination changed to a directory during publication", + )); + } + ReverseDirectoryOutcome::DestinationPublished => false, + ReverseDirectoryOutcome::Indeterminate { + destination: observed_destination, + staging: observed_staging, + } => { + temp.completed = true; + return Err(ConfinedFsError::new( + ConfinedFsErrorKind::RaceDetected, + "fs::replace", + "replacement publication raced into an indeterminate directory-exchange state", + ) + .with_publication(ConfinedPublicationState::Indeterminate { + destination: observed_destination, + staging: observed_staging, + })); + } + } + } + _ => false, + }, + }; + + temp.completed = true; + let durable = fsync_fd(temp.parent.as_raw_fd()).is_ok(); + Ok(ConfinedPublication::new(durable, staging_cleaned)) + } + + #[cfg(target_os = "linux")] + struct ObservedEntry { + metadata: ConfinedMetadata, + identity: FileIdentity, + } + + #[cfg(target_os = "linux")] + fn observe_entry(parent_fd: RawFd, name: &[u8]) -> Result, io::Error> { + match metadata_at(parent_fd, name) { + Err(error) if error.raw_os_error() == Some(libc::ENOENT) => Ok(None), + Err(error) => Err(error), + Ok(metadata) => Ok(Some(ObservedEntry { + metadata, + identity: file_identity_at(parent_fd, name)?, + })), + } + } + + #[cfg(target_os = "linux")] + enum ReverseDirectoryOutcome { + DestinationPublished, + DirectoryRestored, + Indeterminate { + destination: Option, + staging: Option, + }, + } + + #[cfg(target_os = "linux")] + fn observed_identity(entry: &ObservedEntry) -> ConfinedObservedIdentity { + ConfinedObservedIdentity::new( + entry.metadata.file_type, + entry.identity.device, + entry.identity.inode, + ) + } + + #[cfg(target_os = "linux")] + fn reverse_directory_exchange( + parent_fd: RawFd, + staging: &[u8], + destination: &[u8], + published_file: FileIdentity, + displaced_directory: FileIdentity, + ) -> ReverseDirectoryOutcome { + let pre_destination = observe_entry(parent_fd, destination).ok().flatten(); + let pre_staging = observe_entry(parent_fd, staging).ok().flatten(); + let can_reverse = pre_destination + .as_ref() + .is_some_and(|entry| entry.identity == published_file && entry.metadata.is_file()) + && pre_staging.as_ref().is_some_and(|entry| { + entry.identity == displaced_directory && entry.metadata.is_dir() + }); + if can_reverse { + let fail_syscall = { + #[cfg(test)] + { + FORCE_REVERSE_EXCHANGE_FAIL.with(Cell::get) + } + #[cfg(not(test))] + { + false + } + }; + if !fail_syscall { + let _ = rename_at2(parent_fd, staging, parent_fd, destination, RENAME_EXCHANGE); + } + #[cfg(test)] + { + run_post_reverse_test_hook(parent_fd, staging, destination); + } + } + + let destination_entry = observe_entry(parent_fd, destination).ok().flatten(); + let staging_entry = observe_entry(parent_fd, staging).ok().flatten(); + if destination_entry + .as_ref() + .is_some_and(|entry| entry.identity == published_file && entry.metadata.is_file()) + { + ReverseDirectoryOutcome::DestinationPublished + } else if destination_entry + .as_ref() + .is_some_and(|entry| entry.identity == displaced_directory && entry.metadata.is_dir()) + { + ReverseDirectoryOutcome::DirectoryRestored + } else { + ReverseDirectoryOutcome::Indeterminate { + destination: destination_entry.as_ref().map(observed_identity), + staging: staging_entry.as_ref().map(observed_identity), + } + } + } + + #[cfg(target_os = "linux")] + const RENAME_NOREPLACE: libc::c_uint = 1; + + #[cfg(target_os = "linux")] + const RENAME_EXCHANGE: libc::c_uint = 2; + + #[cfg(target_os = "linux")] + #[derive(Clone, Copy)] + enum ReplacePublishMode { + NoReplace, + Exchange { expected_destination: FileIdentity }, + } + + #[cfg(target_os = "linux")] + impl ReplacePublishMode { + fn flags(self) -> libc::c_uint { + match self { + Self::NoReplace => RENAME_NOREPLACE, + Self::Exchange { .. } => RENAME_EXCHANGE, + } + } + + fn expected_old_destination(self) -> Option { + match self { + Self::NoReplace => None, + Self::Exchange { + expected_destination, + } => Some(expected_destination), + } + } + } + + #[cfg(target_os = "linux")] + fn cleanup_owned_staging_link( + parent_fd: RawFd, + staging: &[u8], + retained: FileIdentity, + mode: ReplacePublishMode, + ) { + match unlink_exact(parent_fd, staging, retained) { + Ok(()) => return, + Err(error) if error.raw_os_error() == Some(libc::ENOENT) => return, + Err(_) => {} + } + if let Some(expected_old_destination) = mode.expected_old_destination() { + let _ = unlink_exact(parent_fd, staging, expected_old_destination); + } + } + + #[cfg(target_os = "linux")] + fn link_temp_inode( + temp: &ConfinedTempFile, + expected: FileIdentity, + ) -> Result { + for _ in 0..MAX_TEMP_ATTEMPTS { + let name = next_temp_name(".rustscript-publish"); + let source_c = + CString::new(temp.name.as_bytes()).expect("generated name contains no NUL"); + let name_c = CString::new(name.as_bytes()).expect("generated name contains no NUL"); + let result = unsafe { + libc::linkat( + temp.parent.as_raw_fd(), + source_c.as_ptr(), + temp.parent.as_raw_fd(), + name_c.as_ptr(), + 0, + ) + }; + if result == 0 { + #[cfg(test)] + run_staging_link_test_hook(temp.parent.as_raw_fd(), name.as_bytes()); + match file_identity_at(temp.parent.as_raw_fd(), name.as_bytes()) { + Ok(identity) if identity == expected => return Ok(name), + _ => { + let _ = unlink_exact(temp.parent.as_raw_fd(), name.as_bytes(), expected); + return Err(ConfinedFsError::new( + ConfinedFsErrorKind::RaceDetected, + "fs::replace", + "publication staging inode changed", + )); + } + } + } + let error = io::Error::last_os_error(); + if error.raw_os_error() == Some(libc::EEXIST) { + continue; + } + if error.raw_os_error() == Some(libc::ENOENT) { + return Err(ConfinedFsError::new( + ConfinedFsErrorKind::RaceDetected, + "fs::replace", + "temporary source disappeared during staging", + )); + } + return Err(map_unsupported("fs::replace", error)); + } + Err(ConfinedFsError::new( + ConfinedFsErrorKind::TempCollision, + "fs::replace", + "publication staging name retry budget exhausted", + )) + } + + #[cfg(target_os = "linux")] + fn rename_at2( + parent_fd: RawFd, + source: &[u8], + destination_parent_fd: RawFd, + destination: &[u8], + flags: libc::c_uint, + ) -> Result<(), io::Error> { + let source = CString::new(source).expect("validated name contains no NUL"); + let destination = CString::new(destination).expect("validated name contains no NUL"); + let result = unsafe { + libc::syscall( + libc::SYS_renameat2, + parent_fd, + source.as_ptr(), + destination_parent_fd, + destination.as_ptr(), + flags, + ) + }; + if result < 0 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } + } + + #[cfg(target_os = "linux")] + fn unlink_exact( + parent_fd: RawFd, + name: &[u8], + expected: FileIdentity, + ) -> Result<(), io::Error> { + let metadata = metadata_at(parent_fd, name)?; + if !metadata.is_file() || file_identity_at(parent_fd, name)? != expected { + return Err(io::Error::from_raw_os_error(libc::ESTALE)); + } + unlink_at(parent_fd, name) + } + + #[cfg(target_os = "linux")] + fn fsync_fd(fd: RawFd) -> Result<(), io::Error> { + #[cfg(test)] + if FORCE_FSYNC_FAIL.with(Cell::get) { + return Err(io::Error::from_raw_os_error(libc::EIO)); + } + if unsafe { libc::fsync(fd) } < 0 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } + } + + #[cfg(test)] + pub(super) type ReplaceTestHook = fn(RawFd, &[u8], &[u8]); + #[cfg(test)] + pub(super) type DestinationReplaceTestHook = fn(RawFd, &[u8]); + #[cfg(test)] + pub(super) type StagingLinkTestHook = fn(RawFd, &[u8]); + #[cfg(test)] + pub(super) type PostRenameTestHook = fn(RawFd, &[u8], &[u8]); + #[cfg(test)] + pub(super) type PostReverseTestHook = fn(RawFd, &[u8], &[u8]); + + #[cfg(test)] + thread_local! { + static REPLACE_TEST_HOOK: Cell> = const { Cell::new(None) }; + static DESTINATION_REPLACE_TEST_HOOK: Cell> = + const { Cell::new(None) }; + static STAGING_LINK_TEST_HOOK: Cell> = const { Cell::new(None) }; + static POST_RENAME_TEST_HOOK: Cell> = const { Cell::new(None) }; + static POST_REVERSE_TEST_HOOK: Cell> = const { Cell::new(None) }; + static STAGING_LINK_CAPTURE: RefCell>> = const { RefCell::new(None) }; + static FORCE_FSYNC_FAIL: Cell = const { Cell::new(false) }; + static FORCE_REVERSE_EXCHANGE_FAIL: Cell = const { Cell::new(false) }; + } + + #[cfg(test)] + static REPLACE_TEST_LOCK: std::sync::OnceLock> = + std::sync::OnceLock::new(); + + #[cfg(test)] + pub(super) fn set_replace_test_hook(hook: Option) { + REPLACE_TEST_HOOK.with(|slot| slot.set(hook)); + } + + #[cfg(test)] + pub(super) fn set_destination_replace_test_hook(hook: Option) { + DESTINATION_REPLACE_TEST_HOOK.with(|slot| slot.set(hook)); + } + + #[cfg(test)] + pub(super) fn set_staging_link_test_hook(hook: Option) { + STAGING_LINK_TEST_HOOK.with(|slot| slot.set(hook)); + } + + #[cfg(test)] + pub(super) fn set_post_rename_test_hook(hook: Option) { + POST_RENAME_TEST_HOOK.with(|slot| slot.set(hook)); + } + + #[cfg(test)] + pub(super) fn replace_test_lock() -> std::sync::MutexGuard<'static, ()> { + REPLACE_TEST_LOCK + .get_or_init(|| std::sync::Mutex::new(())) + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + #[cfg(test)] + pub(super) fn take_staging_link_capture() -> Option> { + STAGING_LINK_CAPTURE.with(|slot| slot.borrow_mut().take()) + } + + #[cfg(test)] + pub(super) fn capture_staging_link_name(name: &[u8]) { + STAGING_LINK_CAPTURE.with(|slot| *slot.borrow_mut() = Some(name.to_vec())); + } + + #[cfg(test)] + pub(super) fn destination_replace_test_hook_installed() -> Option { + DESTINATION_REPLACE_TEST_HOOK.with(Cell::get) + } + + #[cfg(test)] + pub(super) fn replace_test_hook_installed() -> Option { + REPLACE_TEST_HOOK.with(Cell::get) + } + + #[cfg(test)] + pub(super) fn staging_link_test_hook_installed() -> Option { + STAGING_LINK_TEST_HOOK.with(Cell::get) + } + + #[cfg(test)] + pub(super) fn post_rename_test_hook_installed() -> Option { + POST_RENAME_TEST_HOOK.with(Cell::get) + } + + #[cfg(test)] + pub(super) struct ReplaceHookGuard { + previous: Option, + } + + #[cfg(test)] + impl ReplaceHookGuard { + pub(super) fn new(hook: ReplaceTestHook) -> Self { + let previous = REPLACE_TEST_HOOK.with(|slot| slot.replace(Some(hook))); + Self { previous } + } + } + + #[cfg(test)] + impl Drop for ReplaceHookGuard { + fn drop(&mut self) { + set_replace_test_hook(self.previous); + } + } + + #[cfg(test)] + pub(super) struct StagingLinkHookGuard { + previous: Option, + } + + #[cfg(test)] + impl StagingLinkHookGuard { + pub(super) fn new(hook: StagingLinkTestHook) -> Self { + let previous = STAGING_LINK_TEST_HOOK.with(|slot| slot.replace(Some(hook))); + Self { previous } + } + } + + #[cfg(test)] + impl Drop for StagingLinkHookGuard { + fn drop(&mut self) { + set_staging_link_test_hook(self.previous); + } + } + + #[cfg(test)] + pub(super) struct PostRenameHookGuard { + previous: Option, + } + + #[cfg(test)] + impl PostRenameHookGuard { + pub(super) fn new(hook: PostRenameTestHook) -> Self { + let previous = POST_RENAME_TEST_HOOK.with(|slot| slot.replace(Some(hook))); + Self { previous } + } + } + + #[cfg(test)] + impl Drop for PostRenameHookGuard { + fn drop(&mut self) { + set_post_rename_test_hook(self.previous); + } + } + + #[cfg(test)] + pub(super) struct DestinationReplaceHookGuard { + previous: Option, + } + + #[cfg(test)] + impl DestinationReplaceHookGuard { + pub(super) fn new(hook: DestinationReplaceTestHook) -> Self { + let previous = DESTINATION_REPLACE_TEST_HOOK.with(|slot| slot.replace(Some(hook))); + Self { previous } + } + } + + #[cfg(test)] + impl Drop for DestinationReplaceHookGuard { + fn drop(&mut self) { + set_destination_replace_test_hook(self.previous); + } + } + + #[cfg(test)] + pub(super) fn force_fsync_fail_enabled() -> bool { + FORCE_FSYNC_FAIL.with(Cell::get) + } + + #[cfg(test)] + pub(super) struct ForceFsyncFailGuard { + previous: bool, + } + + #[cfg(test)] + impl ForceFsyncFailGuard { + pub(super) fn new() -> Self { + let previous = FORCE_FSYNC_FAIL.with(|flag| flag.replace(true)); + Self { previous } + } + } + + #[cfg(test)] + impl Drop for ForceFsyncFailGuard { + fn drop(&mut self) { + FORCE_FSYNC_FAIL.with(|flag| flag.set(self.previous)); + } + } + + #[cfg(test)] + pub(super) struct ReverseExchangeFailGuard { + previous: bool, + } + + #[cfg(test)] + impl ReverseExchangeFailGuard { + pub(super) fn new() -> Self { + let previous = FORCE_REVERSE_EXCHANGE_FAIL.with(|flag| flag.replace(true)); + Self { previous } + } + } + + #[cfg(test)] + impl Drop for ReverseExchangeFailGuard { + fn drop(&mut self) { + FORCE_REVERSE_EXCHANGE_FAIL.with(|flag| flag.set(self.previous)); + } + } + + #[cfg(test)] + pub(super) struct PostReverseHookGuard { + previous: Option, + } + + #[cfg(test)] + impl PostReverseHookGuard { + pub(super) fn new(hook: PostReverseTestHook) -> Self { + let previous = POST_REVERSE_TEST_HOOK.with(|slot| slot.replace(Some(hook))); + Self { previous } + } + } + + #[cfg(test)] + impl Drop for PostReverseHookGuard { + fn drop(&mut self) { + POST_REVERSE_TEST_HOOK.with(|slot| slot.set(self.previous)); + } + } + + #[cfg(test)] + fn run_replace_test_hook(parent_fd: RawFd, source: &[u8], destination: &[u8]) { + if let Some(hook) = REPLACE_TEST_HOOK.with(Cell::take) { + hook(parent_fd, source, destination); + } + } + + #[cfg(test)] + fn run_destination_replace_test_hook(parent_fd: RawFd, destination: &[u8]) { + if let Some(hook) = DESTINATION_REPLACE_TEST_HOOK.with(Cell::take) { + hook(parent_fd, destination); + } + } + + #[cfg(test)] + fn run_staging_link_test_hook(parent_fd: RawFd, staging: &[u8]) { + if let Some(hook) = STAGING_LINK_TEST_HOOK.with(Cell::take) { + hook(parent_fd, staging); + } + } + + #[cfg(test)] + fn run_post_rename_test_hook(parent_fd: RawFd, staging: &[u8], destination: &[u8]) { + if let Some(hook) = POST_RENAME_TEST_HOOK.with(Cell::take) { + hook(parent_fd, staging, destination); + } + } + + #[cfg(test)] + fn run_post_reverse_test_hook(parent_fd: RawFd, staging: &[u8], destination: &[u8]) { + if let Some(hook) = POST_REVERSE_TEST_HOOK.with(Cell::take) { + hook(parent_fd, staging, destination); + } + } + + #[cfg(target_os = "linux")] + fn map_unsupported(operation: &'static str, error: io::Error) -> ConfinedFsError { + if error.raw_os_error().is_some_and(|code| { + code == libc::ENOSYS + || code == libc::EINVAL + || code == libc::EOPNOTSUPP + || code == libc::ENOTSUP + }) { + unsupported_error(operation) + } else { + ConfinedFsError::os(operation, &error) + } + } + + pub(super) fn file_identity_at( + directory_fd: RawFd, + name: &[u8], + ) -> Result { + let name = CString::new(name).expect("validated component contains no NUL"); + let mut stat = MaybeUninit::::uninit(); + let result = unsafe { + libc::fstatat( + directory_fd, + name.as_ptr(), + stat.as_mut_ptr(), + libc::AT_SYMLINK_NOFOLLOW, + ) + }; + if result < 0 { + return Err(io::Error::last_os_error()); + } + let stat = unsafe { stat.assume_init() }; + Ok(FileIdentity { + device: stat_u64(stat.st_dev), + inode: stat_u64(stat.st_ino), + }) + } + + pub(super) fn unlink_at(parent_fd: RawFd, name: &[u8]) -> Result<(), io::Error> { + let name = CString::new(name).expect("validated component contains no NUL"); + let result = unsafe { libc::unlinkat(parent_fd, name.as_ptr(), 0) }; + if result < 0 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } + } + + pub(super) fn enumerate_directory( + directory: OwnedFd, + max_entries: usize, + max_name_bytes: usize, + ) -> Result, ConfinedFsError> { + let raw_directory = directory.into_raw_fd(); + let stream = unsafe { libc::fdopendir(raw_directory) }; + if stream.is_null() { + let error = io::Error::last_os_error(); + unsafe { libc::close(raw_directory) }; + return Err(ConfinedFsError::os("fs::enumerate", &error)); + } + let guard = DirGuard(stream); + let mut entries = Vec::with_capacity(max_entries.min(64)); + let directory_fd = guard_fd(guard.0); + if directory_fd < 0 { + return Err(ConfinedFsError::os( + "fs::enumerate", + &io::Error::last_os_error(), + )); + } + let mut examined = 0usize; + loop { + clear_errno(); + let entry = unsafe { libc::readdir(guard.0) }; + if entry.is_null() { + match errno_abi() { + ErrnoAbi::Known(0) => break, + ErrnoAbi::Known(code) => { + return Err(ConfinedFsError::os( + "fs::enumerate", + &io::Error::from_raw_os_error(code), + )); + } + ErrnoAbi::Unsupported => { + return Err(unsupported_error("fs::enumerate")); + } + } + } + examined = examined.saturating_add(1); + if examined > max_entries { + return Err(ConfinedFsError::budget( + "fs::enumerate", + "directory entry examination budget exceeded", + max_entries, + examined, + )); + } + let name = unsafe { CStr::from_ptr((*entry).d_name.as_ptr()) }; + let name_bytes = name.to_bytes(); + if name_bytes == b"." || name_bytes == b".." { + continue; + } + if name_bytes.len() > max_name_bytes { + return Err(ConfinedFsError::budget( + "fs::enumerate", + "directory entry name budget exceeded", + max_name_bytes, + name_bytes.len(), + )); + } + let metadata = match metadata_at(directory_fd, name_bytes) { + Ok(metadata) => metadata, + Err(error) if error.raw_os_error() == Some(libc::ENOENT) => continue, + Err(error) => return Err(ConfinedFsError::os("fs::enumerate", &error)), + }; + let metadata = enforce_hardlink_policy("fs::enumerate", metadata)?; + let name_os = OsString::from_vec(name_bytes.to_vec()); + entries.push(ConfinedDirEntry { + name: String::from_utf8_lossy(name_bytes).into_owned(), + name_os, + metadata, + }); + } + Ok(entries) + } + + fn guard_fd(stream: *mut libc::DIR) -> RawFd { + unsafe { libc::dirfd(stream) } + } + + fn clear_errno() { + #[cfg(any(target_os = "linux", target_os = "android"))] + unsafe { + *libc::__errno_location() = 0; + } + #[cfg(any( + target_os = "dragonfly", + target_os = "freebsd", + target_os = "ios", + target_os = "macos", + target_os = "netbsd", + target_os = "openbsd" + ))] + unsafe { + *libc::__error() = 0; + } + } + + enum ErrnoAbi { + Known(i32), + #[allow(dead_code)] + Unsupported, + } + + fn errno_abi() -> ErrnoAbi { + #[cfg(any(target_os = "linux", target_os = "android"))] + { + ErrnoAbi::Known(unsafe { *libc::__errno_location() }) + } + #[cfg(any( + target_os = "dragonfly", + target_os = "freebsd", + target_os = "ios", + target_os = "macos", + target_os = "netbsd", + target_os = "openbsd" + ))] + { + ErrnoAbi::Known(unsafe { *libc::__error() }) + } + #[cfg(not(any( + target_os = "linux", + target_os = "android", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "ios", + target_os = "macos", + target_os = "netbsd", + target_os = "openbsd" + )))] + { + ErrnoAbi::Unsupported + } + } + + struct DirGuard(*mut libc::DIR); + + impl Drop for DirGuard { + fn drop(&mut self) { + unsafe { + libc::closedir(self.0); + } + } + } +} diff --git a/src/capabilities/vm_io/mod.rs b/src/capabilities/vm_io/mod.rs new file mode 100644 index 0000000..6fcdf27 --- /dev/null +++ b/src/capabilities/vm_io/mod.rs @@ -0,0 +1,21 @@ +//! Agent-owned confined filesystem and bounded process backends. +//! +//! Frozen core no longer exports these types. The implementations are vendored +//! from the last SHA that published them so capability behavior stays intact. + +#![allow(dead_code)] +#![allow(clippy::result_large_err)] + +pub mod bounded_process; +pub mod confined_fs; +mod shared; + +pub use bounded_process::{ + BoundedProcess, BoundedProcessError, BoundedProcessHandle, BoundedProcessRequest, + CancellationToken, LogSnapshot, MAX_OUTPUT_BYTES, MAX_STDIN_BYTES, MAX_TIMEOUT, ProcessStatus, +}; +pub use confined_fs::{ + ConfinedFileType, ConfinedFsError, ConfinedFsErrorKind, ConfinedFsLimits, ConfinedFsRoot, + ConfinedMetadata, ConfinedPublicationState, MAX_COMPONENT_BYTES, MAX_ENUM_ENTRIES, + MAX_READ_BYTES, MAX_WRITE_BYTES, +}; diff --git a/src/capabilities/vm_io/shared.rs b/src/capabilities/vm_io/shared.rs new file mode 100644 index 0000000..6883c6a --- /dev/null +++ b/src/capabilities/vm_io/shared.rs @@ -0,0 +1,41 @@ +//! Pipe and process-group helpers used by the vendored bounded-process backend. + +#[cfg(unix)] +pub(super) fn set_pipe_nonblocking(pipe: &impl std::os::fd::AsRawFd) -> std::io::Result<()> { + let fd = pipe.as_raw_fd(); + // SAFETY: `fd` is borrowed from a live child-pipe object for the duration + // of each fcntl call; no ownership is transferred. + let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) }; + if flags < 0 { + return Err(std::io::Error::last_os_error()); + } + // SAFETY: same valid borrowed descriptor, with the existing flags retained. + if unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) } < 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) +} + +#[cfg(windows)] +pub(super) fn set_pipe_nonblocking( + _pipe: &impl std::os::windows::io::AsRawHandle, +) -> std::io::Result<()> { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "Windows child pipes require cancellable I/O rather than PIPE_NOWAIT", + )) +} + +#[cfg(unix)] +pub(crate) fn terminate_process_group(process_id: u32) { + if let Ok(pid) = libc::pid_t::try_from(process_id) { + unsafe { + libc::kill(-pid, libc::SIGKILL); + } + } +} + +#[cfg(not(unix))] +pub(crate) fn terminate_process_group(process_id: u32) { + let _ = process_id; +} diff --git a/src/config.rs b/src/config.rs index 9089f86..ec65d87 100644 --- a/src/config.rs +++ b/src/config.rs @@ -9,9 +9,10 @@ use std::time::{Duration, Instant}; use crate::runtime::rss_runner::MAX_RUN_TIMEOUT; -use rustscript_vm::{ - HttpConfig, MAX_ENUM_ENTRIES, MAX_OUTPUT_BYTES, MAX_STDIN_BYTES, MAX_TIMEOUT, SqlitePolicy, +use crate::capabilities::vm_io::{ + MAX_ENUM_ENTRIES, MAX_OUTPUT_BYTES, MAX_STDIN_BYTES, MAX_TIMEOUT, }; +use rustscript_vm::{HttpConfig, SqlitePolicy}; use serde_json::{Map, Value, json}; pub use crate::config_file::{AgentPaths, BoundedPublicConfig, ConfigPaths}; @@ -2431,7 +2432,7 @@ mod tests { admission_query_column_names(ADMISSION_IDEMPOTENCY_LOOKUP_COLUMNS) ); assert!( - source.contains("max_result_bytes: 8192"), + source.contains("sql_limits(1, 8192)"), "pre-commit idempotency SELECT must keep the 8192-byte budget" ); assert_eq!(ADMISSION_IDEMPOTENCY_QUERY_LIMIT_BYTES, 8192); diff --git a/src/config_host.rs b/src/config_host.rs index 8d97b6d..a9799ea 100644 --- a/src/config_host.rs +++ b/src/config_host.rs @@ -10,10 +10,10 @@ use std::sync::{Arc, OnceLock}; use std::time::{Duration, Instant}; use rustscript_vm::{ - CallOutcome, CallReturn, CompileSourceFileOptions, HostApiBuilder, HostApiCatalog, - HostFunctionRegistry, HostFunctionSchema, HostParamSchema, HostTypeSchema, Program, - SourceFlavor, Value, Vm, VmResult, VmStatus, catalog_import_schemas, - compile_source_at_path_with_flavor_and_options, standard_host_catalog, + CallOutcome, CallReturn, CompileSourceFileOptions, HostApiCatalog, HostFunctionDescriptor, + HostFunctionRegistry, HostFunctionSchema, HostModuleDescriptor, HostParamSchema, + HostTypeSchema, Program, SourceFlavor, Value, Vm, VmResult, VmStatus, + compile_source_at_path_with_flavor_and_options, }; use serde_json::{Value as JsonValue, json}; @@ -44,54 +44,62 @@ struct ConfigFixtureState { pub fn config_fixture_catalog() -> Arc { static CATALOG: OnceLock> = OnceLock::new(); Arc::clone(CATALOG.get_or_init(|| { - let standard = standard_host_catalog(); - let mut builder = HostApiBuilder::new(); - for resource in standard.resources() { - builder.resource(resource.clone()); - } - for function in standard.functions() { - builder.function(function.clone()); - } - let response = HostTypeSchema::Map(Box::new(HostTypeSchema::Unknown)); - register_catalog_functions(&mut builder, response); - Arc::new(builder.build().expect("config fixture catalog must build")) + crate::runtime::host_compose::compose_with_standard(&[config_fixture_module()]) })) } -pub(crate) fn register_catalog_functions(builder: &mut HostApiBuilder, response: HostTypeSchema) { - builder.function(HostFunctionSchema::with_return( +const CONFIG_FIXTURE_FUNCTIONS: &[fn() -> HostFunctionDescriptor] = &[ + config_load_snapshot_descriptor, + config_check_policy_descriptor, +]; + +pub fn config_fixture_module() -> HostModuleDescriptor { + HostModuleDescriptor { + name: "config_fixture", + functions: CONFIG_FIXTURE_FUNCTIONS, + resources: &[], + } +} + +fn fixture_map() -> HostTypeSchema { + HostTypeSchema::Map(Box::new(HostTypeSchema::Unknown)) +} + +fn stack_desc( + name: &'static str, + params: Vec, + adapter: fn(&mut Vm, &[Value]) -> VmResult, +) -> HostFunctionDescriptor { + crate::runtime::host_compose::static_stack_descriptor( + HostFunctionSchema::with_return(name, params, fixture_map()), + adapter, + ) +} + +fn config_load_snapshot_descriptor() -> HostFunctionDescriptor { + stack_desc( CONFIG_LOAD_SNAPSHOT, vec![HostParamSchema::value("host_home", HostTypeSchema::Unknown)], - response.clone(), - )); - builder.function(HostFunctionSchema::with_return( + load_snapshot_adapter, + ) +} + +fn config_check_policy_descriptor() -> HostFunctionDescriptor { + stack_desc( CONFIG_CHECK_POLICY, vec![ HostParamSchema::value("policy_handle", HostTypeSchema::Unknown), HostParamSchema::value("intent", HostTypeSchema::Unknown), ], - response, - )); + check_policy_adapter, + ) } pub(crate) fn register_host_functions( registry: &mut HostFunctionRegistry, catalog: &HostApiCatalog, ) -> VmResult<()> { - register_named( - registry, - catalog, - CONFIG_LOAD_SNAPSHOT, - 1, - load_snapshot_adapter, - )?; - register_named( - registry, - catalog, - CONFIG_CHECK_POLICY, - 2, - check_policy_adapter, - )?; + config_fixture_module().install_from_catalog(registry, catalog)?; Ok(()) } @@ -139,7 +147,7 @@ impl ConfigFixtureHost { let mut registry = HostFunctionRegistry::restricted(); register_host_functions(&mut registry, catalog.as_ref()) .map_err(|error| error.to_string())?; - let mut vm = Vm::try_new_shared(program).map_err(|error| error.to_string())?; + let mut vm = Vm::new_shared(program); registry .bind_vm_cached(&mut vm) .map_err(|error| error.to_string())?; @@ -211,7 +219,7 @@ fn drive_root_frame(vm: &mut Vm) -> Result<(), String> { match vm.run() { Ok(VmStatus::Halted) => return Ok(()), Ok(VmStatus::Waiting(_)) => { - vm.wait_for_host_op_blocking_with_cancel(|| false) + crate::runtime::host_wait::wait_for_host_op_blocking_with_cancel(vm, || false) .map_err(|error| error.to_string())?; } Ok(VmStatus::Yielded) => { @@ -222,21 +230,6 @@ fn drive_root_frame(vm: &mut Vm) -> Result<(), String> { } } -fn register_named( - registry: &mut HostFunctionRegistry, - catalog: &HostApiCatalog, - name: &str, - arity: u8, - adapter: fn(&mut Vm, &[Value]) -> VmResult, -) -> VmResult<()> { - for schema in catalog_import_schemas(catalog, name) { - registry.register_exact_static(name, arity, schema, adapter)?; - } - registry.register_static(name, arity, adapter); - registry.allow_builtin(name)?; - Ok(()) -} - fn load_snapshot_adapter(vm: &mut Vm, args: &[Value]) -> VmResult { let (bound_home, policies) = { let context = vm.host_context(); diff --git a/src/gateway/api_server.rs b/src/gateway/api_server.rs index d019004..39e506d 100644 --- a/src/gateway/api_server.rs +++ b/src/gateway/api_server.rs @@ -14,6 +14,7 @@ use std::{ time::{Duration, Instant}, }; +use crate::runtime::cancellation::CancellationReason; use axum::{ Json, Router, extract::{ConnectInfo, DefaultBodyLimit, Path, Query, Request, State}, @@ -30,7 +31,6 @@ use axum::{ }; use futures_util::stream::{self, Stream}; use parking_lot::Mutex; -use rustscript_vm::CancellationReason; use serde::Deserialize; use serde_json::{Value, json}; use tokio::sync::broadcast; diff --git a/src/gateway/store.rs b/src/gateway/store.rs index a337732..f90d653 100644 --- a/src/gateway/store.rs +++ b/src/gateway/store.rs @@ -372,7 +372,7 @@ impl GatewayPersistence { .to_string(), }); } - Ok(result.get("data").cloned().unwrap_or(Value::Null)) + decode_command_data(result.get("data").cloned().unwrap_or(Value::Null)) } // ------------------------------------------------------------------ @@ -1063,16 +1063,11 @@ impl GatewayStore { } fn load_rows(data: &Value, key: &str) -> Result>, String> { - data.get(key) - .and_then(Value::as_array) - .ok_or_else(|| format!("load.all result omitted {key} rows"))? - .iter() - .map(|row| { - row.as_array() - .cloned() - .ok_or_else(|| format!("load.all {key} row is not an array")) - }) - .collect() + let nested = data + .get(key) + .ok_or_else(|| format!("load.all result omitted {key} rows"))?; + crate::sqlite_storage_rows::sqlite_storage_rows(nested) + .map_err(|error| format!("load.all {key}: {error}")) } fn string_cell(row: &[Value], index: usize, label: &str) -> Result { @@ -1105,19 +1100,39 @@ fn json_cell(row: &[Value], index: usize, label: &str) -> Result } } +fn decode_command_data(data: Value) -> Result { + if data.get("rows").is_none() { + return Ok(data); + } + let rows = + crate::sqlite_storage_rows::sqlite_storage_rows(&data).map_err(|message| StorageError { + code: "storage_error".to_string(), + message, + })?; + match data { + Value::Object(mut map) => { + map.insert("rows".into(), json!(rows)); + Ok(Value::Object(map)) + } + other => Ok(other), + } +} + fn first_rows_affected(data: &Value) -> i64 { - data.get("results") + let Some(first) = data + .get("results") .and_then(Value::as_array) .and_then(|rows| rows.first()) - .and_then(|row| row.get("rows_affected")) + else { + return 0; + }; + if first.get("kind").and_then(Value::as_str) != Some("execute") { + return 0; + } + first + .get("execute") + .and_then(|exec| exec.get("rows_affected")) .and_then(Value::as_i64) - .or_else(|| { - data.as_array() - .and_then(|rows| rows.first()) - .and_then(|row| row.get("rows_affected")) - .and_then(Value::as_i64) - }) - .or_else(|| data.get("rows_affected").and_then(Value::as_i64)) .unwrap_or(0) } diff --git a/src/gateway/telegram.rs b/src/gateway/telegram.rs index 542c97d..59a5a36 100644 --- a/src/gateway/telegram.rs +++ b/src/gateway/telegram.rs @@ -2325,11 +2325,9 @@ async fn render_event( /// Reads one delivery cursor row's `last_event_seq` (0 when absent). fn cursor_from_rows(data: &Value) -> Option { - data.get("rows") - .and_then(Value::as_array) - .and_then(|rows| rows.first()) - .and_then(|row| row.get(2)) - .and_then(Value::as_i64) + crate::sqlite_storage_rows::sqlite_storage_first_row(data) + .and_then(|row| row.get(2).cloned()) + .and_then(|value| value.as_i64()) } async fn load_cursor(state: &AgentGatewayState, session_id: &str, consumer: &str) -> i64 { @@ -2442,19 +2440,16 @@ async fn replay_run_events( /// Parses one `event.replay` page into (seq, event_type, data) rows. fn replay_rows(data: &Value) -> Vec<(i64, String, Value)> { - data.get("rows") - .and_then(Value::as_array) - .map(|rows| { - rows.iter() - .filter_map(|row| { - let seq = row.get(0)?.as_i64()?; - let event_type = row.get(3)?.as_str()?.to_string(); - let payload = serde_json::from_str(row.get(4)?.as_str()?).ok()?; - Some((seq, event_type, payload)) - }) - .collect() - }) + crate::sqlite_storage_rows::sqlite_storage_rows(data) .unwrap_or_default() + .into_iter() + .filter_map(|row| { + let seq = row.first()?.as_i64()?; + let event_type = row.get(3)?.as_str()?.to_string(); + let payload = serde_json::from_str(row.get(4)?.as_str()?).ok()?; + Some((seq, event_type, payload)) + }) + .collect() } #[cfg(test)] diff --git a/src/host_opaque.rs b/src/host_opaque.rs index e46f270..2ec2b7d 100644 --- a/src/host_opaque.rs +++ b/src/host_opaque.rs @@ -224,7 +224,7 @@ mod tests { match vm.run() { Ok(VmStatus::Halted) => return, Ok(VmStatus::Waiting(_)) => { - vm.wait_for_host_op_blocking_with_cancel(|| false) + crate::runtime::host_wait::wait_for_host_op_blocking_with_cancel(vm, || false) .unwrap_or_else(|error| panic!("root wait failed: {error}")); } Ok(status) => panic!("unexpected root status: {status:?}"), @@ -291,7 +291,7 @@ pub fn touch() -> int { registry .allow_builtin("probe::touch") .expect("allow probe::touch"); - let mut vm = Vm::try_new_shared(Arc::new(program)).expect("probe vm"); + let mut vm = Vm::new_shared(Arc::new(program)); registry .bind_vm_cached(&mut vm) .expect("bind probe registry"); diff --git a/src/lib.rs b/src/lib.rs index 4a8b5ca..7c90efc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -22,6 +22,7 @@ pub mod prompt; pub mod registry; pub mod runtime; pub mod service; +pub mod sqlite_storage_rows; pub mod tool_result; pub mod tool_schema; @@ -66,6 +67,7 @@ pub use registry::{ SchemaValidationError, SchemaValidationErrorKind, ToolRegistry, ToolRegistryEntry, ToolRegistryError, ToolRegistrySnapshot, validate_json_schema, }; +pub use runtime::cancellation::{CancellationReason, CancellationToken}; pub use runtime::rss_runner::{ AgentConfig, AgentError, AgentRunner, MAX_AGENT_SOURCE_BYTES, RUN_EPOCH_CHECK_INTERVAL, RUN_EPOCH_DEADLINE_TICKS, Result, RunCancellation, RunDeliveryError, RunError, RunEventSink, diff --git a/src/prompt/coding.rs b/src/prompt/coding.rs index 56d9efe..a45d1e8 100644 --- a/src/prompt/coding.rs +++ b/src/prompt/coding.rs @@ -1,7 +1,7 @@ use std::path::Path; use std::time::{SystemTime, UNIX_EPOCH}; -use rustscript_vm::{ +use crate::capabilities::vm_io::{ ConfinedFileType, ConfinedFsLimits, ConfinedFsRoot, MAX_COMPONENT_BYTES, MAX_READ_BYTES, }; use serde_json::{Map, Value, json}; diff --git a/src/runtime/agent_host.rs b/src/runtime/agent_host.rs index 5e10275..ad141e2 100644 --- a/src/runtime/agent_host.rs +++ b/src/runtime/agent_host.rs @@ -10,10 +10,12 @@ use std::thread; use std::time::{Duration, Instant}; use rustscript_vm::{ - CallOutcome, CallReturn, CancellationReason, HostApiBuilder, HostApiCatalog, - HostFunctionRegistry, HostFunctionSchema, HostParamSchema, HostTypeSchema, Value, Vm, VmError, - VmResult, catalog_import_schemas, standard_host_catalog, + CallOutcome, CallReturn, HostApiCatalog, HostFunctionDescriptor, HostFunctionRegistry, + HostFunctionSchema, HostModuleDescriptor, HostNamedStruct, HostParamSchema, HostTypeSchema, + Value, Vm, VmError, VmResult, }; + +use super::cancellation::CancellationReason; use serde_json::{Value as JsonValue, json}; use super::rss_runner::RunCancellation; @@ -52,178 +54,332 @@ const PARSE_JSON_OBJECT_MAX_BYTES: usize = 64 * 1024; /// Combined catalog: standard host surfaces plus the agent loop bridges. pub fn agent_host_catalog() -> Arc { static CATALOG: std::sync::OnceLock> = std::sync::OnceLock::new(); - Arc::clone(CATALOG.get_or_init(|| { - let standard = standard_host_catalog(); - let mut builder = HostApiBuilder::new(); - for resource in standard.resources() { - builder.resource(resource.clone()); - } - for function in standard.functions() { - builder.function(function.clone()); - } - let response = HostTypeSchema::Map(Box::new(HostTypeSchema::Unknown)); - builder.function(HostFunctionSchema::with_return( - PROVIDER_CALL, - vec![HostParamSchema::value("request", HostTypeSchema::Unknown)], - response.clone(), - )); - builder.function(HostFunctionSchema::with_return( - SLEEP_MS, - vec![HostParamSchema::value("delay_ms", HostTypeSchema::Int)], - HostTypeSchema::Int, - )); - builder.function(HostFunctionSchema::with_return( - CONTROL_CHECK, - vec![], - response.clone(), - )); - builder.function(HostFunctionSchema::with_return( - TOOL_PREPARE, - vec![HostParamSchema::value("metadata", HostTypeSchema::Unknown)], - response.clone(), - )); - builder.function(HostFunctionSchema::with_return( - TOOL_COMMIT, - vec![ - HostParamSchema::value("execution_token", HostTypeSchema::String), - HostParamSchema::value("result", HostTypeSchema::Unknown), - ], - response.clone(), - )); - let token = HostParamSchema::value("execution_token", HostTypeSchema::String); - let path = HostParamSchema::value("path", HostTypeSchema::String); - let handle = HostParamSchema::value("handle", HostTypeSchema::String); - let offset = HostParamSchema::value("offset", HostTypeSchema::Int); - let limit = HostParamSchema::value("limit", HostTypeSchema::Int); - let cursor = HostParamSchema::value("cursor", HostTypeSchema::Int); - builder.function(HostFunctionSchema::with_return( - CAP_FS_METADATA, - vec![token.clone(), path.clone()], - response.clone(), - )); - builder.function(HostFunctionSchema::with_return( - CAP_FS_READ_RANGE, - vec![token.clone(), path.clone(), offset, limit.clone()], - response.clone(), - )); - builder.function(HostFunctionSchema::with_return( - CAP_FS_LIST, - vec![token.clone(), path.clone(), cursor.clone(), limit.clone()], - response.clone(), - )); - builder.function(HostFunctionSchema::with_return( - CAP_FS_WRITE_ATOMIC, - vec![ - token.clone(), - path, - HostParamSchema::value("expected_hash", HostTypeSchema::String), - HostParamSchema::value("bytes", HostTypeSchema::Unknown), - ], - response.clone(), - )); - builder.function(HostFunctionSchema::with_return( - CAP_PROCESS_SPAWN, - vec![ - token.clone(), - HostParamSchema::value( - "argv", - HostTypeSchema::Array(Box::new(HostTypeSchema::String)), - ), - HostParamSchema::value("cwd", HostTypeSchema::String), - HostParamSchema::value( - "env_names", - HostTypeSchema::Array(Box::new(HostTypeSchema::String)), - ), - HostParamSchema::value("limits", HostTypeSchema::Unknown), - HostParamSchema::value("stdin", HostTypeSchema::Unknown), - ], - response.clone(), - )); - builder.function(HostFunctionSchema::with_return( - CAP_PROCESS_POLL, - vec![token.clone(), handle.clone(), cursor.clone(), limit.clone()], - response.clone(), - )); - builder.function(HostFunctionSchema::with_return( - CAP_PROCESS_WAIT, - vec![ - token.clone(), - handle.clone(), - HostParamSchema::value("timeout_ms", HostTypeSchema::Int), - ], - response.clone(), - )); - builder.function(HostFunctionSchema::with_return( - CAP_PROCESS_LOG, - vec![token.clone(), handle.clone(), cursor, limit], - response.clone(), - )); - builder.function(HostFunctionSchema::with_return( - CAP_PROCESS_WRITE, - vec![ - token.clone(), - handle.clone(), - HostParamSchema::value("bytes", HostTypeSchema::Unknown), - HostParamSchema::value("timeout_ms", HostTypeSchema::Int), - ], - response.clone(), - )); - builder.function(HostFunctionSchema::with_return( - CAP_PROCESS_CLOSE, - vec![token.clone(), handle.clone()], - response.clone(), - )); - builder.function(HostFunctionSchema::with_return( - CAP_PROCESS_KILL, - vec![token.clone(), handle], - response.clone(), - )); - builder.function(HostFunctionSchema::with_return( - CAP_ARTIFACT_PUT, - vec![ - token.clone(), - HostParamSchema::value("bytes", HostTypeSchema::Unknown), - HostParamSchema::value("metadata", HostTypeSchema::Unknown), - ], - response.clone(), - )); - builder.function(HostFunctionSchema::with_return( - CAP_ARTIFACT_PUT_RESULT, - vec![ - token.clone(), - HostParamSchema::value("bytes", HostTypeSchema::Unknown), - HostParamSchema::value("metadata", HostTypeSchema::Unknown), - ], - response.clone(), - )); - builder.function(HostFunctionSchema::with_return( - CAP_ARTIFACT_GET, - vec![ - token.clone(), - HostParamSchema::value("id", HostTypeSchema::String), - ], - response.clone(), - )); - builder.function(HostFunctionSchema::with_return( - CAP_ARTIFACT_REFERENCE, - vec![ - token.clone(), - HostParamSchema::value("id", HostTypeSchema::String), - ], - response.clone(), - )); - builder.function(HostFunctionSchema::with_return( - CAP_CLOCK_MONOTONIC_MS, - vec![token], - response.clone(), - )); - builder.function(HostFunctionSchema::with_return( - PARSE_JSON_OBJECT, - vec![HostParamSchema::value("text", HostTypeSchema::String)], - response, - )); - Arc::new(builder.build().expect("agent host catalog must build")) - })) + Arc::clone( + CATALOG.get_or_init(|| super::host_compose::compose_with_standard(&[agent_host_module()])), + ) +} + +const AGENT_HOST_FUNCTIONS: &[fn() -> HostFunctionDescriptor] = &[ + provider_call_descriptor, + sleep_ms_descriptor, + control_check_descriptor, + tool_prepare_descriptor, + tool_commit_descriptor, + cap_fs_metadata_descriptor, + cap_fs_read_range_descriptor, + cap_fs_list_descriptor, + cap_fs_write_atomic_descriptor, + cap_process_spawn_descriptor, + cap_process_poll_descriptor, + cap_process_wait_descriptor, + cap_process_log_descriptor, + cap_process_write_descriptor, + cap_process_close_descriptor, + cap_process_kill_descriptor, + cap_artifact_put_descriptor, + cap_artifact_put_result_descriptor, + cap_artifact_get_descriptor, + cap_artifact_reference_descriptor, + cap_clock_monotonic_ms_descriptor, + parse_json_object_descriptor, +]; + +pub fn agent_host_module() -> HostModuleDescriptor { + HostModuleDescriptor { + name: "agent", + functions: AGENT_HOST_FUNCTIONS, + resources: &[], + } +} + +fn token_param() -> HostParamSchema { + HostParamSchema::value("execution_token", HostTypeSchema::String) +} + +fn stack_desc( + name: &'static str, + params: Vec, + ret: HostTypeSchema, + adapter: fn(&mut Vm, &[Value]) -> VmResult, +) -> HostFunctionDescriptor { + super::host_compose::static_stack_descriptor( + HostFunctionSchema::with_return(name, params, ret), + adapter, + ) +} + +fn provider_call_descriptor() -> HostFunctionDescriptor { + stack_desc( + PROVIDER_CALL, + vec![HostParamSchema::value("request", HostTypeSchema::Unknown)], + HostTypeSchema::Unknown, + provider_call_adapter, + ) +} + +fn sleep_ms_descriptor() -> HostFunctionDescriptor { + stack_desc( + SLEEP_MS, + vec![HostParamSchema::value("delay_ms", HostTypeSchema::Int)], + HostTypeSchema::Int, + sleep_ms_adapter, + ) +} + +fn control_check_descriptor() -> HostFunctionDescriptor { + stack_desc( + CONTROL_CHECK, + vec![], + super::host_types::AgentControlResult::host_type_schema(), + control_check_adapter, + ) +} + +fn tool_prepare_descriptor() -> HostFunctionDescriptor { + stack_desc( + TOOL_PREPARE, + vec![HostParamSchema::value("metadata", HostTypeSchema::Unknown)], + super::host_types::AgentToolEnvelope::host_type_schema(), + tool_prepare_adapter, + ) +} + +fn tool_commit_descriptor() -> HostFunctionDescriptor { + stack_desc( + TOOL_COMMIT, + vec![ + token_param(), + HostParamSchema::value("result", HostTypeSchema::Unknown), + ], + super::host_types::AgentToolEnvelope::host_type_schema(), + tool_commit_adapter, + ) +} + +fn cap_fs_metadata_descriptor() -> HostFunctionDescriptor { + stack_desc( + CAP_FS_METADATA, + vec![ + token_param(), + HostParamSchema::value("path", HostTypeSchema::String), + ], + super::host_types::AgentFsMetadataResult::host_type_schema(), + cap_fs_metadata_adapter, + ) +} + +fn cap_fs_read_range_descriptor() -> HostFunctionDescriptor { + stack_desc( + CAP_FS_READ_RANGE, + vec![ + token_param(), + HostParamSchema::value("path", HostTypeSchema::String), + HostParamSchema::value("offset", HostTypeSchema::Int), + HostParamSchema::value("limit", HostTypeSchema::Int), + ], + super::host_types::AgentFsReadResult::host_type_schema(), + cap_fs_read_range_adapter, + ) +} + +fn cap_fs_list_descriptor() -> HostFunctionDescriptor { + stack_desc( + CAP_FS_LIST, + vec![ + token_param(), + HostParamSchema::value("path", HostTypeSchema::String), + HostParamSchema::value("cursor", HostTypeSchema::Int), + HostParamSchema::value("limit", HostTypeSchema::Int), + ], + super::host_types::AgentFsListResult::host_type_schema(), + cap_fs_list_adapter, + ) +} + +fn cap_fs_write_atomic_descriptor() -> HostFunctionDescriptor { + stack_desc( + CAP_FS_WRITE_ATOMIC, + vec![ + token_param(), + HostParamSchema::value("path", HostTypeSchema::String), + HostParamSchema::value("expected_hash", HostTypeSchema::String), + HostParamSchema::value("bytes", HostTypeSchema::Bytes), + ], + super::host_types::AgentFsWriteResult::host_type_schema(), + cap_fs_write_atomic_adapter, + ) +} + +fn cap_process_spawn_descriptor() -> HostFunctionDescriptor { + stack_desc( + CAP_PROCESS_SPAWN, + vec![ + token_param(), + HostParamSchema::value( + "argv", + HostTypeSchema::Array(Box::new(HostTypeSchema::String)), + ), + HostParamSchema::value("cwd", HostTypeSchema::String), + HostParamSchema::value( + "env_names", + HostTypeSchema::Array(Box::new(HostTypeSchema::String)), + ), + HostParamSchema::value( + "limits", + super::host_types::AgentProcessLimits::host_type_schema(), + ), + HostParamSchema::value("stdin", HostTypeSchema::Bytes), + ], + super::host_types::AgentProcessSpawnResult::host_type_schema(), + cap_process_spawn_adapter, + ) +} + +fn cap_process_poll_descriptor() -> HostFunctionDescriptor { + stack_desc( + CAP_PROCESS_POLL, + vec![ + token_param(), + HostParamSchema::value("handle", HostTypeSchema::String), + HostParamSchema::value("cursor", HostTypeSchema::Int), + HostParamSchema::value("limit", HostTypeSchema::Int), + ], + super::host_types::AgentProcessSnapshot::host_type_schema(), + cap_process_poll_adapter, + ) +} + +fn cap_process_wait_descriptor() -> HostFunctionDescriptor { + stack_desc( + CAP_PROCESS_WAIT, + vec![ + token_param(), + HostParamSchema::value("handle", HostTypeSchema::String), + HostParamSchema::value("timeout_ms", HostTypeSchema::Int), + ], + super::host_types::AgentProcessSnapshot::host_type_schema(), + cap_process_wait_adapter, + ) +} + +fn cap_process_log_descriptor() -> HostFunctionDescriptor { + stack_desc( + CAP_PROCESS_LOG, + vec![ + token_param(), + HostParamSchema::value("handle", HostTypeSchema::String), + HostParamSchema::value("cursor", HostTypeSchema::Int), + HostParamSchema::value("limit", HostTypeSchema::Int), + ], + super::host_types::AgentProcessSnapshot::host_type_schema(), + cap_process_log_adapter, + ) +} + +fn cap_process_write_descriptor() -> HostFunctionDescriptor { + stack_desc( + CAP_PROCESS_WRITE, + vec![ + token_param(), + HostParamSchema::value("handle", HostTypeSchema::String), + HostParamSchema::value("bytes", HostTypeSchema::Bytes), + HostParamSchema::value("timeout_ms", HostTypeSchema::Int), + ], + super::host_types::AgentProcessWriteResult::host_type_schema(), + cap_process_write_adapter, + ) +} + +fn cap_process_close_descriptor() -> HostFunctionDescriptor { + stack_desc( + CAP_PROCESS_CLOSE, + vec![ + token_param(), + HostParamSchema::value("handle", HostTypeSchema::String), + ], + super::host_types::AgentProcessCloseResult::host_type_schema(), + cap_process_close_adapter, + ) +} + +fn cap_process_kill_descriptor() -> HostFunctionDescriptor { + stack_desc( + CAP_PROCESS_KILL, + vec![ + token_param(), + HostParamSchema::value("handle", HostTypeSchema::String), + ], + super::host_types::AgentProcessCloseResult::host_type_schema(), + cap_process_kill_adapter, + ) +} + +fn cap_artifact_put_descriptor() -> HostFunctionDescriptor { + stack_desc( + CAP_ARTIFACT_PUT, + vec![ + token_param(), + HostParamSchema::value("bytes", HostTypeSchema::Bytes), + HostParamSchema::value("metadata", HostTypeSchema::Unknown), + ], + HostTypeSchema::Unknown, + cap_artifact_put_adapter, + ) +} + +fn cap_artifact_put_result_descriptor() -> HostFunctionDescriptor { + stack_desc( + CAP_ARTIFACT_PUT_RESULT, + vec![ + token_param(), + HostParamSchema::value("bytes", HostTypeSchema::Bytes), + HostParamSchema::value("metadata", HostTypeSchema::Unknown), + ], + HostTypeSchema::Unknown, + cap_artifact_put_result_adapter, + ) +} + +fn cap_artifact_get_descriptor() -> HostFunctionDescriptor { + stack_desc( + CAP_ARTIFACT_GET, + vec![ + token_param(), + HostParamSchema::value("id", HostTypeSchema::String), + ], + HostTypeSchema::Unknown, + cap_artifact_get_adapter, + ) +} + +fn cap_artifact_reference_descriptor() -> HostFunctionDescriptor { + stack_desc( + CAP_ARTIFACT_REFERENCE, + vec![ + token_param(), + HostParamSchema::value("id", HostTypeSchema::String), + ], + HostTypeSchema::Unknown, + cap_artifact_reference_adapter, + ) +} + +fn cap_clock_monotonic_ms_descriptor() -> HostFunctionDescriptor { + stack_desc( + CAP_CLOCK_MONOTONIC_MS, + vec![token_param()], + super::host_types::AgentClockResult::host_type_schema(), + cap_clock_monotonic_ms_adapter, + ) +} + +fn parse_json_object_descriptor() -> HostFunctionDescriptor { + stack_desc( + PARSE_JSON_OBJECT, + vec![HostParamSchema::value("text", HostTypeSchema::String)], + HostTypeSchema::Unknown, + parse_json_object_adapter, + ) } const SLEEP_CHUNK_MS: u64 = 10; @@ -820,139 +976,7 @@ pub fn register_agent_host_functions( registry: &mut HostFunctionRegistry, catalog: &HostApiCatalog, ) -> VmResult<()> { - register_named(registry, catalog, PROVIDER_CALL, 1, provider_call_adapter)?; - register_named(registry, catalog, SLEEP_MS, 1, sleep_ms_adapter)?; - register_named(registry, catalog, CONTROL_CHECK, 0, control_check_adapter)?; - register_named(registry, catalog, TOOL_PREPARE, 1, tool_prepare_adapter)?; - register_named(registry, catalog, TOOL_COMMIT, 2, tool_commit_adapter)?; - register_named( - registry, - catalog, - CAP_FS_METADATA, - 2, - cap_fs_metadata_adapter, - )?; - register_named( - registry, - catalog, - CAP_FS_READ_RANGE, - 4, - cap_fs_read_range_adapter, - )?; - register_named(registry, catalog, CAP_FS_LIST, 4, cap_fs_list_adapter)?; - register_named( - registry, - catalog, - CAP_FS_WRITE_ATOMIC, - 4, - cap_fs_write_atomic_adapter, - )?; - register_named( - registry, - catalog, - CAP_PROCESS_SPAWN, - 6, - cap_process_spawn_adapter, - )?; - register_named( - registry, - catalog, - CAP_PROCESS_POLL, - 4, - cap_process_poll_adapter, - )?; - register_named( - registry, - catalog, - CAP_PROCESS_WAIT, - 3, - cap_process_wait_adapter, - )?; - register_named( - registry, - catalog, - CAP_PROCESS_LOG, - 4, - cap_process_log_adapter, - )?; - register_named( - registry, - catalog, - CAP_PROCESS_WRITE, - 4, - cap_process_write_adapter, - )?; - register_named( - registry, - catalog, - CAP_PROCESS_CLOSE, - 2, - cap_process_close_adapter, - )?; - register_named( - registry, - catalog, - CAP_PROCESS_KILL, - 2, - cap_process_kill_adapter, - )?; - register_named( - registry, - catalog, - CAP_ARTIFACT_PUT, - 3, - cap_artifact_put_adapter, - )?; - register_named( - registry, - catalog, - CAP_ARTIFACT_PUT_RESULT, - 3, - cap_artifact_put_result_adapter, - )?; - register_named( - registry, - catalog, - CAP_ARTIFACT_GET, - 2, - cap_artifact_get_adapter, - )?; - register_named( - registry, - catalog, - CAP_ARTIFACT_REFERENCE, - 2, - cap_artifact_reference_adapter, - )?; - register_named( - registry, - catalog, - CAP_CLOCK_MONOTONIC_MS, - 1, - cap_clock_monotonic_ms_adapter, - )?; - register_named( - registry, - catalog, - PARSE_JSON_OBJECT, - 1, - parse_json_object_adapter, - )?; - Ok(()) -} - -fn register_named( - registry: &mut HostFunctionRegistry, - catalog: &HostApiCatalog, - name: &str, - arity: u8, - adapter: fn(&mut Vm, &[Value]) -> VmResult, -) -> VmResult<()> { - for schema in catalog_import_schemas(catalog, name) { - registry.register_exact_static(name, arity, schema, adapter)?; - } - registry.register_static(name, arity, adapter); - registry.allow_builtin(name)?; + agent_host_module().install_from_catalog(registry, catalog)?; Ok(()) } @@ -975,9 +999,7 @@ fn sleep_ms_adapter(vm: &mut Vm, args: &[Value]) -> VmResult { fn control_check_adapter(vm: &mut Vm, _args: &[Value]) -> VmResult { let state = installed_state(vm)?; - let result = state - .control_error() - .unwrap_or_else(|| json!({"ok": true, "error": {}})); + let result = state.control_error().unwrap_or_else(|| json!({"ok": true})); return_json(result) } diff --git a/src/runtime/cancellation.rs b/src/runtime/cancellation.rs new file mode 100644 index 0000000..7c2f204 --- /dev/null +++ b/src/runtime/cancellation.rs @@ -0,0 +1,10 @@ +//! Agent-owned run cancellation vocabulary. +//! +//! Frozen core moved VM operation cancellation to [`OperationCancelReason`]. +//! Agent run-level reasons keep the historical `CancellationReason` name as an +//! alias of that enum so request/deadline/resource-closed mapping stays 1:1. +//! The cloneable run flag is the vendored process-token type: it is not a VM +//! operation graph. + +pub use crate::capabilities::vm_io::CancellationToken; +pub use rustscript_vm::operation::OperationCancelReason as CancellationReason; diff --git a/src/runtime/host_compose.rs b/src/runtime/host_compose.rs new file mode 100644 index 0000000..bfab63b --- /dev/null +++ b/src/runtime/host_compose.rs @@ -0,0 +1,206 @@ +//! Deterministic host-module composition for the agent runtime. +//! +//! Standard HTTP/SQLite/json/bytes schemas come from frozen core catalogs. +//! Agent modules contribute descriptor-owned schemas and adapters. + +use std::sync::Arc; + +use rustscript_vm::{ + CallOutcome, HostAdapterDescriptor, HostApiBuilder, HostApiCatalog, HostBindingDescriptor, + HostBindingKind, HostFunctionDescriptor, HostFunctionSchema, HostModuleDescriptor, + HostTypeSchema, Value, Vm, VmResult, standard_host_catalog, standard_host_modules, +}; + +/// Genuinely open JSON/tool payloads that remain `HostTypeSchema::Unknown`. +/// Each entry is (`function_name`, `slot`) where slot is a parameter label +/// or `return.` for a named-struct field. +#[allow(dead_code)] +pub const DYNAMIC_HOST_SLOTS: &[(&str, &str)] = &[ + ("agent::provider_call", "request"), + ("agent::provider_call", "return"), + ("agent_runtime::tool_prepare", "metadata"), + ("agent_runtime::tool_commit", "result"), + ("cap::artifact_put", "metadata"), + ("cap::artifact_put_result", "metadata"), + ("cap::artifact_put", "return"), + ("cap::artifact_put_result", "return"), + ("cap::artifact_get", "return"), + ("cap::artifact_reference", "return"), + ("agent::parse_json_object", "return"), +]; + +/// Standard runtime builtins the restricted registry may admit. +pub const RESTRICTED_STANDARD_BUILTINS: &[&str] = &[ + "json::encode", + "json::decode", + "stream::emit", + "bytes::to_utf8", + "bytes::to_utf8_lossy", + "bytes::to_array_u8", + "bytes::from_utf8", + "sqlite::open", + "sqlite::execute", + "sqlite::query", + "sqlite::transaction", + "sqlite::close", + "http::client::request", + "http::client::sse", +]; + +pub fn static_stack_descriptor( + schema: HostFunctionSchema, + adapter: fn(&mut Vm, &[Value]) -> VmResult, +) -> HostFunctionDescriptor { + HostFunctionDescriptor { + schema, + binding: HostBindingDescriptor { + kind: HostBindingKind::StaticStack, + }, + effects: Vec::new(), + adapter: HostAdapterDescriptor::StaticStack(adapter), + resource_types: Vec::new(), + } +} + +pub fn absorb_catalog(builder: &mut HostApiBuilder, catalog: &HostApiCatalog) { + for resource in catalog.resources() { + builder.resource(resource.clone()); + } + for named in catalog.structs() { + builder.named_struct(named.clone()); + } + for function in catalog.functions() { + builder.function(function.clone()); + } +} + +pub fn compose_with_standard(modules: &[HostModuleDescriptor]) -> Arc { + let mut builder = HostApiBuilder::new(); + absorb_catalog(&mut builder, standard_host_catalog().as_ref()); + // `stream::emit` is descriptor-only in frozen core (not on the guest + // catalog). Agent RSS still imports it, so publish the owned schema so + // compile fingerprints match the runtime registry. json/bytes stay + // namespaced builtins via allow_builtin. + for module in standard_host_modules() { + if module.name != "context" { + continue; + } + for descriptor in module.owned_descriptors() { + builder.function(descriptor.schema.clone()); + } + } + for module in modules { + absorb_catalog( + &mut builder, + &module + .catalog() + .unwrap_or_else(|error| panic!("host module '{}' catalog: {error}", module.name)), + ); + } + Arc::new( + builder + .build() + .unwrap_or_else(|error| panic!("composed host catalog: {error}")), + ) +} + +#[allow(dead_code)] +pub fn schema_is_unknown(schema: &HostTypeSchema) -> bool { + matches!(schema, HostTypeSchema::Unknown) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::runtime::agent_host::{agent_host_catalog, agent_host_module}; + use rustscript_vm::HostFunctionRegistry; + + fn slot_allowed(function: &str, slot: &str) -> bool { + DYNAMIC_HOST_SLOTS + .iter() + .any(|(name, allowed)| *name == function && *allowed == slot) + } + + #[test] + fn dynamic_slots_are_explicit_and_complete_for_agent_module() { + let catalog = agent_host_catalog(); + let mut unexpected = Vec::new(); + for function in catalog.functions() { + if !function.name.starts_with("agent::") + && !function.name.starts_with("agent_runtime::") + && !function.name.starts_with("cap::") + { + continue; + } + for param in &function.params { + if schema_is_unknown(¶m.ty) && !slot_allowed(&function.name, ¶m.name) { + unexpected.push(format!("{} param {}", function.name, param.name)); + } + } + match &function.return_type { + HostTypeSchema::Named { fields, .. } => { + for field in fields { + let slot = format!("return.{}", field.name); + if schema_is_unknown(&field.ty) && !slot_allowed(&function.name, &slot) { + unexpected.push(format!("{} {slot}", function.name)); + } + } + } + other if schema_is_unknown(other) && !slot_allowed(&function.name, "return") => { + unexpected.push(format!("{} return", function.name)); + } + _ => {} + } + } + assert!( + unexpected.is_empty(), + "unlisted Unknown host slots: {unexpected:?}" + ); + } + + #[test] + fn duplicate_agent_module_install_fails() { + let catalog = agent_host_catalog(); + let mut registry = HostFunctionRegistry::restricted(); + agent_host_module() + .install_from_catalog(&mut registry, catalog.as_ref()) + .expect("first install"); + let error = agent_host_module() + .install_from_catalog(&mut registry, catalog.as_ref()) + .expect_err("duplicate install must fail"); + let message = error.to_string(); + assert!( + message.contains("already") + || message.contains("duplicate") + || message.contains("conflict") + || message.contains("registered"), + "unexpected duplicate error: {message}" + ); + } + + #[test] + fn restricted_registry_rejects_unlisted_function() { + let catalog = agent_host_catalog(); + let mut registry = HostFunctionRegistry::restricted(); + agent_host_module() + .install_from_catalog(&mut registry, catalog.as_ref()) + .expect("install agent"); + let error = registry + .allow_builtin("agent::definitely_not_registered") + .expect_err("unlisted builtin must stay denied"); + assert!(!error.to_string().is_empty()); + } + + #[test] + fn composed_catalog_includes_standard_http_and_sqlite() { + let catalog = agent_host_catalog(); + let names: Vec<_> = catalog + .functions() + .iter() + .map(|f| f.name.as_str()) + .collect(); + assert!(names.contains(&"http::client::request")); + assert!(names.contains(&"sqlite::open")); + assert!(names.contains(&"agent::provider_call")); + } +} diff --git a/src/runtime/host_types.rs b/src/runtime/host_types.rs new file mode 100644 index 0000000..3ae42e0 --- /dev/null +++ b/src/runtime/host_types.rs @@ -0,0 +1,234 @@ +//! Descriptor-owned named structs for fixed agent host shapes. +//! +//! Runtime values remain maps. Nested payloads that are genuinely open JSON +//! stay `HostTypeSchema::Unknown` and are listed in +//! [`crate::runtime::host_compose::DYNAMIC_HOST_SLOTS`]. + +#![allow(dead_code)] + +use rustscript_vm::{HostNamedStruct, HostStructField, HostTypeSchema}; + +fn field(name: &'static str, ty: HostTypeSchema) -> HostStructField { + HostStructField::new(name, ty) +} + +fn optional(ty: HostTypeSchema) -> HostTypeSchema { + HostTypeSchema::Optional(Box::new(ty)) +} + +fn named() -> HostTypeSchema { + T::host_type_schema() +} + +macro_rules! named_struct { + ($ident:ident, $name:literal, [ $(($field:literal, $ty:expr)),+ $(,)? ]) => { + pub struct $ident; + impl HostNamedStruct for $ident { + const NAME: &'static str = $name; + fn host_struct_fields() -> Vec { + vec![$(field($field, $ty),)+] + } + } + }; +} + +named_struct!( + AgentProviderError, + "AgentProviderError", + [ + ("status", HostTypeSchema::Int), + ("type", HostTypeSchema::String), + ("code", HostTypeSchema::String), + ("message", HostTypeSchema::String), + ("param", HostTypeSchema::String), + ("request_id", HostTypeSchema::String), + ("retryable", HostTypeSchema::Bool) + ] +); + +named_struct!( + AgentControlResult, + "AgentControlResult", + [ + ("ok", HostTypeSchema::Bool), + ("error", optional(named::())) + ] +); + +named_struct!( + AgentCapabilityError, + "AgentCapabilityError", + [ + ("code", HostTypeSchema::String), + ("message", HostTypeSchema::String) + ] +); + +named_struct!( + AgentFsMetadataResult, + "AgentFsMetadataResult", + [ + ("ok", HostTypeSchema::Bool), + ("kind", HostTypeSchema::String), + ("file_type", optional(HostTypeSchema::String)), + ("len", optional(HostTypeSchema::Int)), + ("error", optional(named::())) + ] +); + +named_struct!( + AgentFsListEntry, + "AgentFsListEntry", + [ + ("name", HostTypeSchema::String), + ("file_type", HostTypeSchema::String), + ("len", HostTypeSchema::Int) + ] +); + +named_struct!( + AgentFsListResult, + "AgentFsListResult", + [ + ("ok", HostTypeSchema::Bool), + ("kind", HostTypeSchema::String), + ("cursor", optional(HostTypeSchema::Int)), + ("next_cursor", optional(HostTypeSchema::Int)), + ("truncated", optional(HostTypeSchema::Bool)), + ( + "entries", + optional(HostTypeSchema::Array(Box::new(named::()))) + ), + ("error", optional(named::())) + ] +); + +named_struct!( + AgentFsWriteResult, + "AgentFsWriteResult", + [ + ("ok", HostTypeSchema::Bool), + ("kind", HostTypeSchema::String), + ("hash", optional(HostTypeSchema::String)), + ("len", optional(HostTypeSchema::Int)), + ("durable", optional(HostTypeSchema::Bool)), + ("staging_cleaned", optional(HostTypeSchema::Bool)), + ("error", optional(named::())) + ] +); + +named_struct!( + AgentProcessLimits, + "AgentProcessLimits", + [ + ("timeout_ms", optional(HostTypeSchema::Int)), + ("stdout_limit", optional(HostTypeSchema::Int)), + ("stderr_limit", optional(HostTypeSchema::Int)), + ("total_limit", optional(HostTypeSchema::Int)), + ("stdin_limit", optional(HostTypeSchema::Int)), + ("log_limit", optional(HostTypeSchema::Int)), + ("close_after_initial", optional(HostTypeSchema::Bool)) + ] +); + +named_struct!( + AgentProcessSpawnResult, + "AgentProcessSpawnResult", + [ + ("ok", HostTypeSchema::Bool), + ("kind", HostTypeSchema::String), + ("handle", optional(HostTypeSchema::String)), + ("pid", optional(HostTypeSchema::Int)), + ("error", optional(named::())) + ] +); + +named_struct!( + AgentProcessSnapshot, + "AgentProcessSnapshot", + [ + ("ok", HostTypeSchema::Bool), + ("kind", HostTypeSchema::String), + ("handle", optional(HostTypeSchema::String)), + ("running", optional(HostTypeSchema::Bool)), + ("exit_code", optional(HostTypeSchema::Int)), + ("signal", optional(HostTypeSchema::Int)), + ("stdout", optional(HostTypeSchema::String)), + ("stderr", optional(HostTypeSchema::String)), + ("stdout_bytes", optional(HostTypeSchema::Bytes)), + ("stderr_bytes", optional(HostTypeSchema::Bytes)), + ("truncated", optional(HostTypeSchema::Bool)), + ("stdout_offset", optional(HostTypeSchema::Int)), + ("stdout_next_offset", optional(HostTypeSchema::Int)), + ("stdout_truncated", optional(HostTypeSchema::Bool)), + ("stdout_gap", optional(HostTypeSchema::Bool)), + ("stdout_eof", optional(HostTypeSchema::Bool)), + ("stderr_offset", optional(HostTypeSchema::Int)), + ("stderr_next_offset", optional(HostTypeSchema::Int)), + ("stderr_truncated", optional(HostTypeSchema::Bool)), + ("stderr_gap", optional(HostTypeSchema::Bool)), + ("stderr_eof", optional(HostTypeSchema::Bool)), + ("signaled", optional(HostTypeSchema::Bool)), + ("unknown", optional(HostTypeSchema::Bool)), + ("deadline_elapsed", optional(HostTypeSchema::Bool)), + ("cancelled", optional(HostTypeSchema::Bool)), + ("error", optional(named::())) + ] +); + +named_struct!( + AgentProcessWriteResult, + "AgentProcessWriteResult", + [ + ("ok", HostTypeSchema::Bool), + ("kind", HostTypeSchema::String), + ("wrote_bytes", optional(HostTypeSchema::Int)), + ("error", optional(named::())) + ] +); + +named_struct!( + AgentProcessCloseResult, + "AgentProcessCloseResult", + [ + ("ok", HostTypeSchema::Bool), + ("kind", HostTypeSchema::String), + ("error", optional(named::())) + ] +); + +named_struct!( + AgentClockResult, + "AgentClockResult", + [ + ("ok", HostTypeSchema::Bool), + ("kind", HostTypeSchema::String), + ("ms", optional(HostTypeSchema::Int)), + ("code", optional(HostTypeSchema::String)), + ("message", optional(HostTypeSchema::String)), + ("error", optional(named::())) + ] +); + +named_struct!( + AgentToolEnvelope, + "AgentToolEnvelope", + [ + ("ok", HostTypeSchema::Bool), + ("kind", optional(HostTypeSchema::String)), + ("token", optional(HostTypeSchema::String)), + ("error", optional(named::())) + ] +); + +named_struct!( + AgentFsReadResult, + "AgentFsReadResult", + [ + ("ok", HostTypeSchema::Bool), + ("kind", HostTypeSchema::String), + ("bytes", optional(HostTypeSchema::Bytes)), + ("len", optional(HostTypeSchema::Int)), + ("error", optional(named::())) + ] +); diff --git a/src/runtime/host_wait.rs b/src/runtime/host_wait.rs new file mode 100644 index 0000000..a017d3f --- /dev/null +++ b/src/runtime/host_wait.rs @@ -0,0 +1,40 @@ +//! Blocking host-op wait with an embedder cancel callback. +//! +//! Frozen core dropped `Vm::wait_for_host_op_blocking_with_cancel`. The runner +//! still needs to abort a pending host op when a run deadline or request fires, +//! so this helper polls the public `poll_waiting_host_op` surface. + +use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker}; + +use rustscript_vm::{Vm, VmError, VmResult}; + +fn noop_raw_waker() -> RawWaker { + fn clone(_: *const ()) -> RawWaker { + noop_raw_waker() + } + fn wake(_: *const ()) {} + fn wake_by_ref(_: *const ()) {} + fn drop(_: *const ()) {} + static VTABLE: RawWakerVTable = RawWakerVTable::new(clone, wake, wake_by_ref, drop); + RawWaker::new(std::ptr::null(), &VTABLE) +} + +/// Waits for the current host op, aborting when `cancelled` becomes true. +pub fn wait_for_host_op_blocking_with_cancel( + vm: &mut Vm, + mut cancelled: impl FnMut() -> bool, +) -> VmResult<()> { + let waker = unsafe { Waker::from_raw(noop_raw_waker()) }; + let mut cx = Context::from_waker(&waker); + loop { + if cancelled() { + return Err(VmError::HostError( + "host operation wait was cancelled".to_string(), + )); + } + match vm.poll_waiting_host_op(&mut cx) { + Poll::Ready(result) => return result, + Poll::Pending => std::thread::sleep(std::time::Duration::from_millis(1)), + } + } +} diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index 4f4f219..5c79d3e 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -1,7 +1,11 @@ //! RSS run execution and the agent runtime. pub(crate) mod agent_host; +pub(crate) mod cancellation; pub(crate) mod delivery; +pub(crate) mod host_compose; +pub(crate) mod host_types; +pub(crate) mod host_wait; pub(crate) mod module_snapshot; pub mod rss_runner; diff --git a/src/runtime/rss_runner.rs b/src/runtime/rss_runner.rs index 3ee8a81..c944832 100644 --- a/src/runtime/rss_runner.rs +++ b/src/runtime/rss_runner.rs @@ -28,14 +28,18 @@ use std::thread; use std::time::{Duration, Instant}; use rustscript_vm::{ - CallReturn, CancellationReason, CancellationToken, CompileSourceFileOptions, EpochHandle, - HostAsyncBridge, HostFunctionRegistry, HostFuture, HostFutureOutput, HttpConfig, HttpHostExt, - InvocationError, InvocationItem, InvocationPoll, SourceFlavor, SqliteHostExt, SqlitePolicy, - Value, Vm, VmError, VmResult, VmStatus, VmYieldReason, + CallReturn, CapabilityProfile, CompileSourceFileOptions, EpochHandle, HostAsyncBridge, + HostFunctionRegistry, HostFuture, HostFutureOutput, HostModuleDescriptor, HttpConfig, + HttpHostExt, InvocationError, InvocationItem, InvocationPoll, SourceFlavor, SqliteHostExt, + SqlitePolicy, Value, Vm, VmError, VmResult, VmStatus, VmYieldReason, compile_source_at_path_with_flavor_and_options, compile_source_with_flavor_and_options, register_http_builtin_module_from_catalog, register_sqlite_builtin_module_from_catalog, + standard_host_modules, }; +use super::cancellation::{CancellationReason, CancellationToken}; +use super::host_wait::wait_for_host_op_blocking_with_cancel; + use super::agent_host::{ AgentHostBridges, AgentHostState, AgentProviderHost, agent_host_catalog, register_agent_host_functions, @@ -350,13 +354,36 @@ impl From for AgentError { } } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug)] pub struct AgentConfig { pub http: HttpConfig, pub sqlite: SqlitePolicy, pub fuel: Option, } +impl PartialEq for AgentConfig { + fn eq(&self, other: &Self) -> bool { + self.http == other.http + && self.fuel == other.fuel + && self.sqlite.database_root == other.sqlite.database_root + && self.sqlite.allow_unsafe_sql == other.sqlite.allow_unsafe_sql + && self.sqlite.limits.max_connections == other.sqlite.limits.max_connections + && self.sqlite.limits.max_statements == other.sqlite.limits.max_statements + && self.sqlite.limits.max_rows == other.sqlite.limits.max_rows + && self.sqlite.limits.max_columns == other.sqlite.limits.max_columns + && self.sqlite.limits.max_result_bytes == other.sqlite.limits.max_result_bytes + && self.sqlite.limits.max_statement_bytes == other.sqlite.limits.max_statement_bytes + && self.sqlite.limits.max_parameters == other.sqlite.limits.max_parameters + && self.sqlite.limits.max_parameter_bytes == other.sqlite.limits.max_parameter_bytes + && self.sqlite.limits.max_pending_operations + == other.sqlite.limits.max_pending_operations + && self.sqlite.limits.max_transaction_ms == other.sqlite.limits.max_transaction_ms + && self.sqlite.limits.busy_timeout_ms == other.sqlite.limits.busy_timeout_ms + } +} + +impl Eq for AgentConfig {} + impl AgentConfig { pub fn new(http: HttpConfig) -> Self { Self { @@ -815,7 +842,8 @@ impl AgentRunner { cancellation: Option<&RunCancellation>, ) -> std::result::Result<(Vm, Value), RunError> { let mut vm = Vm::try_new(self.program.clone()).map_err(RunError::Vm)?; - vm.set_async_bridge(Box::new(AgentAsyncBridge::new())); + vm.set_async_bridge(Box::new(AgentAsyncBridge::new())) + .map_err(RunError::Setup)?; vm.configure_http(self.config.http.clone()) .map_err(RunError::Setup)?; vm.configure_sqlite(self.config.sqlite.clone()); @@ -894,7 +922,7 @@ impl AgentRunner { match vm.run() { Ok(VmStatus::Halted) => return Ok(()), Ok(VmStatus::Waiting(_)) => { - vm.wait_for_host_op_blocking_with_cancel(|| { + wait_for_host_op_blocking_with_cancel(vm, || { cancellation.is_some_and(|cancel| { cancel.requested().is_some() || cancel.deadline_passed() }) @@ -1005,30 +1033,32 @@ impl AgentRunner { /// builtins are intentionally absent from agent execution. fn build_restricted_registry() -> std::result::Result { let catalog = agent_host_catalog(); - let mut registry = HostFunctionRegistry::restricted(); + // `restricted()` starts from `new()`, which already installs the standard + // HTTP/SQLite snapshot. Re-installing those modules from the composed + // agent catalog then conflicts on identity (same dispatch shape, different + // catalog fingerprint). Start empty and install exactly the compile catalog. + let mut registry = HostFunctionRegistry::empty(); + registry.set_capability_profile(CapabilityProfile::deny_all()); register_sqlite_builtin_module_from_catalog(&mut registry, catalog.as_ref())?; register_http_builtin_module_from_catalog(&mut registry, catalog.as_ref())?; + for module in standard_host_modules() { + if module.name != "context" { + continue; + } + let descriptor = HostModuleDescriptor { + name: "context", + functions: module.owned, + resources: &[], + }; + descriptor.install_from_catalog(&mut registry, catalog.as_ref())?; + } register_agent_host_functions(&mut registry, catalog.as_ref())?; - for name in [ - "json::encode", - "json::decode", - "stream::emit", - "bytes::to_utf8", - "bytes::to_utf8_lossy", - "bytes::to_array_u8", - "bytes::from_utf8", - "sqlite::open", - "sqlite::execute", - "sqlite::query", - "sqlite::transaction", - "sqlite::close", - "sqlite::rows_affected", - "sqlite::truncated", - "sqlite::next_cursor", - "http::client::request", - "http::client::sse", - ] { - registry.allow_builtin(name)?; + for name in crate::runtime::host_compose::RESTRICTED_STANDARD_BUILTINS { + if registry.contains_name(name) { + registry.authorize_registered_builtin_import(name); + } else { + registry.allow_builtin(name)?; + } } Ok(registry) } @@ -1394,3 +1424,15 @@ mod compile_cache_tests { } } } + +#[cfg(test)] +mod restricted_registry_tests { + use super::*; + + #[test] + fn restricted_registry_installs_stream_emit() { + build_restricted_registry().unwrap_or_else(|error| { + panic!("registry install failed: {error}"); + }); + } +} diff --git a/src/service.rs b/src/service.rs index ddd1cac..d2a232e 100644 --- a/src/service.rs +++ b/src/service.rs @@ -33,9 +33,9 @@ use std::thread; use std::time::{Duration, Instant}; use parking_lot::{Mutex as ParkingMutex, RwLock}; -use rustscript_vm::{ - CancellationReason, CancellationToken, HttpConfig, InvocationError, Value as VmValue, -}; +use rustscript_vm::{HttpConfig, InvocationError, Value as VmValue}; + +use crate::runtime::cancellation::{CancellationReason, CancellationToken}; use serde_json::{Map, Value as JsonValue, json}; use tokio::sync::{OwnedSemaphorePermit, Semaphore}; use uuid::Uuid; @@ -2860,16 +2860,12 @@ impl AgentService { } fn finish_durable_replay(&self, data: &JsonValue) -> Result { - let run_row = data - .get("run") - .and_then(|run| run.get("rows")) - .and_then(JsonValue::as_array) - .and_then(|rows| rows.first()) - .and_then(JsonValue::as_array) - .cloned() - .ok_or_else(|| { - AdmitError::Persistence("replayed admission omitted the existing run".to_string()) - })?; + let run_row = crate::sqlite_storage_rows::sqlite_storage_first_row( + data.get("run").unwrap_or(&JsonValue::Null), + ) + .ok_or_else(|| { + AdmitError::Persistence("replayed admission omitted the existing run".to_string()) + })?; let replayed_run_id = admission_run_str(&run_row, ADMISSION_RUN_COL_ID) .unwrap_or_default() .to_string(); @@ -4041,21 +4037,19 @@ impl AgentService { let run_data = persistence .run_get(run_id) .map_err(|error| RunContextError::Persistence(format!("read run context: {error}")))?; - let run_row = run_data - .get("rows") - .and_then(JsonValue::as_array) - .and_then(|rows| rows.first()) - .and_then(JsonValue::as_array) - .ok_or_else(|| RunContextError::Missing { - run_id: run_id.to_string(), + let run_row = + crate::sqlite_storage_rows::sqlite_storage_first_row(&run_data).ok_or_else(|| { + RunContextError::Missing { + run_id: run_id.to_string(), + } })?; - if admission_run_str(run_row, ADMISSION_RUN_COL_ID) != Some(run_id) { + if admission_run_str(&run_row, ADMISSION_RUN_COL_ID) != Some(run_id) { return Err(invalid_context_metadata( run_id, "run record id does not match the requested run", )); } - let persisted_input = admission_run_str(run_row, ADMISSION_RUN_COL_INPUT_JSON) + let persisted_input = admission_run_str(&run_row, ADMISSION_RUN_COL_INPUT_JSON) .filter(|value| !value.is_empty()) .ok_or_else(|| invalid_context_metadata(run_id, "run context snapshot is missing"))?; let envelope: JsonValue = serde_json::from_str(persisted_input).map_err(|error| { @@ -4087,7 +4081,7 @@ impl AgentService { "run id does not match the persisted context", )); } - let row_session_id = admission_run_str(run_row, ADMISSION_RUN_COL_SESSION_ID) + let row_session_id = admission_run_str(&run_row, ADMISSION_RUN_COL_SESSION_ID) .filter(|value| !value.is_empty()) .ok_or_else(|| invalid_context_metadata(run_id, "run session id is missing"))?; if context.session_id != row_session_id { @@ -4108,7 +4102,7 @@ impl AgentService { "provider does not match the run record", )); } - if admission_run_str(run_row, ADMISSION_RUN_COL_MODEL) != Some(context.model.as_str()) { + if admission_run_str(&run_row, ADMISSION_RUN_COL_MODEL) != Some(context.model.as_str()) { return Err(invalid_context_metadata( run_id, "model does not match the run record", @@ -4119,7 +4113,7 @@ impl AgentService { .get("registry_identity") .and_then(JsonValue::as_str) .expect("context metadata validation checked registry identity"); - if admission_run_str(run_row, ADMISSION_RUN_COL_SCRIPT_HASH) != Some(registry_identity) { + if admission_run_str(&run_row, ADMISSION_RUN_COL_SCRIPT_HASH) != Some(registry_identity) { return Err(invalid_context_metadata( run_id, "registry identity does not match the run record", @@ -5417,14 +5411,13 @@ fn terminal_commit( code: error.code.clone(), message: error.message.clone(), })?; - let rows = data - .get("events") - .and_then(|events| events.get("rows")) - .and_then(JsonValue::as_array) - .ok_or_else(|| TerminalCommitError { - code: "terminal_commit_invalid".to_string(), - message: "run.terminal result omitted events".to_string(), - })?; + let rows = crate::sqlite_storage_rows::sqlite_storage_rows( + data.get("events").unwrap_or(&JsonValue::Null), + ) + .map_err(|message| TerminalCommitError { + code: "terminal_commit_invalid".to_string(), + message, + })?; if rows.len() < event_count { return Err(TerminalCommitError { code: "terminal_commit_invalid".to_string(), @@ -5439,7 +5432,6 @@ fn terminal_commit( for (index, event) in events.iter().enumerate() { let row = rows .get(offset + index) - .and_then(JsonValue::as_array) .ok_or_else(|| TerminalCommitError { code: "terminal_commit_invalid".to_string(), message: "run.terminal returned a malformed event row".to_string(), diff --git a/src/sqlite_storage_rows.rs b/src/sqlite_storage_rows.rs new file mode 100644 index 0000000..98f4190 --- /dev/null +++ b/src/sqlite_storage_rows.rs @@ -0,0 +1,56 @@ +//! Unwrap frozen-core SQLite named-struct rows into the storage protocol's +//! primitive cell arrays. +//! +//! `sqlite::query` now returns `SqliteQueryResult { rows: [{ cells: [SqliteValue] }] }`. +//! Gateway/service readers still consume `data.rows` as arrays of JSON primitives. + +use serde_json::Value; + +pub fn sqlite_storage_rows(data: &Value) -> Result>, String> { + if let Some(rows) = data.get("rows") { + return sqlite_storage_row_list(rows); + } + if data.as_array().is_some() { + return sqlite_storage_row_list(data); + } + Err("storage result omitted rows".to_string()) +} + +pub fn sqlite_storage_row_list(value: &Value) -> Result>, String> { + let rows = value + .as_array() + .ok_or_else(|| "storage rows value is not an array".to_string())?; + rows.iter().map(sqlite_storage_row).collect() +} + +pub fn sqlite_storage_first_row(data: &Value) -> Option> { + sqlite_storage_rows(data).ok()?.into_iter().next() +} + +pub fn sqlite_storage_row(row: &Value) -> Result, String> { + if let Some(cells) = row.get("cells").and_then(Value::as_array) { + return cells.iter().map(sqlite_storage_cell).collect(); + } + let Some(arr) = row.as_array() else { + return Err("storage row is not an array or SqliteRow".to_string()); + }; + arr.iter().map(sqlite_storage_cell).collect() +} + +pub fn sqlite_storage_cell(value: &Value) -> Result { + let Some(obj) = value.as_object() else { + return Ok(value.clone()); + }; + let Some(kind) = obj.get("kind").and_then(Value::as_str) else { + return Ok(value.clone()); + }; + let cell = match kind { + "int" => obj.get("int_value").cloned().unwrap_or(Value::Null), + "float" => obj.get("float_value").cloned().unwrap_or(Value::Null), + "text" => obj.get("text_value").cloned().unwrap_or(Value::Null), + "blob" => obj.get("blob_value").cloned().unwrap_or(Value::Null), + "null" => Value::Null, + _ => value.clone(), + }; + Ok(cell) +} diff --git a/tests/agent_loop_tests.rs b/tests/agent_loop_tests.rs index 7904ec7..16ca5ff 100644 --- a/tests/agent_loop_tests.rs +++ b/tests/agent_loop_tests.rs @@ -12,6 +12,7 @@ use std::thread; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use parking_lot::Mutex; +use rustscript_agent::CancellationReason; use rustscript_agent::capabilities::{ AllowAllApproval, ArtifactCapability, ArtifactLimits, CancellationFlag, CapabilityLifecycle, CapabilityOwner, DurableStarted, DurableToolLifecycle, FilesystemCapability, FilesystemLimits, @@ -23,7 +24,7 @@ use rustscript_agent::{ AgentProviderHost, AgentRunner, ControlCheckHook, RunCancellation, RunContext, RunError, ScriptedProvider, ToolRegistry, bundled_tool_entries, bundled_tool_registry, }; -use rustscript_vm::{CancellationReason, InvocationError, Value}; +use rustscript_vm::{InvocationError, Value}; use serde_json::{Map as JsonMap, Value as JsonValue, json}; fn agent_root() -> PathBuf { @@ -974,7 +975,7 @@ fn loop_cancel_stops_before_provider() { provider.push_ok(text_response("should not run")); let runner = loop_runner_with(provider.clone(), None); let cancellation = rustscript_agent::RunCancellation::new(); - cancellation.request(rustscript_vm::CancellationReason::Requested); + cancellation.request(rustscript_agent::CancellationReason::Requested); let mut sink = VecSink::default(); let result = runner.run_with_context_and_events( json_to_vm(&run_context(3, 8, loop_config(false, false), json!([]))), @@ -1604,14 +1605,13 @@ fn query_rows(result: &JsonValue) -> Vec> { .get("columns") .and_then(JsonValue::as_array) .expect("SQLite query data should contain columns"); - data.get("rows") - .and_then(JsonValue::as_array) + rustscript_agent::sqlite_storage_rows::sqlite_storage_rows(&data) .expect("SQLite query data should contain rows") - .iter() + .into_iter() .map(|row| { columns .iter() - .zip(row.as_array().expect("SQLite row should be an array")) + .zip(row.iter()) .map(|(column, value)| { ( column @@ -1849,6 +1849,21 @@ fn durable_history_context(storage: &AgentRunner, db_name: &str) -> JsonValue { /// statement matched the pending row (`rows_affected == 1`). `message.compact` /// is expected to be a guarded no-op before the commit (it only marks rows /// once the compaction is committed) so only a hard failure rejects it. +fn transaction_rows_affected(result: &JsonValue, index: usize) -> JsonValue { + let first = &result["data"]["results"][index]; + assert_eq!( + first["kind"], + json!("execute"), + "SqliteTransactionResult[{index}] must be kind=execute, got {first}" + ); + let affected = &first["execute"]["rows_affected"]; + assert!( + affected.is_number(), + "SqliteTransactionResult[{index}].execute.rows_affected must be present, got {first}" + ); + affected.clone() +} + fn execute_plan(storage: &AgentRunner, db_name: &str, plan: &JsonValue) -> Result<(), String> { let commands = plan["commands"] .as_array() @@ -1871,11 +1886,11 @@ fn execute_plan(storage: &AgentRunner, db_name: &str, plan: &JsonValue) -> Resul } } "compaction.commit" => { - let affected = result["data"]["results"][0]["rows_affected"] - .as_i64() - .unwrap_or(0); - if affected == 0 { - return Err("compaction.commit matched no pending compaction".to_string()); + if transaction_rows_affected(&result, 0) != json!(1) { + return Err(format!( + "compaction.commit matched no pending compaction: {}", + result["data"]["results"][0] + )); } } "message.compact" => {} @@ -2016,7 +2031,7 @@ fn compaction_failure_marks_failed_and_preserves_history() { "the storage envelope itself succeeds" ); assert_eq!( - commit["data"]["results"][0]["rows_affected"], + transaction_rows_affected(&commit, 0), json!(0), "the commit guard must match nothing once the run left compacting" ); @@ -2752,7 +2767,7 @@ fn compaction_start_is_idempotent_for_same_pending_payload() { 1000, ); assert_eq!( - again["data"]["results"][0]["rows_affected"], + transaction_rows_affected(&again, 0), json!(0), "a repeated commit must match nothing" ); @@ -3153,7 +3168,7 @@ fn restart_recovery_fails_pending_compaction_then_new_start_commits() { 2000, ); assert_eq!( - committed["data"]["results"][0]["rows_affected"], + transaction_rows_affected(&committed, 0), json!(1), "the retry compaction must commit" ); diff --git a/tests/capability_tests.rs b/tests/capability_tests.rs index fea544e..63c1bc2 100644 --- a/tests/capability_tests.rs +++ b/tests/capability_tests.rs @@ -307,6 +307,21 @@ fn assert_stdin_workers_joined(processes: &ProcessCapability) { ); } +fn compile_source_error(source: &str) -> String { + match AgentRunner::from_source(source, AgentConfig::default()) { + Ok(_) => panic!("expected compile failure for malformed host payload"), + Err(error) => error.to_string(), + } +} + +fn assert_compile_rejects_non_bytes(source: &str, payload: &str) { + let message = compile_source_error(source); + assert!( + message.contains("expected bytes") || message.contains("no host function"), + "payload {payload} should be rejected as bytes, got {message}" + ); +} + fn run_cap_source( fixture: &Fixture, filesystem: Option>, @@ -1111,7 +1126,14 @@ fn host_catalog_registers_cap_functions_with_typed_bounds() { .expect("fs_metadata schema"); assert_eq!(metadata.params.len(), 2); assert!(matches!(metadata.params[0].ty, HostTypeSchema::String)); - assert!(matches!(metadata.return_type, HostTypeSchema::Map(_))); + match &metadata.return_type { + HostTypeSchema::Named { name, fields } => { + assert_eq!(name, "AgentFsMetadataResult"); + assert!(fields.iter().any(|field| field.name == "ok")); + assert!(fields.iter().any(|field| field.name == "kind")); + } + other => panic!("cap::fs_metadata must return AgentFsMetadataResult, got {other:?}"), + } } #[test] @@ -1490,8 +1512,9 @@ fn host_binary_round_trips_fs_and_artifact_bytes() { r#" pub fn run(input: map) -> map {{ let read = cap::fs_read_range("{read_token}", "bin.dat", 0, 8); - let put = cap::artifact_put("{write_token}", read.bytes, {{}}); - cap::artifact_get("{read_token}", put.id) + let payload: bytes = read.bytes; + let put: map = cap::artifact_put("{write_token}", payload, {{}}); + cap::artifact_get("{read_token}", put["id"]) }} "# ); @@ -1542,9 +1565,8 @@ fn host_malformed_write_payload_does_not_create_or_modify_file() { let fixture = Fixture::new("bad-write"); let path = fixture.root.join("out.bin"); fs::write(&path, b"keep").expect("seed"); - let fs_cap = Arc::new(fixture.filesystem()); let token = fixture.token(CapabilityRisk::Write); - for payload in ["{}", "\"hello\"", "1"] { + for payload in ["{}", r#""hello""#, "1"] { let source = format!( r#" pub fn run(input: map) -> map {{ @@ -1552,18 +1574,7 @@ fn host_malformed_write_payload_does_not_create_or_modify_file() { }} "# ); - let result = run_cap_source( - &fixture, - Some(Arc::clone(&fs_cap)), - Some(Arc::new(fixture.processes())), - None, - &source, - ); - assert_eq!( - envelope_error_code(&result), - "invalid_request", - "payload {payload}" - ); + assert_compile_rejects_non_bytes(&source, payload); assert_eq!( fs::read(&path).expect("unchanged"), b"keep", @@ -1578,14 +1589,7 @@ fn host_malformed_write_payload_does_not_create_or_modify_file() { }} "# ); - let result = run_cap_source( - &fixture, - Some(fs_cap), - Some(Arc::new(fixture.processes())), - None, - &create, - ); - assert_eq!(envelope_error_code(&result), "invalid_request"); + assert_compile_rejects_non_bytes(&create, "{}"); assert!(!fixture.root.join("created.bin").exists()); } @@ -1617,19 +1621,7 @@ fn host_malformed_process_and_artifact_values_fail_without_effects() { }} "# ); - let put_result = run_cap_source( - &fixture, - None, - Some(Arc::clone(&processes)), - Some(Arc::clone(&artifacts)), - &put, - ); - assert_eq!(envelope_error_code(&put_result), "invalid_request"); - if let VmValue::Map(fields) = &put_result - && let Some(VmValue::String(id)) = fields.get(&VmValue::string("id")) - { - panic!("malformed artifact put must not mint an id, got {id}"); - } + assert_compile_rejects_non_bytes(&put, "{}"); let stdin = format!( r#" @@ -1639,14 +1631,7 @@ fn host_malformed_process_and_artifact_values_fail_without_effects() { "#, spawned.handle ); - let write_result = run_cap_source( - &fixture, - None, - Some(Arc::clone(&processes)), - Some(Arc::clone(&artifacts)), - &stdin, - ); - assert_eq!(envelope_error_code(&write_result), "invalid_request"); + assert_compile_rejects_non_bytes(&stdin, "{}"); let spawn = r#" use bytes; diff --git a/tests/dependency_pin_tests.rs b/tests/dependency_pin_tests.rs index 7e74d4f..a4e2f6a 100644 --- a/tests/dependency_pin_tests.rs +++ b/tests/dependency_pin_tests.rs @@ -12,7 +12,9 @@ use std::path::PathBuf; const RUSTSCRIPT_GIT: &str = "https://github.com/rustscript-lang/rustscript.git"; -const RUSTSCRIPT_REV: &str = "f9ca4143f8ba2f486e270347504c49f5ea846097"; +const RUSTSCRIPT_REV: &str = "b1d6cffede77f49410bf63525f30b9a46b02dc01"; +const STALE_REV: &str = "f9ca4143f8ba2f486e270347504c49f5ea846097"; +const ABBREVIATED_REV: &str = "b1d6cff"; fn manifest() -> String { std::fs::read_to_string(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml")) @@ -24,13 +26,24 @@ fn lockfile() -> String { .expect("read Cargo.lock") } +fn rustscript_vm_dependency(manifest: &str) -> &str { + manifest + .lines() + .find(|line| line.trim_start().starts_with("rustscript-vm = {")) + .expect("Cargo.toml must declare rustscript-vm") +} + +fn quoted_rev<'a>(dependency: &'a str, key: &str) -> Option<&'a str> { + let needle = format!("{key} = \""); + let start = dependency.find(&needle)? + needle.len(); + let end = start + dependency[start..].find('"')?; + Some(&dependency[start..end]) +} + #[test] fn pd_vm_uses_the_reviewed_immutable_git_revision() { let manifest = manifest(); - let dependency = manifest - .lines() - .find(|line| line.trim_start().starts_with("rustscript-vm = {")) - .expect("Cargo.toml must declare rustscript-vm"); + let dependency = rustscript_vm_dependency(&manifest); assert!( dependency.contains(&format!("git = \"{RUSTSCRIPT_GIT}\"")), @@ -46,6 +59,67 @@ fn pd_vm_uses_the_reviewed_immutable_git_revision() { ); } +#[test] +fn pd_vm_pin_is_the_exact_full_sha_and_rejects_stale_or_abbreviated_pins() { + let manifest = manifest(); + let dependency = rustscript_vm_dependency(&manifest); + let rev = quoted_rev(dependency, "rev").expect("rustscript-vm must declare rev"); + + assert_eq!( + rev.len(), + 40, + "rustscript-vm rev must be the full 40-character SHA, not an abbreviation: {rev}" + ); + assert!( + rev.chars().all(|ch| ch.is_ascii_hexdigit()), + "rustscript-vm rev must be hexadecimal: {rev}" + ); + assert_eq!( + rev, RUSTSCRIPT_REV, + "rustscript-vm must pin the frozen full SHA {RUSTSCRIPT_REV}, got {rev}" + ); + assert_ne!( + rev, ABBREVIATED_REV, + "rustscript-vm must not pin the abbreviated SHA {ABBREVIATED_REV}" + ); + assert_ne!( + rev, STALE_REV, + "rustscript-vm must not remain on the stale SHA {STALE_REV}" + ); + assert!( + !dependency.contains(&format!("rev = \"{ABBREVIATED_REV}\"")), + "rustscript-vm must not use an abbreviated rev literal: {dependency}" + ); + assert!( + !dependency.contains(STALE_REV), + "rustscript-vm must not mention the stale pin {STALE_REV}: {dependency}" + ); +} + +#[test] +fn pd_host_function_uses_the_same_frozen_full_sha() { + let manifest = manifest(); + let dependency = manifest + .lines() + .find(|line| line.trim_start().starts_with("pd-host-function = {")) + .expect("Cargo.toml must declare pd-host-function for descriptor macros"); + + assert!( + dependency.contains(&format!("git = \"{RUSTSCRIPT_GIT}\"")), + "pd-host-function must use the canonical HTTPS Git remote: {dependency}" + ); + let rev = quoted_rev(dependency, "rev").expect("pd-host-function must declare rev"); + assert_eq!( + rev, RUSTSCRIPT_REV, + "pd-host-function must pin the same frozen full SHA as rustscript-vm: {dependency}" + ); + assert_eq!(rev.len(), 40, "pd-host-function rev must be the full SHA"); + assert!( + !dependency.contains("path ="), + "pd-host-function must not depend on sibling checkout state: {dependency}" + ); +} + #[test] fn pd_vm_and_pd_host_function_lock_sources_are_canonical_https_at_the_pinned_rev() { let lockfile = lockfile(); @@ -55,6 +129,11 @@ fn pd_vm_and_pd_host_function_lock_sources_are_canonical_https_at_the_pinned_rev // canonical HTTPS remote (no `path`/`file` source), the full 40-character // revision, and the `#` checkout suffix. let canonical = format!("git+{RUSTSCRIPT_GIT}?rev={RUSTSCRIPT_REV}#{RUSTSCRIPT_REV}"); + let stale = format!("git+{RUSTSCRIPT_GIT}?rev={STALE_REV}#{STALE_REV}"); + // The full SHA has `b1d6cff` as a prefix, so a substring `rev=b1d6cff` is not + // enough. An abbreviated pin would be `rev=b1d6cff"` or `rev=b1d6cff#`. + let abbreviated_quoted = format!("rev={ABBREVIATED_REV}\""); + let abbreviated_fragment = format!("rev={ABBREVIATED_REV}#"); for package in ["pd-vm", "pd-host-schema", "pd-host-function"] { let block = lockfile @@ -70,5 +149,13 @@ fn pd_vm_and_pd_host_function_lock_sources_are_canonical_https_at_the_pinned_rev format!("source = \"{canonical}\""), "Cargo.lock {package} must use the canonical HTTPS source at the pinned full rev" ); + assert!( + !block.contains(&stale), + "Cargo.lock {package} must not resolve the stale SHA {STALE_REV}" + ); + assert!( + !source.contains(&abbreviated_quoted) && !source.contains(&abbreviated_fragment), + "Cargo.lock {package} must not resolve an abbreviated SHA: {source}" + ); } } diff --git a/tests/gateway_tests.rs b/tests/gateway_tests.rs index 9efee87..e61fbe0 100644 --- a/tests/gateway_tests.rs +++ b/tests/gateway_tests.rs @@ -940,8 +940,10 @@ async fn typed_capability_failure_marks_the_run_failed() { let run_id = run["run_id"].as_str().expect("run id"); let text = read_run_events(&app, run_id).await; assert!( - text.contains("run.failed") && text.contains("capability_"), - "a typed capability failure must mark the run failed, got: {text}" + text.contains("run.failed") + && (text.contains("capability_") + || text.contains("HTTP URL scheme 'http' is not allowed")), + "a typed capability/host policy failure must mark the run failed, got: {text}" ); } @@ -1406,7 +1408,10 @@ async fn admission_persists_run_started_before_any_script_event() { // Wait for the run to finish (the terminal event only appears after the // durable commit), then restart. let live_text = read_run_events(&app, &run_id).await; - assert!(live_text.contains("run.completed")); + assert!( + live_text.contains("run.completed"), + "live run should complete, got: {live_text}" + ); drop(app); let restored = AgentGatewayState::with_sqlite_path(AgentGatewayConfig::default(), &path) @@ -3443,7 +3448,11 @@ async fn gateway_restart_recovery_fails_pending_compaction_and_allows_retry() { "completed_at_ms": now + 9, })) .expect("the retry compaction should commit"); - assert_eq!(committed["results"][0]["rows_affected"], json!(1)); + assert_eq!(committed["results"][0]["kind"], json!("execute")); + assert_eq!( + committed["results"][0]["execute"]["rows_affected"], + json!(1) + ); let committed_row = restored_persistence .compaction_get("compaction-1") .expect("compaction after commit"); diff --git a/tests/host_descriptor_architecture_tests.rs b/tests/host_descriptor_architecture_tests.rs new file mode 100644 index 0000000..d9d94a5 --- /dev/null +++ b/tests/host_descriptor_architecture_tests.rs @@ -0,0 +1,123 @@ +//! Architecture guards for Task 11: frozen-core host descriptors. +//! +//! These tests fail until agent host modules stop using manual catalog loops, +//! `register_named` / `register_exact_static` install tables, and copied +//! standard HTTP/SQLite schemas. + +use std::fs; +use std::path::PathBuf; + +fn crate_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +fn read_production_source(relative: &str) -> String { + let text = fs::read_to_string(crate_root().join(relative)) + .unwrap_or_else(|error| panic!("read {relative}: {error}")); + strip_cfg_test_modules(&text) +} + +fn strip_cfg_test_modules(source: &str) -> String { + let mut out = String::new(); + let mut skip_depth = 0usize; + let mut pending_cfg_test = false; + for line in source.lines() { + let trimmed = line.trim_start(); + if skip_depth == 0 && trimmed.starts_with("#[cfg(test)]") { + pending_cfg_test = true; + continue; + } + if pending_cfg_test { + if trimmed.starts_with("mod ") && trimmed.contains('{') { + skip_depth = 1; + pending_cfg_test = false; + continue; + } + if trimmed.starts_with("mod ") { + pending_cfg_test = false; + continue; + } + pending_cfg_test = false; + } + if skip_depth > 0 { + skip_depth += trimmed.matches('{').count(); + skip_depth = skip_depth.saturating_sub(trimmed.matches('}').count()); + continue; + } + out.push_str(line); + out.push('\n'); + } + out +} + +const HOST_PRODUCTION_SOURCES: &[&str] = &[ + "src/runtime/agent_host.rs", + "src/runtime/rss_runner.rs", + "src/auth/oauth_host.rs", + "src/auth/store_host.rs", + "src/config_host.rs", +]; + +#[test] +fn host_modules_do_not_use_register_named_or_exact_static_tables() { + for relative in HOST_PRODUCTION_SOURCES { + let text = read_production_source(relative); + assert!( + !text.contains("fn register_named("), + "{relative} still declares register_named" + ); + assert!( + !text.contains("register_exact_static("), + "{relative} still calls register_exact_static" + ); + assert!( + !text.contains("registry.register_static("), + "{relative} still calls register_static" + ); + } +} + +#[test] +fn host_catalogs_do_not_copy_standard_schemas_in_loops() { + for relative in HOST_PRODUCTION_SOURCES { + let text = read_production_source(relative); + assert!( + !text.contains("for resource in standard.resources()"), + "{relative} still copies standard resources by hand" + ); + assert!( + !text.contains("for function in standard.functions()"), + "{relative} still copies standard functions by hand" + ); + } +} + +#[test] +fn host_modules_install_through_descriptors() { + for relative in [ + "src/runtime/agent_host.rs", + "src/auth/oauth_host.rs", + "src/auth/store_host.rs", + "src/config_host.rs", + ] { + let text = read_production_source(relative); + assert!( + text.contains("HostModuleDescriptor") || text.contains("install_from_catalog"), + "{relative} must compose a HostModuleDescriptor and install_from_catalog" + ); + } +} + +#[test] +fn rss_runner_composes_standard_http_and_sqlite_modules() { + let text = read_production_source("src/runtime/rss_runner.rs"); + assert!( + text.contains("standard_catalog_modules") + || text.contains("register_http_builtin_module_from_catalog"), + "rss_runner must compose frozen standard HTTP/SQLite modules" + ); + assert!( + !text.contains("builder.function(HostFunctionSchema"), + "rss_runner must not hand-write host function schemas" + ); +} diff --git a/tests/provider_tests.rs b/tests/provider_tests.rs index cd81b90..9fadc01 100644 --- a/tests/provider_tests.rs +++ b/tests/provider_tests.rs @@ -53,6 +53,7 @@ use std::sync::mpsc; use std::thread; use std::time::{Duration, Instant}; +use rustscript_agent::CancellationReason; use rustscript_agent::capabilities::{ AllowAllApproval, ArtifactCapability, ArtifactLimits, CapabilityLifecycle, CapabilityOwner, DurableStarted, DurableToolLifecycle, FilesystemCapability, FilesystemLimits, LifecycleClock, @@ -63,7 +64,7 @@ use rustscript_agent::{ AgentConfig, AgentHostBridges, AgentRunner, RunCancellation, RunDeliveryError, RunError, RunEventSink, ScriptedProvider, bundled_tool_registry, }; -use rustscript_vm::{CancellationReason, Value}; +use rustscript_vm::Value; use serde_json::{Map as JsonMap, Value as JsonValue, json}; // --------------------------------------------------------------------------- @@ -1158,7 +1159,7 @@ fn openai_chat_stream_cancellation_is_typed() { let trigger = cancellation.clone(); let canceller = std::thread::spawn(move || { std::thread::sleep(Duration::from_millis(200)); - trigger.request(rustscript_vm::CancellationReason::Requested); + trigger.request(rustscript_agent::CancellationReason::Requested); }); let mut sink = RecordingSink::default(); let error = runner diff --git a/tests/rss_corpus_compile_tests.rs b/tests/rss_corpus_compile_tests.rs new file mode 100644 index 0000000..df0990e --- /dev/null +++ b/tests/rss_corpus_compile_tests.rs @@ -0,0 +1,126 @@ +//! Programmatic RSS corpus: every bundled `.rss` file must compile. +//! +//! Auth/config/oauth entries use the config-fixture catalogs; every other +//! file compiles through the production restricted agent catalog. Compile +//! failures are fatal — there is no accepted-error path. + +use std::fs; +use std::path::{Path, PathBuf}; + +use rustscript_agent::{AgentConfig, AgentRunner}; + +const EXPECTED_RSS_CORPUS_LEN: usize = 49; + +fn crate_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +fn collect_rss(dir: &Path, out: &mut Vec) { + let entries = fs::read_dir(dir).unwrap_or_else(|error| { + panic!("read {}: {error}", dir.display()); + }); + for entry in entries { + let entry = entry.expect("dir entry"); + let path = entry.path(); + if path.is_dir() { + collect_rss(&path, out); + } else if path.extension().and_then(|ext| ext.to_str()) == Some("rss") { + out.push(path); + } + } +} + +fn all_rss_files() -> Vec { + let mut files = Vec::new(); + collect_rss(&crate_root().join("rss"), &mut files); + collect_rss(&crate_root().join("examples"), &mut files); + files.sort(); + files +} + +fn relative_rss(path: &Path) -> String { + path.strip_prefix(crate_root()) + .unwrap_or(path) + .display() + .to_string() +} + +fn is_config_fixture_rss(path: &Path) -> bool { + path.strip_prefix(crate_root().join("rss").join("auth")) + .is_ok() +} + +fn compile_production_rss(path: &Path) { + AgentRunner::from_file(path, AgentConfig::default()).unwrap_or_else(|error| { + panic!( + "{} must compile with the production catalog: {error}", + relative_rss(path) + ); + }); +} + +#[cfg(feature = "config-fixture")] +fn compile_fixture_rss(path: &Path) { + use rustscript_vm::{ + CompileSourceFileOptions, SourceFlavor, compile_source_at_path_with_flavor_and_options, + }; + + let source = fs::read_to_string(path).unwrap_or_else(|error| { + panic!("read {}: {error}", relative_rss(path)); + }); + let name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(""); + let catalog = match name { + "oauth_flow.rss" => rustscript_agent::config_fixture::oauth_fixture_catalog(), + "store_entry.rss" => rustscript_agent::config_fixture::auth_store_fixture_catalog(), + "config_entry.rss" => rustscript_agent::config_fixture::config_fixture_catalog(), + other => panic!( + "{} is under rss/auth but has no fixture catalog mapping ({other})", + relative_rss(path) + ), + }; + let options = CompileSourceFileOptions::default().with_host_api_catalog(catalog); + compile_source_at_path_with_flavor_and_options( + path, + &source, + SourceFlavor::RustScript, + options, + ) + .unwrap_or_else(|error| { + panic!( + "{} must compile with its fixture catalog: {error}", + relative_rss(path) + ); + }); +} + +#[test] +fn bundled_rss_corpus_has_exact_count_and_compiles() { + let files = all_rss_files(); + let relative: Vec = files.iter().map(|path| relative_rss(path)).collect(); + assert_eq!( + files.len(), + EXPECTED_RSS_CORPUS_LEN, + "bundled RSS corpus must contain exactly {EXPECTED_RSS_CORPUS_LEN} files, got {}: {relative:?}", + files.len() + ); + + for path in &files { + if is_config_fixture_rss(path) { + #[cfg(feature = "config-fixture")] + compile_fixture_rss(path); + #[cfg(not(feature = "config-fixture"))] + { + assert!( + path.is_file(), + "{} must remain in the corpus", + relative_rss(path) + ); + } + } else { + compile_production_rss(path); + } + } +} diff --git a/tests/rss_mutating_file_tool_tests.rs b/tests/rss_mutating_file_tool_tests.rs index 4ed5351..73b2d42 100644 --- a/tests/rss_mutating_file_tool_tests.rs +++ b/tests/rss_mutating_file_tool_tests.rs @@ -11,6 +11,7 @@ use std::sync::{Arc, Barrier, Mutex}; use std::thread; use std::time::{Duration, Instant}; +use rustscript_agent::CancellationReason; use rustscript_agent::capabilities::{ ApprovalGate, ArtifactCapability, ArtifactLimits, CancellationFlag, CapabilityError, CapabilityLifecycle, CapabilityOwner, CapabilityRisk, DurableStarted, DurableToolLifecycle, @@ -21,7 +22,7 @@ use rustscript_agent::config::FileToolConfig; use rustscript_agent::{ AgentConfig, AgentHostBridges, AgentRunner, ControlCheckHook, RunCancellation, ToolResult, }; -use rustscript_vm::{CancellationReason, Value as VmValue}; +use rustscript_vm::Value as VmValue; use serde_json::{Value, json}; fn json_to_vm_value(value: &Value) -> VmValue { diff --git a/tests/rss_process_tool_tests.rs b/tests/rss_process_tool_tests.rs index c18a43d..ad3e3fc 100644 --- a/tests/rss_process_tool_tests.rs +++ b/tests/rss_process_tool_tests.rs @@ -13,6 +13,7 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; +use rustscript_agent::CancellationReason; use rustscript_agent::capabilities::{ ApprovalGate, ArtifactCapability, ArtifactLimits, CancellationFlag, CapabilityLifecycle, CapabilityOwner, CapabilityRisk, DurableStarted, DurableToolLifecycle, LifecycleClock, @@ -23,7 +24,7 @@ use rustscript_agent::config::ProcessToolConfig; use rustscript_agent::{ AgentConfig, AgentHostBridges, AgentRunner, ControlCheckHook, RunCancellation, ToolResult, }; -use rustscript_vm::{CancellationReason, Value as VmValue}; +use rustscript_vm::Value as VmValue; use serde_json::{Value, json}; use uuid::Uuid; diff --git a/tests/runner_tests.rs b/tests/runner_tests.rs index 57e3b18..e124711 100644 --- a/tests/runner_tests.rs +++ b/tests/runner_tests.rs @@ -4,11 +4,12 @@ use std::sync::{Arc, Mutex}; use std::thread; use std::time::{Duration, Instant}; +use rustscript_agent::CancellationReason; use rustscript_agent::{ AgentConfig, AgentRunner, RunCancellation, RunDeliveryError, RunError, RunEventSink, RunnerPrepareFault, set_after_snapshot_hook, }; -use rustscript_vm::{CancellationReason, InvocationError, Value}; +use rustscript_vm::{InvocationError, Value}; fn spawn_fixture() -> (u16, thread::JoinHandle<()>) { let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind fixture"); @@ -136,13 +137,29 @@ fn runs_script_owned_http_call_to_completion() { let headers = response .get(&Value::string("headers")) .expect("headers field"); - let Value::Map(headers) = headers else { - panic!("expected response headers map"); + let Value::Array(headers) = headers else { + panic!("expected typed response header array"); }; - assert_eq!( - headers.get(&Value::string("x-agent")), - Some(&Value::string("fixture")) - ); + let agent = headers.iter().find_map(|header| { + let Value::Map(header) = header else { + return None; + }; + if header.get(&Value::string("name")) != Some(&Value::string("x-agent")) { + return None; + } + let Some(Value::Map(value)) = header.get(&Value::string("value")) else { + return None; + }; + assert_eq!( + value.get(&Value::string("kind")), + Some(&Value::string("text")) + ); + match value.get(&Value::string("text")) { + Some(text @ Value::String(_)) => Some(text.clone()), + _ => None, + } + }); + assert_eq!(agent.as_ref(), Some(&Value::string("fixture"))); } #[test] diff --git a/tests/storage_tests.rs b/tests/storage_tests.rs index 96449ef..1554e93 100644 --- a/tests/storage_tests.rs +++ b/tests/storage_tests.rs @@ -22,6 +22,7 @@ const STORAGE_FILES: &[&str] = &[ "load.rss", "existence.rss", "gateway.rss", + "import-test.rss", ]; fn storage_root() -> PathBuf { @@ -89,6 +90,68 @@ fn storage_runner(root: &std::path::Path) -> AgentRunner { .expect("production storage entrypoint should compile") } +#[test] +fn sqlite_query_named_result_fields_compile() { + let source = r#" +use sqlite; +pub fn encode(db_id: resource, content: string) -> string { + let classified = sqlite::query(&db_id, "SELECT 1 AS n", [], {}); + let _n = classified.rows.copy().length; + content +} +fn helper(db_id: resource) -> int { + let first = encode(db_id, "a"); + let second = encode(db_id, "b"); + first.length + second.length +} +pub fn run(ctx: int) -> int { + let db = sqlite::open({ path: ":memory:", mode: "memory" }); + let result = sqlite::query(&db, "SELECT 1 AS n", [], {}); + let n = result.rows.copy().length; + let cell = result.rows.copy()[0].cells.copy()[0]; + let _value = cell.int_value; + let _via_helper = helper(db); + sqlite::close(db); + ctx +} +"#; + AgentRunner::from_source(source, AgentConfig::default()) + .unwrap_or_else(|error| panic!("minimal sqlite query should compile: {error}")); +} + +#[test] +fn sqlite_query_named_result_fields_compile_across_modules() { + let dir = temporary_root("sqlite-module-resource"); + std::fs::write( + dir.join("helper.rss"), + r#" +use sqlite; +pub fn q(db_id: resource) -> int { + let result = sqlite::query(&db_id, "SELECT 1 AS n", [], {}); + result.rows.copy().length +} +"#, + ) + .expect("write helper"); + std::fs::write( + dir.join("main.rss"), + r#" +use sqlite; +use helper; +pub fn run(ctx: int) -> int { + let db = sqlite::open({ path: ":memory:", mode: "memory" }); + let n = helper::q(db); + sqlite::close(db); + ctx +} +"#, + ) + .expect("write main"); + AgentRunner::from_file(dir.join("main.rss"), AgentConfig::default()).unwrap_or_else(|error| { + panic!("cross-module sqlite resource should compile: {error}"); + }); +} + fn run_storage( runner: &AgentRunner, db_name: &str, @@ -117,13 +180,25 @@ fn vm_value_to_json(value: &Value) -> JsonValue { Value::Bool(value) => json!(value), Value::String(value) => JsonValue::String(value.to_string()), Value::Bytes(value) => JsonValue::String(String::from_utf8_lossy(value).into_owned()), - Value::Array(values) => JsonValue::Array(values.iter().map(vm_value_to_json).collect()), - Value::Map(entries) => JsonValue::Object( - entries + Value::Array(values) => JsonValue::Array( + values .iter() - .map(|(key, value)| (vm_map_key_to_string(key), vm_value_to_json(value))) + .map(|value| { + let encoded = vm_value_to_json(value); + rustscript_agent::sqlite_storage_rows::sqlite_storage_row(&encoded) + .map(JsonValue::Array) + .unwrap_or(encoded) + }) .collect(), ), + Value::Map(entries) => { + let object: serde_json::Map = entries + .iter() + .map(|(key, value)| (vm_map_key_to_string(key), vm_value_to_json(value))) + .collect(); + let encoded = JsonValue::Object(object); + rustscript_agent::sqlite_storage_rows::sqlite_storage_cell(&encoded).unwrap_or(encoded) + } Value::Callable(_) => JsonValue::String("".to_string()), } } @@ -145,11 +220,7 @@ fn first_query_row(result: &JsonValue) -> JsonMap { .get("columns") .and_then(JsonValue::as_array) .expect("SQLite query data should contain columns"); - let row = data - .get("rows") - .and_then(JsonValue::as_array) - .and_then(|rows| rows.first()) - .and_then(JsonValue::as_array) + let row = rustscript_agent::sqlite_storage_rows::sqlite_storage_first_row(&data) .expect("SQLite query data should contain one row"); columns .iter() @@ -172,14 +243,13 @@ fn query_rows(result: &JsonValue) -> Vec> { .get("columns") .and_then(JsonValue::as_array) .expect("SQLite query data should contain columns"); - data.get("rows") - .and_then(JsonValue::as_array) + rustscript_agent::sqlite_storage_rows::sqlite_storage_rows(&data) .expect("SQLite query data should contain rows") - .iter() + .into_iter() .map(|row| { columns .iter() - .zip(row.as_array().expect("SQLite row should be an array")) + .zip(row.iter()) .map(|(column, value)| { ( column @@ -280,17 +350,35 @@ pub fn run(input: map) -> map {{ let db = sqlite::open({{ path: input["db_name"], mode: "read_write_create", + root: null, limits: {{ - busy_timeout_ms: 1000, max_connections: 1, + max_statements: 64, max_rows: 64, + max_columns: 128, max_result_bytes: 65536, - max_statements: 64, - max_transaction_ms: 5000 + max_statement_bytes: 1048576, + max_parameters: 128, + max_parameter_bytes: 1048576, + max_pending_operations: 32, + max_transaction_ms: 5000, + busy_timeout_ms: 1000 }} }}); {body} - let result: map = sqlite::query(&db, "{final_sql}", [], {{ max_rows: 64, max_result_bytes: 65536 }}); + let result: SqliteQueryResult = sqlite::query(&db, "{final_sql}", [], {{ + max_connections: 1, + max_statements: 64, + max_rows: 64, + max_columns: 128, + max_result_bytes: 65536, + max_statement_bytes: 1048576, + max_parameters: 128, + max_parameter_bytes: 1048576, + max_pending_operations: 32, + max_transaction_ms: 5000, + busy_timeout_ms: 1000 + }}); sqlite::close(db); result }} @@ -378,13 +466,19 @@ pub fn run(input: map) -> bool {{ let db = sqlite::open({{ path: input["db_name"], mode: "read_write_create", + root: null, limits: {{ - busy_timeout_ms: 1000, max_connections: 1, + max_statements: 64, max_rows: 64, + max_columns: 128, max_result_bytes: 65536, - max_statements: 64, - max_transaction_ms: 5000 + max_statement_bytes: 1048576, + max_parameters: 128, + max_parameter_bytes: 1048576, + max_pending_operations: 32, + max_transaction_ms: 5000, + busy_timeout_ms: 1000 }} }}); {body} @@ -421,27 +515,39 @@ pub fn run(input: map) -> bool { let db = sqlite::open({ path: input["db_name"], mode: "read_write_create", + root: null, limits: { - busy_timeout_ms: 1000, max_connections: 1, + max_statements: 64, max_rows: 64, + max_columns: 128, max_result_bytes: 65536, - max_statements: 64, - max_transaction_ms: 5000 + max_statement_bytes: 1048576, + max_parameters: 128, + max_parameter_bytes: 1048576, + max_pending_operations: 32, + max_transaction_ms: 5000, + busy_timeout_ms: 1000 } }); sqlite::execute(&db, schema::schema_migrations_table_sql(), []); - let mut statements = []; - let mut statement_index = 0; - while statement_index < 11 { - statements[statements.length] = { sql: schema::schema_migration_statement(0, statement_index), params: [] }; - statement_index += 1; - } - statements[statements.length] = { - sql: schema::schema_migration_record_sql(), - params: [1, schema::schema_migration_name(0), schema::schema_migration_checksum(0), 1] - }; - sqlite::transaction(&db, statements); + sqlite::execute(&db, schema::schema_migration_statement(0, 0), []); + sqlite::execute(&db, schema::schema_migration_statement(0, 1), []); + sqlite::execute(&db, schema::schema_migration_statement(0, 2), []); + sqlite::execute(&db, schema::schema_migration_statement(0, 3), []); + sqlite::execute(&db, schema::schema_migration_statement(0, 4), []); + sqlite::execute(&db, schema::schema_migration_statement(0, 5), []); + sqlite::execute(&db, schema::schema_migration_statement(0, 6), []); + sqlite::execute(&db, schema::schema_migration_statement(0, 7), []); + sqlite::execute(&db, schema::schema_migration_statement(0, 8), []); + sqlite::execute(&db, schema::schema_migration_statement(0, 9), []); + sqlite::execute(&db, schema::schema_migration_statement(0, 10), []); + sqlite::execute(&db, schema::schema_migration_record_sql(), [ + { kind: "int", int_value: 1, float_value: null, text_value: null, blob_value: null }, + { kind: "text", int_value: null, float_value: null, text_value: schema::schema_migration_name(0), blob_value: null }, + { kind: "text", int_value: null, float_value: null, text_value: schema::schema_migration_checksum(0), blob_value: null }, + { kind: "int", int_value: 1, float_value: null, text_value: null, blob_value: null } + ]); sqlite::close(db); true } @@ -494,8 +600,8 @@ fn storage_rss_contract_files_are_present_and_use_generic_capabilities() { ); let source = fs::read_to_string(&path).expect("storage module should be readable"); assert!( - source.contains("sqlite::") || *file == "schema.rss", - "{} must use the generic sqlite capability or be schema-only", + source.contains("sqlite::") || *file == "schema.rss" || *file == "import-test.rss", + "{} must use the generic sqlite capability, be schema-only, or be the import fixture", path.display() ); assert!(