typed-rust: lower string->utf8 and utf8->string

ober

af5e095d662ec9864396445c9ff64c99dde18b9a

diff --git a/docs/jerboa-to-rust.md b/docs/jerboa-to-rust.md
index d1c914f..4f2efad 100644
--- a/docs/jerboa-to-rust.md
+++ b/docs/jerboa-to-rust.md
@@ -87,6 +87,11 @@ Landed:
   (`make-bytevector`) and in-place mutation (`bytevector-u8-set!`,
   `bytevector-copy!`) are not yet lowered -- they need the mutable-ownership
   model that the current Clone-heavy lowering does not yet provide.
+- `string->utf8` / `utf8->string` cross the text/binary boundary. `string->utf8`
+  is `(String -> Bytes)` and lowers to `(s).as_bytes().to_vec()`; `utf8->string`
+  is `(Bytes -> String)` and lowers to lossy decode
+  `String::from_utf8_lossy(&(buf)).into_owned()` (matches the String input ABI,
+  never panics). Both borrow rather than move their source.
 - Rust `struct`/`enum` generation for Typed Jerboa records and variants,
   including `Clone`, `Debug`, and `PartialEq` derives.
 - Recursive variant field boxing and match rebinding for owned child values.
diff --git a/docs/typed-jerboa.md b/docs/typed-jerboa.md
index 80c073c..35106c9 100644
--- a/docs/typed-jerboa.md
+++ b/docs/typed-jerboa.md
@@ -234,7 +234,11 @@ Current landing:
   `(Bytes Nat -> Nat)` and lowers to indexed access `buf[(i) as usize] as u64`;
   combined with the bitwise primitives and same-module recursion, this is enough
   to express a constant-time `equal?` (XOR-accumulate fold) entirely in Typed
-  Jerboa — see `tests/fixtures/typed/rust-ct-equal.ss`. `debug-string` checks one typed
+  Jerboa — see `tests/fixtures/typed/rust-ct-equal.ss`. `string->utf8`
+  (`String -> Bytes`) and `utf8->string` (`Bytes -> String`) cross the
+  text/binary boundary, lowering to `as_bytes().to_vec()` and a lossy
+  `from_utf8_lossy` decode, so a constant-time auth-token comparison ports too
+  (`tests/fixtures/typed/rust-utf8.ss`). `debug-string` checks one typed
   operand and lowers to Rust `format!("{:?}", ...)`, using the Debug derives on
   generated records and variants. Imported calls and richer forms are reported
   as unsupported. Typed core IR is now produced by the checker and consumed by
diff --git a/lib/jerboa/typed/checker.ss b/lib/jerboa/typed/checker.ss
index 9589095..fa7dc31 100644
--- a/lib/jerboa/typed/checker.ss
+++ b/lib/jerboa/typed/checker.ss
@@ -365,7 +365,13 @@
               'bytevector-length '()))
       (cons 'bytevector-u8-ref
             (make-typed-call-sig (list 'Bytes 'Nat) 'Nat '()
-              'bytevector-u8-ref '()))))
+              'bytevector-u8-ref '()))
+      (cons 'string->utf8
+            (make-typed-call-sig (list 'String) 'Bytes '()
+              'string->utf8 '()))
+      (cons 'utf8->string
+            (make-typed-call-sig (list 'Bytes) 'String '()
+              'utf8->string '()))))
 
   (def (field-types fields)
     (map typed-field-type fields))
diff --git a/lib/jerboa/typed/rust.ss b/lib/jerboa/typed/rust.ss
index 892914a..bb14dbe 100644
--- a/lib/jerboa/typed/rust.ss
+++ b/lib/jerboa/typed/rust.ss
@@ -1302,6 +1302,26 @@
       (emit-expression (cadr args))
       ") as usize] as u64)"))
 
+  ;; borrow the String's bytes and copy into an owned Vec<u8> (no move, so the
+  ;; source String stays usable under the Clone-heavy ownership model)
+  (def (emit-string->utf8 args)
+    (unless (= (length args) 1)
+      (error 'typed-rust "string->utf8 expects one operand" args))
+    (string-append
+      "("
+      (emit-expression (car args))
+      ").as_bytes().to_vec()"))
+
+  ;; lossy decode (never panics; invalid sequences become U+FFFD), matching the
+  ;; String input ABI's from_utf8_lossy and avoiding a move of the buffer
+  (def (emit-utf8->string args)
+    (unless (= (length args) 1)
+      (error 'typed-rust "utf8->string expects one operand" args))
+    (string-append
+      "String::from_utf8_lossy(&("
+      (emit-expression (car args))
+      ")).into_owned()"))
+
   (def (emit-equality args)
     (unless (= (length args) 2)
       (error 'typed-rust "equal? expects two operands" args))
@@ -1505,6 +1525,8 @@
         [(string-append) (emit-string-append args)]
         [(bytevector-length) (emit-bytevector-length args)]
         [(bytevector-u8-ref) (emit-bytevector-u8-ref args)]
+        [(string->utf8) (emit-string->utf8 args)]
+        [(utf8->string) (emit-utf8->string args)]
         [(debug-string) (emit-debug-string args)]
         [(record-ctor)
          (let ([record (lookup-name (ir-call-info-ref ir 'record)
diff --git a/tests/fixtures/typed/rust-utf8.ss b/tests/fixtures/typed/rust-utf8.ss
new file mode 100644
index 0000000..25c0c90
--- /dev/null
+++ b/tests/fixtures/typed/rust-utf8.ss
@@ -0,0 +1,26 @@
+(typed-library (sample typed utf8-auth)
+  (export encode decode round-trip
+          fold-diff token-bytes-equal?)
+
+  ;; UTF-8 codec at the text/binary boundary
+  (def (encode (s : String)) : Bytes
+    (string->utf8 s))
+
+  (def (decode (data : Bytes)) : String
+    (utf8->string data))
+
+  (def (round-trip (s : String)) : String
+    (utf8->string (string->utf8 s)))
+
+  ;; constant-time XOR-accumulate over two byte buffers
+  (def (fold-diff (a : Bytes) (b : Bytes) (i : Nat) (n : Nat) (acc : Nat)) : Nat
+    (if (>= i n)
+      acc
+      (fold-diff a b (+ i 1) n
+                 (bitwise-ior acc (bitwise-xor (bytevector-u8-ref a i)
+                                               (bytevector-u8-ref b i))))))
+
+  ;; compare a presented token string against expected bytes in constant time:
+  ;; encode the text to UTF-8, then fold the byte differences (0 iff equal).
+  (def (token-bytes-equal? (presented : String) (expected : Bytes) (n : Nat)) : Bool
+    (= (fold-diff (string->utf8 presented) expected 0 n 0) 0)))
diff --git a/tests/test-typed-checker.ss b/tests/test-typed-checker.ss
index 2e4e164..06c3a3c 100644
--- a/tests/test-typed-checker.ss
+++ b/tests/test-typed-checker.ss
@@ -621,6 +621,32 @@
          (bytevector-u8-ref data))))
   '(bad-call-arity))
 
+(test "builtin string->utf8 returns Bytes, utf8->string returns String"
+  (error-kinds
+    '(typed-library (body utf8-ok)
+       (export enc dec)
+       (def (enc (s : String)) : Bytes
+         (string->utf8 s))
+       (def (dec (b : Bytes)) : String
+         (utf8->string b))))
+  '())
+
+(test "builtin string->utf8 rejects non-String"
+  (error-kinds
+    '(typed-library (body utf8-enc-bad)
+       (export f)
+       (def (f (b : Bytes)) : Bytes
+         (string->utf8 b))))
+  '(argument-type-mismatch))
+
+(test "builtin utf8->string rejects non-Bytes"
+  (error-kinds
+    '(typed-library (body utf8-dec-bad)
+       (export f)
+       (def (f (s : String)) : String
+         (utf8->string s))))
+  '(argument-type-mismatch))
+
 (test "record constructor and accessor calls"
   (error-kinds
     '(typed-library (body record-ok)
diff --git a/tests/test-typed-rust.ss b/tests/test-typed-rust.ss
index b653474..0610df4 100644
--- a/tests/test-typed-rust.ss
+++ b/tests/test-typed-rust.ss
@@ -184,6 +184,21 @@
 (define bvref-rust
   (typed-library-form->rust-string bvref-form))
 
+;; string <-> bytes conversion: the text/binary boundary crypto code needs
+;; (hash a message, decode a token) without moving the source value.
+(define utf8-form
+  '(typed-library (sample typed utf8)
+     (export encode decode round-trip)
+     (def (encode (s : String)) : Bytes
+       (string->utf8 s))
+     (def (decode (data : Bytes)) : String
+       (utf8->string data))
+     (def (round-trip (s : String)) : String
+       (utf8->string (string->utf8 s)))))
+
+(define utf8-rust
+  (typed-library-form->rust-string utf8-form))
+
 (define return-form
   '(typed-library (sample typed return-values)
      (export greeting echo-text echo-bytes)
@@ -451,6 +466,16 @@
          "(acc | ((a[(i) as usize] as u64) ^ (b[(i) as usize] as u64)))"))
   #t)
 
+(test "rust lowers string<->bytes utf8 conversions"
+  (and (substring? utf8-rust "pub fn encode(s: String) -> Vec<u8>")
+       (substring? utf8-rust "(s).as_bytes().to_vec()")
+       (substring? utf8-rust "pub fn decode(data: Vec<u8>) -> String")
+       (substring? utf8-rust "String::from_utf8_lossy(&(data)).into_owned()")
+       ;; nested round-trip: decode(encode(s))
+       (substring? utf8-rust
+         "String::from_utf8_lossy(&((s).as_bytes().to_vec())).into_owned()"))
+  #t)
+
 (test "rust emits handle registry for record and variant ABI"
   (and (substring? handle-rust "fn jt_store_handle<T: Any + Send>(value: T) -> u64")
        (substring? handle-rust "fn jt_clone_handle<T: Any + Clone>(id: u64) -> Option<T>")