mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 17:10:50 -10:00
fix(pet): validate whole shared frames natively and bound Apple reads
P01-06 (Refs #6561). The native Shared-mode views render frames polled from the companion's loopback port; after the owner exits any local process can bind that port and answer. Android PetSharedClient.poll() validated only the pose it was about to draw (980 finite points), and did so after it had already stored the frame, refreshed freshness, leased sound and flushed a pending action with that frame's source revision. epoch, timeMs, digest, surface, caustic, behaviour/needs and both poses' state/style were unchecked, and org.json's getDouble accepts "NaN"/"Infinity" strings, so non-finite values (and NaN dotScale/glow, which coerceIn passes through) reached the renderer. It now validates the whole frame — both poses against the core's validatePetState bounds and known channels, style colour/alpha ranges, UUID epoch, finite times — before any state changes. Apple PetSharedHost.request() buffered the entire body with session.data(for:) (under a 2 s resource timeout) before comparing its size with the 8/64 MiB bound. It now rejects a declared oversize Content-Length up front and streams session.bytes(for:) into the buffer, cancelling the task at the bound. PetSharedFrame.validate() adds the same state/style/behaviour/needs/activity checks as Android. Validation: - swiftc -typecheck, macOS 14 target, the full macos/build.sh source set: exit 0. - Swift runtime check (scratch harness, not committed): a frame built from the real core's presentation() plus the owner's fields is accepted; frames with still.state.activity 7, style.channel "bogus" or needs "panic" are rejected. Negative control: HEAD PetShared.swift accepted all three invalid frames. - Kotlin: typechecked PetSharedClient.kt + android/PetSim.kt with Gradle 8.14.5's bundled Kotlin 2.0.21 compiler (OpenJDK 17) against local stubs for android.os.SystemClock, org.json and PetNativeCore: exit 0; a deliberately wrong argument produced a type error, so the check is real. - Not run: Android Gradle build/instrumented tests, iOS build, the streaming bound against an oversized live response. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: CodeWhale Bot <[email protected]>
This commit is contained in:
1 parent
9f33ed4098
commit
251470477c
2 files changed
+57
-14
No files matched your search
@@ -46,25 +46,38 @@ class PetSharedClient(private val descriptor: File) {
|
||||
fun poll(still: Boolean, sound: Boolean): PetScene {
|
||||
val next = request("/v1/frame")
|
||||
require(next.getInt("version") == 1 && next.getString("identity") == connection().getString("identity")) { "Shared identity changed; select its connection again." }
|
||||
if (frame?.optString("epoch") != next.getString("epoch") || frame?.optLong("tick") != next.getLong("tick")) changed = SystemClock.elapsedRealtime()
|
||||
frame = next; identity = "${next.getString("identity")} · tick ${next.getLong("tick")} · ${next.getString("digest")}"
|
||||
// Validate every rendered field of both poses before this frame can
|
||||
// refresh freshness, lease sound or flush a pending action. org.json
|
||||
// parses "NaN"/"Infinity" strings through getDouble, so check finiteness.
|
||||
val epoch = next.getString("epoch"); UUID.fromString(epoch)
|
||||
val tick = next.getLong("tick"); val timeMs = next.getDouble("timeMs")
|
||||
val surface = next.getDouble("surface"); val caustic = next.getDouble("caustic")
|
||||
require(tick >= 0 && timeMs.isFinite() && timeMs >= 0 && surface.isFinite() && caustic.isFinite()
|
||||
&& next.getString("digest").length == 16 && next.getString("behaviour") in BEHAVIOURS && next.getString("needs") in NEEDS) { "Invalid shared pet frame." }
|
||||
val poses = listOf(next, next.getJSONObject("still")).map { pose ->
|
||||
val points = pose.getJSONArray("points"); require(points.length() == 980) { "Invalid shared pet frame." }
|
||||
val dots = FloatArray(1960) { i -> points.getJSONArray(i / 2).also { require(it.length() == 2) }.getDouble(i % 2).also { require(it.isFinite() && kotlin.math.abs(it) <= 8) }.toFloat() }
|
||||
val state = PetNativeCore.decodeState(pose.getJSONObject("state")); val style = PetNativeCore.decodeStyle(pose.getJSONObject("style"))
|
||||
require(validState(state) && validStyle(style)) { "Invalid shared pet frame." }
|
||||
Triple(dots, state, style)
|
||||
}
|
||||
val appearance = next.optJSONObject("appearance")?.let { a ->
|
||||
fun color(key: String): List<Int> { val c=a.getJSONArray(key); require(c.length()==3); return (0..2).map { c.getInt(it).also { n -> require(n in 0..255) } } }
|
||||
val dotScale = a.getDouble("dotScale"); val glow = a.getDouble("glow"); require(dotScale.isFinite() && glow.isFinite())
|
||||
PetAppearance(color("background"), color("backgroundTop"), dotScale.toFloat().coerceIn(.65f,1.8f), glow.toFloat().coerceIn(0f,1f), a.getBoolean("environment"))
|
||||
}
|
||||
if (frame?.optString("epoch") != epoch || frame?.optLong("tick") != tick) changed = SystemClock.elapsedRealtime()
|
||||
frame = next; identity = "${next.getString("identity")} · tick $tick · ${next.getString("digest")}"
|
||||
val fresh = SystemClock.elapsedRealtime() - changed < 800
|
||||
message = if (fresh && next.getBoolean("producerConnected")) "Following ${next.getString("source")}" else "${next.getString("source")} · telemetry unobserved"
|
||||
if (!next.getBoolean("storageAvailable")) message += " · storage unavailable; previous recording kept"
|
||||
if (next.getBoolean("audioUnavailable")) granted = false
|
||||
if (SystemClock.elapsedRealtime() - lease >= 500) setSound(sound && fresh)
|
||||
flush()
|
||||
val pose = if (still) next.getJSONObject("still") else next
|
||||
val points = pose.getJSONArray("points"); require(points.length() == 980)
|
||||
val dots = FloatArray(1960) { i -> points.getJSONArray(i / 2).getDouble(i % 2).also { require(it.isFinite() && kotlin.math.abs(it) <= 8) }.toFloat() }
|
||||
val state = PetNativeCore.decodeState(pose.getJSONObject("state")); val style = PetNativeCore.decodeStyle(pose.getJSONObject("style"))
|
||||
val (dots, state, style) = poses[if (still) 1 else 0]
|
||||
if (!fresh || !next.getBoolean("producerConnected")) { style.hollow = true }
|
||||
val appearance = next.optJSONObject("appearance")?.let { a ->
|
||||
fun color(key: String): List<Int> { val c=a.getJSONArray(key); require(c.length()==3); return (0..2).map { c.getInt(it).also { n -> require(n in 0..255) } } }
|
||||
PetAppearance(color("background"), color("backgroundTop"), a.getDouble("dotScale").toFloat().coerceIn(.65f,1.8f), a.getDouble("glow").toFloat().coerceIn(0f,1f), a.getBoolean("environment"))
|
||||
}
|
||||
return PetScene(next.getDouble("timeMs"), state, style, dots, next.getString("behaviour"), next.getString("needs"),
|
||||
next.getDouble("surface").toFloat(), if (still) 0f else next.getDouble("caustic").toFloat(), null, 0, next.getString("digest"), appearance,
|
||||
return PetScene(timeMs, state, style, dots, next.getString("behaviour"), next.getString("needs"),
|
||||
surface.toFloat(), if (still) 0f else caustic.toFloat(), null, 0, next.getString("digest"), appearance,
|
||||
next.optJSONObject("activity")?.takeIf { it.optBoolean("observed") && fresh }?.let { a ->
|
||||
a.getString("label").take(96) + (if (a.isNull("tool")) "" else " · " + a.getString("tool").take(96)) +
|
||||
(if (a.optInt("parallel") > 0) " · ${a.getInt("parallel")} parallel agents" else "")
|
||||
@@ -82,6 +95,13 @@ class PetSharedClient(private val descriptor: File) {
|
||||
fun detach() { if (granted) runCatching { setSound(false) }; granted = false }
|
||||
fun export(): ByteArray = request("/v1/export").toString().toByteArray(Charsets.UTF_8)
|
||||
companion object {
|
||||
private val BEHAVIOURS = setOf("swim", "dive", "roll", "breathe", "drift", "doze", "wake")
|
||||
private val NEEDS = setOf("none", "orient", "approach", "call")
|
||||
/** The shared core's validatePetState bounds. */
|
||||
private fun validState(s: PetState) = listOf(s.activity, s.coherence, s.attention, s.observed, s.lit).all { it.isFinite() && it in 0.0..1.0 }
|
||||
&& listOf(s.roamX, s.roamY, s.flip).all { it.isFinite() && kotlin.math.abs(it) <= 1.0 } && CHANNELS.any { it.key == s.channel }
|
||||
private fun validStyle(f: Frame) = listOf(f.r, f.g, f.b).all { it.isFinite() && it in 0.0..255.0 } && f.alpha.isFinite() && f.alpha in 0.0..1.0
|
||||
&& f.work.isFinite() && CHANNELS.any { it.key == f.channel }
|
||||
fun validateConnection(d: JSONObject) {
|
||||
require(d.getInt("version") == 1 && d.getInt("port") in 1..65535 && d.getString("token").matches(Regex("[a-f0-9]{64}"))) { "Invalid shared pet connection." }
|
||||
UUID.fromString(d.getString("identity"))
|
||||
|
||||
@@ -37,12 +37,27 @@ public struct PetSharedFrame: Decodable {
|
||||
public let appearance: PetAppearance?
|
||||
public let activity: PetActionCue?
|
||||
public let producerConnected: Bool, storageAvailable: Bool, audioOwner: String?, audioUnavailable: Bool
|
||||
/// Every rendered field of both poses, not only the points: after the
|
||||
/// owner exits, any local process can bind its loopback port and answer.
|
||||
public func validate() throws {
|
||||
guard version == 1, UUID(uuidString: identity) != nil, UUID(uuidString: epoch) != nil,
|
||||
timeMs.isFinite, timeMs >= 0, digest.count == 16, appearance?.valid != false,
|
||||
["swim", "dive", "roll", "breathe", "drift", "doze", "wake"].contains(behaviour),
|
||||
["none", "orient", "approach", "call"].contains(needs),
|
||||
activity.map({ $0.label.count <= 256 && ($0.tool?.count ?? 0) <= 256 && (0...256).contains($0.parallel) }) != false,
|
||||
[state, still.state].allSatisfy(Self.valid), [style, still.style].allSatisfy(Self.valid),
|
||||
[points, still.points].allSatisfy({ $0.count == 980 && $0.allSatisfy { $0.count == 2 && $0.allSatisfy { $0.isFinite && abs($0) <= 8 } } })
|
||||
else { throw PetCoreError.invalid("Invalid shared pet frame.") }
|
||||
}
|
||||
/// The shared core's validatePetState bounds.
|
||||
static func valid(_ s: PetState) -> Bool {
|
||||
[s.activity, s.coherence, s.attention, s.observed, s.lit].allSatisfy { $0.isFinite && (0...1).contains($0) }
|
||||
&& [s.roamX, s.roamY, s.flip].allSatisfy { $0.isFinite && abs($0) <= 1 } && channelIndex(s.channel) != nil
|
||||
}
|
||||
static func valid(_ f: Frame) -> Bool {
|
||||
[f.r, f.g, f.b].allSatisfy { $0.isFinite && (0...255).contains($0) } && f.alpha.isFinite && (0...1).contains(f.alpha)
|
||||
&& f.work.isFinite && channelIndex(f.channel) != nil
|
||||
}
|
||||
}
|
||||
|
||||
/// A view, never a world. Its task polls immutable owner snapshots. Reopening
|
||||
@@ -135,8 +150,16 @@ public struct PetSharedFrame: Decodable {
|
||||
var request = URLRequest(url: URL(string: "http://127.0.0.1:\(connection.port)\(path)")!)
|
||||
request.setValue("Bearer " + connection.token, forHTTPHeaderField: "Authorization")
|
||||
if let body { request.httpMethod = "POST"; request.httpBody = body; request.setValue("application/json", forHTTPHeaderField: "Content-Type") }
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard data.count <= (path == "/v1/export" ? 64 : 8) * 1024 * 1024, (response as? HTTPURLResponse)?.statusCode == 200 else {
|
||||
// Stream against the bound; never buffer an oversized body before checking it.
|
||||
let limit = (path == "/v1/export" ? 64 : 8) * 1024 * 1024
|
||||
let (bytes, response) = try await session.bytes(for: request)
|
||||
guard response.expectedContentLength <= Int64(limit) else { bytes.task.cancel(); throw PetCoreError.invalid("Shared pet response exceeds its bound.") }
|
||||
var data = Data(); data.reserveCapacity(Int(max(0, response.expectedContentLength)))
|
||||
for try await byte in bytes {
|
||||
guard data.count < limit else { bytes.task.cancel(); throw PetCoreError.invalid("Shared pet response exceeds its bound.") }
|
||||
data.append(byte)
|
||||
}
|
||||
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
|
||||
let error = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
|
||||
let message = error?["error"] as? String ?? "Shared pet unavailable."
|
||||
if path == "/v1/action", (response as? HTTPURLResponse)?.statusCode == 409, !message.contains("storage") { pending = nil }
|
||||
|
||||
Reference in new issue
Block a user