Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
module gunbc.recurring_failure_mode.response_format_variant_silently_read_as_json

import std.types { NonEmptyStr }
import std.decl_ref { DeclarationRef, WholeDeclaration }
import gunbc.recurring_failure_mode { RecurringFailureMode }

data response_format_variant_silently_read_as_json: RecurringFailureMode = RecurringFailureMode {
identity: "response_format_variant_silently_read_as_json" as NonEmptyStr,
receipts: [
"**a `transport rest` response_format the two realizations read differently, with the interpreter's unread spelling silently defaulting to Json** (INVALID STATE: `response_format: Text`, the std.serialization WireFormat variant extdeps.github.pulls github.Pulls.Diff writes, was read as text by emitted code -- v1.compiler.emit_rust emit_plain_response_body takes the authored variant name -- and as ABSENT by the interpreter, whose dispatch_rest read the property only as a string literal and defaulted every other spelling to Json. HARM: a silent wrong answer (DESIGN section 5). The interpreter JSON-decoded a plain-text body and answered RestBodyUndecodable at status 200, or raised on an operation with no outcome field, while the declaration said text; the quoted \"Text\" that DID work in the interpreter was read as Json by emitted code. One declaration, two meanings, neither refusal located at the declaration.)",

"DISCRIMINATING PROBE, 2026-10-04, live against api.github.com on gunb-ai/gunbc#13225 with the PR-diff Accept header: two operations identical except for the spelling. `response_format: Text` answered RestBodyUndecodable { status: 200, cause: JSON body did not decode: expected value at line 1 column 1 }; `response_format: \"Text\"` answered RestOk with 2,964 diff lines. Found while modeling extdeps.github.workflow_runs DownloadJobLogsForWorkflowRun, whose first live read failed the same way.",

"HERMETIC RED, now enrolled: test.claim.rest_exchange_replay_test a_text_response_format_reads_a_plain_body_as_text replays a plain-text 200 through dispatch_rest for an operation spelled `response_format: Text` (test.fixture.rest_exchange_replay_probe TextBody); before the fix it answered RestBodyUndecodable. an_unrecognized_response_format_refuses_rather_than_defaulting_to_json is the other side: any spelling that is not a WireFormat variant refuses with a located message instead of defaulting.",

"THE FIX MADE ONE READING, NOT A SECOND ONE: the interpreter now reads the property through the same v1.std.core transport_response_format and authored_name_at the emitter uses, accepts Text and Json, keeps absent as Json, and refuses anything else.",

"WHAT IS NOT FIXED, named so it is not read as covered: emitted code still treats any non-Text spelling, including the quoted string, as Json without refusing (emit_plain_response_body's is_text is a Bool over the authored name). Making that a refusal is a change to the .dag source of v1.compiler.emit_rust plus the stage0 mirror regeneration, and is this row's next rung.",

"RUNG FOUND AT: below the ladder -- silent wrongness in the interpreter. RUNG NOW: mechanically preventable for the interpreter (the replay witness reds a regression on the acceptance path); the emitter path stays mitigatable at best.",

"CEILING: structurally impossible. response_format's value is a closed two-variant type, so the declaration can carry WireFormat itself and the reserved-key read can decode it as that type, leaving no spelling for either realization to misread.",

"NEXT-RUNG TRIGGER, A CAPABILITY: the transport property is decoded as std.serialization WireFormat at parse, once, and both the interpreter and v1.compiler.emit_rust consume that decoded value -- sufficient that a spelling which is not a variant is refused at the declaration and the two realizations cannot read one declaration two ways.",
],
evidence: [
DeclarationRef { module_path: "test.claim.rest_exchange_replay_test", decl_name: "a_text_response_format_reads_a_plain_body_as_text", field: WholeDeclaration },
DeclarationRef { module_path: "test.claim.rest_exchange_replay_test", decl_name: "an_unrecognized_response_format_refuses_rather_than_defaulting_to_json", field: WholeDeclaration },
DeclarationRef { module_path: "std.serialization", decl_name: "WireFormat", field: WholeDeclaration },
],
}
40 changes: 40 additions & 0 deletions dag/test/claim/rest_exchange_replay_test.dag
Original file line number Diff line number Diff line change
Expand Up @@ -444,3 +444,43 @@ test fn a_coproduct_with_no_declared_wire_spelling_refuses_even_an_authored_matc
WitnessInterrupted { at: _, route: _, state: _ } => false
}
}

// THE TYPED RESPONSE FORMAT IS HONOURED (gunbc.recurring_failure_mode
// response_format_variant_silently_read_as_json). RED before the fix: the interpreter read
// response_format only as a string literal, so `response_format: Text` fell back to Json and this
// plain-text body answered RestBodyUndecodable at status 200.
test fn a_text_response_format_reads_a_plain_body_as_text() -> Bool {
let result = evaluate_in_witness_frame(
frame: replay_frame(fixtures: [fixture_for(
operation: "TextBody",
observation: RestResponseObserved { status: 200, body: RestBodyRead { body: "diff --git a/x b/x\n+plain text, not JSON" } }
)]),
subject: fn(_scope) { test.RestReplay.TextBody() }
)
match result {
WitnessReturned { value } => value.text == "diff --git a/x b/x\n+plain text, not JSON" && match value.observed {
RestOk => true
_ => false
}
WitnessRefused { diagnostic } => false
WitnessInterrupted { at: _, route: _, state: _ } => false
}
}

// ANY OTHER SPELLING REFUSES. The quoted string is the one that used to be the only working
// spelling in the interpreter and was read as Json by emitted code; now neither realization
// can take it, and the interpreter says so instead of guessing.
test fn an_unrecognized_response_format_refuses_rather_than_defaulting_to_json() -> Bool {
let result = evaluate_in_witness_frame(
frame: replay_frame(fixtures: [fixture_for(
operation: "FormatUnrecognized",
observation: RestResponseObserved { status: 200, body: RestBodyRead { body: "plain" } }
)]),
subject: fn(_scope) { test.RestReplay.FormatUnrecognized() }
)
match result {
WitnessReturned { value } => false
WitnessRefused { diagnostic } => contains(witness_diagnostic_rendered_reason(diagnostic: diagnostic), "response_format must be a std.serialization WireFormat variant")
WitnessInterrupted { at: _, route: _, state: _ } => false
}
}
25 changes: 25 additions & 0 deletions dag/test/fixture/rest_exchange_replay_probe.dag
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ module test.fixture.rest_exchange_replay_probe

import std.types { Bool, Int, List, String }
import extdeps.transports.rest { RestOutcome }
import std.serialization { Text }
import extdeps.github.workflow_runs { WorkflowRunList }
import extdeps.cloud.gcp.gcp { WifProviderWire, WifProviderListWire, WifPoolWire }

Expand All @@ -28,6 +29,10 @@ type WireUncontractedShape {
// WifProvidersPage and WifPool carry the OUTPUT SHAPES of extdeps.cloud.gcp.iam_admin
// ListWorkloadIdentityPoolProviders and GetWorkloadIdentityPool verbatim, so a recorded IAM body
// reaches the same type-directed decode gunbc.auth.heal_publisher_provision reads through.
// TextBody spells response_format as the std.serialization WireFormat variant github.Pulls.Diff
// uses, so a plain-text body must come back as text through dispatch_rest rather than through the
// JSON decoder. FormatUnrecognized spells it as no WireFormat variant, which must refuse rather than
// default to Json.
service test.RestReplay {
config { endpoint: "https://rest-replay.invalid" }

Expand All @@ -40,6 +45,26 @@ service test.RestReplay {
transport rest { method: GET, path: "/answer" }
}

operation TextBody {
output {
text: String
observed: RestOutcome
}
readonly
transport rest { method: GET, path: "/answer", response_format: Text }
response { 200 => String }
}

operation FormatUnrecognized {
output {
text: String
observed: RestOutcome
}
readonly
transport rest { method: GET, path: "/answer", response_format: "Text" }
response { 200 => String }
}

operation RootArray {
output {
pulls: List<Int>
Expand Down
53 changes: 38 additions & 15 deletions src/v1/stage0/src/v1_interpreter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,16 +63,16 @@ use crate::v1_std_core::{
binop_right, block_stmts, cast_expr, cast_target, expr_call_func_at, expr_field_access_summary,
expr_method_call_semantics, expr_method_name_at, expr_var_name_at, field_access_base,
field_access_field_at, field_binding_name_at, field_binding_pattern, field_init_node_name_at,
field_init_node_value, find_property, find_property_string, foreach_body, foreach_collection,
foreach_variable_at, if_condition, if_else_branch, if_then_branch, import_is_all,
import_specific_names_at, index_base, index_expr, is_file_transport, is_rest_transport,
is_shell_transport, lambda_body, lambda_param_names_at, let_binding_name_at, let_body,
let_value, match_arm_nodes, match_scrutinee, method_arg_nodes, method_receiver,
param_node_default_value, param_node_name_at, qualified_last_segment, record_lit_type_name_at,
return_value, slice_base, slice_end, slice_start, transport_stdin, type_name_compatible,
unaryop_operand, CallSemantics, Cardinality, Connective, ErrorNode, ExprData, FieldAccessStyle,
FieldSummary, FieldValueShape, InferredNode, MatchPattern, MethodSemantics, NewlineIndex, Node,
SourceSpan, StringPart, UnaryOpKind, VarBindingKind,
field_init_node_value, find_property, foreach_body, foreach_collection, foreach_variable_at,
if_condition, if_else_branch, if_then_branch, import_is_all, import_specific_names_at,
index_base, index_expr, is_file_transport, is_rest_transport, is_shell_transport, lambda_body,
lambda_param_names_at, let_binding_name_at, let_body, let_value, match_arm_nodes,
match_scrutinee, method_arg_nodes, method_receiver, param_node_default_value,
param_node_name_at, qualified_last_segment, record_lit_type_name_at, return_value, slice_base,
slice_end, slice_start, transport_stdin, type_name_compatible, unaryop_operand, CallSemantics,
Cardinality, Connective, ErrorNode, ExprData, FieldAccessStyle, FieldSummary, FieldValueShape,
InferredNode, MatchPattern, MethodSemantics, NewlineIndex, Node, SourceSpan, StringPart,
UnaryOpKind, VarBindingKind,
};

#[path = "bounded_shell_host_drain.rs"]
Expand Down Expand Up @@ -19080,12 +19080,35 @@ fn dispatch_rest(
None => None,
};

let response_format = find_property_string(
transport.properties.clone(),
"response_format".to_string(),
// THE RESPONSE FORMAT IS THE AUTHORED std.serialization WireFormat VARIANT, READ THE WAY THE
// EMITTER READS IT (v1.compiler.emit_rust emit_plain_response_body: transport_response_format,
// then authored_name_at), so the interpreter and emitted code cannot disagree on one
// declaration. This site used to read only a string literal and default every other spelling
// to Json, so `response_format: Text` -- the typed spelling github.Pulls.Diff writes -- was
// silently JSON-decoded here while emitted code read it as text
// (gunbc.recurring_failure_mode response_format_variant_silently_read_as_json). Absent is
// Json, as before; a present value that is not a WireFormat variant refuses rather than
// defaulting.
let response_format = match crate::v1_std_core::transport_response_format(
transport.clone(),
si.clone(),
)
.unwrap_or_else(|| "Json".to_string());
) {
None => "Json".to_string(),
Some(node) => {
let authored = crate::v1_std_core::authored_name_at(si.clone(), node.clone());
if authored == "Text" || authored == "Json" {
authored
} else {
return Err(InterpError::TypeError {
msg: format!(
"transport rest response_format must be a std.serialization WireFormat variant (Json or Text); \
found {:?} on {}",
authored, op_node.name
),
});
}
}
};

// TLS posture (extdeps.transports.rest TlsPosture). Absent = VerifyPeer, the fail-closed
// default (ureq's stock rustls verifier). InsecureAcceptAnyCert is the modeled dissolution
Expand Down