From 4121d568c8313ecf508cd458446d154718e87ff7 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 4 Mar 2026 14:59:11 +0100 Subject: [PATCH] fix: sanitize mpv event channel strings for valid UTF-8 --- .../com/edde746/plezy/mpv/MpvPlayerCore.kt | 23 +++++++-- ios/Runner/MpvPlayer/MpvPlayerCore.swift | 27 +++++++--- linux/CMakeLists.txt | 11 ++++ linux/runner/CMakeLists.txt | 8 +++ linux/runner/mpv/mpv_player.cc | 49 ++++++++++++++++-- macos/Runner/MpvPlayer/MpvPlayerCore.swift | 27 +++++++--- windows/CMakeLists.txt | 11 ++++ windows/runner/CMakeLists.txt | 8 +++ windows/runner/mpv/mpv_player.cpp | 50 +++++++++++++++++-- 9 files changed, 190 insertions(+), 24 deletions(-) diff --git a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt index d1d944eb..735fecd2 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt @@ -35,6 +35,20 @@ class MpvPlayerCore(private val activity: Activity) : // Guards MPVLib.create/destroy which share global native state private val mpvLock = Object() + + // JNI's NewStringUTF may produce Java Strings with lone surrogates when + // mpv passes invalid UTF-8 (log messages, system-encoded paths, etc.). + // Lone surrogates cause FormatException in Flutter's StandardMessageCodec + // when it encodes them as UTF-8. Re-encoding strips these invalid chars. + private fun safeString(s: String): String { + for (c in s) { + if (c.isSurrogate()) { + // Slow path: re-encode through UTF-8 to replace lone surrogates with U+FFFD + return s.encodeToByteArray().decodeToString() + } + } + return s // Fast path: no surrogates, string is safe + } } private var surfaceView: SurfaceView? = null @@ -344,8 +358,9 @@ class MpvPlayerCore(private val activity: Activity) : } override fun eventProperty(property: String, value: String) { + val safe = safeString(value) activity.runOnUiThread { - delegate?.onPropertyChange(property, value) + delegate?.onPropertyChange(property, safe) } } @@ -376,11 +391,13 @@ class MpvPlayerCore(private val activity: Activity) : MPVLib.MPV_LOG_LEVEL_TRACE -> "trace" else -> "info" } + val safePrefix = safeString(prefix) + val safeText = safeString(text) activity.runOnUiThread { delegate?.onEvent("log-message", mapOf( - "prefix" to prefix, + "prefix" to safePrefix, "level" to levelStr, - "text" to text + "text" to safeText )) } } diff --git a/ios/Runner/MpvPlayer/MpvPlayerCore.swift b/ios/Runner/MpvPlayer/MpvPlayerCore.swift index 9519307e..04b0ea60 100644 --- a/ios/Runner/MpvPlayer/MpvPlayerCore.swift +++ b/ios/Runner/MpvPlayer/MpvPlayerCore.swift @@ -35,6 +35,21 @@ private class MetalLayer: CAMetalLayer { } } +/// Safely convert a C string to Swift String with UTF-8 validation. +/// Falls back to Latin-1 decoding if the bytes are not valid UTF-8. +/// mpv does not guarantee UTF-8 for log messages, error strings, or +/// system-encoded paths — sending invalid UTF-8 through Flutter's +/// StandardMessageCodec causes FormatException crashes. +private func safeString(_ cstr: UnsafePointer) -> String { + if let s = String(validatingUTF8: cstr) { + return s + } + // Latin-1 fallback: interpret each byte as its Unicode scalar + let len = strlen(cstr) + let buf = UnsafeBufferPointer(start: UnsafeRawPointer(cstr).assumingMemoryBound(to: UInt8.self), count: len) + return String(buf.map { Character(Unicode.Scalar($0)) }) +} + /// Core MPV player using Metal rendering for iOS class MpvPlayerCore: NSObject { @@ -523,9 +538,9 @@ class MpvPlayerCore: NSObject { case MPV_EVENT_LOG_MESSAGE: if let msgPtr = event.data?.assumingMemoryBound(to: mpv_event_log_message.self) { let msg = msgPtr.pointee - let prefix = msg.prefix.map { String(cString: $0) } ?? "" - let level = msg.level.map { String(cString: $0) } ?? "" - let text = msg.text.map { String(cString: $0) } ?? "" + let prefix = msg.prefix.map { safeString($0) } ?? "" + let level = msg.level.map { safeString($0) } ?? "" + let text = msg.text.map { safeString($0) } ?? "" DispatchQueue.main.async { self.delegate?.onEvent(name: "log-message", data: [ @@ -564,7 +579,7 @@ class MpvPlayerCore: NSObject { case MPV_FORMAT_STRING: if let ptr = property.data { let cstr = ptr.assumingMemoryBound(to: UnsafePointer?.self).pointee - value = cstr.map { String(cString: $0) } + value = cstr.map { safeString($0) } } default: @@ -611,7 +626,7 @@ class MpvPlayerCore: NSObject { private func convertNode(_ node: mpv_node) -> Any? { switch node.format { case MPV_FORMAT_STRING: - return node.u.string.map { String(cString: $0) } + return node.u.string.map { safeString($0) } case MPV_FORMAT_FLAG: return node.u.flag != 0 @@ -636,7 +651,7 @@ class MpvPlayerCore: NSObject { guard let list = node.u.list?.pointee else { return nil } var dict = [String: Any]() for i in 0.. #include #include +#include + +// Sanitize a C string that may contain invalid UTF-8 sequences. +// Uses simdutf for SIMD-accelerated validation (fast path for valid strings), +// then falls back to iterative replacement with U+FFFD on the rare invalid case. +// mpv does not guarantee UTF-8 for log messages, error strings, or +// system-encoded paths — sending these unsanitized through Flutter's +// StandardMessageCodec causes FormatException crashes. +static std::string SanitizeUtf8(const char* input) { + if (!input) return std::string(); + size_t len = strlen(input); + if (len == 0) return std::string(); + + // Fast path: SIMD-accelerated validation — almost all strings pass this + if (simdutf::validate_utf8(input, len)) { + return std::string(input, len); + } + + // Slow path: find each invalid position, copy valid prefix, insert U+FFFD, + // skip the bad byte, and repeat. + std::string result; + result.reserve(len); + size_t pos = 0; + + while (pos < len) { + auto r = simdutf::validate_utf8_with_errors(input + pos, len - pos); + // Copy the valid prefix up to the error + if (r.count > 0) { + result.append(input + pos, r.count); + } + pos += r.count; + if (r.error == simdutf::error_code::SUCCESS) { + break; // remaining tail is valid + } + // Replace the invalid byte with U+FFFD and skip it + result.append("\xEF\xBF\xBD"); + pos++; + } + + return result; +} // Flutter on Linux uses EGL (OpenGL ES) for both X11 and Wayland. static void* get_opengl_proc_address(void* ctx, const char* name) { @@ -483,11 +524,11 @@ void MpvPlayer::HandleMpvEvent(mpv_event* event) { FlValue* data = fl_value_new_map(); fl_value_set_string_take(data, "prefix", - fl_value_new_string(msg->prefix ? msg->prefix : "")); + fl_value_new_string(SanitizeUtf8(msg->prefix).c_str())); fl_value_set_string_take(data, "level", - fl_value_new_string(msg->level ? msg->level : "")); + fl_value_new_string(SanitizeUtf8(msg->level).c_str())); fl_value_set_string_take(data, "text", - fl_value_new_string(msg->text ? msg->text : "")); + fl_value_new_string(SanitizeUtf8(msg->text).c_str())); SendEvent("log-message", data); fl_value_unref(data); break; @@ -559,7 +600,7 @@ FlValue* MpvPlayer::NodeToFlValue(mpv_node* node) { switch (node->format) { case MPV_FORMAT_STRING: - return fl_value_new_string(node->u.string ? node->u.string : ""); + return fl_value_new_string(SanitizeUtf8(node->u.string).c_str()); case MPV_FORMAT_FLAG: return fl_value_new_bool(node->u.flag != 0); case MPV_FORMAT_INT64: diff --git a/macos/Runner/MpvPlayer/MpvPlayerCore.swift b/macos/Runner/MpvPlayer/MpvPlayerCore.swift index 0a2dfeb0..96563a0c 100644 --- a/macos/Runner/MpvPlayer/MpvPlayerCore.swift +++ b/macos/Runner/MpvPlayer/MpvPlayerCore.swift @@ -35,6 +35,21 @@ private class MetalLayer: CAMetalLayer { } } +/// Safely convert a C string to Swift String with UTF-8 validation. +/// Falls back to Latin-1 decoding if the bytes are not valid UTF-8. +/// mpv does not guarantee UTF-8 for log messages, error strings, or +/// system-encoded paths — sending invalid UTF-8 through Flutter's +/// StandardMessageCodec causes FormatException crashes. +private func safeString(_ cstr: UnsafePointer) -> String { + if let s = String(validatingUTF8: cstr) { + return s + } + // Latin-1 fallback: interpret each byte as its Unicode scalar + let len = strlen(cstr) + let buf = UnsafeBufferPointer(start: UnsafeRawPointer(cstr).assumingMemoryBound(to: UInt8.self), count: len) + return String(buf.map { Character(Unicode.Scalar($0)) }) +} + /// Core MPV player using Metal rendering class MpvPlayerCore: NSObject { @@ -506,9 +521,9 @@ class MpvPlayerCore: NSObject { case MPV_EVENT_LOG_MESSAGE: if let msgPtr = event.data?.assumingMemoryBound(to: mpv_event_log_message.self) { let msg = msgPtr.pointee - let prefix = msg.prefix.map { String(cString: $0) } ?? "" - let level = msg.level.map { String(cString: $0) } ?? "" - let text = msg.text.map { String(cString: $0) } ?? "" + let prefix = msg.prefix.map { safeString($0) } ?? "" + let level = msg.level.map { safeString($0) } ?? "" + let text = msg.text.map { safeString($0) } ?? "" DispatchQueue.main.async { self.delegate?.onEvent(name: "log-message", data: [ @@ -547,7 +562,7 @@ class MpvPlayerCore: NSObject { case MPV_FORMAT_STRING: if let ptr = property.data { let cstr = ptr.assumingMemoryBound(to: UnsafePointer?.self).pointee - value = cstr.map { String(cString: $0) } + value = cstr.map { safeString($0) } } default: @@ -592,7 +607,7 @@ class MpvPlayerCore: NSObject { private func convertNode(_ node: mpv_node) -> Any? { switch node.format { case MPV_FORMAT_STRING: - return node.u.string.map { String(cString: $0) } + return node.u.string.map { safeString($0) } case MPV_FORMAT_FLAG: return node.u.flag != 0 @@ -617,7 +632,7 @@ class MpvPlayerCore: NSObject { guard let list = node.u.list?.pointee else { return nil } var dict = [String: Any]() for i in 0.. #include +#include + +// Sanitize a C string that may contain invalid UTF-8 sequences. +// Uses simdutf for SIMD-accelerated validation (fast path for valid strings), +// then falls back to iterative replacement with U+FFFD on the rare invalid case. +// mpv does not guarantee UTF-8 for log messages, error strings, or +// system-encoded paths — sending these unsanitized through Flutter's +// StandardMessageCodec causes FormatException crashes. +static std::string SanitizeUtf8(const char* input) { + if (!input) return std::string(); + size_t len = strlen(input); + if (len == 0) return std::string(); + + // Fast path: SIMD-accelerated validation — almost all strings pass this + if (simdutf::validate_utf8(input, len)) { + return std::string(input, len); + } + + // Slow path: find each invalid position, copy valid prefix, insert U+FFFD, + // skip the bad byte, and repeat. + std::string result; + result.reserve(len); + size_t pos = 0; + + while (pos < len) { + auto r = simdutf::validate_utf8_with_errors(input + pos, len - pos); + // Copy the valid prefix up to the error + if (r.count > 0) { + result.append(input + pos, r.count); + } + pos += r.count; + if (r.error == simdutf::error_code::SUCCESS) { + break; // remaining tail is valid + } + // Replace the invalid byte with U+FFFD and skip it + result.append("\xEF\xBF\xBD"); + pos++; + } + + return result; +} static void LogToFile(const char* message) { std::ofstream log("C:\\Users\\admin\\mpv_debug.log", std::ios::app); @@ -349,11 +390,11 @@ void MpvPlayer::HandleMpvEvent(mpv_event* event) { flutter::EncodableMap data; data[flutter::EncodableValue("prefix")] = - flutter::EncodableValue(msg->prefix ? msg->prefix : ""); + flutter::EncodableValue(SanitizeUtf8(msg->prefix)); data[flutter::EncodableValue("level")] = - flutter::EncodableValue(msg->level ? msg->level : ""); + flutter::EncodableValue(SanitizeUtf8(msg->level)); data[flutter::EncodableValue("text")] = - flutter::EncodableValue(msg->text ? msg->text : ""); + flutter::EncodableValue(SanitizeUtf8(msg->text)); SendEvent("log-message", data); break; } @@ -435,8 +476,7 @@ void MpvPlayer::SendPropertyChange(const char* name, mpv_node* data) { if (data) { switch (data->format) { case MPV_FORMAT_STRING: - value = flutter::EncodableValue( - data->u.string ? std::string(data->u.string) : std::string()); + value = flutter::EncodableValue(SanitizeUtf8(data->u.string)); break; case MPV_FORMAT_FLAG: value = flutter::EncodableValue(data->u.flag != 0);