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
8 changes: 8 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions changelog.d/8453-owned-ui-strings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
### Fixed

- Native UI and audio backends now copy runtime strings into owned Rust
storage before use, preventing dangling borrows when Perry's garbage
collector relocates the original string.
1 change: 1 addition & 0 deletions crates/perry-audio-miniaudio/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@ cc = "1"

[target.'cfg(any(target_os = "linux", target_os = "windows", target_os = "android"))'.dependencies]
libc.workspace = true
perry-ffi.workspace = true
32 changes: 3 additions & 29 deletions crates/perry-audio-miniaudio/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,33 +22,7 @@ use std::cell::RefCell;
use std::ffi::CString;
use std::sync::Mutex;

// =============================================================================
// String header — mirrors perry_runtime::string::StringHeader. Kept inline
// (don't depend on perry-runtime — that would create a dep cycle through
// the UI crates that re-export us).
// =============================================================================

#[repr(C)]
struct StringHeader {
pub utf16_len: u32,
pub byte_len: u32,
pub capacity: u32,
pub refcount: u32,
pub flags: u32,
}

fn str_from_header(ptr: *const u8) -> String {
if ptr.is_null() {
return String::new();
}
unsafe {
let header = ptr as *const StringHeader;
let len = (*header).byte_len as usize;
let data = ptr.add(std::mem::size_of::<StringHeader>());
let slice = std::slice::from_raw_parts(data, len);
std::str::from_utf8(slice).unwrap_or("").to_owned()
}
}
use perry_ffi::copy_string_from_raw as str_from_header;

// =============================================================================
// miniaudio FFI — only the slice we actually need.
Expand Down Expand Up @@ -419,7 +393,7 @@ pub extern "C" fn perry_audio_load_sound(path_ptr: i64, bus: f64, stream: f64) -
return 0;
}
let is_streaming = stream != 0.0;
let filename = str_from_header(path_ptr as *const u8);
let filename = unsafe { str_from_header(path_ptr as *const u8) };
if filename.is_empty() {
return 0;
}
Expand Down Expand Up @@ -944,7 +918,7 @@ pub extern "C" fn perry_audio_create_bus(name_ptr: i64, parent: f64) -> i64 {
if !ensure_engine() {
return 0;
}
let name = str_from_header(name_ptr as *const u8);
let name = unsafe { str_from_header(name_ptr as *const u8) };
let parent_ptr = match resolve_bus_group(parent) {
Some(p) => p,
None => {
Expand Down
75 changes: 75 additions & 0 deletions crates/perry-ffi/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,81 @@ pub fn read_bytes(handle: JsString) -> Option<&'static [u8]> {
}
}

/// Copy a runtime string payload into GC-independent Rust storage.
///
/// A null pointer is treated as an empty string. Invalid UTF-8 is replaced
/// with the Unicode replacement character, matching
/// [`String::from_utf8_lossy`]. Unlike [`read_string`], the returned value
/// remains valid if a later Perry runtime allocation moves the source object.
///
/// # Safety
///
/// `ptr` must be null or point to a valid Perry [`StringHeader`] followed by
/// at least `byte_len` initialized payload bytes. The source must not move or
/// be freed while this function is copying it.
pub unsafe fn copy_string_from_raw<T>(ptr: *const T) -> String {
if ptr.is_null() {
return String::new();
}

let ptr = ptr.cast::<u8>();
// SAFETY: upheld by the caller; the payload immediately follows the header.
let header = unsafe { &*(ptr.cast::<StringHeader>()) };
let data = unsafe { ptr.add(std::mem::size_of::<StringHeader>()) };
let bytes = unsafe { std::slice::from_raw_parts(data, header.byte_len as usize) };
String::from_utf8_lossy(bytes).into_owned()
}

#[cfg(test)]
mod copy_string_tests {
use super::*;

fn runtime_string(bytes: &[u8]) -> Vec<u32> {
let header_len = std::mem::size_of::<StringHeader>();
let word_count = (header_len + bytes.len()).div_ceil(std::mem::size_of::<u32>());
let mut storage = vec![0_u32; word_count];

let header = StringHeader {
utf16_len: String::from_utf8_lossy(bytes).encode_utf16().count() as u32,
byte_len: bytes.len() as u32,
capacity: bytes.len() as u32,
refcount: 1,
flags: 0,
};

// SAFETY: `Vec<u32>` supplies sufficient alignment and `word_count`
// reserves enough initialized storage for the header and payload.
unsafe {
storage.as_mut_ptr().cast::<StringHeader>().write(header);
std::ptr::copy_nonoverlapping(
bytes.as_ptr(),
storage.as_mut_ptr().cast::<u8>().add(header_len),
bytes.len(),
);
}

storage
}

#[test]
fn copies_non_ascii_payload_into_owned_storage() {
let mut storage = runtime_string("Grüße 👋".as_bytes());
// SAFETY: `runtime_string` created a valid header and payload.
let copied = unsafe { copy_string_from_raw(storage.as_ptr()) };

storage.fill(0);
assert_eq!(copied, "Grüße 👋");
}

#[test]
fn replaces_invalid_utf8_and_accepts_null() {
let storage = runtime_string(&[b'f', 0x80]);
// SAFETY: `runtime_string` created a valid header and payload.
assert_eq!(unsafe { copy_string_from_raw(storage.as_ptr()) }, "f�");
assert_eq!(unsafe { copy_string_from_raw::<u8>(std::ptr::null()) }, "");
}
}

/// Allocate a runtime string from raw bytes — bypasses the UTF-8
/// validation [`alloc_string`] does implicitly. Use for compressed
/// payloads, crypto digests, and other binary-as-string outputs
Expand Down
2 changes: 2 additions & 0 deletions crates/perry-ffi/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ pub struct StringHeader {
pub flags: u32,
}

const _: () = assert!(std::mem::size_of::<StringHeader>() == 20);

/// Header for a runtime-allocated JS array.
#[repr(C)]
pub struct ArrayHeader {
Expand Down
1 change: 1 addition & 0 deletions crates/perry-ui-android/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ crate-type = ["rlib", "staticlib"]
geisterhand = []

[target.'cfg(target_os = "android")'.dependencies]
perry-ffi.workspace = true
perry-ui = { path = "../perry-ui" }
perry-audio-miniaudio = { workspace = true }
perry-runtime = { path = "../perry-runtime", default-features = false }
Expand Down
14 changes: 2 additions & 12 deletions crates/perry-ui-android/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,24 +21,14 @@ struct AppConfig {
}

/// Extract a &str from a *const StringHeader pointer (Perry runtime string format).
pub fn str_from_header(ptr: *const u8) -> &'static str {
if ptr.is_null() {
return "";
}
unsafe {
let header = ptr as *const perry_runtime::string::StringHeader;
let len = (*header).byte_len as usize;
let data = ptr.add(std::mem::size_of::<perry_runtime::string::StringHeader>());
std::str::from_utf8_unchecked(std::slice::from_raw_parts(data, len))
}
}
pub(crate) use perry_ffi::copy_string_from_raw as str_from_header;

/// Create an app. Stores config for deferred creation. Returns app handle (i64).
pub fn app_create(title_ptr: *const u8, width: f64, height: f64) -> i64 {
let title = if title_ptr.is_null() {
"Perry App".to_string()
} else {
str_from_header(title_ptr).to_string()
unsafe { str_from_header(title_ptr) }.to_string()
};

let w = if width > 0.0 { width } else { 400.0 };
Expand Down
1 change: 0 additions & 1 deletion crates/perry-ui-android/src/audio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
//! Results are stored in atomics, read lock-free by the main/UI thread.

use jni::objects::{JObject, JValue};
use std::cell::RefCell;
use std::fs::File;
use std::io::Write;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
Expand Down
20 changes: 5 additions & 15 deletions crates/perry-ui-android/src/background.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,7 @@ use crate::callback;
use crate::jni_bridge;
use jni::objects::JValue;

fn str_from_header(ptr: *const u8) -> String {
if ptr.is_null() {
return String::new();
}
unsafe {
let header = ptr as *const perry_runtime::string::StringHeader;
let len = (*header).byte_len as usize;
let data = ptr.add(std::mem::size_of::<perry_runtime::string::StringHeader>());
String::from_utf8_lossy(std::slice::from_raw_parts(data, len)).into_owned()
}
}
use perry_ffi::copy_string_from_raw as str_from_header;

const TAG_TRUE: u64 = 0x7FFC_0000_0000_0004;
const TAG_FALSE: u64 = 0x7FFC_0000_0000_0003;
Expand All @@ -40,7 +30,7 @@ fn boolean_truthy(v: f64) -> bool {
}

pub fn register_task(identifier_ptr: *const u8, handler: f64) {
let id = str_from_header(identifier_ptr);
let id = unsafe { str_from_header(identifier_ptr) };
if id.is_empty() {
return;
}
Expand Down Expand Up @@ -70,11 +60,11 @@ pub fn schedule(
requires_network: f64,
requires_charging: f64,
) {
let id = str_from_header(identifier_ptr);
let id = unsafe { str_from_header(identifier_ptr) };
if id.is_empty() {
return;
}
let kind = str_from_header(kind_ptr);
let kind = unsafe { str_from_header(kind_ptr) };
let kind = if kind.is_empty() {
"appRefresh".to_string()
} else {
Expand Down Expand Up @@ -108,7 +98,7 @@ pub fn schedule(
}

pub fn cancel(identifier_ptr: *const u8) {
let id = str_from_header(identifier_ptr);
let id = unsafe { str_from_header(identifier_ptr) };
if id.is_empty() {
return;
}
Expand Down
2 changes: 1 addition & 1 deletion crates/perry-ui-android/src/clipboard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ pub fn read() -> f64 {

/// Write text to the system clipboard.
pub fn write(text_ptr: *const u8) {
let text = crate::app::str_from_header(text_ptr);
let text = unsafe { crate::app::str_from_header(text_ptr) };
let mut env = jni_bridge::get_env();

let jstr = env.new_string(text).expect("Failed to create JNI string");
Expand Down
8 changes: 3 additions & 5 deletions crates/perry-ui-android/src/dialog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,7 @@ use crate::callback;
use crate::jni_bridge;
use jni::objects::JValue;

fn str_from_header(ptr: *const u8) -> &'static str {
crate::app::str_from_header(ptr)
}
use perry_ffi::copy_string_from_raw as str_from_header;

extern "C" {
fn js_closure_call1(closure: f64, arg: f64) -> f64;
Expand Down Expand Up @@ -34,8 +32,8 @@ pub fn save_file_dialog(

/// Show an alert dialog with title, message, buttons and callback.
pub fn alert(title_ptr: *const u8, message_ptr: *const u8, _buttons_ptr: *const u8, callback: f64) {
let title = str_from_header(title_ptr);
let message = str_from_header(message_ptr);
let title = unsafe { str_from_header(title_ptr) };
let message = unsafe { str_from_header(message_ptr) };

let mut env = jni_bridge::get_env();
let _ = env.push_local_frame(32);
Expand Down
4 changes: 2 additions & 2 deletions crates/perry-ui-android/src/drag_drop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,7 @@ pub extern "C" fn Java_com_perry_app_PerryBridge_nativeInvokeDropCallback(
/// thread, so it pumps microtasks afterwards.
#[no_mangle]
pub extern "C" fn Java_com_perry_app_PerryBridge_nativeInvokeDragProvider<'local>(
mut env: jni::JNIEnv<'local>,
env: jni::JNIEnv<'local>,
_class: jni::objects::JClass<'local>,
key: jni::sys::jlong,
) -> jni::objects::JString<'local> {
Expand All @@ -316,7 +316,7 @@ fn drag_provider_payload(key: i64) -> Option<String> {
if sh.is_null() {
None
} else {
Some(str_from_header(sh).to_string())
Some(unsafe { str_from_header(sh) }.to_string())
}
}
}
Expand Down
Loading
Loading