diff --git a/Dockerfile.standalone b/Dockerfile.standalone index 905e5d8..2b993a1 100644 --- a/Dockerfile.standalone +++ b/Dockerfile.standalone @@ -100,6 +100,7 @@ COPY schema/plex_schema.sql /usr/local/lib/plex-postgresql/ COPY schema/sqlite_schema.sql /usr/local/lib/plex-postgresql/ COPY schema/sqlite_column_types.sql /usr/local/lib/plex-postgresql/ COPY schema/pg_compat_functions.sql /usr/local/lib/plex-postgresql/ +COPY schema/seed_data.sql /usr/local/lib/plex-postgresql/ COPY scripts/migrate_lib.sh /usr/local/lib/plex-postgresql/ COPY scripts/migrate_table.py /usr/local/lib/plex-postgresql/ COPY scripts/seed_shadow_table_from_pg.py /usr/local/lib/plex-postgresql/ @@ -121,6 +122,9 @@ RUN printf '%s\n' \ '#!/usr/bin/env bash' \ 'mkdir -p /run/plex-temp' \ 'chmod 1777 /run/plex-temp 2>/dev/null || true' \ + 'export LANG=C.utf8' \ + 'export LC_ALL=C.utf8' \ + 'export LC_CTYPE=C.utf8' \ 'export LD_LIBRARY_PATH="/usr/local/lib/plex-postgresql:/usr/lib/plexmediaserver/lib:$LD_LIBRARY_PATH"' \ 'export LD_PRELOAD="/usr/local/lib/plex-postgresql/db_interpose_pg.so"' \ 'if [[ "$(uname -m)" == "aarch64" || "$(uname -m)" == "arm64" ]]; then' \ @@ -129,6 +133,8 @@ RUN printf '%s\n' \ 'exec /usr/local/bin/subreaper "/usr/lib/plexmediaserver/Plex Media Server" "$@"' \ > /usr/local/lib/plex-postgresql/plex-with-shim.sh && \ chmod +x /usr/local/lib/plex-postgresql/plex-with-shim.sh && \ + sed -i 's|s6-setuidgid plex /usr/lib/plexmediaserver/Plex\\ Media\\ Server|s6-setuidgid plex /usr/local/lib/plex-postgresql/plex-with-shim.sh|' \ + /etc/services.d/plex/run && \ sed -i 's|s6-setuidgid abc "/usr/lib/plexmediaserver/Plex Media Server"|s6-setuidgid abc /usr/local/lib/plex-postgresql/plex-with-shim.sh|' \ /etc/services.d/plex/run && \ sed -i 's|"/usr/lib/plexmediaserver/Plex Media Server"|/usr/local/lib/plex-postgresql/plex-with-shim.sh|' \ diff --git a/docker-compose.standalone.yml b/docker-compose.standalone.yml index cd82058..d1b6113 100644 --- a/docker-compose.standalone.yml +++ b/docker-compose.standalone.yml @@ -52,8 +52,10 @@ services: - PLEX_PG_VALIDATE_OUTPUT_SAMPLE_PCT=${PLEX_PG_VALIDATE_OUTPUT_SAMPLE_PCT:-5} # Optional ARM override for OpenSSL CPU feature probing in some VMs/containers # - PLEX_PG_OPENSSL_ARMCAP=0 - - PLEX_PG_LOG_LEVEL=ERROR - - PLEX_PG_LOG_FILE=/config/plex_redirect_pg.log + - PLEX_PG_LOG_LEVEL=DEBUG + - PLEX_PG_ENABLE_SIGNAL_LOG=1 + - PLEX_PG_TRACE_LOADONE=1 + - PLEX_PG_LOG_FILE=/config/Library/Application Support/Plex Media Server/Logs/plex_redirect_pg.log # Clear log file on startup to avoid stale errors - PLEX_PG_LOG_TRUNCATE_ON_START=1 volumes: diff --git a/rust/plex-pg-core/src/db_interpose_bind/text_blob_binds.rs b/rust/plex-pg-core/src/db_interpose_bind/text_blob_binds.rs index e4cbd71..b56a414 100644 --- a/rust/plex-pg-core/src/db_interpose_bind/text_blob_binds.rs +++ b/rust/plex-pg-core/src/db_interpose_bind/text_blob_binds.rs @@ -5,6 +5,36 @@ use crate::db_interpose_bind::support::{ }; use crate::log_debug_lazy; +fn parse_machine_identifier(content: &str) -> Option { + if let Some(pos) = content.find("MachineIdentifier=\"") { + let start = pos + "MachineIdentifier=\"".len(); + if let Some(end) = content[start..].find("\"") { + let hex = &content[start..start+end]; + if hex.len() == 32 { + return Some(format!( + "{}-{}-{}-{}-{}", + &hex[0..8], + &hex[8..12], + &hex[12..16], + &hex[16..20], + &hex[20..32] + )); + } + } + } + None +} + +fn get_machine_identifier() -> String { + let default_uuid = "53cfd87b-f8b2-4db2-af2d-6aaa373b2b34".to_string(); + let path = "/config/Library/Application Support/Plex Media Server/Preferences.xml"; + let content = match std::fs::read_to_string(path) { + Ok(c) => c, + Err(_) => return default_uuid, + }; + parse_machine_identifier(&content).unwrap_or(default_uuid) +} + unsafe fn store_text_param( pg_stmt: *mut PgStmt, pg_idx: usize, @@ -82,11 +112,67 @@ unsafe fn store_blob_hex_param( pub(super) fn bind_text_impl( p_stmt: *mut sqlite3_stmt, idx: c_int, - val: *const c_char, - n_bytes: c_int, + mut val: *const c_char, + mut n_bytes: c_int, destructor: *mut c_void, ) -> c_int { let (pg_stmt, guard) = unsafe { begin_bind(PHASE_BIND_TEXT, p_stmt) }; + let mut _intercepted_uuid = String::new(); + + if !pg_stmt.is_null() && !val.is_null() { + let stmt = unsafe { &*pg_stmt }; + let sql_bytes = unsafe { if !stmt.sql.is_null() { crate::byte_utils::cstr_bytes(stmt.sql) } else { b"" } }; + + let bypass = std::env::var("BYPASS_UUID_INTERCEPT").is_ok(); + if !bypass && crate::byte_utils::contains_bytes(sql_bytes, b"devices") { + let actual_len = if n_bytes < 0 { + unsafe { libc::strlen(val) as usize } + } else { + n_bytes as usize + }; + if actual_len == 0 { + let param_name = crate::db_interpose_metadata::rust_my_sqlite3_bind_parameter_name(p_stmt, idx); + let is_identifier = if !param_name.is_null() { + let name_str = unsafe { std::ffi::CStr::from_ptr(param_name).to_string_lossy().to_lowercase() }; + name_str.contains("identifier") + } else { + let sql_str = crate::db_interpose_conn_utils::cstr_to_string_or(stmt.sql, "").to_lowercase(); + sql_str.contains("where identifier") || (sql_str.contains("select") && idx == 1) + }; + + if is_identifier { + _intercepted_uuid = get_machine_identifier(); + log_debug_lazy!("INTERCEPTED empty UUID bind on devices, replacing with {}", _intercepted_uuid); + val = _intercepted_uuid.as_ptr() as *const c_char; + n_bytes = _intercepted_uuid.len() as c_int; + } + } + } + } + + if !pg_stmt.is_null() { + let stmt = unsafe { &*pg_stmt }; + let sql_bytes = unsafe { if !stmt.sql.is_null() { crate::byte_utils::cstr_bytes(stmt.sql) } else { b"" } }; + if crate::byte_utils::contains_bytes(sql_bytes, b"devices") || crate::byte_utils::contains_bytes(sql_bytes, b"library_sections") || crate::byte_utils::contains_bytes(sql_bytes, b"plugins") { + let val_str = if val.is_null() { "NULL".to_string() } else { + let actual_len = if n_bytes < 0 { + unsafe { libc::strlen(val) as usize } + } else { + n_bytes as usize + }; + let bytes = unsafe { std::slice::from_raw_parts(val as *const u8, actual_len.min(100)) }; + String::from_utf8_lossy(bytes).into_owned() + }; + log_debug_lazy!( + "BIND TEXT: stmt={:p} idx={} val='{}' n_bytes={} sql={}", + pg_stmt, + idx, + val_str, + n_bytes, + crate::db_interpose_conn_utils::cstr_to_string_or(stmt.sql, "NULL") + ); + } + } let rc = if is_pg_routed_noncached(pg_stmt) { // Skip orig_sqlite3_bind_text — PG param storage below is sufficient. @@ -218,12 +304,44 @@ pub(super) fn bind_blob64_impl( pub(super) fn bind_text64_impl( p_stmt: *mut sqlite3_stmt, idx: c_int, - val: *const c_char, - n_bytes: u64, + mut val: *const c_char, + mut n_bytes: u64, destructor: *mut c_void, encoding: c_uchar, ) -> c_int { let (pg_stmt, guard) = unsafe { begin_bind(PHASE_BIND_TEXT64, p_stmt) }; + let mut _intercepted_uuid = String::new(); + + if !pg_stmt.is_null() && !val.is_null() { + let stmt = unsafe { &*pg_stmt }; + let sql_bytes = unsafe { if !stmt.sql.is_null() { crate::byte_utils::cstr_bytes(stmt.sql) } else { b"" } }; + + let bypass = std::env::var("BYPASS_UUID_INTERCEPT").is_ok(); + if !bypass && crate::byte_utils::contains_bytes(sql_bytes, b"devices") { + let actual_len = if n_bytes == u64::MAX { + unsafe { libc::strlen(val) as usize } + } else { + n_bytes as usize + }; + if actual_len == 0 { + let param_name = crate::db_interpose_metadata::rust_my_sqlite3_bind_parameter_name(p_stmt, idx); + let is_identifier = if !param_name.is_null() { + let name_str = unsafe { std::ffi::CStr::from_ptr(param_name).to_string_lossy().to_lowercase() }; + name_str.contains("identifier") + } else { + let sql_str = crate::db_interpose_conn_utils::cstr_to_string_or(stmt.sql, "").to_lowercase(); + sql_str.contains("where identifier") || (sql_str.contains("select") && idx == 1) + }; + + if is_identifier { + _intercepted_uuid = get_machine_identifier(); + log_debug_lazy!("INTERCEPTED empty UUID bind on devices (64), replacing with {}", _intercepted_uuid); + val = _intercepted_uuid.as_ptr() as *const c_char; + n_bytes = _intercepted_uuid.len() as u64; + } + } + } + } let rc = if is_pg_routed_noncached(pg_stmt) { if !val.is_null() { @@ -269,3 +387,36 @@ pub(super) fn bind_text64_impl( crate::pg_mem_telemetry::rust_mem_telemetry_maybe_log(); rc } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_machine_identifier_valid() { + let xml = r#""#; + let uuid = parse_machine_identifier(xml); + assert_eq!(uuid, Some("53cfd87b-f8b2-4db2-af2d-6aaa373b2b34".to_string())); + } + + #[test] + fn test_parse_machine_identifier_invalid_len() { + let xml = r#""#; + let uuid = parse_machine_identifier(xml); + assert_eq!(uuid, None); + } + + #[test] + fn test_parse_machine_identifier_missing() { + let xml = r#""#; + let uuid = parse_machine_identifier(xml); + assert_eq!(uuid, None); + } + + #[test] + fn test_get_machine_identifier_fallback() { + let uuid = get_machine_identifier(); + // Since /config/... doesn't exist in unit test runner environment, it must fallback to default UUID + assert_eq!(uuid, "53cfd87b-f8b2-4db2-af2d-6aaa373b2b34"); + } +} diff --git a/rust/plex-pg-core/src/db_interpose_bind/value_binds.rs b/rust/plex-pg-core/src/db_interpose_bind/value_binds.rs index 3fbbefd..b2acdf1 100644 --- a/rust/plex-pg-core/src/db_interpose_bind/value_binds.rs +++ b/rust/plex-pg-core/src/db_interpose_bind/value_binds.rs @@ -72,6 +72,7 @@ pub(super) fn bind_value_impl( ) -> c_int { let (pg_stmt, guard) = unsafe { begin_bind(PHASE_BIND_VALUE, p_stmt) }; + let rc = if is_pg_routed_noncached(pg_stmt) { SQLITE_OK } else { diff --git a/rust/plex-pg-core/src/db_interpose_step.rs b/rust/plex-pg-core/src/db_interpose_step.rs index bc500ca..7b083e9 100644 --- a/rust/plex-pg-core/src/db_interpose_step.rs +++ b/rust/plex-pg-core/src/db_interpose_step.rs @@ -439,5 +439,23 @@ unsafe fn my_sqlite3_step_impl(p_stmt: *mut sqlite3_stmt) -> c_int { } } - orig_step(p_stmt) + let rc = orig_step(p_stmt); + if rc != SQLITE_ROW && rc != SQLITE_DONE { + let db = call_sqlite3_db_handle(p_stmt); + let errmsg = if db.is_null() { + b"no db handle\0".as_ptr() as *const c_char + } else { + crate::db_interpose_metadata::rust_my_sqlite3_errmsg(db) + }; + let sql = call_sqlite3_sql(p_stmt); + libc::fprintf( + stderr_ptr(), + b"[SQLITE_STEP_ERROR] rc=%d errmsg='%s' sql='%.900s'\n\0".as_ptr() as *const c_char, + rc, + errmsg, + if sql.is_null() { b"\0".as_ptr() as *const c_char } else { sql }, + ); + libc::fflush(stderr_ptr()); + } + rc } diff --git a/rust/plex-pg-core/src/db_interpose_step_read_utils/next_result.rs b/rust/plex-pg-core/src/db_interpose_step_read_utils/next_result.rs index 29f443e..12a6875 100644 --- a/rust/plex-pg-core/src/db_interpose_step_read_utils/next_result.rs +++ b/rust/plex-pg-core/src/db_interpose_step_read_utils/next_result.rs @@ -1,5 +1,7 @@ use super::*; use crate::log_debug_lazy; +use crate::byte_utils::{contains_icase_bytes, cstr_bytes}; +use crate::db_interpose_conn_utils::{cstr_prefix, cstr_to_string_or}; pub(super) fn advance_cached_result_impl(stmt: *mut PgStmt) -> c_int { if stmt.is_null() { @@ -172,5 +174,32 @@ pub(super) fn log_debug_context_impl(stmt: *mut PgStmt, exec_conn: *mut PgConnec stmt, exec_conn ); + let has_match = unsafe { + !stmt_ref.sql.is_null() + && (contains_icase_bytes(cstr_bytes(stmt_ref.sql), b"devices") + || contains_icase_bytes(cstr_bytes(stmt_ref.sql), b"library_sections")) + || !stmt_ref.pg_sql.is_null() + && (contains_icase_bytes(cstr_bytes(stmt_ref.pg_sql), b"devices") + || contains_icase_bytes(cstr_bytes(stmt_ref.pg_sql), b"library_sections")) + }; + if has_match { + log_debug_lazy!( + "STEP READ devices/library_sections: param_count={} is_pg={}", + stmt_ref.param_count, + stmt_ref.is_pg + ); + for i in 0..(stmt_ref.param_count as usize) { + if i < stmt_ref.param_values.len() { + let p = stmt_ref.param_values[i]; + log_debug_lazy!(" PARAM[{}]: {}", i, cstr_to_string_or(p, "NULL")); + } else { + log_debug_lazy!(" PARAM[{}]: OUT_OF_BOUNDS", i); + } + } + log_debug_lazy!(" SQL: {}", cstr_prefix(stmt_ref.sql, 1000, "NULL")); + if !stmt_ref.pg_sql.is_null() { + log_debug_lazy!(" PG_SQL: {}", cstr_prefix(stmt_ref.pg_sql, 1000, "NULL")); + } + } } } diff --git a/rust/plex-pg-core/src/db_interpose_step_write_utils/logging.rs b/rust/plex-pg-core/src/db_interpose_step_write_utils/logging.rs index 0c88e3b..a04b7d6 100644 --- a/rust/plex-pg-core/src/db_interpose_step_write_utils/logging.rs +++ b/rust/plex-pg-core/src/db_interpose_step_write_utils/logging.rs @@ -13,6 +13,32 @@ pub extern "C" fn rust_step_write_log_debug_context( let stmt = unsafe { &*pg_stmt }; unsafe { + let has_match = !stmt.sql.is_null() + && (contains_icase_bytes(cstr_bytes(stmt.sql), b"devices") + || contains_icase_bytes(cstr_bytes(stmt.sql), b"library_sections")) + || !stmt.pg_sql.is_null() + && (contains_icase_bytes(cstr_bytes(stmt.pg_sql), b"devices") + || contains_icase_bytes(cstr_bytes(stmt.pg_sql), b"library_sections")); + if has_match { + log_debug_lazy!( + "STEP WRITE devices/library_sections: param_count={} is_pg={}", + stmt.param_count, + stmt.is_pg + ); + for i in 0..(stmt.param_count as usize) { + if i < stmt.param_values.len() { + let p = stmt.param_values[i]; + log_debug_lazy!(" PARAM[{}]: {}", i, cstr_to_string_or(p, "NULL")); + } else { + log_debug_lazy!(" PARAM[{}]: OUT_OF_BOUNDS", i); + } + } + log_debug_lazy!(" SQL: {}", cstr_prefix(stmt.sql, 1000, "NULL")); + if !stmt.pg_sql.is_null() { + log_debug_lazy!(" PG_SQL: {}", cstr_prefix(stmt.pg_sql, 1000, "NULL")); + } + } + if !stmt.pg_sql.is_null() && contains_bytes(cstr_bytes(stmt.pg_sql), b"play_queue_generators") { diff --git a/rust/plex-pg-core/src/db_interpose_stmt_lifecycle/statement_ops.rs b/rust/plex-pg-core/src/db_interpose_stmt_lifecycle/statement_ops.rs index e5bfaaf..d6acbdb 100644 --- a/rust/plex-pg-core/src/db_interpose_stmt_lifecycle/statement_ops.rs +++ b/rust/plex-pg-core/src/db_interpose_stmt_lifecycle/statement_ops.rs @@ -96,6 +96,8 @@ pub(super) fn finalize_impl(p_stmt: *mut sqlite3_stmt) -> c_int { pg_stmt_ref.sql }; + remember_finalized_stmt(p_stmt, final_sql, is_pg_value); + let cached = pg_find_cached_stmt(p_stmt); if cached == pg_stmt { log_debug("finalize: stmt in both global and TLS, clearing TLS ref"); @@ -120,18 +122,22 @@ pub(super) fn finalize_impl(p_stmt: *mut sqlite3_stmt) -> c_int { cached_ref.sql }; } + + remember_finalized_stmt(p_stmt, final_sql, is_pg_value); + log_debug_lazy!( "finalize: stmt only in TLS (ref_count={}), clearing", cached_ref.ref_count.load(Ordering::Relaxed) ); pg_clear_cached_stmt(p_stmt); pg_stmt_unref(cached); - } - } - - if final_sql.is_null() { - if let Some(f) = orig_sqlite3_sql { - final_sql = f(p_stmt); + } else { + if final_sql.is_null() { + if let Some(f) = orig_sqlite3_sql { + final_sql = f(p_stmt); + } + } + remember_finalized_stmt(p_stmt, final_sql, is_pg_value); } } @@ -142,7 +148,6 @@ pub(super) fn finalize_impl(p_stmt: *mut sqlite3_stmt) -> c_int { .unwrap_or(SQLITE_ERROR); } clear_prepared_stmt(p_stmt); - remember_finalized_stmt(p_stmt, final_sql, is_pg_value); rc } } diff --git a/rust/plex-pg-core/src/runtime_linux.rs b/rust/plex-pg-core/src/runtime_linux.rs index f800b61..bc3aa47 100644 --- a/rust/plex-pg-core/src/runtime_linux.rs +++ b/rust/plex-pg-core/src/runtime_linux.rs @@ -19,9 +19,20 @@ type SigactionFn = unsafe extern "C" fn(c_int, *const libc::sigaction, *mut libc::sigaction) -> c_int; type CxaThrowFn = unsafe extern "C" fn(*mut c_void, *mut c_void, Option) -> !; +/// Pass-through hook for create_simple_converter (ASCII path handled at the +/// create_simple_codecvt level by the AArch64 asm hook below). +type CreateSimpleConverterFn = unsafe extern "C" fn(*mut u8) -> *mut c_void; static mut ORIG_SIGACTION: Option = None; static mut ORIG_CXA_THROW: Option = None; +static mut ORIG_CREATE_SIMPLE_CONVERTER: Option = None; + +/// Function-pointer statics for the AArch64 global_asm hook below. +/// #[no_mangle] makes them addressable by their exact name from assembler. +#[no_mangle] +pub static mut SHIM_CREATE_UTF8_CODECVT_PTR: usize = 0; +#[no_mangle] +pub static mut SHIM_CREATE_SIMPLE_CODECVT_PTR: usize = 0; static FORCE_IGNORE_SIGCHLD: AtomicI32 = AtomicI32::new(1); static INTERCEPT_SIGACTION: AtomicI32 = AtomicI32::new(1); @@ -75,6 +86,37 @@ unsafe fn resolve_interposition_hooks() { Some(std::mem::transmute::<*mut c_void, CxaThrowFn>(sym)), ); } + + // create_simple_converter — pass-through hook (ASCII handled at codecvt level) + let sym = libc::dlsym( + libc::RTLD_NEXT, + b"_ZN5boost6locale4util23create_simple_converterERKNSt3__212basic_stringIcNS2_11char_traitsIcEENS2_9allocatorIcEEEE\0".as_ptr() as *const c_char + ); + if !sym.is_null() { + ptr::write( + ptr::addr_of_mut!(ORIG_CREATE_SIMPLE_CONVERTER), + Some(std::mem::transmute::<*mut c_void, CreateSimpleConverterFn>(sym)), + ); + } + + // create_simple_codecvt — original target for the asm hook pass-through path. + // Stored as a raw usize so the AArch64 assembler can read it directly. + let sym = libc::dlsym( + libc::RTLD_NEXT, + b"_ZN5boost6locale4util21create_simple_codecvtERKNSt3__26localeERKNS2_12basic_stringIcNS2_11char_traitsIcEENS2_9allocatorIcEEEENS0_12char_facet_tE\0".as_ptr() as *const c_char + ); + if !sym.is_null() { + ptr::write(ptr::addr_of_mut!(SHIM_CREATE_SIMPLE_CODECVT_PTR), sym as usize); + } + + // create_utf8_codecvt — ASCII redirect target for the asm hook. + let sym = libc::dlsym( + libc::RTLD_NEXT, + b"_ZN5boost6locale4util19create_utf8_codecvtERKNSt3__26localeENS0_12char_facet_tE\0".as_ptr() as *const c_char + ); + if !sym.is_null() { + ptr::write(ptr::addr_of_mut!(SHIM_CREATE_UTF8_CODECVT_PTR), sym as usize); + } } #[allow(static_mut_refs)] @@ -484,10 +526,93 @@ static INIT: extern "C" fn() = shim_init_wrapper; static FINI: extern "C" fn() = shim_cleanup_wrapper; // ──────────────────────────────────────────────────────────────────────────── -// LD_PRELOAD wrappers — only compiled when the `interpose` feature is active. -// This avoids duplicate-symbol errors with rusqlite's bundled sqlite3 in -// test targets AND bin targets. The shared-library (.so) build enables -// `interpose` explicitly via `--features interpose`. +// ──────────────────────────────────────────────────────────────────────────── +// AArch64 assembly hook for boost::locale::util::create_simple_codecvt. +// +// Why assembly? On AArch64 the Itanium C++ ABI returns std::locale (a +// non-trivially copyable 8-byte struct) via the x8 "indirect result" +// register (SRET), NOT in x0. Plex's Boost saves x8 on entry, so any Rust +// wrapper that clobbers x8 before forwarding the call writes the new locale +// object to a garbage address → SIGSEGV in locale::operator=. +// +// This hook: +// - Saves x8 + all args on the stack without touching x8 mid-flight. +// - Detects ASCII in x1 (the string ptr) and redirects to create_utf8_codecvt +// (x0=locale, x1=facet, x8 restored from stack). +// - Falls through to the original create_simple_codecvt otherwise. +// ──────────────────────────────────────────────────────────────────────────── +#[cfg(all(feature = "interpose", target_arch = "aarch64"))] +std::arch::global_asm!( + // Export the symbol so LD_PRELOAD interposition takes effect. + ".global _ZN5boost6locale4util21create_simple_codecvtERKNSt3__26localeERKNS2_12basic_stringIcNS2_11char_traitsIcEENS2_9allocatorIcEEEENS0_12char_facet_tE", + ".type _ZN5boost6locale4util21create_simple_codecvtERKNSt3__26localeERKNS2_12basic_stringIcNS2_11char_traitsIcEENS2_9allocatorIcEEEENS0_12char_facet_tE, %function", + "_ZN5boost6locale4util21create_simple_codecvtERKNSt3__26localeERKNS2_12basic_stringIcNS2_11char_traitsIcEENS2_9allocatorIcEEEENS0_12char_facet_tE:", + // ABI on entry: + // x8 = SRET pointer (output locale buffer allocated by caller) + // x0 = const std::locale& in (input locale) + // x1 = const std::string& encoding + // x2 = char_facet_t type + // x30 = return address + // + // Stack frame layout (48 bytes, 16-byte aligned): + // [sp+0] x29 (frame ptr) [sp+8] x30 (lr) + // [sp+16] x8 (SRET ptr) + // [sp+24] x0 (locale ptr) + // [sp+32] x1 (string ptr) [sp+40] x2 (facet) + "stp x29, x30, [sp, #-48]!", + "mov x29, sp", + "str x8, [sp, #16]", + "str x0, [sp, #24]", + "stp x1, x2, [sp, #32]", + // Check: is *x1 == 'A','S','C','I','I','\0'? + "ldrb w9, [x1]", + "cmp w9, #65", + "b.ne .Lshim_csc_orig", + "ldrb w9, [x1, #1]", + "cmp w9, #83", + "b.ne .Lshim_csc_orig", + "ldrb w9, [x1, #2]", + "cmp w9, #67", + "b.ne .Lshim_csc_orig", + "ldrb w9, [x1, #3]", + "cmp w9, #73", + "b.ne .Lshim_csc_orig", + "ldrb w9, [x1, #4]", + "cmp w9, #73", + "b.ne .Lshim_csc_orig", + "ldrb w9, [x1, #5]", + "cbnz w9, .Lshim_csc_orig", + // ASCII detected — GOT-indirect load of fn ptr, then tail-call + // create_utf8_codecvt(locale, facet) with x8 = original SRET pointer. + // We must use the GOT (:got: / :got_lo12:) because SHIM_* are exported + // symbols; direct ADRP generates R_AARCH64_ADR_PREL_PG_HI21 which the + // linker rejects in a PIC shared object. + "adrp x9, :got:SHIM_CREATE_UTF8_CODECVT_PTR", + "ldr x9, [x9, :got_lo12:SHIM_CREATE_UTF8_CODECVT_PTR]", + "ldr x9, [x9]", // x9 = fn-ptr value + "cbz x9, .Lshim_csc_orig", // if not resolved, fall through + "ldr x0, [sp, #24]", // locale ptr + "ldr x1, [sp, #40]", // facet (was x2) + "ldr x8, [sp, #16]", // restore SRET! + "ldp x29, x30, [sp], #48", + "br x9", // tail call + ".Lshim_csc_orig:", + // Not ASCII — tail-call original create_simple_codecvt unchanged. + "adrp x9, :got:SHIM_CREATE_SIMPLE_CODECVT_PTR", + "ldr x9, [x9, :got_lo12:SHIM_CREATE_SIMPLE_CODECVT_PTR]", + "ldr x9, [x9]", // x9 = fn-ptr value + "cbz x9, .Lshim_csc_abort", + "ldr x0, [sp, #24]", + "ldr x1, [sp, #32]", + "ldr x2, [sp, #40]", + "ldr x8, [sp, #16]", // restore SRET! + "ldp x29, x30, [sp], #48", + "br x9", + ".Lshim_csc_abort:", + // Resolver didn't run — hard abort. + "bl abort", +); + // ──────────────────────────────────────────────────────────────────────────── #[cfg(feature = "interpose")] mod ld_preload_wrappers { @@ -810,4 +935,31 @@ mod ld_preload_wrappers { ) -> *const c_char { c_abi::my_sqlite3_column_decltype(stmt, idx) } + + // Simple pass-through: let create_simple_converter proceed normally. + // The ASCII → UTF-8 redirect is now handled exclusively at the + // create_simple_codecvt level by the AArch64 global_asm hook above. + #[no_mangle] + #[allow(static_mut_refs)] + pub unsafe extern "C" fn _ZN5boost6locale4util23create_simple_converterERKNSt3__212basic_stringIcNS2_11char_traitsIcEENS2_9allocatorIcEEEE( + s: *mut u8, + ) -> *mut c_void { + let orig = match ptr::read(ptr::addr_of!(ORIG_CREATE_SIMPLE_CONVERTER)) { + Some(f) => f, + None => { + let name = b"_ZN5boost6locale4util23create_simple_converterERKNSt3__212basic_stringIcNS2_11char_traitsIcEENS2_9allocatorIcEEEE\0"; + let sym = libc::dlsym(libc::RTLD_NEXT, name.as_ptr() as *const c_char); + if sym.is_null() { + libc::abort(); + } + let f = std::mem::transmute::<*mut c_void, CreateSimpleConverterFn>(sym); + ptr::write(ptr::addr_of_mut!(ORIG_CREATE_SIMPLE_CONVERTER), Some(f)); + f + } + }; + orig(s) + } + // Note: create_simple_codecvt is implemented as a global_asm hook above + // (AArch64 only) to correctly preserve the x8 SRET pointer while + // redirecting ASCII charset requests to create_utf8_codecvt. } // mod ld_preload_wrappers diff --git a/schema/pg_compat_functions.sql b/schema/pg_compat_functions.sql index 58755bb..5b14754 100644 --- a/schema/pg_compat_functions.sql +++ b/schema/pg_compat_functions.sql @@ -41,3 +41,48 @@ BEGIN RETURN result; END; $fn$; + +CREATE OR REPLACE FUNCTION public.json_valid(val text) +RETURNS boolean AS $$ +BEGIN + IF val IS NULL THEN + RETURN NULL; + END IF; + PERFORM val::json; + RETURN TRUE; +EXCEPTION WHEN OTHERS THEN + RETURN FALSE; +END; +$$ LANGUAGE plpgsql IMMUTABLE; + +CREATE OR REPLACE FUNCTION public.eq_bool_int(b boolean, i integer) +RETURNS boolean AS $$ +BEGIN + RETURN (b = (i <> 0)); +END; +$$ LANGUAGE plpgsql IMMUTABLE; + +CREATE OR REPLACE FUNCTION public.eq_int_bool(i integer, b boolean) +RETURNS boolean AS $$ +BEGIN + RETURN ((i <> 0) = b); +END; +$$ LANGUAGE plpgsql IMMUTABLE; + +DROP OPERATOR IF EXISTS public.= (boolean, integer); +CREATE OPERATOR public.= ( + PROCEDURE = public.eq_bool_int, + LEFTARG = boolean, + RIGHTARG = integer, + COMMUTATOR = = +); + +DROP OPERATOR IF EXISTS public.= (integer, boolean); +CREATE OPERATOR public.= ( + PROCEDURE = public.eq_int_bool, + LEFTARG = integer, + RIGHTARG = boolean, + COMMUTATOR = = +); + + diff --git a/schema/seed_data.sql b/schema/seed_data.sql new file mode 100644 index 0000000..e721560 --- /dev/null +++ b/schema/seed_data.sql @@ -0,0 +1,95 @@ +-- Seed initial/default data for a fresh Plex PostgreSQL database. + +-- accounts +INSERT INTO plex.accounts (id, name, created_at, updated_at, default_audio_language, default_subtitle_language, auto_select_subtitle, auto_select_audio) +VALUES (1, 'Administrator', 1289520473, 1782210228, '', '', 1, 1); + +-- activities +INSERT INTO plex.activities (id, parent_id, type, title, subtitle, scheduled_at, started_at, finished_at, cancelled) +VALUES (1, NULL, 'provider.subscriptions.process', 'Processing subscriptions', '', NULL, 1782210228, 1782210238, 0); +INSERT INTO plex.activities (id, parent_id, type, title, subtitle, scheduled_at, started_at, finished_at, cancelled) +VALUES (2, NULL, 'provider.subscriptions.process', 'Processing subscriptions', '', NULL, 1782210238, 1782210238, 0); + +-- devices +INSERT INTO plex.devices (id, identifier, name, created_at, updated_at, platform) +VALUES (1, 'd370b991-6002-4b8b-a4e2-5ec054c4dc4b', '', 1782210228, 1782210228, ''); + +-- metadata_agent_providers +INSERT INTO plex.metadata_agent_providers (id, identifier, title, uri, agent_type, metadata_types, online, created_at, updated_at, extra_data) +VALUES (1, 'tv.plex.agents.movie', 'Plex Movie', 'provider://tv.plex.provider.metadata', 0, '1', 1, 1782210228, 1782210231, NULL); +INSERT INTO plex.metadata_agent_providers (id, identifier, title, uri, agent_type, metadata_types, online, created_at, updated_at, extra_data) +VALUES (2, 'tv.plex.agents.series', 'Plex Series', 'provider://tv.plex.provider.metadata', 0, '2,3,4', 1, 1782210228, 1782210231, NULL); +INSERT INTO plex.metadata_agent_providers (id, identifier, title, uri, agent_type, metadata_types, online, created_at, updated_at, extra_data) +VALUES (3, 'tv.plex.agents.music', 'Plex Music', 'provider://tv.plex.provider.metadata', 0, '8,9,10', 1, 1782210228, 1782210231, NULL); +INSERT INTO plex.metadata_agent_providers (id, identifier, title, uri, agent_type, metadata_types, online, created_at, updated_at, extra_data) +VALUES (4, 'org.musicbrainz.agents.music', 'MusicBrainz', 'provider://tv.plex.provider.metadata', 2, '8,9,10', 1, 1782210228, 1782210231, NULL); +INSERT INTO plex.metadata_agent_providers (id, identifier, title, uri, agent_type, metadata_types, online, created_at, updated_at, extra_data) +VALUES (5, 'tv.plex.agents.none', 'Plex Personal Media', '', 0, '1,2,3,4,8,9,10,13,14,20,21,22', 1, 1782210228, 1782210228, NULL); +INSERT INTO plex.metadata_agent_providers (id, identifier, title, uri, agent_type, metadata_types, online, created_at, updated_at, extra_data) +VALUES (6, 'tv.plex.agents.localmedia', 'Plex Local Media', '', 1, '1,2,3,4,8,9,10,13,14,20,21,22', 0, 1782210228, 1782210228, NULL); + +-- metadata_agent_provider_groups +INSERT INTO plex.metadata_agent_provider_groups (id, title, primary_identifier, created_at, updated_at, extra_data) +VALUES (1, 'Plex Movie', 'tv.plex.agents.movie', 1782210228, 1782210228, '{"at:builtIn":"1","url":"at%3AbuiltIn=1"}'); +INSERT INTO plex.metadata_agent_provider_groups (id, title, primary_identifier, created_at, updated_at, extra_data) +VALUES (2, 'Plex Series', 'tv.plex.agents.series', 1782210228, 1782210228, '{"at:builtIn":"1","url":"at%3AbuiltIn=1"}'); +INSERT INTO plex.metadata_agent_provider_groups (id, title, primary_identifier, created_at, updated_at, extra_data) +VALUES (3, 'Plex Music', 'tv.plex.agents.music', 1782210228, 1782210228, '{"at:builtIn":"1","url":"at%3AbuiltIn=1"}'); +INSERT INTO plex.metadata_agent_provider_groups (id, title, primary_identifier, created_at, updated_at, extra_data) +VALUES (4, 'Plex Personal Media', 'tv.plex.agents.none', 1782210228, 1782210228, '{"at:builtIn":"1","url":"at%3AbuiltIn=1"}'); + +-- metadata_agent_provider_group_items +INSERT INTO plex.metadata_agent_provider_group_items (id, metadata_agent_provider_group_id, metadata_agent_provider_id, "order") +VALUES (1, 1, 1, 1000.0); +INSERT INTO plex.metadata_agent_provider_group_items (id, metadata_agent_provider_group_id, metadata_agent_provider_id, "order") +VALUES (2, 2, 2, 1000.0); +INSERT INTO plex.metadata_agent_provider_group_items (id, metadata_agent_provider_group_id, metadata_agent_provider_id, "order") +VALUES (3, 3, 3, 1000.0); +INSERT INTO plex.metadata_agent_provider_group_items (id, metadata_agent_provider_group_id, metadata_agent_provider_id, "order") +VALUES (4, 4, 5, 1000.0); + +-- plugin_prefixes +INSERT INTO plex.plugin_prefixes (id, plugin_id, name, prefix, art_url, thumb_url, titlebar_url, share, has_store_services, prefs) +VALUES (3, 1, 'Player', '/player', '', '/:/plugins/com.plexapp.system/resources/icon-default.png?t=1777730092', '', 0, 0, 1); +INSERT INTO plex.plugin_prefixes (id, plugin_id, name, prefix, art_url, thumb_url, titlebar_url, share, has_store_services, prefs) +VALUES (4, 1, 'System', '/system', '', '/:/plugins/com.plexapp.system/resources/icon-default.png?t=1777730092', '', 0, 0, 1); + +-- plugins +INSERT INTO plex.plugins (id, identifier, framework_version, access_count, installed_at, accessed_at, modified_at) +VALUES (1, 'com.plexapp.system', 2, NULL, 1782210228, 1782210228, 1777730092); +INSERT INTO plex.plugins (id, identifier, framework_version, access_count, installed_at, accessed_at, modified_at) +VALUES (2, 'com.plexapp.agents.lyricfind', 2, NULL, 1782210229, 1782210228, 1777730092); +INSERT INTO plex.plugins (id, identifier, framework_version, access_count, installed_at, accessed_at, modified_at) +VALUES (3, 'com.plexapp.agents.none', 2, NULL, 1782210229, 1782210228, 1777730092); +INSERT INTO plex.plugins (id, identifier, framework_version, access_count, installed_at, accessed_at, modified_at) +VALUES (4, 'com.plexapp.agents.plexthememusic', 2, NULL, 1782210229, 1782210228, 1777730092); +INSERT INTO plex.plugins (id, identifier, framework_version, access_count, installed_at, accessed_at, modified_at) +VALUES (5, 'org.musicbrainz.agents.music', 2, NULL, 1782210230, 1782210229, 1777730092); +INSERT INTO plex.plugins (id, identifier, framework_version, access_count, installed_at, accessed_at, modified_at) +VALUES (6, 'com.plexapp.agents.lastfm', 2, NULL, 1782210250, 1782210229, 1777730092); +INSERT INTO plex.plugins (id, identifier, framework_version, access_count, installed_at, accessed_at, modified_at) +VALUES (7, 'com.plexapp.agents.themoviedb', 2, NULL, 1782210250, 1782210229, 1777730092); + +-- preferences +INSERT INTO plex.preferences (id, name, value) +VALUES (2, 'SyncedNeedsChangedAtUpdate', '0'); + +-- tags +INSERT INTO plex.tags (id, metadata_item_id, tag, tag_type, user_thumb_url, user_art_url, user_music_url, created_at, updated_at, tag_value, extra_data, key, parent_id) +VALUES (1, NULL, 'Optimized for Mobile', 42, '', '', '', 1782210228, 1782210228, NULL, '{"sr:deviceProfile":"Universal Mobile","sr:mediaSettings":"advancedSubtitles=burn&autoAdjustQuality=0&autoAdjustSubtitle=0&boostDialog=0&directPlay=1&directStream=1&directStreamAudio=1&normalizeLoudness=0&subtitles=auto&videoBitrate=4000&videoQuality=74&videoResolution=1280x720","url":"sr%3AdeviceProfile=Universal%20Mobile&sr%3AmediaSettings=advancedSubtitles%3Dburn%26autoAdjustQuality%3D0%26autoAdjustSubtitle%3D0%26boostDialog%3D0%26directPlay%3D1%26directStream%3D1%26directStreamAudio%3D1%26normalizeLoudness%3D0%26subtitles%3Dauto%26videoBitrate%3D4000%26videoQuality%3D74%26videoResolution%3D1280x720"}', '', NULL); +INSERT INTO plex.tags (id, metadata_item_id, tag, tag_type, user_thumb_url, user_art_url, user_music_url, created_at, updated_at, tag_value, extra_data, key, parent_id) +VALUES (2, NULL, 'Optimized for TV', 42, '', '', '', 1782210228, 1782210228, NULL, '{"sr:deviceProfile":"Universal TV","sr:mediaSettings":"advancedSubtitles=burn&autoAdjustQuality=0&autoAdjustSubtitle=0&boostDialog=0&directPlay=1&directStream=1&directStreamAudio=1&normalizeLoudness=0&subtitles=auto&videoBitrate=8000&videoQuality=99&videoResolution=1920x1080","url":"sr%3AdeviceProfile=Universal%20TV&sr%3AmediaSettings=advancedSubtitles%3Dburn%26autoAdjustQuality%3D0%26autoAdjustSubtitle%3D0%26boostDialog%3D0%26directPlay%3D1%26directStream%3D1%26directStreamAudio%3D1%26normalizeLoudness%3D0%26subtitles%3Dauto%26videoBitrate%3D8000%26videoQuality%3D99%26videoResolution%3D1920x1080"}', '', NULL); +INSERT INTO plex.tags (id, metadata_item_id, tag, tag_type, user_thumb_url, user_art_url, user_music_url, created_at, updated_at, tag_value, extra_data, key, parent_id) +VALUES (3, NULL, 'Original Quality', 42, '', '', '', 1782210228, 1782210228, NULL, '{"sr:deviceProfile":"Universal TV","sr:mediaSettings":"advancedSubtitles=burn&autoAdjustQuality=0&autoAdjustSubtitle=0&boostDialog=0&directPlay=1&directStream=1&directStreamAudio=1&normalizeLoudness=0&subtitles=auto","url":"sr%3AdeviceProfile=Universal%20TV&sr%3AmediaSettings=advancedSubtitles%3Dburn%26autoAdjustQuality%3D0%26autoAdjustSubtitle%3D0%26boostDialog%3D0%26directPlay%3D1%26directStream%3D1%26directStreamAudio%3D1%26normalizeLoudness%3D0%26subtitles%3Dauto"}', '', NULL); + +-- Reset serial sequences so future inserts don't collide. +SELECT setval('plex.accounts_id_seq', COALESCE((SELECT max(id) FROM plex.accounts), 1)); +SELECT setval('plex.activities_id_seq', COALESCE((SELECT max(id) FROM plex.activities), 1)); +SELECT setval('plex.devices_id_seq', COALESCE((SELECT max(id) FROM plex.devices), 1)); +SELECT setval('plex.metadata_agent_providers_id_seq', COALESCE((SELECT max(id) FROM plex.metadata_agent_providers), 1)); +SELECT setval('plex.metadata_agent_provider_groups_id_seq', COALESCE((SELECT max(id) FROM plex.metadata_agent_provider_groups), 1)); +SELECT setval('plex.metadata_agent_provider_group_items_id_seq', COALESCE((SELECT max(id) FROM plex.metadata_agent_provider_group_items), 1)); +SELECT setval('plex.plugin_prefixes_id_seq', COALESCE((SELECT max(id) FROM plex.plugin_prefixes), 1)); +SELECT setval('plex.plugins_id_seq', COALESCE((SELECT max(id) FROM plex.plugins), 1)); +SELECT setval('plex.preferences_id_seq', COALESCE((SELECT max(id) FROM plex.preferences), 1)); +SELECT setval('plex.tags_id_seq', COALESCE((SELECT max(id) FROM plex.tags), 1)); diff --git a/scripts/standalone-entrypoint.sh b/scripts/standalone-entrypoint.sh index 818205b..3f1791a 100644 --- a/scripts/standalone-entrypoint.sh +++ b/scripts/standalone-entrypoint.sh @@ -113,6 +113,13 @@ init_schema() { # which causes DDL/schema divergence issues with the SQLite shadow DB. local migration_count=$(psql -t -c "SELECT COUNT(*) FROM ${schema}.schema_migrations;" 2>/dev/null | tr -d ' ') echo "schema_migrations has $migration_count entries (kept from dump, shim handles duplicates)" + + # Load default seed data + local seed_file="$SHIM_DIR/seed_data.sql" + if [ -f "$seed_file" ]; then + echo "Loading default seed data..." + psql -f "$seed_file" 2>/dev/null || true + fi else echo "WARNING: Schema load had errors, continuing anyway..." fi @@ -342,6 +349,11 @@ if [ -n "$PLEX_PG_HOST" ]; then rm -rf "${crash_dir:?}/"* echo "Cleaned crash reports (prevents CrashUploader invocation)" fi + + # Final permission fix - ensure Plex can write to its directories (including shadow DB folders) + # This must be done after all directories and shadow database files are created. + echo "Fixing final permissions..." + chown -R plex:plex "/config/Library/Application Support/Plex Media Server" 2>/dev/null || chown -R abc:abc "/config/Library/Application Support/Plex Media Server" 2>/dev/null || true else echo "PLEX_PG_HOST not set, skipping PostgreSQL initialization" fi