frust-devtools-protocol 0.5.2

Wire contract between the Frust in-app debug service and external tooling: JSON-RPC message types, NDJSON framing, discovery line.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
//! The devtools discovery-line contract: a server announces its listening
//! port — and, since auth landed, the per-process token a client must present
//! at `handshake` — with one printed line built from [`DISCOVERY_PREFIX`].
//! [`format_discovery_line`] writes it and [`parse_discovery_line`] reads it
//! back, so the two sides share one definition rather than two independently
//! typed literals that could drift (the same shared-marker-string pattern as
//! `frust-drive`'s `FRUST-SIGNING-FALLBACK` token,
//! `crates/frust-drive/src/android_build/signing.rs`).
//!
//! # Shape
//!
//! ```text
//! frust-devtools listening on 54321 token 6f1c…c0de
//! frust-devtools listening on 54321          (a server running with auth off)
//! ```
//!
//! The token is **whitespace-delimited** and always last, so a host logger that
//! appends its own trailing text cannot swallow it, and a line from a server
//! that requires no token still parses — [`Discovery::token`] is simply `None`.

/// The exact substring a devtools server's discovery line carries, followed
/// immediately by a bare decimal port number.
pub const DISCOVERY_PREFIX: &str = "frust-devtools listening on ";

/// The exact substring a shell logs (via [`format_failure_line`]) when the
/// in-app devtools service is compiled in but could not start — a bind refused
/// by the OS, an ephemeral-port exhaustion, a runtime that would not build. The
/// human reason follows immediately.
///
/// Tooling greps for this the same way it greps for [`DISCOVERY_PREFIX`], so a
/// session that will never announce a port turns "waiting for a discovery
/// line…" into the concrete reason instead of an eternal silent wait. Shared
/// here so the logging side and the parsing side cannot drift.
pub const FAILURE_PREFIX: &str = "frust-devtools: service did not start: ";

/// What separates the port from the auth token on a discovery line. Private:
/// both sides go through [`format_discovery_line`]/[`parse_discovery_line`]
/// rather than splicing the marker themselves.
const TOKEN_MARKER: &str = " token ";

/// A parsed discovery line: everything a client needs to open an authenticated
/// connection.
///
/// `token` is `None` for a line written by a server running with auth disabled
/// (`ServiceConfig`'s switch) or by a build predating the token — a client with
/// no token simply sends none and lets the server decide.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Discovery {
    pub port: u16,
    pub token: Option<String>,
}

/// Builds the one discovery line a server logs on start. The server side's
/// only formatter — see the module doc for the shape.
pub fn format_discovery_line(port: u16, token: Option<&str>) -> String {
    match token {
        Some(token) => format!("{DISCOVERY_PREFIX}{port}{TOKEN_MARKER}{token}"),
        None => format!("{DISCOVERY_PREFIX}{port}"),
    }
}

/// Finds [`DISCOVERY_PREFIX`] anywhere in `line` — a **substring** search,
/// not a line-start anchor — and parses the port (and optional token) that
/// follow it.
///
/// Substring (not anchored) search is deliberate: a host logger commonly
/// prepends its own prefix before app output reaches it (Android `logcat`'s
/// `MM-DD HH:MM:SS.mmm PID TID L Tag: `, a desktop process supervisor's
/// timestamp, …), so tooling must find the marker wherever it lands in the
/// line, not only at column 0.
///
/// Returns `None` if the prefix is absent, or is not immediately followed
/// by at least one digit. A line with no token — or with the marker but
/// nothing after it — still yields its port, with `token: None`.
pub fn parse_discovery_line(line: &str) -> Option<Discovery> {
    let idx = line.find(DISCOVERY_PREFIX)?;
    let rest = &line[idx + DISCOVERY_PREFIX.len()..];
    let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
    if digits.is_empty() {
        return None;
    }
    let port: u16 = digits.parse().ok()?;
    let token = rest[digits.len()..]
        .strip_prefix(TOKEN_MARKER)
        .map(|after| {
            after
                .chars()
                .take_while(|c| !c.is_whitespace())
                .collect::<String>()
        })
        .filter(|token| !token.is_empty());
    Some(Discovery { port, token })
}

/// Builds the one line a shell logs when the devtools service fails to start.
/// The shell side's only formatter — pair of [`parse_failure_line`].
pub fn format_failure_line(reason: &str) -> String {
    format!("{FAILURE_PREFIX}{reason}")
}

/// Finds [`FAILURE_PREFIX`] anywhere in `line` (a substring search, for the
/// same host-logger-prefix reason as [`parse_discovery_line`]) and returns the
/// trimmed human reason after it, or `None` if the marker is absent or nothing
/// non-empty follows it.
pub fn parse_failure_line(line: &str) -> Option<&str> {
    let idx = line.find(FAILURE_PREFIX)?;
    let reason = line[idx + FAILURE_PREFIX.len()..].trim_end();
    (!reason.is_empty()).then_some(reason)
}

/// Redacts the token value from a discovery line for safe logging/display.
///
/// Given a log line that may contain a devtools discovery announcement (e.g.,
/// `frust-devtools listening on 54321 token abc123def456`), returns it with the
/// token value replaced by `<redacted>`. This is used to strip the authentication
/// token from human-visible logs where it should not be exposed.
///
/// If the line does not contain [`DISCOVERY_PREFIX`] or no [`TOKEN_MARKER`]
/// follows the port, returns the line unchanged (borrowed, no allocation).
///
/// Like [`parse_discovery_line`], uses substring (not anchored) search to tolerate
/// host-logger prefixes (timestamps, tags, etc.).
///
/// # Examples
///
/// ```
/// use frust_devtools_protocol::redact_discovery_token;
/// use std::borrow::Cow;
///
/// // Redact a token
/// let with_token = "frust-devtools listening on 54321 token abc123";
/// let redacted = redact_discovery_token(with_token);
/// assert_eq!(redacted.as_ref(), "frust-devtools listening on 54321 token <redacted>");
///
/// // Line without a token is returned borrowed (no allocation)
/// let without_token = "frust-devtools listening on 54321";
/// let unchanged = redact_discovery_token(without_token);
/// assert!(matches!(unchanged, Cow::Borrowed(_)));
/// ```
pub fn redact_discovery_token(line: &str) -> std::borrow::Cow<'_, str> {
    use std::borrow::Cow;

    // Find the discovery prefix (substring search, same as parse_discovery_line)
    let prefix_idx = match line.find(DISCOVERY_PREFIX) {
        Some(idx) => idx,
        None => return Cow::Borrowed(line),
    };

    let rest = &line[prefix_idx + DISCOVERY_PREFIX.len()..];

    // Extract the port digits
    let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
    if digits.is_empty() {
        return Cow::Borrowed(line);
    }

    let after_port = &rest[digits.len()..];

    // Check if TOKEN_MARKER follows the port
    if !after_port.starts_with(TOKEN_MARKER) {
        return Cow::Borrowed(line);
    }

    // Find where the token value ends (at the next whitespace or end of string)
    let after_marker = &after_port[TOKEN_MARKER.len()..];
    let token_end = after_marker
        .chars()
        .position(|c| c.is_whitespace())
        .unwrap_or(after_marker.len());

    // If there's no token value (empty or only whitespace), return unchanged
    if token_end == 0 {
        return Cow::Borrowed(line);
    }

    // Build the redacted line:
    // - everything up to and including the TOKEN_MARKER
    // - the redaction mask
    // - everything after the token
    let before_token =
        &line[..prefix_idx + DISCOVERY_PREFIX.len() + digits.len() + TOKEN_MARKER.len()];
    let after_token = &after_marker[token_end..];

    Cow::Owned(format!("{}{}{}", before_token, "<redacted>", after_token))
}

#[cfg(test)]
mod tests {
    use super::*;

    fn port_of(line: &str) -> Option<u16> {
        parse_discovery_line(line).map(|d| d.port)
    }

    #[test]
    fn failure_line_round_trips() {
        let line = format_failure_line("Connection refused (os error 111)");
        assert_eq!(
            parse_failure_line(&line),
            Some("Connection refused (os error 111)")
        );
    }

    #[test]
    fn parses_failure_line_with_logcat_prefix() {
        let line = "08-10 10:22:16.394 27868 27868 W frust   : frust_shell_common::devtools: \
                    frust-devtools: service did not start: Connection refused (os error 111)";
        assert_eq!(
            parse_failure_line(line),
            Some("Connection refused (os error 111)")
        );
    }

    #[test]
    fn failure_line_absent_or_empty_is_none() {
        assert_eq!(parse_failure_line("some unrelated log line"), None);
        assert_eq!(parse_failure_line(FAILURE_PREFIX), None);
    }

    #[test]
    fn parses_bare_line() {
        assert_eq!(
            parse_discovery_line("frust-devtools listening on 54321"),
            Some(Discovery {
                port: 54321,
                token: None
            })
        );
    }

    #[test]
    fn parses_with_trailing_text() {
        assert_eq!(port_of("frust-devtools listening on 54321\n"), Some(54321));
        assert_eq!(
            port_of("frust-devtools listening on 54321 (waiting for client)"),
            Some(54321)
        );
    }

    #[test]
    fn parses_with_logcat_style_prefix() {
        let line = "08-10 12:00:00.123  1234  5678 I Frust   : frust-devtools listening on 8123";
        assert_eq!(port_of(line), Some(8123));
    }

    #[test]
    fn parses_with_generic_timestamp_and_tag_prefix() {
        let line = "[2026-08-10T12:00:00Z] app: frust-devtools listening on 65000";
        assert_eq!(port_of(line), Some(65000));
    }

    #[test]
    fn returns_none_when_prefix_absent() {
        assert_eq!(parse_discovery_line("some unrelated log line"), None);
    }

    #[test]
    fn returns_none_when_no_digits_follow_prefix() {
        assert_eq!(
            parse_discovery_line("frust-devtools listening on not-a-port"),
            None
        );
    }

    #[test]
    fn returns_none_on_empty_line() {
        assert_eq!(parse_discovery_line(""), None);
    }

    #[test]
    fn format_and_parse_round_trip_with_a_token() {
        let line = format_discovery_line(54321, Some("0123456789abcdef0123456789abcdef"));
        assert_eq!(
            parse_discovery_line(&line),
            Some(Discovery {
                port: 54321,
                token: Some("0123456789abcdef0123456789abcdef".to_string()),
            })
        );
    }

    #[test]
    fn format_and_parse_round_trip_without_a_token() {
        let line = format_discovery_line(1234, None);
        assert_eq!(
            parse_discovery_line(&line),
            Some(Discovery {
                port: 1234,
                token: None
            })
        );
    }

    #[test]
    fn a_tokened_line_survives_a_logger_prefix_and_trailing_text() {
        let line = format!(
            "08-10 12:00:00.123  1234  5678 I Frust   : {} (waiting)",
            format_discovery_line(8123, Some("deadbeef"))
        );
        assert_eq!(
            parse_discovery_line(&line),
            Some(Discovery {
                port: 8123,
                token: Some("deadbeef".to_string()),
            })
        );
    }

    #[test]
    fn a_truncated_token_marker_still_yields_the_port() {
        // Tolerance, not strictness: the port is recoverable, and a client
        // that presents no token simply gets rejected at handshake.
        assert_eq!(
            parse_discovery_line("frust-devtools listening on 9000 token "),
            Some(Discovery {
                port: 9000,
                token: None
            })
        );
    }

    #[test]
    fn trailing_text_that_is_not_the_token_marker_is_not_a_token() {
        assert_eq!(
            parse_discovery_line("frust-devtools listening on 9000 tokenish stuff"),
            Some(Discovery {
                port: 9000,
                token: None
            })
        );
    }

    #[test]
    fn redact_token_basic() {
        let line = "frust-devtools listening on 54321 token abc123def456";
        let redacted = redact_discovery_token(line);
        assert_eq!(
            redacted.as_ref(),
            "frust-devtools listening on 54321 token <redacted>"
        );
    }

    #[test]
    fn redact_token_returns_borrowed_when_no_token() {
        use std::borrow::Cow;
        let line = "frust-devtools listening on 54321";
        let result = redact_discovery_token(line);
        assert!(matches!(result, Cow::Borrowed(_)));
        assert_eq!(result.as_ref(), line);
    }

    #[test]
    fn redact_token_returns_borrowed_for_non_discovery_line() {
        use std::borrow::Cow;
        let line = "some unrelated log line";
        let result = redact_discovery_token(line);
        assert!(matches!(result, Cow::Borrowed(_)));
        assert_eq!(result.as_ref(), line);
    }

    #[test]
    fn redact_token_with_uppercase_token() {
        let line = "frust-devtools listening on 8000 token ABCDEF0123456789";
        let redacted = redact_discovery_token(line);
        assert_eq!(
            redacted.as_ref(),
            "frust-devtools listening on 8000 token <redacted>"
        );
    }

    #[test]
    fn redact_token_with_lowercase_token() {
        let line = "frust-devtools listening on 8000 token abcdef0123456789";
        let redacted = redact_discovery_token(line);
        assert_eq!(
            redacted.as_ref(),
            "frust-devtools listening on 8000 token <redacted>"
        );
    }

    #[test]
    fn redact_token_with_logcat_prefix() {
        let line = "08-10 12:00:00.123  1234  5678 I Frust   : frust-devtools listening on 8123 token deadbeef";
        let redacted = redact_discovery_token(line);
        assert_eq!(
            redacted.as_ref(),
            "08-10 12:00:00.123  1234  5678 I Frust   : frust-devtools listening on 8123 token <redacted>"
        );
    }

    #[test]
    fn redact_token_with_generic_timestamp_prefix() {
        let line =
            "[2026-08-10T12:00:00Z] app: frust-devtools listening on 65000 token secret123abc";
        let redacted = redact_discovery_token(line);
        assert_eq!(
            redacted.as_ref(),
            "[2026-08-10T12:00:00Z] app: frust-devtools listening on 65000 token <redacted>"
        );
    }

    #[test]
    fn redact_token_with_trailing_text() {
        let line = "frust-devtools listening on 54321 token abc123 (waiting for client)";
        let redacted = redact_discovery_token(line);
        assert_eq!(
            redacted.as_ref(),
            "frust-devtools listening on 54321 token <redacted> (waiting for client)"
        );
    }

    #[test]
    fn redact_token_with_newline() {
        let line = "frust-devtools listening on 54321 token abc123\n";
        let redacted = redact_discovery_token(line);
        assert_eq!(
            redacted.as_ref(),
            "frust-devtools listening on 54321 token <redacted>\n"
        );
    }

    #[test]
    fn redact_token_marker_without_value_is_unchanged() {
        use std::borrow::Cow;
        let line = "frust-devtools listening on 9000 token ";
        let result = redact_discovery_token(line);
        // Marker present but no token value, so unchanged
        assert!(matches!(result, Cow::Borrowed(_)));
    }

    #[test]
    fn parse_discovery_still_extracts_real_token() {
        let line = "frust-devtools listening on 54321 token abc123def456";
        let discovery = parse_discovery_line(line).expect("should parse");
        assert_eq!(discovery.port, 54321);
        assert_eq!(discovery.token, Some("abc123def456".to_string()));
    }

    #[test]
    fn redact_token_format_discovery_line_round_trip() {
        // Create a discovery line with format_discovery_line, then redact it
        let line = format_discovery_line(54321, Some("0123456789abcdef0123456789abcdef"));
        let redacted = redact_discovery_token(&line);

        // The redacted line should have the token masked
        assert!(redacted.contains("<redacted>"));
        assert!(!redacted.contains("0123456789abcdef0123456789abcdef"));

        // But the original parse should still work on the un-redacted line
        let discovery = parse_discovery_line(&line).expect("should parse");
        assert_eq!(discovery.port, 54321);
        assert_eq!(
            discovery.token,
            Some("0123456789abcdef0123456789abcdef".to_string())
        );
    }
}