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
1,214 changes: 1,029 additions & 185 deletions 03_PROTO/crates/riina-codegen/src/emit.rs

Large diffs are not rendered by default.

89 changes: 89 additions & 0 deletions 03_PROTO/crates/riina-codegen/src/lower.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,95 @@ pub(crate) fn builtin_canonical(name: &str) -> Option<&'static str> {
return Some(canonical);
}
}
// Security builtins (keselamatan) — REQ-70 family routing. 41 of the 42 are
// here; `csrf_generate` is the single exclusion and is justified below.
//
// ON THE PAIR-TAKING MEMBERS. An earlier increment routed only the
// single-argument subset, on the reasoning that eleven members take a pair
// and `split_pair` hands back a `Value::BuiltinPartial` for a non-pair
// argument, which the C backend has no equivalent of. That reasoning was
// WRONG, and checking it rather than repeating it is what unblocked the
// rest of the family: every one of those signatures is typed
// `Ty::Prod(..) -> _` in riina-typechecker, so the curried surface form
// `f(a, b)` is REJECTED AT TYPE-CHECK — identically under `riinac run` and
// `riinac build` — and only `f((a, b))` ever reaches a runtime. The
// interpreter's partial arm is unreachable from well-typed source, so C
// needing no partial-application machinery costs nothing.
//
// NOT ROUTED: `csrf_generate` / `csrf_jana`. It is the one member whose
// result is not a function of its input — a token seeded from the clock and
// a process-local counter — so the two backends cannot be held to agreement
// by a differential, only to a shape. Mirroring it would mean transcribing
// Rust's `DefaultHasher` into C to reproduce a generator its own doc comment
// already marks as "a *reference* token, not a certified CSPRNG". Spreading
// that to a second implementation makes the eventual fix twice the work and
// buys nothing: `csrf_validate` takes plain `Teks`, so a compiled program
// can still carry tokens minted elsewhere and is not cut off from the
// family the way the sanitizers would have been without `baca_baris`.
match name {
// The taint SOURCE. Routed with the sanitizers because it is their only
// input: `Tainted<Teks, UserInput>` has exactly one producer, so
// routing the sanitizers alone would mark them native-only while
// leaving them unreachable from compiled code.
"baca_baris" | "read_line" | "baca_garisan" => return Some("baca_baris"),
// Sanitizers — pure transforms, mirrored byte-for-byte in emit.rs.
"sanitasi_html" | "sanitize_html" => return Some("sanitize_html"),
"sanitasi_xml" | "sanitize_xml" => return Some("sanitize_xml"),
"sanitasi_sql" | "sanitize_sql" => return Some("sanitize_sql"),
"sanitasi_js" | "sanitize_js" => return Some("sanitize_js"),
"sanitasi_css" | "sanitize_css" => return Some("sanitize_css"),
"sanitasi_url" | "sanitize_url" => return Some("sanitize_url"),
"sanitasi_laluan" | "sanitize_path" => return Some("sanitize_path"),
"sanitasi_perintah" | "sanitize_command" => return Some("sanitize_command"),
"sanitasi_ldap" | "sanitize_ldap" => return Some("sanitize_ldap"),
"sanitasi_json" | "sanitize_json" => return Some("sanitize_json"),
"sanitasi_emel" | "sanitize_email" => return Some("sanitize_email"),
// Validators / normalizers.
"sahkan_url" | "validate_url" => return Some("validate_url"),
"normal_unicode" | "normalize_unicode" => return Some("normalize_unicode"),
"buang_null" | "strip_nulls" => return Some("strip_nulls"),
// Modelled sinks. These do NOT perform the dangerous operation in
// either backend — their value is that the type system forces a
// `Disanitasi<_>` argument to reach them at all, which is a
// compile-time property and so already holds for both backends.
"sql_laksana" | "sql_execute" => return Some("sql_execute"),
"ldap_cari" | "ldap_search" => return Some("ldap_search"),
"xml_cari" | "xml_query" => return Some("xml_query"),
"js_nilai" | "js_eval" => return Some("js_eval"),
"html_papar" | "html_render" => return Some("html_render"),
"shell_laksana" | "shell_exec" => return Some("shell_exec"),
"http_arah_selamat" | "http_redirect_safe" => return Some("http_redirect_safe"),
"http_dapat" | "http_get" => return Some("http_get"),
"http_ambil_selamat" | "http_fetch_safe" => return Some("http_fetch_safe"),
"badan_http" | "http_body" => return Some("http_body"),
// Pair-taking sinks. Modelled the same way, and reached only through an
// explicit tuple (see the note above).
"dom_tetap_html" | "dom_set_html" => return Some("dom_set_html"),
"dom_tetap_atribut" | "dom_set_attr" => return Some("dom_set_attr"),
"emel_hantar" | "email_send" => return Some("email_send"),
"emel_tetap_kepala" | "email_set_header" => return Some("email_set_header"),
"http_hantar" | "http_post" => return Some("http_post"),
"http_kemaskini" | "http_put" => return Some("http_put"),
"http_padam" | "http_delete" => return Some("http_delete"),
// Input validation. Counts UNICODE SCALAR VALUES, not bytes.
"sahkan_panjang" | "validate_length" => return Some("validate_length"),
// CSRF checks — pure predicates over their two arguments.
"csrf_sahkan" | "csrf_validate" => return Some("csrf_validate"),
"csrf_semak_origin" | "csrf_check_origin" => return Some("csrf_check_origin"),
"csrf_semak_referer" | "csrf_check_referer" => return Some("csrf_check_referer"),
// Safe file I/O. Routable only because these share the verified gate
// with `fail_*`, which the emitted C now carries.
"fail_baca_selamat" | "file_read_safe" => return Some("file_read_safe"),
"fail_tulis_selamat" | "file_write_safe" => return Some("file_write_safe"),
"fail_buang_selamat" | "file_delete_safe" => return Some("file_delete_safe"),
// Safe parsers. Routable only because the emitted JSON parser is now
// STRICT: "malformed input yields Unit" is not a contract a lenient
// parser can honour, and the old one never failed on anything.
"json_urai_selamat" | "json_parse_safe" => return Some("json_parse_safe"),
"nyahsiri_selamat" | "deserialize_safe" => return Some("deserialize_safe"),
"xml_urai_selamat" | "xml_parse_safe" => return Some("xml_parse_safe"),
_ => {}
}
// File builtins (REQ-70 family routing) — routed ONLY because the emitted
// C now carries the verified gate.
//
Expand Down
39 changes: 39 additions & 0 deletions 03_PROTO/crates/riinac/tests/collection_differential.rs
Original file line number Diff line number Diff line change
Expand Up @@ -189,3 +189,42 @@ fn map_and_set_agree() {
\x20 cetakln(ke_teks(set_panjang(s)));",
);
}

// ── Rendering composite values ─────────────────────────────────────────────

/// `cetakln` and `ke_teks` both render through one function, and until REQ-70's
/// keselamatan increment the C one answered the literal text `<value>` for every
/// composite tag: PAIR, LIST, MAP and both SUM arms. A compiled program printing
/// a list showed `<value>` where `riinac run` shows `[1, 2, 3]`.
///
/// The cases here separate the two rendering modes the interpreter actually has,
/// because a C author mirroring only the obvious one would still pass a
/// list-of-ints test:
///
/// * `builtins::format_value` prints a string BARE and a bool as `betul`/`salah`;
/// * `Value`'s `Display` — which `format_value` falls through to, and which is
/// the ONLY path a sum takes — QUOTES the string and prints Rust's English
/// `true`/`false`.
///
/// So the same bool renders `betul` inside a list and `true` inside a sum. That
/// is an inconsistency in the reference rather than a design; it is pinned here
/// so that changing it is a deliberate language decision and not codegen drift.
#[test]
fn composite_values_render_identically() {
assert_backends_agree("fmt_list", " cetakln(ke_teks([1, 2, 3]));");
assert_backends_agree("fmt_pair", " cetakln(ke_teks((1, \"dua\")));");
assert_backends_agree("fmt_nested", " cetakln(ke_teks(([1, 2], (betul, ()))));");
}

/// The mode split, isolated: a bool and a string each rendered directly, inside
/// a list, and inside a sum.
#[test]
fn sum_rendering_uses_display_not_format_value() {
assert_backends_agree(
"fmt_modes",
" cetakln(ke_teks(betul));\n\
\x20 cetakln(ke_teks([betul, salah]));\n\
\x20 cetakln(ke_teks(\"teks\"));\n\
\x20 cetakln(ke_teks([\"teks\"]));",
);
}
117 changes: 117 additions & 0 deletions 03_PROTO/crates/riinac/tests/json_differential.rs
Original file line number Diff line number Diff line change
Expand Up @@ -213,3 +213,120 @@ fn accessors_agree() {
" cetakln(json_ke_teks(json_letak((json_urai(\"{\\\"z\\\":1}\"), (\"a\", 9)))));",
);
}

// ── Malformed input ────────────────────────────────────────────────────────

/// Run one program under both backends and require BOTH to fail, having printed
/// the same output up to the point of failure.
///
/// The success-only helper above cannot express this, which is precisely how the
/// divergence these cases pin survived: every existing case fed WELL-FORMED
/// JSON, so a C parser that could not fail was never asked to.
fn assert_backends_both_reject(tag: &str, body: &str) {
let dir = std::env::temp_dir().join(format!("riina_req70_jsonbad_{tag}_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create dir");
let stem = format!("ujian_jsonbad_{tag}");
let src: PathBuf = dir.join(format!("{stem}.rii"));
std::fs::write(
&src,
format!("fungsi utama() -> Nombor kesan Tulis {{\n{body}\n 0\n}}\n"),
)
.expect("write program");

let interp = Command::new(env!("CARGO_BIN_EXE_riinac"))
.arg("run")
.arg(&src)
.output()
.expect("riinac run");
assert!(
!interp.status.success(),
"the interpreter ACCEPTED malformed JSON for {tag} — this test's premise \
is that it rejects it: {}",
String::from_utf8_lossy(&interp.stdout)
);
let interp_out = String::from_utf8_lossy(&interp.stdout).into_owned();

let build = Command::new(env!("CARGO_BIN_EXE_riinac"))
.arg("build")
.arg(&src)
.output()
.expect("riinac build");
assert!(
build.status.success(),
"compile failed for {tag}: {}{}",
String::from_utf8_lossy(&build.stdout),
String::from_utf8_lossy(&build.stderr)
);
let run = Command::new(dir.join(&stem)).output().expect("run binary");
let c_out = String::from_utf8_lossy(&run.stdout).into_owned();
assert!(
!run.status.success(),
"THE REGRESSION: the compiled binary accepted malformed JSON for {tag} \
and printed {c_out:?} where `riinac run` refuses. A backend that \
invents a value for input the interpreter rejects is a silent \
correctness hole on attacker-controlled data."
);
assert_eq!(
interp_out, c_out,
"backends printed different output before failing for {tag}"
);

let _ = std::fs::remove_dir_all(&dir);
}

/// Each case is one arm the old lenient emitter took SILENTLY, with the value it
/// invented: `xyz` and `""` became `0` (everything fell through to `strtoll`),
/// `12abc` became `12` (no trailing-content check), `nul` became `()` (the
/// literal arms advanced a fixed width without comparing), and `[1,2` closed
/// itself into `[1,2]`.
#[test]
fn malformed_documents_are_rejected_by_both() {
assert_backends_both_reject("mal_word", " cetakln(ke_teks(json_urai(\"xyz\")));");
assert_backends_both_reject("mal_empty", " cetakln(ke_teks(json_urai(\"\")));");
assert_backends_both_reject("mal_trail", " cetakln(ke_teks(json_urai(\"12abc\")));");
assert_backends_both_reject("mal_nul", " cetakln(ke_teks(json_urai(\"nul\")));");
assert_backends_both_reject("mal_arr", " cetakln(ke_teks(json_urai(\"[1,2\")));");
assert_backends_both_reject(
"mal_obj",
" cetakln(ke_teks(json_urai(\"{\\\"a\\\" 1}\")));",
);
assert_backends_both_reject(
"mal_str",
" cetakln(ke_teks(json_urai(\"\\\"unterminated\")));",
);
}

/// Output printed BEFORE the malformed parse must survive in both backends, so
/// the failure is a stop and not a silent difference in flush behaviour.
#[test]
fn output_before_a_rejected_parse_is_identical() {
assert_backends_both_reject(
"mal_prefix",
" cetakln(\"before\");\n\
\x20 cetakln(ke_teks(json_urai(\"nope\")));\n\
\x20 cetakln(\"after\");",
);
}

/// A NEGATIVE number is the case where the old emitter produced a plausible
/// wrong answer rather than an obvious one: `strtoll` gave -5 and the cast to
/// `uint64_t` made it 18446744073709551611, where the interpreter parses `-5` as
/// `u64` (fails), then as `f64`, then applies Rust's SATURATING `as u64` — 0.
#[test]
fn negative_numbers_saturate_to_zero_in_both() {
assert_backends_agree("neg", " cetakln(ke_teks(json_urai(\"-5\")));");
assert_backends_agree("negfrac", " cetakln(ke_teks(json_urai(\"-0.5\")));");
assert_backends_agree("frac", " cetakln(ke_teks(json_urai(\"3.9\")));");
assert_backends_agree("exp", " cetakln(ke_teks(json_urai(\"1e3\")));");
}

/// Unicode whitespace around a document is accepted by `str::trim`, so the C
/// side may not use the ASCII four. The old `riina_json_skip_ws` did.
#[test]
fn unicode_whitespace_around_a_document_agrees() {
assert_backends_agree(
"ws_nbsp",
" cetakln(json_ke_teks(json_urai(\"\u{00A0}[1,2]\u{2007}\")));",
);
}
Loading
Loading