Skip to main content

frust_devtools_protocol/
discovery.rs

1//! The devtools discovery-line contract: a server announces its listening
2//! port — and, since auth landed, the per-process token a client must present
3//! at `handshake` — with one printed line built from [`DISCOVERY_PREFIX`].
4//! [`format_discovery_line`] writes it and [`parse_discovery_line`] reads it
5//! back, so the two sides share one definition rather than two independently
6//! typed literals that could drift (the same shared-marker-string pattern as
7//! `frust-drive`'s `FRUST-SIGNING-FALLBACK` token,
8//! `crates/frust-drive/src/android_build/signing.rs`).
9//!
10//! # Shape
11//!
12//! ```text
13//! frust-devtools listening on 54321 token 6f1c…c0de
14//! frust-devtools listening on 54321          (a server running with auth off)
15//! ```
16//!
17//! The token is **whitespace-delimited** and always last, so a host logger that
18//! appends its own trailing text cannot swallow it, and a line from a server
19//! that requires no token still parses — [`Discovery::token`] is simply `None`.
20
21/// The exact substring a devtools server's discovery line carries, followed
22/// immediately by a bare decimal port number.
23pub const DISCOVERY_PREFIX: &str = "frust-devtools listening on ";
24
25/// The exact substring a shell logs (via [`format_failure_line`]) when the
26/// in-app devtools service is compiled in but could not start — a bind refused
27/// by the OS, an ephemeral-port exhaustion, a runtime that would not build. The
28/// human reason follows immediately.
29///
30/// Tooling greps for this the same way it greps for [`DISCOVERY_PREFIX`], so a
31/// session that will never announce a port turns "waiting for a discovery
32/// line…" into the concrete reason instead of an eternal silent wait. Shared
33/// here so the logging side and the parsing side cannot drift.
34pub const FAILURE_PREFIX: &str = "frust-devtools: service did not start: ";
35
36/// What separates the port from the auth token on a discovery line. Private:
37/// both sides go through [`format_discovery_line`]/[`parse_discovery_line`]
38/// rather than splicing the marker themselves.
39const TOKEN_MARKER: &str = " token ";
40
41/// A parsed discovery line: everything a client needs to open an authenticated
42/// connection.
43///
44/// `token` is `None` for a line written by a server running with auth disabled
45/// (`ServiceConfig`'s switch) or by a build predating the token — a client with
46/// no token simply sends none and lets the server decide.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct Discovery {
49    pub port: u16,
50    pub token: Option<String>,
51}
52
53/// Builds the one discovery line a server logs on start. The server side's
54/// only formatter — see the module doc for the shape.
55pub fn format_discovery_line(port: u16, token: Option<&str>) -> String {
56    match token {
57        Some(token) => format!("{DISCOVERY_PREFIX}{port}{TOKEN_MARKER}{token}"),
58        None => format!("{DISCOVERY_PREFIX}{port}"),
59    }
60}
61
62/// Finds [`DISCOVERY_PREFIX`] anywhere in `line` — a **substring** search,
63/// not a line-start anchor — and parses the port (and optional token) that
64/// follow it.
65///
66/// Substring (not anchored) search is deliberate: a host logger commonly
67/// prepends its own prefix before app output reaches it (Android `logcat`'s
68/// `MM-DD HH:MM:SS.mmm PID TID L Tag: `, a desktop process supervisor's
69/// timestamp, …), so tooling must find the marker wherever it lands in the
70/// line, not only at column 0.
71///
72/// Returns `None` if the prefix is absent, or is not immediately followed
73/// by at least one digit. A line with no token — or with the marker but
74/// nothing after it — still yields its port, with `token: None`.
75pub fn parse_discovery_line(line: &str) -> Option<Discovery> {
76    let idx = line.find(DISCOVERY_PREFIX)?;
77    let rest = &line[idx + DISCOVERY_PREFIX.len()..];
78    let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
79    if digits.is_empty() {
80        return None;
81    }
82    let port: u16 = digits.parse().ok()?;
83    let token = rest[digits.len()..]
84        .strip_prefix(TOKEN_MARKER)
85        .map(|after| {
86            after
87                .chars()
88                .take_while(|c| !c.is_whitespace())
89                .collect::<String>()
90        })
91        .filter(|token| !token.is_empty());
92    Some(Discovery { port, token })
93}
94
95/// Builds the one line a shell logs when the devtools service fails to start.
96/// The shell side's only formatter — pair of [`parse_failure_line`].
97pub fn format_failure_line(reason: &str) -> String {
98    format!("{FAILURE_PREFIX}{reason}")
99}
100
101/// Finds [`FAILURE_PREFIX`] anywhere in `line` (a substring search, for the
102/// same host-logger-prefix reason as [`parse_discovery_line`]) and returns the
103/// trimmed human reason after it, or `None` if the marker is absent or nothing
104/// non-empty follows it.
105pub fn parse_failure_line(line: &str) -> Option<&str> {
106    let idx = line.find(FAILURE_PREFIX)?;
107    let reason = line[idx + FAILURE_PREFIX.len()..].trim_end();
108    (!reason.is_empty()).then_some(reason)
109}
110
111/// Redacts the token value from a discovery line for safe logging/display.
112///
113/// Given a log line that may contain a devtools discovery announcement (e.g.,
114/// `frust-devtools listening on 54321 token abc123def456`), returns it with the
115/// token value replaced by `<redacted>`. This is used to strip the authentication
116/// token from human-visible logs where it should not be exposed.
117///
118/// If the line does not contain [`DISCOVERY_PREFIX`] or no [`TOKEN_MARKER`]
119/// follows the port, returns the line unchanged (borrowed, no allocation).
120///
121/// Like [`parse_discovery_line`], uses substring (not anchored) search to tolerate
122/// host-logger prefixes (timestamps, tags, etc.).
123///
124/// # Examples
125///
126/// ```
127/// use frust_devtools_protocol::redact_discovery_token;
128/// use std::borrow::Cow;
129///
130/// // Redact a token
131/// let with_token = "frust-devtools listening on 54321 token abc123";
132/// let redacted = redact_discovery_token(with_token);
133/// assert_eq!(redacted.as_ref(), "frust-devtools listening on 54321 token <redacted>");
134///
135/// // Line without a token is returned borrowed (no allocation)
136/// let without_token = "frust-devtools listening on 54321";
137/// let unchanged = redact_discovery_token(without_token);
138/// assert!(matches!(unchanged, Cow::Borrowed(_)));
139/// ```
140pub fn redact_discovery_token(line: &str) -> std::borrow::Cow<'_, str> {
141    use std::borrow::Cow;
142
143    // Find the discovery prefix (substring search, same as parse_discovery_line)
144    let prefix_idx = match line.find(DISCOVERY_PREFIX) {
145        Some(idx) => idx,
146        None => return Cow::Borrowed(line),
147    };
148
149    let rest = &line[prefix_idx + DISCOVERY_PREFIX.len()..];
150
151    // Extract the port digits
152    let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
153    if digits.is_empty() {
154        return Cow::Borrowed(line);
155    }
156
157    let after_port = &rest[digits.len()..];
158
159    // Check if TOKEN_MARKER follows the port
160    if !after_port.starts_with(TOKEN_MARKER) {
161        return Cow::Borrowed(line);
162    }
163
164    // Find where the token value ends (at the next whitespace or end of string)
165    let after_marker = &after_port[TOKEN_MARKER.len()..];
166    let token_end = after_marker
167        .chars()
168        .position(|c| c.is_whitespace())
169        .unwrap_or(after_marker.len());
170
171    // If there's no token value (empty or only whitespace), return unchanged
172    if token_end == 0 {
173        return Cow::Borrowed(line);
174    }
175
176    // Build the redacted line:
177    // - everything up to and including the TOKEN_MARKER
178    // - the redaction mask
179    // - everything after the token
180    let before_token =
181        &line[..prefix_idx + DISCOVERY_PREFIX.len() + digits.len() + TOKEN_MARKER.len()];
182    let after_token = &after_marker[token_end..];
183
184    Cow::Owned(format!("{}{}{}", before_token, "<redacted>", after_token))
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    fn port_of(line: &str) -> Option<u16> {
192        parse_discovery_line(line).map(|d| d.port)
193    }
194
195    #[test]
196    fn failure_line_round_trips() {
197        let line = format_failure_line("Connection refused (os error 111)");
198        assert_eq!(
199            parse_failure_line(&line),
200            Some("Connection refused (os error 111)")
201        );
202    }
203
204    #[test]
205    fn parses_failure_line_with_logcat_prefix() {
206        let line = "08-10 10:22:16.394 27868 27868 W frust   : frust_shell_common::devtools: \
207                    frust-devtools: service did not start: Connection refused (os error 111)";
208        assert_eq!(
209            parse_failure_line(line),
210            Some("Connection refused (os error 111)")
211        );
212    }
213
214    #[test]
215    fn failure_line_absent_or_empty_is_none() {
216        assert_eq!(parse_failure_line("some unrelated log line"), None);
217        assert_eq!(parse_failure_line(FAILURE_PREFIX), None);
218    }
219
220    #[test]
221    fn parses_bare_line() {
222        assert_eq!(
223            parse_discovery_line("frust-devtools listening on 54321"),
224            Some(Discovery {
225                port: 54321,
226                token: None
227            })
228        );
229    }
230
231    #[test]
232    fn parses_with_trailing_text() {
233        assert_eq!(port_of("frust-devtools listening on 54321\n"), Some(54321));
234        assert_eq!(
235            port_of("frust-devtools listening on 54321 (waiting for client)"),
236            Some(54321)
237        );
238    }
239
240    #[test]
241    fn parses_with_logcat_style_prefix() {
242        let line = "08-10 12:00:00.123  1234  5678 I Frust   : frust-devtools listening on 8123";
243        assert_eq!(port_of(line), Some(8123));
244    }
245
246    #[test]
247    fn parses_with_generic_timestamp_and_tag_prefix() {
248        let line = "[2026-08-10T12:00:00Z] app: frust-devtools listening on 65000";
249        assert_eq!(port_of(line), Some(65000));
250    }
251
252    #[test]
253    fn returns_none_when_prefix_absent() {
254        assert_eq!(parse_discovery_line("some unrelated log line"), None);
255    }
256
257    #[test]
258    fn returns_none_when_no_digits_follow_prefix() {
259        assert_eq!(
260            parse_discovery_line("frust-devtools listening on not-a-port"),
261            None
262        );
263    }
264
265    #[test]
266    fn returns_none_on_empty_line() {
267        assert_eq!(parse_discovery_line(""), None);
268    }
269
270    #[test]
271    fn format_and_parse_round_trip_with_a_token() {
272        let line = format_discovery_line(54321, Some("0123456789abcdef0123456789abcdef"));
273        assert_eq!(
274            parse_discovery_line(&line),
275            Some(Discovery {
276                port: 54321,
277                token: Some("0123456789abcdef0123456789abcdef".to_string()),
278            })
279        );
280    }
281
282    #[test]
283    fn format_and_parse_round_trip_without_a_token() {
284        let line = format_discovery_line(1234, None);
285        assert_eq!(
286            parse_discovery_line(&line),
287            Some(Discovery {
288                port: 1234,
289                token: None
290            })
291        );
292    }
293
294    #[test]
295    fn a_tokened_line_survives_a_logger_prefix_and_trailing_text() {
296        let line = format!(
297            "08-10 12:00:00.123  1234  5678 I Frust   : {} (waiting)",
298            format_discovery_line(8123, Some("deadbeef"))
299        );
300        assert_eq!(
301            parse_discovery_line(&line),
302            Some(Discovery {
303                port: 8123,
304                token: Some("deadbeef".to_string()),
305            })
306        );
307    }
308
309    #[test]
310    fn a_truncated_token_marker_still_yields_the_port() {
311        // Tolerance, not strictness: the port is recoverable, and a client
312        // that presents no token simply gets rejected at handshake.
313        assert_eq!(
314            parse_discovery_line("frust-devtools listening on 9000 token "),
315            Some(Discovery {
316                port: 9000,
317                token: None
318            })
319        );
320    }
321
322    #[test]
323    fn trailing_text_that_is_not_the_token_marker_is_not_a_token() {
324        assert_eq!(
325            parse_discovery_line("frust-devtools listening on 9000 tokenish stuff"),
326            Some(Discovery {
327                port: 9000,
328                token: None
329            })
330        );
331    }
332
333    #[test]
334    fn redact_token_basic() {
335        let line = "frust-devtools listening on 54321 token abc123def456";
336        let redacted = redact_discovery_token(line);
337        assert_eq!(
338            redacted.as_ref(),
339            "frust-devtools listening on 54321 token <redacted>"
340        );
341    }
342
343    #[test]
344    fn redact_token_returns_borrowed_when_no_token() {
345        use std::borrow::Cow;
346        let line = "frust-devtools listening on 54321";
347        let result = redact_discovery_token(line);
348        assert!(matches!(result, Cow::Borrowed(_)));
349        assert_eq!(result.as_ref(), line);
350    }
351
352    #[test]
353    fn redact_token_returns_borrowed_for_non_discovery_line() {
354        use std::borrow::Cow;
355        let line = "some unrelated log line";
356        let result = redact_discovery_token(line);
357        assert!(matches!(result, Cow::Borrowed(_)));
358        assert_eq!(result.as_ref(), line);
359    }
360
361    #[test]
362    fn redact_token_with_uppercase_token() {
363        let line = "frust-devtools listening on 8000 token ABCDEF0123456789";
364        let redacted = redact_discovery_token(line);
365        assert_eq!(
366            redacted.as_ref(),
367            "frust-devtools listening on 8000 token <redacted>"
368        );
369    }
370
371    #[test]
372    fn redact_token_with_lowercase_token() {
373        let line = "frust-devtools listening on 8000 token abcdef0123456789";
374        let redacted = redact_discovery_token(line);
375        assert_eq!(
376            redacted.as_ref(),
377            "frust-devtools listening on 8000 token <redacted>"
378        );
379    }
380
381    #[test]
382    fn redact_token_with_logcat_prefix() {
383        let line = "08-10 12:00:00.123  1234  5678 I Frust   : frust-devtools listening on 8123 token deadbeef";
384        let redacted = redact_discovery_token(line);
385        assert_eq!(
386            redacted.as_ref(),
387            "08-10 12:00:00.123  1234  5678 I Frust   : frust-devtools listening on 8123 token <redacted>"
388        );
389    }
390
391    #[test]
392    fn redact_token_with_generic_timestamp_prefix() {
393        let line =
394            "[2026-08-10T12:00:00Z] app: frust-devtools listening on 65000 token secret123abc";
395        let redacted = redact_discovery_token(line);
396        assert_eq!(
397            redacted.as_ref(),
398            "[2026-08-10T12:00:00Z] app: frust-devtools listening on 65000 token <redacted>"
399        );
400    }
401
402    #[test]
403    fn redact_token_with_trailing_text() {
404        let line = "frust-devtools listening on 54321 token abc123 (waiting for client)";
405        let redacted = redact_discovery_token(line);
406        assert_eq!(
407            redacted.as_ref(),
408            "frust-devtools listening on 54321 token <redacted> (waiting for client)"
409        );
410    }
411
412    #[test]
413    fn redact_token_with_newline() {
414        let line = "frust-devtools listening on 54321 token abc123\n";
415        let redacted = redact_discovery_token(line);
416        assert_eq!(
417            redacted.as_ref(),
418            "frust-devtools listening on 54321 token <redacted>\n"
419        );
420    }
421
422    #[test]
423    fn redact_token_marker_without_value_is_unchanged() {
424        use std::borrow::Cow;
425        let line = "frust-devtools listening on 9000 token ";
426        let result = redact_discovery_token(line);
427        // Marker present but no token value, so unchanged
428        assert!(matches!(result, Cow::Borrowed(_)));
429    }
430
431    #[test]
432    fn parse_discovery_still_extracts_real_token() {
433        let line = "frust-devtools listening on 54321 token abc123def456";
434        let discovery = parse_discovery_line(line).expect("should parse");
435        assert_eq!(discovery.port, 54321);
436        assert_eq!(discovery.token, Some("abc123def456".to_string()));
437    }
438
439    #[test]
440    fn redact_token_format_discovery_line_round_trip() {
441        // Create a discovery line with format_discovery_line, then redact it
442        let line = format_discovery_line(54321, Some("0123456789abcdef0123456789abcdef"));
443        let redacted = redact_discovery_token(&line);
444
445        // The redacted line should have the token masked
446        assert!(redacted.contains("<redacted>"));
447        assert!(!redacted.contains("0123456789abcdef0123456789abcdef"));
448
449        // But the original parse should still work on the un-redacted line
450        let discovery = parse_discovery_line(&line).expect("should parse");
451        assert_eq!(discovery.port, 54321);
452        assert_eq!(
453            discovery.token,
454            Some("0123456789abcdef0123456789abcdef".to_string())
455        );
456    }
457}