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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions runtime/ruby/action_controller/authenticity_token.rb
Original file line number Diff line number Diff line change
Expand Up @@ -84,4 +84,9 @@ def self.masked_authenticity_token
def self.csrf_token_valid?(given, expected)
AuthenticityToken.valid?(given, expected)
end

# Real masked tokens are available — turn the shared flag on. Extras
# that omit this file keep the base.rb default (off) so stub-empty
# tokens do not fail-closed every POST.
set_forgery_flag(true)
end
68 changes: 48 additions & 20 deletions runtime/ruby/action_controller/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,14 @@
module ActionController
# One-slot array so class-level CSRF state is a store every target
# can index, not a `self` ivar or `class << self` writer.
FORGERY_SLOT = [true]
#
# Default OFF: extras transpile this file without
# `authenticity_token.rb` (empty `masked_authenticity_token` stub),
# so fail-closed CSRF turns every POST into 422. The ruby-family
# reopen in authenticity_token.rb flips the flag on when the real
# masked-token implementation is present. Tests that need the check
# set `allow_forgery_protection = true` explicitly.
FORGERY_SLOT = [false]

def self.forgery_flag
FORGERY_SLOT[0] == true
Expand Down Expand Up @@ -78,27 +85,41 @@ def self.sanitize_location(path)
s = s.gsub(REDIRECT_LINE_BREAK_PATTERN, REDIRECT_LINE_BREAKS)
end
s = s.tr("\\", "/")
# No `break`: go/typescript emit cannot lower it (MCP wont_lower
# and the TS real-blog gate both flagged this walk).
keep = true
while keep && s.length > 0
c = s[0, 1].to_s
if c == " " || header_control?(c)
s = s[1, s.length].to_s
else
keep = false
# Index-walk strip helpers (one counter while each). Not
# `while keep && …` (Elixir BoolOp) and not two whiles in this
# method (while_to_recursion allows one top-level while per method).
s = strip_leading_controls(s)
s = strip_trailing_controls(s)
s
end

def self.strip_leading_controls(s)
# Index walk — not per-char recursion (stack-safe on long pads).
# Canonical counter `while` so Elixir while_to_recursion applies:
# early return when a kept char is found; trailing `i += 1` step.
n = s.length
i = 0
while i < n
c = s[i, 1].to_s
if !(c == " " || header_control?(c))
return s[i, n - i].to_s
end
i += 1
end
keep = true
while keep && s.length > 0
c = s[s.length - 1, 1].to_s
if c == " " || header_control?(c)
s = s[0, s.length - 1].to_s
else
keep = false
""
end

def self.strip_trailing_controls(s)
n = s.length
i = n
while i > 0
c = s[i - 1, 1].to_s
if !(c == " " || header_control?(c))
return s[0, i].to_s
end
i -= 1
end
s
""
end

# Host of an absolute URL (`http://h/path`), or "" when the value is
Expand Down Expand Up @@ -569,19 +590,26 @@ def send_data(data, type: "application/octet-stream", disposition: "attachment")
# `authenticity_token` or `X-CSRF-Token`. An empty session token
# matches nothing (fail closed). Tests set
# `allow_forgery_protection = false`.
#
# No trailing `nil`: Elixir's mutation-threaded emit would assign
# the `unless`/`render` result to `record` and then discard it,
# tripping `--warnings-as-errors` on an unused variable.
def verify_authenticity_token
unless verified_request?
render "<h1>422 Unprocessable Content</h1>", status: :unprocessable_content
end
nil
end

def verified_request?
return true unless ActionController.forgery_flag
verb = @request_method.to_s
return true if verb == "" || verb == "GET" || verb == "HEAD"
expected = session[:_csrf_token].to_s
return true if ActionController.csrf_token_valid?(params["authenticity_token"].to_s, expected)
# `.fetch(k, "")` — not bare `params[k]`. Crystal Hash#[] raises
# KeyError on a missing key; Python's `.get(k)` returns None and
# `.to_s` then AttributeErrors. Cross-target nil-safe read.
token = params.fetch("authenticity_token", "")
return true if ActionController.csrf_token_valid?(token.to_s, expected)
ActionController.csrf_token_valid?(csrf_header_token, expected)
end

Expand Down
2 changes: 2 additions & 0 deletions runtime/ruby/action_controller/base.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ module ActionController
def self.header_value_ok?: (String? v) -> bool
def self.header_control?: (String c) -> bool
def self.sanitize_location: (String path) -> String
def self.strip_leading_controls: (String s) -> String
def self.strip_trailing_controls: (String s) -> String
def self.location_host: (String url) -> String
def self.find_substr: (String hay, String needle) -> Integer
def self.find_last: (String hay, String needle) -> Integer
Expand Down
10 changes: 9 additions & 1 deletion src/emit/crystal/expr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1517,7 +1517,15 @@ pub(super) fn emit_send_base(
recv_s
};
if args_s.is_empty() {
format!("{recv_s}.{method}")
// Crystal `String#size` / `Array#size` return `Int32`;
// Roundhouse `Ty::Int` is `Int64`. Cast so returns and
// locals typed Int64 (e.g. `find_last`) typecheck —
// `return i` where `i = hay.size - n` was Int32.
if method == "size" {
format!("{recv_s}.{method}.to_i64")
} else {
format!("{recv_s}.{method}")
}
} else if parenthesized {
format!("{recv_s}.{method}({})", args_s.join(", "))
} else {
Expand Down
150 changes: 139 additions & 11 deletions src/emit/csharp/expr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,13 @@ thread_local! {
static DECLARED: RefCell<HashSet<String>> = RefCell::new(HashSet::new());
/// For locals first assigned `nil`, the nullable C# type taken from a
/// later non-nil assignment — so `var x = null` (illegal in C#) becomes
/// `T? x = null`.
/// `T? x = null`. Also filled for the first typed assign with a `?`
/// suffix (nil-first path); hoist must not treat that alone as proof
/// of a nil write — see `SAW_NIL`.
static NIL_TYPES: RefCell<HashMap<String, String>> = RefCell::new(HashMap::new());
/// Locals that are actually assigned a `nil` literal somewhere in the
/// method. Hoisted primitives stay non-nullable unless listed here.
static SAW_NIL: RefCell<HashSet<String>> = RefCell::new(HashSet::new());
/// For locals first assigned an empty `{}`/`[]`, the C# container type
/// inferred from later `map[k]=v` / `list << x` — so the empty literal
/// gets a precise `new List<T>()` instead of `object?`.
Expand Down Expand Up @@ -327,6 +332,24 @@ fn recv_is_hash(r: &Expr) -> bool {
if matches!(instance_prop_ty(name.as_str()), Some(crate::ty::Ty::Hash { .. })))
}

/// Key/value C# types for a Hash-typed receiver. Falls back to
/// `string, object?` when the Hash shape is unknown (Untyped bag).
fn hash_kv_tys(r: &Expr) -> (String, String) {
let hash_ty = match &*r.node {
ExprNode::Ivar { name } => instance_prop_ty(name.as_str()),
_ => None,
};
let hash_ty = hash_ty.as_ref().or(r.ty.as_ref());
let Some(t) = hash_ty else {
return ("string".into(), "object?".into());
};
// Peel `Hash | Nil` so a nullable opts hash still copies as Dictionary.
match t.peel_nilable() {
crate::ty::Ty::Hash { key, value } => (csharp_ty(key), csharp_ty(value)),
_ => ("string".into(), "object?".into()),
}
}

fn recv_is_array(r: &Expr) -> bool {
if ty_is(r.ty.as_ref(), |t| matches!(t, crate::ty::Ty::Array { .. })) {
return true;
Expand All @@ -336,6 +359,51 @@ fn recv_is_array(r: &Expr) -> bool {
if matches!(instance_prop_ty(name.as_str()), Some(crate::ty::Ty::Array { .. })))
}

/// Elem type of an Array-typed receiver, peeling a nullable outer `Array[T]?`.
fn array_elem_ty(r: &Expr) -> Option<crate::ty::Ty> {
// Instance-property types apply only to `@ivar` reads. A local
// `Var` that shadows a same-named property must use `r.ty` so a
// nullable-string array param is not coerced as if it were the
// non-nullable property.
let from_prop = match &*r.node {
ExprNode::Ivar { name } => instance_prop_ty(name.as_str()),
_ => None,
};
let array_ty = from_prop.as_ref().or(r.ty.as_ref());
Comment thread
coderabbitai[bot] marked this conversation as resolved.
match array_ty {
Some(crate::ty::Ty::Array { elem }) => Some((**elem).clone()),
Some(crate::ty::Ty::Union { variants }) => variants.iter().find_map(|t| match t {
crate::ty::Ty::Array { elem } => Some((**elem).clone()),
_ => None,
}),
_ => None,
}
}

/// `string?` into `List<string>` (HeaderStore `@vals << value` after the
/// nil-rejecting header_value_ok? guard). With `<Nullable>enable</Nullable>`,
/// CS8604 fails the write; coalesce to `""`.
fn coerce_list_elem_arg(recv: &Expr, arg: &Expr, arg_s: &str) -> String {
let Some(elem) = array_elem_ty(recv) else {
return arg_s.to_string();
};
let elem_is_plain_str = matches!(elem, crate::ty::Ty::Str | crate::ty::Ty::Sym);
let arg_nilable_str = matches!(
arg.ty.as_ref(),
Some(crate::ty::Ty::Union { variants })
if variants.len() == 2
&& variants.iter().any(|v| matches!(v, crate::ty::Ty::Nil))
&& variants
.iter()
.any(|v| matches!(v, crate::ty::Ty::Str | crate::ty::Ty::Sym))
);
if elem_is_plain_str && arg_nilable_str {
format!("({arg_s} ?? \"\")")
} else {
arg_s.to_string()
}
}

fn is_instance_method_of(class_name: &str, method: &str) -> bool {
let cm = camel(method);
let mut cur = Some(class_name.to_string());
Expand Down Expand Up @@ -507,10 +575,12 @@ pub(super) fn set_returns_unit(b: bool) {
pub(super) fn begin_method(body: &Expr) {
let mut counts: HashMap<String, usize> = HashMap::new();
let mut nil_types: HashMap<String, String> = HashMap::new();
count_assigns(body, &mut counts, &mut nil_types);
let mut saw_nil: HashSet<String> = HashSet::new();
count_assigns(body, &mut counts, &mut nil_types, &mut saw_nil);
DECLARED.with(|d| d.borrow_mut().clear());
LOOP_ID.with(|c| *c.borrow_mut() = 0);
NIL_TYPES.with(|t| *t.borrow_mut() = nil_types);
SAW_NIL.with(|s| *s.borrow_mut() = saw_nil);

let mut container_types: HashMap<String, String> = HashMap::new();
scan_container_types(body, &mut container_types);
Expand All @@ -530,9 +600,20 @@ pub(super) fn begin_method(body: &Expr) {
// Prefer the nullable nil-first type (a `result` assigned
// `null` then `Article` should hoist as `Article?`, not
// `object?`), so the eventual `return result` type-checks.
let ty = NIL_TYPES
.with(|t| t.borrow().get(n).cloned())
.unwrap_or_else(|| ty.clone());
// Do NOT widen primitives merely because count_assigns
// tagged the first typed assign with `?` — that made
// `if (found)` fail CS0266 (`bool?` → `bool`). Keep the
// plain primitive unless the body actually assigns nil.
let ty = match ty.as_str() {
"bool" | "long" | "int" | "double" | "string"
if !SAW_NIL.with(|s| s.borrow().contains(n)) =>
{
ty.clone()
}
_ => NIL_TYPES
.with(|t| t.borrow().get(n).cloned())
.unwrap_or_else(|| ty.clone()),
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
format!("{ty} {n} = {};", cs_default(&ty))
})
.collect();
Expand Down Expand Up @@ -682,13 +763,19 @@ fn count_assigns(
e: &Expr,
counts: &mut HashMap<String, usize>,
nil_types: &mut HashMap<String, String>,
saw_nil: &mut HashSet<String>,
) {
if let ExprNode::OpAssign { target: LValue::Var { name, .. }, .. } = &*e.node {
*counts.entry(camel(name.as_str())).or_insert(0) += 2;
}
if let ExprNode::Assign { target: LValue::Var { name, .. }, value } = &*e.node {
let cn = camel(name.as_str());
*counts.entry(cn.clone()).or_insert(0) += 1;
if matches!(&*value.node, ExprNode::Lit { value: Literal::Nil })
|| matches!(value.ty.as_ref(), Some(crate::ty::Ty::Nil))
{
saw_nil.insert(cn.clone());
}
if !nil_types.contains_key(&cn) {
if let Some(ty) = value.ty.as_ref() {
if !matches!(ty, crate::ty::Ty::Nil) {
Expand All @@ -702,7 +789,7 @@ fn count_assigns(
}
}
for child in children(e) {
count_assigns(child, counts, nil_types);
count_assigns(child, counts, nil_types, saw_nil);
}
}

Expand Down Expand Up @@ -1360,7 +1447,7 @@ fn emit_send(
if let ExprNode::Range { begin, end, exclusive } = &*args[0].node {
return emit_slice_range(&rs, begin.as_ref(), end.as_ref(), *exclusive);
}
if matches!(r.ty.as_ref(), Some(crate::ty::Ty::Array { .. })) {
if recv_is_array(r) {
return format!("{rs}[(int)({})]", args_s[0]);
}
// Ruby `Hash#[]` returns nil for a missing key; C#'s Dictionary
Expand All @@ -1386,7 +1473,8 @@ fn emit_send(
return format!("{} {} {}", emit_expr(r), op, args_s[0]);
}
crate::emit::shared::ops::BinopCase::Append => {
return format!("{}.Add({})", emit_expr(r), args_s[0]);
let arg = coerce_list_elem_arg(r, &args[0], &args_s[0]);
return format!("{}.Add({})", emit_expr(r), arg);
}
crate::emit::shared::ops::BinopCase::NotBinop => {}
}
Expand All @@ -1406,7 +1494,17 @@ fn emit_send(
}
if let (Some(r), 2) = (recv, args.len()) {
if method == "[]=" {
return format!("{}[{}] = {}", emit_expr(r), args_s[0], args_s[1]);
let idx = if list_index_needs_int_cast(r, &args[0]) {
format!("(int)({})", args_s[0])
} else {
args_s[0].clone()
};
let val = if recv_is_hash(r) {
args_s[1].clone()
} else {
coerce_list_elem_arg(r, &args[1], &args_s[1])
};
return format!("{}[{}] = {}", emit_expr(r), idx, val);
}
if method == "fetch" {
return format!("({}.GetValueOrDefault({}, {}))", emit_expr(r), args_s[0], args_s[1]);
Expand Down Expand Up @@ -1465,6 +1563,15 @@ fn emit_send(
}
"keys" if recv_is_hash(r) => return format!("{rs}.Keys.ToList()"),
"values" if recv_is_hash(r) => return format!("{rs}.Values.ToList()"),
// Hash#dup must copy: see kotlin form_with (attrs.delete
// must not mutate the caller's opts). Preserve the
// receiver's Dictionary<K,V> — Dictionary is invariant, so
// `new Dictionary<string, object?>(dict)` does not compile
// when `dict` is `Dictionary<string, string>`.
"dup" if recv_is_hash(r) => {
let (k, v) = hash_kv_tys(r);
return format!("new Dictionary<{k}, {v}>({rs})");
}
"freeze" | "dup" | "to_a" => return rs,
"to_h" if recv_is_hash(r) => return rs,
_ => {}
Expand Down Expand Up @@ -1815,7 +1922,12 @@ fn emit_case_stmt(scrutinee: &Expr, arms: &[Arm]) -> String {
}

fn emit_assign(target: &LValue, value: &Expr) -> String {
let val = emit_expr(value);
let val = match target {
LValue::Index { recv, .. } if !recv_is_hash(recv) => {
coerce_list_elem_arg(recv, value, &emit_expr(value))
}
_ => emit_expr(value),
};
match target {
LValue::Var { name, .. } => {
let n = camel(name.as_str());
Expand Down Expand Up @@ -1878,11 +1990,27 @@ fn lvalue_ref(target: &LValue) -> String {
LValue::Var { name, .. } => camel(name.as_str()),
LValue::Ivar { name } => format!("this.{}", ivar_name(name.as_str())),
LValue::Attr { recv, name } => format!("{}.{}", emit_expr(recv), pascal(name.as_str())),
LValue::Index { recv, index } => format!("{}[{}]", emit_expr(recv), emit_expr(index)),
LValue::Index { recv, index } => {
let idx = if list_index_needs_int_cast(recv, index) {
format!("(int)({})", emit_expr(index))
} else {
emit_expr(index)
};
format!("{}[{}]", emit_expr(recv), idx)
}
LValue::Const { path } => path.iter().map(|s| s.to_string()).collect::<Vec<_>>().join("."),
}
}

/// True when `recv[index]` is a List/Array access (not a Dictionary)
/// that needs a C# `(int)` cast from the IR's `long` index.
fn list_index_needs_int_cast(recv: &Expr, index: &Expr) -> bool {
if recv_is_hash(recv) {
return false;
}
recv_is_array(recv) || matches!(index.ty.as_ref(), Some(crate::ty::Ty::Int))
}

fn emit_op_assign(target: &LValue, op: OpAssignOp, value: &Expr) -> String {
let lhs = lvalue_ref(target);
let v = emit_expr(value);
Expand Down
Loading
Loading