Skip to content

nomount: Add a built-in NoMount backend - #43

Merged
matsuzaka-yuki merged 24 commits into
masterfrom
feat/nomount-builtin
Oct 15, 2026
Merged

matsuzaka-yuki merged 24 commits into
masterfrom
feat/nomount-builtin

Conversation

@matsuzaka-yuki

Copy link
Copy Markdown
Collaborator

Adds NoMount as a third injection backend next to the built-in Folk Mount engine
and the metamodule path: instead of mounting anything it redirects path
resolution and directory iteration in a kernel module, so nothing shows up in
/proc/mounts.

What is in here

  • userspace/nomount/ vendors upstream
    maxsteeel/nomount at 7046c7a
    (v2.1.1). The kernel sources stay byte-identical (hashes in UPSTREAM.md) and
    build-lkm.sh compiles them from a temporary copy inside the DDK image, so
    re-syncing stays a mechanical diff. GPL-2.0-only, same as kernel/.
  • The existing DDK matrix builds <kmi>_nomount.ko next to kernelsu.ko and
    ksud embeds them as assets. There is no standalone module zip and nothing is
    flashed.
  • ksud speaks the driver's keyring protocol itself (add_key with packed
    4096-byte payloads) and regenerates the rules instead of shelling out to
    upstream's metamount.sh / rule-paths.sh: probe (absent / outdated / ready,
    API >= 21), load from the embedded asset, apply, read back to verify,
    per-module hot load and unload, UID visibility, isolated-process handling.
  • mount_mode gains nomount; auto, builtin, metamodule and off behave
    as before. A driver that cannot be used falls back to the built-in engine and
    records why (no_asset_for_kmi, outdated, not_loaded, unknown_kmi).
  • Manager: a NoMount page (driver, injection, per-module rules, visibility), an
    app exclusion picker, a settings entry, and a confirmation dialog when
    switching to NoMount, because that switch changes who can see the injected
    files.

Review notes

userspace/nomount/kernel/** is upstream code, so please read it as a vendor
drop rather than as a change of ours. Everything else, including the rule
generator, the protocol implementation and the manager surface, is new.

Device verification (OnePlus 13s, Android 16, 6.6.118 GKI, SUSFS active)

  • Rule generation matches upstream: 101 rules, line by line identical to the
    metamount.sh / rule-paths.sh output for the installed module set. One
    deliberate difference: for a .replace / opaque directory upstream emits one
    rule per child (48 of which the kernel rejects with -ENOTDIR), while we
    redirect the directory itself.
  • Injection: new files, new directories, whiteouts, directory replacement and
    symlinks; /proc/mounts stays clean.
  • Visibility: UID exclusions and the isolated toggle, checked with
    su <uid> -c.
  • Lifecycle: hot load and unload (101 -> 0 -> 101), the per-module skip marker,
    disable/enable sync, mode migration (auto for upgraded devices, nomount
    for fresh ones) and the off / builtin / nomount reboot matrix.
  • Failure paths: a driver that cannot answer reports a named cause, and the
    boot record carries a warning when the engine falls back.
  • Coexistence: SUSFS, FolkZygisk and the metamodule path; 0 AVC denials and no
    NoMount frames in dmesg backtraces.

Checks

  • cargo ndk -t arm64-v8a check and clippy clean, plus the CI shape:
    RUSTFLAGS=-Dwarnings cargo clippy --release for aarch64 and x86_64.
  • cargo fmt --all --check on stable and on nightly.
  • cargo test -p ksud: 204 pass, 1 failure that pre-dates this branch
    (lkm_image_btf::tests::rejects_conflicting_loading_module_values).
  • Manager: compileDebugKotlin, testDebugUnitTest, assembleRelease.
  • The NoMount DDK step in ddk-lkm.yml runs for the first time in this PR;
    locally only 6.6 (the device KMI), 5.10 and 6.18 have been built so far.

Not verified

  • Other KMIs on hardware (5.10, 6.12 and 6.18 are compile-only for now).
  • Rule sync after a module update or uninstall (disable/enable is covered).
  • A real upstream NoMount metamodule zip: the deferral path is stub-tested, not
    exercised on a device.
  • Safe mode while nomount is the active provider.

Note

The first two commits (911b10f2, 1cd0a2f7) are pre-existing local main
commits unrelated to NoMount; they are ancestors of this branch.

Provider detection no longer gates module installation or activation: ksud
installs archives through the upstream path, with no Zygisk lock and no
conflict or marker checks. An active external Zygisk provider simply takes
precedence, and the built-in engine steps aside at boot by reading the
provider set before touching the boot guard.

The Manager drops preflight validation and reports the effective provider
instead. Enable persists the configuration before scanning providers, so a
failing scan can no longer turn a completed enable into a CLI error.
NoMount is a VFS path redirection module; FolkSU intends to use it as a
built-in module injection backend, so its kernel sources are vendored here
instead of being downloaded prebuilt from upstream releases.

- Vendor kernel/src (GPL-2.0-only) at 7046c7a (v2.1.1) plus UPSTREAM.md,
  NOTICE and both upstream license texts; imported files stay byte-identical.
- build.sh nomount builds it out-of-tree with the same DDK image matrix as
  kernelsu.ko and stages <kmi>_nomount.ko into ksud's embedded assets. The
  build runs from a temporary copy so the vendored sources are never written
  to.
- ddk-lkm.yml builds and uploads the module with the kernelsu.ko artifact;
  ksud.yml/ksud-extra.yml stage it before building ksud.
- assets.rs exposes the KMI set that ships a NoMount module.

Verified locally against DDK 20260828: android12-5.10 (70000 B),
android15-6.6 (59168 B) and android17-6.18 (70584 B) all build and report
version=21, license=GPL.
ksud can now drive NoMount on its own, without the upstream metamodule or the
`nm` binary: the kernel protocol is reimplemented in Rust and the rules come
from the module directory instead of metamount.sh.

- nomount.rs encodes the page-aligned add_key payload, probes the driver,
  refuses an API below 21, and verifies every applied rule by reading the
  kernel list back, so a transport that drops rules cannot look successful.
- nomount_plan.rs walks the modules (partition remapping, whiteout markers,
  opaque directories) and replaces a replaced directory by redirecting it:
  upstream whiteouts it without the directory flag and the kernel then rejects
  every child rule with -ENOTDIR, so children would be lost.
- Visibility: the stored UID list and the isolated-process flag are replayed
  into the kernel, which is what hides injected files from apps now that there
  are no mounts to unmount.
- magic_mount gains the nomount provider: it loads the embedded module for the
  running KMI, falls back to the built-in engine when NoMount cannot be made
  usable, and records why. `mount status` becomes schema 2 with boot_warning.
- New commands: ksud nomount status/inject/list/clear and uid
  set/add/del/isolated.

Host tests: 26 new cases covering the wire layout, batching, read-back
verification, rule generation, the visibility diff and the kernel's
last-rule-wins conflict rule.
The NoMount backend is now visible and controllable, and the default backend
moves to NoMount with the built-in engine as its fallback.

- NomountScreen reports the driver, the injection and the visibility state.
  Every row that can look healthy is gated on the driver answering, and the
  read state is never replaced by an invented fallback.
- FolkMountStatus accepts schema 1 and 2 and reads boot_warning, so a stale ksud
  or a stale app degrades to one unavailable row instead of a hard failure.
- New mount modes: nomount (NoMount, falls back to the built-in engine) and off
  (inject nothing). The settings dropdown, provider labels and strings follow.
- A missing mount_mode now means: keep the previous behaviour when this device
  already mounted modules (upgrade), otherwise select nomount, and record the
  decision so the default applies only once.

Verified with ./gradlew :app:compileDebugKotlin and :app:testDebugUnitTest.
Not included: pushing App Profile "unmount modules" into the UID list, which
needs an enumeration API the manager does not have yet.
Also apply rustfmt to the provider dispatch.
Device testing showed the kernel creates intermediate directories to host
rules whose parent path does not exist, and that reconcile treated those as
stale: it deleted them and the kernel rebuilt them on the next add, reporting
a pointless rules_removed=3 on every injection.

- reconcile_rules skips rules flagged as kernel-created virtual directories.
- Rule carries the virtual_dir flag, nomount list prints "(virtual dir)" and
  the JSON output exposes it.

Verified on a real device (CPH2723, android15-6.6): two consecutive injections
now report rules_removed=0 with the kernel's three virtual directories intact.
…el total

Device testing showed the NoMount provider left `target_count` empty in
`ksud mount status`, and that the two commands disagreed on the rule count:
`nomount status` reported everything the kernel holds (including the
intermediate directories the kernel creates itself), while `mount status`
reported what we published.

- The per-boot record now carries the count for every provider that knows it,
  which is every provider except the metamodule's opaque script.
- `nomount status` reports `rules_applied` for the rules we published and
  `kernel_rules` for the kernel total, so the manager and the CLI agree.

Verified on a real device: mount status shows target_count=101 and nomount
status shows rules_applied=101 with kernel_rules=104.
The page used a plain Scaffold with a compact TopAppBar and a bare arrow
icon, which does not match the other pages. It now uses the shared
ExpressiveScaffold, LargeFlexibleTopAppBar, TopBarBackButton and
expressiveTopAppBarColors, with a Refresh action like the Susfs and Sulog
pages have.

Checked on device: the bar collapses while scrolling and the back button
keeps the shared styling.
The upstream metamodule UI could inject or drop a single module's rules
without rebooting, and showed how many files each module injects. Both are now
available to the built-in backend.

- nomount_plan exposes per-module collection, including a variant that ignores
  disable/skip marks because a module that was just turned off still has the
  paths that need removing.
- `ksud nomount hot-load|hot-unload <id>` and per-module counts plus the UID
  arrays in `nomount status` (schema 2).
- `ksud module skip <id> [--off]` writes the skip_mount marker the planners
  already honour.

Verified on device: hot-unload takes the module from 101 rules to 0, hot-load
restores 101, and the skip marker toggles.
Closes the two features the upstream metamodule WebUI had and the built-in
page lacked.

- The NoMount page lists every module that contributes rules with its rule
  count; tapping a module injects it immediately and the trailing button drops
  it until the next reboot.
- "Excluded apps" opens a picker over the installed apps (search, show system
  apps, per-row checkbox) whose selection is saved in one go, plus a clear
  action on the page. The exclusion list is what hides injected files from an
  app now that nothing is mounted.
- NomountStatus reads schema 1 and 2 and carries the module and UID lists.

Verified on device: selecting two apps persists uids 10370/10496 into the
kernel policy, clearing empties it, and the module list renders with counts.
…stalls

Two writers on the same paths is the one conflict the built-in backend cannot
resolve: the kernel keeps the last rule written. Both sides are now handled.

- A metamodule that ships its own NoMount tool or LKM owns injection: the
  backend detects it, reports it as `deferred_to` in `nomount status`, refuses
  to write rules itself, and at boot hands the mount stage to that module.
- Installing a metamodule while the built-in backend is the injector is
  refused with a message that says what to change, because that metamodule
  would inject on its own.
- `nomount inject --dry-run --verbose` prints every rule and skip reason, which
  is what the comparison against upstream's metamount.sh needs.

Verified on device with a stub metamodule: deferred_to=nomount-stub,
owns_injection=false, inject and hot-load both refuse with exit 1, and a
regular module still installs normally.
- "Clear everything" drops the rules and the exclusions in one action, with a
  summary that says it lasts until the next reboot.
- The exclusion picker can select the apps whose profile hides modules from
  them, which is the same decision the kernel already answers per UID; it
  honours the picker's own visibility (system apps only when shown) and the
  result is visible as a count before saving.
- The driver card explains when an installed module owns injection instead of
  this backend.

Verified on device: clear-everything takes the rules to 0 from the UI and the
follow action selects the listed apps (168 of them on this device), with no
write until Save.
A kernel with CONFIG_NOMOUNT=y needs no load at all: everything here talks to
the key API, and the probe already answers, so the built-in path worked. Two
things were missing around it.

- An outdated driver (built-in or an LKM someone else loaded) is no longer
  followed by a load attempt: a registered key type always makes a second
  registration fail, so the load could only produce a misleading error. The
  backend now reports the version it found and refuses to inject.
- `nomount status` reports `driver_kind` (builtin or module) and the last
  action that changed kernel state, so "who emptied the rules" does not depend
  on the kernel ring buffer. The manager shows the kind next to the version.

Verified on device: driver_kind=module, and last_action flips from empty to
inject after an injection.
A kernel whose KMI this build ships no module for (legacy or custom kernels)
already fell back to the built-in engine correctly, but the reason only
appeared in the boot record and the page said "unavailable" with nothing behind
it.

- `nomount status` reports `unavailable_code` (no_asset_for_kmi, outdated,
  not_loaded, unknown_kmi) with the KMI or version it refers to.
- The NoMount page and the settings row render that as a localized sentence
  that says what to do, for example "use Automatic or Built-in Folk Mount".
- The reason was computed after the early return for an unusable driver, so it
  was dead code: it now runs before the return.

Verified on device by building a ksud without the android15-6.6 module: the
boot record names the missing asset, the CLI reports
no_asset_for_kmi/android15-6.6, and the page shows the actionable sentence,
while the built-in engine keeps injecting the modules.
Switching to NoMount flips who can see the injected files: the built-in Folk
Mount hides them from apps whose profile says "unmount modules" (every non-root
app by default), while NoMount starts with no exclusions at all. That was
implicit before.

- Selecting NoMount now asks for confirmation first and only writes the mode
  after the user agrees. The dialog names the change, why it happens and what to
  do to keep the old behaviour.
- The NoMount page says it in two places: the exclusion row reads "every app can
  see the injected files" when the list is empty, and a Default visibility row
  explains the difference from Folk Mount.

Verified on device: the dialog gates the write (the mode file stays unchanged
until confirm), confirming switches to nomount, and both rows render.
Both READMEs said visibility follows each app profile's "unmount modules" setting. It does not: ksud applies a fixed exception list it stores itself, and the app profiles are copied into that list on demand from the NoMount page. Say what actually happens, including that a later profile change needs another copy.
The exclusion row caption asserted the list tracks app profiles on its own; only the "Follow app profiles" action copies them, and nothing re-syncs afterwards. The caption now describes that copy, and the action summary says the profiles are not tracked.
ShellCheck fails the workflow with SC2148 because the file is run as "sh build-lkm.sh" and carries no shebang, so the tool cannot pick a target shell.
The ShellCheck job has been red on master as well, for two reasons. The SC1090 directive sat on a line holding three commands, so it covered "set -a" instead of the source. The SC2030/SC2031 notes come from the deliberate ( export ANDROID_NDK_HOME=... ) subshells, which is the intended scoping. Give each one the directive it needs; no behaviour change.
The full-screen prompt painted its text straight on a background, so the headline kept Material's default black content colour and vanished on a dark surface. Wrapping the screen in a Surface makes its content follow onSurface.

The PIN field keeps focus after the keyboard is dismissed, so tapping a cell only re-requested focus and the pad read as read-only. Ask the IME back on tap, and again whenever the pad is re-enabled after a rejected attempt.
The job's checkout is mounted at $GITHUB_WORKSPACE, while /github/workspace only holds the artifacts the kernelsu.ko step creates there. Asking sh for the script under /github/workspace exited 127 on every KMI, and matrix fail-fast then cancelled the remaining jobs of the run.
The dialog took its strings from LocalContext, which Android lint rejects (LocalContextGetResourceValueCall: the value is not configuration-aware) and which aborts :app:lintDebug for every build. Read them through LocalResources, as the rest of the app does.
The same rule fires on the notice shown after a plugin install, and it fails the Build Manager job on master as well, so the release pipeline cannot get past :app:lintDebug. Same fix: query the notice through LocalResources.
@matsuzaka-yuki
matsuzaka-yuki merged commit 71da53f into master Oct 15, 2026
24 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant