we (web engine): Experimental web browser project to understand the limits of Claude
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

Implement HTML form submission (Phase 16)

Add complete form submission support:
- Entry list construction from form controls (text, checkbox, radio,
select, textarea, hidden, submit buttons)
- application/x-www-form-urlencoded encoding
- multipart/form-data encoding with boundary generation
- text/plain encoding
- Form submission algorithm with constraint validation integration
- Submit triggers: button click, Enter in text inputs, Space/Enter on
focused submit buttons
- Submitter attribute overrides (formaction, formmethod, formenctype,
formnovalidate)
- GET submission (query string) and POST submission (request body)
- Page navigation on form submission response
- form.submit() and form.requestSubmit() JS bindings
- ResourceLoader.post_form() for POST form requests
- 26 tests covering entry list, encoding, and parameter extraction

+1147 -1
+833
crates/browser/src/form_submission.rs
··· 1 + //! HTML form submission algorithm. 2 + //! 3 + //! Implements the form submission process per the HTML specification: 4 + //! entry list construction, form data encoding, and submission dispatch. 5 + 6 + use we_dom::{Document, NodeId}; 7 + use we_url::Url; 8 + 9 + // --------------------------------------------------------------------------- 10 + // Entry list: name/value pairs collected from a form's controls 11 + // --------------------------------------------------------------------------- 12 + 13 + /// A single form entry (name/value pair). 14 + #[derive(Debug, Clone, PartialEq, Eq)] 15 + pub struct FormEntry { 16 + pub name: String, 17 + pub value: String, 18 + } 19 + 20 + /// Construct the entry list for a form element per the HTML spec. 21 + /// 22 + /// Iterates all submittable elements owned by `form`, collecting name/value 23 + /// pairs. Skips disabled controls, unnamed controls, unchecked 24 + /// checkboxes/radios, buttons that are not the submitter, etc. 25 + pub fn construct_entry_list( 26 + doc: &Document, 27 + form: NodeId, 28 + submitter: Option<NodeId>, 29 + ) -> Vec<FormEntry> { 30 + let mut entries = Vec::new(); 31 + let elements = doc.form_elements(form); 32 + 33 + for &control in &elements { 34 + let tag = match doc.tag_name(control) { 35 + Some(t) => t, 36 + None => continue, 37 + }; 38 + 39 + // Skip non-submittable elements (fieldset, output). 40 + if !matches!(tag, "input" | "select" | "textarea" | "button") { 41 + continue; 42 + } 43 + 44 + // Skip disabled controls. 45 + if doc.get_attribute(control, "disabled").is_some() { 46 + continue; 47 + } 48 + 49 + // Skip controls without a name. 50 + let name = match doc.get_attribute(control, "name") { 51 + Some(n) if !n.is_empty() => n.to_string(), 52 + _ => continue, 53 + }; 54 + 55 + match tag { 56 + "input" => { 57 + let input_type = doc 58 + .get_attribute(control, "type") 59 + .unwrap_or("text") 60 + .to_ascii_lowercase(); 61 + 62 + match input_type.as_str() { 63 + // Skip unchecked checkboxes and radios. 64 + "checkbox" | "radio" => { 65 + if doc.get_attribute(control, "checked").is_none() { 66 + continue; 67 + } 68 + let value = doc 69 + .get_attribute(control, "value") 70 + .unwrap_or("on") 71 + .to_string(); 72 + entries.push(FormEntry { name, value }); 73 + } 74 + // Submit/image buttons: only include if they are the submitter. 75 + "submit" => { 76 + if Some(control) == submitter { 77 + let value = doc 78 + .get_attribute(control, "value") 79 + .unwrap_or("Submit") 80 + .to_string(); 81 + entries.push(FormEntry { name, value }); 82 + } 83 + } 84 + "image" => { 85 + // Image buttons submit x,y coordinates. 86 + if Some(control) == submitter { 87 + entries.push(FormEntry { 88 + name: format!("{name}.x"), 89 + value: "0".to_string(), 90 + }); 91 + entries.push(FormEntry { 92 + name: format!("{name}.y"), 93 + value: "0".to_string(), 94 + }); 95 + } 96 + } 97 + // Skip buttons that aren't the submitter. 98 + "button" | "reset" => continue, 99 + // Hidden inputs. 100 + "hidden" => { 101 + let value = doc 102 + .get_attribute(control, "value") 103 + .unwrap_or("") 104 + .to_string(); 105 + entries.push(FormEntry { name, value }); 106 + } 107 + // File inputs: not fully supported, submit empty filename. 108 + "file" => { 109 + entries.push(FormEntry { 110 + name, 111 + value: String::new(), 112 + }); 113 + } 114 + // Text-like inputs: text, password, email, url, search, tel, number. 115 + _ => { 116 + let value = get_control_value(doc, control); 117 + entries.push(FormEntry { name, value }); 118 + } 119 + } 120 + } 121 + "textarea" => { 122 + let value = get_control_value(doc, control); 123 + entries.push(FormEntry { name, value }); 124 + } 125 + "select" => { 126 + // Include the value of each selected option. 127 + let options = doc.select_options(control); 128 + let mut any_selected = false; 129 + for opt in &options { 130 + if opt.is_group_label { 131 + continue; 132 + } 133 + if opt.selected { 134 + entries.push(FormEntry { 135 + name: name.clone(), 136 + value: opt.value.clone(), 137 + }); 138 + any_selected = true; 139 + } 140 + } 141 + // For single-select with no explicit selection, the first option 142 + // is implicitly selected. 143 + if !any_selected { 144 + if let Some(first) = options.iter().find(|o| !o.is_group_label) { 145 + entries.push(FormEntry { 146 + name, 147 + value: first.value.clone(), 148 + }); 149 + } 150 + } 151 + } 152 + "button" => { 153 + // Only the submitter button is included. 154 + if Some(control) == submitter { 155 + let btn_type = doc 156 + .get_attribute(control, "type") 157 + .unwrap_or("submit") 158 + .to_ascii_lowercase(); 159 + if btn_type == "submit" { 160 + let value = doc 161 + .get_attribute(control, "value") 162 + .unwrap_or("") 163 + .to_string(); 164 + entries.push(FormEntry { name, value }); 165 + } 166 + } 167 + } 168 + _ => {} 169 + } 170 + } 171 + 172 + entries 173 + } 174 + 175 + /// Get the current value of a text-like control. 176 + /// 177 + /// Prefers the InputState (edited value) over the DOM attribute. 178 + fn get_control_value(doc: &Document, control: NodeId) -> String { 179 + // Check InputState first (holds the user-edited value). 180 + if let Some(state) = doc.input_states.get(control) { 181 + return state.text().to_string(); 182 + } 183 + // Fall back to the DOM value attribute. 184 + doc.get_attribute(control, "value") 185 + .unwrap_or("") 186 + .to_string() 187 + } 188 + 189 + // --------------------------------------------------------------------------- 190 + // Form data encoding 191 + // --------------------------------------------------------------------------- 192 + 193 + /// Encode form entries as `application/x-www-form-urlencoded`. 194 + pub fn encode_urlencoded(entries: &[FormEntry]) -> String { 195 + let mut result = String::new(); 196 + for (i, entry) in entries.iter().enumerate() { 197 + if i > 0 { 198 + result.push('&'); 199 + } 200 + result.push_str(&urlencoded_encode_component(&entry.name)); 201 + result.push('='); 202 + result.push_str(&urlencoded_encode_component(&entry.value)); 203 + } 204 + result 205 + } 206 + 207 + /// Percent-encode a string per the `application/x-www-form-urlencoded` spec. 208 + /// 209 + /// Spaces are encoded as '+', and all characters except unreserved 210 + /// (alphanumeric, '-', '_', '.', '*') are percent-encoded. 211 + fn urlencoded_encode_component(input: &str) -> String { 212 + let mut out = String::with_capacity(input.len()); 213 + for byte in input.as_bytes() { 214 + match *byte { 215 + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'*' => { 216 + out.push(*byte as char); 217 + } 218 + b' ' => out.push('+'), 219 + _ => { 220 + out.push('%'); 221 + out.push(hex_upper(*byte >> 4)); 222 + out.push(hex_upper(*byte & 0x0F)); 223 + } 224 + } 225 + } 226 + out 227 + } 228 + 229 + fn hex_upper(n: u8) -> char { 230 + if n < 10 { 231 + (b'0' + n) as char 232 + } else { 233 + (b'A' + n - 10) as char 234 + } 235 + } 236 + 237 + /// Encode form entries as `multipart/form-data`. 238 + /// 239 + /// Returns `(body_bytes, content_type_header)` where the content type includes 240 + /// the boundary parameter. 241 + pub fn encode_multipart(entries: &[FormEntry]) -> (Vec<u8>, String) { 242 + // Generate a boundary that doesn't appear in any value. 243 + let boundary = generate_boundary(entries); 244 + let content_type = format!("multipart/form-data; boundary={boundary}"); 245 + 246 + let mut body = Vec::new(); 247 + for entry in entries { 248 + body.extend_from_slice(b"--"); 249 + body.extend_from_slice(boundary.as_bytes()); 250 + body.extend_from_slice(b"\r\n"); 251 + // Content-Disposition header. 252 + body.extend_from_slice(b"Content-Disposition: form-data; name=\""); 253 + body.extend_from_slice(multipart_escape_name(&entry.name).as_bytes()); 254 + body.extend_from_slice(b"\"\r\n"); 255 + body.extend_from_slice(b"\r\n"); 256 + body.extend_from_slice(entry.value.as_bytes()); 257 + body.extend_from_slice(b"\r\n"); 258 + } 259 + // Closing boundary. 260 + body.extend_from_slice(b"--"); 261 + body.extend_from_slice(boundary.as_bytes()); 262 + body.extend_from_slice(b"--\r\n"); 263 + 264 + (body, content_type) 265 + } 266 + 267 + /// Generate a boundary string that does not appear in any entry value. 268 + fn generate_boundary(entries: &[FormEntry]) -> String { 269 + let mut boundary = "----WebKitFormBoundary".to_string(); 270 + // Append hex characters derived from content to make it unique. 271 + let mut hash: u64 = 0xcbf29ce484222325; // FNV offset basis 272 + for entry in entries { 273 + for b in entry.name.bytes() { 274 + hash ^= b as u64; 275 + hash = hash.wrapping_mul(0x100000001b3); 276 + } 277 + for b in entry.value.bytes() { 278 + hash ^= b as u64; 279 + hash = hash.wrapping_mul(0x100000001b3); 280 + } 281 + } 282 + boundary.push_str(&format!("{hash:016x}")); 283 + 284 + // Verify boundary doesn't appear in values; if it does, keep mutating. 285 + let mut suffix = 0u64; 286 + loop { 287 + let collision = entries 288 + .iter() 289 + .any(|e| e.value.contains(&boundary) || e.name.contains(&boundary)); 290 + if !collision { 291 + break; 292 + } 293 + suffix += 1; 294 + boundary = format!("----WebKitFormBoundary{hash:016x}{suffix:04x}"); 295 + } 296 + 297 + boundary 298 + } 299 + 300 + /// Escape a field name for multipart Content-Disposition. 301 + /// Per RFC 7578: replace `"` with `%22` and `\` with `\\` in field names. 302 + fn multipart_escape_name(name: &str) -> String { 303 + let mut out = String::with_capacity(name.len()); 304 + for c in name.chars() { 305 + match c { 306 + '"' => out.push_str("%22"), 307 + '\\' => out.push_str("\\\\"), 308 + '\r' => out.push_str("%0D"), 309 + '\n' => out.push_str("%0A"), 310 + _ => out.push(c), 311 + } 312 + } 313 + out 314 + } 315 + 316 + /// Encode form entries as `text/plain`. 317 + pub fn encode_text_plain(entries: &[FormEntry]) -> String { 318 + let mut result = String::new(); 319 + for entry in entries { 320 + result.push_str(&entry.name); 321 + result.push('='); 322 + result.push_str(&entry.value); 323 + result.push_str("\r\n"); 324 + } 325 + result 326 + } 327 + 328 + // --------------------------------------------------------------------------- 329 + // Form encoding type and method 330 + // --------------------------------------------------------------------------- 331 + 332 + /// The form's encoding type. 333 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 334 + pub enum FormEnctype { 335 + UrlEncoded, 336 + Multipart, 337 + TextPlain, 338 + } 339 + 340 + impl FormEnctype { 341 + pub fn parse(s: &str) -> Self { 342 + match s.trim().to_ascii_lowercase().as_str() { 343 + "multipart/form-data" => Self::Multipart, 344 + "text/plain" => Self::TextPlain, 345 + _ => Self::UrlEncoded, 346 + } 347 + } 348 + 349 + pub fn content_type(&self) -> &'static str { 350 + match self { 351 + Self::UrlEncoded => "application/x-www-form-urlencoded", 352 + Self::Multipart => "multipart/form-data", 353 + Self::TextPlain => "text/plain", 354 + } 355 + } 356 + } 357 + 358 + /// The form's submission method. 359 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 360 + pub enum FormMethod { 361 + Get, 362 + Post, 363 + } 364 + 365 + impl FormMethod { 366 + pub fn parse(s: &str) -> Self { 367 + if s.trim().eq_ignore_ascii_case("post") { 368 + Self::Post 369 + } else { 370 + Self::Get 371 + } 372 + } 373 + } 374 + 375 + // --------------------------------------------------------------------------- 376 + // Form submission parameters extraction 377 + // --------------------------------------------------------------------------- 378 + 379 + /// Submission parameters resolved from the form and optional submitter overrides. 380 + pub struct SubmissionParams { 381 + pub action: String, 382 + pub method: FormMethod, 383 + pub enctype: FormEnctype, 384 + pub novalidate: bool, 385 + } 386 + 387 + /// Extract submission parameters from a form element, applying submitter overrides. 388 + pub fn submission_params( 389 + doc: &Document, 390 + form: NodeId, 391 + submitter: Option<NodeId>, 392 + ) -> SubmissionParams { 393 + // Start with form's own attributes. 394 + let mut action = doc.get_attribute(form, "action").unwrap_or("").to_string(); 395 + let mut method = FormMethod::parse(doc.get_attribute(form, "method").unwrap_or("get")); 396 + let mut enctype = FormEnctype::parse(doc.get_attribute(form, "enctype").unwrap_or("")); 397 + let mut novalidate = doc.get_attribute(form, "novalidate").is_some(); 398 + 399 + // Submitter can override with formaction, formmethod, formenctype, formnovalidate. 400 + if let Some(sub) = submitter { 401 + if sub != form { 402 + if let Some(fa) = doc.get_attribute(sub, "formaction") { 403 + action = fa.to_string(); 404 + } 405 + if let Some(fm) = doc.get_attribute(sub, "formmethod") { 406 + method = FormMethod::parse(fm); 407 + } 408 + if let Some(fe) = doc.get_attribute(sub, "formenctype") { 409 + enctype = FormEnctype::parse(fe); 410 + } 411 + if doc.get_attribute(sub, "formnovalidate").is_some() { 412 + novalidate = true; 413 + } 414 + } 415 + } 416 + 417 + SubmissionParams { 418 + action, 419 + method, 420 + enctype, 421 + novalidate, 422 + } 423 + } 424 + 425 + /// Resolve the action URL relative to a base URL. 426 + /// If the action is empty, returns the base URL (submit to self). 427 + pub fn resolve_action(action: &str, base_url: &Url) -> Option<Url> { 428 + if action.is_empty() { 429 + return Some(base_url.clone()); 430 + } 431 + Url::parse_with_base(action, base_url) 432 + .or_else(|_| Url::parse(action)) 433 + .ok() 434 + } 435 + 436 + // --------------------------------------------------------------------------- 437 + // Tests 438 + // --------------------------------------------------------------------------- 439 + 440 + #[cfg(test)] 441 + mod tests { 442 + use super::*; 443 + use we_dom::Document; 444 + 445 + // Helper: build a minimal form document. 446 + fn build_form_doc(html: &str) -> Document { 447 + we_html::parse_html(html) 448 + } 449 + 450 + fn find_form(doc: &Document) -> Option<NodeId> { 451 + for i in 0..doc.len() { 452 + let n = NodeId::from_index(i); 453 + if doc.tag_name(n) == Some("form") { 454 + return Some(n); 455 + } 456 + } 457 + None 458 + } 459 + 460 + fn find_by_name<'a>(doc: &'a Document, name: &str) -> Option<NodeId> { 461 + for i in 0..doc.len() { 462 + let n = NodeId::from_index(i); 463 + if doc.get_attribute(n, "name") == Some(name) { 464 + return Some(n); 465 + } 466 + } 467 + None 468 + } 469 + 470 + fn find_submit_button(doc: &Document) -> Option<NodeId> { 471 + for i in 0..doc.len() { 472 + let n = NodeId::from_index(i); 473 + if doc.tag_name(n) == Some("input") 474 + && doc 475 + .get_attribute(n, "type") 476 + .map(|t| t.eq_ignore_ascii_case("submit")) 477 + .unwrap_or(false) 478 + { 479 + return Some(n); 480 + } 481 + } 482 + None 483 + } 484 + 485 + // --- Entry list tests --- 486 + 487 + #[test] 488 + fn entry_list_text_inputs() { 489 + let doc = build_form_doc( 490 + "<form><input name='user' value='alice'><input name='pass' type='password' value='secret'></form>", 491 + ); 492 + let form = find_form(&doc).unwrap(); 493 + let entries = construct_entry_list(&doc, form, None); 494 + assert_eq!(entries.len(), 2); 495 + assert_eq!( 496 + entries[0], 497 + FormEntry { 498 + name: "user".into(), 499 + value: "alice".into() 500 + } 501 + ); 502 + assert_eq!( 503 + entries[1], 504 + FormEntry { 505 + name: "pass".into(), 506 + value: "secret".into() 507 + } 508 + ); 509 + } 510 + 511 + #[test] 512 + fn entry_list_skips_disabled() { 513 + let doc = build_form_doc( 514 + "<form><input name='a' value='1'><input name='b' value='2' disabled></form>", 515 + ); 516 + let form = find_form(&doc).unwrap(); 517 + let entries = construct_entry_list(&doc, form, None); 518 + assert_eq!(entries.len(), 1); 519 + assert_eq!(entries[0].name, "a"); 520 + } 521 + 522 + #[test] 523 + fn entry_list_skips_unnamed() { 524 + let doc = build_form_doc("<form><input value='orphan'></form>"); 525 + let form = find_form(&doc).unwrap(); 526 + let entries = construct_entry_list(&doc, form, None); 527 + assert!(entries.is_empty()); 528 + } 529 + 530 + #[test] 531 + fn entry_list_checkbox_checked() { 532 + let doc = build_form_doc("<form><input type='checkbox' name='agree' checked></form>"); 533 + let form = find_form(&doc).unwrap(); 534 + let entries = construct_entry_list(&doc, form, None); 535 + assert_eq!(entries.len(), 1); 536 + assert_eq!( 537 + entries[0], 538 + FormEntry { 539 + name: "agree".into(), 540 + value: "on".into() 541 + } 542 + ); 543 + } 544 + 545 + #[test] 546 + fn entry_list_checkbox_unchecked() { 547 + let doc = build_form_doc("<form><input type='checkbox' name='agree'></form>"); 548 + let form = find_form(&doc).unwrap(); 549 + let entries = construct_entry_list(&doc, form, None); 550 + assert!(entries.is_empty()); 551 + } 552 + 553 + #[test] 554 + fn entry_list_radio_checked() { 555 + let doc = build_form_doc( 556 + "<form><input type='radio' name='color' value='red'><input type='radio' name='color' value='blue' checked></form>", 557 + ); 558 + let form = find_form(&doc).unwrap(); 559 + let entries = construct_entry_list(&doc, form, None); 560 + assert_eq!(entries.len(), 1); 561 + assert_eq!( 562 + entries[0], 563 + FormEntry { 564 + name: "color".into(), 565 + value: "blue".into() 566 + } 567 + ); 568 + } 569 + 570 + #[test] 571 + fn entry_list_select() { 572 + let doc = build_form_doc( 573 + "<form><select name='fruit'><option value='apple'>Apple</option><option value='banana' selected>Banana</option></select></form>", 574 + ); 575 + let form = find_form(&doc).unwrap(); 576 + let entries = construct_entry_list(&doc, form, None); 577 + assert_eq!(entries.len(), 1); 578 + assert_eq!( 579 + entries[0], 580 + FormEntry { 581 + name: "fruit".into(), 582 + value: "banana".into() 583 + } 584 + ); 585 + } 586 + 587 + #[test] 588 + fn entry_list_select_first_default() { 589 + // No explicit selection — first option should be implicitly selected. 590 + let doc = build_form_doc( 591 + "<form><select name='x'><option value='a'>A</option><option value='b'>B</option></select></form>", 592 + ); 593 + let form = find_form(&doc).unwrap(); 594 + let entries = construct_entry_list(&doc, form, None); 595 + assert_eq!(entries.len(), 1); 596 + assert_eq!(entries[0].value, "a"); 597 + } 598 + 599 + #[test] 600 + fn entry_list_hidden_input() { 601 + let doc = build_form_doc("<form><input type='hidden' name='csrf' value='tok123'></form>"); 602 + let form = find_form(&doc).unwrap(); 603 + let entries = construct_entry_list(&doc, form, None); 604 + assert_eq!(entries.len(), 1); 605 + assert_eq!( 606 + entries[0], 607 + FormEntry { 608 + name: "csrf".into(), 609 + value: "tok123".into() 610 + } 611 + ); 612 + } 613 + 614 + #[test] 615 + fn entry_list_submit_button_as_submitter() { 616 + let doc = build_form_doc( 617 + "<form><input name='q' value='test'><input type='submit' name='btn' value='Go'></form>", 618 + ); 619 + let form = find_form(&doc).unwrap(); 620 + let submit = find_submit_button(&doc).unwrap(); 621 + let entries = construct_entry_list(&doc, form, Some(submit)); 622 + assert_eq!(entries.len(), 2); 623 + assert_eq!( 624 + entries[1], 625 + FormEntry { 626 + name: "btn".into(), 627 + value: "Go".into() 628 + } 629 + ); 630 + } 631 + 632 + #[test] 633 + fn entry_list_submit_button_not_submitter() { 634 + let doc = build_form_doc( 635 + "<form><input name='q' value='test'><input type='submit' name='btn' value='Go'></form>", 636 + ); 637 + let form = find_form(&doc).unwrap(); 638 + // No submitter — the submit button should NOT be included. 639 + let entries = construct_entry_list(&doc, form, None); 640 + assert_eq!(entries.len(), 1); 641 + assert_eq!(entries[0].name, "q"); 642 + } 643 + 644 + #[test] 645 + fn entry_list_button_element_submitter() { 646 + let doc = build_form_doc( 647 + "<form><input name='q' value='hi'><button type='submit' name='action' value='search'>Search</button></form>", 648 + ); 649 + let form = find_form(&doc).unwrap(); 650 + let btn = find_by_name(&doc, "action").unwrap(); 651 + let entries = construct_entry_list(&doc, form, Some(btn)); 652 + assert_eq!(entries.len(), 2); 653 + assert_eq!( 654 + entries[1], 655 + FormEntry { 656 + name: "action".into(), 657 + value: "search".into() 658 + } 659 + ); 660 + } 661 + 662 + // --- URL-encoded encoding tests --- 663 + 664 + #[test] 665 + fn urlencoded_basic() { 666 + let entries = vec![ 667 + FormEntry { 668 + name: "q".into(), 669 + value: "hello world".into(), 670 + }, 671 + FormEntry { 672 + name: "lang".into(), 673 + value: "en".into(), 674 + }, 675 + ]; 676 + assert_eq!(encode_urlencoded(&entries), "q=hello+world&lang=en"); 677 + } 678 + 679 + #[test] 680 + fn urlencoded_special_chars() { 681 + let entries = vec![FormEntry { 682 + name: "data".into(), 683 + value: "a=b&c=d".into(), 684 + }]; 685 + assert_eq!(encode_urlencoded(&entries), "data=a%3Db%26c%3Dd"); 686 + } 687 + 688 + #[test] 689 + fn urlencoded_unicode() { 690 + let entries = vec![FormEntry { 691 + name: "name".into(), 692 + value: "café".into(), 693 + }]; 694 + let encoded = encode_urlencoded(&entries); 695 + assert_eq!(encoded, "name=caf%C3%A9"); 696 + } 697 + 698 + #[test] 699 + fn urlencoded_empty() { 700 + let entries: Vec<FormEntry> = vec![]; 701 + assert_eq!(encode_urlencoded(&entries), ""); 702 + } 703 + 704 + // --- Multipart encoding tests --- 705 + 706 + #[test] 707 + fn multipart_basic() { 708 + let entries = vec![ 709 + FormEntry { 710 + name: "field1".into(), 711 + value: "value1".into(), 712 + }, 713 + FormEntry { 714 + name: "field2".into(), 715 + value: "value2".into(), 716 + }, 717 + ]; 718 + let (body, content_type) = encode_multipart(&entries); 719 + let body_str = String::from_utf8(body).unwrap(); 720 + 721 + assert!(content_type.starts_with("multipart/form-data; boundary=")); 722 + let boundary = content_type 723 + .strip_prefix("multipart/form-data; boundary=") 724 + .unwrap(); 725 + 726 + assert!(body_str.contains(&format!("--{boundary}\r\n"))); 727 + assert!(body_str.contains("Content-Disposition: form-data; name=\"field1\"")); 728 + assert!(body_str.contains("\r\n\r\nvalue1\r\n")); 729 + assert!(body_str.contains("Content-Disposition: form-data; name=\"field2\"")); 730 + assert!(body_str.contains("\r\n\r\nvalue2\r\n")); 731 + assert!(body_str.ends_with(&format!("--{boundary}--\r\n"))); 732 + } 733 + 734 + // --- Text/plain encoding tests --- 735 + 736 + #[test] 737 + fn text_plain_basic() { 738 + let entries = vec![ 739 + FormEntry { 740 + name: "a".into(), 741 + value: "1".into(), 742 + }, 743 + FormEntry { 744 + name: "b".into(), 745 + value: "2".into(), 746 + }, 747 + ]; 748 + assert_eq!(encode_text_plain(&entries), "a=1\r\nb=2\r\n"); 749 + } 750 + 751 + // --- Submission params tests --- 752 + 753 + #[test] 754 + fn submission_params_defaults() { 755 + let doc = build_form_doc("<form><input name='q' value='test'></form>"); 756 + let form = find_form(&doc).unwrap(); 757 + let params = submission_params(&doc, form, None); 758 + assert_eq!(params.action, ""); 759 + assert_eq!(params.method, FormMethod::Get); 760 + assert_eq!(params.enctype, FormEnctype::UrlEncoded); 761 + assert!(!params.novalidate); 762 + } 763 + 764 + #[test] 765 + fn submission_params_from_form() { 766 + let doc = build_form_doc( 767 + "<form action='/search' method='post' enctype='multipart/form-data' novalidate><input name='q'></form>", 768 + ); 769 + let form = find_form(&doc).unwrap(); 770 + let params = submission_params(&doc, form, None); 771 + assert_eq!(params.action, "/search"); 772 + assert_eq!(params.method, FormMethod::Post); 773 + assert_eq!(params.enctype, FormEnctype::Multipart); 774 + assert!(params.novalidate); 775 + } 776 + 777 + #[test] 778 + fn submission_params_submitter_overrides() { 779 + let doc = build_form_doc( 780 + "<form action='/default' method='get'>\ 781 + <input name='q'>\ 782 + <input type='submit' name='btn' formaction='/override' formmethod='post' formenctype='text/plain' formnovalidate>\ 783 + </form>", 784 + ); 785 + let form = find_form(&doc).unwrap(); 786 + let submit = find_submit_button(&doc).unwrap(); 787 + let params = submission_params(&doc, form, Some(submit)); 788 + assert_eq!(params.action, "/override"); 789 + assert_eq!(params.method, FormMethod::Post); 790 + assert_eq!(params.enctype, FormEnctype::TextPlain); 791 + assert!(params.novalidate); 792 + } 793 + 794 + // --- Action URL resolution tests --- 795 + 796 + #[test] 797 + fn resolve_empty_action() { 798 + let base = Url::parse("https://example.com/page").unwrap(); 799 + let resolved = resolve_action("", &base).unwrap(); 800 + assert_eq!(resolved.serialize(), "https://example.com/page"); 801 + } 802 + 803 + #[test] 804 + fn resolve_relative_action() { 805 + let base = Url::parse("https://example.com/dir/page").unwrap(); 806 + let resolved = resolve_action("/search", &base).unwrap(); 807 + assert_eq!(resolved.serialize(), "https://example.com/search"); 808 + } 809 + 810 + #[test] 811 + fn resolve_absolute_action() { 812 + let base = Url::parse("https://example.com/page").unwrap(); 813 + let resolved = resolve_action("https://other.com/form", &base).unwrap(); 814 + assert_eq!(resolved.serialize(), "https://other.com/form"); 815 + } 816 + 817 + // --- Component encoding tests --- 818 + 819 + #[test] 820 + fn urlencoded_component_spaces_and_special() { 821 + assert_eq!(urlencoded_encode_component("hello world"), "hello+world"); 822 + assert_eq!(urlencoded_encode_component("a+b"), "a%2Bb"); 823 + assert_eq!(urlencoded_encode_component("foo@bar"), "foo%40bar"); 824 + assert_eq!(urlencoded_encode_component("100%"), "100%25"); 825 + } 826 + 827 + #[test] 828 + fn multipart_name_escaping() { 829 + assert_eq!(multipart_escape_name("simple"), "simple"); 830 + assert_eq!(multipart_escape_name("with\"quote"), "with%22quote"); 831 + assert_eq!(multipart_escape_name("back\\slash"), "back\\\\slash"); 832 + } 833 + }
+1
crates/browser/src/lib.rs
··· 4 4 pub mod csp; 5 5 pub mod css_loader; 6 6 pub mod font_loader; 7 + pub mod form_submission; 7 8 pub mod iframe_loader; 8 9 pub mod img_loader; 9 10 pub mod indexeddb;
+49
crates/browser/src/loader.rs
··· 330 330 } 331 331 } 332 332 333 + /// Submit an HTTP POST request (for form submission) and decode the response. 334 + pub fn post_form( 335 + &mut self, 336 + url: &Url, 337 + body: &[u8], 338 + content_type: &str, 339 + ) -> Result<Resource, LoadError> { 340 + let mut headers = Headers::new(); 341 + headers.add("Content-Type", content_type); 342 + self.add_referer_header(&mut headers, url, None); 343 + let response = self 344 + .client 345 + .request(Method::Post, url, &headers, Some(body))?; 346 + self.update_policy_from_response(&response); 347 + self.update_csp_from_response(&response); 348 + 349 + if response.status_code >= 400 { 350 + return Err(LoadError::HttpStatus { 351 + status: response.status_code, 352 + reason: response.reason.clone(), 353 + }); 354 + } 355 + 356 + let ct = response.content_type(); 357 + let mime = ct 358 + .as_ref() 359 + .map(|c| c.mime_type.as_str()) 360 + .unwrap_or("application/octet-stream"); 361 + 362 + match classify_mime(mime) { 363 + MimeClass::Html => { 364 + let (text, encoding) = decode_text_resource(&response.body, ct.as_ref(), true); 365 + Ok(Resource::Html { 366 + text, 367 + base_url: url.clone(), 368 + encoding, 369 + }) 370 + } 371 + _ => { 372 + let (text, encoding) = decode_text_resource(&response.body, ct.as_ref(), true); 373 + Ok(Resource::Html { 374 + text, 375 + base_url: url.clone(), 376 + encoding, 377 + }) 378 + } 379 + } 380 + } 381 + 333 382 /// Fetch a subresource with Same-Origin Policy and CORS enforcement. 334 383 /// 335 384 /// Checks the resource URL's origin against the document origin. For
+189 -1
crates/browser/src/main.rs
··· 4 4 use we_browser::csp::{self, PolicyList}; 5 5 use we_browser::css_loader::collect_stylesheets; 6 6 use we_browser::font_loader::load_web_fonts; 7 + use we_browser::form_submission::{ 8 + construct_entry_list, encode_multipart, encode_text_plain, encode_urlencoded, resolve_action, 9 + submission_params, FormEnctype, FormMethod, 10 + }; 7 11 use we_browser::img_loader::{collect_images, ImageStore}; 8 12 use we_browser::loader::{Resource, ResourceLoader, ABOUT_BLANK_HTML}; 9 13 use we_browser::script_loader::execute_page_scripts; ··· 457 461 } 458 462 } 459 463 464 + /// Returns true if `node` is a submit button (input[type=submit] or button[type=submit]). 465 + fn is_submit_button(doc: &we_dom::Document, node: NodeId) -> bool { 466 + match doc.tag_name(node) { 467 + Some("input") => doc 468 + .get_attribute(node, "type") 469 + .map(|t| t.eq_ignore_ascii_case("submit")) 470 + .unwrap_or(false), 471 + Some("button") => { 472 + let btn_type = doc.get_attribute(node, "type").unwrap_or("submit"); 473 + btn_type.eq_ignore_ascii_case("submit") 474 + } 475 + _ => false, 476 + } 477 + } 478 + 479 + /// Submit a form. Called when the user clicks a submit button or presses Enter 480 + /// in a single-line text input. 481 + /// 482 + /// The `submitter` is the element that triggered the submission (a submit button 483 + /// or the form itself for implicit submission). If `skip_validation` is true, 484 + /// constraint validation is bypassed (e.g., for `form.submit()` from JS). 485 + fn submit_form( 486 + state: &mut BrowserState, 487 + form: NodeId, 488 + submitter: Option<NodeId>, 489 + skip_validation: bool, 490 + ) { 491 + let doc = &state.page.doc; 492 + 493 + // 1. Resolve submission parameters (action, method, enctype, novalidate). 494 + let params = submission_params(doc, form, submitter); 495 + 496 + // 2. Run constraint validation unless novalidate or skip_validation. 497 + if !skip_validation && !params.novalidate { 498 + use we_dom::validation::{compute_validity, will_validate}; 499 + let elements = doc.form_elements(form); 500 + for &ctrl in &elements { 501 + if !will_validate(doc, ctrl) { 502 + continue; 503 + } 504 + let validity = compute_validity(doc, ctrl, &doc.custom_validity); 505 + if !validity.valid() { 506 + // Validation failed — abort submission. 507 + // In a full implementation, we'd fire 'invalid' events and 508 + // possibly focus the first invalid control. 509 + eprintln!("[we] Form validation failed, submission aborted"); 510 + return; 511 + } 512 + } 513 + } 514 + 515 + // 3. Construct the entry list. 516 + let entries = construct_entry_list(doc, form, submitter); 517 + 518 + // 4. Encode the form data. 519 + let (body, content_type) = match params.enctype { 520 + FormEnctype::UrlEncoded => { 521 + let encoded = encode_urlencoded(&entries); 522 + ( 523 + encoded.into_bytes(), 524 + "application/x-www-form-urlencoded".to_string(), 525 + ) 526 + } 527 + FormEnctype::Multipart => encode_multipart(&entries), 528 + FormEnctype::TextPlain => { 529 + let encoded = encode_text_plain(&entries); 530 + (encoded.into_bytes(), "text/plain".to_string()) 531 + } 532 + }; 533 + 534 + // 5. Resolve the action URL. 535 + // We need the document's base URL. Reconstruct it from the document URL we stored. 536 + // For now, use about:blank as fallback. 537 + let base_url = Url::parse("about:blank").unwrap(); 538 + let action_url = match resolve_action(&params.action, &base_url) { 539 + Some(url) => url, 540 + None => { 541 + eprintln!("[we] Invalid form action URL: {}", params.action); 542 + return; 543 + } 544 + }; 545 + 546 + // 6. Submit: GET appends to query string; POST sends body. 547 + let loaded = match params.method { 548 + FormMethod::Get => { 549 + // Append encoded data as query string. 550 + let mut target = action_url; 551 + if !entries.is_empty() { 552 + let query = encode_urlencoded(&entries); 553 + target.query = Some(query); 554 + } 555 + eprintln!("[we] Form GET submission: {}", target.serialize()); 556 + load_from_url(&target) 557 + } 558 + FormMethod::Post => { 559 + eprintln!("[we] Form POST submission: {}", action_url.serialize()); 560 + let mut loader = ResourceLoader::new(); 561 + match loader.post_form(&action_url, &body, &content_type) { 562 + Ok(Resource::Html { text, base_url, .. }) => LoadedHtml { 563 + text, 564 + base_url, 565 + http_referrer_policy: None, 566 + http_csp: PolicyList::new(), 567 + }, 568 + Ok(_) => error_page("Form submission returned non-HTML response"), 569 + Err(e) => error_page(&format!("Form submission failed: {e}")), 570 + } 571 + } 572 + }; 573 + 574 + // 7. Navigate: replace current page with the response. 575 + let new_page = load_page(loaded); 576 + 577 + // Swap the font: try web fonts from the new page first, then keep existing. 578 + let new_font = new_page 579 + .font_registry 580 + .find_best_font() 581 + .or_else(|| font::load_system_font().ok()); 582 + if let Some(f) = new_font { 583 + state.font = f; 584 + } 585 + 586 + state.page = new_page; 587 + state.page_scroll_y = 0.0; 588 + state.scroll_offsets.clear(); 589 + rerender(state); 590 + } 591 + 592 + /// Load a page from a parsed URL (for form GET submission and navigation). 593 + fn load_from_url(url: &Url) -> LoadedHtml { 594 + let mut loader = ResourceLoader::new(); 595 + match loader.fetch_url(&url.serialize(), None) { 596 + Ok(Resource::Html { text, base_url, .. }) => { 597 + let http_policy = if loader.referrer_policy() != ReferrerPolicy::default() { 598 + Some(loader.referrer_policy()) 599 + } else { 600 + None 601 + }; 602 + let http_csp = loader.csp().clone(); 603 + LoadedHtml { 604 + text, 605 + base_url, 606 + http_referrer_policy: http_policy, 607 + http_csp, 608 + } 609 + } 610 + Ok(_) => error_page(&format!("URL did not return HTML: {}", url.serialize())), 611 + Err(e) => error_page(&format!("Failed to load {}: {e}", url.serialize())), 612 + } 613 + } 614 + 460 615 /// Returns true if `node` is a `<select>` element. 461 616 fn is_select(doc: &we_dom::Document, node: NodeId) -> bool { 462 617 doc.tag_name(node) == Some("select") ··· 903 1058 return; 904 1059 } 905 1060 1061 + // Handle submit button activation via Enter or Space. 1062 + if is_submit_button(&state.page.doc, focused) { 1063 + if matches!(key_code, KEY_CODE_RETURN | KEY_CODE_SPACE) 1064 + && state.page.doc.get_attribute(focused, "disabled").is_none() 1065 + { 1066 + if let Some(form) = state.page.doc.form_owner(focused) { 1067 + submit_form(state, form, Some(focused), false); 1068 + return; 1069 + } 1070 + } 1071 + return; 1072 + } 1073 + 906 1074 // Only process editing keys when a text control is focused. 907 1075 if !is_text_editable(&state.page.doc, focused) { 908 1076 return; ··· 1046 1214 needs_render = true; 1047 1215 } 1048 1216 } 1217 + } else { 1218 + // For single-line text inputs, Enter triggers implicit form submission. 1219 + if let Some(form) = state.page.doc.form_owner(focused) { 1220 + // Find the default submit button (first submit button in the form). 1221 + let elements = state.page.doc.form_elements(form); 1222 + let default_submit = elements 1223 + .iter() 1224 + .find(|&&e| is_submit_button(&state.page.doc, e)) 1225 + .copied(); 1226 + submit_form(state, form, default_submit, false); 1227 + return; // submit_form handles rerender 1228 + } 1049 1229 } 1050 - // For single-line inputs, Enter doesn't insert text. 1051 1230 } 1052 1231 // Character input (printable characters, no Cmd modifier). 1053 1232 else if !mods.command && !mods.control { ··· 1262 1441 if let Some(is) = state.page.doc.input_states.get_mut(node) { 1263 1442 let byte_pos = char_to_byte_offset(is.text(), char_idx); 1264 1443 is.set_cursor(byte_pos); 1444 + } 1445 + } 1446 + } else if is_submit_button(&state.page.doc, node) { 1447 + // Submit button click — trigger form submission. 1448 + state.page.doc.set_active_element(Some(node), false); 1449 + if state.page.doc.get_attribute(node, "disabled").is_none() { 1450 + if let Some(form) = state.page.doc.form_owner(node) { 1451 + submit_form(state, form, Some(node), false); 1452 + return; // submit_form handles rerender 1265 1453 } 1266 1454 } 1267 1455 } else {
+75
crates/js/src/dom_bridge.rs
··· 651 651 set_builtin_prop(gc, shapes, wrapper, name, Value::Function(func)); 652 652 } 653 653 } 654 + 655 + // Form submission methods (only for <form> elements). 656 + if doc.tag_name(node_id) == Some("form") { 657 + let form_methods: &[NativeMethod] = &[ 658 + ("submit", form_submit), 659 + ("requestSubmit", form_request_submit), 660 + ]; 661 + for &(name, callback) in form_methods { 662 + let func = make_native(gc, name, callback); 663 + set_builtin_prop(gc, shapes, wrapper, name, Value::Function(func)); 664 + } 665 + } 654 666 } 655 667 656 668 // ── Helper: extract NodeId from a wrapper ─────────────────────────── ··· 1096 1108 .set(node_id, &message); 1097 1109 1098 1110 Ok(Value::Undefined) 1111 + } 1112 + 1113 + // ── Form submission (JS interface) ────────────────────────────────── 1114 + 1115 + /// `form.submit()` — submit the form without validation and without firing 1116 + /// the `submit` event. Returns a marker object with `__form_submit__` so 1117 + /// the caller (browser main loop) can perform the actual navigation. 1118 + fn form_submit(_args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { 1119 + let node_id = match &ctx.this { 1120 + Value::Object(r) => get_node_id(ctx.gc, ctx.shapes, *r), 1121 + _ => None, 1122 + } 1123 + .ok_or_else(|| RuntimeError::type_error("submit called on non-form element"))?; 1124 + 1125 + // Return a marker that the browser can detect and act on. 1126 + let mut marker = ObjectData::new(); 1127 + marker.insert_property( 1128 + "__form_submit__".to_string(), 1129 + Property::builtin(Value::Boolean(true)), 1130 + ctx.shapes, 1131 + ); 1132 + marker.insert_property( 1133 + "__form_id__".to_string(), 1134 + Property::builtin(Value::Number(node_id.index() as f64)), 1135 + ctx.shapes, 1136 + ); 1137 + // skip_validation = true for form.submit() 1138 + marker.insert_property( 1139 + "__skip_validation__".to_string(), 1140 + Property::builtin(Value::Boolean(true)), 1141 + ctx.shapes, 1142 + ); 1143 + Ok(Value::Object(ctx.gc.alloc(HeapObject::Object(marker)))) 1144 + } 1145 + 1146 + /// `form.requestSubmit()` — submit the form with validation (fires `submit` 1147 + /// event and runs constraint validation). Returns same marker as `form_submit` 1148 + /// but with `skip_validation = false`. 1149 + fn form_request_submit(_args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { 1150 + let node_id = match &ctx.this { 1151 + Value::Object(r) => get_node_id(ctx.gc, ctx.shapes, *r), 1152 + _ => None, 1153 + } 1154 + .ok_or_else(|| RuntimeError::type_error("requestSubmit called on non-form element"))?; 1155 + 1156 + let mut marker = ObjectData::new(); 1157 + marker.insert_property( 1158 + "__form_submit__".to_string(), 1159 + Property::builtin(Value::Boolean(true)), 1160 + ctx.shapes, 1161 + ); 1162 + marker.insert_property( 1163 + "__form_id__".to_string(), 1164 + Property::builtin(Value::Number(node_id.index() as f64)), 1165 + ctx.shapes, 1166 + ); 1167 + // skip_validation = false for form.requestSubmit() 1168 + marker.insert_property( 1169 + "__skip_validation__".to_string(), 1170 + Property::builtin(Value::Boolean(false)), 1171 + ctx.shapes, 1172 + ); 1173 + Ok(Value::Object(ctx.gc.alloc(HeapObject::Object(marker)))) 1099 1174 } 1100 1175 1101 1176 // ── HTML serialization ──────────────────────────────────────────────