From bf71a0f28fff3a083acd85ffd0956bd7dc1c6857 Mon Sep 17 00:00:00 2001 From: saicharankandukuri Date: Sat, 16 May 2026 12:47:48 +0530 Subject: [PATCH 01/13] add go port under go-udroid/ --- go-udroid/README.md | 130 +++++++++ go-udroid/cmd/udroid/cache.go | 58 ++++ go-udroid/cmd/udroid/helpers.go | 54 ++++ go-udroid/cmd/udroid/install.go | 150 +++++++++++ go-udroid/cmd/udroid/list.go | 122 +++++++++ go-udroid/cmd/udroid/login.go | 249 ++++++++++++++++++ go-udroid/cmd/udroid/main.go | 13 + go-udroid/cmd/udroid/remove.go | 67 +++++ go-udroid/cmd/udroid/reset.go | 48 ++++ go-udroid/cmd/udroid/root.go | 92 +++++++ go-udroid/config.example.yaml | 36 +++ go-udroid/go.mod | 37 +++ go-udroid/go.sum | 92 +++++++ go-udroid/internal/config/config.go | 133 ++++++++++ go-udroid/internal/manifest/fetch.go | 102 +++++++ go-udroid/internal/manifest/manifest.go | 128 +++++++++ go-udroid/internal/manifest/manifest_test.go | 69 +++++ go-udroid/internal/manifest/ref.go | 40 +++ go-udroid/internal/proot/args.go | 195 ++++++++++++++ go-udroid/internal/proot/args_test.go | 95 +++++++ go-udroid/internal/proot/exec.go | 121 +++++++++ go-udroid/internal/proot/options.go | 125 +++++++++ go-udroid/internal/rootfs/download.go | 108 ++++++++ go-udroid/internal/rootfs/embed/hosts | 13 + .../internal/rootfs/embed/proc_loadavg.txt | 1 + go-udroid/internal/rootfs/embed/proc_stat.txt | 16 ++ .../internal/rootfs/embed/proc_uptime.txt | 1 + .../internal/rootfs/embed/proc_version.txt | 1 + .../internal/rootfs/embed/proc_vmstat.txt | 168 ++++++++++++ go-udroid/internal/rootfs/embed/resolv.conf | 2 + go-udroid/internal/rootfs/fixes.go | 211 +++++++++++++++ go-udroid/internal/rootfs/groups.go | 48 ++++ go-udroid/internal/rootfs/remove.go | 45 ++++ go-udroid/internal/rootfs/verify.go | 39 +++ go-udroid/internal/termux/termux.go | 96 +++++++ go-udroid/internal/ui/plain.go | 145 ++++++++++ go-udroid/internal/ui/ui.go | 45 ++++ 37 files changed, 3095 insertions(+) create mode 100644 go-udroid/README.md create mode 100644 go-udroid/cmd/udroid/cache.go create mode 100644 go-udroid/cmd/udroid/helpers.go create mode 100644 go-udroid/cmd/udroid/install.go create mode 100644 go-udroid/cmd/udroid/list.go create mode 100644 go-udroid/cmd/udroid/login.go create mode 100644 go-udroid/cmd/udroid/main.go create mode 100644 go-udroid/cmd/udroid/remove.go create mode 100644 go-udroid/cmd/udroid/reset.go create mode 100644 go-udroid/cmd/udroid/root.go create mode 100644 go-udroid/config.example.yaml create mode 100644 go-udroid/go.mod create mode 100644 go-udroid/go.sum create mode 100644 go-udroid/internal/config/config.go create mode 100644 go-udroid/internal/manifest/fetch.go create mode 100644 go-udroid/internal/manifest/manifest.go create mode 100644 go-udroid/internal/manifest/manifest_test.go create mode 100644 go-udroid/internal/manifest/ref.go create mode 100644 go-udroid/internal/proot/args.go create mode 100644 go-udroid/internal/proot/args_test.go create mode 100644 go-udroid/internal/proot/exec.go create mode 100644 go-udroid/internal/proot/options.go create mode 100644 go-udroid/internal/rootfs/download.go create mode 100644 go-udroid/internal/rootfs/embed/hosts create mode 100644 go-udroid/internal/rootfs/embed/proc_loadavg.txt create mode 100644 go-udroid/internal/rootfs/embed/proc_stat.txt create mode 100644 go-udroid/internal/rootfs/embed/proc_uptime.txt create mode 100644 go-udroid/internal/rootfs/embed/proc_version.txt create mode 100644 go-udroid/internal/rootfs/embed/proc_vmstat.txt create mode 100644 go-udroid/internal/rootfs/embed/resolv.conf create mode 100644 go-udroid/internal/rootfs/fixes.go create mode 100644 go-udroid/internal/rootfs/groups.go create mode 100644 go-udroid/internal/rootfs/remove.go create mode 100644 go-udroid/internal/rootfs/verify.go create mode 100644 go-udroid/internal/termux/termux.go create mode 100644 go-udroid/internal/ui/plain.go create mode 100644 go-udroid/internal/ui/ui.go diff --git a/go-udroid/README.md b/go-udroid/README.md new file mode 100644 index 0000000..90812f7 --- /dev/null +++ b/go-udroid/README.md @@ -0,0 +1,130 @@ +# go-udroid + +A Go port of [fs-manager-udroid](../README.md) — a proot wrapper that installs +Linux rootfs tarballs as containers on Termux/Android. + +This port is designed so the same core (`internal/proot`, `internal/manifest`, +`internal/rootfs`) can later back a Bubble Tea TUI without touching the +business logic. + +## Build + +```bash +cd go-udroid +go build -o udroid ./cmd/udroid +``` + +Static cross-compile for Termux (no CGO): + +```bash +CGO_ENABLED=0 GOOS=linux GOARCH=arm64 \ + go build -ldflags='-s -w' -o udroid-linux-arm64 ./cmd/udroid +``` + +## Usage + +The CLI surface mirrors the bash version. Run `udroid help` for the full list. + +```bash +udroid install jammy:raw # download + extract + apply fixes +udroid login jammy:raw # interactive shell +udroid login --profile dev jammy:raw # use a saved login profile +udroid login jammy:raw -- echo hello # one-shot command +udroid login --custom my-rootfs # log into a custom install +udroid list --size # tabulate installed/available +udroid remove jammy:raw # uninstall +udroid reset jammy:raw # remove + reinstall +udroid cache update # refresh distro manifest +udroid cache clear # drop downloaded tarballs +``` + +## Configuration + +Drop a YAML file at `~/.config/udroid/config.yaml` (or point `UDROID_CONFIG` +at any path). See [`config.example.yaml`](./config.example.yaml). + +Resolution order (highest priority first): + +1. CLI flags +2. `UDROID_*` env vars +3. `--config ` / `$UDROID_CONFIG` +4. `$XDG_CONFIG_HOME/udroid/config.yaml` then `~/.config/udroid/config.yaml` +5. Built-in defaults + +### Profiles + +Save a named bundle of login flags and recall them by name: + +```yaml +profiles: + dev: + user: dev + binds: [/sdcard/projects:/workspace] + isolated: false +``` + +```bash +udroid login --profile dev jammy:raw +``` + +CLI flags always win over profile values, profile values win over `defaults`. + +## Layout + +``` +cmd/udroid/ # cobra entrypoints — thin glue +internal/ + manifest/ # distro-data.json fetch + parse + ref parsing + proot/ # typed Options, BuildArgs (pure), exec wrappers + rootfs/ # download / verify / extract / fixes / remove + config/ # viper-loaded yaml + profile merging + ui/ # UI interface + plain implementation + termux/ # path constants + arch detection +``` + +### The proot argv builder + +`internal/proot/args.go::BuildArgs(Options) []string` is a pure function. +It turns a typed `Options` struct into the argv that's handed to +`exec.Command("proot", ...)`. No I/O, no globals, fully deterministic, so +the CLI and a future TUI can share the same call. + +## Shelled-out commands + +The Go port keeps a strict static binary. The only external program it +invokes is `proot` itself, for: + +- **Extraction** — needs `proot --link2symlink tar` because Linux rootfs + tarballs contain hard links that don't survive on Android's filesystem + without proot's link2symlink translation. +- **Login** — replaces the Go process via `syscall.Exec` so proot becomes + the foreground process the user interacts with. + +Everything else (HTTP, sha256, JSON, arch detection, /proc fake files, +group entries) is native Go. + +## Testing + +```bash +go test ./... +``` + +There's coverage on the args builder (the riskiest piece) and on the +manifest parser against the existing `udroid/src/test.json` fixture so +changes can't silently break the on-disk format. + +## Status + +This is an early port. It exercises the same code paths as the bash +version but hasn't yet been exercised on real Termux installs across the +same matrix of distros and Android versions. Treat as alpha until it +ships its first release. + +### Known regressions vs. the bash version + +- **No partial-download resume.** Bash uses `wget -c`; this port restarts + from byte 0 on retry. Acceptable on stable links, noticeable on flaky + mobile data. +- **`list --size` is slower.** Bash shelled out to `du -sh`; the Go + version walks the tree natively. Multi-GB installs may take a few + seconds to size up. diff --git a/go-udroid/cmd/udroid/cache.go b/go-udroid/cmd/udroid/cache.go new file mode 100644 index 0000000..9354aa3 --- /dev/null +++ b/go-udroid/cmd/udroid/cache.go @@ -0,0 +1,58 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/RandomCoderOrg/fs-manager-udroid/go-udroid/internal/manifest" + "github.com/RandomCoderOrg/fs-manager-udroid/go-udroid/internal/rootfs" +) + +func newCacheCmd(a *app) *cobra.Command { + root := &cobra.Command{ + Use: "cache", + Short: "manage local caches", + } + root.AddCommand( + &cobra.Command{ + Use: "update", + Short: "refresh distro manifest from remote", + RunE: func(cmd *cobra.Command, args []string) error { + _, err := loadManifest(cmd.Context(), a, manifest.ModeOnline, true) + if err != nil { + return err + } + a.ui.Info("manifest updated") + return nil + }, + }, + &cobra.Command{ + Use: "clear", + Short: "clear downloaded tarball cache", + RunE: func(cmd *cobra.Command, args []string) error { + size, _ := rootfs.Size(a.paths.DownloadCache) + entries, err := os.ReadDir(a.paths.DownloadCache) + if err != nil { + return err + } + if len(entries) == 0 { + a.ui.Warn("cache is empty") + return nil + } + ok, err := a.ui.Confirm(fmt.Sprintf("clear %s of cache?", humanBytes(size)), true) + if err != nil || !ok { + return err + } + for _, e := range entries { + _ = os.RemoveAll(filepath.Join(a.paths.DownloadCache, e.Name())) + } + a.ui.Info("cache cleared") + return nil + }, + }, + ) + return root +} diff --git a/go-udroid/cmd/udroid/helpers.go b/go-udroid/cmd/udroid/helpers.go new file mode 100644 index 0000000..acbfb15 --- /dev/null +++ b/go-udroid/cmd/udroid/helpers.go @@ -0,0 +1,54 @@ +package main + +import ( + "context" + "fmt" + + "github.com/RandomCoderOrg/fs-manager-udroid/go-udroid/internal/manifest" +) + +// loadManifest builds a fetcher honouring the user's manifest_url override. +func loadManifest(ctx context.Context, a *app, mode manifest.Mode, strict bool) (*manifest.Manifest, error) { + f := manifest.NewFetcher(a.paths.RuntimeCache) + if a.cfg != nil && a.cfg.ManifestURL != "" { + f.URL = a.cfg.ManifestURL + } + return f.Load(ctx, mode, strict) +} + +// resolveRef fills in missing suite or variant via interactive prompts, +// returning an error if the user supplied a value that isn't in the manifest. +func resolveRef(a *app, mf *manifest.Manifest, ref manifest.Ref) (manifest.Ref, error) { + if ref.Suite == "" { + s, err := a.ui.Choose("select suite", mf.Suites) + if err != nil { + return ref, err + } + ref.Suite = s + } + if !mf.HasSuite(ref.Suite) { + return ref, fmt.Errorf("suite %q not in manifest", ref.Suite) + } + suite, err := mf.Suite(ref.Suite) + if err != nil { + return ref, err + } + if ref.Variant == "" { + v, err := a.ui.Choose("select variant", suite.Variants) + if err != nil { + return ref, err + } + ref.Variant = v + } + found := false + for _, v := range suite.Variants { + if v == ref.Variant { + found = true + break + } + } + if !found { + return ref, fmt.Errorf("variant %q not in suite %q", ref.Variant, ref.Suite) + } + return ref, nil +} diff --git a/go-udroid/cmd/udroid/install.go b/go-udroid/cmd/udroid/install.go new file mode 100644 index 0000000..efc161e --- /dev/null +++ b/go-udroid/cmd/udroid/install.go @@ -0,0 +1,150 @@ +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/RandomCoderOrg/fs-manager-udroid/go-udroid/internal/manifest" + "github.com/RandomCoderOrg/fs-manager-udroid/go-udroid/internal/proot" + "github.com/RandomCoderOrg/fs-manager-udroid/go-udroid/internal/rootfs" +) + +func newInstallCmd(a *app) *cobra.Command { + var ( + noVerify bool + alwaysRetry bool + customFile string + customName string + ) + cmd := &cobra.Command{ + Use: "install :", + Aliases: []string{"i"}, + Short: "install a distro", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + if customFile != "" || customName != "" { + return runCustomInstall(ctx, a, customFile, customName) + } + if len(args) == 0 { + return fmt.Errorf("install: : required") + } + ref, err := manifest.ParseRef(args[0]) + if err != nil { + return err + } + return runInstall(ctx, a, ref, noVerify, alwaysRetry) + }, + } + cmd.Flags().BoolVar(&noVerify, "no-verify-integrity", false, "skip sha256 verification") + cmd.Flags().BoolVar(&alwaysRetry, "always-retry", false, "retry download until success or Ctrl-C") + cmd.Flags().StringVar(&customFile, "file", "", "(custom) path to local tarball") + cmd.Flags().StringVar(&customName, "name", "", "(custom) name for the installed rootfs") + return cmd +} + +func runInstall(ctx context.Context, a *app, ref manifest.Ref, noVerify, alwaysRetry bool) error { + a.ui.Title("> INSTALL " + ref.String()) + if alwaysRetry && noVerify { + return fmt.Errorf("--always-retry is incompatible with --no-verify-integrity") + } + + mf, err := loadManifest(ctx, a, manifest.ModeOnline, false) + if err != nil { + return err + } + ref, err = resolveRef(a, mf, ref) + if err != nil { + return err + } + v, err := mf.Variant(ref.Suite, ref.Variant, a.arch) + if err != nil { + return err + } + if v.URL == "" { + return fmt.Errorf("no download URL for %s on %s — variant not supported", ref, a.arch) + } + destDir := filepath.Join(a.paths.InstalledFsDir, v.Name) + if _, err := os.Stat(destDir); err == nil { + return fmt.Errorf("filesystem %q already installed at %s", v.Name, destDir) + } + + ext := filepath.Ext(v.URL) + tarPath := filepath.Join(a.paths.DownloadCache, v.Name+".tar"+ext) + + a.ui.Info(fmt.Sprintf("downloading %s ...", v.Name)) + bar := a.ui.Progress("download " + v.Name) + if err := rootfs.Download(ctx, v.URL, tarPath, alwaysRetry, bar); err != nil { + return err + } + + if !noVerify { + if err := a.ui.Spinner("verifying sha256", func() error { + return rootfs.VerifySHA256(tarPath, v.SHASum) + }); err != nil { + ok, _ := a.ui.Confirm("integrity check failed. re-download?", true) + if !ok { + return err + } + _ = os.Remove(tarPath) + if err := rootfs.Download(ctx, v.URL, tarPath, alwaysRetry, a.ui.Progress("re-download "+v.Name)); err != nil { + return err + } + if err := rootfs.VerifySHA256(tarPath, v.SHASum); err != nil { + return err + } + } + } + + if err := os.MkdirAll(destDir, 0o755); err != nil { + return err + } + a.ui.Info("extracting to " + destDir) + if err := proot.ExtractTarball(ctx, tarPath, destDir); err != nil { + return err + } + + a.ui.Info("applying proot fixes") + groups, _ := rootfs.HostAndroidGroups() + if err := rootfs.ApplyFixes(destDir, rootfs.FixesOptions{ + TermuxPrefix: a.paths.Prefix, + AndroidGroups: groups, + }); err != nil { + return err + } + a.ui.Info("✔ " + v.Name + " installed") + return nil +} + +func runCustomInstall(ctx context.Context, a *app, file, name string) error { + if file == "" || name == "" { + return fmt.Errorf("custom install requires both --file and --name") + } + if _, err := os.Stat(file); err != nil { + return fmt.Errorf("tarball %q: %w", file, err) + } + dest := filepath.Join(a.paths.InstalledFsDir, "custom-"+name) + if _, err := os.Stat(dest); err == nil { + return fmt.Errorf("custom filesystem %q already installed", name) + } + if err := os.MkdirAll(dest, 0o755); err != nil { + return err + } + a.ui.Title("> INSTALL custom-" + name) + a.ui.Info("extracting " + file + " -> " + dest) + if err := proot.ExtractTarball(ctx, file, dest); err != nil { + return err + } + groups, _ := rootfs.HostAndroidGroups() + if err := rootfs.ApplyFixes(dest, rootfs.FixesOptions{ + TermuxPrefix: a.paths.Prefix, + AndroidGroups: groups, + }); err != nil { + return err + } + a.ui.Info("✔ custom-" + name + " installed") + return nil +} diff --git a/go-udroid/cmd/udroid/list.go b/go-udroid/cmd/udroid/list.go new file mode 100644 index 0000000..edac5f8 --- /dev/null +++ b/go-udroid/cmd/udroid/list.go @@ -0,0 +1,122 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/olekukonko/tablewriter" + "github.com/spf13/cobra" + + "github.com/RandomCoderOrg/fs-manager-udroid/go-udroid/internal/manifest" + "github.com/RandomCoderOrg/fs-manager-udroid/go-udroid/internal/rootfs" +) + +func newListCmd(a *app) *cobra.Command { + var ( + showSize bool + showCustomFs bool + installedOnly bool + ) + cmd := &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "list distros and their install status", + RunE: func(cmd *cobra.Command, args []string) error { + mf, err := loadManifest(cmd.Context(), a, manifest.ModeOffline, false) + if err != nil { + return err + } + return runList(a, mf, showSize, showCustomFs, installedOnly) + }, + } + cmd.Flags().BoolVar(&showSize, "size", false, "include installed size") + cmd.Flags().BoolVar(&showCustomFs, "custom", false, "also list custom-installed rootfs") + cmd.Flags().BoolVar(&installedOnly, "installed", false, "only show installed rootfs") + return cmd +} + +func runList(a *app, mf *manifest.Manifest, showSize, showCustomFs, installedOnly bool) error { + t := tablewriter.NewWriter(a.ui.Out()) + hdr := []string{"suite:variant", "arch supported", "status"} + if showSize { + hdr = append(hdr, "size") + } + t.SetHeader(hdr) + t.SetAutoWrapText(false) + + for _, suiteName := range mf.Suites { + s, err := mf.Suite(suiteName) + if err != nil { + continue + } + for _, vName := range s.Variants { + v, err := mf.Variant(suiteName, vName, a.arch) + if err != nil { + continue + } + supported := "NO" + for _, arch := range v.SupportedArchs { + if arch == string(a.arch) { + supported = "YES" + break + } + } + installed := "" + path := filepath.Join(a.paths.InstalledFsDir, v.Name) + if _, err := os.Stat(path); err == nil { + installed = "[installed]" + } + if installedOnly && installed == "" { + continue + } + row := []string{suiteName + ":" + vName, supported, installed} + if showSize { + row = append(row, sizeOrBlank(path)) + } + t.Append(row) + } + } + t.Render() + + if showCustomFs { + fmt.Fprintln(a.ui.Out(), "\ncustom rootfs:") + entries, _ := os.ReadDir(a.paths.InstalledFsDir) + for _, e := range entries { + if !e.IsDir() || !strings.HasPrefix(e.Name(), "custom-") { + continue + } + line := " " + strings.TrimPrefix(e.Name(), "custom-") + if showSize { + line += "\t" + sizeOrBlank(filepath.Join(a.paths.InstalledFsDir, e.Name())) + } + fmt.Fprintln(a.ui.Out(), line) + } + } + return nil +} + +func sizeOrBlank(path string) string { + if _, err := os.Stat(path); err != nil { + return "" + } + n, err := rootfs.Size(path) + if err != nil { + return "" + } + return humanBytes(n) +} + +func humanBytes(n int64) string { + const u = 1024 + if n < u { + return fmt.Sprintf("%dB", n) + } + div, exp := int64(u), 0 + for x := n / u; x >= u; x /= u { + div *= u + exp++ + } + return fmt.Sprintf("%.1f%ciB", float64(n)/float64(div), "KMGTPE"[exp]) +} diff --git a/go-udroid/cmd/udroid/login.go b/go-udroid/cmd/udroid/login.go new file mode 100644 index 0000000..01610bd --- /dev/null +++ b/go-udroid/cmd/udroid/login.go @@ -0,0 +1,249 @@ +package main + +import ( + "bufio" + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + + "github.com/RandomCoderOrg/fs-manager-udroid/go-udroid/internal/config" + "github.com/RandomCoderOrg/fs-manager-udroid/go-udroid/internal/manifest" + "github.com/RandomCoderOrg/fs-manager-udroid/go-udroid/internal/proot" + "github.com/RandomCoderOrg/fs-manager-udroid/go-udroid/internal/rootfs" +) + +func newLoginCmd(a *app) *cobra.Command { + var ( + profile string + loginUser string + bindList []string + customDistro string + nameOverride string + isolated bool + fixLowPorts bool + ashmemMemfd bool + noSharedTmp bool + noLink2Symlink bool + noSysVIPC bool + noKillOnExit bool + noFakeRootID bool + noCapLastCap bool + reinstallFixes bool + noPulseServer bool + runScript string + ) + cmd := &cobra.Command{ + Use: "login [flags] : [-- cmd ...]", + Aliases: []string{"l"}, + Short: "log in to an installed rootfs", + Long: "Spawn a proot session inside an installed rootfs. Pass '--' to run a one-shot command instead of dropping into a shell.", + RunE: func(cmd *cobra.Command, args []string) error { + // args after `--` are the command to run inside the rootfs. + dashIdx := cmd.ArgsLenAtDash() + var passthrough []string + if dashIdx >= 0 { + passthrough = args[dashIdx:] + args = args[:dashIdx] + } + + distroName, err := resolveLoginTarget(a, args, customDistro, nameOverride) + if err != nil { + return err + } + rootFS := filepath.Join(a.paths.InstalledFsDir, distroName) + if _, err := os.Stat(rootFS); err != nil { + return fmt.Errorf("rootfs %q not installed", distroName) + } + if reinstallFixes { + groups, _ := rootfs.HostAndroidGroups() + if err := rootfs.ApplyFixes(rootFS, rootfs.FixesOptions{ + TermuxPrefix: a.paths.Prefix, + AndroidGroups: groups, + LoginUser: loginUser, + }); err != nil { + return err + } + } + + opts := proot.DefaultOptions(rootFS) + opts.HostPrefix = a.paths.Prefix + opts.HostHome = a.paths.Home + opts.AndroidPackage = a.paths.Package + + // merge profile from config + if profile != "" { + prof, ok := a.cfg.Profile(profile) + if !ok { + return fmt.Errorf("profile %q not found in config", profile) + } + applyProfile(&opts, prof) + } else if a.cfg != nil { + applyProfile(&opts, a.cfg.Defaults) + } + + // CLI flags override profile + if loginUser != "" { + opts.LoginUser = loginUser + } + for _, b := range bindList { + opts.Binds = append(opts.Binds, parseBindFlag(b)) + } + if isolated { + opts.Isolated = true + } + if fixLowPorts { + opts.FixLowPorts = true + } + if ashmemMemfd { + opts.AshmemMemfd = true + } + if noSharedTmp { + opts.SharedTmp = false + } + if noLink2Symlink { + opts.Link2Symlink = false + } + if noSysVIPC { + opts.SysVIPC = false + } + if noKillOnExit { + opts.KillOnExit = false + } + if noFakeRootID { + opts.FakeRootID = false + } + if noCapLastCap { + opts.CapLastCapFix = false + } + if noPulseServer { + opts.PulseServer = false + } + if runScript != "" { + opts.RunScript = runScript + } + + // Pick CWD: explicit isolated => /root, otherwise host PWD. + if isolated { + opts.CWD = "/root" + } + + // per-fs udroid_proot_mounts file + opts.Binds = append(opts.Binds, readPerFSMounts(rootFS)...) + + if len(passthrough) > 0 { + opts.Command = passthrough + } + + a.ui.Title("> LOGIN " + distroName) + return proot.Login(opts) + }, + } + f := cmd.Flags() + f.StringVar(&profile, "profile", "", "named login profile from config.yaml") + f.StringVar(&loginUser, "user", "", "login user inside the rootfs (default root)") + f.StringArrayVarP(&bindList, "bind", "b", nil, "extra bind, e.g. /host:/guest") + f.StringVar(&customDistro, "custom", "", "log into a custom (locally-installed) rootfs by name") + f.StringVar(&nameOverride, "name", "", "explicit installed name (skip manifest lookup)") + f.BoolVar(&isolated, "isolated", false, "skip termux/storage mounts and cwd inheritance") + f.BoolVar(&fixLowPorts, "fix-low-ports", false, "allow binding to ports below 1024") + f.BoolVar(&ashmemMemfd, "ashmem-memfd", false, "experimental memfd via ashmem") + f.BoolVar(&noSharedTmp, "no-shared-tmp", false, "use rootfs /tmp instead of termux $PREFIX/tmp") + f.BoolVar(&noLink2Symlink, "no-link2symlink", false, "disable proot link2symlink") + f.BoolVar(&noSysVIPC, "no-sysvipc", false, "disable sysvipc emulation") + f.BoolVar(&noKillOnExit, "no-kill-on-exit", false, "disable kill-on-exit") + f.BoolVar(&noFakeRootID, "no-fake-root-id", false, "disable --root-id") + f.BoolVar(&noCapLastCap, "no-cap-last-cap", false, "disable cap_last_cap fix mount") + f.BoolVar(&reinstallFixes, "reinstall-fixes", false, "re-apply proot-fixes before login") + f.BoolVar(&noPulseServer, "no-pulseserver", false, "skip starting host pulseaudio") + f.StringVar(&runScript, "run-script", "", "host-side script to run inside the rootfs") + return cmd +} + +func resolveLoginTarget(a *app, args []string, custom, nameOverride string) (string, error) { + if nameOverride != "" { + return nameOverride, nil + } + if custom != "" { + return "custom-" + custom, nil + } + if len(args) == 0 { + return "", fmt.Errorf("login: : required") + } + ref, err := manifest.ParseRef(args[0]) + if err != nil { + return "", err + } + mf, err := loadManifest(context.Background(), a, manifest.ModeOffline, false) + if err != nil { + return "", err + } + ref, err = resolveRef(a, mf, ref) + if err != nil { + return "", err + } + v, err := mf.Variant(ref.Suite, ref.Variant, a.arch) + if err != nil { + return "", err + } + if v.Name == "" { + return "", fmt.Errorf("variant %s has no Name field in manifest", ref) + } + return v.Name, nil +} + +func parseBindFlag(s string) proot.Bind { + parts := strings.SplitN(s, ":", 2) + if len(parts) == 1 { + return proot.Bind{Source: parts[0]} + } + return proot.Bind{Source: parts[0], Target: parts[1]} +} + +func applyProfile(o *proot.Options, p config.LoginProfile) { + if p.User != "" { + o.LoginUser = p.User + } + if p.RunScript != "" { + o.RunScript = p.RunScript + } + for _, b := range p.Binds { + o.Binds = append(o.Binds, parseBindFlag(b)) + } + if len(p.Command) > 0 { + o.Command = p.Command + } + o.Isolated = config.BoolDeref(p.Isolated, o.Isolated) + o.Link2Symlink = config.BoolDeref(p.Link2Symlink, o.Link2Symlink) + o.SysVIPC = config.BoolDeref(p.SysVIPC, o.SysVIPC) + o.KillOnExit = config.BoolDeref(p.KillOnExit, o.KillOnExit) + o.FakeRootID = config.BoolDeref(p.FakeRootID, o.FakeRootID) + o.CapLastCapFix = config.BoolDeref(p.CapLastCapFix, o.CapLastCapFix) + o.SharedTmp = config.BoolDeref(p.SharedTmp, o.SharedTmp) + o.FixLowPorts = config.BoolDeref(p.FixLowPorts, o.FixLowPorts) + o.AshmemMemfd = config.BoolDeref(p.AshmemMemfd, o.AshmemMemfd) + o.PulseServer = config.BoolDeref(p.PulseServer, o.PulseServer) +} + +// readPerFSMounts parses the optional /udroid_proot_mounts file — +// blank lines and `#` comments are ignored. +func readPerFSMounts(rootFS string) []proot.Bind { + f, err := os.Open(filepath.Join(rootFS, "udroid_proot_mounts")) + if err != nil { + return nil + } + defer f.Close() + var binds []proot.Bind + sc := bufio.NewScanner(f) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + binds = append(binds, parseBindFlag(line)) + } + return binds +} diff --git a/go-udroid/cmd/udroid/main.go b/go-udroid/cmd/udroid/main.go new file mode 100644 index 0000000..2eca7db --- /dev/null +++ b/go-udroid/cmd/udroid/main.go @@ -0,0 +1,13 @@ +// Command udroid is the Go port of fs-manager-udroid — a proot wrapper +// for installing and running Linux rootfs containers on Termux/Android. +package main + +import ( + "os" +) + +func main() { + if err := newRootCmd().Execute(); err != nil { + os.Exit(1) + } +} diff --git a/go-udroid/cmd/udroid/remove.go b/go-udroid/cmd/udroid/remove.go new file mode 100644 index 0000000..9b59e4f --- /dev/null +++ b/go-udroid/cmd/udroid/remove.go @@ -0,0 +1,67 @@ +package main + +import ( + "context" + "fmt" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/RandomCoderOrg/fs-manager-udroid/go-udroid/internal/manifest" + "github.com/RandomCoderOrg/fs-manager-udroid/go-udroid/internal/rootfs" +) + +func newRemoveCmd(a *app) *cobra.Command { + var ( + customDistro string + nameOverride string + ) + cmd := &cobra.Command{ + Use: "remove :", + Aliases: []string{"rm", "uninstall"}, + Short: "remove an installed rootfs", + RunE: func(cmd *cobra.Command, args []string) error { + name, err := resolveRemoveTarget(a, args, customDistro, nameOverride) + if err != nil { + return err + } + path := filepath.Join(a.paths.InstalledFsDir, name) + a.ui.Title("> REMOVE " + name) + return a.ui.Spinner("removing "+name, func() error { + return rootfs.Remove(path) + }) + }, + } + cmd.Flags().StringVar(&customDistro, "custom", "", "remove a custom rootfs by name") + cmd.Flags().StringVar(&nameOverride, "name", "", "explicit installed name to remove") + return cmd +} + +func resolveRemoveTarget(a *app, args []string, custom, nameOverride string) (string, error) { + if nameOverride != "" { + return nameOverride, nil + } + if custom != "" { + return "custom-" + custom, nil + } + if len(args) == 0 { + return "", fmt.Errorf("remove: : required") + } + ref, err := manifest.ParseRef(args[0]) + if err != nil { + return "", err + } + mf, err := loadManifest(context.Background(), a, manifest.ModeOffline, false) + if err != nil { + return "", err + } + ref, err = resolveRef(a, mf, ref) + if err != nil { + return "", err + } + v, err := mf.Variant(ref.Suite, ref.Variant, a.arch) + if err != nil { + return "", err + } + return v.Name, nil +} diff --git a/go-udroid/cmd/udroid/reset.go b/go-udroid/cmd/udroid/reset.go new file mode 100644 index 0000000..7456107 --- /dev/null +++ b/go-udroid/cmd/udroid/reset.go @@ -0,0 +1,48 @@ +package main + +import ( + "fmt" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/RandomCoderOrg/fs-manager-udroid/go-udroid/internal/manifest" + "github.com/RandomCoderOrg/fs-manager-udroid/go-udroid/internal/rootfs" +) + +func newResetCmd(a *app) *cobra.Command { + cmd := &cobra.Command{ + Use: "reset :", + Aliases: []string{"reinstall"}, + Short: "remove and reinstall a rootfs", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + ref, err := manifest.ParseRef(args[0]) + if err != nil { + return err + } + mf, err := loadManifest(ctx, a, manifest.ModeOffline, false) + if err != nil { + return err + } + ref, err = resolveRef(a, mf, ref) + if err != nil { + return err + } + v, err := mf.Variant(ref.Suite, ref.Variant, a.arch) + if err != nil { + return err + } + path := filepath.Join(a.paths.InstalledFsDir, v.Name) + a.ui.Title(fmt.Sprintf("> RESET %s", ref)) + if err := a.ui.Spinner("removing "+v.Name, func() error { + return rootfs.Remove(path) + }); err != nil { + return err + } + return runInstall(ctx, a, ref, false, false) + }, + } + return cmd +} diff --git a/go-udroid/cmd/udroid/root.go b/go-udroid/cmd/udroid/root.go new file mode 100644 index 0000000..f3e587d --- /dev/null +++ b/go-udroid/cmd/udroid/root.go @@ -0,0 +1,92 @@ +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/RandomCoderOrg/fs-manager-udroid/go-udroid/internal/config" + "github.com/RandomCoderOrg/fs-manager-udroid/go-udroid/internal/termux" + "github.com/RandomCoderOrg/fs-manager-udroid/go-udroid/internal/ui" +) + +// app gathers the singletons every subcommand needs. Built once in +// PersistentPreRun and reachable via the command context. +type app struct { + cfg *config.Config + paths termux.Paths + arch termux.Arch + ui ui.UI +} + +func newRootCmd() *cobra.Command { + var ( + configFile string + verbose bool + ) + state := &app{} + root := &cobra.Command{ + Use: "udroid", + Short: "proot-based linux rootfs manager for Termux on Android", + Long: "udroid manages Linux rootfs tarballs as proot containers on Termux/Android.", + SilenceUsage: true, + SilenceErrors: true, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + cfg, err := config.Load(configFile) + if err != nil { + return err + } + paths := applyPathOverrides(termux.DefaultPaths(), cfg.Paths) + if err := paths.EnsureWritable(); err != nil { + return fmt.Errorf("prepare directories: %w", err) + } + arch := termux.DetectArch() + if arch == "" { + return fmt.Errorf("unsupported architecture") + } + state.cfg = cfg + state.paths = paths + state.arch = arch + state.ui = ui.NewPlain() + if verbose { + fmt.Fprintln(os.Stderr, "arch:", arch, "prefix:", paths.Prefix) + } + return nil + }, + } + root.PersistentFlags().StringVar(&configFile, "config", "", "path to config.yaml") + root.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output") + + root.AddCommand( + newInstallCmd(state), + newLoginCmd(state), + newRemoveCmd(state), + newResetCmd(state), + newListCmd(state), + newCacheCmd(state), + ) + return root +} + +func applyPathOverrides(p termux.Paths, o config.PathsOverride) termux.Paths { + if o.Prefix != "" { + p.Prefix = o.Prefix + } + if o.Home != "" { + p.Home = o.Home + } + if o.Root != "" { + p.Root = o.Root + } + if o.InstalledFsDir != "" { + p.InstalledFsDir = o.InstalledFsDir + } + if o.DownloadCache != "" { + p.DownloadCache = o.DownloadCache + } + if o.RuntimeCache != "" { + p.RuntimeCache = o.RuntimeCache + } + return p +} diff --git a/go-udroid/config.example.yaml b/go-udroid/config.example.yaml new file mode 100644 index 0000000..b11d468 --- /dev/null +++ b/go-udroid/config.example.yaml @@ -0,0 +1,36 @@ +# udroid config — drop at ~/.config/udroid/config.yaml or point UDROID_CONFIG at it. +# Every key is optional; missing values fall back to the built-in defaults. + +manifest_url: https://raw.githubusercontent.com/RandomCoderOrg/udroid-download/main/distro-data.json + +paths: + # Uncomment to redirect the directories udroid manages. Useful for testing + # on a regular Linux host where the termux $PREFIX doesn't exist. + # prefix: /home/me/udroid-prefix + # root: /home/me/udroid-prefix/var/lib/udroid + +# `defaults` apply to every `login` call unless a profile or CLI flag overrides. +defaults: + user: root + shared_tmp: true + +# Named profiles — invoke with `udroid login --profile dev jammy:raw`. +# Any field omitted is inherited from `defaults` or the built-in proot defaults. +profiles: + dev: + user: dev + binds: + - /sdcard/projects:/workspace + isolated: false + shared_tmp: true + + ci: + user: root + isolated: true + kill_on_exit: true + shared_tmp: false + + audio: + user: root + pulse_server: true # default true; set false to skip the host daemon + fix_low_ports: true diff --git a/go-udroid/go.mod b/go-udroid/go.mod new file mode 100644 index 0000000..4762c27 --- /dev/null +++ b/go-udroid/go.mod @@ -0,0 +1,37 @@ +module github.com/RandomCoderOrg/fs-manager-udroid/go-udroid + +go 1.22 + +require ( + github.com/olekukonko/tablewriter v0.0.5 + github.com/schollz/progressbar/v3 v3.14.6 + github.com/spf13/cobra v1.8.1 + github.com/spf13/viper v1.19.0 +) + +require ( + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mattn/go-runewidth v0.0.9 // indirect + github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/multierr v1.9.0 // indirect + golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect + golang.org/x/sys v0.22.0 // indirect + golang.org/x/term v0.22.0 // indirect + golang.org/x/text v0.14.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go-udroid/go.sum b/go-udroid/go.sum new file mode 100644 index 0000000..7d91b35 --- /dev/null +++ b/go-udroid/go.sum @@ -0,0 +1,92 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= +github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/schollz/progressbar/v3 v3.14.6 h1:GyjwcWBAf+GFDMLziwerKvpuS7ZF+mNTAXIB2aspiZs= +github.com/schollz/progressbar/v3 v3.14.6/go.mod h1:Nrzpuw3Nl0srLY0VlTvC4V6RL50pcEymjy6qyJAaLa0= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= +github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= +go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk= +golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/go-udroid/internal/config/config.go b/go-udroid/internal/config/config.go new file mode 100644 index 0000000..48e9846 --- /dev/null +++ b/go-udroid/internal/config/config.go @@ -0,0 +1,133 @@ +// Package config loads runtime configuration for udroid. +// +// Resolution order (highest priority first): +// 1. CLI flags (the caller binds them to viper) +// 2. UDROID_* env vars +// 3. $UDROID_CONFIG file, then $XDG_CONFIG_HOME/udroid/config.yaml, +// then $HOME/.config/udroid/config.yaml +// 4. Built-in defaults +// +// LoginProfile lets users save a named bundle of proot toggles and binds +// and recall it via `udroid login --profile :`. +package config + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/viper" +) + +// Config is the in-memory shape of config.yaml. +type Config struct { + ManifestURL string `mapstructure:"manifest_url"` + Paths PathsOverride `mapstructure:"paths"` + Profiles map[string]LoginProfile `mapstructure:"profiles"` + Defaults LoginProfile `mapstructure:"defaults"` +} + +// PathsOverride lets users redirect any of the canonical directories. +// Empty values fall back to termux.DefaultPaths(). +type PathsOverride struct { + Prefix string `mapstructure:"prefix"` + Home string `mapstructure:"home"` + Root string `mapstructure:"root"` + InstalledFsDir string `mapstructure:"installed_fs_dir"` + DownloadCache string `mapstructure:"download_cache"` + RuntimeCache string `mapstructure:"runtime_cache"` +} + +// LoginProfile is a re-usable bundle of proot login options. Boolean +// fields are pointers so we can distinguish "user said false" from "user +// didn't say anything"; nil means inherit from Defaults / Options default. +type LoginProfile struct { + User string `mapstructure:"user"` + Binds []string `mapstructure:"binds"` + Command []string `mapstructure:"command"` + RunScript string `mapstructure:"run_script"` + Isolated *bool `mapstructure:"isolated"` + Link2Symlink *bool `mapstructure:"link2symlink"` + SysVIPC *bool `mapstructure:"sysvipc"` + KillOnExit *bool `mapstructure:"kill_on_exit"` + FakeRootID *bool `mapstructure:"fake_root_id"` + CapLastCapFix *bool `mapstructure:"cap_last_cap_fix"` + SharedTmp *bool `mapstructure:"shared_tmp"` + FixLowPorts *bool `mapstructure:"fix_low_ports"` + AshmemMemfd *bool `mapstructure:"ashmem_memfd"` + PulseServer *bool `mapstructure:"pulse_server"` +} + +// Load reads config from the canonical locations and returns a populated +// Config. Missing config files are not an error — the defaults kick in. +func Load(explicitPath string) (*Config, error) { + v := viper.New() + v.SetConfigType("yaml") + v.SetEnvPrefix("UDROID") + v.AutomaticEnv() + v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) + + // defaults + v.SetDefault("manifest_url", "https://raw.githubusercontent.com/RandomCoderOrg/udroid-download/main/distro-data.json") + + switch { + case explicitPath != "": + v.SetConfigFile(explicitPath) + default: + if p := os.Getenv("UDROID_CONFIG"); p != "" { + v.SetConfigFile(p) + } else { + v.SetConfigName("config") + for _, dir := range configDirs() { + v.AddConfigPath(dir) + } + } + } + + if err := v.ReadInConfig(); err != nil { + // Missing file is fine — only fail on parse errors. + if _, ok := err.(viper.ConfigFileNotFoundError); !ok { + if !os.IsNotExist(err) { + return nil, fmt.Errorf("read config: %w", err) + } + } + } + + var c Config + if err := v.Unmarshal(&c); err != nil { + return nil, fmt.Errorf("decode config: %w", err) + } + return &c, nil +} + +// configDirs returns the search path in priority order. +func configDirs() []string { + var dirs []string + if x := os.Getenv("XDG_CONFIG_HOME"); x != "" { + dirs = append(dirs, filepath.Join(x, "udroid")) + } + if h, err := os.UserHomeDir(); err == nil { + dirs = append(dirs, filepath.Join(h, ".config", "udroid")) + } + dirs = append(dirs, "/etc/udroid") + return dirs +} + +// Profile returns the named profile or false if it doesn't exist. +func (c *Config) Profile(name string) (LoginProfile, bool) { + if c.Profiles == nil { + return LoginProfile{}, false + } + p, ok := c.Profiles[name] + return p, ok +} + +// BoolDeref returns *p when non-nil and fallback otherwise — used when +// merging profile bools into Options. +func BoolDeref(p *bool, fallback bool) bool { + if p == nil { + return fallback + } + return *p +} diff --git a/go-udroid/internal/manifest/fetch.go b/go-udroid/internal/manifest/fetch.go new file mode 100644 index 0000000..dbccd83 --- /dev/null +++ b/go-udroid/internal/manifest/fetch.go @@ -0,0 +1,102 @@ +package manifest + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "time" +) + +// DefaultURL is the upstream distro catalogue maintained by RandomCoderOrg. +const DefaultURL = "https://raw.githubusercontent.com/RandomCoderOrg/udroid-download/main/distro-data.json" + +// Fetcher caches the catalogue at CachePath and refreshes it from URL when +// asked. Strict mode treats a network failure as fatal; non-strict mode +// silently falls back to the previously cached copy. +type Fetcher struct { + URL string + CachePath string + Client *http.Client +} + +// NewFetcher returns a fetcher rooted at the runtime cache dir. +func NewFetcher(runtimeCacheDir string) *Fetcher { + return &Fetcher{ + URL: DefaultURL, + CachePath: filepath.Join(runtimeCacheDir, "distro-data.json.cache"), + Client: &http.Client{Timeout: 30 * time.Second}, + } +} + +// Mode picks between fetching afresh and trusting the on-disk copy. +type Mode int + +const ( + ModeOnline Mode = iota // refresh from remote; fall back to cache on failure unless strict + ModeOffline // never touch the network +) + +// Load returns a parsed manifest, refreshing the cache file according to mode. +// +// Special case: if mode is ModeOffline but no cache exists, we still hit +// the network for a one-shot fetch — bash behaviour. Otherwise commands +// like `login` and `remove` would fail on a fresh install before the user +// ever ran `install` or `cache update`. +func (f *Fetcher) Load(ctx context.Context, mode Mode, strict bool) (*Manifest, error) { + if err := os.MkdirAll(filepath.Dir(f.CachePath), 0o755); err != nil { + return nil, err + } + _, statErr := os.Stat(f.CachePath) + cached := statErr == nil + + switch { + case mode == ModeOnline: + if err := f.refresh(ctx); err != nil { + if strict || !cached { + return nil, err + } + // silently fall back to cache + } + case !cached: + // offline + no cache — fall through to a single fetch. + if err := f.refresh(ctx); err != nil { + return nil, fmt.Errorf("no cached manifest and fetch failed: %w", err) + } + } + return Load(f.CachePath) +} + +// refresh downloads into a temp file then renames atomically so a failed +// download never replaces a working cache. +func (f *Fetcher) refresh(ctx context.Context) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, f.URL, nil) + if err != nil { + return err + } + resp, err := f.Client.Do(req) + if err != nil { + return fmt.Errorf("fetch manifest: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("fetch manifest: HTTP %s", resp.Status) + } + tmp, err := os.CreateTemp(filepath.Dir(f.CachePath), "manifest-*.json.tmp") + if err != nil { + return err + } + tmpName := tmp.Name() + if _, err := io.Copy(tmp, resp.Body); err != nil { + tmp.Close() + os.Remove(tmpName) + return err + } + if err := tmp.Close(); err != nil { + os.Remove(tmpName) + return err + } + return os.Rename(tmpName, f.CachePath) +} diff --git a/go-udroid/internal/manifest/manifest.go b/go-udroid/internal/manifest/manifest.go new file mode 100644 index 0000000..b005c44 --- /dev/null +++ b/go-udroid/internal/manifest/manifest.go @@ -0,0 +1,128 @@ +// Package manifest loads, parses, and queries the distro-data.json catalogue +// that drives installs. The bash version stores arch-specific URLs and +// checksums as top-level keys ("aarch64url", "aarch64sha", ...) inside each +// variant; we keep that wire format verbatim so existing caches and the +// upstream remote stay compatible. +// +// Note: the upstream manifest spells "variants" as "varients". We preserve +// that spelling rather than fix it — users' cached JSON would otherwise stop +// resolving. +package manifest + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/RandomCoderOrg/fs-manager-udroid/go-udroid/internal/termux" +) + +// Manifest is the parsed in-memory view of distro-data.json. It keeps the +// raw decoded tree because variant entries hold dynamic arch-keyed fields +// that don't map cleanly onto a static Go struct. +type Manifest struct { + Suites []string `json:"suites"` + raw map[string]json.RawMessage +} + +// Load parses distro-data.json from disk. +func Load(path string) (*Manifest, error) { + b, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read manifest: %w", err) + } + return Parse(b) +} + +// Parse decodes manifest bytes. +func Parse(b []byte) (*Manifest, error) { + var raw map[string]json.RawMessage + if err := json.Unmarshal(b, &raw); err != nil { + return nil, fmt.Errorf("decode manifest: %w", err) + } + var head struct { + Suites []string `json:"suites"` + } + if err := json.Unmarshal(b, &head); err != nil { + return nil, fmt.Errorf("decode suites: %w", err) + } + return &Manifest{Suites: head.Suites, raw: raw}, nil +} + +// Suite describes the inner shape of one suite ({"varients": [...], : {...}}). +type Suite struct { + Variants []string `json:"varients"` + raw map[string]json.RawMessage +} + +// Suite returns the suite section by name. +func (m *Manifest) Suite(name string) (*Suite, error) { + r, ok := m.raw[name] + if !ok { + return nil, fmt.Errorf("suite %q not found", name) + } + var inner map[string]json.RawMessage + if err := json.Unmarshal(r, &inner); err != nil { + return nil, fmt.Errorf("decode suite %q: %w", name, err) + } + var head struct { + Variants []string `json:"varients"` + } + if err := json.Unmarshal(r, &head); err != nil { + return nil, fmt.Errorf("decode suite %q variants: %w", name, err) + } + return &Suite{Variants: head.Variants, raw: inner}, nil +} + +// Variant is the resolved entry for a specific suite:variant pair on a +// specific architecture. SHASum may be empty when the upstream omits it. +type Variant struct { + Suite string + Variant string + Name string `json:"Name"` + FriendlyName string `json:"FirendlyName"` // typo preserved from upstream + URL string + SHASum string + SupportedArchs []string `json:"arch"` +} + +// Variant resolves suite:variant on the given arch. +func (m *Manifest) Variant(suite, variant string, arch termux.Arch) (*Variant, error) { + s, err := m.Suite(suite) + if err != nil { + return nil, err + } + r, ok := s.raw[variant] + if !ok { + return nil, fmt.Errorf("variant %q not found in suite %q", variant, suite) + } + // Decode static fields, then pull arch-keyed url/sha by string lookup. + var v Variant + if err := json.Unmarshal(r, &v); err != nil { + return nil, fmt.Errorf("decode variant %q: %w", variant, err) + } + v.Suite = suite + v.Variant = variant + + var dyn map[string]any + if err := json.Unmarshal(r, &dyn); err != nil { + return nil, fmt.Errorf("decode variant %q dyn: %w", variant, err) + } + if url, _ := dyn[string(arch)+"url"].(string); url != "" { + v.URL = url + } + if sum, _ := dyn[string(arch)+"sha"].(string); sum != "" { + v.SHASum = sum + } + return &v, nil +} + +// HasSuite returns true when the suite is listed. +func (m *Manifest) HasSuite(name string) bool { + for _, s := range m.Suites { + if s == name { + return true + } + } + return false +} diff --git a/go-udroid/internal/manifest/manifest_test.go b/go-udroid/internal/manifest/manifest_test.go new file mode 100644 index 0000000..4ffb4da --- /dev/null +++ b/go-udroid/internal/manifest/manifest_test.go @@ -0,0 +1,69 @@ +package manifest + +import ( + "os" + "strings" + "testing" + + "github.com/RandomCoderOrg/fs-manager-udroid/go-udroid/internal/termux" +) + +func TestParseAgainstExistingFixture(t *testing.T) { + // The bash codebase ships udroid/src/test.json — reuse it to confirm + // schema compatibility (including the "varients" misspelling we + // deliberately preserve). + for _, candidate := range []string{ + "../../../udroid/src/test.json", + } { + b, err := os.ReadFile(candidate) + if err != nil { + continue + } + m, err := Parse(b) + if err != nil { + t.Fatalf("parse: %v", err) + } + if !m.HasSuite("jammy") { + t.Fatalf("expected jammy suite in %v", m.Suites) + } + v, err := m.Variant("jammy", "raw", termux.ArchAArch64) + if err != nil { + t.Fatalf("variant lookup: %v", err) + } + if !strings.HasPrefix(v.URL, "https://") { + t.Errorf("expected aarch64url, got %q", v.URL) + } + if v.SHASum == "" { + t.Errorf("expected aarch64sha, got empty") + } + if v.Name != "udroid-jammy-raw" { + t.Errorf("expected canonical Name, got %q", v.Name) + } + return + } + t.Skip("test.json fixture not found") +} + +func TestParseRef(t *testing.T) { + cases := []struct { + in string + wantS string + wantV string + wantErr bool + }{ + {"jammy:raw", "jammy", "raw", false}, + {"jammy", "jammy", "", false}, + {":xfce4", "", "xfce4", false}, + {"", "", "", false}, + {"x:x", "x", "x", true}, + } + for _, c := range cases { + r, err := ParseRef(c.in) + if (err != nil) != c.wantErr { + t.Errorf("ParseRef(%q) err = %v, wantErr %v", c.in, err, c.wantErr) + } + if r.Suite != c.wantS || r.Variant != c.wantV { + t.Errorf("ParseRef(%q) = {%q,%q}, want {%q,%q}", c.in, r.Suite, r.Variant, c.wantS, c.wantV) + } + } +} diff --git a/go-udroid/internal/manifest/ref.go b/go-udroid/internal/manifest/ref.go new file mode 100644 index 0000000..075160d --- /dev/null +++ b/go-udroid/internal/manifest/ref.go @@ -0,0 +1,40 @@ +package manifest + +import ( + "fmt" + "strings" +) + +// Ref is a parsed "suite:variant" reference. Either side may be empty when +// the user typed a partial reference like "jammy" or ":xfce4" — callers can +// then prompt the user to fill in the missing half. +type Ref struct { + Suite string + Variant string +} + +// ParseRef accepts "suite:variant", "suite", or ":variant". An empty string +// returns an empty Ref without error so callers can decide whether that +// is a fatal condition. +func ParseRef(s string) (Ref, error) { + if s == "" { + return Ref{}, nil + } + parts := strings.SplitN(s, ":", 2) + r := Ref{Suite: parts[0]} + if len(parts) == 2 { + r.Variant = parts[1] + } + if r.Suite != "" && r.Variant != "" && r.Suite == r.Variant { + return r, fmt.Errorf("suite and variant cannot be identical (%q)", s) + } + return r, nil +} + +// String renders the canonical "suite:variant" form. +func (r Ref) String() string { + return r.Suite + ":" + r.Variant +} + +// Complete returns true when both halves are set. +func (r Ref) Complete() bool { return r.Suite != "" && r.Variant != "" } diff --git a/go-udroid/internal/proot/args.go b/go-udroid/internal/proot/args.go new file mode 100644 index 0000000..5d14f33 --- /dev/null +++ b/go-udroid/internal/proot/args.go @@ -0,0 +1,195 @@ +package proot + +import ( + "os" + "path/filepath" +) + +// BuildArgs is a pure transform from typed Options to the argv that gets +// handed to exec.Command("proot", ...). +// +// Order of args matters to proot in some cases (notably the trailing +// program + its argv comes last); see comments at each section. +// +// Determinism: same Options always produces the same argv. No env reads, +// no time, no random ordering, which keeps the output testable. +func BuildArgs(o Options) []string { + var a []string + + // --- session-scoped flags -------------------------------------------------- + if o.FixLowPorts { + a = append(a, "-p") + } + if o.AshmemMemfd { + a = append(a, "--ashmem-memfd") + } + if o.KillOnExit { + a = append(a, "--kill-on-exit") + } + if o.Link2Symlink { + a = append(a, "--link2symlink") + } + if o.SysVIPC { + a = append(a, "--sysvipc") + } + if o.FakeRootID { + a = append(a, "--root-id") + } + if o.KernelRelease != "" { + a = append(a, "--kernel-release="+o.KernelRelease) + } + if o.FollowSymlinks { + a = append(a, "-L") + } + if o.CWD != "" { + a = append(a, "--cwd="+o.CWD) + } + + // --- core mounts ----------------------------------------------------------- + if o.CoreMounts { + a = append(a, + "--bind=/dev", + "--bind=/dev/urandom:/dev/random", + "--bind=/proc", + "--bind=/proc/self/fd:/dev/fd", + "--bind=/proc/self/fd/0:/dev/stdin", + "--bind=/proc/self/fd/1:/dev/stdout", + "--bind=/proc/self/fd/2:/dev/stderr", + "--bind=/sys", + ) + } + + if o.CapLastCapFix { + a = append(a, "--bind=/dev/null:/proc/sys/kernel/cap_last_cap") + } + + if o.SharedTmp && o.HostPrefix != "" { + a = append(a, + "--bind="+o.HostPrefix+"/tmp:/tmp", + "--bind="+o.RootFS+"/dev/shm:/dev/shm", + ) + } else { + a = append(a, "--bind="+o.RootFS+"/tmp:/dev/shm") + } + + // --- fake /proc/* (used when the host blocks reading those files) --------- + if o.FakeProcFiles { + for _, rel := range []string{"loadavg", "stat", "uptime", "version", "vmstat"} { + a = append(a, "--bind="+filepath.Join(o.RootFS, "proc", "."+rel)+":/proc/"+rel) + } + } + + // --- user binds (after core so they can override) ------------------------ + for _, b := range o.Binds { + a = append(a, b.String()) + } + + // --- termux + android paths ----------------------------------------------- + if o.TermuxMounts && !o.Isolated { + pkg := o.AndroidPackage + if pkg == "" { + pkg = "com.termux" + } + a = append(a, + "--bind=/data/dalvik-cache", + "--bind=/data/data/"+pkg+"/cache", + ) + if _, err := os.Stat("/data/data/" + pkg + "/files/apps"); err == nil { + a = append(a, "--bind=/data/data/"+pkg+"/files/apps") + } + if o.HostHome != "" { + a = append(a, "--bind="+o.HostHome) + } + // shared storage probes — pick the first that resolves + for _, candidate := range []struct{ src, dst string }{ + {"/storage/self/primary", "/sdcard"}, + {"/storage/emulated/0", "/sdcard"}, + {"/sdcard", "/sdcard"}, + } { + if _, err := os.Stat(candidate.src); err == nil { + a = append(a, "--bind="+candidate.src+":"+candidate.dst) + break + } + } + if _, err := os.Stat("/storage"); err == nil { + a = append(a, "--bind=/storage") + } + if _, err := os.Stat("/apex"); err == nil { + a = append(a, "--bind=/apex") + } + if _, err := os.Stat("/linkerconfig/ld.config.txt"); err == nil { + a = append(a, "--bind=/linkerconfig/ld.config.txt") + } + if o.HostPrefix != "" { + a = append(a, "--bind="+o.HostPrefix) + } + if _, err := os.Stat("/system"); err == nil { + a = append(a, "--bind=/system") + } + if _, err := os.Stat("/vendor"); err == nil { + a = append(a, "--bind=/vendor") + } + for _, f := range []string{"/plat_property_contexts", "/property_contexts"} { + if _, err := os.Stat(f); err == nil { + a = append(a, "--bind="+f) + } + } + } + + // --- rootfs (must come after binds) -------------------------------------- + a = append(a, "--rootfs="+o.RootFS) + + // --- shell launcher ------------------------------------------------------- + a = append(a, buildLauncher(o)...) + return a +} + +// buildLauncher returns the trailing `/usr/bin/env -i HOME=... su -l user -c "..."` +// piece. Split out so unit tests can exercise it without the bind stew above. +func buildLauncher(o Options) []string { + term := os.Getenv("TERM") + if term == "" { + term = "xterm-256color" + } + user := o.LoginUser + if user == "" { + user = "root" + } + env := []string{"/usr/bin/env", "-i", "HOME=/root", "LANG=C.UTF-8", "TERM=" + term} + + // run-script overrides Command + if o.RunScript != "" { + return append(env, "/bin/su", "-l", user, "-c", "/"+filepath.Base(o.RunScript)) + } + if len(o.Command) > 0 { + joined := shellJoin(o.Command) + return append(env, "/bin/su", "-l", user, "-c", joined) + } + return append(env, "/bin/su", "-l", user) +} + +// shellJoin quotes each token so the resulting string is safe to hand to +// `su -c`. Conservative — wraps every token in single quotes and escapes +// embedded single quotes. +func shellJoin(parts []string) string { + out := "" + for i, p := range parts { + if i > 0 { + out += " " + } + out += "'" + escapeSingleQuote(p) + "'" + } + return out +} + +func escapeSingleQuote(s string) string { + out := make([]byte, 0, len(s)) + for i := 0; i < len(s); i++ { + if s[i] == '\'' { + out = append(out, '\'', '\\', '\'', '\'') + continue + } + out = append(out, s[i]) + } + return string(out) +} diff --git a/go-udroid/internal/proot/args_test.go b/go-udroid/internal/proot/args_test.go new file mode 100644 index 0000000..3dc95d3 --- /dev/null +++ b/go-udroid/internal/proot/args_test.go @@ -0,0 +1,95 @@ +package proot + +import ( + "strings" + "testing" +) + +// TestBuildArgs_BaseFlagsPresent locks in that the defaults emit the +// session-scoped flags every login depends on (kill-on-exit, link2symlink, +// sysvipc, root-id, fake kernel release). +func TestBuildArgs_BaseFlagsPresent(t *testing.T) { + o := DefaultOptions("/tmp/does-not-matter-for-args") + o.HostPrefix = "/data/data/com.termux/files/usr" + o.HostHome = "/data/data/com.termux/files/home" + got := strings.Join(BuildArgs(o), " ") + for _, want := range []string{ + "--kill-on-exit", + "--link2symlink", + "--sysvipc", + "--root-id", + "--kernel-release=5.4.2-proot-facked", + "-L", + "--rootfs=/tmp/does-not-matter-for-args", + } { + if !strings.Contains(got, want) { + t.Errorf("expected flag %q in argv, got:\n%s", want, got) + } + } +} + +// TestBuildArgs_NoFlagsDisableFeatures ensures the --no-* toggles in the +// CLI drop the corresponding proot flag entirely (rather than emitting +// some "--no-link2symlink" pseudo-flag, which proot wouldn't understand). +func TestBuildArgs_NoFlagsDisableFeatures(t *testing.T) { + o := DefaultOptions("/x") + o.Link2Symlink = false + o.SysVIPC = false + o.KillOnExit = false + got := strings.Join(BuildArgs(o), " ") + for _, banned := range []string{"--link2symlink", "--sysvipc", "--kill-on-exit"} { + if strings.Contains(got, banned) { + t.Errorf("flag %q should be absent, got:\n%s", banned, got) + } + } +} + +// TestBuildArgs_RootfsLast verifies that --rootfs precedes the launcher +// but follows the user binds; proot processes mounts in order so +// reordering can break overlay semantics. +func TestBuildArgs_RootfsLast(t *testing.T) { + o := DefaultOptions("/x") + o.Binds = []Bind{{Source: "/host/data", Target: "/data"}} + args := BuildArgs(o) + rootfsIdx, bindIdx, launcherIdx := -1, -1, -1 + for i, a := range args { + if a == "--rootfs=/x" { + rootfsIdx = i + } + if a == "--bind=/host/data:/data" { + bindIdx = i + } + if a == "/usr/bin/env" { + launcherIdx = i + } + } + if rootfsIdx < 0 || bindIdx < 0 || launcherIdx < 0 { + t.Fatalf("missing markers: rootfs=%d bind=%d launcher=%d in %v", rootfsIdx, bindIdx, launcherIdx, args) + } + if !(bindIdx < rootfsIdx && rootfsIdx < launcherIdx) { + t.Errorf("expected bind(%d) < rootfs(%d) < launcher(%d)", bindIdx, rootfsIdx, launcherIdx) + } +} + +// TestBuildArgs_CommandIsQuoted ensures embedded single-quotes in a +// passthrough command don't escape the shell wrapper. +func TestBuildArgs_CommandIsQuoted(t *testing.T) { + o := DefaultOptions("/x") + o.Command = []string{"echo", "it's fine"} + args := BuildArgs(o) + last := args[len(args)-1] + if !strings.Contains(last, `'echo' 'it'\''s fine'`) { + t.Errorf("expected escaped command, got %q", last) + } +} + +func TestBuildArgs_IsolatedSkipsTermuxBinds(t *testing.T) { + o := DefaultOptions("/x") + o.HostPrefix = "/usr" + o.HostHome = "/home/u" + o.Isolated = true + got := strings.Join(BuildArgs(o), " ") + if strings.Contains(got, "/home/u") { + t.Errorf("isolated should not mount HostHome, got:\n%s", got) + } +} diff --git a/go-udroid/internal/proot/exec.go b/go-udroid/internal/proot/exec.go new file mode 100644 index 0000000..dbcceeb --- /dev/null +++ b/go-udroid/internal/proot/exec.go @@ -0,0 +1,121 @@ +package proot + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" +) + +// Binary is the name (or absolute path) of the proot executable. Override +// this at startup if proot is staged somewhere unusual. +var Binary = "proot" + +// Login executes proot with the args derived from o, wiring stdin/stdout +// directly to the calling process so the user's terminal works correctly. +// On unix-like systems this performs an exec(2) replacement so proot +// becomes the foreground process — the Go binary never returns. +func Login(o Options) error { + if err := o.Validate(); err != nil { + return err + } + if o.RunScript != "" { + if err := stageRunScript(o.RootFS, o.RunScript); err != nil { + return err + } + } + if o.PulseServer { + startPulseAudio() + } + args := append([]string{Binary}, BuildArgs(o)...) + bin, err := exec.LookPath(Binary) + if err != nil { + return fmt.Errorf("proot not found in PATH: %w", err) + } + // Use exec(2) replacement to drop the Go process — proot becomes pid. + return syscall.Exec(bin, args, os.Environ()) +} + +// startPulseAudio kicks off the host's pulseaudio daemon with TCP loopback +// enabled so audio inside the container reaches Android's mixer. Best +// effort — failures (missing binary, already running, etc.) are silent so +// non-audio installs aren't bothered. +func startPulseAudio() { + bin, err := exec.LookPath("pulseaudio") + if err != nil { + return + } + _ = exec.Command(bin, + "--start", + `--load=module-native-protocol-tcp auth-ip-acl=127.0.0.1 auth-anonymous=1`, + "--exit-idle-time=-1", + ).Run() +} + +// stageRunScript copies a host script into the rootfs root so the inner +// `su -c /