Skip to content

Commit c093bf1

Browse files
fix(drive): sync backend validation in startDriveProxy + serde_json for JNI replies
1 parent 59dd7c5 commit c093bf1

2 files changed

Lines changed: 135 additions & 94 deletions

File tree

src/android_jni.rs

Lines changed: 112 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -313,11 +313,38 @@ pub extern "system" fn Java_com_therealaleph_mhrv_Native_startDriveProxy(
313313
}
314314
};
315315

316+
// Validate the Drive backend SYNCHRONOUSLY before we hand a
317+
// handle back to Kotlin. Loads credentials.json, refreshes the
318+
// OAuth access token, and ensures the target folder exists —
319+
// any of which can fail (no token cached, expired refresh,
320+
// network unreachable, folder ID typo, ...). Without this
321+
// up-front validation, startDriveProxy would return a non-zero
322+
// handle even on credential failure, leaving Kotlin with a
323+
// dead service holding a zombie handle.
324+
//
325+
// The runtime is the same one the listener will run on, so
326+
// the HTTP/2 connection task spawned during `build_backend`
327+
// stays alive after we return.
328+
let backend = match rt.block_on(crate::drive_tunnel::build_backend(&config)) {
329+
Ok(b) => b,
330+
Err(e) => {
331+
tracing::error!("android: drive backend init failed: {}", e);
332+
// Drop the runtime explicitly — `build_backend` may have
333+
// spawned the HTTP/2 driver task that we no longer need.
334+
rt.shutdown_timeout(std::time::Duration::from_secs(1));
335+
return 0i64;
336+
}
337+
};
338+
316339
let (tx, rx) = oneshot::channel::<()>();
317340
let cfg_for_task = config;
318341
rt.spawn(async move {
319-
if let Err(e) =
320-
crate::drive_tunnel::run_client_with_shutdown(&cfg_for_task, rx).await
342+
if let Err(e) = crate::drive_tunnel::run_client_with_backend(
343+
&cfg_for_task,
344+
backend,
345+
rx,
346+
)
347+
.await
321348
{
322349
tracing::error!("android: drive client exited: {}", e);
323350
}
@@ -386,52 +413,32 @@ pub extern "system" fn Java_com_therealaleph_mhrv_Native_driveCompleteAuth<'a>(
386413
config_json: JString,
387414
code: JString,
388415
) -> jstring {
389-
let result_json = safe(
390-
r#"{"ok":false,"error":"panic"}"#.to_string(),
391-
AssertUnwindSafe(|| {
392-
install_logging_once();
393-
let json = jstring_to_string(&mut env, &config_json);
394-
let raw = jstring_to_string(&mut env, &code);
395-
if raw.trim().is_empty() {
396-
return r#"{"ok":false,"error":"empty code"}"#.to_string();
397-
}
398-
let config: Config = match serde_json::from_str(&json) {
399-
Ok(c) => c,
400-
Err(e) => {
401-
return format!(
402-
r#"{{"ok":false,"error":"bad config json: {}"}}"#,
403-
json_escape(&e.to_string())
404-
);
405-
}
406-
};
407-
let backend =
408-
match crate::google_drive::GoogleDriveBackend::from_config(&config) {
409-
Ok(b) => b,
410-
Err(e) => {
411-
return format!(
412-
r#"{{"ok":false,"error":"{}"}}"#,
413-
json_escape(&e.to_string())
414-
);
415-
}
416-
};
417-
let Some(rt) = one_shot_runtime() else {
418-
return r#"{"ok":false,"error":"tokio init failed"}"#.to_string();
419-
};
420-
match rt.block_on(backend.apply_auth_code(&raw)) {
421-
Ok(()) => {
422-
let path = backend.token_path().display().to_string();
423-
format!(
424-
r#"{{"ok":true,"tokenPath":"{}"}}"#,
425-
json_escape(&path)
426-
)
427-
}
428-
Err(e) => format!(
429-
r#"{{"ok":false,"error":"{}"}}"#,
430-
json_escape(&e.to_string())
431-
),
416+
let result_json = safe(error_json("panic"), AssertUnwindSafe(|| {
417+
install_logging_once();
418+
let json = jstring_to_string(&mut env, &config_json);
419+
let raw = jstring_to_string(&mut env, &code);
420+
if raw.trim().is_empty() {
421+
return error_json("empty code");
422+
}
423+
let config: Config = match serde_json::from_str(&json) {
424+
Ok(c) => c,
425+
Err(e) => return error_json(&format!("bad config json: {}", e)),
426+
};
427+
let backend = match crate::google_drive::GoogleDriveBackend::from_config(&config) {
428+
Ok(b) => b,
429+
Err(e) => return error_json(&e.to_string()),
430+
};
431+
let Some(rt) = one_shot_runtime() else {
432+
return error_json("tokio init failed");
433+
};
434+
match rt.block_on(backend.apply_auth_code(&raw)) {
435+
Ok(()) => {
436+
let path = backend.token_path().display().to_string();
437+
serde_json::json!({"ok": true, "tokenPath": path}).to_string()
432438
}
433-
}),
434-
);
439+
Err(e) => error_json(&e.to_string()),
440+
}
441+
}));
435442
env.new_string(result_json)
436443
.map(|s| s.into_raw())
437444
.unwrap_or(std::ptr::null_mut())
@@ -466,8 +473,12 @@ pub extern "system" fn Java_com_therealaleph_mhrv_Native_driveTokenPresent(
466473
}))
467474
}
468475

469-
fn json_escape(s: &str) -> String {
470-
s.replace('\\', "\\\\").replace('"', "\\\"")
476+
/// Build a `{"ok": false, "error": "<msg>"}` blob with proper JSON
477+
/// escaping. Wraps `serde_json::json!` so callers don't need to spell
478+
/// out the shape on every error site, and so a stray `\n` / `"` in an
479+
/// error message can't poison the parser on the Kotlin side.
480+
fn error_json(msg: &str) -> String {
481+
serde_json::json!({"ok": false, "error": msg}).to_string()
471482
}
472483

473484
/// `Native.stopProxy(long handle)` -> boolean. Idempotent: calling on an
@@ -583,50 +594,58 @@ pub extern "system" fn Java_com_therealaleph_mhrv_Native_checkUpdate<'a>(
583594
env: JNIEnv<'a>,
584595
_class: JClass,
585596
) -> jstring {
586-
let result_json = safe(
587-
r#"{"kind":"error","reason":"panic"}"#.to_string(),
588-
AssertUnwindSafe(|| {
589-
install_logging_once();
590-
let Some(rt) = one_shot_runtime() else {
591-
return r#"{"kind":"error","reason":"tokio init failed"}"#.to_string();
592-
};
593-
let outcome = rt.block_on(crate::update_check::check(
594-
crate::update_check::Route::Direct,
595-
));
596-
update_check_to_json(&outcome)
597-
}),
598-
);
597+
// Default-on-panic value uses the same `kind`/`reason` shape the
598+
// happy path produces so the Kotlin parser doesn't need a special
599+
// case for unwind crashes.
600+
let panic_default = serde_json::json!({"kind": "error", "reason": "panic"}).to_string();
601+
let result_json = safe(panic_default, AssertUnwindSafe(|| {
602+
install_logging_once();
603+
let Some(rt) = one_shot_runtime() else {
604+
return serde_json::json!({"kind": "error", "reason": "tokio init failed"})
605+
.to_string();
606+
};
607+
let outcome =
608+
rt.block_on(crate::update_check::check(crate::update_check::Route::Direct));
609+
update_check_to_json(&outcome)
610+
}));
599611
env.new_string(result_json)
600612
.map(|s| s.into_raw())
601613
.unwrap_or(std::ptr::null_mut())
602614
}
603615

604616
fn update_check_to_json(u: &crate::update_check::UpdateCheck) -> String {
605-
// Hand-serialized to keep the JNI side free of serde derive noise on
606-
// the inner enum (which would need `#[derive(Serialize)]`). Short
607-
// enough that the hand-rolled version is simpler than pulling
608-
// serde_json in here for one call.
609-
fn esc(s: &str) -> String {
610-
s.replace('\\', "\\\\").replace('"', "\\\"")
611-
}
612-
match u {
613-
crate::update_check::UpdateCheck::UpToDate { current, latest } => format!(
614-
r#"{{"kind":"upToDate","current":"{}","latest":"{}"}}"#,
615-
esc(current), esc(latest),
616-
),
617-
crate::update_check::UpdateCheck::UpdateAvailable { current, latest, release_url, .. } => format!(
618-
r#"{{"kind":"updateAvailable","current":"{}","latest":"{}","url":"{}"}}"#,
619-
esc(current), esc(latest), esc(release_url),
620-
),
621-
crate::update_check::UpdateCheck::Offline(reason) => format!(
622-
r#"{{"kind":"offline","reason":"{}"}}"#,
623-
esc(reason),
624-
),
625-
crate::update_check::UpdateCheck::Error(reason) => format!(
626-
r#"{{"kind":"error","reason":"{}"}}"#,
627-
esc(reason),
628-
),
629-
}
617+
// serde_json::json! handles all the JSON escaping (control chars,
618+
// backslashes, embedded quotes, non-BMP code points) in one go;
619+
// the hand-rolled escaper that lived here only handled `\\` and
620+
// `"`, so a `\n` in an offline reason or release-note URL would
621+
// produce malformed JSON the Kotlin side couldn't parse.
622+
let value = match u {
623+
crate::update_check::UpdateCheck::UpToDate { current, latest } => serde_json::json!({
624+
"kind": "upToDate",
625+
"current": current,
626+
"latest": latest,
627+
}),
628+
crate::update_check::UpdateCheck::UpdateAvailable {
629+
current,
630+
latest,
631+
release_url,
632+
..
633+
} => serde_json::json!({
634+
"kind": "updateAvailable",
635+
"current": current,
636+
"latest": latest,
637+
"url": release_url,
638+
}),
639+
crate::update_check::UpdateCheck::Offline(reason) => serde_json::json!({
640+
"kind": "offline",
641+
"reason": reason,
642+
}),
643+
crate::update_check::UpdateCheck::Error(reason) => serde_json::json!({
644+
"kind": "error",
645+
"reason": reason,
646+
}),
647+
};
648+
value.to_string()
630649
}
631650

632651
/// `Native.testSni(googleIp, sni)` -> String. Returns a small JSON blob
@@ -639,21 +658,21 @@ pub extern "system" fn Java_com_therealaleph_mhrv_Native_testSni<'a>(
639658
google_ip: JString,
640659
sni: JString,
641660
) -> jstring {
642-
let result_json = safe(r#"{"ok":false,"error":"panic"}"#.to_string(), AssertUnwindSafe(|| {
661+
let result_json = safe(error_json("panic"), AssertUnwindSafe(|| {
643662
install_logging_once();
644663
let ip = jstring_to_string(&mut env, &google_ip);
645664
let s = jstring_to_string(&mut env, &sni);
646665
if ip.is_empty() || s.is_empty() {
647-
return r#"{"ok":false,"error":"empty google_ip or sni"}"#.to_string();
666+
return error_json("empty google_ip or sni");
648667
}
649668
let Some(rt) = one_shot_runtime() else {
650-
return r#"{"ok":false,"error":"tokio init failed"}"#.to_string();
669+
return error_json("tokio init failed");
651670
};
652671
let probe = rt.block_on(crate::scan_sni::probe_one(&ip, &s));
653672
match (probe.latency_ms, probe.error) {
654673
(Some(ms), _) => {
655674
tracing::info!("sni_probe: {} via {} ok in {}ms", s, ip, ms);
656-
format!(r#"{{"ok":true,"latencyMs":{}}}"#, ms)
675+
serde_json::json!({"ok": true, "latencyMs": ms}).to_string()
657676
}
658677
(None, Some(e)) => {
659678
// Surface the reason in logcat too — otherwise users see a
@@ -662,10 +681,9 @@ pub extern "system" fn Java_com_therealaleph_mhrv_Native_testSni<'a>(
662681
// - "connect: ..." -> TCP to google_ip:443 blocked
663682
// - "handshake: ..." -> TLS fail (cert, ALPN, etc.)
664683
tracing::warn!("sni_probe: {} via {} FAIL: {}", s, ip, e);
665-
let cleaned = e.replace('\\', "\\\\").replace('"', "\\\"");
666-
format!(r#"{{"ok":false,"error":"{}"}}"#, cleaned)
684+
error_json(&e)
667685
}
668-
_ => r#"{"ok":false,"error":"unknown"}"#.to_string(),
686+
_ => error_json("unknown"),
669687
}
670688
}));
671689
env.new_string(result_json).map(|s| s.into_raw()).unwrap_or(std::ptr::null_mut())

src/drive_tunnel.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -751,6 +751,20 @@ pub async fn run_client_with_shutdown(
751751
shutdown: tokio::sync::oneshot::Receiver<()>,
752752
) -> Result<(), DriveError> {
753753
let backend = init_backend(config).await?;
754+
run_client_with_backend(config, backend, shutdown).await
755+
}
756+
757+
/// Run the SOCKS5 client side with a pre-built (and pre-validated) Drive
758+
/// backend. JNI / UI entry points use this so OAuth refresh + folder
759+
/// discovery can happen synchronously up front and surface any failure
760+
/// before they commit to spawning the listener task. The runtime that
761+
/// drives this future must be the same one the `backend` was built
762+
/// against — its HTTP/2 connection task is already attached to it.
763+
pub async fn run_client_with_backend(
764+
config: &Config,
765+
backend: Arc<GoogleDriveBackend>,
766+
shutdown: tokio::sync::oneshot::Receiver<()>,
767+
) -> Result<(), DriveError> {
754768
let client_id = if config.drive_client_id.trim().is_empty() {
755769
random_hex(4)
756770
} else {
@@ -790,6 +804,15 @@ pub async fn run_client_with_shutdown(
790804
}
791805
}
792806

807+
/// Build and validate a Drive backend (loads credentials JSON, refreshes
808+
/// the OAuth access token, ensures the target folder exists). Surfaces
809+
/// any failure synchronously so JNI / UI callers can early-return
810+
/// before spawning long-lived state. Public so it can be shared by the
811+
/// CLI and the JNI entry points.
812+
pub async fn build_backend(config: &Config) -> Result<Arc<GoogleDriveBackend>, DriveError> {
813+
init_backend(config).await
814+
}
815+
793816
pub async fn run_server(config: &Config) -> Result<(), DriveError> {
794817
let backend = init_backend(config).await?;
795818
let (new_tx, mut new_rx) = mpsc::channel(1024);

0 commit comments

Comments
 (0)