1use serde_json::Value;
14
15pub const MARKER: &str = "to=functions.";
17
18const CHANNEL_WORDS: [&str; 4] = ["analysis", "commentary", "assistant", "user"];
19
20const GLITCH_SURFACES: [&str; 6] = [
22 "SolidGoldMagikarp",
23 "davidjl",
24 "TheNitromeFan",
25 "Smartling",
26 "rawdownloadcloneembedreportprint",
27 "\u{e000}",
28];
29
30const FAKE_RESULT: [&str; 4] = ["code_output", "Cell 0:", "Cell 1:", "Cell 2:"];
31
32const SPECIAL_TOKENS: [&str; 4] = ["<|start|>", "<|channel|>", "<|message|>", "<|call|>"];
34
35pub fn escape_replay(text: &str) -> String {
38 let mut out = text.to_string();
39 for tok in SPECIAL_TOKENS {
40 if out.contains(tok) {
41 let inner = &tok[2..tok.len() - 2];
42 out = out.replace(tok, &format!("< {inner} >"));
43 }
44 }
45 out
46}
47
48pub fn scan_json(value: &Value) -> Option<Hit> {
50 match value {
51 Value::String(s) => scan_text(s),
52 Value::Array(items) => items.iter().find_map(scan_json),
53 Value::Object(map) => map.values().find_map(scan_json),
54 _ => None,
55 }
56}
57
58pub fn scan_text(text: &str) -> Option<Hit> {
60 scan_text_with_boundary(text, None)
61}
62
63pub fn scan_text_with_boundary(text: &str, trusted_end: Option<usize>) -> Option<Hit> {
68 let mut search_from = 0;
69 let mut marker_count = 0usize;
70 while let Some(rel) = text[search_from..].find(MARKER) {
71 let at = search_from + rel;
72 search_from = at + MARKER.len();
73 if in_fence_or_quote(text, at) {
74 continue;
75 }
76 marker_count += 1;
77 if let Some(hit) = classify_marker(text, at, marker_count, trusted_end) {
78 return Some(hit);
79 }
80 }
81 None
82}
83
84#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct Hit {
87 pub at: usize,
88 pub co_signal: CoSignal,
89}
90
91impl Hit {
92 pub fn summary(&self) -> String {
93 format!(
94 "Harmony protocol leak at byte {} ({})",
95 self.at,
96 self.co_signal.as_str()
97 )
98 }
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum CoSignal {
103 ChannelAdjacency,
104 GlitchToken,
105 NonLatinJunk,
106 Cascade,
107 FakeResultFraming,
108 TrustedBoundary,
109}
110
111impl CoSignal {
112 pub fn as_str(self) -> &'static str {
113 match self {
114 Self::ChannelAdjacency => "channel-adjacency",
115 Self::GlitchToken => "glitch-token",
116 Self::NonLatinJunk => "non-latin-junk",
117 Self::Cascade => "cascade",
118 Self::FakeResultFraming => "fake-result-framing",
119 Self::TrustedBoundary => "trusted-boundary",
120 }
121 }
122}
123
124fn classify_marker(
125 text: &str,
126 at: usize,
127 marker_count: usize,
128 trusted_end: Option<usize>,
129) -> Option<Hit> {
130 if marker_count >= 2 {
131 return Some(Hit {
132 at,
133 co_signal: CoSignal::Cascade,
134 });
135 }
136 if channel_adjacent(text, at) {
137 return Some(Hit {
138 at,
139 co_signal: CoSignal::ChannelAdjacency,
140 });
141 }
142 if glitch_near(text, at) {
143 return Some(Hit {
144 at,
145 co_signal: CoSignal::GlitchToken,
146 });
147 }
148 if fake_result_after(text, at) {
149 return Some(Hit {
150 at,
151 co_signal: CoSignal::FakeResultFraming,
152 });
153 }
154 if non_latin_after(text, at) {
155 return Some(Hit {
156 at,
157 co_signal: CoSignal::NonLatinJunk,
158 });
159 }
160 if let Some(end) = trusted_end
161 && at >= end
162 {
163 return Some(Hit {
164 at,
165 co_signal: CoSignal::TrustedBoundary,
166 });
167 }
168 None
169}
170
171fn channel_adjacent(text: &str, at: usize) -> bool {
172 let prefix = text[..at].trim_end();
173 CHANNEL_WORDS.iter().any(|w| {
174 let Some(rest) = prefix.strip_suffix(w) else {
175 return false;
176 };
177 rest.is_empty()
178 || rest
179 .chars()
180 .next_back()
181 .is_some_and(|c| !c.is_ascii_alphanumeric() && c != '_')
182 })
183}
184
185fn glitch_near(text: &str, at: usize) -> bool {
186 let start = at.saturating_sub(80);
187 let window = &text[start..at];
188 GLITCH_SURFACES.iter().any(|g| window.contains(g))
189}
190
191fn fake_result_after(text: &str, at: usize) -> bool {
192 let end = (at + 240).min(text.len());
193 let window = &text[at..end];
194 FAKE_RESULT.iter().any(|f| window.contains(f))
195}
196
197fn non_latin_after(text: &str, at: usize) -> bool {
198 let mut run = 0usize;
199 for ch in text[at..].chars().take(160) {
200 if is_non_latin_junk(ch) {
201 run += 1;
202 if run >= 8 {
203 return true;
204 }
205 } else if !ch.is_ascii_whitespace() {
206 run = 0;
207 }
208 }
209 false
210}
211
212fn is_non_latin_junk(ch: char) -> bool {
213 let u = ch as u32;
214 (0x0400..=0x04FF).contains(&u)
217 || (0x0E00..=0x0E7F).contains(&u)
218 || (0x10A0..=0x10FF).contains(&u)
219 || (0x2E80..=0x9FFF).contains(&u)
220 || (0xAC00..=0xD7AF).contains(&u)
221 || (0xF900..=0xFAFF).contains(&u)
222}
223
224fn in_fence_or_quote(text: &str, at: usize) -> bool {
226 let before = &text[..at];
227 fenced(before) || quoted(before)
228}
229
230fn fenced(before: &str) -> bool {
231 before.matches("```").count() % 2 == 1
232}
233
234fn quoted(before: &str) -> bool {
235 odd_unescaped(before, '"') || odd_unescaped(before, '`')
236}
237
238fn odd_unescaped(s: &str, quote: char) -> bool {
239 let mut n = 0usize;
240 let mut escaped = false;
241 for ch in s.chars() {
242 if escaped {
243 escaped = false;
244 continue;
245 }
246 if ch == '\\' {
247 escaped = true;
248 continue;
249 }
250 if ch == quote {
251 n += 1;
252 }
253 }
254 n % 2 == 1
255}
256
257pub fn last_hunk_start(patch: &str) -> Option<usize> {
259 patch
260 .rmatch_indices("\n@@")
261 .next()
262 .map(|(i, _)| i + 1)
263 .or_else(|| patch.starts_with("@@").then_some(0))
264}
265
266pub fn truncate_at_line(text: &str, at: usize) -> String {
268 let line_start = text[..at].rfind('\n').map(|i| i + 1).unwrap_or(0);
269 text[..line_start].to_string()
270}
271
272pub fn leak_is_after_last_hunk(patch: &str, at: usize) -> bool {
275 let Some(hunk) = last_hunk_start(patch) else {
276 return false;
277 };
278 if at < hunk {
279 return false;
280 }
281 let line_start = patch[..at].rfind('\n').map(|i| i + 1).unwrap_or(0);
282 !matches!(patch[line_start..at].chars().next(), Some('+' | '-' | ' '))
283}
284
285#[cfg(test)]
286#[path = "harmony_tests.rs"]
287mod tests;