Skip to content

Commit a216fb2

Browse files
fix(drive): rollback uploads, tighten validation, dedupe helpers
1 parent c093bf1 commit a216fb2

10 files changed

Lines changed: 394 additions & 88 deletions

File tree

Dockerfile.drive-node

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,11 @@
1818
# refresh token, chmod 0600) into the same dir on successful OAuth.
1919

2020
# ---- builder ------------------------------------------------------------
21-
# Need >= 1.85 for the edition2024 stabilization that time-macros (and a
22-
# few other transitive deps in our lockfile) now require. `rust:1` always
23-
# points at the latest 1.x stable — fine for a build image we throw away.
24-
FROM rust:1-slim-bookworm AS builder
21+
# Pin the Rust toolchain so a future `rust:1` retag (or a transitive dep
22+
# bumping its MSRV) can't silently break this image. Need >= 1.85 for
23+
# the edition2024 stabilization that time-macros and a few other
24+
# transitive deps in our lockfile now require; bump deliberately.
25+
FROM rust:1.85-slim-bookworm AS builder
2526
WORKDIR /src
2627

2728
# `ring` (TLS backend) needs a C compiler + assembler. Everything else is

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -338,7 +338,11 @@ Tune `drive_idle_timeout_secs` (default 300) upward if you tunnel long-poll HTTP
338338
339339
### Onboarding a non-technical user (Android)
340340

341-
Once one device has finished OAuth, you can hand the configured state to another via QR or text — no Cloud Console steps required on the receiving end. In the Drive section: **Share Drive setup****Show QR + payload** → copy / send the `mhrv-rs-setup://...` link via WhatsApp / Telegram / SMS. The recipient pastes the link, scans the QR, picks the QR image from their gallery, or just taps the link if their messenger linkifies it. The bundle includes the OAuth refresh token, so they don't run their own consent flow — they share the sharer's Google identity for `drive.file` scope.
341+
Once one device has finished OAuth, you can hand the configured state to another via QR or text — no Cloud Console steps required on the receiving end. In the Drive section: **Share Drive setup****Show QR + payload** → copy / send the `mhrv-rs-setup://import/...` link via WhatsApp / Telegram / SMS. The recipient pastes the link, scans the QR, picks the QR image from their gallery, or just taps the link if their messenger linkifies it. The bundle includes the OAuth refresh token, so they don't run their own consent flow — they share the sharer's Google identity for `drive.file` scope.
342+
343+
> **Read this before you share.** The setup blob bundles the OAuth `client_secret` AND a long-lived refresh token. Anything that can read the QR / link — a chat backup, a screenshot synced to cloud, a compromised device — gets the same `drive.file` access this app has, indefinitely. There is no per-recipient revoke: the only way to invalidate a leaked share is to rotate (or delete) the OAuth client in Google Cloud Console, which also kicks every device you've already onboarded with that client. Treat the share like a long-lived password: keep the recipient list small, prefer scanning camera-to-camera over messengers, and rotate the OAuth client on a schedule if the same identity is shared widely.
344+
>
345+
> If you want per-device revocation without a Cloud Console round-trip, do the OAuth flow separately on each device instead of using setup-share — refresh tokens minted from independent consent flows can be revoked one at a time from your Google Account's "Third-party apps with account access" page.
342346
343347
Caveat: the **sharer** still needs an unfiltered path to `accounts.google.com` for the initial OAuth dance, since the consent page opens in their system browser. If your network blocks Google Accounts, do the initial OAuth on a different network (mobile data, friend's Wi-Fi) and then share the resulting setup. Recipients aren't bound by this — they get the refresh token via the QR.
344348

android/app/src/main/AndroidManifest.xml

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -61,17 +61,19 @@
6161
<category android:name="android.intent.category.BROWSABLE" />
6262
<data android:scheme="mhrv-rs" />
6363
</intent-filter>
64-
<!-- Drive setup deep link: tapping mhrv-rs-setup://... in
65-
WhatsApp / Telegram / SMS opens the app and offers to
66-
import the bundled credentials + refresh token. Distinct
67-
scheme so the recipient is asked to confirm a different
68-
(credential-bearing) payload than the regular config
69-
share. -->
64+
<!-- Drive setup deep link: tapping
65+
mhrv-rs-setup://import/<base64> in WhatsApp / Telegram /
66+
SMS opens the app and offers to import the bundled
67+
credentials + refresh token. Distinct scheme + fixed
68+
host="import" so a foreign URL like
69+
`mhrv-rs-setup://attacker.example/...` doesn't trigger
70+
the import flow; the trust prompt is the second line
71+
of defence. -->
7072
<intent-filter>
7173
<action android:name="android.intent.action.VIEW" />
7274
<category android:name="android.intent.category.DEFAULT" />
7375
<category android:name="android.intent.category.BROWSABLE" />
74-
<data android:scheme="mhrv-rs-setup" />
76+
<data android:scheme="mhrv-rs-setup" android:host="import" />
7577
</intent-filter>
7678
</activity>
7779

android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -298,8 +298,13 @@ object ConfigStore {
298298
* + refresh token so a recipient can connect with no manual OAuth.
299299
* Different from [HASH_PREFIX] because the payload includes secrets,
300300
* the recipient flow needs to write extra files, and we don't want
301-
* to silently fall through to the regular config import path. */
302-
private const val DRIVE_SETUP_PREFIX = "mhrv-rs-setup://"
301+
* to silently fall through to the regular config import path.
302+
*
303+
* The fixed `import/` host narrows the deep-link surface: the
304+
* AndroidManifest filter requires `android:host="import"`, so a
305+
* foreign URL like `mhrv-rs-setup://attacker.example/...` won't
306+
* even reach the trust prompt. */
307+
private const val DRIVE_SETUP_PREFIX = "mhrv-rs-setup://import/"
303308

304309
/** Filename inside the app's filesDir where imported credentials are
305310
* written. Must match what the regular Drive import flow uses, so
@@ -437,7 +442,14 @@ object ConfigStore {
437442
/** Read the on-disk credentials + token files and bundle them with
438443
* the user's Drive config knobs into a shareable string. Returns
439444
* null when there's nothing to share (no credentials imported, or
440-
* no token cached yet — the sharer has to complete OAuth first). */
445+
* no token cached yet — the sharer has to complete OAuth first).
446+
*
447+
* Caller is responsible for showing a destructive-action warning
448+
* before producing the QR. The bundle contains the OAuth
449+
* `client_secret` and a long-lived refresh token; anyone with the
450+
* QR (or a backup of the chat that delivered it) keeps `drive.file`
451+
* access until the sharer rotates the OAuth client in Google
452+
* Cloud Console. There is no per-recipient revoke. */
441453
fun encodeDriveSetup(ctx: Context, cfg: MhrvConfig): String? {
442454
if (cfg.driveCredentialsPath.isBlank()) return null
443455
val credsFile = File(cfg.driveCredentialsPath)
@@ -540,19 +552,10 @@ object ConfigStore {
540552
tokenFile.writeText(JSONObject().apply {
541553
put("refresh_token", setup.refreshToken)
542554
}.toString())
543-
// Best-effort 0600. Android's FileProvider sandbox already
544-
// walls /data/user/0/<pkg>/files/ off from other apps, so
545-
// this is belt-and-braces.
546-
runCatching {
547-
credsFile.setReadable(false, false)
548-
credsFile.setReadable(true, true)
549-
credsFile.setWritable(false, false)
550-
credsFile.setWritable(true, true)
551-
tokenFile.setReadable(false, false)
552-
tokenFile.setReadable(true, true)
553-
tokenFile.setWritable(false, false)
554-
tokenFile.setWritable(true, true)
555-
}
555+
// No setReadable/setWritable dance: Android's per-app
556+
// sandbox under /data/user/0/<pkg>/files/ already walls
557+
// these files off from other apps. The previous gymnastics
558+
// were no-ops on every Android version we support.
556559
base.copy(
557560
mode = Mode.GOOGLE_DRIVE,
558561
driveCredentialsPath = credsFile.absolutePath,

android/app/src/main/res/values-fa/strings.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@
130130
<string name="btn_drive_setup_import">وارد کردن</string>
131131
<string name="help_drive_scan_setup">اگر کسی QR تنظیمات Drive را با شما به اشتراک گذاشته، اینجا اسکن کنید. برنامه credentials و توکن OAuth را می‌نویسد، folder_id را تنظیم می‌کند و فقط کافی است Connect را بزنید — نیازی به Google Cloud Console یا مرورگر نیست.</string>
132132
<string name="dialog_drive_share_title">اشتراک‌گذاری تنظیمات Drive</string>
133-
<string name="dialog_drive_share_warning">این QR شامل client secret و refresh token شما است. هر کس آن را اسکن کند به همان مقدار دسترسی به Drive شما خواهد داشت که این برنامه دارد. فقط با افراد قابل اعتماد به اشتراک بگذارید.</string>
133+
<string name="dialog_drive_share_warning">این QR شامل client secret و refresh token شما است. هر کس آن را اسکن کند — یا بعداً در پشتیبان چت، اسکرین‌شات یا دستگاه آلوده پیدا کند — همان دسترسی `drive.file` این برنامه را خواهد داشت و تا زمانی که OAuth client را در Google Cloud Console عوض نکنید نگه می‌دارد (که این دستگاه را هم خارج می‌کند). امکان لغو دسترسی فقط برای یک گیرنده وجود ندارد. آن را مثل یک رمز عبور بلندمدت در نظر بگیرید و فقط با افراد قابل اعتماد به اشتراک بگذارید.</string>
134134
<string name="dialog_drive_share_unavailable">هنوز چیزی برای اشتراک‌گذاری وجود ندارد — ابتدا روی این دستگاه OAuth را تکمیل کنید (دکمهٔ احراز Google Drive).</string>
135135
<string name="dialog_drive_share_qr_too_large">حجم بسته برای QR زیاد است. از دکمه‌های کپی / ارسال برای فرستادن متن استفاده کنید.</string>
136136
<string name="dialog_drive_setup_scan_prompt">QR تنظیمات Drive را اسکن کنید</string>

android/app/src/main/res/values/strings.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@
145145
<string name="btn_drive_setup_import">Import</string>
146146
<string name="help_drive_scan_setup">If someone shared a Drive setup QR with you, scan it here. The app will write their credentials and OAuth token, set the folder ID, and you can tap Connect — no Google Cloud Console or browser steps needed.</string>
147147
<string name="dialog_drive_share_title">Share Drive setup</string>
148-
<string name="dialog_drive_share_warning">This QR contains your OAuth client secret AND your Drive refresh token. Anyone who scans it gets the same access to your Drive that this app has. Only share with people you trust.</string>
148+
<string name="dialog_drive_share_warning">This QR contains your OAuth client secret AND your Drive refresh token. Anyone who scans it — or later finds it in a chat backup, screenshot, or compromised device — gets the same `drive.file` access this app has, and keeps it until you rotate the OAuth client in Google Cloud Console (which also kicks THIS device). There is no way to revoke a single recipient. Treat it like a long-lived password and only share with people you trust.</string>
149149
<string name="dialog_drive_share_unavailable">Nothing to share yet — finish OAuth on this device first (Authorize Google Drive button).</string>
150150
<string name="dialog_drive_share_qr_too_large">Setup payload is too large for a QR code. Use the Copy / Send buttons to share the text instead.</string>
151151
<string name="dialog_drive_setup_scan_prompt">Scan a Drive setup QR</string>

src/bin/ui.rs

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -153,11 +153,6 @@ struct UiState {
153153
/// Result of the most recent `DriveCompleteAuth`. `Ok(token_path)` on
154154
/// success, `Err(message)` otherwise.
155155
drive_auth_result: Option<Result<String, String>>,
156-
/// Whether the Drive client is the one currently held in `active`
157-
/// on the background thread. Independent from `running` because the
158-
/// drive client doesn't go through ProxyServer / DomainFronter, so
159-
/// the stats panel stays empty while it runs.
160-
drive_running: bool,
161156
}
162157

163158
#[derive(Clone, Debug)]
@@ -294,6 +289,12 @@ struct FormState {
294289
drive_poll_ms: u64,
295290
drive_flush_ms: u64,
296291
drive_idle_timeout_secs: u64,
292+
/// Round-tripped from config.json so a hand-edited override
293+
/// survives a save. Not surfaced as a UI control; `0` means "use
294+
/// built-in default" (currently 8). Hidden because the right value
295+
/// is dictated by the user's network and Drive quota — operators
296+
/// who care can edit config.json directly.
297+
drive_storage_concurrency: usize,
297298
}
298299

299300
#[derive(Clone, Debug)]
@@ -385,6 +386,7 @@ fn load_form() -> (FormState, Option<String>) {
385386
drive_poll_ms: c.drive_poll_ms,
386387
drive_flush_ms: c.drive_flush_ms,
387388
drive_idle_timeout_secs: c.drive_idle_timeout_secs,
389+
drive_storage_concurrency: c.drive_storage_concurrency,
388390
}
389391
} else {
390392
FormState {
@@ -421,6 +423,7 @@ fn load_form() -> (FormState, Option<String>) {
421423
drive_poll_ms: 500,
422424
drive_flush_ms: 300,
423425
drive_idle_timeout_secs: 300,
426+
drive_storage_concurrency: 0,
424427
}
425428
};
426429
(form, load_err)
@@ -601,6 +604,7 @@ impl FormState {
601604
drive_poll_ms: self.drive_poll_ms,
602605
drive_flush_ms: self.drive_flush_ms,
603606
drive_idle_timeout_secs: self.drive_idle_timeout_secs,
607+
drive_storage_concurrency: self.drive_storage_concurrency,
604608
})
605609
}
606610
}
@@ -671,12 +675,18 @@ struct ConfigWire<'a> {
671675
drive_flush_ms: u64,
672676
#[serde(skip_serializing_if = "is_zero_u64")]
673677
drive_idle_timeout_secs: u64,
678+
#[serde(skip_serializing_if = "is_zero_usize")]
679+
drive_storage_concurrency: usize,
674680
}
675681

676682
fn is_zero_u64(v: &u64) -> bool {
677683
*v == 0
678684
}
679685

686+
fn is_zero_usize(v: &usize) -> bool {
687+
*v == 0
688+
}
689+
680690
fn is_false(b: &bool) -> bool {
681691
!*b
682692
}
@@ -759,6 +769,11 @@ impl<'a> From<&'a Config> for ConfigWire<'a> {
759769
} else {
760770
0
761771
},
772+
drive_storage_concurrency: if c.mode == "google_drive" {
773+
c.drive_storage_concurrency
774+
} else {
775+
0
776+
},
762777
}
763778
}
764779
}
@@ -2222,12 +2237,27 @@ fn pick_credentials_file() -> Option<String> {
22222237
$f = New-Object System.Windows.Forms.OpenFileDialog; \
22232238
$f.Filter = 'JSON files (*.json)|*.json|All files (*.*)|*.*'; \
22242239
if ($f.ShowDialog() -eq 'OK') { Write-Output $f.FileName }";
2225-
let out = std::process::Command::new("powershell")
2226-
.args(["-NoProfile", "-Command", script])
2227-
.output()
2228-
.ok()?;
2229-
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
2230-
if s.is_empty() { None } else { Some(s) }
2240+
// Try PowerShell 7 (`pwsh`) first, then Windows PowerShell. Some
2241+
// hardened images ship only one or the other, so falling through
2242+
// both lets the dialog work regardless. The script is identical
2243+
// for both interpreters.
2244+
for exe in &["pwsh", "powershell"] {
2245+
let Ok(out) = std::process::Command::new(exe)
2246+
.args(["-NoProfile", "-Command", script])
2247+
.output()
2248+
else {
2249+
continue;
2250+
};
2251+
if !out.status.success() {
2252+
continue;
2253+
}
2254+
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
2255+
if s.is_empty() {
2256+
return None;
2257+
}
2258+
return Some(s);
2259+
}
2260+
None
22312261
}
22322262
#[cfg(target_os = "macos")]
22332263
{
@@ -2325,7 +2355,6 @@ fn background_thread(shared: Arc<Shared>, rx: Receiver<Cmd>) {
23252355
let mut s = shared2.state.lock().unwrap();
23262356
s.running = true;
23272357
s.started_at = Some(Instant::now());
2328-
s.drive_running = true;
23292358
}
23302359
let port = cfg.socks5_port.unwrap_or(cfg.listen_port + 1);
23312360
push_log(
@@ -2341,7 +2370,6 @@ fn background_thread(shared: Arc<Shared>, rx: Receiver<Cmd>) {
23412370
st.running = false;
23422371
st.started_at = None;
23432372
st.proxy_active = false;
2344-
st.drive_running = false;
23452373
push_log(&shared2, "[ui] drive client stopped");
23462374
});
23472375
active = Some((handle, fronter_slot, shutdown_tx));

src/config.rs

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,13 @@ pub struct Config {
224224
/// default of 15 s was too aggressive for real protocols.
225225
#[serde(default = "default_drive_idle_timeout_secs")]
226226
pub drive_idle_timeout_secs: u64,
227+
/// Max concurrent in-flight Drive uploads/downloads. `0` (default)
228+
/// uses the built-in [`drive_tunnel::STORAGE_CONCURRENCY`] of 8.
229+
/// Bump up if you have a fat pipe and many sessions; HTTP/2
230+
/// multiplexes everything onto one TLS connection so the cost of
231+
/// raising this is just a few more in-flight streams.
232+
#[serde(default)]
233+
pub drive_storage_concurrency: usize,
227234
}
228235

229236
fn default_fetch_ips_from_api() -> bool {
@@ -315,9 +322,14 @@ impl Config {
315322
"drive_poll_ms and drive_flush_ms must be greater than 0".into(),
316323
));
317324
}
318-
if self.drive_idle_timeout_secs == 0 {
325+
// Floor at 15s to match the UI sliders. Lower values
326+
// force-close real protocols (TLS, long-poll HTTP, idle
327+
// WebSockets) on every flush and were previously only
328+
// rejected at zero — a hand-edited `config.json` could
329+
// still set 1 and silently break every connection.
330+
if self.drive_idle_timeout_secs < 15 {
319331
return Err(ConfigError::Invalid(
320-
"drive_idle_timeout_secs must be greater than 0".into(),
332+
"drive_idle_timeout_secs must be at least 15".into(),
321333
));
322334
}
323335
// The id is concatenated unsanitised into Drive filenames and
@@ -496,6 +508,30 @@ mod tests {
496508
assert_eq!(cfg.drive_idle_timeout_secs, 300);
497509
}
498510

511+
#[test]
512+
fn rejects_google_drive_idle_timeout_below_floor() {
513+
// Validator floor is 15s — below it a hand-edited config could
514+
// set 1 and force-close every session on each flush. Verify both
515+
// 0 and a low-but-positive value are rejected, and exactly 15
516+
// is accepted.
517+
let mk = |idle: u64| {
518+
format!(
519+
"{{\"mode\":\"google_drive\",\"drive_credentials_path\":\"c.json\",\"drive_idle_timeout_secs\":{}}}",
520+
idle
521+
)
522+
};
523+
for bad in [0u64, 1, 14] {
524+
let cfg: Config = serde_json::from_str(&mk(bad)).unwrap();
525+
assert!(
526+
cfg.validate().is_err(),
527+
"drive_idle_timeout_secs = {} should reject",
528+
bad
529+
);
530+
}
531+
let cfg: Config = serde_json::from_str(&mk(15)).unwrap();
532+
cfg.validate().expect("15s should be accepted");
533+
}
534+
499535
#[test]
500536
fn rejects_google_drive_client_id_with_special_chars() {
501537
let s = r#"{

0 commit comments

Comments
 (0)