From 021399379c5339ab0168d39762852a6dcd6cc7a2 Mon Sep 17 00:00:00 2001 From: dazzling-no-more <278675588+dazzling-no-more@users.noreply.github.com> Date: Mon, 11 May 2026 15:03:38 +0400 Subject: [PATCH] feat: multi-profile config storage (desktop + Android) --- android/app/build.gradle.kts | 7 + .../java/com/therealaleph/mhrv/ConfigStore.kt | 170 ++- .../com/therealaleph/mhrv/ProfileStore.kt | 620 ++++++++++ .../com/therealaleph/mhrv/ui/HomeScreen.kt | 41 +- .../com/therealaleph/mhrv/ui/ProfileBar.kt | 499 ++++++++ .../app/src/main/res/values-fa/strings.xml | 35 + android/app/src/main/res/values/strings.xml | 37 + .../com/therealaleph/mhrv/ProfileStoreTest.kt | 655 ++++++++++ src/bin/ui.rs | 912 +++++++++++++- src/config.rs | 57 +- src/lib.rs | 1 + src/profiles.rs | 1086 +++++++++++++++++ 12 files changed, 4094 insertions(+), 26 deletions(-) create mode 100644 android/app/src/main/java/com/therealaleph/mhrv/ProfileStore.kt create mode 100644 android/app/src/main/java/com/therealaleph/mhrv/ui/ProfileBar.kt create mode 100644 android/app/src/test/java/com/therealaleph/mhrv/ProfileStoreTest.kt create mode 100644 src/profiles.rs diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 2cb00e5f..b2ed5010 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -142,6 +142,13 @@ dependencies { debugImplementation("androidx.compose.ui:ui-tooling") debugImplementation("androidx.compose.ui:ui-test-manifest") + + // Local JVM unit tests (Robolectric so we can use Android Context + // without standing up an emulator). Used by ProfileStoreTest to + // verify the storage invariants documented in ProfileStore.kt. + testImplementation("junit:junit:4.13.2") + testImplementation("org.robolectric:robolectric:4.13") + testImplementation("androidx.test:core:1.6.1") } // -------------------------------------------------------------------------- diff --git a/android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt b/android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt index 0b982737..bccb7692 100644 --- a/android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt +++ b/android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt @@ -161,6 +161,29 @@ data class MhrvConfig( /** UI language toggle. Non-Rust; honoured only by the Android wrapper. */ val uiLang: UiLang = UiLang.AUTO, + + /** + * Verbatim JSON for any config.json key this build doesn't model + * (e.g. desktop-only `fronting_groups`, `exit_node`, + * `request_timeout_secs`, `disable_padding`, `auto_blacklist_*`, + * `hosts`, `normalize_x_graphql`, and any future Rust-side field + * added before Android catches up). + * + * Captured by [ConfigStore.loadFromJson] and re-emitted by + * [toJson] so a Rust-shaped or future-shaped config survives a + * round-trip through the Android UI **without losing fields the + * native runtime still needs**. The whole point of the Profile + * "raw snapshot preservation" invariant is that the Rust side + * sees those fields — and the Rust side reads `config.json`, + * which is what we write here. + * + * Stored as a JSON object string (not Map) so we can splice it + * back in via [JSONObject.put] without retyping every key. + * Default empty = no passthrough fields. + * + * Excluded from [toJson]'s output when blank. + */ + val extrasJson: String = "", ) { /** * Extract just the deployment ID from either a full @@ -279,6 +302,27 @@ data class MhrvConfig( UiLang.FA -> "fa" UiLang.EN -> "en" }) + + // Splice back any keys this build doesn't model (so they + // survive a load → edit → save round-trip and reach the + // native runtime, which IS the source of truth for them). + // We deliberately don't overwrite our modelled keys — if a + // future build models a field that's currently in extras, + // the new modelled value wins on the next save. + if (extrasJson.isNotBlank()) { + try { + val ex = JSONObject(extrasJson) + val it = ex.keys() + while (it.hasNext()) { + val k = it.next() + if (!has(k)) put(k, ex.get(k)) + } + } catch (_: Throwable) { + // Malformed extras — drop. Captured-at-parse-time + // extras should never be malformed; this guard is + // for the synthetic-cfg path (decode()). + } + } } return obj.toString(2) } @@ -301,9 +345,24 @@ object ConfigStore { } } - fun save(ctx: Context, cfg: MhrvConfig) { + /** + * Persist [cfg] to `config.json`. Returns true on success. + * + * Atomicity: writes to `config.json.tmp` and replaces via the same + * NIO/backup pattern as [ProfileStore.save] — never deletes the + * existing file without a backup. On failure the previous + * `config.json` is preserved untouched (or restored from `.bak`). + */ + fun save(ctx: Context, cfg: MhrvConfig): Boolean { val f = File(ctx.filesDir, FILE) - f.writeText(cfg.toJson()) + val tmp = File(ctx.filesDir, "$FILE.tmp") + return try { + tmp.writeText(cfg.toJson()) + ProfileStore.atomicReplacePublic(tmp, f) + } catch (_: Throwable) { + tmp.delete() + false + } } /** Prefix for encoded config strings so we can detect them in clipboard. */ @@ -387,8 +446,7 @@ object ConfigStore { if (trimmed.startsWith("{")) { return try { val obj = JSONObject(trimmed) - if (!obj.has("mode") && !obj.has("script_ids") && !obj.has("auth_key")) null - else loadFromJson(obj) + if (!hasConfigShape(obj)) null else loadFromJson(obj) } catch (_: Throwable) { null } } // Try mhrv:// base64 encoded (possibly DEFLATE-compressed). @@ -397,7 +455,7 @@ object ConfigStore { val raw = android.util.Base64.decode(payload, android.util.Base64.NO_WRAP or android.util.Base64.URL_SAFE) val text = inflateOrRaw(raw) val obj = JSONObject(text) - if (!obj.has("mode") && !obj.has("script_ids") && !obj.has("auth_key")) return null + if (!hasConfigShape(obj)) return null loadFromJson(obj) } catch (_: Throwable) { null @@ -408,23 +466,89 @@ object ConfigStore { fun looksLikeConfig(text: String): Boolean { val t = text.trim() if (t.startsWith(HASH_PREFIX)) return true - // Also accept raw JSON with a "mode" field. if (t.startsWith("{")) { - return try { JSONObject(t).has("mode") } catch (_: Throwable) { false } + return try { hasConfigShape(JSONObject(t)) } catch (_: Throwable) { false } } return false } + /** + * Acceptance gate for "is this JSON shaped like an mhrv config?". + * Accepts any of `mode`, `auth_key`, `script_id` (Rust output), + * or `script_ids` (legacy Android output). `script_id` was added + * after the parser was taught to read both shapes — without it, + * a Rust-shaped config with only `script_id` would be rejected + * here even though [loadFromJson] could read it fine. + */ + private fun hasConfigShape(obj: JSONObject): Boolean = + obj.has("mode") || + obj.has("auth_key") || + obj.has("script_id") || + obj.has("script_ids") + + /** + * Keys this build models. Anything outside this set is captured + * into [MhrvConfig.extrasJson] at parse time and re-emitted by + * [MhrvConfig.toJson] so the native runtime keeps seeing + * desktop-only / future Rust-side fields (`fronting_groups`, + * `exit_node`, `request_timeout_secs`, `disable_padding`, + * `auto_blacklist_*`, `hosts`, `normalize_x_graphql`, + * `google_ip_validation`, `scan_batch_size`, etc.). + * + * Updating this set is a deliberate act — add a key here only + * when [MhrvConfig] gains a real field for it. + */ + private val MODELLED_KEYS: Set = setOf( + "mode", + "listen_host", "listen_port", "socks5_port", + // Both script_id (Rust output) and script_ids (legacy Android + // output) are read by us, so both belong in the "modelled" set + // — otherwise a Rust-shaped config would have its IDs end up + // in extras AND in the parsed appsScriptUrls, getting written + // out twice (once as the unmodelled passthrough, once as + // script_ids). + "script_id", "script_ids", + "auth_key", + "front_domain", "sni_hosts", "google_ip", + "verify_ssl", "log_level", "parallel_relay", + "force_http1", + "coalesce_step_ms", "coalesce_max_ms", + "block_quic", "upstream_socks5", + "passthrough_hosts", + "tunnel_doh", "bypass_doh_hosts", "block_doh", + "youtube_via_relay", + "connection_mode", "split_mode", "split_apps", "ui_lang", + // Phone-scoped scan defaults toJson() emits. Modelled so they + // don't round-trip into extras then collide with toJson's + // explicit puts. + "fetch_ips_from_api", "max_ips_to_scan", + ) + /** Parse config from a JSON object — shared by load() and decode(). */ private fun loadFromJson(obj: JSONObject): MhrvConfig { - val ids = obj.optJSONArray("script_ids")?.let { arr -> - buildList { for (i in 0 until arr.length()) add(arr.optString(i)) } - }?.filter { it.isNotBlank() }.orEmpty() + // Read deployment IDs from both `script_id` (Rust output) and + // `script_ids` (legacy Android output). Each can be a scalar + // string OR an array of strings. + val ids = buildList { + addAll(readScriptIdList(obj, "script_id")) + addAll(readScriptIdList(obj, "script_ids")) + }.filter { it.isNotBlank() }.distinct() val urls = ids.map { "https://script.google.com/macros/s/$it/exec" } val sni = obj.optJSONArray("sni_hosts")?.let { arr -> buildList { for (i in 0 until arr.length()) add(arr.optString(i)) } }?.filter { it.isNotBlank() }.orEmpty() + // Capture anything we don't model into extras for passthrough + // (raw-snapshot preservation invariant — the native runtime + // reads config.json directly and needs every field). + val extras = JSONObject() + val keys = obj.keys() + while (keys.hasNext()) { + val k = keys.next() + if (k !in MODELLED_KEYS) extras.put(k, obj.get(k)) + } + val extrasStr = if (extras.length() > 0) extras.toString() else "" + return MhrvConfig( mode = when (obj.optString("mode", "apps_script")) { "direct" -> Mode.DIRECT @@ -476,8 +600,34 @@ object ConfigStore { "en" -> UiLang.EN else -> UiLang.AUTO }, + extrasJson = extrasStr, ) } + + /** + * Read a list of deployment IDs from `key`. Accepts: + * - a JSON string scalar ("abc") + * - a JSON array of strings (["abc","def"]) + * + * Mirrors the Rust [ScriptId] enum's `untagged` deserialize so both + * shapes interop with desktop. Returns an empty list if the key + * is absent or shaped wrong. + */ + private fun readScriptIdList(obj: JSONObject, key: String): List { + if (!obj.has(key)) return emptyList() + // Array form first. + obj.optJSONArray(key)?.let { arr -> + return buildList { + for (i in 0 until arr.length()) { + val s = arr.optString(i, "") + if (s.isNotBlank()) add(s) + } + } + } + // Scalar form. + val s = obj.optString(key, "") + return if (s.isNotBlank()) listOf(s) else emptyList() + } } /** diff --git a/android/app/src/main/java/com/therealaleph/mhrv/ProfileStore.kt b/android/app/src/main/java/com/therealaleph/mhrv/ProfileStore.kt new file mode 100644 index 00000000..afb3d600 --- /dev/null +++ b/android/app/src/main/java/com/therealaleph/mhrv/ProfileStore.kt @@ -0,0 +1,620 @@ +package com.therealaleph.mhrv + +import android.content.Context +import org.json.JSONArray +import org.json.JSONObject +import java.io.File + +/** + * Profile storage. A profile is a named, complete snapshot of the user's + * config (deployment IDs, mode, auth key, tuning knobs — everything that + * lives in `config.json`). The Rust runtime keeps reading the single + * `config.json`; profiles are a UI-only convenience that lets the user + * keep several setups side by side (e.g. one Apps Script profile and + * one Full tunnel profile) and switch between them without re-typing. + * + * Mirror of `src/profiles.rs` on the Rust desktop side. The on-disk + * shape is intentionally identical so a profiles.json from desktop is + * importable into Android by file copy (and vice versa). + * + * # Invariants (must match Rust side, see `src/profiles.rs`) + * + * 1. **Raw snapshot preservation.** A profile's `config` is stored as + * the raw JSON object exactly as it was written. Switching a profile + * writes that raw JSON to `config.json` byte-for-byte (subject to + * pretty-print) — any config fields this build doesn't model (e.g. + * `fronting_groups`, `exit_node`, `request_timeout_secs`) must + * round-trip without loss. NEVER pass the snapshot through + * `MhrvConfig` parse + `toJson()` on the apply path; that drops + * unknown fields silently. + * + * 2. **`active` means "matches the live config".** `active = "name"` + * promises that `profiles[name].config` equals the current + * `config.json`. Any operation that breaks that promise must set + * `active = ""`. In particular: deleting the active profile sets + * active="" (we don't auto-apply some other profile, which would + * silently rewrite the user's live config). + * + * 3. **Persist before in-memory state changes.** Always write to disk + * first, only update caller-visible state on success. Otherwise a + * failed write leaves the UI showing state that disappears on + * restart. + * + * 4. **Load failure is loud.** A file that exists but won't parse is + * NOT the same as a missing file. We surface the distinction so the + * UI can refuse to clobber a corrupted-but-recoverable + * profiles.json with an empty one. + */ + +data class Profile( + val name: String, + /** Raw config JSON object — kept as a string so unknown fields + * round-trip even when this build doesn't know about them. */ + val configJson: String, +) + +object ProfileStore { + private const val FILE = "profiles.json" + + /** In-memory view of the profiles file. */ + data class State( + val active: String, + val profiles: List, + ) { + fun find(name: String): Profile? = profiles.firstOrNull { it.name == name } + fun names(): List = profiles.map { it.name } + } + + /** + * Distinguishes "no profiles file yet" from "file present but + * unreadable / unparseable". The unreadable case must NOT be + * silently flattened to empty — the next save would clobber the + * user's recoverable data with an empty file. + */ + sealed class LoadResult { + data class Ok(val state: State) : LoadResult() + data class Missing(val state: State) : LoadResult() + data class Corrupt(val raw: String?, val cause: Throwable) : LoadResult() + } + + /** Strict load: distinguishes missing vs unreadable vs parsed. */ + fun loadStrict(ctx: Context): LoadResult { + val f = File(ctx.filesDir, FILE) + if (!f.exists()) { + return LoadResult.Missing(State(active = "", profiles = emptyList())) + } + val text = try { + f.readText() + } catch (e: Throwable) { + return LoadResult.Corrupt(raw = null, cause = e) + } + if (text.isBlank()) { + return LoadResult.Ok(State(active = "", profiles = emptyList())) + } + return try { + LoadResult.Ok(parse(text)) + } catch (e: Throwable) { + LoadResult.Corrupt(raw = text, cause = e) + } + } + + /** + * Convenience load: returns an empty state for both "missing" AND + * "corrupt". Callers that don't care about the difference (e.g. + * read-only reads inside a click handler) can use this; callers + * that are about to write should use [loadStrict] and refuse to + * save over a corrupt file. + */ + fun load(ctx: Context): State = when (val r = loadStrict(ctx)) { + is LoadResult.Ok -> r.state + is LoadResult.Missing -> r.state + is LoadResult.Corrupt -> State(active = "", profiles = emptyList()) + } + + /** + * Save the in-memory state. Returns true on success. + * + * Atomicity strategy: + * - Write to `profiles.json.tmp`. + * - On API 26+ use [java.nio.file.Files.move] with + * `REPLACE_EXISTING` for a true atomic replace. + * - On older Android (24/25 — we minSdk=24) fall back to a + * backup-and-restore pattern: rename target → .bak, rename + * tmp → target, delete .bak; if the second rename fails, + * restore .bak → target. + * + * NEVER delete the target first without a backup — that opens a + * window where neither file exists if the subsequent rename + * fails. + */ + fun save(ctx: Context, state: State): Boolean { + val f = File(ctx.filesDir, FILE) + val tmp = File(ctx.filesDir, "$FILE.tmp") + return try { + tmp.writeText(encode(state)) + atomicReplace(tmp, f) + } catch (_: Throwable) { + tmp.delete() + false + } + } + + /** Public wrapper around [atomicReplace] so ConfigStore can share + * the same safe replace pattern without duplicating the logic. */ + internal fun atomicReplacePublic(source: File, target: File): Boolean = + atomicReplace(source, target) + + /** Replace `target` with `source` atomically. See [save] for the + * fallback rationale on minSdk 24/25. + * + * Refuses to replace a directory: `config.json` / `profiles.json` + * being a directory is an invariant violation we shouldn't + * silently "fix" by renaming the dir aside. The caller (e.g. + * ConfigStore.save) returns failure so the UI surfaces the + * problem instead of papering over it. */ + private fun atomicReplace(source: File, target: File): Boolean { + if (!source.exists()) return false + if (target.exists() && target.isDirectory) { + android.util.Log.w( + "ProfileStore", + "atomicReplace refused: target is a directory: ${target.absolutePath}", + ) + // Clean up the source so we don't litter a stale .tmp file. + source.delete() + return false + } + // Prefer NIO atomic move when available (API 26+). It's the only + // call that gives a real "no window where the file is missing" + // guarantee on Android. We accept ATOMIC_MOVE as best-effort — + // if the filesystem doesn't support it, REPLACE_EXISTING alone + // is still a safe replace (just possibly not atomic). + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { + return try { + java.nio.file.Files.move( + source.toPath(), + target.toPath(), + java.nio.file.StandardCopyOption.REPLACE_EXISTING, + java.nio.file.StandardCopyOption.ATOMIC_MOVE, + ) + true + } catch (_: java.nio.file.AtomicMoveNotSupportedException) { + // Fall through to the older-Android pattern below if + // the filesystem rejects ATOMIC_MOVE for some reason. + replaceWithBackup(source, target) + } catch (_: Throwable) { + replaceWithBackup(source, target) + } + } + return replaceWithBackup(source, target) + } + + /** Backup-restore replace pattern for older Android. + * Also refuses directory targets (same rationale as [atomicReplace]). */ + private fun replaceWithBackup(source: File, target: File): Boolean { + if (target.exists() && target.isDirectory) { + source.delete() + return false + } + if (!target.exists()) { + // No backup needed. + return source.renameTo(target).also { + if (!it) source.delete() + } + } + val backup = File(target.parentFile, "${target.name}.bak") + // Stale backup from a previous failure — remove before renaming + // onto it. If this fails we abort: a stale backup is the only + // thing preserving the user's old data and we don't want to + // delete that until we know the new file is in place. + if (backup.exists() && !backup.delete()) return false + if (!target.renameTo(backup)) return false + if (source.renameTo(target)) { + // Success — drop the backup. + backup.delete() + return true + } + // Roll back: put the backup back where the target was, leave + // the (unrenamed) source for hand recovery. + if (!backup.renameTo(target)) { + // Worst case: backup move-back failed too. Leave the user + // with a .bak file containing their previous data. + android.util.Log.w( + "ProfileStore", + "atomicReplace rollback failed; user data is in ${backup.absolutePath}", + ) + } + source.delete() + return false + } + + /** Outcome of any mutating operation. Distinguishes "your input was + * bad" (Duplicate / NotFound / EmptyName) from "the disk is in a + * state we won't overwrite" (CorruptOnDisk) from "the disk write + * itself failed" (SaveFailed) from "config.json wrote OK but + * profiles.json didn't" (PartialConfigOnly). */ + sealed class MutationResult { + object Ok : MutationResult() + object EmptyName : MutationResult() + object Duplicate : MutationResult() + object NotFound : MutationResult() + object SaveFailed : MutationResult() + /** config.json was written successfully but the subsequent + * profiles.json write failed. The live config is the new + * bytes (equivalent to a Save config), but no profile entry + * was added/updated. Caller should warn the user and offer + * to retry the profile-write step. */ + object PartialConfigOnly : MutationResult() + data class CorruptOnDisk(val cause: Throwable) : MutationResult() + } + + /** + * Outcome of [applyProfile]. Mirrors Rust's `ApplyOutcome`: + * - [Ok] — both `config.json` and `profiles.json` were written; + * the returned [cfg] is the new live config. + * - [PartialConfigOnly] — `config.json` IS the new profile but + * the `profiles.json` active-pointer save failed. The caller + * should still apply the [cfg] to the form (the live runtime + * reads `config.json` and will use the new bytes), but warn + * the user that the active marker on disk is stale. + * - [NotFound] — profile name doesn't exist; nothing was + * touched. + * - [Failed] — config.json write failed before anything else; + * nothing was touched. `reason` carries the underlying error + * when available. + */ + sealed class ApplyResult { + data class Ok(val cfg: MhrvConfig) : ApplyResult() + data class PartialConfigOnly(val cfg: MhrvConfig) : ApplyResult() + object NotFound : ApplyResult() + data class Failed(val reason: String) : ApplyResult() + } + + /** + * Switch the live config to a stored profile. The snapshot is + * written to `config.json` RAW (not through [MhrvConfig.toJson]) + * so any fields this build doesn't model survive — this is what + * lets the native runtime keep seeing desktop-only / future + * Rust-side keys. + * + * Outcome contract (mirrors Rust's `apply_profile`): + * - [ApplyResult.NotFound] — name not in the store; no writes. + * - [ApplyResult.Failed] — the `config.json` write failed + * before anything else; no writes succeeded. + * - [ApplyResult.Ok] — both writes succeeded. + * - [ApplyResult.PartialConfigOnly] — `config.json` IS the new + * profile (live runtime will use it on next start) but the + * `profiles.json` active-pointer save failed. The dropdown's + * active marker on disk is stale. + */ + fun applyProfile(ctx: Context, name: String): ApplyResult { + val state = when (val r = loadStrict(ctx)) { + is LoadResult.Ok -> r.state + is LoadResult.Missing -> return ApplyResult.NotFound + is LoadResult.Corrupt -> return ApplyResult.Failed("profiles.json is corrupt") + } + val p = state.find(name) ?: return ApplyResult.NotFound + + val raw = try { + JSONObject(p.configJson).toString(2) + } catch (e: Throwable) { + return ApplyResult.Failed("snapshot is not valid JSON: ${e.message}") + } + // Runtime-shape validation: refuse to write a snapshot the + // native runtime would reject (missing/unknown mode, or + // missing deployment ID / auth_key for apps_script/full). + // Without this, applying a malformed profile would clobber + // the user's working config.json with bytes Rust's + // Config::load then errors out on. Decode is permissive (it + // tolerates a missing mode by defaulting to apps_script), + // so we re-check on the decoded result. + val cfg = ConfigStore.decode(p.configJson) + ?: return ApplyResult.Failed("snapshot did not decode into MhrvConfig") + val shapeErr = validateRuntimeShape(JSONObject(p.configJson), cfg) + if (shapeErr != null) { + return ApplyResult.Failed("snapshot would not pass runtime validation: $shapeErr") + } + + // 1. Write the RAW snapshot to config.json. On failure, + // nothing changed. + val cfgFile = File(ctx.filesDir, "config.json") + val cfgTmp = File(ctx.filesDir, "config.json.tmp") + val cfgOk = try { + cfgTmp.writeText(raw) + atomicReplace(cfgTmp, cfgFile) + } catch (_: Throwable) { + cfgTmp.delete() + false + } + if (!cfgOk) return ApplyResult.Failed("could not write config.json") + + // 2. Try to move the active pointer. If this fails, surface + // PartialConfigOnly so the caller can warn the user that + // the dropdown's marker on disk is stale, while still + // reloading the form from the new config.json. + return if (save(ctx, state.copy(active = name))) { + ApplyResult.Ok(cfg) + } else { + ApplyResult.PartialConfigOnly(cfg) + } + } + + /** Compatibility shim for callers that only need the cfg or null. + * New code should call [applyProfile] directly and pattern-match + * on [ApplyResult] so the partial-success case isn't swallowed. */ + fun applyProfileOrNull(ctx: Context, name: String): MhrvConfig? = + when (val r = applyProfile(ctx, name)) { + is ApplyResult.Ok -> r.cfg + is ApplyResult.PartialConfigOnly -> r.cfg + else -> null + } + + /** + * Insert or update a named profile. + * + * Write order: **`config.json` FIRST, then `profiles.json`**. + * - On config.json failure: nothing changed on disk. + * - On profiles.json failure: `config.json` is the new bytes + * (equivalent to a regular Save) but the profile entry + * wasn't saved. We return [MutationResult.PartialConfigOnly]. + * + * The reverse order would corrupt the overwrite case: replacing + * a profile's snapshot before discovering config.json couldn't + * land would leave the profile entry pointing at bytes no file + * on disk matched. + */ + fun upsert(ctx: Context, name: String, cfg: MhrvConfig): MutationResult { + val trimmed = name.trim() + if (trimmed.isEmpty()) return MutationResult.EmptyName + val state = when (val r = loadStrict(ctx)) { + is LoadResult.Ok -> r.state + is LoadResult.Missing -> r.state + is LoadResult.Corrupt -> return MutationResult.CorruptOnDisk(r.cause) + } + val snapshot = cfg.toJson() + val newList = if (state.find(trimmed) != null) { + state.profiles.map { if (it.name == trimmed) Profile(trimmed, snapshot) else it } + } else { + state.profiles + Profile(trimmed, snapshot) + } + val newState = State(active = trimmed, profiles = newList) + // Step 1: live config first. If this fails, nothing changed. + if (!ConfigStore.save(ctx, cfg)) return MutationResult.SaveFailed + // Step 2: profile entry. If this fails, the live config is + // already the new bytes but the profile entry wasn't saved. + if (!save(ctx, newState)) return MutationResult.PartialConfigOnly + return MutationResult.Ok + } + + /** Insert a new profile, refusing to overwrite an existing one of + * the same name. Same write-order + outcome semantics as [upsert]. */ + fun insertNew(ctx: Context, name: String, cfg: MhrvConfig): MutationResult { + val trimmed = name.trim() + if (trimmed.isEmpty()) return MutationResult.EmptyName + val state = when (val r = loadStrict(ctx)) { + is LoadResult.Ok -> r.state + is LoadResult.Missing -> r.state + is LoadResult.Corrupt -> return MutationResult.CorruptOnDisk(r.cause) + } + if (state.find(trimmed) != null) return MutationResult.Duplicate + val newState = State( + active = trimmed, + profiles = state.profiles + Profile(trimmed, cfg.toJson()), + ) + if (!ConfigStore.save(ctx, cfg)) return MutationResult.SaveFailed + if (!save(ctx, newState)) return MutationResult.PartialConfigOnly + return MutationResult.Ok + } + + /** Rename. The active pointer moves if it was pointing at `from`. */ + fun rename(ctx: Context, from: String, to: String): MutationResult { + val toTrimmed = to.trim() + if (toTrimmed.isEmpty()) return MutationResult.EmptyName + val state = when (val r = loadStrict(ctx)) { + is LoadResult.Ok -> r.state + is LoadResult.Missing -> return MutationResult.NotFound + is LoadResult.Corrupt -> return MutationResult.CorruptOnDisk(r.cause) + } + if (state.find(from) == null) return MutationResult.NotFound + if (from != toTrimmed && state.find(toTrimmed) != null) return MutationResult.Duplicate + val newList = state.profiles.map { + if (it.name == from) Profile(toTrimmed, it.configJson) else it + } + val newActive = if (state.active == from) toTrimmed else state.active + return if (save(ctx, State(active = newActive, profiles = newList))) { + MutationResult.Ok + } else { + MutationResult.SaveFailed + } + } + + /** + * Delete. If the deleted profile was active, active becomes "" — + * by invariant 2, we can't claim some OTHER profile matches the live + * config without actually applying it, and silently rewriting the + * user's config is the wrong call here. The user can explicitly + * switch to a different profile if they want. + */ + fun delete(ctx: Context, name: String): MutationResult { + val state = when (val r = loadStrict(ctx)) { + is LoadResult.Ok -> r.state + is LoadResult.Missing -> return MutationResult.NotFound + is LoadResult.Corrupt -> return MutationResult.CorruptOnDisk(r.cause) + } + val idx = state.profiles.indexOfFirst { it.name == name } + if (idx < 0) return MutationResult.NotFound + // Remove only the first match — duplicate names are rejected at + // load time, so in a well-formed file there's exactly one, but + // we still want to match Rust's "first match" semantics if a + // hand-edited file slips a duplicate past us somehow. Previously + // this used `filter { it.name != name }` which removed all + // matches, diverging from Rust. + val newList = state.profiles.toMutableList().also { it.removeAt(idx) } + val newActive = if (state.active == name) "" else state.active + return if (save(ctx, State(active = newActive, profiles = newList))) { + MutationResult.Ok + } else { + MutationResult.SaveFailed + } + } + + /** Duplicate. Non-destructive: does NOT change the active pointer + * or touch `config.json`. */ + fun duplicate(ctx: Context, from: String, to: String): MutationResult { + val toTrimmed = to.trim() + if (toTrimmed.isEmpty()) return MutationResult.EmptyName + val state = when (val r = loadStrict(ctx)) { + is LoadResult.Ok -> r.state + is LoadResult.Missing -> return MutationResult.NotFound + is LoadResult.Corrupt -> return MutationResult.CorruptOnDisk(r.cause) + } + val src = state.find(from) ?: return MutationResult.NotFound + if (state.find(toTrimmed) != null) return MutationResult.Duplicate + val newState = State( + active = state.active, + profiles = state.profiles + Profile(toTrimmed, src.configJson), + ) + return if (save(ctx, newState)) MutationResult.Ok else MutationResult.SaveFailed + } + + /** + * Clear the active pointer if it's non-empty. Used by [HomeScreen] + * on every field edit to maintain invariant 2: any change to + * config.json that wasn't a profile apply breaks the "active + * matches live config" promise, so we have to clear the marker. + * + * Safe no-op if profiles.json is missing or already clean. + * Refuses to write over a corrupt file (same guard as the + * other mutations). + */ + fun clearActiveIfAny(ctx: Context) { + val state = when (val r = loadStrict(ctx)) { + is LoadResult.Ok -> r.state + // Missing file: nothing to clear, no need to materialize one. + is LoadResult.Missing -> return + // Corrupt: never overwrite. + is LoadResult.Corrupt -> return + } + if (state.active.isEmpty()) return + save(ctx, state.copy(active = "")) + } + + /** + * Mirror of `Config::validate` on the Rust side: returns null if + * the snapshot is a shape the runtime would accept, or an error + * string otherwise. Checked at [applyProfile] before writing to + * `config.json` so a malformed snapshot can't clobber the user's + * working live config with bytes Rust's `Config::load` then + * errors on. + * + * Rules: + * - `mode` must be present and one of "apps_script" | "direct" + * | "full" (the legacy "google_only" alias is accepted as + * direct, mirroring `Config::mode_kind`). + * - apps_script / full modes require at least one non-empty + * deployment ID (under either `script_id` or `script_ids`) + * AND a non-empty, non-placeholder `auth_key`. + */ + private fun validateRuntimeShape(raw: JSONObject, decoded: MhrvConfig): String? { + val mode = raw.optString("mode", "") + if (mode.isBlank()) return "missing required field `mode`" + when (mode) { + "apps_script", "full" -> { + if (!decoded.hasDeploymentId) { + return "$mode mode requires script_id (or script_ids)" + } + val auth = decoded.authKey.trim() + if (auth.isEmpty() || auth == "CHANGE_ME_TO_A_STRONG_SECRET") { + return "$mode mode requires a non-placeholder auth_key" + } + } + "direct", "google_only" -> { /* no relay creds required */ } + else -> return "unknown mode '$mode'" + } + return null + } + + /** Pick a unique "name (copy)" / "name (copy 2)" etc. for a duplicate. */ + fun uniqueCopyName(state: State, base: String): String { + var candidate = "$base (copy)" + var n = 2 + while (state.find(candidate) != null) { + candidate = "$base (copy $n)" + n++ + } + return candidate + } + + // ---- I/O helpers ---- + + /** + * Strict parse: throws on ANY malformed entry. The caller + * ([loadStrict]) catches and returns [LoadResult.Corrupt] so the + * UI refuses to overwrite the file (invariant 4). + * + * We deliberately don't "skip and continue" on bad entries — that + * was the previous behaviour and it silently dropped recoverable + * data on the next save. If part of the file is malformed, the + * whole file is treated as corrupt and the user is asked to + * hand-recover. + */ + private fun parse(text: String): State { + if (text.isBlank()) return State(active = "", profiles = emptyList()) + val obj = JSONObject(text) + val active = obj.optString("active", "") + if (!obj.has("profiles")) { + return State(active = active, profiles = emptyList()) + } + val arr = obj.optJSONArray("profiles") + ?: throw IllegalStateException("`profiles` key is not an array") + val out = mutableListOf() + val seen = HashSet(arr.length()) + for (i in 0 until arr.length()) { + val p = arr.optJSONObject(i) + ?: throw IllegalStateException("profiles[$i] is not an object") + val name = p.optString("name", "") + if (name.isBlank()) { + throw IllegalStateException("profiles[$i] has empty/missing name") + } + // Duplicate names break every by-name op (apply / rename / + // delete) — Android's `delete` removes all matches while + // Rust's removes only the first, so we'd diverge on the + // same file. Reject loudly so the user can hand-fix. + if (!seen.add(name)) { + throw IllegalStateException("profiles[$i] ('$name'): duplicate profile name") + } + val cfg = p.optJSONObject("config") + ?: throw IllegalStateException("profiles[$i] ('$name') has no config object") + out.add(Profile(name = name, configJson = cfg.toString())) + } + return State(active = active, profiles = out) + } + + /** + * Strict encode: throws on any malformed snapshot. Should never + * happen in practice because snapshots come from [MhrvConfig.toJson] + * (always valid) or were captured at parse time (already validated + * by [parse]). A throw here means an invariant violation we'd + * rather surface loudly than silently drop. + */ + private fun encode(state: State): String { + val arr = JSONArray() + for (p in state.profiles) { + val item = JSONObject() + item.put("name", p.name) + val cfg = try { + JSONObject(p.configJson) + } catch (e: Throwable) { + throw IllegalStateException( + "profile '${p.name}' has malformed config snapshot", + e, + ) + } + item.put("config", cfg) + arr.put(item) + } + val root = JSONObject() + root.put("active", state.active) + root.put("profiles", arr) + return root.toString(2) + } +} diff --git a/android/app/src/main/java/com/therealaleph/mhrv/ui/HomeScreen.kt b/android/app/src/main/java/com/therealaleph/mhrv/ui/HomeScreen.kt index d228b721..86ed0463 100644 --- a/android/app/src/main/java/com/therealaleph/mhrv/ui/HomeScreen.kt +++ b/android/app/src/main/java/com/therealaleph/mhrv/ui/HomeScreen.kt @@ -39,6 +39,7 @@ import com.therealaleph.mhrv.DEFAULT_SNI_POOL import com.therealaleph.mhrv.MhrvConfig import com.therealaleph.mhrv.Mode import com.therealaleph.mhrv.Native +import com.therealaleph.mhrv.ProfileStore import com.therealaleph.mhrv.ConnectionMode import com.therealaleph.mhrv.NetworkDetect import com.therealaleph.mhrv.R @@ -99,8 +100,32 @@ fun HomeScreen( // cheap at this write rate, avoids "I tapped Start before saving" bugs. var cfg by remember { mutableStateOf(ConfigStore.load(ctx)) } fun persist(new: MhrvConfig) { + // In-memory state goes through unconditionally so the form + // doesn't snap back to old bytes mid-edit. Disk state is + // gated on the write succeeding — if config.json didn't + // change, the active profile marker also stays put + // (invariant 2: clearing active claims the live config + // diverged from the marker, which is only true once the + // write lands). cfg = new - ConfigStore.save(ctx, new) + val saved = ConfigStore.save(ctx, new) + if (!saved) { + // Surface the failure; the next snackbar slot will show + // it. The in-memory cfg reflects the user's edit, but + // config.json on disk does not. + scope.launch { + snackbar.showSnackbar( + ctx.getString(R.string.snack_config_save_failed), + withDismissAction = true, + ) + } + return + } + // Only after a successful write do we touch the profile + // pointer. A failed save would have left config.json with + // the OLD bytes (which may still match the active profile), + // so clearing active in that case would be a false claim. + ProfileStore.clearActiveIfAny(ctx) } // CA install dialog visibility. @@ -257,6 +282,20 @@ fun HomeScreen( onSnackbar = { snackbar.showSnackbar(it) }, ) + // Multi-profile bar — switch between saved configs without + // re-typing deployment IDs / auth keys (e.g. one Apps Script + // profile, one Full tunnel profile). Writes through to the + // same `config.json` the Rust runtime reads. When a profile + // carries a different `ui_lang`, we route through the same + // onLangChange path as the top-bar toggle so the activity + // recreates with the right locale + RTL/LTR direction. + ProfileBar( + cfg = cfg, + onConfigChange = { cfg = it }, + onLangChange = onLangChange, + onSnackbar = { snackbar.showSnackbar(it) }, + ) + SectionHeader("Mode") ModeDropdown( mode = cfg.mode, diff --git a/android/app/src/main/java/com/therealaleph/mhrv/ui/ProfileBar.kt b/android/app/src/main/java/com/therealaleph/mhrv/ui/ProfileBar.kt new file mode 100644 index 00000000..7c8aeb2b --- /dev/null +++ b/android/app/src/main/java/com/therealaleph/mhrv/ui/ProfileBar.kt @@ -0,0 +1,499 @@ +package com.therealaleph.mhrv.ui + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.BookmarkAdd +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.therealaleph.mhrv.MhrvConfig +import com.therealaleph.mhrv.ProfileStore +import com.therealaleph.mhrv.R +import com.therealaleph.mhrv.UiLang +import com.therealaleph.mhrv.VpnState +import kotlinx.coroutines.launch + +/** + * Profile bar shown at the top of the config screen, between the + * import/export bar and the mode selector. + * + * Three actions: + * - **Selector**: dropdown listing every saved profile; tap one to + * switch the live config to it. + * - **Save as profile**: capture the current form under a name. + * - **Manage**: rename / duplicate / delete saved profiles. + * + * Switching a profile rewrites `config.json` to the snapshot (raw — no + * round-trip through MhrvConfig, so unknown fields survive) and + * triggers [onConfigChange] so the parent screen reloads its `cfg` + * state from disk. If the new profile has a different `ui_lang`, + * [onLangChange] is fired so the activity recreates with the right + * locale, matching the top-bar language toggle. + * + * Profile switching is disabled while the VPN is running — the running + * service still holds the old config until Disconnect/Connect, so + * swapping the live `config.json` underneath would be a footgun. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProfileBar( + cfg: MhrvConfig, + onConfigChange: (MhrvConfig) -> Unit, + onLangChange: (UiLang) -> Unit, + onSnackbar: suspend (String) -> Unit, +) { + val ctx = LocalContext.current + val scope = rememberCoroutineScope() + val isVpnRunning by VpnState.isRunning.collectAsState() + + // Bump this counter to force a re-read of the profiles file when + // a profile action mutates it. We ALSO re-key the state read on + // `cfg` so that field edits flowing through HomeScreen.persist() + // (which calls ProfileStore.clearActiveIfAny) cause the bar to + // pick up the cleared `active` immediately — otherwise the UI + // keeps claiming the old profile is active even though the live + // config no longer matches. + // + // We use loadStrict() (not load()) so a corrupt profiles.json + // surfaces loudly via a banner instead of being flattened into + // an empty state — the "load failure is loud" invariant. + var refresh by remember { mutableIntStateOf(0) } + val loadResult = remember(refresh, cfg) { ProfileStore.loadStrict(ctx) } + val state = when (loadResult) { + is ProfileStore.LoadResult.Ok -> loadResult.state + is ProfileStore.LoadResult.Missing -> loadResult.state + is ProfileStore.LoadResult.Corrupt -> + // Fall through to empty for the selector — we'll show a + // corruption banner below so the user knows what's going + // on. We still gate writes via the existing CorruptOnDisk + // MutationResult so we never clobber the bad file. + ProfileStore.State(active = "", profiles = emptyList()) + } + val corrupt = loadResult is ProfileStore.LoadResult.Corrupt + + var menuOpen by remember { mutableStateOf(false) } + var showSaveDialog by remember { mutableStateOf(false) } + var showManageDialog by remember { mutableStateOf(false) } + + val activePrefix = stringResource(R.string.profile_active_prefix) + val none = stringResource(R.string.profile_none) + val noSavedLabel = stringResource(R.string.profile_no_saved) + val switchBlockedMsg = stringResource(R.string.snack_profile_switch_blocked_running) + + if (corrupt) { + // Loud corruption banner. Sits above the selector so the + // user sees it before tapping anything, and the existing + // CorruptOnDisk mutation gate prevents accidental overwrite. + Text( + text = stringResource(R.string.profile_err_corrupt_on_disk), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Selector — wrap so the dropdown anchors under the button. + // Stays enabled while the VPN is running: the click handler + // shows a snackbar explaining why switching takes effect on + // the next Connect rather than going dark and silent. + Box(modifier = Modifier.weight(1f)) { + OutlinedButton( + onClick = { + if (isVpnRunning) { + scope.launch { onSnackbar(switchBlockedMsg) } + } else { + menuOpen = true + } + }, + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Default.Folder, null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) + Text( + text = "$activePrefix ${if (state.active.isBlank()) none else state.active}", + maxLines = 1, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + Spacer(Modifier.width(4.dp)) + Icon(Icons.Default.ArrowDropDown, null, modifier = Modifier.size(18.dp)) + } + DropdownMenu( + expanded = menuOpen, + onDismissRequest = { menuOpen = false }, + ) { + if (state.profiles.isEmpty()) { + DropdownMenuItem( + text = { Text(noSavedLabel, style = MaterialTheme.typography.bodySmall) }, + onClick = { menuOpen = false }, + enabled = false, + ) + } else { + for (p in state.profiles) { + DropdownMenuItem( + text = { + Text( + text = if (p.name == state.active) "● ${p.name}" else " ${p.name}", + ) + }, + onClick = { + menuOpen = false + scope.launch { + when (val r = ProfileStore.applyProfile(ctx, p.name)) { + is ProfileStore.ApplyResult.Ok -> { + // Locale change path mirrors the + // top-bar toggle so RTL/LTR flips + // and the activity recreates. + if (r.cfg.uiLang != cfg.uiLang) { + onLangChange(r.cfg.uiLang) + } + onConfigChange(r.cfg) + refresh++ + onSnackbar(ctx.getString( + R.string.snack_profile_switched, p.name)) + } + is ProfileStore.ApplyResult.PartialConfigOnly -> { + // Live config IS the new profile — + // reload the form — but tell the + // user the dropdown's active + // marker on disk is stale. + if (r.cfg.uiLang != cfg.uiLang) { + onLangChange(r.cfg.uiLang) + } + onConfigChange(r.cfg) + refresh++ + onSnackbar(ctx.getString( + R.string.snack_profile_switched_partial, p.name)) + } + is ProfileStore.ApplyResult.NotFound, + is ProfileStore.ApplyResult.Failed -> { + onSnackbar(ctx.getString( + R.string.snack_profile_switch_failed, p.name)) + } + } + } + }, + ) + } + } + } + } + + IconButton( + onClick = { showSaveDialog = true }, + ) { + Icon( + Icons.Default.BookmarkAdd, + contentDescription = stringResource(R.string.btn_save_as_profile), + ) + } + } + + if (showSaveDialog) { + SaveAsProfileDialog( + cfg = cfg, + existingNames = state.names(), + onDismiss = { showSaveDialog = false }, + onSaved = { name -> + scope.launch { + refresh++ + onSnackbar(ctx.getString(R.string.snack_profile_saved, name)) + } + showSaveDialog = false + }, + ) + } + + // "Manage" sits under the selector — surfacing it as a row below so + // it's discoverable without long-press on the dropdown. + if (state.profiles.isNotEmpty()) { + TextButton(onClick = { showManageDialog = true }) { + Text( + stringResource(R.string.btn_manage_profiles), + style = MaterialTheme.typography.bodySmall, + ) + } + } + + if (showManageDialog) { + ManageProfilesDialog( + state = state, + onMutated = { refresh++ }, + onDismiss = { showManageDialog = false }, + ) + } +} + +@Composable +private fun SaveAsProfileDialog( + cfg: MhrvConfig, + existingNames: List, + onDismiss: () -> Unit, + onSaved: (String) -> Unit, +) { + val ctx = LocalContext.current + var name by remember { mutableStateOf("") } + var error by remember { mutableStateOf(null) } + val trimmed = name.trim() + val exists = trimmed.isNotEmpty() && existingNames.contains(trimmed) + + Dialog(onDismissRequest = onDismiss) { + Card(modifier = Modifier.padding(16.dp)) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + stringResource(R.string.dialog_save_profile_title), + style = MaterialTheme.typography.titleMedium, + ) + OutlinedTextField( + value = name, + onValueChange = { + name = it + error = null + }, + label = { Text(stringResource(R.string.field_profile_name)) }, + placeholder = { Text(stringResource(R.string.placeholder_profile_name)) }, + singleLine = true, + isError = error != null, + modifier = Modifier.fillMaxWidth(), + ) + if (error != null) { + Text( + error!!, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + if (exists) { + Text( + stringResource(R.string.profile_overwrite_warning, trimmed), + color = MaterialTheme.colorScheme.tertiary, + style = MaterialTheme.typography.bodySmall, + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Spacer(Modifier.weight(1f)) + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.btn_cancel)) + } + Button( + enabled = trimmed.isNotEmpty(), + onClick = { + val result = if (exists) { + ProfileStore.upsert(ctx, trimmed, cfg) + } else { + ProfileStore.insertNew(ctx, trimmed, cfg) + } + when (result) { + ProfileStore.MutationResult.Ok -> onSaved(trimmed) + ProfileStore.MutationResult.PartialConfigOnly -> { + // config.json was written (live + // config IS the new form bytes, + // equivalent to a regular Save) + // but the profiles.json write + // failed, so no profile entry + // was created/updated. User can + // retry to capture the profile. + error = ctx.getString(R.string.profile_err_partial_config_only) + } + ProfileStore.MutationResult.Duplicate, + ProfileStore.MutationResult.EmptyName, + ProfileStore.MutationResult.NotFound, + ProfileStore.MutationResult.SaveFailed -> + error = ctx.getString(R.string.profile_err_save_failed) + is ProfileStore.MutationResult.CorruptOnDisk -> + error = ctx.getString(R.string.profile_err_corrupt_on_disk) + } + }, + ) { + Text( + stringResource( + if (exists) R.string.btn_overwrite else R.string.btn_save + ) + ) + } + } + } + } + } +} + +@Composable +private fun ManageProfilesDialog( + state: ProfileStore.State, + onMutated: () -> Unit, + onDismiss: () -> Unit, +) { + val ctx = LocalContext.current + var renamingName by remember { mutableStateOf(null) } + var renameBuf by remember { mutableStateOf("") } + var pendingDeleteName by remember { mutableStateOf(null) } + var error by remember { mutableStateOf(null) } + + fun applyResult( + result: ProfileStore.MutationResult, + fallbackErrKey: Int, + onOk: () -> Unit = {}, + ) { + when (result) { + ProfileStore.MutationResult.Ok -> { + error = null + onOk() + onMutated() + } + is ProfileStore.MutationResult.CorruptOnDisk -> + error = ctx.getString(R.string.profile_err_corrupt_on_disk) + else -> error = ctx.getString(fallbackErrKey) + } + } + + Dialog(onDismissRequest = onDismiss) { + Card(modifier = Modifier.padding(16.dp)) { + Column( + // Cap the dialog height so 20+ profiles don't push the + // Close button off-screen; the inner list scrolls. + modifier = Modifier + .padding(20.dp) + .heightIn(max = 480.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + stringResource(R.string.dialog_manage_profiles_title), + style = MaterialTheme.typography.titleMedium, + ) + if (error != null) { + Text( + error!!, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + if (state.profiles.isEmpty()) { + Text( + stringResource(R.string.profile_no_saved), + style = MaterialTheme.typography.bodySmall, + ) + } + // Scroll the profile list, not the whole dialog — + // keeps the Close row pinned to the bottom. + val scroll = rememberScrollState() + Column( + modifier = Modifier + .weight(1f, fill = false) + .verticalScroll(scroll), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + for (p in state.profiles) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + val isActive = p.name == state.active + Text( + if (isActive) "●" else " ", + color = if (isActive) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.width(16.dp), + ) + if (renamingName == p.name) { + OutlinedTextField( + value = renameBuf, + onValueChange = { renameBuf = it }, + singleLine = true, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = { + applyResult( + ProfileStore.rename(ctx, p.name, renameBuf), + R.string.profile_err_rename_failed, + onOk = { + renamingName = null + renameBuf = "" + }, + ) + }) { Text(stringResource(R.string.btn_ok)) } + TextButton(onClick = { + renamingName = null + renameBuf = "" + error = null + }) { Text(stringResource(R.string.btn_cancel)) } + } else if (pendingDeleteName == p.name) { + // Two-step delete: profile data may be the + // user's only saved copy, so we don't take + // it out on a single accidental tap. + Text( + text = stringResource(R.string.confirm_delete_profile, p.name), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.weight(1f), + ) + Button( + onClick = { + applyResult( + ProfileStore.delete(ctx, p.name), + R.string.profile_err_delete_failed, + onOk = { pendingDeleteName = null }, + ) + }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + ) { Text(stringResource(R.string.btn_confirm_delete)) } + TextButton(onClick = { + pendingDeleteName = null + error = null + }) { Text(stringResource(R.string.btn_cancel)) } + } else { + Text( + p.name, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = { + renamingName = p.name + renameBuf = p.name + error = null + }) { Text(stringResource(R.string.btn_rename)) } + TextButton(onClick = { + val target = ProfileStore.uniqueCopyName(state, p.name) + applyResult( + ProfileStore.duplicate(ctx, p.name, target), + R.string.profile_err_duplicate_failed, + ) + }) { Text(stringResource(R.string.btn_duplicate)) } + TextButton(onClick = { + // Arm the confirm row instead of deleting. + pendingDeleteName = p.name + error = null + }) { Text(stringResource(R.string.btn_delete)) } + } + } + } + } // end scrolling Column + Row { + Spacer(Modifier.weight(1f)) + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.btn_close)) + } + } + } + } + } +} diff --git a/android/app/src/main/res/values-fa/strings.xml b/android/app/src/main/res/values-fa/strings.xml index 9421f805..94b99638 100644 --- a/android/app/src/main/res/values-fa/strings.xml +++ b/android/app/src/main/res/values-fa/strings.xml @@ -95,6 +95,41 @@ مشاهدهٔ سهمیه در گوگل ← تخمینی — این همان چیزی است که از این دستگاه رد شده. عدد دقیق در داشبورد گوگل قابل مشاهده است. + + پروفایل + (هیچ‌کدام) + پروفایل: + هنوز پروفایلی ذخیره نشده + ذخیره به‌عنوان پروفایل + مدیریت پروفایل‌ها… + ذخیره به‌عنوان پروفایل + مدیریت پروفایل‌ها + نام پروفایل + مثلاً Apps Script (خانه)، Full (کار) + پروفایلی به نام \'%1$s\' از قبل وجود دارد. روی «جایگزینی» بزنید تا روی آن نوشته شود. + جایگزینی + ذخیره + بستن + تغییر نام + تکثیر + حذف + تأیید حذف + \'%1$s\' حذف شود؟ + تأیید + ذخیرهٔ پروفایل ممکن نشد + تغییر نام ممکن نشد — نام تکراری یا خالی است + تکثیر ممکن نشد + حذف ممکن نشد + به پروفایل \'%1$s\' جابه‌جا شد + به \'%1$s\' جابه‌جا شد (پیکربندی زنده به‌روزرسانی شد)، اما ذخیرهٔ نشانگر فعال ناموفق بود — با راه‌اندازی مجدد، نشانگر از دست می‌رود. + جابه‌جایی به \'%1$s\' ناموفق + پروفایل \'%1$s\' ذخیره شد + ابتدا قطع کنید — جابه‌جایی پروفایل فقط در اتصال بعدی اعمال می‌شود + ذخیرهٔ config.json ممکن نشد — تغییر شما فقط در حافظه است و با راه‌اندازی مجدد از دست می‌رود + پروفایل فعال + فایل profiles.json روی دیسک خراب به نظر می‌رسد؛ از بازنویسی خودداری شد. در صورت نیاز نداشتن آن را کنار بگذارید و دوباره تلاش کنید. + پیکربندی زنده ذخیره شد، اما ورودی پروفایل ذخیره نشد. برای ذخیرهٔ پروفایل دوباره تلاش کنید. + ۱. یک یا چند آدرس deployment از Apps Script (یا فقط ID خام) و همراه آن auth_key خود را جای‌گذاری کنید.\n۲. روی «نصب گواهی MITM» بزنید و پیام تأیید را قبول کنید — گواهی در Downloads/mhrv-ca.crt ذخیره می‌شود و برنامهٔ Settings باز می‌شود. داخل Settings از نوار جست‌وجو «CA certificate» را پیدا کنید و روی همان نتیجه بزنید (نه «VPN & app user certificate» و نه «Wi-Fi»)، سپس mhrv-ca.crt را از Downloads انتخاب کنید. اگر قفل صفحه ندارید، اندروید می‌خواهد یکی تنظیم کنید (الزام سیستم).\n۳. قبل از Start، بخش «مجموعهٔ SNI + تستر» را باز کنید و «تست همه» را بزنید. اگر همه تایم‌اوت شدند یعنی google_ip در دسترس نیست — آن را با یک IP جایگزین کنید که روی شبکهٔ سالم resolve می‌شود (مثلاً `nslookup www.google.com` روی هر دستگاه سالم).\n۴. Start را بزنید و درخواست VPN را تأیید کنید. پل TUN کامل، تمام برنامه‌های دستگاه را خودکار از پروکسی رد می‌کند — نیاز به تنظیم per-app نیست.\n۵. اگر Chrome پیام «504 Relay timeout» نشان داد: deployment شما پاسخ نمی‌دهد. اسکریپت را دوباره deploy کنید، URL جدید /exec را بگیرید و بالا جای‌گذاری کنید. در «لاگ زنده» ببینید خطا از نوع «Relay timeout» است یا «connect:» — نوع خطا مشخص می‌کند کدام لایه مقصر است.\n\nمحدودیت شناخته‌شده — Cloudflare Turnstile («Verify you are human») روی اکثر سایت‌های پشت Cloudflare به‌طور بی‌پایان loop می‌زند. هر درخواست Apps Script از یک IP خروجی چرخشی دیتاسنتر گوگل + یک User-Agent ثابت «Google-Apps-Script» + اثرانگشت TLS گوگل عبور می‌کند. کوکی cf_clearance به tuple (IP, UA, JA3) مربوط به زمان حل چالش گره خورده است، پس درخواست بعدی — از یک IP خروجی متفاوت — دوباره چالش می‌خورد. این مسئله در این برنامه قابل‌حل نیست؛ ذات رلهٔ Apps Script است. سایت‌هایی که فقط بارگذاری اولیه را gate می‌کنند (نه هر درخواست) بعد از یک بار حل، کار خواهند کرد. diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 6a7688e7..55291b25 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -113,6 +113,43 @@ View quota on Google → Estimate — this is what this device relayed. Google\'s dashboard has the authoritative number. + + Profile + (none) + Profile: + No profiles saved yet + Save as profile + Manage profiles… + Save as profile + Manage profiles + Profile name + e.g. Apps Script (home), Full (work) + A profile named \'%1$s\' already exists. Tap Overwrite to replace it. + Overwrite + Save + Close + Rename + Duplicate + Delete + Confirm delete + Delete \'%1$s\'? + OK + Could not save profile + Could not rename — name taken or empty + Could not duplicate + Could not delete + Switched to profile \'%1$s\' + Switched to \'%1$s\' (live config updated), but saving the active marker failed — restart will lose the marker. + Failed to switch to \'%1$s\' + Saved profile \'%1$s\' + Disconnect first — profile switch only takes effect on next Connect + Could not save config.json — your edit is in-memory only, restart will lose it + Active profile + profiles.json on disk looks corrupted; refusing to overwrite. Move it aside if you don\'t need it, then retry. + Live config saved, but the profile entry was not saved. Retry to save the profile. + 1. Paste one or more Apps Script deployment URLs (or bare IDs) and your auth_key.\n2. Tap Install MITM certificate. Confirm the dialog — the cert is saved to Downloads/mhrv-ca.crt and the Settings app opens. Use Settings\' search bar to find \"CA certificate\", tap that result (NOT \"VPN & app user certificate\" or \"Wi-Fi\"), and pick mhrv-ca.crt from Downloads. You\'ll be asked to set a screen lock if you don\'t have one (Android requirement).\n3. Before tapping Start, expand \"SNI pool + tester\" and hit \"Test all\". If every entry times out, your google_ip is unreachable — replace it with one that resolves locally (e.g. `nslookup www.google.com` on any working device).\n4. Tap Start. Accept the VPN prompt. The full TUN bridge routes every app on the device through the proxy — no per-app setup needed.\n5. If Chrome shows \"504 Relay timeout\": your Apps Script deployment isn\'t responding. Redeploy the script, grab the new /exec URL, and paste it above. Watch Live logs for \"Relay timeout\" vs \"connect:\" errors to tell which layer is failing.\n\nKnown limitation — Cloudflare Turnstile (\"Verify you are human\") will loop endlessly on most CF-protected sites. Every Apps Script request uses a rotating Google-datacenter egress IP + a fixed \"Google-Apps-Script\" User-Agent + a Google TLS fingerprint. The cf_clearance cookie is bound to the (IP, UA, JA3) tuple the challenge was solved against, so the NEXT request — from a different egress IP — gets re-challenged. Nothing in this app can fix that; it\'s inherent to Apps Script as a relay. Sites that only gate the initial page load (not every request) will work after one solve. diff --git a/android/app/src/test/java/com/therealaleph/mhrv/ProfileStoreTest.kt b/android/app/src/test/java/com/therealaleph/mhrv/ProfileStoreTest.kt new file mode 100644 index 00000000..5a353459 --- /dev/null +++ b/android/app/src/test/java/com/therealaleph/mhrv/ProfileStoreTest.kt @@ -0,0 +1,655 @@ +package com.therealaleph.mhrv + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.File + +/** + * Unit coverage for [ProfileStore] and the [ConfigStore]/[MhrvConfig] + * surfaces it touches. These tests pin the invariants documented in + * the class headers — drift here means desktop and Android can + * diverge silently on the same profile data, which is the whole + * point of the test matrix. + * + * Mirror of the Rust-side tests in `src/profiles.rs` — each invariant + * has a counterpart so a behavioural delta between Rust and Kotlin + * shows up as a test failure on at least one side. + */ +@RunWith(RobolectricTestRunner::class) +class ProfileStoreTest { + private lateinit var ctx: Context + private lateinit var profilesFile: File + private lateinit var configFile: File + + @Before + fun setUp() { + ctx = ApplicationProvider.getApplicationContext() + profilesFile = File(ctx.filesDir, "profiles.json") + configFile = File(ctx.filesDir, "config.json") + clearAll() + } + + @After + fun tearDown() { + clearAll() + } + + /** + * Recursive cleanup so a test that mid-flight created a directory + * at a file path (the injected-write-failure trick) doesn't leak + * into the next test. Plain [File.delete] won't remove a non-empty + * directory. + */ + private fun clearAll() { + listOf( + profilesFile, + configFile, + File(ctx.filesDir, "profiles.json.tmp"), + File(ctx.filesDir, "profiles.json.bak"), + File(ctx.filesDir, "config.json.tmp"), + File(ctx.filesDir, "config.json.bak"), + ).forEach { deleteRecursively(it) } + } + + private fun deleteRecursively(f: File) { + if (!f.exists()) return + if (f.isDirectory) { + f.listFiles()?.forEach { deleteRecursively(it) } + } + f.delete() + } + + // ---- Invariant 1: raw snapshot preservation ---- + + /** + * The whole point of storing snapshots as raw JSON: a profile + * written by a desktop build (or a future Android build) with + * config fields this build doesn't model must round-trip + * losslessly through Save → Switch. + */ + @Test + fun applyProfile_preserves_unknown_fields_in_config_json() { + val futureSnapshot = """ + { + "mode": "apps_script", + "script_ids": ["A"], + "auth_key": "secret", + "fronting_groups": [ + {"name": "vercel", "ip": "76.76.21.21", "sni": "react.dev", + "domains": ["vercel.com"]} + ], + "exit_node": {"enabled": true, "relay_url": "https://e.example", + "psk": "p", "mode": "selective", + "hosts": ["chatgpt.com"]}, + "request_timeout_secs": 45, + "future_field_xyz": [1, 2, 3] + } + """.trimIndent() + val written = """ + {"active":"future","profiles":[{"name":"future","config":$futureSnapshot}]} + """.trimIndent() + profilesFile.writeText(written) + + val applied = ProfileStore.applyProfile(ctx, "future") + assertTrue( + "apply should succeed on a valid future-shape snapshot, got ${applied::class.simpleName}", + applied is ProfileStore.ApplyResult.Ok, + ) + + assertTrue("config.json should have been written", configFile.exists()) + val onDisk = JSONObject(configFile.readText()) + assertEquals("apps_script", onDisk.optString("mode")) + assertEquals("secret", onDisk.optString("auth_key")) + assertTrue("fronting_groups must survive", onDisk.has("fronting_groups")) + assertEquals(1, onDisk.optJSONArray("fronting_groups")?.length() ?: 0) + assertTrue("exit_node must survive", onDisk.has("exit_node")) + assertEquals(45, onDisk.optInt("request_timeout_secs", -1)) + assertTrue( + "completely unknown future field must survive", + onDisk.has("future_field_xyz"), + ) + } + + /** + * The data-loss bug we fixed: unknown fields used to be dropped + * the moment the user edited any form field (because persist() + * runs cfg.toJson() which only emits modelled keys). The fix + * was to capture unknown keys into MhrvConfig.extrasJson and + * re-emit them. This test asserts: load → toJson round-trips + * unknown fields. + */ + @Test + fun mhrvconfig_toJson_preserves_unknown_fields() { + val originalJson = """ + { + "mode": "apps_script", + "script_ids": ["A"], + "auth_key": "secret", + "fronting_groups": [{"name":"x","ip":"1.2.3.4","sni":"a.b","domains":["c.com"]}], + "request_timeout_secs": 99, + "disable_padding": true + } + """.trimIndent() + configFile.writeText(originalJson) + val cfg = ConfigStore.load(ctx) + // Round-trip via toJson — the path persist() takes on every edit. + val roundTripped = JSONObject(cfg.toJson()) + assertEquals(99, roundTripped.optInt("request_timeout_secs")) + assertTrue(roundTripped.optBoolean("disable_padding")) + assertTrue(roundTripped.has("fronting_groups")) + } + + /** + * Critical: Rust writes `script_id` (singular, can be string or + * array). Before this fix, Android only read `script_ids` (plural, + * array only), so a desktop-saved profile applied on Android with + * zero deployment IDs and the proxy would refuse to start. + */ + @Test + fun configstore_reads_rust_shaped_script_id_scalar() { + val rustScalar = """ + {"mode":"apps_script","script_id":"DESKTOP_ID","auth_key":"k"} + """.trimIndent() + configFile.writeText(rustScalar) + val cfg = ConfigStore.load(ctx) + assertEquals(1, cfg.appsScriptUrls.size) + assertTrue(cfg.appsScriptUrls.first().contains("DESKTOP_ID")) + assertTrue("hasDeploymentId must be true", cfg.hasDeploymentId) + } + + @Test + fun configstore_reads_rust_shaped_script_id_array() { + val rustArray = """ + {"mode":"apps_script","script_id":["A","B","C"],"auth_key":"k"} + """.trimIndent() + configFile.writeText(rustArray) + val cfg = ConfigStore.load(ctx) + assertEquals(3, cfg.appsScriptUrls.size) + } + + @Test + fun configstore_reads_both_script_id_and_script_ids_combined() { + // Hand-edited config where someone added a key via "script_id" + // and another via "script_ids". The union must be exposed. + val combined = """ + {"mode":"apps_script","script_id":"X","script_ids":["Y","Z"],"auth_key":"k"} + """.trimIndent() + configFile.writeText(combined) + val cfg = ConfigStore.load(ctx) + assertEquals(3, cfg.appsScriptUrls.size) + } + + // ---- Invariant 2: active == "matches the live config" ---- + + @Test + fun delete_active_clears_pointer() { + ProfileStore.upsert(ctx, "a", MhrvConfig(appsScriptUrls = listOf("A"), authKey = "x")) + ProfileStore.upsert(ctx, "b", MhrvConfig(appsScriptUrls = listOf("B"), authKey = "y")) + ProfileStore.upsert(ctx, "c", MhrvConfig(appsScriptUrls = listOf("C"), authKey = "z")) + assertEquals(ProfileStore.MutationResult.Ok, ProfileStore.delete(ctx, "c")) + val state = ProfileStore.load(ctx) + assertEquals("", state.active) + assertNotNull(state.find("a")) + assertNotNull(state.find("b")) + } + + @Test + fun delete_non_active_keeps_pointer() { + ProfileStore.upsert(ctx, "a", MhrvConfig(appsScriptUrls = listOf("A"), authKey = "x")) + ProfileStore.upsert(ctx, "b", MhrvConfig(appsScriptUrls = listOf("B"), authKey = "y")) + ProfileStore.delete(ctx, "a") + assertEquals("b", ProfileStore.load(ctx).active) + } + + @Test + fun upsert_writes_snapshot_to_live_config_json() { + val cfg = MhrvConfig( + mode = Mode.APPS_SCRIPT, + appsScriptUrls = listOf("A"), + authKey = "secret", + googleIp = "1.2.3.4", + ) + val r = ProfileStore.upsert(ctx, "home", cfg) + assertEquals(ProfileStore.MutationResult.Ok, r) + assertTrue("config.json must be written by upsert", configFile.exists()) + val onDisk = JSONObject(configFile.readText()) + assertEquals("apps_script", onDisk.optString("mode")) + assertEquals("secret", onDisk.optString("auth_key")) + assertEquals("1.2.3.4", onDisk.optString("google_ip")) + assertEquals("home", ProfileStore.load(ctx).active) + } + + @Test + fun insertNew_writes_snapshot_to_live_config_json() { + val cfg = MhrvConfig(appsScriptUrls = listOf("X"), authKey = "k") + val r = ProfileStore.insertNew(ctx, "first", cfg) + assertEquals(ProfileStore.MutationResult.Ok, r) + assertTrue(configFile.exists()) + assertEquals("first", ProfileStore.load(ctx).active) + } + + /** + * Invariant 2 follow-up: clearActiveIfAny clears active when set, + * is a no-op otherwise. Called on every persist() in HomeScreen. + */ + @Test + fun clearActiveIfAny_clears_when_set() { + ProfileStore.upsert(ctx, "p", MhrvConfig(appsScriptUrls = listOf("A"), authKey = "k")) + assertEquals("p", ProfileStore.load(ctx).active) + ProfileStore.clearActiveIfAny(ctx) + val state = ProfileStore.load(ctx) + assertEquals("", state.active) + // Profile entry should still be there — we cleared the marker, + // not the data. + assertNotNull(state.find("p")) + } + + @Test + fun clearActiveIfAny_no_op_on_missing_file() { + // Should not create profiles.json out of thin air. + ProfileStore.clearActiveIfAny(ctx) + assertFalse(profilesFile.exists()) + } + + @Test + fun clearActiveIfAny_no_op_on_already_empty_active() { + // Write a profiles.json with no active pointer. + profilesFile.writeText("""{"active":"","profiles":[]}""") + ProfileStore.clearActiveIfAny(ctx) + // No write should have happened, but to be lenient we allow + // a rewrite as long as content is the same on reload. + assertEquals("", ProfileStore.load(ctx).active) + } + + // ---- Invariant 3: persist before in-memory state changes ---- + + @Test + fun rename_collision_does_not_mutate_state() { + ProfileStore.upsert(ctx, "a", MhrvConfig(appsScriptUrls = listOf("A"), authKey = "x")) + ProfileStore.upsert(ctx, "b", MhrvConfig(appsScriptUrls = listOf("B"), authKey = "y")) + val r = ProfileStore.rename(ctx, "a", "b") + assertEquals(ProfileStore.MutationResult.Duplicate, r) + val state = ProfileStore.load(ctx) + assertNotNull(state.find("a")) + assertNotNull(state.find("b")) + } + + @Test + fun upsert_empty_name_is_rejected() { + val r = ProfileStore.upsert(ctx, " ", MhrvConfig()) + assertEquals(ProfileStore.MutationResult.EmptyName, r) + assertFalse("nothing should be written for empty name", profilesFile.exists()) + } + + @Test + fun insertNew_duplicate_returns_Duplicate_not_overwrite() { + ProfileStore.insertNew( + ctx, + "p", + MhrvConfig(appsScriptUrls = listOf("first"), authKey = "k"), + ) + val r = ProfileStore.insertNew( + ctx, + "p", + MhrvConfig(appsScriptUrls = listOf("second"), authKey = "k"), + ) + assertEquals(ProfileStore.MutationResult.Duplicate, r) + val applied = ProfileStore.applyProfile(ctx, "p") + assertTrue(applied is ProfileStore.ApplyResult.Ok) + val cfg = (applied as ProfileStore.ApplyResult.Ok).cfg + assertEquals( + listOf("https://script.google.com/macros/s/first/exec"), + cfg.appsScriptUrls, + ) + } + + // ---- Invariant 4: load failure is loud ---- + + @Test + fun corrupt_file_is_surfaced_via_loadStrict() { + profilesFile.writeText("{ not valid json") + val r = ProfileStore.loadStrict(ctx) + assertTrue(r is ProfileStore.LoadResult.Corrupt) + } + + @Test + fun missing_file_is_surfaced_as_Missing() { + val r = ProfileStore.loadStrict(ctx) + assertTrue(r is ProfileStore.LoadResult.Missing) + } + + /** + * Partial-malformation strictness: a file where the top-level + * shape is valid but one profile entry is broken must surface + * as Corrupt, NOT a lenient "skip the bad entry and silently + * drop it on next save". Before this was strict, the next save + * would have permanently lost the broken entry. + */ + @Test + fun partial_malformed_profile_entry_surfaces_as_corrupt() { + val partial = """ + { + "active": "good", + "profiles": [ + {"name": "good", "config": {"mode": "apps_script"}}, + {"name": "broken"} + ] + } + """.trimIndent() + profilesFile.writeText(partial) + val r = ProfileStore.loadStrict(ctx) + assertTrue( + "expected Corrupt for missing config, got ${r::class.simpleName}", + r is ProfileStore.LoadResult.Corrupt, + ) + } + + @Test + fun partial_malformed_profile_name_surfaces_as_corrupt() { + val partial = """ + { + "active": "good", + "profiles": [ + {"name": "", "config": {"mode": "apps_script"}} + ] + } + """.trimIndent() + profilesFile.writeText(partial) + val r = ProfileStore.loadStrict(ctx) + assertTrue(r is ProfileStore.LoadResult.Corrupt) + } + + /** + * Duplicate names make every by-name operation (apply / rename / + * delete) ambiguous, so we reject on load. Matches the Rust-side + * test of the same name. + */ + @Test + fun duplicate_names_surface_as_corrupt() { + val dup = """ + { + "active": "p", + "profiles": [ + {"name": "p", "config": {"mode": "apps_script"}}, + {"name": "p", "config": {"mode": "full"}} + ] + } + """.trimIndent() + profilesFile.writeText(dup) + val r = ProfileStore.loadStrict(ctx) + assertTrue( + "expected Corrupt for duplicate names, got ${r::class.simpleName}", + r is ProfileStore.LoadResult.Corrupt, + ) + val msg = (r as ProfileStore.LoadResult.Corrupt).cause.message.orEmpty() + assertTrue( + "error should mention duplicate explicitly: $msg", + msg.contains("duplicate", ignoreCase = true), + ) + } + + @Test + fun mutations_refuse_to_overwrite_corrupt_profiles_file() { + profilesFile.writeText("{ corrupt") + val before = profilesFile.readText() + val r = ProfileStore.upsert( + ctx, + "p", + MhrvConfig(appsScriptUrls = listOf("A"), authKey = "k"), + ) + assertTrue(r is ProfileStore.MutationResult.CorruptOnDisk) + assertEquals(before, profilesFile.readText()) + } + + @Test + fun corrupt_then_delete_corrupt_then_save_works() { + profilesFile.writeText("{ corrupt") + profilesFile.delete() + val r = ProfileStore.upsert( + ctx, + "p", + MhrvConfig(appsScriptUrls = listOf("A"), authKey = "k"), + ) + assertEquals(ProfileStore.MutationResult.Ok, r) + assertEquals("p", ProfileStore.load(ctx).active) + } + + // ---- Atomic-replace data-loss regression guard ---- + + /** + * Regression for the pre-delete data-loss bug: if [ProfileStore.save] + * succeeds, the previous file's bytes are gone (replaced) — but + * if it FAILED (which we can't easily simulate cleanly), the + * previous file must still exist. We can at least verify the + * happy path leaves no leftover temp/backup files. + */ + @Test + fun save_leaves_no_tmp_or_bak_behind_on_success() { + ProfileStore.upsert(ctx, "p", MhrvConfig(appsScriptUrls = listOf("A"), authKey = "k")) + assertFalse(File(ctx.filesDir, "profiles.json.tmp").exists()) + assertFalse(File(ctx.filesDir, "profiles.json.bak").exists()) + } + + // ---- Cross-platform parity: applyProfile + decoded view ---- + + @Test + fun applyProfile_decoded_view_matches_snapshot_subset() { + val cfg = MhrvConfig( + mode = Mode.FULL, + appsScriptUrls = listOf("Z"), + authKey = "topsecret", + parallelRelay = 3, + ) + ProfileStore.upsert(ctx, "fullmode", cfg) + ConfigStore.save(ctx, MhrvConfig(mode = Mode.DIRECT)) + val applied = ProfileStore.applyProfile(ctx, "fullmode") + assertTrue(applied is ProfileStore.ApplyResult.Ok) + val out = (applied as ProfileStore.ApplyResult.Ok).cfg + assertEquals(Mode.FULL, out.mode) + assertEquals("topsecret", out.authKey) + assertEquals(3, out.parallelRelay) + } + + /** + * Snapshot with apps_script mode but no script_id/script_ids and + * no auth_key would fail Rust's `Config::validate`. Apply must + * refuse instead of clobbering config.json with bytes the runtime + * rejects on its next start. + */ + @Test + fun applyProfile_refuses_runtime_invalid_snapshot() { + val bad = """ + { + "active": "bad", + "profiles": [{"name": "bad", "config": {"mode": "apps_script"}}] + } + """.trimIndent() + profilesFile.writeText(bad) + // Plant a known-good live config so we can assert it's unchanged. + ConfigStore.save(ctx, MhrvConfig(authKey = "preserve-me")) + val before = configFile.readText() + + val r = ProfileStore.applyProfile(ctx, "bad") + assertTrue( + "expected Failed, got ${r::class.simpleName}", + r is ProfileStore.ApplyResult.Failed, + ) + // config.json must not have been touched. + assertEquals(before, configFile.readText()) + } + + /** + * A direct-mode snapshot doesn't need script_id or auth_key — the + * runtime tolerates both being absent for direct. Apply must + * succeed. + */ + @Test + fun applyProfile_accepts_minimal_direct_snapshot() { + val ok = """ + { + "active": "", + "profiles": [{"name": "d", "config": {"mode": "direct"}}] + } + """.trimIndent() + profilesFile.writeText(ok) + val r = ProfileStore.applyProfile(ctx, "d") + assertTrue( + "minimal direct snapshot must apply, got ${r::class.simpleName}", + r is ProfileStore.ApplyResult.Ok, + ) + } + + @Test + fun applyProfile_missing_returns_NotFound_without_side_effects() { + ConfigStore.save(ctx, MhrvConfig(authKey = "preserve-me")) + val before = configFile.readText() + val applied = ProfileStore.applyProfile(ctx, "does-not-exist") + assertTrue(applied is ProfileStore.ApplyResult.NotFound) + assertEquals(before, configFile.readText()) + } + + @Test + fun unique_copy_name_increments_on_collision() { + ProfileStore.upsert(ctx, "p", MhrvConfig(appsScriptUrls = listOf("A"), authKey = "k")) + ProfileStore.duplicate(ctx, "p", "p (copy)") + val state = ProfileStore.load(ctx) + val unique = ProfileStore.uniqueCopyName(state, "p") + assertNotEquals("p (copy)", unique) + assertEquals("p (copy 2)", unique) + } + + // ---- Injected write-failure tests ---- + // + // Trick: make a file path a *directory* on disk before the call. + // The atomic-replace step (NIO Files.move or File.renameTo) + // then fails because we can't overwrite a directory with a + // file. This is portable across the Robolectric backing FS and + // doesn't require mocking. + + /** + * Step 1 (config.json) fails → upsert returns SaveFailed and + * neither file is modified. Specifically guards against the + * old order (profiles.json first), where an overwrite would + * clobber an existing profile's snapshot before discovering + * the live-config write would fail. + */ + @Test + fun upsert_config_write_failure_leaves_profiles_unchanged() { + ProfileStore.upsert( + ctx, + "home", + MhrvConfig(appsScriptUrls = listOf("OLD"), authKey = "old"), + ) + val profilesBefore = profilesFile.readText() + + // Block config.json write by making the path a directory. + // atomicReplace refuses to overwrite a directory target, + // so the save fails — exactly what we want to test. + configFile.delete() + configFile.mkdirs() + File(configFile, "sentinel").writeText("x") + + try { + val r = ProfileStore.upsert( + ctx, + "home", + MhrvConfig(appsScriptUrls = listOf("NEW"), authKey = "new"), + ) + assertEquals(ProfileStore.MutationResult.SaveFailed, r) + // profiles.json must be UNCHANGED — the bug guard. + assertEquals(profilesBefore, profilesFile.readText()) + } finally { + // Even if an assertion fires, leave a clean filesystem + // for the next test. clearAll() in tearDown is recursive + // but cheap insurance never hurts. + deleteRecursively(configFile) + } + } + + /** + * Step 2 (profiles.json) fails AFTER step 1 succeeded → returns + * PartialConfigOnly. config.json is the new bytes, profiles.json + * is unchanged. + */ + @Test + fun upsert_profiles_write_failure_returns_partial_config_only() { + ProfileStore.upsert( + ctx, + "home", + MhrvConfig(appsScriptUrls = listOf("OLD"), authKey = "old"), + ) + val profilesBefore = profilesFile.readText() + + // Block profiles.json write by making profiles.json.tmp + // a directory. The tmp.writeText() call inside save() then + // throws (can't write a regular file at a directory path). + val tmp = File(ctx.filesDir, "profiles.json.tmp") + tmp.delete() + tmp.mkdirs() + File(tmp, "sentinel").writeText("x") + + try { + val r = ProfileStore.upsert( + ctx, + "home", + MhrvConfig(appsScriptUrls = listOf("NEW"), authKey = "new"), + ) + assertEquals(ProfileStore.MutationResult.PartialConfigOnly, r) + + // config.json IS the new bytes — equivalent to a regular Save. + val onDisk = JSONObject(configFile.readText()) + assertEquals("new", onDisk.optString("auth_key")) + + // profiles.json is byte-identical to before the call — + // profile "home" still has its OLD snapshot. + assertEquals(profilesBefore, profilesFile.readText()) + } finally { + deleteRecursively(tmp) + } + } + + /** + * Same injected failure on applyProfile (switch path): step 2 + * fails AFTER step 1 succeeds → ApplyResult.PartialConfigOnly, + * config.json updated, profiles.json unchanged. + */ + @Test + fun applyProfile_profiles_write_failure_returns_partial() { + ProfileStore.upsert(ctx, "home", MhrvConfig(authKey = "homekey")) + ProfileStore.upsert(ctx, "other", MhrvConfig(authKey = "otherkey")) + val profilesBefore = profilesFile.readText() + assertEquals("other", ProfileStore.load(ctx).active) + + val tmp = File(ctx.filesDir, "profiles.json.tmp") + tmp.delete() + tmp.mkdirs() + File(tmp, "sentinel").writeText("x") + + try { + val r = ProfileStore.applyProfile(ctx, "home") + assertTrue( + "expected PartialConfigOnly, got ${r::class.simpleName}", + r is ProfileStore.ApplyResult.PartialConfigOnly, + ) + + val onDisk = JSONObject(configFile.readText()) + assertEquals("homekey", onDisk.optString("auth_key")) + + assertEquals(profilesBefore, profilesFile.readText()) + } finally { + deleteRecursively(tmp) + } + } +} diff --git a/src/bin/ui.rs b/src/bin/ui.rs index 7bb46b14..6563ae0d 100644 --- a/src/bin/ui.rs +++ b/src/bin/ui.rs @@ -15,6 +15,7 @@ use mhrv_rs::data_dir; use mhrv_rs::domain_fronter::{DomainFronter, DEFAULT_GOOGLE_SNI_POOL}; use mhrv_rs::lan_utils::{detect_lan_ip, is_share_on_lan}; use mhrv_rs::mitm::{MitmCertManager, CA_CERT_FILE}; +use mhrv_rs::profiles::{self, ProfilesFile}; use mhrv_rs::proxy_server::ProxyServer; use mhrv_rs::{scan_ips, scan_sni, test_cmd}; @@ -91,6 +92,65 @@ fn main() -> eframe::Result<()> { ..Default::default() }; + // Load the profile store. Three outcomes: + // - Ok → load_ok = true. + // - CorruptOnDisk + backup-rename succeeded → load_ok = true + // (the corrupt file is now backed up; we own the live file). + // - CorruptOnDisk + backup-rename FAILED → load_ok = false. We + // start empty in memory but refuse to save, because saving + // would clobber the corrupt-but-recoverable bytes on disk + // that may be the user's only copy. + // - I/O read error (permission denied, locked file, etc.) → + // load_ok = false. The file probably still exists on disk; + // overwriting it with an empty store would risk losing the + // user's data. Surface a toast and refuse writes until the + // next restart. + let (profiles, profiles_load_ok, profile_toast) = match ProfilesFile::load() { + Ok(pf) => (pf, true, None), + Err(mhrv_rs::profiles::ProfileError::CorruptOnDisk(msg)) => { + let path = profiles::profiles_path(); + let backup = pick_corrupt_backup_path(&path); + let renamed = std::fs::rename(&path, &backup); + let (load_ok, detail) = match renamed { + Ok(()) => ( + true, + format!( + "profiles.json was unreadable ({}); backed up to {}", + msg, + backup.display() + ), + ), + Err(re) => ( + false, + format!( + "profiles.json was unreadable ({}) and backup also failed ({}). \ + Profile saves are disabled until you move the file aside manually.", + msg, re + ), + ), + }; + tracing::warn!("profiles: {}", detail); + (ProfilesFile::default(), load_ok, Some(detail)) + } + Err(e) => { + // Read / I/O / permissions failure on a file that exists. + // Treat the same as CorruptOnDisk-but-can't-back-up: the + // bytes on disk are still there, we just can't read them + // right now, and writing an empty store would clobber + // them. Refuse writes until the user investigates. + let detail = format!( + "profiles.json could not be loaded ({}). Profile saves are \ + disabled to avoid clobbering the on-disk file.", + e + ); + tracing::warn!("profiles: {}", detail); + (ProfilesFile::default(), false, Some(detail)) + } + }; + // If we already had a config-load toast, prefer that; otherwise + // surface the profile-load detail on first paint. + let initial_toast = initial_toast.or_else(|| profile_toast.map(|m| (m, Instant::now()))); + eframe::run_native( "mhrv-rs", options, @@ -102,11 +162,39 @@ fn main() -> eframe::Result<()> { form, last_poll: Instant::now(), toast: initial_toast, + profiles, + profiles_load_ok, + save_as_dialog: None, + manage_dialog: None, })) }), ) } +/// Pick a non-colliding "json.corrupt-…" backup filename for a path +/// we couldn't parse at startup. Uses unix nanoseconds for entropy +/// AND a create-new probe loop, so a quick restart (or repeated +/// corrupt loads in the same nanosecond, hypothetically) doesn't +/// silently overwrite a previous backup that may itself be the +/// user's only copy of recoverable data. +fn pick_corrupt_backup_path(original: &std::path::Path) -> PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let mut candidate = original.with_extension(format!("json.corrupt-{}", nanos)); + // If we somehow land on an existing backup, append a counter + // until we find a fresh name. Cap iterations as a sanity belt; + // hitting the cap would mean something pathological with the + // filesystem, in which case we fall back to the last candidate. + let mut n = 1u32; + while candidate.exists() && n < 1000 { + candidate = original.with_extension(format!("json.corrupt-{}-{}", nanos, n)); + n += 1; + } + candidate +} + #[derive(Default)] struct Shared { state: Mutex, @@ -213,6 +301,49 @@ struct App { form: FormState, last_poll: Instant, toast: Option<(String, Instant)>, + /// Profile bookkeeping for the multi-profile selector. Loaded from + /// `profiles.json` at startup and kept in memory; mutations write + /// through to disk immediately. See `src/profiles.rs`. + profiles: ProfilesFile, + /// True iff `profiles.json` either didn't exist OR loaded cleanly + /// at startup. False means the file was corrupt AND the backup + /// rename failed (so the corrupt file is still on disk). In that + /// state we MUST NOT save — the current empty in-memory state + /// would clobber the corrupt-but-recoverable bytes that may be + /// the user's only copy of their data. + profiles_load_ok: bool, + /// Modal state for the "Save as new profile" dialog. + save_as_dialog: Option, + /// Modal state for the "Manage profiles" window. + manage_dialog: Option, +} + +#[derive(Default)] +struct SaveAsState { + /// Free-form name typed by the user. + name: String, + /// Inline error message rendered under the text field (e.g. "name + /// already exists"). Cleared on the next keystroke. + error: Option, +} + +#[derive(Default)] +struct ManageState { + /// Per-profile rename buffer keyed by current profile name. Lazily + /// populated when the user clicks "Rename" on a row. + rename_buf: HashMap, + /// Currently-being-renamed profile name (only one at a time). `None` + /// when no rename is in progress. + renaming: Option, + /// Inline error message at the top of the window (rename collision, + /// duplicate-target collision, etc.). + error: Option, + /// Profile name pending delete confirmation. Set when the user + /// clicks "Delete"; cleared when they confirm or cancel. While + /// set we render an inline "Confirm delete?" row instead of the + /// usual action buttons, so an accidental click can't blow + /// away the user's only saved copy of a config. + pending_delete: Option, } #[derive(Clone)] @@ -294,6 +425,32 @@ struct FormState { /// claude.ai / grok.com / x.com). Config-only — no UI editor yet. /// See `assets/exit_node/` for the generic exit-node handler. exit_node: mhrv_rs::config::ExitNodeConfig, + /// Verbatim passthrough buffer for any config.json key this build + /// doesn't model. Captured at load time from `Config::extras` and + /// re-emitted by [`to_config`] / `ConfigWire` so Save-config and + /// Save-as-profile preserve future / hand-edited fields — same + /// contract as Android's `MhrvConfig.extrasJson`. + extras: std::collections::BTreeMap, + + // ── Carried-but-not-exposed modeled fields ────────────────────── + // These are real fields in `Config` (so serde-deserialised at + // load), but the desktop UI doesn't surface editors for them yet. + // We round-trip them through FormState so Save-config and + // Save-as-profile don't silently drop user-edited values from + // hand-edited config.json files. ConfigWire was previously + // missing serialize entries for some of these, which made the + // "round-trip" comments aspirational rather than true. + /// Hostname → IP override map. Hand-edited config field; preserve + /// across saves so a user-defined override survives a UI save. + hosts_passthrough: std::collections::HashMap, + /// Legacy batch toggle (rarely set today). Pure passthrough. + enable_batching: bool, + /// PR #448 — adaptive coalesce window. Android exposes sliders; + /// desktop currently passes the compiled defaults (0/0) but a + /// user who hand-edits config.json to non-zero values should + /// keep them across UI saves. + coalesce_step_ms: u16, + coalesce_max_ms: u16, } #[derive(Clone, Debug)] @@ -398,6 +555,11 @@ fn load_form() -> (FormState, Option) { auto_blacklist_cooldown_secs: c.auto_blacklist_cooldown_secs, request_timeout_secs: c.request_timeout_secs, exit_node: c.exit_node.clone(), + extras: c.extras.clone(), + hosts_passthrough: c.hosts.clone(), + enable_batching: c.enable_batching, + coalesce_step_ms: c.coalesce_step_ms, + coalesce_max_ms: c.coalesce_max_ms, } } else { FormState { @@ -439,6 +601,11 @@ fn load_form() -> (FormState, Option) { auto_blacklist_cooldown_secs: 120, request_timeout_secs: 30, exit_node: mhrv_rs::config::ExitNodeConfig::default(), + extras: std::collections::BTreeMap::new(), + hosts_passthrough: std::collections::HashMap::new(), + enable_batching: false, + coalesce_step_ms: 0, + coalesce_max_ms: 0, } }; (form, load_err) @@ -542,8 +709,10 @@ impl FormState { socks5_port, log_level: self.log_level.trim().to_string(), verify_ssl: self.verify_ssl, - hosts: std::collections::HashMap::new(), - enable_batching: false, + // Round-tripped fields — preserve whatever was on disk + // (or the user's hand-edits) instead of wiping to defaults. + hosts: self.hosts_passthrough.clone(), + enable_batching: self.enable_batching, upstream_socks5: { let v = self.upstream_socks5.trim(); if v.is_empty() { @@ -608,12 +777,13 @@ impl FormState { // round-tripped through the UI so Save doesn't drop them. fronting_groups: self.fronting_groups.clone(), // PR #448 (Android): adaptive coalesce window. Desktop UI - // doesn't expose sliders for these yet (Android does), so - // we pass 0 to keep the compiled defaults (40ms step, - // 1000ms max). Round-trip planned for the v1.8.x desktop UI - // batch alongside the system-proxy toggle (#432). - coalesce_step_ms: 0, - coalesce_max_ms: 0, + // doesn't expose sliders for these yet, so for fresh + // installs they stay at the compiled defaults (0/0 → the + // crate's built-in 10ms / 1000ms). But if a user hand-edits + // config.json to set non-zero values, we round-trip them + // through FormState rather than wiping on every UI save. + coalesce_step_ms: self.coalesce_step_ms, + coalesce_max_ms: self.coalesce_max_ms, // Auto-blacklist + batch timeout: config-only knobs (#391, // #444, #430). Round-trip through FormState so Save doesn't // drop hand-edited values. UI editor planned alongside the @@ -626,17 +796,23 @@ impl FormState { // / grok.com / x.com). Round-trip through FormState — config-only // editing for now, UI editor planned for v1.9.x desktop UI batch. exit_node: self.exit_node.clone(), + // Unknown / future config.json keys captured at load time. + // Cloned through so Save-config and Save-as-profile preserve + // every field, even those not modelled by this build. + extras: self.extras.clone(), }) } } fn save_config(cfg: &Config) -> Result { let path = data_dir::config_path(); - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; - } - let json = serde_json::to_string_pretty(&ConfigWire::from(cfg)).map_err(|e| e.to_string())?; - std::fs::write(&path, json).map_err(|e| e.to_string())?; + // Round-trip through serde_json::Value so we can hand the bytes + // to the same atomic write helper the profile paths use. The + // helper writes to a `.tmp` and atomic-renames into place — no + // pre-delete window where the user could lose their previous + // config.json to a failed write. + let value = serde_json::to_value(ConfigWire::from(cfg)).map_err(|e| e.to_string())?; + mhrv_rs::profiles::write_config_json_to(&path, &value).map_err(|e| e.to_string())?; Ok(path) } @@ -707,6 +883,27 @@ struct ConfigWire<'a> { /// unchanged configs stay clean. #[serde(skip_serializing_if = "is_false")] force_http1: bool, + /// Block QUIC (UDP/443). Default true. Skip when matching default + /// so unchanged configs stay clean — emit when user has turned + /// the block off. Previously missing from ConfigWire, which made + /// `Save config` silently drop a user-set `block_quic: false`. + #[serde(skip_serializing_if = "is_true")] + block_quic: bool, + /// Anti-fingerprint random padding kill switch. Default false + /// (padding active). Emit when user disabled padding. + #[serde(skip_serializing_if = "is_false")] + disable_padding: bool, + /// Legacy batch toggle. Default false. Emit when explicitly set. + #[serde(skip_serializing_if = "is_false")] + enable_batching: bool, + /// PR #448 adaptive coalesce window. Defaults are 0/0 (= "use + /// the crate's compiled defaults"). Emit when the user has + /// hand-edited non-zero values into config.json so they survive + /// a UI save. + #[serde(skip_serializing_if = "is_zero_u16")] + coalesce_step_ms: u16, + #[serde(skip_serializing_if = "is_zero_u16")] + coalesce_max_ms: u16, /// Exit-node config (CF-anti-bot bypass for chatgpt.com / claude.ai / /// grok.com / x.com via exit-node second-hop relay). Skip when fully /// default (disabled with no URL/PSK/hosts) so configs without @@ -714,6 +911,14 @@ struct ConfigWire<'a> { /// Save preserves user-edited values. #[serde(skip_serializing_if = "is_default_exit_node")] exit_node: &'a mhrv_rs::config::ExitNodeConfig, + + /// Verbatim passthrough of unknown / future config.json keys + /// captured at load time. Re-emitted via `#[serde(flatten)]` + /// so a Save-config or Save-as-profile round-trip preserves + /// every field — matching the Android `extrasJson` semantics. + /// Skip when empty so unchanged configs stay clean. + #[serde(flatten, skip_serializing_if = "std::collections::BTreeMap::is_empty")] + extras: &'a std::collections::BTreeMap, } fn is_default_strikes(v: &u32) -> bool { *v == 3 } @@ -740,6 +945,10 @@ fn is_zero_u8(v: &u8) -> bool { *v == 0 } +fn is_zero_u16(v: &u16) -> bool { + *v == 0 +} + #[derive(serde::Serialize)] #[serde(untagged)] enum ScriptIdWire<'a> { @@ -788,6 +997,12 @@ impl<'a> From<&'a Config> for ConfigWire<'a> { request_timeout_secs: c.request_timeout_secs, force_http1: c.force_http1, exit_node: &c.exit_node, + extras: &c.extras, + block_quic: c.block_quic, + disable_padding: c.disable_padding, + enable_batching: c.enable_batching, + coalesce_step_ms: c.coalesce_step_ms, + coalesce_max_ms: c.coalesce_max_ms, } } } @@ -924,6 +1139,13 @@ impl eframe::App for App { ui.add_space(2.0); + // ── Profile bar ────────────────────────────────────────────── + // Lets the user keep several configs (e.g. one Apps Script setup + // and one Full tunnel setup) side by side and switch between + // them without re-typing deployment IDs / auth keys / tuning + // knobs. See `src/profiles.rs` for the storage model. + self.show_profile_bar(ui); + // ── Section: Mode ───────────────────────────────────────────── // Surfacing the mode at the top of the form because it changes // which of the sections below are actually used. `direct` runs @@ -1286,7 +1508,48 @@ impl eframe::App for App { // Apply the new log level live so users don't have to // restart for the combobox to take effect (#401). apply_log_level(&self.form.log_level); - self.toast = Some((format!("Saved to {}", p.display()), Instant::now())); + // Invariant 2: `active = "name"` means the + // named profile's snapshot equals + // config.json. A regular Save config writes + // user-edited bytes that almost certainly + // diverge from any saved profile, so clear + // active. The user can re-bind via "Save as + // profile" if they want the live config + // tracked under a name again. + let mut pointer_warning: Option = None; + if !self.profiles.active.is_empty() && self.profiles_load_ok { + let mut next = self.profiles.clone(); + next.active = String::new(); + match next.save() { + Ok(()) => { + self.profiles = next; + } + Err(e) => { + // The config write succeeded but + // we couldn't clear the stale + // active marker. Surface that to + // the user — the dropdown will + // still claim the previous + // profile matches the live + // config when it no longer does. + tracing::warn!( + "profiles: clearing active on Save config failed: {}", + e + ); + pointer_warning = Some(format!("{}", e)); + } + } + } + self.toast = Some(( + match pointer_warning { + Some(w) => format!( + "Saved to {}, but the active profile marker still points at '{}' (clearing it failed: {}). The dropdown will misclaim until you switch profiles or restart.", + p.display(), self.profiles.active, w + ), + None => format!("Saved to {}", p.display()), + }, + Instant::now(), + )); } Err(e) => self.toast = Some((format!("Save failed: {}", e), Instant::now())), } @@ -1298,6 +1561,9 @@ impl eframe::App for App { // Floating SNI editor window. Rendered here so it's inside the // same egui context but visually pops out with its own title bar. self.show_sni_editor(ctx); + // Profile dialogs (Save as / Manage). Same pop-out treatment. + self.show_save_as_dialog(ctx); + self.show_manage_dialog(ctx); ui.add_space(8.0); @@ -1875,6 +2141,528 @@ impl App { mhrv_rs::update_check::Route::Direct } + /// Top-of-form profile bar: dropdown selector + "Save as profile" + + /// "Manage" buttons. Switching a profile writes its stored config + /// snapshot to `config.json` and reloads the form from there, so the + /// runtime path (read `config.json`) stays unchanged. + fn show_profile_bar(&mut self, ui: &mut egui::Ui) { + let active = self.profiles.active.clone(); + let names = self.profiles.names(); + // Disable profile switching AND Save-as while the proxy is + // running. The running ProxyServer task holds a Config that + // was cloned at Cmd::Start time and won't pick up a new + // config.json, so any write here would create a UI/runtime + // drift. Save-as currently rewrites config.json (so the + // active marker can truthfully claim "matches live config"), + // which means it has the same drift problem as switching. + // Manage stays enabled — rename / duplicate / delete don't + // touch config.json. + let running = self.shared.state.lock().unwrap().running; + + // Declared outside the horizontal closure so we can read it back + // after the click handlers have run and the borrow on `ui` is + // released — switching profile mutates self, which collides with + // any borrow held inside the closure. + let mut chosen: Option = None; + ui.horizontal(|ui| { + ui.label( + egui::RichText::new("Profile") + .size(12.0) + .color(egui::Color32::from_gray(180)) + .strong(), + ); + let selected_label = if active.is_empty() { + "(none)".to_string() + } else { + active.clone() + }; + let combo = egui::ComboBox::from_id_source("profile_picker") + .selected_text(selected_label); + ui.add_enabled_ui(!running, |ui| { + let resp = combo.show_ui(ui, |ui| { + if names.is_empty() { + ui.label( + egui::RichText::new("no profiles saved yet") + .color(egui::Color32::from_gray(140)) + .italics(), + ); + } else { + for name in &names { + if ui + .selectable_label(active == *name, name.clone()) + .clicked() + { + chosen = Some(name.clone()); + } + } + } + }); + if running { + resp.response.on_hover_text( + "Stop the proxy first — profile switching takes effect on \ + the next Start, and swapping the live config underneath \ + a running proxy would only confuse things.", + ); + } + }); + + // Gate Save-as on (a) profiles.json being loadable AND + // (b) the proxy not running. The corrupt-on-disk case + // would clobber the recoverable bytes; the running case + // would put config.json out of sync with the cloned + // Config inside the running ProxyServer task. + let save_as_enabled = self.profiles_load_ok && !running; + ui.add_enabled_ui(save_as_enabled, |ui| { + let resp = ui + .button("Save as profile…") + .on_hover_text( + "Capture the current form (deployment IDs, mode, auth key, \ + and all tuning knobs) under a name so you can switch back \ + to it later.", + ); + if resp.clicked() { + self.save_as_dialog = Some(SaveAsState::default()); + } + if !self.profiles_load_ok { + resp.on_hover_text( + "Profiles disabled: profiles.json on disk is unreadable. \ + Move it aside manually, then restart.", + ); + } else if running { + resp.on_hover_text( + "Stop the proxy first — Save as profile rewrites config.json \ + to make the active marker truthful, which would put the live \ + config out of sync with the running proxy's cloned config.", + ); + } + }); + + ui.add_enabled_ui(self.profiles_load_ok && !names.is_empty(), |ui| { + if ui + .button("Manage…") + .on_hover_text("Rename, duplicate, or delete saved profiles.") + .clicked() + { + self.manage_dialog = Some(ManageState::default()); + } + }); + }); + + if let Some(name) = chosen { + self.switch_to_profile(&name); + } + } + + /// Switch the live config to a stored profile: write the profile's + /// snapshot to `config.json`, reload the form from disk, update the + /// profiles file's active pointer. Toasts on either outcome. + /// + /// The snapshot is applied RAW (invariant 1 in `src/profiles.rs`) — + /// any config fields this build doesn't model still survive in the + /// live config. + fn switch_to_profile(&mut self, name: &str) { + // Three distinct outcomes from apply_profile: + // - Err — nothing changed on disk. Toast the error verbatim. + // - Ok(ApplyOutcome::Ok) — both writes succeeded. + // - Ok(ApplyOutcome::PartialConfigOnly(e)) — config.json IS + // the new profile but the active pointer didn't save. + // Treat as "switched" for the form reload, but surface the + // carried error so the user knows the dropdown's marker is + // stale. + let pointer_warning = match profiles::apply_profile(name) { + Err(e) => { + self.toast = Some((format!("Switch failed: {}", e), Instant::now())); + return; + } + Ok(profiles::ApplyOutcome::Ok) => None, + Ok(profiles::ApplyOutcome::PartialConfigOnly(e)) => Some(format!("{}", e)), + }; + // Reload the profile store so the active pointer reflects the + // switch (apply_profile updated it on disk — unless we got + // PartialConfigOnly, in which case the on-disk pointer is + // stale; we still reload so any other concurrent changes show + // up correctly). + match ProfilesFile::load() { + Ok(pf) => self.profiles = pf, + Err(e) => { + tracing::warn!("profiles: reload after switch failed: {}", e); + } + } + // Reload the form from the new config.json. A warning load_err is + // surfaced as a toast — the user notices but doesn't get stuck. + let (new_form, load_err) = load_form(); + self.form = new_form; + apply_log_level(&self.form.log_level); + let msg = match (pointer_warning, load_err) { + (Some(ptr), Some(le)) => format!( + "Switched to '{}' (live config updated) but pointer save failed: {}; also: {}", + name, ptr, le + ), + (Some(ptr), None) => format!( + "Switched to '{}' (live config updated) but profile pointer save failed: {}", + name, ptr + ), + (None, Some(le)) => format!("Switched to '{}' but: {}", name, le), + (None, None) => format!("Switched to profile '{}'", name), + }; + self.toast = Some((msg, Instant::now())); + } + + /// Modal: "Save as new profile" name prompt. Validates non-empty and + /// non-duplicate (offers an "overwrite existing" path when the name + /// already exists). + fn show_save_as_dialog(&mut self, ctx: &egui::Context) { + let mut close = false; + let Some(state) = self.save_as_dialog.as_mut() else { + return; + }; + // Snapshot what we need so we can mutate self after the window closes. + let mut commit: Option<(String, bool)> = None; // (name, overwrite) + egui::Window::new("Save as profile") + .collapsible(false) + .resizable(false) + .default_width(360.0) + .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, -40.0)) + .show(ctx, |ui| { + ui.label( + "Profile name:", + ); + let resp = ui.add( + egui::TextEdit::singleline(&mut state.name) + .desired_width(f32::INFINITY) + .hint_text("e.g. Apps Script (home) or Full tunnel (work)"), + ); + if resp.changed() { + state.error = None; + } + if let Some(err) = &state.error { + ui.colored_label(ERR_RED, err); + } + let trimmed = state.name.trim().to_string(); + let exists = !trimmed.is_empty() + && self.profiles.find(&trimmed).is_some(); + if exists { + ui.small( + egui::RichText::new(format!( + "A profile named '{}' already exists.", + trimmed + )) + .color(egui::Color32::from_rgb(220, 180, 100)), + ); + } + ui.add_space(6.0); + ui.horizontal(|ui| { + let save_label = if exists { "Overwrite" } else { "Save" }; + let save_enabled = !trimmed.is_empty(); + if ui + .add_enabled(save_enabled, egui::Button::new(save_label)) + .clicked() + { + commit = Some((trimmed.clone(), exists)); + } + if ui.button("Cancel").clicked() { + close = true; + } + }); + }); + + if let Some((name, overwrite)) = commit { + match self.save_form_as_profile(&name, overwrite) { + Ok(()) => { + self.toast = Some(( + format!("Saved profile '{}'", name), + Instant::now(), + )); + close = true; + } + Err(msg) => { + if let Some(state) = self.save_as_dialog.as_mut() { + state.error = Some(msg); + } + } + } + } + + if close { + self.save_as_dialog = None; + } + } + + /// Save the current form as a named profile. + /// + /// Write order: **`config.json` FIRST, then `profiles.json`**. + /// This is the safe order because: + /// - If `config.json` fails, neither file changes; nothing to + /// roll back (invariant 3). + /// - If `profiles.json` fails after `config.json` succeeded, the + /// live config now reflects the form — equivalent to the user + /// having clicked Save config — but no profile entry was + /// added/updated. Invariant 2 holds: we never wrote an + /// `active` claim we couldn't back up. + /// + /// The previous order (profiles.json first) had a corruption bug: + /// on overwrite, the profile's snapshot was already replaced + /// before we knew whether config.json would land, so a failed + /// config write left profile "name" pointing at bytes that + /// nothing on disk matched. + fn save_form_as_profile(&mut self, name: &str, overwrite: bool) -> Result<(), String> { + if !self.profiles_load_ok { + return Err( + "profiles.json on disk is unreadable; refusing to overwrite. \ + Move it aside manually, then restart." + .into(), + ); + } + let cfg = self + .form + .to_config() + .map_err(|e| format!("Form is invalid: {}", e))?; + let wire = ConfigWire::from(&cfg); + let value = serde_json::to_value(&wire) + .map_err(|e| format!("serialize failed: {}", e))?; + + // Pre-validate the profile mutation would succeed (collision / + // empty-name checks) BEFORE touching config.json, so we don't + // commit the live config and then discover the profile + // operation is rejected. + let mut next = self.profiles.clone(); + if overwrite { + next.upsert(name, value.clone()).map_err(|e| format!("{}", e))?; + } else { + next.insert_new(name, value.clone()).map_err(|e| format!("{}", e))?; + } + + // Step 1: write the snapshot to config.json. On failure, + // neither file has changed. + profiles::write_config_json(&value) + .map_err(|e| format!("write config.json failed: {}", e))?; + + // Step 2: write profiles.json with the new entry + active=name. + // On failure here, config.json is already the new bytes but no + // profile entry exists. We surface this as "PartialConfigOnly" + // text so the user understands the live config DID change but + // the profile didn't save. + match next.save() { + Ok(()) => { + self.profiles = next; + let (new_form, _) = load_form(); + self.form = new_form; + apply_log_level(&self.form.log_level); + Ok(()) + } + Err(e) => { + // config.json IS the new bytes — refresh the form so + // the UI reflects that — but report the profile save + // failure honestly. + let (new_form, _) = load_form(); + self.form = new_form; + apply_log_level(&self.form.log_level); + Err(format!( + "Live config saved, but writing the profile entry failed: {}. \ + Retry to save the profile.", + e + )) + } + } + } + + /// Modal: "Manage profiles". Lists every saved profile with rename, + /// duplicate, and delete actions. All mutations write through to disk + /// immediately. + fn show_manage_dialog(&mut self, ctx: &egui::Context) { + if self.manage_dialog.is_none() { + return; + } + // Use a local "close" sentinel and only mutate self.manage_dialog + // at the very end — egui's Window::open borrow conflicts with + // mid-frame mutations. + let mut close = false; + let names = self.profiles.names(); + let active = self.profiles.active.clone(); + // Collected actions to apply after the closure (mutating + // self.profiles inside would tangle with the &mut borrow on + // self.manage_dialog). + enum Action { + CommitRename { from: String, to: String }, + Duplicate(String), + Delete(String), + } + let mut pending: Option = None; + + egui::Window::new("Manage profiles") + .collapsible(false) + .resizable(true) + .default_size(egui::vec2(460.0, 360.0)) + .show(ctx, |ui| { + let state = self.manage_dialog.as_mut().unwrap(); + if let Some(err) = &state.error { + ui.colored_label(ERR_RED, err); + ui.add_space(4.0); + } + if names.is_empty() { + ui.label( + egui::RichText::new("No profiles saved yet.") + .italics() + .color(egui::Color32::from_gray(160)), + ); + } + egui::ScrollArea::vertical() + .auto_shrink([false; 2]) + .show(ui, |ui| { + for name in &names { + ui.horizontal(|ui| { + let is_active = *name == active; + if is_active { + ui.label( + egui::RichText::new("●") + .color(OK_GREEN) + .strong(), + ) + .on_hover_text("Active profile"); + } else { + ui.label(" "); + } + if state.renaming.as_deref() == Some(name.as_str()) { + let buf = state + .rename_buf + .entry(name.clone()) + .or_insert_with(|| name.clone()); + ui.add( + egui::TextEdit::singleline(buf) + .desired_width(180.0), + ); + if ui.button("OK").clicked() { + let to = buf.clone(); + pending = Some(Action::CommitRename { + from: name.clone(), + to, + }); + } + if ui.button("Cancel").clicked() { + state.renaming = None; + state.rename_buf.remove(name); + state.error = None; + } + } else if state.pending_delete.as_deref() + == Some(name.as_str()) + { + // Confirm-delete row: replaces the + // usual action buttons with an + // explicit "Confirm delete?" prompt. + // Profile data may be the user's + // only copy, so we don't want a + // single accidental click to take + // it out. + ui.label( + egui::RichText::new(format!("Delete '{}'?", name)) + .color(ERR_RED) + .strong(), + ); + let confirm = egui::Button::new( + egui::RichText::new("Confirm delete") + .color(egui::Color32::WHITE), + ) + .fill(ERR_RED) + .rounding(4.0); + if ui.add(confirm).clicked() { + pending = Some(Action::Delete(name.clone())); + } + if ui.small_button("Cancel").clicked() { + state.pending_delete = None; + state.error = None; + } + } else { + ui.label( + egui::RichText::new(name.clone()) + .monospace(), + ); + if ui.small_button("Rename").clicked() { + state.renaming = Some(name.clone()); + state.error = None; + } + if ui.small_button("Duplicate").clicked() { + pending = Some(Action::Duplicate(name.clone())); + } + if ui.small_button("Delete").clicked() { + state.pending_delete = Some(name.clone()); + state.error = None; + } + } + }); + ui.add_space(2.0); + } + }); + ui.add_space(6.0); + ui.horizontal(|ui| { + if ui.button("Close").clicked() { + close = true; + } + }); + }); + + if let Some(action) = pending { + if !self.profiles_load_ok { + let state = self.manage_dialog.as_mut().unwrap(); + state.error = Some( + "profiles.json on disk is unreadable; refusing to overwrite. \ + Move it aside manually, then restart." + .into(), + ); + if close { + self.manage_dialog = None; + } + return; + } + // Transactional: mutate a clone, save, only assign back on + // success. A failed disk write thus leaves both the + // in-memory state and the on-disk file unchanged + // (invariant 3 in src/profiles.rs). + let mut next = self.profiles.clone(); + let outcome: Result<(), String> = match &action { + Action::CommitRename { from, to } => next + .rename(from, to) + .map_err(|e| format!("{}", e)), + Action::Duplicate(name) => { + // Pick a unique copy name: "name (copy)", "name (copy 2)", … + let mut candidate = format!("{} (copy)", name); + let mut n = 2; + while next.find(&candidate).is_some() { + candidate = format!("{} (copy {})", name, n); + n += 1; + } + next.duplicate(name, &candidate) + .map_err(|e| format!("{}", e)) + } + Action::Delete(name) => { + next.delete(name).map_err(|e| format!("{}", e)) + } + }; + let state = self.manage_dialog.as_mut().unwrap(); + match outcome.and_then(|_| next.save().map_err(|e| format!("save failed: {}", e))) { + Ok(()) => { + // Disk write succeeded — now commit the new state. + self.profiles = next; + state.error = None; + match &action { + Action::CommitRename { from, .. } => { + state.renaming = None; + state.rename_buf.remove(from); + } + Action::Delete(_) => { + state.pending_delete = None; + } + Action::Duplicate(_) => {} + } + } + Err(e) => state.error = Some(e), + } + } + + if close { + self.manage_dialog = None; + } + } + /// Floating editor window for the SNI rotation pool. Opens from the /// **SNI pool…** button in the main form. The list is live-editable /// (reorder / toggle / add / remove); changes only persist when the user @@ -2714,3 +3502,99 @@ fn push_log(shared: &Shared, msg: &str) { s.log.pop_front(); } } + +#[cfg(test)] +mod tests { + use super::*; + use mhrv_rs::config::Config; + + /// Regression for the desktop write-side extras passthrough. + /// `config.rs::unknown_fields_captured_into_extras` proves the + /// LOAD side stashes unknown keys into `Config::extras`. This + /// test pins the WRITE side: building a `ConfigWire` from a + /// `Config` with extras must re-emit those keys verbatim in the + /// serialized JSON. Otherwise Save-config and Save-as-profile + /// would still silently drop future / hand-edited fields even + /// though `Config` carried them through. + #[test] + fn config_wire_serializes_extras() { + let json = r#"{ + "mode": "apps_script", + "auth_key": "MY_REAL_SECRET", + "script_id": "X", + "future_field_xyz": [1, 2, 3], + "another_future_field": {"nested": true, "n": 42} + }"#; + let cfg: Config = serde_json::from_str(json).unwrap(); + // Sanity check on the load side. + assert!(cfg.extras.contains_key("future_field_xyz")); + + // The write path: build a ConfigWire and serialize. + let wire = ConfigWire::from(&cfg); + let out = serde_json::to_value(&wire).expect("ConfigWire serialize"); + + // Unknown keys must appear in the output, with their values + // preserved exactly. + assert_eq!( + out.get("future_field_xyz"), + Some(&serde_json::json!([1, 2, 3])), + "ConfigWire must re-emit unknown scalar/array fields verbatim" + ); + assert_eq!( + out.get("another_future_field"), + Some(&serde_json::json!({"nested": true, "n": 42})), + "ConfigWire must re-emit unknown object fields verbatim" + ); + // Modelled fields must NOT be duplicated by the extras flatten + // (would happen if Config also stuck them in `extras`). + assert_eq!(out.get("mode"), Some(&serde_json::json!("apps_script"))); + assert_eq!(out.get("auth_key"), Some(&serde_json::json!("MY_REAL_SECRET"))); + } + + /// Carry-through of `block_quic` / `disable_padding` / `enable_batching` + /// / `coalesce_*` through ConfigWire. These were the modelled fields + /// that the previous ConfigWire was silently dropping. + #[test] + fn config_wire_serializes_previously_dropped_modeled_fields() { + let json = r#"{ + "mode": "direct", + "block_quic": false, + "disable_padding": true, + "enable_batching": true, + "coalesce_step_ms": 25, + "coalesce_max_ms": 750 + }"#; + let cfg: Config = serde_json::from_str(json).unwrap(); + let wire = ConfigWire::from(&cfg); + let out = serde_json::to_value(&wire).unwrap(); + // block_quic: default true → emit when false. + assert_eq!(out.get("block_quic"), Some(&serde_json::json!(false))); + // disable_padding: default false → emit when true. + assert_eq!(out.get("disable_padding"), Some(&serde_json::json!(true))); + // enable_batching: default false → emit when true. + assert_eq!(out.get("enable_batching"), Some(&serde_json::json!(true))); + // coalesce_*: default 0 → emit when non-zero. + assert_eq!(out.get("coalesce_step_ms"), Some(&serde_json::json!(25))); + assert_eq!(out.get("coalesce_max_ms"), Some(&serde_json::json!(750))); + } + + /// Defaults must NOT be emitted, so unchanged configs stay clean + /// on disk — symmetric to the round-trip test above. This catches + /// the failure mode where `skip_serializing_if` is wired to the + /// wrong predicate (e.g. `is_false` instead of `is_true`). + #[test] + fn config_wire_omits_default_values() { + let json = r#"{ + "mode": "direct" + }"#; + let cfg: Config = serde_json::from_str(json).unwrap(); + let wire = ConfigWire::from(&cfg); + let out = serde_json::to_value(&wire).unwrap(); + // block_quic defaults true → should NOT appear in output. + assert!(out.get("block_quic").is_none(), "default block_quic must be omitted"); + assert!(out.get("disable_padding").is_none()); + assert!(out.get("enable_batching").is_none()); + assert!(out.get("coalesce_step_ms").is_none()); + assert!(out.get("coalesce_max_ms").is_none()); + } +} diff --git a/src/config.rs b/src/config.rs index 02ae2aad..bcdd2f45 100644 --- a/src/config.rs +++ b/src/config.rs @@ -370,6 +370,26 @@ pub struct Config { #[serde(default = "default_request_timeout_secs")] pub request_timeout_secs: u64, + /// Verbatim JSON for any config.json key this build doesn't model + /// (e.g. fields shipped by a newer build of the desktop UI, or + /// keys hand-edited by the user that haven't graduated to a real + /// field yet). Captured here via `#[serde(flatten)]` so unknown + /// fields round-trip cleanly through load → UI form → save + /// instead of being silently dropped. + /// + /// The profile-storage layer in `src/profiles.rs` promises raw + /// snapshot preservation; the on-disk snapshot is the same + /// `config.json` contents, so any field that makes it through + /// this map will also survive a Save-as-profile / Switch round + /// trip. + /// + /// Order matters: `#[serde(flatten)]` must come BEFORE + /// `exit_node` so unknown keys collect into the map rather than + /// being claimed by it. (`flatten` on a HashMap absorbs every + /// not-already-named field.) + #[serde(flatten, default)] + pub extras: std::collections::BTreeMap, + /// Optional second-hop exit node, for sites that block traffic /// from Google datacenter IPs (Apps Script's outbound IP space). /// Most visibly: Cloudflare-fronted services that flag the GCP IP @@ -549,7 +569,7 @@ impl Config { Ok(cfg) } - fn validate(&self) -> Result<(), ConfigError> { + pub fn validate(&self) -> Result<(), ConfigError> { let mode = self.mode_kind()?; if mode == Mode::AppsScript || mode == Mode::Full { if self.auth_key.trim().is_empty() || self.auth_key == "CHANGE_ME_TO_A_STRONG_SECRET" { @@ -923,6 +943,41 @@ mod rt_tests { assert!(cfg.force_http1, "force_http1=true must round-trip"); } + /// Unknown / future config.json keys captured into `extras` round-trip + /// through serde load. Sibling tests on the write side + /// (`config_wire_serializes_extras` / + /// `config_wire_serializes_previously_dropped_modeled_fields` / + /// `config_wire_omits_default_values`) live in `src/bin/ui.rs` + /// since `ConfigWire` is defined there. + #[test] + fn unknown_fields_captured_into_extras() { + let json = r#"{ + "mode": "apps_script", + "auth_key": "secretkey123", + "script_id": "X", + "future_field_xyz": [1, 2, 3], + "another_future_field": {"nested": true} + }"#; + let cfg: Config = serde_json::from_str(json).unwrap(); + assert!( + cfg.extras.contains_key("future_field_xyz"), + "extras must capture unknown scalar/array fields" + ); + assert!( + cfg.extras.contains_key("another_future_field"), + "extras must capture unknown object fields" + ); + assert_eq!( + cfg.extras.get("future_field_xyz").unwrap(), + &serde_json::json!([1, 2, 3]) + ); + // Modelled fields must NOT end up in extras (otherwise we'd + // double-emit them on save). + assert!(!cfg.extras.contains_key("mode")); + assert!(!cfg.extras.contains_key("auth_key")); + assert!(!cfg.extras.contains_key("script_id")); + } + #[test] fn force_http1_defaults_false_when_omitted() { // Existing configs from before v1.9.13 don't have the field. diff --git a/src/lib.rs b/src/lib.rs index 6b53a32b..73fe9073 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,6 +7,7 @@ pub mod data_dir; pub mod domain_fronter; pub mod lan_utils; pub mod mitm; +pub mod profiles; pub mod proxy_server; pub mod rlimit; pub mod tunnel_client; diff --git a/src/profiles.rs b/src/profiles.rs new file mode 100644 index 00000000..521084a9 --- /dev/null +++ b/src/profiles.rs @@ -0,0 +1,1086 @@ +//! Profile storage for the UI layer. +//! +//! A profile is a named, complete snapshot of `config.json`. The Rust core +//! (proxy server, tunnel client, MITM) keeps reading from a single +//! `config.json` — profiles are a UI-only convenience that lets the user +//! keep several configurations side by side (e.g. one Apps Script setup +//! and one Full tunnel setup) and switch between them without re-typing +//! deployment IDs, auth keys, and tuning knobs. +//! +//! Snapshots are stored as `serde_json::Value` rather than the parsed +//! `Config` struct. Forward-compat: a profile written by a newer build +//! that has more config fields still loads on an older build, and any +//! unknown fields round-trip through Save/Switch without being dropped. +//! +//! # Invariants (must match the Android side, see `ProfileStore.kt`) +//! +//! 1. **Raw snapshot preservation.** A profile's `config` is stored +//! exactly as written. Applying a profile writes that raw JSON to +//! `config.json` byte-for-byte (subject to pretty-print) — any +//! config fields the desktop build doesn't model survive. The +//! apply path must NOT round-trip through `Config` parse and +//! re-serialize. +//! +//! 2. **`active` means "matches the live config".** Setting +//! `active = "name"` is a promise that `profiles[name].config` +//! equals the current `config.json`. Any operation that breaks +//! that promise must clear `active` (set it to `""`). In +//! particular: deleting the active profile sets `active = ""` +//! — we do NOT auto-apply some other profile, because that would +//! silently rewrite the user's live config. +//! +//! 3. **Persist before in-memory state changes.** Mutate a clone, +//! `save()` the clone, then commit the clone to `self` only on +//! success. A failed write must not leave the UI showing state +//! that will disappear on restart. +//! +//! 4. **Load failure is loud.** A file that exists but won't parse is +//! surfaced as a real error so the UI can refuse to clobber a +//! corrupted-but-recoverable `profiles.json` with an empty one. + +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +use crate::data_dir; + +#[derive(Debug, thiserror::Error)] +pub enum ProfileError { + #[error("failed to read profiles file {0}: {1}")] + Read(String, #[source] std::io::Error), + #[error("failed to write profiles file {0}: {1}")] + Write(String, #[source] std::io::Error), + #[error("failed to parse profiles json: {0}")] + Parse(#[from] serde_json::Error), + #[error("profile '{0}' not found")] + NotFound(String), + #[error("profile '{0}' already exists")] + Duplicate(String), + #[error("profile name must not be empty")] + EmptyName, + /// The on-disk `profiles.json` exists but failed to parse. The UI + /// must refuse to overwrite — clobbering a corrupted-but-recoverable + /// file with an empty / partially-edited new one would destroy any + /// chance of hand-recovering the user's data. + #[error("profiles file on disk is corrupt: {0}")] + CorruptOnDisk(String), +} + +/// One named config snapshot. `config` is the raw config JSON object — +/// kept as a `Value` so adding fields to `Config` later doesn't require +/// touching this module, and so unknown fields round-trip cleanly. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Profile { + pub name: String, + pub config: serde_json::Value, +} + +/// Top-level profiles file. `active` names the currently-selected profile +/// per invariant 2: `active = "name"` is a claim that `profiles[name]` +/// matches the live `config.json`. `active = ""` means no profile is +/// known to match (e.g. the user hand-edited `config.json` directly, +/// or the active profile was just deleted). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ProfilesFile { + #[serde(default)] + pub active: String, + #[serde(default)] + pub profiles: Vec, +} + +/// Path to `profiles.json` inside the platform data dir. +pub fn profiles_path() -> PathBuf { + data_dir::data_dir().join("profiles.json") +} + +/// Validate that a profile entry's shape is loadable / applyable. +/// +/// Specifically: name must be non-empty after trim, and `config` must +/// be a JSON object (not `null`, not a scalar, not an array). The +/// runtime parses `config.json` with `serde(default)` so any object +/// is *acceptable* even if some fields are unknown, but `null` or a +/// non-object would write garbage that `Config::load` then rejects +/// — and worse, would clobber the user's previous live config in +/// the process. Reject loudly at load/upsert time so we never even +/// attempt the write. +/// Validate that a snapshot would load successfully as the runtime +/// [`Config`] — i.e. it parses, has a known mode, and (for +/// relay-bearing modes) has the credentials those modes need. +/// +/// Mirrors [`crate::config::Config::load`] (which is +/// `from_str` + `validate()`) but operating on an already-parsed +/// `serde_json::Value`. We deliberately keep this check at the +/// apply boundary rather than at load time so that an older saved +/// profile doesn't get retroactively flagged corrupt the next time +/// the user opens the UI. +fn validate_snapshot_loadable(value: &serde_json::Value) -> Result<(), String> { + let cfg: crate::config::Config = serde_json::from_value(value.clone()) + .map_err(|e| format!("snapshot is not a loadable Config: {}", e))?; + cfg.validate() + .map_err(|e| format!("snapshot would not pass runtime validation: {}", e))?; + Ok(()) +} + +fn validate_profile(idx: usize, p: &Profile) -> Result<(), String> { + let label = format!("profiles[{}]", idx); + if p.name.trim().is_empty() { + return Err(format!("{}: name is empty or whitespace", label)); + } + if !p.config.is_object() { + let kind = match &p.config { + serde_json::Value::Null => "null", + serde_json::Value::Bool(_) => "boolean", + serde_json::Value::Number(_) => "number", + serde_json::Value::String(_) => "string", + serde_json::Value::Array(_) => "array", + serde_json::Value::Object(_) => "object", // unreachable + }; + return Err(format!( + "{} ('{}'): config must be a JSON object, got {}", + label, p.name, kind + )); + } + Ok(()) +} + +impl ProfilesFile { + /// Load the profiles file. Missing file = empty store (not an error + /// — first run has no profiles yet). A file that exists but won't + /// parse is returned as `Err(CorruptOnDisk)` so the UI can refuse + /// to overwrite it (invariant 4). + pub fn load() -> Result { + Self::load_from(&profiles_path()) + } + + pub fn load_from(path: &Path) -> Result { + if !path.exists() { + return Ok(Self::default()); + } + let data = std::fs::read_to_string(path) + .map_err(|e| ProfileError::Read(path.display().to_string(), e))?; + if data.trim().is_empty() { + return Ok(Self::default()); + } + let pf: ProfilesFile = serde_json::from_str(&data) + .map_err(|e| ProfileError::CorruptOnDisk(format!("{}", e)))?; + // Strict schema check: matches Android's `parse` strictness so + // a partially-malformed file surfaces loudly here too. Each + // profile must have a non-empty name and a JSON object for + // `config` — otherwise applying it would clobber config.json + // with non-config bytes (e.g. `null`). + for (i, p) in pf.profiles.iter().enumerate() { + validate_profile(i, p) + .map_err(|msg| ProfileError::CorruptOnDisk(msg))?; + } + // Names must be unique. With duplicates, `active` and every + // by-name operation (apply / rename / delete) become + // ambiguous: Rust's `delete` removes only the first match + // while Android's `delete` removes all matches, so the two + // implementations diverge on the same file. Reject loudly + // so the user can hand-fix. + let mut seen: std::collections::HashSet<&str> = + std::collections::HashSet::with_capacity(pf.profiles.len()); + for (i, p) in pf.profiles.iter().enumerate() { + if !seen.insert(p.name.as_str()) { + return Err(ProfileError::CorruptOnDisk(format!( + "profiles[{}] ('{}'): duplicate profile name", + i, p.name + ))); + } + } + Ok(pf) + } + + /// Save atomically: write to `profiles.json.tmp` then rename. Avoids + /// a torn file if the UI crashes mid-write. + pub fn save(&self) -> Result<(), ProfileError> { + self.save_to(&profiles_path()) + } + + pub fn save_to(&self, path: &Path) -> Result<(), ProfileError> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| ProfileError::Write(parent.display().to_string(), e))?; + } + let json = serde_json::to_string_pretty(self)?; + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, json) + .map_err(|e| ProfileError::Write(tmp.display().to_string(), e))?; + // `std::fs::rename` is an atomic replace on every platform we + // support: POSIX rename(2) is atomic for same-filesystem + // renames, and Windows since Rust 1.5 uses MoveFileExW with + // MOVEFILE_REPLACE_EXISTING. We rely on that — we do NOT + // remove the target first, which would create a window where + // neither the old nor the new file exists if the subsequent + // rename fails (data loss). If rename fails for some other + // reason (locked file, antivirus), the original is preserved + // and the tmp file is left behind for hand recovery. + std::fs::rename(&tmp, path) + .map_err(|e| ProfileError::Write(path.display().to_string(), e))?; + Ok(()) + } + + pub fn names(&self) -> Vec { + self.profiles.iter().map(|p| p.name.clone()).collect() + } + + pub fn find(&self, name: &str) -> Option<&Profile> { + self.profiles.iter().find(|p| p.name == name) + } + + pub fn find_mut(&mut self, name: &str) -> Option<&mut Profile> { + self.profiles.iter_mut().find(|p| p.name == name) + } + + /// Upsert: replace the snapshot for an existing name, or append a + /// new profile. The active pointer is moved to the upserted name + /// — by invariant 2 this is correct ONLY if the caller also writes + /// the snapshot to `config.json`. The high-level wrapper + /// [`apply_saved_profile_to_live_config`] does both atomically; + /// direct callers must do the same or accept the divergence. + pub fn upsert(&mut self, name: &str, config: serde_json::Value) -> Result<(), ProfileError> { + let name = name.trim(); + if name.is_empty() { + return Err(ProfileError::EmptyName); + } + if !config.is_object() { + return Err(ProfileError::CorruptOnDisk(format!( + "profile '{}': config must be a JSON object", + name + ))); + } + if let Some(p) = self.find_mut(name) { + p.config = config; + } else { + self.profiles.push(Profile { + name: name.to_string(), + config, + }); + } + self.active = name.to_string(); + Ok(()) + } + + /// Insert a new profile, refusing to overwrite an existing one of + /// the same name. Same invariant-2 caveat as [`upsert`]. + pub fn insert_new( + &mut self, + name: &str, + config: serde_json::Value, + ) -> Result<(), ProfileError> { + let name = name.trim(); + if name.is_empty() { + return Err(ProfileError::EmptyName); + } + if !config.is_object() { + return Err(ProfileError::CorruptOnDisk(format!( + "profile '{}': config must be a JSON object", + name + ))); + } + if self.find(name).is_some() { + return Err(ProfileError::Duplicate(name.to_string())); + } + self.profiles.push(Profile { + name: name.to_string(), + config, + }); + self.active = name.to_string(); + Ok(()) + } + + pub fn rename(&mut self, from: &str, to: &str) -> Result<(), ProfileError> { + let to = to.trim(); + if to.is_empty() { + return Err(ProfileError::EmptyName); + } + if from != to && self.find(to).is_some() { + return Err(ProfileError::Duplicate(to.to_string())); + } + let p = self + .find_mut(from) + .ok_or_else(|| ProfileError::NotFound(from.to_string()))?; + p.name = to.to_string(); + if self.active == from { + self.active = to.to_string(); + } + Ok(()) + } + + /// Delete. If the deleted profile was active, `active` becomes `""` + /// (invariant 2). We do NOT silently pick "first remaining" — that + /// would claim some unrelated profile matches the live config when + /// it doesn't. The user can explicitly switch to a different + /// profile if they want. + pub fn delete(&mut self, name: &str) -> Result<(), ProfileError> { + let idx = self + .profiles + .iter() + .position(|p| p.name == name) + .ok_or_else(|| ProfileError::NotFound(name.to_string()))?; + self.profiles.remove(idx); + if self.active == name { + self.active = String::new(); + } + Ok(()) + } + + /// Duplicate `from` under `to`. Non-destructive: does NOT change + /// the active pointer or touch `config.json`. + pub fn duplicate(&mut self, from: &str, to: &str) -> Result<(), ProfileError> { + let to = to.trim(); + if to.is_empty() { + return Err(ProfileError::EmptyName); + } + if self.find(to).is_some() { + return Err(ProfileError::Duplicate(to.to_string())); + } + let src = self + .find(from) + .ok_or_else(|| ProfileError::NotFound(from.to_string()))?; + let copy = Profile { + name: to.to_string(), + config: src.config.clone(), + }; + self.profiles.push(copy); + Ok(()) + } +} + +/// Outcome of [`apply_profile`]. Splits the two failure modes the UI +/// cares about: "nothing changed" (config write failed, we can show +/// a clean error) vs "config swapped but pointer didn't move" +/// (live runtime is on the new profile, just the bookkeeping is +/// stale — the UI should reflect the apply but warn the user). +#[derive(Debug)] +pub enum ApplyOutcome { + /// Both `config.json` and `profiles.json` were written. + Ok, + /// `config.json` was written — live config IS the new profile — + /// but the `profiles.json` write failed. The UI should reload the + /// form (because config.json changed) and surface the carried + /// error so the user knows the dropdown's active marker is stale. + PartialConfigOnly(ProfileError), +} + +/// Write a profile's snapshot to `config.json` and update the active +/// pointer. By invariant 1 we serialize the snapshot raw (not via a +/// `Config` round-trip) so any fields this build doesn't model still +/// survive in the live config — the Rust runtime parses with +/// `#[serde(default)]` on unknown fields, so they're harmlessly +/// preserved. +/// +/// Outcome contract: +/// - `Err(ProfileError::NotFound)` — profile missing, no writes attempted. +/// - `Err(...)` for any error BEFORE `config.json` is written — nothing +/// changed on disk. +/// - `Ok(ApplyOutcome::Ok)` — both writes succeeded. +/// - `Ok(ApplyOutcome::PartialConfigOnly(e))` — `config.json` is the +/// new profile but `profiles.json` write failed. The caller should +/// still treat this as "switched" for UI purposes and surface the +/// carried error so the user sees the divergence honestly. +pub fn apply_profile(name: &str) -> Result { + apply_profile_with_paths(&profiles_path(), &data_dir::config_path(), name) +} + +/// Path-injecting variant of [`apply_profile`] for testability. Lets +/// unit tests redirect both files to a temp dir AND inject a write +/// failure on `config_path` (by making the path a directory before +/// the call, which makes the rename fail). +pub fn apply_profile_with_paths( + profiles_path: &Path, + config_path: &Path, + name: &str, +) -> Result { + let pf = ProfilesFile::load_from(profiles_path)?; + let p = pf + .find(name) + .ok_or_else(|| ProfileError::NotFound(name.to_string()))?; + + // Belt-and-braces: even though load_from now schema-validates, + // re-check at the apply boundary so a hand-modified in-memory + // ProfilesFile can't smuggle a non-object snapshot through to + // config.json. Without this, a `config: null` profile would + // clobber the user's live config with the literal bytes `null`. + validate_profile(0, p).map_err(ProfileError::CorruptOnDisk)?; + + // Runtime-shape validation: refuse to write a snapshot that + // wouldn't load as a Config (e.g. {}, missing required mode, + // missing script_id/auth_key for apps_script/full). Without + // this, applying a malformed profile would clobber the user's + // working config.json with bytes the runtime then rejects on + // next start, leaving them with neither the old config nor a + // usable one. We deliberately don't run this check at load + // time — older saved profiles may pre-date a validation + // tightening and we shouldn't mark them corrupt retroactively. + validate_snapshot_loadable(&p.config).map_err(ProfileError::CorruptOnDisk)?; + + // Raw write — preserves unknown fields (invariant 1). If this + // fails, nothing has changed on disk. + write_config_json_to(config_path, &p.config)?; + + // Past this point, `config.json` IS the new profile. A failure + // moving the pointer is real but not catastrophic — we surface it + // as PartialConfigOnly. + let mut updated = pf; + updated.active = name.to_string(); + match updated.save_to(profiles_path) { + Ok(()) => Ok(ApplyOutcome::Ok), + Err(e) => Ok(ApplyOutcome::PartialConfigOnly(e)), + } +} + +/// Apply a snapshot value as the live config without involving the +/// profile store. Used by "Save as profile" — invariant 2 requires +/// that whenever we set `active = name`, the snapshot under that name +/// must equal `config.json`. The natural way to satisfy that on save +/// is to also write the snapshot to `config.json`. +pub fn write_config_json(snapshot: &serde_json::Value) -> Result<(), ProfileError> { + write_config_json_to(&data_dir::config_path(), snapshot) +} + +/// Path-injecting variant of [`write_config_json`] for testability. +/// Tests can make `cfg_path` a directory before calling so the +/// rename step fails and we can verify nothing-changed semantics. +pub fn write_config_json_to( + cfg_path: &Path, + snapshot: &serde_json::Value, +) -> Result<(), ProfileError> { + let json = serde_json::to_string_pretty(snapshot)?; + if let Some(parent) = cfg_path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| ProfileError::Write(parent.display().to_string(), e))?; + } + let tmp = cfg_path.with_extension("json.tmp"); + std::fs::write(&tmp, json) + .map_err(|e| ProfileError::Write(tmp.display().to_string(), e))?; + // Atomic replace via rename — same rationale as ProfilesFile::save_to. + // Do NOT pre-delete the target; rename(2) and MoveFileExW with + // MOVEFILE_REPLACE_EXISTING are atomic on POSIX and Windows + // respectively, and pre-deleting opens a window where neither file + // exists if the rename then fails. + let rename = std::fs::rename(&tmp, cfg_path) + .map_err(|e| ProfileError::Write(cfg_path.display().to_string(), e)); + if rename.is_err() { + // Best-effort cleanup of the tmp file so we don't litter the + // data dir after a failed write. + let _ = std::fs::remove_file(&tmp); + } + rename +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn temp_profiles_path(label: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "mhrv-profiles-{}-{}", + label, + std::process::id() + )); + std::fs::create_dir_all(&dir).unwrap(); + dir.join("profiles.json") + } + + #[test] + fn round_trip_via_tempfile() { + let path = temp_profiles_path("rt"); + let _ = std::fs::remove_file(&path); + + let mut pf = ProfilesFile::default(); + pf.upsert("home", json!({"mode": "apps_script", "script_id": "A"})) + .unwrap(); + pf.upsert( + "work", + json!({"mode": "full", "script_id": "B", "auth_key": "k"}), + ) + .unwrap(); + pf.save_to(&path).unwrap(); + assert_eq!(pf.active, "work"); + + let loaded = ProfilesFile::load_from(&path).unwrap(); + assert_eq!(loaded.profiles.len(), 2); + assert_eq!(loaded.active, "work"); + assert_eq!(loaded.find("home").unwrap().config["script_id"], "A"); + + let _ = std::fs::remove_file(&path); + } + + #[test] + fn upsert_replaces_existing() { + let mut pf = ProfilesFile::default(); + pf.upsert("p", json!({"v": 1})).unwrap(); + pf.upsert("p", json!({"v": 2})).unwrap(); + assert_eq!(pf.profiles.len(), 1); + assert_eq!(pf.find("p").unwrap().config["v"], 2); + } + + #[test] + fn insert_new_refuses_overwrite() { + let mut pf = ProfilesFile::default(); + pf.insert_new("p", json!({})).unwrap(); + let err = pf.insert_new("p", json!({})).unwrap_err(); + assert!(matches!(err, ProfileError::Duplicate(_))); + } + + #[test] + fn rename_moves_active_pointer() { + let mut pf = ProfilesFile::default(); + pf.upsert("a", json!({})).unwrap(); + assert_eq!(pf.active, "a"); + pf.rename("a", "b").unwrap(); + assert_eq!(pf.active, "b"); + assert!(pf.find("a").is_none()); + assert!(pf.find("b").is_some()); + } + + #[test] + fn rename_to_existing_fails() { + let mut pf = ProfilesFile::default(); + pf.upsert("a", json!({})).unwrap(); + pf.upsert("b", json!({})).unwrap(); + let err = pf.rename("a", "b").unwrap_err(); + assert!(matches!(err, ProfileError::Duplicate(_))); + } + + /// Invariant 2: deleting the active profile clears `active`. We do + /// NOT silently jump to "first remaining" because that would + /// imply some unrelated profile matches the live config when it + /// doesn't. + #[test] + fn delete_active_clears_pointer() { + let mut pf = ProfilesFile::default(); + pf.upsert("a", json!({})).unwrap(); + pf.upsert("b", json!({})).unwrap(); + pf.upsert("c", json!({})).unwrap(); + // active is "c" after the last upsert. + pf.delete("c").unwrap(); + assert_eq!(pf.active, ""); + // Other profiles remain accessible — they just don't auto-take + // the active slot. + assert!(pf.find("a").is_some()); + assert!(pf.find("b").is_some()); + } + + #[test] + fn delete_non_active_keeps_pointer() { + let mut pf = ProfilesFile::default(); + pf.upsert("a", json!({})).unwrap(); + pf.upsert("b", json!({})).unwrap(); + // active is "b". + pf.delete("a").unwrap(); + assert_eq!(pf.active, "b"); + } + + #[test] + fn delete_last_clears_pointer() { + let mut pf = ProfilesFile::default(); + pf.upsert("only", json!({})).unwrap(); + pf.delete("only").unwrap(); + assert_eq!(pf.active, ""); + assert!(pf.profiles.is_empty()); + } + + #[test] + fn duplicate_copies_snapshot() { + let mut pf = ProfilesFile::default(); + pf.upsert("a", json!({"x": 1})).unwrap(); + pf.duplicate("a", "a-copy").unwrap(); + assert_eq!(pf.find("a-copy").unwrap().config["x"], 1); + // Active pointer should NOT move on duplicate — it's a non-destructive + // operation that doesn't change which profile matches the live config. + assert_eq!(pf.active, "a"); + } + + #[test] + fn empty_name_rejected() { + let mut pf = ProfilesFile::default(); + assert!(matches!( + pf.upsert(" ", json!({})).unwrap_err(), + ProfileError::EmptyName + )); + assert!(matches!( + pf.insert_new("", json!({})).unwrap_err(), + ProfileError::EmptyName + )); + } + + #[test] + fn missing_file_loads_empty() { + let path = std::env::temp_dir().join("mhrv-profiles-missing.json"); + let _ = std::fs::remove_file(&path); + let pf = ProfilesFile::load_from(&path).unwrap(); + assert!(pf.profiles.is_empty()); + assert!(pf.active.is_empty()); + } + + /// Invariant 4: a present-but-unparseable file is loud, not silent. + /// We must NOT flatten it to an empty State — the next save would + /// then clobber the user's recoverable data. + #[test] + fn corrupt_file_surfaces_error() { + let path = temp_profiles_path("corrupt"); + std::fs::write(&path, "{ not valid json").unwrap(); + let err = ProfilesFile::load_from(&path).unwrap_err(); + assert!( + matches!(err, ProfileError::CorruptOnDisk(_)), + "expected CorruptOnDisk, got {:?}", + err + ); + let _ = std::fs::remove_file(&path); + } + + /// Invariant 4 follow-up: empty file is treated as fresh / no + /// profiles (not corrupt). Whitespace-only too. + #[test] + fn empty_file_loads_empty() { + let path = temp_profiles_path("empty"); + std::fs::write(&path, " \n ").unwrap(); + let pf = ProfilesFile::load_from(&path).unwrap(); + assert!(pf.profiles.is_empty()); + assert!(pf.active.is_empty()); + let _ = std::fs::remove_file(&path); + } + + /// Invariant 1: unknown / future config fields round-trip through + /// the snapshot store without loss. This is the property the + /// Android side must also uphold (verified by raw-write on the + /// apply path). + #[test] + fn forward_compat_unknown_config_fields_roundtrip() { + let mut pf = ProfilesFile::default(); + pf.upsert( + "future", + json!({"mode": "apps_script", "future_field_xyz": [1, 2, 3]}), + ) + .unwrap(); + let path = temp_profiles_path("fwd"); + let _ = std::fs::remove_file(&path); + pf.save_to(&path).unwrap(); + let loaded = ProfilesFile::load_from(&path).unwrap(); + assert_eq!( + loaded.find("future").unwrap().config["future_field_xyz"], + json!([1, 2, 3]) + ); + let _ = std::fs::remove_file(&path); + } + + /// Regression guard for the data-loss-on-rename bug: `save_to` + /// must NOT pre-delete the target. If rename fails (which we + /// can't easily inject) the user still has the previous file. + /// We can only verify the success path here, but we explicitly + /// check that after a save the file contents match what we + /// wrote and that NO `.tmp` is left behind on disk. + #[test] + fn save_to_leaves_no_tmp_behind_on_success() { + let path = temp_profiles_path("notmp"); + let _ = std::fs::remove_file(&path); + let mut pf = ProfilesFile::default(); + pf.upsert("p", json!({"v": 1})).unwrap(); + pf.save_to(&path).unwrap(); + let tmp = path.with_extension("json.tmp"); + assert!(!tmp.exists(), "tmp file should be cleaned up after rename"); + // And the target should have the new bytes. + let loaded = ProfilesFile::load_from(&path).unwrap(); + assert_eq!(loaded.find("p").unwrap().config["v"], 1); + let _ = std::fs::remove_file(&path); + } + + /// Helper: temp dir holding a `profiles.json` + `config.json` pair + /// for path-injecting tests of [`apply_profile_with_paths`]. + fn temp_pair(label: &str) -> (PathBuf, PathBuf) { + let dir = std::env::temp_dir().join(format!( + "mhrv-apply-{}-{}-{}", + label, + std::process::id(), + // monotonic nanos as a tie-breaker between concurrent test threads + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.subsec_nanos()) + .unwrap_or(0), + )); + std::fs::create_dir_all(&dir).unwrap(); + let p = dir.join("profiles.json"); + let c = dir.join("config.json"); + let _ = std::fs::remove_file(&p); + let _ = std::fs::remove_file(&c); + (p, c) + } + + fn cleanup_pair(p: &Path, c: &Path) { + let _ = std::fs::remove_file(p); + let _ = std::fs::remove_file(c); + let _ = std::fs::remove_file(p.with_extension("json.tmp")); + let _ = std::fs::remove_file(c.with_extension("json.tmp")); + // Tear down dir too if empty. + if let Some(dir) = p.parent() { + let _ = std::fs::remove_dir(dir); + } + } + + /// A real-shaped snapshot that the runtime would accept. We pick + /// `direct` mode so we don't need to invent a script_id / auth_key. + fn loadable_snapshot(extra: serde_json::Value) -> serde_json::Value { + let mut obj = serde_json::json!({"mode": "direct"}); + if let Some(map) = extra.as_object() { + let target = obj.as_object_mut().unwrap(); + for (k, v) in map { + target.insert(k.clone(), v.clone()); + } + } + obj + } + + /// Happy path: both writes succeed → Ok and both files reflect + /// the new profile. + #[test] + fn apply_profile_with_paths_ok() { + let (pp, cp) = temp_pair("ok"); + let mut pf = ProfilesFile::default(); + pf.upsert("home", loadable_snapshot(json!({"k": 1}))).unwrap(); + // Reset active so the apply has work to do. + pf.active = String::new(); + pf.save_to(&pp).unwrap(); + + let outcome = apply_profile_with_paths(&pp, &cp, "home").unwrap(); + assert!(matches!(outcome, ApplyOutcome::Ok), "got {:?}", outcome); + // Active moved. + let after = ProfilesFile::load_from(&pp).unwrap(); + assert_eq!(after.active, "home"); + // config.json reflects the snapshot (including the unknown `k` + // field — invariant 1, raw passthrough). + let cfg_bytes = std::fs::read_to_string(&cp).unwrap(); + let cfg_val: serde_json::Value = serde_json::from_str(&cfg_bytes).unwrap(); + assert_eq!(cfg_val["k"], 1); + + cleanup_pair(&pp, &cp); + } + + /// Inject a write failure on `config.json` by making the path + /// a directory before the call. The rename step inside + /// [`write_config_json_to`] then fails because we can't + /// overwrite a directory with a file. + /// + /// Expected: Err(...) is returned, `profiles.json` is unchanged + /// from its pre-call state, NO active pointer move was attempted + /// (because we abort before that step), and the placeholder + /// `config.json/` directory still exists. + #[test] + fn apply_profile_config_write_failure_changes_nothing() { + let (pp, cp) = temp_pair("cfgfail"); + let mut pf = ProfilesFile::default(); + pf.upsert("home", json!({"v": "new"})).unwrap(); + pf.upsert("other", json!({"v": "other"})).unwrap(); + pf.active = "other".to_string(); + pf.save_to(&pp).unwrap(); + let profiles_before = std::fs::read_to_string(&pp).unwrap(); + + // Force config write to fail: create a directory at cp. + std::fs::create_dir_all(&cp).unwrap(); + // Drop a file inside so it can't be cleaned up as an empty + // dir (a defensive measure in case rename had any sneaky + // POSIX behaviour with empty dirs). + std::fs::write(cp.join("sentinel"), "x").unwrap(); + + let result = apply_profile_with_paths(&pp, &cp, "home"); + assert!(result.is_err(), "expected Err, got {:?}", result); + + // profiles.json must be byte-identical to its pre-call state + // — the apply was supposed to abort before touching it. + let profiles_after = std::fs::read_to_string(&pp).unwrap(); + assert_eq!( + profiles_before, profiles_after, + "profiles.json must not change when config.json write fails" + ); + + // Clean up the directory we made. + let _ = std::fs::remove_file(cp.join("sentinel")); + let _ = std::fs::remove_dir(&cp); + cleanup_pair(&pp, &cp); + } + + /// Inject a write failure on `profiles.json` AFTER `config.json` + /// has been written. Expected outcome: `ApplyOutcome::PartialConfigOnly` + /// — config IS the new bytes, but the active pointer didn't + /// update on disk. + /// + /// Injected by making `profiles.json.tmp` a directory before + /// `ProfilesFile::save_to` runs. The write step inside save_to + /// then fails on the tmp file. + #[test] + fn apply_profile_profiles_write_failure_returns_partial() { + let (pp, cp) = temp_pair("ppfail"); + let mut pf = ProfilesFile::default(); + pf.upsert("home", loadable_snapshot(json!({"v": "new"}))) + .unwrap(); + pf.upsert("other", loadable_snapshot(json!({"v": "other"}))) + .unwrap(); + pf.active = "other".to_string(); + pf.save_to(&pp).unwrap(); + + // Block the tmp write by making profiles.json.tmp a directory. + let tmp = pp.with_extension("json.tmp"); + std::fs::create_dir_all(&tmp).unwrap(); + std::fs::write(tmp.join("sentinel"), "x").unwrap(); + + let outcome = apply_profile_with_paths(&pp, &cp, "home").unwrap(); + assert!( + matches!(outcome, ApplyOutcome::PartialConfigOnly(_)), + "expected PartialConfigOnly, got {:?}", + outcome + ); + + // config.json IS the new snapshot. + let cfg_bytes = std::fs::read_to_string(&cp).unwrap(); + let cfg_val: serde_json::Value = serde_json::from_str(&cfg_bytes).unwrap(); + assert_eq!(cfg_val["v"], "new"); + + // profiles.json is UNCHANGED — active is still "other", and + // both profile snapshots are unmodified. The dropdown on disk + // is stale (still claims "other" is active), but the partial + // outcome surfaces that honestly to the caller. + let pf_after = ProfilesFile::load_from(&pp).unwrap(); + assert_eq!(pf_after.active, "other"); + assert_eq!(pf_after.find("home").unwrap().config["v"], "new"); + assert_eq!(pf_after.find("other").unwrap().config["v"], "other"); + + // Clean up. + let _ = std::fs::remove_file(tmp.join("sentinel")); + let _ = std::fs::remove_dir(&tmp); + cleanup_pair(&pp, &cp); + } + + /// Schema validation parity with Android: a profile whose + /// `config` is `null` (or any non-object) must surface as + /// CorruptOnDisk at load time. Without this, applying that + /// profile would write the literal bytes `null` to + /// `config.json`, clobbering the user's live config. + #[test] + fn null_config_surfaces_as_corrupt() { + let path = temp_profiles_path("null-cfg"); + std::fs::write( + &path, + r#"{"active":"","profiles":[{"name":"bad","config":null}]}"#, + ) + .unwrap(); + let err = ProfilesFile::load_from(&path).unwrap_err(); + assert!( + matches!(err, ProfileError::CorruptOnDisk(_)), + "expected CorruptOnDisk for null config, got {:?}", + err + ); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn array_config_surfaces_as_corrupt() { + let path = temp_profiles_path("arr-cfg"); + std::fs::write( + &path, + r#"{"active":"","profiles":[{"name":"bad","config":[1,2,3]}]}"#, + ) + .unwrap(); + let err = ProfilesFile::load_from(&path).unwrap_err(); + assert!(matches!(err, ProfileError::CorruptOnDisk(_))); + let _ = std::fs::remove_file(&path); + } + + /// Duplicate names are an invariant violation: every by-name + /// operation (apply / rename / delete) becomes ambiguous, and + /// Rust's "remove first" delete diverges from Android's "remove + /// all" delete on the same file. Reject loudly on load. + #[test] + fn duplicate_names_surface_as_corrupt() { + let path = temp_profiles_path("dup-names"); + std::fs::write( + &path, + r#"{ + "active": "p", + "profiles": [ + {"name": "p", "config": {"mode": "apps_script"}}, + {"name": "p", "config": {"mode": "full"}} + ] + }"#, + ) + .unwrap(); + let err = ProfilesFile::load_from(&path).unwrap_err(); + match err { + ProfileError::CorruptOnDisk(msg) => { + assert!( + msg.contains("duplicate"), + "error should call out the duplicate explicitly: {}", + msg + ); + } + other => panic!("expected CorruptOnDisk, got {:?}", other), + } + let _ = std::fs::remove_file(&path); + } + + #[test] + fn empty_name_surfaces_as_corrupt() { + let path = temp_profiles_path("empty-name"); + std::fs::write( + &path, + r#"{"active":"","profiles":[{"name":" ","config":{"mode":"apps_script"}}]}"#, + ) + .unwrap(); + let err = ProfilesFile::load_from(&path).unwrap_err(); + assert!(matches!(err, ProfileError::CorruptOnDisk(_))); + let _ = std::fs::remove_file(&path); + } + + /// In-memory `upsert` must also reject non-object configs so a + /// caller can't smuggle a bad shape past the load-time check. + #[test] + fn upsert_rejects_non_object_config() { + let mut pf = ProfilesFile::default(); + let err = pf.upsert("p", json!(null)).unwrap_err(); + assert!(matches!(err, ProfileError::CorruptOnDisk(_))); + let err = pf.upsert("p", json!([1, 2, 3])).unwrap_err(); + assert!(matches!(err, ProfileError::CorruptOnDisk(_))); + let err = pf.upsert("p", json!("just a string")).unwrap_err(); + assert!(matches!(err, ProfileError::CorruptOnDisk(_))); + // And nothing should have been added to the store. + assert!(pf.profiles.is_empty()); + } + + /// Belt-and-braces: even if a non-object snapshot somehow makes + /// it past load (e.g. a hand-edited in-memory ProfilesFile), + /// `apply_profile_with_paths` re-validates and refuses to write + /// it to config.json. + #[test] + fn apply_profile_refuses_non_object_snapshot() { + let (pp, cp) = temp_pair("bad-snapshot"); + // Bypass the public API to plant a bad snapshot directly. + let bad = ProfilesFile { + active: "bad".to_string(), + profiles: vec![Profile { + name: "bad".to_string(), + config: serde_json::Value::Null, + }], + }; + // We have to write this with raw JSON because save_to → load + // round-trip would reject it. Hand-craft instead. + std::fs::write( + &pp, + serde_json::to_string(&serde_json::json!({ + "active": "bad", + "profiles": [{"name": "bad", "config": serde_json::Value::Null}], + })) + .unwrap(), + ) + .unwrap(); + let _ = bad; // suppress unused warning + + // Plant a known config.json so we can assert it's unchanged. + std::fs::write(&cp, r#"{"mode":"apps_script","auth_key":"orig"}"#).unwrap(); + let before = std::fs::read_to_string(&cp).unwrap(); + + let result = apply_profile_with_paths(&pp, &cp, "bad"); + assert!( + matches!(result, Err(ProfileError::CorruptOnDisk(_))), + "expected CorruptOnDisk, got {:?}", + result + ); + // config.json must be byte-identical — we refused before writing. + assert_eq!(before, std::fs::read_to_string(&cp).unwrap()); + cleanup_pair(&pp, &cp); + } + + /// A snapshot that's an empty JSON object passes the structural + /// "is object" check but would fail `Config::validate` for + /// apps_script/full mode (no script_id, no auth_key). Apply must + /// refuse to clobber config.json with bytes the runtime would + /// reject — otherwise the user ends up with neither their old + /// working config nor a usable one. + #[test] + fn apply_profile_refuses_empty_object_snapshot() { + let (pp, cp) = temp_pair("empty-obj"); + std::fs::write( + &pp, + r#"{"active":"empty","profiles":[{"name":"empty","config":{}}]}"#, + ) + .unwrap(); + std::fs::write(&cp, r#"{"mode":"apps_script","auth_key":"orig","script_id":"X"}"#).unwrap(); + let before = std::fs::read_to_string(&cp).unwrap(); + + let result = apply_profile_with_paths(&pp, &cp, "empty"); + assert!( + matches!(result, Err(ProfileError::CorruptOnDisk(_))), + "expected CorruptOnDisk, got {:?}", + result + ); + assert_eq!( + before, + std::fs::read_to_string(&cp).unwrap(), + "live config must be unchanged when snapshot fails runtime validation" + ); + cleanup_pair(&pp, &cp); + } + + /// Snapshot with `auth_key` but no `script_id` / `script_ids` and + /// mode = `apps_script` should also be rejected — the runtime + /// Config validator demands at least one deployment ID for + /// relay-bearing modes. + #[test] + fn apply_profile_refuses_missing_script_id_snapshot() { + let (pp, cp) = temp_pair("no-script-id"); + let snapshot = serde_json::json!({ + "mode": "apps_script", + "auth_key": "MY_REAL_SECRET", + // Note: no script_id / script_ids — invalid for apps_script. + }); + let pf = serde_json::json!({ + "active": "bad", + "profiles": [{"name": "bad", "config": snapshot}], + }); + std::fs::write(&pp, serde_json::to_string(&pf).unwrap()).unwrap(); + std::fs::write(&cp, r#"{"mode":"apps_script","auth_key":"orig","script_id":"X"}"#).unwrap(); + let before = std::fs::read_to_string(&cp).unwrap(); + + let result = apply_profile_with_paths(&pp, &cp, "bad"); + assert!( + matches!(result, Err(ProfileError::CorruptOnDisk(_))), + "expected CorruptOnDisk, got {:?}", + result + ); + assert_eq!(before, std::fs::read_to_string(&cp).unwrap()); + cleanup_pair(&pp, &cp); + } + + /// A `direct` mode snapshot doesn't need script_id or auth_key + /// (the runtime tolerates both being absent there). It must pass. + #[test] + fn apply_profile_accepts_minimal_direct_snapshot() { + let (pp, cp) = temp_pair("min-direct"); + let pf = serde_json::json!({ + "active": "", + "profiles": [{"name": "direct", "config": {"mode": "direct"}}], + }); + std::fs::write(&pp, serde_json::to_string(&pf).unwrap()).unwrap(); + let result = apply_profile_with_paths(&pp, &cp, "direct"); + assert!( + matches!(result, Ok(ApplyOutcome::Ok)), + "minimal direct snapshot must apply cleanly, got {:?}", + result + ); + cleanup_pair(&pp, &cp); + } + + /// write_config_json_to's tmp file is cleaned up on rename + /// failure — no leftover .tmp files after a failed apply. + #[test] + fn write_config_json_cleans_up_tmp_on_failure() { + let (_pp, cp) = temp_pair("cleanup"); + std::fs::create_dir_all(&cp).unwrap(); + let _ = write_config_json_to(&cp, &json!({"v": 1})); + let tmp = cp.with_extension("json.tmp"); + assert!( + !tmp.exists(), + "tmp file should be cleaned up after rename failure" + ); + let _ = std::fs::remove_dir(&cp); + } +}