Found on a live Pixel 8 Pro: system_server was writing VpnHide-NC/NI lines to
logcat — the ones that name the exact UIDs we hook — at ~3/s, five days after
Debug logging had been turned off. The canonical config on disk said
debug:false and had said so since the day after that boot.
HookLog.enabled was assigned in exactly one place, HookLog.reload(), reachable
only from install() at boot and from the FileObserver callback. That observer
had stopped delivering: an atomic rename over the config produced no callback at
all, confirmed on the device. So the flag was frozen at whatever the config said
at boot, and no amount of toggling in the app could move it — only a reboot.
The irony is that SystemServerConfigCache sits right beside it re-reading the
same file every second by fingerprint, and was current the whole time. The
hooks' targeting was right; only the logging flag was stale. So give the flag to
the component that already polls: the cache sets HookLog.enabled whenever it
installs a fresh config. The watcher stays, as latency rather than correctness —
it makes a flip land immediately instead of within a second — and the comments
now say so, because the next person to add state here will be tempted to hang it
off the same callback.
Not a leak: nothing an app can read. It is a toggle that silently did nothing,
plus a stream of Binder-hot-path log calls the user asked us to stop making.
The module now compiles clean, so make that the enforced state rather than a
snapshot. `-PvpnhideWarningsAsErrors=true` on the CI Gradle invocations, matching
how -PvpnhideEmulatorX86 is already wired; off by default, because a warning
mid-edit is information, not a reason to stop a local build.
The usual objection to allWarningsAsErrors is that a toolchain bump turns
unrelated new warnings into a red CI on someone else's PR. That does not apply
here: every input that decides what warns is pinned — Gradle by the wrapper,
AGP/Kotlin/Compose in libs.versions.toml, the NDK by ndkVersion, and the JDK by
the CI container image. New warnings can only arrive with a deliberate version
bump, on the PR that does the bumping, where they belong.
Verified both directions: reintroducing the nullable-receiver warning fixed in
the previous commit passes without the flag and fails with it.
Six is enough noise that a seventh — a real one — scrolls past unread. Only one
of them was a latent defect; the rest are recorded here so nobody re-litigates
them later.
The real one: BundleSchemaGoldenTest called File.parentFile.mkdirs() on a
receiver Kotlin now types as nullable. It cannot actually be null for the
relative path used there, but the warning is on its way to becoming an error,
and a golden-refresh path that NPEs would be found at the worst moment.
The other five were deliberate: four `Unit` literals standing in for empty
branches in Compose `when` slots, and one Json format rebuilt inside a test
body. The `Unit`s said "nothing happens here", which is what an empty branch
already says, so the branches are now empty; the format is built once per class.
No behaviour change. Not adding allWarningsAsErrors along with this: it would
lock in a clean build today at the cost of breaking CI on the next Kotlin or AGP
bump, which is the maintainer's call rather than a side effect of a cleanup.
loadDashboardState was 623 lines, and the ~25 guards that decide which banners
a device gets were ~290 of them, each ending in a res.getString. That shape is
what kept them untested: every branch needed a real Resources, this module has
no Robolectric, and so the single most user-visible piece of logic in the app
had zero coverage while the pure detectors feeding it had plenty.
Split along the seam the codebase already uses elsewhere — classifyKmodProblem
/renderKmodProblem, classifyKpmProblem/renderKpmProblem. dashboardIssues() takes
a DashboardFacts and returns List<DashboardIssue>, no Android types anywhere;
DashboardIssue.toMessage() words one. A case carries the data its wording needs
rather than a preformatted string, so the split is real and not cosmetic.
filesystemHidingDashboardMessage was the odd one out doing both halves itself;
it folds in, and its file goes.
Emission order is load-bearing — the screen groups by severity but keeps
emission order within a group, so this decides which error a user reads first.
It used to be implied by the physical layout of one long block, where inserting
a guard in the wrong place silently reordered banners. It is now eight named
calls in one place, pinned by a test.
The four backends' diagnosis blocks were near-identical eighteen-line copies
differing only in kind, activator path and classifier; they collapse into
deriveModuleFact. Derivation splits into deriveModuleFacts / deriveLsposedFacts
/ deriveEnvironmentFacts / resolveProtectionFacts, leaving an orchestrator that
reads as the four steps it always was. Both detekt suppressions come off — the
gate now passes on merit rather than by exemption.
Behaviour is unchanged, checked emission by emission against the original: all
34 sites map one-to-one, in the same order, with the same severities. Two things
that only looked like changes: the non-kmod module problems dropped their
downloadArtifact at the callsite, which was a no-op because only renderKmodProblem
ever sets one, and Active.version being null already meant no mismatch banner
because versionsMismatch returns false for it. One dead local (zygiskStatusRaw)
went with the rewrite.
Also fixes a mark the profiler has been asking for and never received:
measure-startup.py reads dashboard_issues_done, the code only ever emitted
dashboard_messages_done, so the "issues" stage has always measured nothing.
The word is attached to the check suite, that suite's run state, the canonical
report model, the precondition gate, hook attach telemetry, the export, and two
bundle section names. None of it is misfiled — they really are all diagnostics —
but the name alone no longer tells you which layer you are in, and the pair that
actually bites is DiagnosticsCache (a run) versus RoutingGateCache (a
precondition, and the only one of the two derived from the canonical config).
Also records the derivedCaches membership rule next to the StateCache entry, so
someone adding a cache reads it where they are already looking.
Three unrelated things were wearing the same annotation, so the annotation had
stopped carrying information.
JavaChecks used six per-function DEPRECATION suppressions for one reason: the
deprecated ConnectivityManager surface is the point of that file. allNetworks,
getNetworkInfo(type), the network-handle calls — those are what a VPN-probing
app reaches for, so the checks proving we hid the tunnel must reach for the same
ones, and "modernising" them would quietly drop detection coverage. Stated once
at file level with a pointer to docs/detection-vectors.md. The file holds no
non-probe code for the blanket to hide a real warning in; it also carried 34
imports left behind when the UI moved out, now gone.
Motion.kt repeated one identical UNCHECKED_CAST six times to re-type specs
cached as Any. One private helper, one suppression, and the soundness argument
(these are tweens and thresholdless springs — they never touch a value of T)
written down, along with what must not be routed through it.
The four casts in the hook process are NOT collapsible the same way, which is
worth recording: `as?` against a concrete generic type still checks the raw
class at runtime, so a ROM that reshaped a field falls out as null. Behind a
helper taking an unbounded T the cast erases and that check silently disappears,
turning a clean bail-out into a ClassCastException somewhere later. They keep
their own suppressions and now state which AOSP declaration each one trusts.
The four config-derived caches were refreshed by four open-coded calls whose
two invariants — root snapshot first, everyone else with force=false — lived
only in a comment. Getting the flag wrong is not a crash, it is a silent extra
root shell per cache, so nothing would ever surface the mistake.
They are a list now, iterated after the root refresh, with the membership rule
written down: a cache belongs here iff its load reads the canonical config. The
caches that deliberately do not qualify are named too, since "did someone forget
to add this one?" was previously answerable only by reading all nine of them.
Skip caches that have never loaded. refreshInPlace bypasses the concrete cache's
ensureLoaded, so calling it on a pristine cache runs load() without the inputs
that method stashes; the load fails, the cache records the error, and ensure()
then early-returns on that error forever. A cache with no value has nothing that
can go stale, so there was never a reason to touch it. Replaces the runCatching
that was papering over exactly this for RoutingGateCache.
The branch added /proc/vpnhide_diag and a kmod_diag bundle section but
documented neither, and this repo's rule is that every path it creates is
written down with its owner and lifetime.
Also logs the truncation fix that came with the reordering: bundles from
bloatware-heavy ROMs were losing exactly the network and routing sections
a connectivity report needs, because the per-user package scan ahead of
them ate the su timeout.
Build-time -DVPNHIDE_BARE_INIT makes vpnhide_init return immediately (no hooks,
no proc nodes, no symbol resolution) so a loaded-but-does-nothing .ko can be
tested in the field to separate 'loading the module at all' from anything the
module's init does. Default builds unaffected.
probe_mask (module_param, baked in via -DVPNHIDE_PROBE_MASK_DEFAULT) gates which
hooks register at load — bits 0-9 the kretprobes, 10 socket-bind, 11 filesystem
(ANDed with filesystem_hiding). Default 0xfff = current behaviour; a diagnostic
variant bakes in a reduced set with no source edit. A fully-masked build stays
loaded-but-inert (init no longer aborts when nothing was attempted). Surfaced in
/proc/vpnhide_diag.
Also reorder the debug snapshot so network_*/proc_net_* emit before the heavy
per-user pm scans (which overran the su timeout on bloatware-heavy HyperOS and
truncated exactly the route sections); timeout 60s->120s as backstop.
Read-only proc node, separate from the frozen control/telemetry wire, for
root-causing field reports where the module perturbs networking. Surfaces
kernel-internal state that /proc/vpnhide_ctl does not: per-probe registration
plus kretprobe/kprobe nmissed counters (to see if a device exhausts
VPNHIDE_KRETPROBE_MAXACTIVE), active vs installed hook masks, and the LIVE
is_vpn_ifname() verdict against every netdev in the reader's netns. Reports the
current verdict as-is, bugs included, so a false-positive interface match is
observable. The debug-bundle collector cats it into a new kmod_diag section; no
UI surface.
The rest of the cheap moves out of the flat package: picker/ (the Hiding
tab), diagnostics/ (the check suite and its report), settings/ and debug/.
Each holds a feature's screen, its cache and its pure model together, so
the tree says what the module is made of instead of listing 96 files.
42 files stay at the root on purpose — the vocabulary both processes and
several features share (StorageConfig, ShellUtils, RootSnapshotCache,
HookRegistry, DashboardData, the agent bridge). Moving those would be
import churn with nothing gained; dashboard/ and agent/ are left alone for
the same reason.
No logic touched: package lines, imports, and a layout table in AGENTS.md.
Two of the cheap leaves from the flat 96-file package: nothing else in the
module reaches into them beyond a handful of entry points, so the move is
mechanical and the tree gains two names that say what they hold.
- startup/: MainActivity, StartupCoordinator, StartupTrace and the
blocking screen. The manifest and scripts/measure-startup.py follow the
activity to `.startup.MainActivity` — verified against the built APK,
which carries dev/okhsunrog/vpnhide/startup/MainActivity
- statistics/: the screen, its cache and the pure counter model
No logic touched; the diff is package lines and imports.
~700 lines of shell lived inside `"""…"""` blocks with every `$` written
as `${'$'}`. That cost a release: an apostrophe in a comment inside a
single-quoted block ended the quoting, the batched command stopped
parsing, and every forensic section vanished from bug reports until
someone read a bundle closely (#306). Nothing could have caught it —
the only check was a `contains("emit_file …")` assertion, and shellcheck
cannot read Kotlin.
The scripts are now files under `app/src/main/resources/shell/`, verified
to be packaged into the APK, and CI shellchecks them beside the module
scripts. Parameters (paths, framing prefixes, the two feature flags)
arrive as a generated assignment prelude, so the values stay defined once
in Kotlin and the script stays something you can lint and run.
Equivalence was checked by executing the old and new commands and
comparing the section sequences they emit: 48 identical sections for the
root probe, 60 for the debug probe, same order.
- root_snapshot.sh, debug_snapshot.sh, hook_counters.sh, and the shared
package_inventory.sh (now a function the callers invoke)
- shellcheck found what it was brought in for: a dozen unquoted
expansions and a pattern-matching `${FRAC%${FRAC#???}}`. The `eval`-body
quoting rule is suppressed per file with the reason
- tests assert against the prelude and the script's use of a variable
rather than an interpolated literal
The bind hook decided whether to act by comparing the release string:
below 5.7 it stayed inert, on the assumption that the kernel refuses an
unprivileged bind before it even parses the name, so a name-specific
ENODEV from us would announce the interface instead of hiding it. The
oracle argument is sound; the version test is not the way to ask it. A
LineageOS 5.4 build (the KPM report in the previous commit) lets an app
bind to tun0, and there the same gate means we simply do not hide.
Replaced with the question that actually decides it: what does this
kernel return for a bind to a name that cannot exist? Denying a hidden
interface with exactly that errno is oracle-free by construction —
EPERM where every bind is refused (indistinguishable, as today), ENODEV
where names resolve first (hidden, as on 5.7+).
- one socket + one setsockopt through the real libc entry, cached for the
process; an unusable measurement falls back to the old heuristic, so
behaviour is never worse than before
- hidden_bind_errno is pure and unit-tested over the decision table, and
the hook test now covers the EPERM-mirroring path end to end
- bind-probe gains a bind_absent_name case, so the QEMU lanes record what
each supported kernel family answers instead of us assuming it
The index-helper hook runs before that helper's own CAP_NET_RAW check, so
it answered ENODEV even for callers the kernel was about to refuse with
EPERM. On a tree where the check bites, a VPN name then reads differently
from every other name — the exact oracle the Zygisk hook refuses to
create — and widening the hook to all kernels below 5.9 in the previous
commit widened that to the 4.x families where the check really does bite.
Ask capable(CAP_NET_RAW) first and stay out of the way when it fails: the
kernel refuses those callers itself, identically for every interface.
Deny only the ones that would otherwise have bound, which is the case the
LineageOS 5.4 report is about. capable() needs no struct offsets, so it
cannot go stale on a vendor kernel the way an offset table can; a kernel
where it will not resolve keeps the previous unconditional denial.
A user on 4PDA configured VPN Hide by putting roles on his VPN app and
then asked for a manual with real examples. The roles list explains what
Java/Native/Apps/Ports each do, but never says whose row to set them on —
and the answer (the detector app, not the VPN) is the one thing you
cannot guess from the labels.
- a "which app do I configure" block, first in the Hiding tab's help
sheet, where the confusion actually happens
- a worked-examples table in all three READMEs: bank that must not see
the VPN, bank that also scans the app list, an app that objects to
Zygisk, an app probing a localhost proxy port — plus a concrete
bank + WireGuard example
`writeSuperkeySetting` and `writeFilesystemHidingSetting` each change one
field in `settings`, but took their base from
`buildCanonicalConfigFromTargetsSnapshot` — rebuilding every app's roles
from the snapshot's per-role sets and writing that back. One of those
sets round-tripped through UIDs: `appHiding` was stored as resolved UIDs
and mapped back through `pm list packages`, so a target the inventory
could not see (a profile the scan failed to read — the case #293 added
diagnostics for) disappeared from the projection and lost its role on
disk. A toggle unrelated to the app list silently unconfigured an app.
- both writers take `snapshot.canonicalConfig` and copy the one field
- TargetsSnapshot no longer stores the five per-role sets beside the
config they came from. They are projections of it now, so the object
cannot carry two versions of one truth, and `observerNames` no longer
passes through UIDs at all. `observerUids` stays for the consumers that
need the wire's language, derived on demand
- regression test: an app-hiding target absent from the inventory keeps
its role
Not reproduced on a device — the chain is read off the code, and the
missing-from-inventory precondition is one users have hit.
The SIOCGIF* pre-screen dereferenced the caller's `arg` before the real
ioctl had a chance to validate it. A target app passing a bad or short
pointer — the case the kernel answers with EFAULT — instead took a
SIGSEGV inside its own process, caused by our hook. The setsockopt hook
was written to avoid exactly this (copy_from_self, with the reasoning in
its doc comment); the ioctl path was the inconsistent one.
Reads the 16-byte name through copy_from_self and passes a non-socket fd
straight through, since this ioctl family cannot apply to one. Fault
containment itself is already covered by
self_copy_contains_bad_caller_pointers.
Found by a review pass over the project's unsafe code.
Two devices report the app dying at startup whenever a VPN interface is
up, with nothing in the app's own logs. The probe crate built with
`panic = "abort"`, and the full check suite runs on every cold start, so
any panic in a probe — parsing whatever an arbitrary vendor kernel hands
back — took the process down with SIGABRT and no Java trace. The JNI
entry already wrapped the run in `catch_unwind` (via jni's `with_env` +
ThrowRuntimeExAndDefault); abort made that dead code.
- release and dev profiles unwind. Costs ~39 KB of unwind tables on
arm64, the only ABI we ship
- a panic hook logs the message and its file:line to logcat under
VpnHide-Native, which the bundle's filter now captures. The default
hook writes to stderr, which for an app process goes nowhere — this is
why such a crash leaves no trace. Backtraces are deliberately not
attempted: fat LTO + strip would make them unsymbolised addresses
- the Kotlin caller swallows a thrown probe run, so the worst case is one
empty check run instead of a dead app
- pass the SIOCGIFFLAGS/SIOCGIFMTU ifreq by unique reference. The kernel
writes the result through that pointer, so deriving it from a shared
borrow is UB — and it is the tun0-visible branch that reads the value
back, exactly the state these reports are about
@sliva_ru reported `setsockopt SO_BINDTODEVICE tun0` leaking on a
self-built LineageOS sm8350 (5.4.302-qgki) while the KPM was fully
healthy — every hook installed, error 0x0. The vector was never covered
there: the resolved-ifindex hook was wired only for kernels in [5.7,
5.9), and below that we deliberately relied on the kernel refusing an
unprivileged bind without CAP_NET_RAW.
That assumption came from upstream sources and the QEMU reference images,
and it does not survive contact with vendor trees. His kernel has the
gate compiled in — `sock_bindtoindex_locked` calls `ns_capable`, verified
by disassembling the vmlinux rebuilt from his boot image — and an
untrusted app still bound a socket to tun0. Whatever satisfies the check
on that ROM, a kernel-side policy we do not control cannot back a
coverage claim.
- probe both helper names by symbol on any kernel below 5.9: upstream
renamed sock_setbindtodevice_locked to sock_bindtoindex_locked in 5.8,
and 5.4 vendor trees backport the newer one (his does)
- when neither resolves, leave the hook bit clear rather than reporting a
vector we do not cover
- the partial-hooks warning now fires only when a missing hook costs a
measured vector, so kernels that never had the symbol — and close the
surface by capability or SELinux anyway — stay quiet
- correct the invariant in detection-vectors.md and at the guard in
socket_bind_before_common, with the counterexample, so it does not get
reintroduced. The zygisk hook still carries the same version gate; its
oracle argument holds only while every bind is refused, noted in the
doc as wanting a runtime probe.
`kpm list supercall failed with rc=-1` was the whole diagnosis in a bug
report — the same message whether the saved SuperKey was rejected, or the
activator only ever had KernelPatch's trusted-`su` grant (enough for the
hello ping it authenticates with, not necessarily for module management).
Telling those apart took reading the activator source alongside a second
capture of superkey_saved.
The failure detail now carries `(auth: saved superkey)` / `(auth: trusted
su)`, so the next bundle answers it by itself.
A report came in with the KPM active and green on the dashboard, and a red
`ioctl SIOCGIFCONF` leak in Diagnostics — no way for the user (or for me)
to connect the two. The backend was reporting PARTIAL_HOOKS with three
kernel hooks missing, and nothing rendered that: hook ownership was
derived from the backend family alone, so a vector whose hook never
installed still counted as covered and read as an ordinary leak.
- missingBackendHooks(): the gap between the family set and the mask the
kernel backend actually reported. Empty for an unread status (that is
"unknown", not "nothing installed") and for Zygisk, whose mask is
per-process
- dashboard warning naming the count and the unresolved symbols, so the
green card is no longer the whole story. Not an error: what installed
still works, and no reinstall fixes a renamed kernel symbol
- the leaking check now carries its missing hooks and says so when
expanded, turning "leak" into "your kernel does not expose sock_ioctl"
- classifyKpmProblem gains NeedsSuperkey: APatch answered but refused the
module control call while no SuperKey is saved. Previously this fell to
the generic branch, which told the user to collect a log or reinstall
the zip; the actual fix is one field in Settings → Security
A bug report from a MediaTek 4.14 device (POCO M3 Pro 5G, HyperOS) came
back with the KPM loaded but reporting PARTIAL_HOOKS and mask 0x20003bc —
sock_ioctl, fib_route_seq_show and ipv6_route_seq_show missing, so
ioctl(SIOCGIFCONF) still enumerated tun0 for targeted apps.
kallsyms on that build carries exactly those three as
`name$<hex>` (Clang CFI + full LTO promoting a local symbol), while every
target that did install is either global or `name.llvm.<decimal>`. The
suffix matcher only accepted `.isra.N` / `.constprop.N` / `.llvm.N`, so
lookup_fn returned 0 and install_hook silently skipped them.
- accept `$<hex>` as a clone suffix, requiring the hex run to end the
string so the neighbouring `name$<hex>.cfi_jt` jump-table alias is
rejected — hooking a trampoline would patch the wrong instructions
- move the matcher to shared/vpnhide_logic.h where it is freestanding and
host-testable, with the observed forms (including the .cfi_jt alias)
pinned in test_vpnhide_logic.c
- skip module-owned symbols in the kallsyms walk; hook targets are
vmlinux functions and a same-named module symbol is a different one
The kallsyms correlation is 3/3 missing and 0/9 installed, but this is
not yet confirmed on the device — the reporter is sending a kernel-image
export next, which will show whether the hook then installs.
96 files sat flat in one package, which hid the only boundary in this
module with a real failure mode: `hook/` is loaded by LSPosed into
system_server, everything else runs in the app process. Nothing enforced
it — `internal` is module-wide, so a hook file referencing a Compose
screen compiles fine and fails as a system_server crash on a device.
The seam was already clean (no Compose in any Xposed-importing file), so
this is a pure move: 7 files, no logic touched, 12 imports added.
- hook/: HookEntry, PackageVisibilityHooks, SystemServerConfigCache,
HookLog, HookReflectionProbe, ConnectivityAttachDiagnostics and the
shared /data/system watcher the three of them use
- shared vocabulary stays at the root package (HookRegistry, LsposedStats,
canonical-config parsing, LogTags) — it is used by ~15 app-side files
and belongs to neither process
- assets/xposed_init and the R8 keep rule now name the new class;
verified against the built APK, which carries
dev/okhsunrog/vpnhide/hook/HookEntry after minification
- HookPackageBoundaryTest enforces both directions: no UI in hook/, no
Xposed imports outside it, and xposed_init matching the real package
`state.json` carried two schema numbers (one on the state, one on the
report), both pinned at 1 since the format existed, and a KDoc saying
compatibility was not a concern. A number that never moves tells a
triager nothing about the file in front of them, and nothing stopped the
shape from drifting between releases.
One version for the whole bundle, and a golden file so it cannot go
stale: the serialized shape is pinned in a test, which fails on any
change and points at the refresh command and the bump rule.
- give LsposedState and ProtectionCheck explicit @SerialName values.
Without them kotlinx emits the fully-qualified class name as the `kind`
discriminator, so those two blocks read
"dev.okhsunrog.vpnhide.LsposedState.Active" while every other sealed
type reads "active" — and the value would silently change if the class
ever moved package
- drop DiagnosticReport.schema; the top-level one is the bundle's version
- bump it to 2 and document the history + bump rules in
docs/debug-bundle.md §2.1, with the current schema in the triage list
- BundleSchemaGoldenTest pins the encoded shape (including the dashboard
block, the only path those two sealed types reach) and asserts no
fully-qualified name reaches the wire
Importing is not what everyone wants from the leftovers: someone who has
already set the app up again by hand just wants them off /data/adb, and
nothing else on the device removes them — not a module uninstall, not an
app reinstall.
The dialog now offers merge / replace / delete-without-importing. Delete
runs the same rm as the two import paths, but on its own: no canonical
write and no activator run, since the config the backends use does not
change. Caches are reloaded afterwards so the banner and the Settings
entry disappear on the spot.
The three choices moved into the dialog body as full-width rows — three
labels in the button row overflow on narrow screens — and the Settings
entry is now "Pre-1.0 configuration" rather than "Import …", since
importing is no longer the only thing it does.
Up to 0.7.1 the hiding config lived in per-component text files. 1.0.0
folded them into the canonical JSON on first launch and 1.2.0 removed
that fold, so a device upgrading straight from 0.7.x to 1.2.x kept the
files on disk, read none of them, and looked like the update had wiped
its settings.
The importer is back, and because 1.2.0 also dropped the cleanup that
deleted those files, the data is still recoverable on affected devices.
- read the pre-1.0 lists in the root snapshot and resolve them to roles,
mapping observer UIDs back through the current package inventory
- fold them in silently at startup when the canonical config holds no
user-configured app (auto-hidden VPN apps don't count), same as 1.0.0
- otherwise offer a dashboard banner and a Settings entry with merge
(union of roles, keeps existing per-hook selections) or replace
(legacy list wins; settings, self and auto-hide marks are kept)
- delete the legacy files in the same root transaction as the config
write, so "files gone" is the imported marker; declining only sets a
DataStore flag that silences the banner
- clean the same paths on Full Reset, and carry them in the debug bundle
`line[..line.len().min(80)]` panics when byte 80 lands inside a
multi-byte character, and the crate builds with `panic = "abort"`, so the
app would die running its own diagnostics. Interface names are arbitrary
bytes, so a line that matches is_vpn_iface can legitimately carry
non-ASCII UTF-8; the truncation branch itself is routine, since
/proc/net/route lines run past 80 bytes.
Backs the cut off to the nearest boundary instead of cutting at a fixed
character count, so the 80-byte budget on the reported detail is kept.
Spotted by DanSqw (github.com/DanSqw/vpnhide).
An apostrophe in a comment inside the single-quoted app_scan_diagnostics
block closed the quoting early: `#` starts no comment inside `'...'`, so
the `for U in $IDS; do ... done` loop was split across the quote boundary
and the whole batched command failed to parse. Every forensic section was
lost with it — the bundle only carried "root debug snapshot command
failed". Shipped in 1.2.5 via the package-list streaming change.
Rewrites the comment without apostrophes (and says why), plus a test that
runs the generated probes through `sh -n`. A contains("emit_file …")
assertion cannot see this class of break; a real parse can.
Explains the vpnhide_debug_*.zip / state.json format for humans and agents
triaging a bug report: capture kinds and zip layout, every top-level field, the
report/gate/outcome/verdict semantics, all raw sections grouped by purpose, the
rootShell not-verified-vs-inactive caveat, truncation/redaction, kernel-image
bundle, triage playbooks, and a code map of where each part is produced.
The ARG_MAX load fix (#302) and the fail-soft picker change (#304) both
addressed the same user-visible problem — the app list not loading / demanding
a profile unlock on MIUI/HyperOS — and read as two overlapping entries. Fold
them into a single Fixed entry describing the combined outcome.