Skip to main content

ilink_hub/
lib.rs

1pub mod bridge;
2pub mod client;
3pub mod error;
4pub mod hub;
5pub mod ilink;
6pub mod mcp;
7pub mod metrics;
8pub mod paths;
9pub mod relay;
10pub mod runtime;
11pub mod server;
12pub mod store;
13
14pub use error::HubError;
15pub use hub::queue::InMemoryQueue;
16pub use hub::queue::MessageQueue;
17pub use hub::HubState;
18pub use ilink::QrLoginUiEvent;
19pub use runtime::serve::{run_serve, ServeOptions};
20
21/// Redact a virtual token for logging: show only the first 8 characters followed by `…`.
22/// This lets operators correlate log lines without exposing the full credential.
23/// Safe against UTF-8 byte boundary panics.
24pub fn redact_token(t: &str) -> String {
25    let prefix: String = t.chars().take(8).collect();
26    format!("{prefix}…")
27}
28
29/// Redact credentials in a database URL for startup logs.
30///
31/// Keeps scheme, host, path/db name, and username; replaces any password with
32/// `***`. Also redacts common credential query params (`password`, `passwd`,
33/// `pwd`, `sslpassword`, `key`, `secret`, `token`). Unparseable strings (e.g.
34/// some SQLite DSNs) are returned unchanged when they do not contain a
35/// `user:password@` authority segment.
36pub fn redact_database_url(url: &str) -> String {
37    match url::Url::parse(url) {
38        Ok(mut parsed) => {
39            if parsed.password().is_some() {
40                let _ = parsed.set_password(Some("***"));
41            }
42            redact_sensitive_query_params(&mut parsed);
43            parsed.to_string()
44        }
45        Err(_) => {
46            // Fallback for non-standard DSNs: mask `scheme://user:secret@rest`.
47            if let Some(scheme_sep) = url.find("://") {
48                let after_scheme = &url[scheme_sep + 3..];
49                if let Some(at) = after_scheme.find('@') {
50                    let creds = &after_scheme[..at];
51                    if let Some(colon) = creds.find(':') {
52                        let user = &creds[..colon];
53                        return format!(
54                            "{}://{}:***@{}",
55                            &url[..scheme_sep],
56                            user,
57                            &after_scheme[at + 1..]
58                        );
59                    }
60                }
61            }
62            url.to_string()
63        }
64    }
65}
66
67fn is_sensitive_query_key(key: &str) -> bool {
68    matches!(
69        key.to_ascii_lowercase().as_str(),
70        "password" | "passwd" | "pwd" | "sslpassword" | "key" | "secret" | "token"
71    )
72}
73
74fn redact_sensitive_query_params(url: &mut url::Url) {
75    let pairs: Vec<(String, String)> = url
76        .query_pairs()
77        .map(|(k, v)| (k.into_owned(), v.into_owned()))
78        .collect();
79    if pairs.is_empty() {
80        return;
81    }
82    if !pairs.iter().any(|(k, _)| is_sensitive_query_key(k)) {
83        return;
84    }
85    url.set_query(None);
86    {
87        let mut query = url.query_pairs_mut();
88        for (k, v) in pairs {
89            if is_sensitive_query_key(&k) {
90                query.append_pair(&k, "***");
91            } else {
92                query.append_pair(&k, &v);
93            }
94        }
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    #[test]
103    fn test_redact_token_safety() {
104        // Empty string
105        assert_eq!(redact_token(""), "…");
106        // Short ASCII
107        assert_eq!(redact_token("abc"), "abc…");
108        // Exact 8 ASCII
109        assert_eq!(redact_token("12345678"), "12345678…");
110        // Long ASCII
111        assert_eq!(redact_token("abcdefghijkl"), "abcdefgh…");
112        // Multi-byte Unicode (each emoji/char is multi-byte)
113        // 🦀 is 4 bytes. Slicing at index 8 would split the 3rd crab and panic in byte-based slicing.
114        assert_eq!(redact_token("🦀🦀🦀🦀🦀🦀🦀🦀🦀🦀"), "🦀🦀🦀🦀🦀🦀🦀🦀…");
115        assert_eq!(redact_token("测试token长度校验"), "测试token长…");
116    }
117
118    #[test]
119    fn test_redact_database_url_masks_password() {
120        let redacted = redact_database_url("mysql://user:secret@host/db");
121        assert!(
122            !redacted.contains("secret"),
123            "password must not appear in redacted URL: {redacted}"
124        );
125        assert!(
126            redacted.contains("***"),
127            "expected *** placeholder: {redacted}"
128        );
129        assert!(
130            redacted.contains("mysql://"),
131            "scheme preserved: {redacted}"
132        );
133        assert!(redacted.contains("host"), "host preserved: {redacted}");
134        assert!(redacted.contains("db"), "db name preserved: {redacted}");
135        assert!(redacted.contains("user"), "username preserved: {redacted}");
136    }
137
138    #[test]
139    fn test_redact_database_url_no_password_unchanged_shape() {
140        let url = "mysql://user@host/db";
141        let redacted = redact_database_url(url);
142        assert!(!redacted.contains("***"));
143        assert!(redacted.contains("mysql://"));
144        assert!(redacted.contains("host"));
145    }
146
147    #[test]
148    fn test_redact_database_url_sqlite_memory() {
149        // Common local DSN — must not panic and must not invent a password mask.
150        let redacted = redact_database_url("sqlite::memory:");
151        assert_eq!(redacted, "sqlite::memory:");
152    }
153
154    #[test]
155    fn test_redact_database_url_query_password_params() {
156        let redacted = redact_database_url("mysql://user@host/db?password=secret&sslkey=abc");
157        assert!(
158            !redacted.contains("secret"),
159            "query password must be redacted: {redacted}"
160        );
161        // `sslkey` is not in the sensitive-key list; only password/key/secret/token…
162        // but `key` alone is. sslkey stays — assert password gone and *** present.
163        assert!(
164            redacted.contains("password=***")
165                || redacted.contains("password%3D***")
166                || redacted.contains("***")
167        );
168
169        let redacted2 = redact_database_url("postgres://host/db?password=secret");
170        assert!(
171            !redacted2.contains("secret"),
172            "postgres query password must be redacted: {redacted2}"
173        );
174
175        let redacted3 = redact_database_url("sqlite:///tmp/x.db?key=supersecret");
176        assert!(
177            !redacted3.contains("supersecret"),
178            "SQLCipher key query must be redacted: {redacted3}"
179        );
180    }
181}