max / shop
- Co-Authored-By
- Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 file changed,
+45 insertions,
-12 deletions
| @@ -151,26 +151,35 @@ | |||
| 151 | 151 | return None; | |
| 152 | 152 | } | |
| 153 | 153 | ||
| 154 | - | let payload = match payload_b64 { | |
| 155 | - | Some(b) if !b.is_empty() => B64.decode(b).ok()?, | |
| 156 | - | _ => Vec::new(), | |
| 157 | - | }; | |
| 158 | - | ||
| 159 | 154 | match control.action { | |
| 160 | 155 | // Payload-carrying actions share the chunk-reassembly path. A | |
| 161 | 156 | // continuation chunk parses with the default action ('T'), so | |
| 162 | 157 | // route the completed command using the *stored* first-chunk | |
| 163 | 158 | // action, not the incoming chunk's action. | |
| 164 | 159 | 'T' | 't' | 'f' => { | |
| 165 | - | let (control, payload) = self.accept_chunked(control, payload)?; | |
| 160 | + | let b64 = payload_b64.unwrap_or(&[]); | |
| 161 | + | let (control, payload) = self.accept_chunked(control, b64)?; | |
| 166 | 162 | Some(match control.action { | |
| 167 | 163 | 'f' => Command::FrameAppend { control, payload }, | |
| 168 | 164 | _ => Command::Transmit { control, payload }, | |
| 169 | 165 | }) | |
| 170 | 166 | } | |
| 171 | - | 'p' => Some(Command::Place { control }), | |
| 172 | - | 'c' => Some(Command::FrameCompose { control }), | |
| 173 | - | 'd' => Some(Command::Delete { control }), | |
| 167 | + | // Payload-less actions. If a `;<payload>` was sent anyway, validate | |
| 168 | + | // that it's decodable so a malformed one still errors — preserves | |
| 169 | + | // the pre-refactor contract without keeping the decoded bytes. | |
| 170 | + | 'p' | 'c' | 'd' => { | |
| 171 | + | if let Some(b) = payload_b64 { | |
| 172 | + | if !b.is_empty() && B64.decode(b).is_err() { | |
| 173 | + | return None; | |
| 174 | + | } | |
| 175 | + | } | |
| 176 | + | match control.action { | |
| 177 | + | 'p' => Some(Command::Place { control }), | |
| 178 | + | 'c' => Some(Command::FrameCompose { control }), | |
| 179 | + | 'd' => Some(Command::Delete { control }), | |
| 180 | + | _ => unreachable!(), | |
| 181 | + | } | |
| 182 | + | } | |
| 174 | 183 | _ => { | |
| 175 | 184 | tracing::trace!("kitty-graphics: unhandled action {}", control.action); | |
| 176 | 185 | None | |
| @@ -180,10 +189,15 @@ | |||
| 180 | 189 | ||
| 181 | 190 | /// Reassemble chunked payload for actions that carry image data. | |
| 182 | 191 | /// Returns `Some((control, payload))` when a transmission completes. | |
| 192 | + | /// | |
| 193 | + | /// Decodes base64 straight into the accumulator to avoid per-chunk | |
| 194 | + | /// transient allocation. On decode failure the accumulator is truncated | |
| 195 | + | /// back to its pre-call length so a bad chunk doesn't contaminate an | |
| 196 | + | /// in-flight transmission. | |
| 183 | 197 | fn accept_chunked( | |
| 184 | 198 | &mut self, | |
| 185 | 199 | control: Control, | |
| 186 | - | payload: Vec<u8>, | |
| 200 | + | payload_b64: &[u8], | |
| 187 | 201 | ) -> Option<(Control, Vec<u8>)> { | |
| 188 | 202 | let key = if let Some(id) = control.id { | |
| 189 | 203 | PartialKey::ById(id) | |
| @@ -194,7 +208,13 @@ | |||
| 194 | 208 | }; | |
| 195 | 209 | ||
| 196 | 210 | if !control.more_chunks && !self.partial.contains_key(&key) { | |
| 197 | - | // Single-shot — no reassembly needed. | |
| 211 | + | // Single-shot — no reassembly needed. Decode into a fresh Vec | |
| 212 | + | // sized to the payload; the caller owns the result. | |
| 213 | + | let payload = if payload_b64.is_empty() { | |
| 214 | + | Vec::new() | |
| 215 | + | } else { | |
| 216 | + | B64.decode(payload_b64).ok()? | |
| 217 | + | }; | |
| 198 | 218 | return Some((control, payload)); | |
| 199 | 219 | } | |
| 200 | 220 | ||
| @@ -202,7 +222,20 @@ | |||
| 202 | 222 | control: control.clone(), | |
| 203 | 223 | payload: Vec::new(), | |
| 204 | 224 | }); | |
| 205 | - | entry.payload.extend_from_slice(&payload); | |
| 225 | + | ||
| 226 | + | if !payload_b64.is_empty() { | |
| 227 | + | let start = entry.payload.len(); | |
| 228 | + | // Upper bound: 3 decoded bytes per 4 base64 chars, rounded up. | |
| 229 | + | let extra = payload_b64.len().div_ceil(4) * 3; | |
| 230 | + | entry.payload.resize(start + extra, 0); | |
| 231 | + | match B64.decode_slice(payload_b64, &mut entry.payload[start..]) { | |
| 232 | + | Ok(written) => entry.payload.truncate(start + written), | |
| 233 | + | Err(_) => { | |
| 234 | + | entry.payload.truncate(start); | |
| 235 | + | return None; | |
| 236 | + | } | |
| 237 | + | } | |
| 238 | + | } | |
| 206 | 239 | // Merge in fields the first chunk didn't carry. | |
| 207 | 240 | merge_control(&mut entry.control, &control); | |
| 208 | 241 |