diff --git a/go-udroid/README.md b/go-udroid/README.md new file mode 100644 index 0000000..cd35909 --- /dev/null +++ b/go-udroid/README.md @@ -0,0 +1,221 @@ +# go-udroid + +A Go port of [fs-manager-udroid](../README.md) — a proot wrapper that installs +Linux rootfs tarballs as containers on Termux/Android. + +The core packages (`internal/proot`, `internal/manifest`, `internal/rootfs`) +are independent of the CLI so a Bubble Tea TUI can reuse them later without +changes. + +## Build + +Quickest path on Termux: run the install script. It checks for `go`, +`proot`, and `tar`, offers to install whichever are missing, builds a +static binary, and drops it as `udroid-go` so the bash `udroid` can stay +in place. + +```bash +cd go-udroid +./install.sh # interactive; installs to $PREFIX/bin/udroid-go +./install.sh -y # non-interactive +./install.sh --no-install # build-only, leaves ./udroid-go in cwd +./install.sh --prefix=/opt/udroid # install elsewhere +./install.sh --bin-name=udroid # override the binary name +./install.sh --skip-deps # caller already has deps on PATH +``` + +Manual build, if you'd rather not run a script: + +```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 covers everything the bash version offered plus a small set +of docker-shaped verbs (`pull`, `exec`, `images`, `inspect`, `info`, +`search`, `rmi`) for users coming from that ecosystem. Run `udroid help` for +the full list. + +### Browse + +```bash +udroid list # installed + available +udroid list --size # include on-disk size +udroid list --installed # only installed +udroid images # alias for `list` +udroid search jammy # substring match on suite/variant/friendly name +udroid info # paths, manifest URL, install/cache totals +udroid info --json # same data, machine-readable +udroid inspect ubuntu-jammy # JSON: size, mtime, applied fixes, manifest match +``` + +### Install / cache lifecycle + +```bash +udroid pull jammy:raw # download tarball into cache, no install +udroid install jammy:raw # download + extract + apply fixes +udroid install --file ./my.tar.xz --name x # install a local tarball as "custom-x" +udroid remove jammy:raw # uninstall +udroid reset jammy:raw # remove + reinstall +udroid rmi jammy:raw # drop a single cached tarball +udroid cache update # refresh distro manifest +udroid cache clear # drop all cached tarballs +``` + +### Run + +```bash +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 via `--` +udroid login --custom my-rootfs # log into a custom install +udroid login --dry-run jammy:raw # print proot argv and exit + +udroid exec ubuntu-jammy ls -la /tmp # one-shot, no `--` needed +udroid exec -u alice ubuntu-jammy env # run as a specific user +``` + +**`exec` flag handling:** flags for udroid (e.g. `-u`) must come **before** +the rootfs name. Everything after the name — including dash-prefixed tokens +like `-la` or `--foo` — is forwarded verbatim to the inner command. Matches +`docker exec` behaviour. + +## 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 + +### Logging + +Diagnostic events are written to `$TMPDIR/udroid.log` (configurable). The +log is structured via `log/slog`; pick `text` or `json` formatting. + +| flag | config key | default | +|---|---|---| +| `--log-level` | `log.level` | `info` | +| `--log-file` | `log.file` | `$TMPDIR/udroid.log` | +| `--log-format`| `log.format` | `text` | +| `--verbose`/`-v` | — | mirror log output to stderr | + +Set `--log-level=debug --verbose` while diagnosing an issue to see every +event on stderr in real time. + +### 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`. + +**Full profile schema.** Every key below is optional. Boolean fields are +pointers internally so omitting them means "inherit"; setting them to +`true` or `false` is what flips the toggle. Strings/lists fall back to the +zero value when absent. + +| Field | Type | Effect when set | +|------------------|----------------|-----------------| +| `user` | string | login user inside the rootfs (default `root`) | +| `binds` | list of string | extra `--bind` entries; each is `src` or `src:dst` | +| `command` | list of string | run this once instead of an interactive shell | +| `run_script` | string | host-side script copied into rootfs and exec'd | +| `isolated` | bool | skip termux/storage/host-cwd mounts | +| `link2symlink` | bool | proot `--link2symlink` (default `true`) | +| `sysvipc` | bool | proot `--sysvipc` (default `true`) | +| `kill_on_exit` | bool | proot `--kill-on-exit` (default `true`) | +| `fake_root_id` | bool | proot `--root-id` (default `true`) | +| `cap_last_cap_fix` | bool | bind-mask `/proc/sys/kernel/cap_last_cap` (default `true`) | +| `shared_tmp` | bool | bind termux `$PREFIX/tmp` to `/tmp` (default `true`) | +| `fix_low_ports` | bool | proot `-p`, allow ports < 1024 | +| `ashmem_memfd` | bool | proot `--ashmem-memfd` (experimental) | +| `pulse_server` | bool | start host pulseaudio with TCP loopback (default `true`) | + +The same schema applies to the top-level `defaults:` block — it is just a +profile that always runs. + +## 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/exec.go b/go-udroid/cmd/udroid/exec.go new file mode 100644 index 0000000..32f2089 --- /dev/null +++ b/go-udroid/cmd/udroid/exec.go @@ -0,0 +1,93 @@ +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + + "github.com/RandomCoderOrg/fs-manager-udroid/go-udroid/internal/manifest" + "github.com/RandomCoderOrg/fs-manager-udroid/go-udroid/internal/proot" +) + +// newExecCmd runs a one-shot command inside an installed rootfs. It is +// the docker-shaped form of `login -- ` — same machinery, no +// flag surface, fewer keystrokes. +// +// Flag handling: SetInterspersed(false) stops flag parsing as soon as the +// first positional () is seen, so anything after that — including +// dash-prefixed tokens like `-la` or `--foo` — is forwarded verbatim to +// the inner command. Matches `docker exec` UX; `udroid exec -u user name +// ls -la /tmp` works without a `--` separator. +func newExecCmd(a *app) *cobra.Command { + var loginUser string + cmd := &cobra.Command{ + Use: "exec [flags] [args...]", + Short: "run a command inside an installed rootfs", + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) < 2 { + return fmt.Errorf("exec: and are required") + } + name, command := args[0], args[1:] + distroName, err := resolveExecTarget(a, name) + 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) + } + opts := buildExecOptions(a, rootFS, command, loginUser) + return proot.Login(opts) + }, + } + cmd.Flags().StringVarP(&loginUser, "user", "u", "", "user inside the rootfs (default root)") + cmd.Flags().SetInterspersed(false) + return cmd +} + +// resolveExecTarget accepts either an installed name (e.g. "ubuntu-jammy") +// or a manifest ref ("ubuntu:jammy"). Refs are looked up against the +// offline manifest so exec stays usable without network. +func resolveExecTarget(a *app, raw string) (string, error) { + if !strings.Contains(raw, ":") { + return raw, nil + } + ref, err := manifest.ParseRef(raw) + if err != nil { + return "", err + } + mf, err := loadManifest(context.Background(), a, manifest.ModeOffline, false) + 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 in manifest", ref) + } + return v.Name, nil +} + +// buildExecOptions wires the same defaults+config layering login uses, +// then pre-fills Command so proot runs a one-shot and exits. +func buildExecOptions(a *app, rootFS string, command []string, loginUser string) proot.Options { + opts := proot.DefaultOptions(rootFS) + opts.HostPrefix = a.paths.Prefix + opts.HostHome = a.paths.Home + opts.AndroidPackage = a.paths.Package + if a.cfg != nil { + applyProfile(&opts, a.cfg.Defaults) + } + opts.Binds = append(opts.Binds, readPerFSMounts(rootFS)...) + if loginUser != "" { + opts.LoginUser = loginUser + } + opts.Command = command + return opts +} diff --git a/go-udroid/cmd/udroid/exec_passthrough_test.go b/go-udroid/cmd/udroid/exec_passthrough_test.go new file mode 100644 index 0000000..2a609ad --- /dev/null +++ b/go-udroid/cmd/udroid/exec_passthrough_test.go @@ -0,0 +1,53 @@ +package main + +import ( + "bytes" + "errors" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +// TestExecForwardsDashFlags pins the SetInterspersed(false) contract: +// dash-prefixed tokens after the rootfs name must reach RunE intact rather +// than being parsed as flags on `exec` itself. The test runs only the +// arg-parsing layer — no proot, no filesystem — by replacing RunE with a +// captor. +func TestExecForwardsDashFlags(t *testing.T) { + var captured []string + cmd := &cobra.Command{ + Use: "exec [flags] [args...]", + RunE: func(_ *cobra.Command, args []string) error { + captured = args + return errors.New("stop") // bail out so cobra doesn't run anything + }, + } + var user string + cmd.Flags().StringVarP(&user, "user", "u", "", "user") + cmd.Flags().SetInterspersed(false) + + root := &cobra.Command{Use: "udroid"} + root.AddCommand(cmd) + root.SetArgs([]string{"exec", "ubuntu-jammy", "ls", "-la", "/tmp"}) + root.SetOut(&bytes.Buffer{}) + root.SetErr(&bytes.Buffer{}) + _ = root.Execute() + + want := []string{"ubuntu-jammy", "ls", "-la", "/tmp"} + if strings.Join(captured, "|") != strings.Join(want, "|") { + t.Fatalf("dash flags swallowed: got %q, want %q", captured, want) + } + + // And the -u flag still works when placed before the positionals. + user = "" + captured = nil + root.SetArgs([]string{"exec", "-u", "alice", "ubuntu-jammy", "env"}) + _ = root.Execute() + if user != "alice" { + t.Fatalf("--user not parsed: got %q", user) + } + if strings.Join(captured, "|") != "ubuntu-jammy|env" { + t.Fatalf("positional capture wrong: got %q", captured) + } +} 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/info.go b/go-udroid/cmd/udroid/info.go new file mode 100644 index 0000000..d95c088 --- /dev/null +++ b/go-udroid/cmd/udroid/info.go @@ -0,0 +1,209 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/spf13/cobra" + + "github.com/RandomCoderOrg/fs-manager-udroid/go-udroid/internal/manifest" + "github.com/RandomCoderOrg/fs-manager-udroid/go-udroid/internal/rootfs" +) + +// Version is the udroid binary version. Set at build time via +// go build -ldflags "-X main.Version=v0.1.0" +// Leaving it as "dev" when unset surfaces the dev build clearly. +var Version = "dev" + +// infoReport is the structured form of `info`. Same shape JSON and text +// reads from, so adding a field updates both views. +type infoReport struct { + Version string `json:"version"` + Runtime runtimeInfo `json:"runtime"` + Arch string `json:"arch"` + Package string `json:"android_package"` + Paths pathsInfo `json:"paths"` + Manifest manifestInfo `json:"manifest"` + Log logInfo `json:"log"` + Installs countSize `json:"installs"` + Cache countSize `json:"cache"` +} + +type runtimeInfo struct { + GoVersion string `json:"go_version"` + OS string `json:"os"` + GoArch string `json:"go_arch"` +} + +type pathsInfo struct { + Prefix string `json:"prefix"` + Home string `json:"home"` + InstalledFsDir string `json:"installed_fs_dir"` + DownloadCache string `json:"download_cache"` + RuntimeCache string `json:"runtime_cache"` +} + +type manifestInfo struct { + URL string `json:"url"` + CachePath string `json:"cache_path"` + Cached bool `json:"cached"` +} + +type logInfo struct { + Level string `json:"level"` + File string `json:"file"` + Format string `json:"format"` +} + +type countSize struct { + Count int `json:"count"` + SizeBytes int64 `json:"size_bytes"` + SizeHuman string `json:"size_human"` +} + +// newInfoCmd dumps the runtime state. Default output is human-readable; `--json` +// emits the same data as JSON for scripting. +func newInfoCmd(a *app) *cobra.Command { + var asJSON bool + cmd := &cobra.Command{ + Use: "info", + Short: "show runtime configuration and disk usage", + RunE: func(cmd *cobra.Command, args []string) error { + r := gatherInfo(a) + if asJSON { + b, err := json.MarshalIndent(r, "", " ") + if err != nil { + return err + } + fmt.Fprintln(a.ui.Out(), string(b)) + return nil + } + printInfo(a, r) + return nil + }, + } + cmd.Flags().BoolVar(&asJSON, "json", false, "emit info as JSON") + return cmd +} + +// gatherInfo populates an infoReport from the app singletons + disk probes. +func gatherInfo(a *app) infoReport { + mfCache := filepath.Join(a.paths.RuntimeCache, "distro-data.json.cache") + _, mfErr := os.Stat(mfCache) + manifestURL := manifest.DefaultURL + if a.cfg != nil && a.cfg.ManifestURL != "" { + manifestURL = a.cfg.ManifestURL + } + return infoReport{ + Version: Version, + Runtime: runtimeInfo{ + GoVersion: runtime.Version(), + OS: runtime.GOOS, + GoArch: runtime.GOARCH, + }, + Arch: string(a.arch), + Package: a.paths.Package, + Paths: pathsInfo{ + Prefix: a.paths.Prefix, + Home: a.paths.Home, + InstalledFsDir: a.paths.InstalledFsDir, + DownloadCache: a.paths.DownloadCache, + RuntimeCache: a.paths.RuntimeCache, + }, + Manifest: manifestInfo{ + URL: manifestURL, + CachePath: mfCache, + Cached: mfErr == nil, + }, + Log: gatherLogInfo(a), + Installs: dirCountSize(a.paths.InstalledFsDir, func(name string) bool { return true }), + Cache: dirCountSize(a.paths.DownloadCache, func(name string) bool { return strings.Contains(name, ".tar") }), + } +} + +// gatherLogInfo reads the effective log knobs from the config. CLI overrides +// aren't visible here because PersistentPreRunE collapses them into the +// logger directly without storing — we report the config-resolved values. +func gatherLogInfo(a *app) logInfo { + li := logInfo{Level: "info", Format: "text"} + if a.cfg != nil { + if a.cfg.Log.Level != "" { + li.Level = a.cfg.Log.Level + } + if a.cfg.Log.File != "" { + li.File = a.cfg.Log.File + } + if a.cfg.Log.Format != "" { + li.Format = a.cfg.Log.Format + } + } + if li.File == "" { + dir := os.Getenv("TMPDIR") + if dir == "" { + dir = "/tmp" + } + li.File = filepath.Join(dir, "udroid.log") + } + return li +} + +// dirCountSize counts entries in dir matching keep() and adds up their sizes. +// Empty/missing dirs return a zero report rather than an error so info stays +// useful on first-run installs. +func dirCountSize(dir string, keep func(name string) bool) countSize { + entries, err := os.ReadDir(dir) + if err != nil { + return countSize{} + } + var ( + count int + total int64 + ) + for _, e := range entries { + if !keep(e.Name()) { + continue + } + count++ + full := filepath.Join(dir, e.Name()) + if size, err := rootfs.Size(full); err == nil { + total += size + } + } + return countSize{Count: count, SizeBytes: total, SizeHuman: humanBytes(total)} +} + +// printInfo writes the human-friendly view. Two columns of "key: value" +// grouped by section; no fancy box-drawing so it stays grep-friendly. +func printInfo(a *app, r infoReport) { + out := a.ui.Out() + fmt.Fprintf(out, "udroid %s (%s, %s/%s)\n", + r.Version, r.Runtime.GoVersion, r.Runtime.OS, r.Runtime.GoArch) + fmt.Fprintln(out) + fmt.Fprintln(out, "Host") + fmt.Fprintf(out, " arch: %s\n", r.Arch) + fmt.Fprintf(out, " android package: %s\n", r.Package) + fmt.Fprintln(out) + fmt.Fprintln(out, "Paths") + fmt.Fprintf(out, " prefix: %s\n", r.Paths.Prefix) + fmt.Fprintf(out, " home: %s\n", r.Paths.Home) + fmt.Fprintf(out, " installed fs: %s\n", r.Paths.InstalledFsDir) + fmt.Fprintf(out, " download cache: %s\n", r.Paths.DownloadCache) + fmt.Fprintf(out, " runtime cache: %s\n", r.Paths.RuntimeCache) + fmt.Fprintln(out) + fmt.Fprintln(out, "Manifest") + fmt.Fprintf(out, " url: %s\n", r.Manifest.URL) + fmt.Fprintf(out, " cache: %s (cached=%t)\n", r.Manifest.CachePath, r.Manifest.Cached) + fmt.Fprintln(out) + fmt.Fprintln(out, "Logging") + fmt.Fprintf(out, " level: %s\n", r.Log.Level) + fmt.Fprintf(out, " file: %s\n", r.Log.File) + fmt.Fprintf(out, " format: %s\n", r.Log.Format) + fmt.Fprintln(out) + fmt.Fprintln(out, "Storage") + fmt.Fprintf(out, " installs: %d (%s)\n", r.Installs.Count, r.Installs.SizeHuman) + fmt.Fprintf(out, " cached tarballs: %d (%s)\n", r.Cache.Count, r.Cache.SizeHuman) +} diff --git a/go-udroid/cmd/udroid/inspect.go b/go-udroid/cmd/udroid/inspect.go new file mode 100644 index 0000000..93bbcc1 --- /dev/null +++ b/go-udroid/cmd/udroid/inspect.go @@ -0,0 +1,164 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/RandomCoderOrg/fs-manager-udroid/go-udroid/internal/manifest" + "github.com/RandomCoderOrg/fs-manager-udroid/go-udroid/internal/rootfs" +) + +// inspectReport is the JSON shape `inspect` emits, one entry per name. +type inspectReport struct { + Name string `json:"name"` + Path string `json:"path"` + Installed bool `json:"installed"` + SizeBytes int64 `json:"size_bytes,omitempty"` + SizeHuman string `json:"size_human,omitempty"` + InstalledAt string `json:"installed_at,omitempty"` + ManifestEntry *manifestSummary `json:"manifest_entry,omitempty"` + AppliedFixes map[string]bool `json:"applied_fixes,omitempty"` + PerFSMounts []string `json:"per_fs_mounts,omitempty"` + Custom bool `json:"custom,omitempty"` +} + +// manifestSummary is the manifest-side view embedded into inspectReport +// when we can match the install back to a known suite:variant pair. +type manifestSummary struct { + Suite string `json:"suite"` + Variant string `json:"variant"` + FriendlyName string `json:"friendly_name,omitempty"` + URL string `json:"url,omitempty"` + SHASum string `json:"sha256,omitempty"` + SupportedArchs []string `json:"supported_archs,omitempty"` +} + +// newInspectCmd dumps a JSON object per name to stdout. Output is one JSON +// array so the result is pipeable into jq for filtering / formatting. +func newInspectCmd(a *app) *cobra.Command { + return &cobra.Command{ + Use: "inspect [...]", + Short: "show low-level details about installed rootfs", + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return fmt.Errorf("inspect: at least one required") + } + mf, _ := loadManifest(cmd.Context(), a, manifest.ModeOffline, false) + reports := make([]inspectReport, 0, len(args)) + for _, raw := range args { + reports = append(reports, buildInspectReport(a, mf, raw)) + } + out, err := json.MarshalIndent(reports, "", " ") + if err != nil { + return err + } + fmt.Fprintln(a.ui.Out(), string(out)) + return nil + }, + } +} + +// buildInspectReport gathers everything for one name. Missing installs +// produce a report with Installed=false so the caller still sees something +// rather than an error — matches docker inspect's behavior on unknown ids. +func buildInspectReport(a *app, mf *manifest.Manifest, raw string) inspectReport { + name := normalizeInspectName(raw) + path := filepath.Join(a.paths.InstalledFsDir, name) + r := inspectReport{Name: name, Path: path, Custom: strings.HasPrefix(name, "custom-")} + + st, err := os.Stat(path) + if err != nil { + return r + } + r.Installed = true + r.InstalledAt = st.ModTime().UTC().Format(time.RFC3339) + if size, err := rootfs.Size(path); err == nil { + r.SizeBytes = size + r.SizeHuman = humanBytes(size) + } + r.AppliedFixes = detectAppliedFixes(path) + r.PerFSMounts = readPerFSMountsRaw(path) + if mf != nil && !r.Custom { + r.ManifestEntry = matchManifestEntry(a, mf, name) + } + return r +} + +// normalizeInspectName accepts either an installed name or a suite:variant +// ref. When given a ref we don't go through the manifest (it may be +// missing); we just convert to the "-" naming convention +// the installer uses. Caller still has to stat the path. +func normalizeInspectName(raw string) string { + if !strings.Contains(raw, ":") { + return raw + } + parts := strings.SplitN(raw, ":", 2) + return parts[0] + "-" + parts[1] +} + +// detectAppliedFixes probes for the fake /proc files ApplyFixes drops. +// Their presence is the cheapest signal that the post-install fixups ran. +func detectAppliedFixes(rootFS string) map[string]bool { + files := []string{"stat", "vmstat", "loadavg", "uptime", "version"} + out := make(map[string]bool, len(files)) + for _, f := range files { + _, err := os.Stat(filepath.Join(rootFS, "proc", "."+f)) + out["proc/."+f] = err == nil + } + return out +} + +// readPerFSMountsRaw returns the literal lines of /udroid_proot_mounts +// (comments and blanks stripped) so users can see the per-install binds +// exactly as written. +func readPerFSMountsRaw(rootFS string) []string { + b, err := os.ReadFile(filepath.Join(rootFS, "udroid_proot_mounts")) + if err != nil { + return nil + } + var out []string + for _, line := range strings.Split(string(b), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + out = append(out, line) + } + return out +} + +// matchManifestEntry walks every suite:variant pair looking for a variant +// whose installer-side Name matches. We can't reverse-derive suite:variant +// from the name alone (the upstream "Name" field is opaque), so a linear +// scan is the honest approach. Returns nil when there's no match. +func matchManifestEntry(a *app, mf *manifest.Manifest, name string) *manifestSummary { + 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 + } + if v.Name == name { + return &manifestSummary{ + Suite: suiteName, + Variant: vName, + FriendlyName: v.FriendlyName, + URL: v.URL, + SHASum: v.SHASum, + SupportedArchs: v.SupportedArchs, + } + } + } + } + return nil +} diff --git a/go-udroid/cmd/udroid/install.go b/go-udroid/cmd/udroid/install.go new file mode 100644 index 0000000..6a43375 --- /dev/null +++ b/go-udroid/cmd/udroid/install.go @@ -0,0 +1,179 @@ +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 +} + +// runInstall is the install pipeline reading top-to-bottom: resolve the +// manifest entry, fetch+verify the tarball, extract under proot, apply +// the fakes/profile fixes. Each step is a named helper. +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") + } + + variant, err := resolveInstallVariant(ctx, a, ref) + if err != nil { + return err + } + destDir := filepath.Join(a.paths.InstalledFsDir, variant.Name) + if _, err := os.Stat(destDir); err == nil { + return fmt.Errorf("filesystem %q already installed at %s", variant.Name, destDir) + } + + tarPath, err := fetchTarball(ctx, a, variant, noVerify, alwaysRetry) + if err != nil { + return err + } + if err := extractAndFix(ctx, a, tarPath, destDir); err != nil { + return err + } + a.ui.Info("✔ " + variant.Name + " installed") + return nil +} + +// resolveInstallVariant fetches/refreshes the manifest, fills in any +// missing suite/variant via prompts, and looks up the per-arch entry. +func resolveInstallVariant(ctx context.Context, a *app, ref manifest.Ref) (*manifest.Variant, error) { + mf, err := loadManifest(ctx, a, manifest.ModeOnline, false) + if err != nil { + return nil, err + } + ref, err = resolveRef(a, mf, ref) + if err != nil { + return nil, err + } + v, err := mf.Variant(ref.Suite, ref.Variant, a.arch) + if err != nil { + return nil, err + } + if v.URL == "" { + return nil, fmt.Errorf("no download URL for %s on %s — variant not supported", ref, a.arch) + } + return v, nil +} + +// fetchTarball downloads the variant tarball into the cache and verifies +// its sha256. On a checksum mismatch it offers a single re-download with +// a fresh verify; persistent failure aborts the install. +func fetchTarball(ctx context.Context, a *app, v *manifest.Variant, noVerify, alwaysRetry bool) (string, error) { + ext := filepath.Ext(v.URL) + tarPath := filepath.Join(a.paths.DownloadCache, v.Name+".tar"+ext) + + a.ui.Info(fmt.Sprintf("downloading %s ...", v.Name)) + if err := rootfs.Download(ctx, v.URL, tarPath, alwaysRetry, a.ui.Progress("download "+v.Name)); err != nil { + return "", err + } + if noVerify { + return tarPath, nil + } + if err := verifyOrRedownload(ctx, a, v, tarPath, alwaysRetry); err != nil { + return "", err + } + return tarPath, nil +} + +// verifyOrRedownload runs the sha256 check and, on mismatch, prompts the +// user to delete the cached tarball and try once more. A second failure +// is fatal so we don't loop forever on a bad upstream. +func verifyOrRedownload(ctx context.Context, a *app, v *manifest.Variant, tarPath string, alwaysRetry bool) error { + err := a.ui.Spinner("verifying sha256", func() error { + return rootfs.VerifySHA256(tarPath, v.SHASum) + }) + if err == nil { + return 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 + } + return rootfs.VerifySHA256(tarPath, v.SHASum) +} + +// extractAndFix creates the install dir, unpacks the tarball via proot, +// then writes the fake /proc files, /etc/hosts, env profile, and android +// aid_* groups so the rootfs is usable. +func extractAndFix(ctx context.Context, a *app, tarPath, destDir string) error { + 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() + return rootfs.ApplyFixes(destDir, rootfs.FixesOptions{ + TermuxPrefix: a.paths.Prefix, + AndroidGroups: groups, + }) +} + +// runCustomInstall installs an arbitrary local tarball as a "custom-" +// prefixed rootfs. Skips the manifest lookup and integrity check — the +// user is responsible for the source. +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) + } + a.ui.Title("> INSTALL custom-" + name) + if err := extractAndFix(ctx, a, file, dest); 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..b41c6fc --- /dev/null +++ b/go-udroid/cmd/udroid/list.go @@ -0,0 +1,177 @@ +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" +) + +// listFlags is the small bag of toggles the `list` command exposes. +type listFlags struct { + showSize bool + showCustomFs bool + installedOnly bool +} + +func newListCmd(a *app) *cobra.Command { + f := &listFlags{} + cmd := &cobra.Command{ + Use: "list", + Aliases: []string{"ls", "images"}, + 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, f) + }, + } + cmd.Flags().BoolVar(&f.showSize, "size", false, "include installed size") + cmd.Flags().BoolVar(&f.showCustomFs, "custom", false, "also list custom-installed rootfs") + cmd.Flags().BoolVar(&f.installedOnly, "installed", false, "only show installed rootfs") + return cmd +} + +// runList prints the variants table and (optionally) the custom-fs list. +// The body reads as the two-section structure the user sees. +func runList(a *app, mf *manifest.Manifest, f *listFlags) error { + renderVariantTable(a, mf, f) + if f.showCustomFs { + renderCustomFsList(a, f) + } + return nil +} + +// renderVariantTable builds the suite:variant / arch / status [/ size] +// table. Iterates each suite-variant pair, drops the row when --installed +// is on and the row isn't installed. +func renderVariantTable(a *app, mf *manifest.Manifest, f *listFlags) { + t := tablewriter.NewWriter(a.ui.Out()) + t.SetHeader(tableHeader(f.showSize)) + t.SetAutoWrapText(false) + + for _, suiteName := range mf.Suites { + s, err := mf.Suite(suiteName) + if err != nil { + continue + } + for _, vName := range s.Variants { + row, ok := variantRow(a, suiteName, vName, mf, f) + if !ok { + continue + } + t.Append(row) + } + } + t.Render() +} + +// tableHeader returns the column names; "size" is appended only when the +// user asked for it. +func tableHeader(includeSize bool) []string { + h := []string{"suite:variant", "arch supported", "status"} + if includeSize { + h = append(h, "size") + } + return h +} + +// variantRow assembles one table row. Returns (_, false) when the user +// passed --installed and this variant isn't installed, so the caller +// knows to skip the append. +func variantRow(a *app, suiteName, vName string, mf *manifest.Manifest, f *listFlags) ([]string, bool) { + v, err := mf.Variant(suiteName, vName, a.arch) + if err != nil { + return nil, false + } + installPath := filepath.Join(a.paths.InstalledFsDir, v.Name) + installed := pathExists(installPath) + if f.installedOnly && !installed { + return nil, false + } + + row := []string{ + suiteName + ":" + vName, + archSupportedLabel(v.SupportedArchs, string(a.arch)), + installedLabel(installed), + } + if f.showSize { + row = append(row, sizeOrBlank(installPath)) + } + return row, true +} + +// archSupportedLabel turns the list of arches a variant supports into a +// simple YES/NO based on the running arch. +func archSupportedLabel(supported []string, arch string) string { + for _, s := range supported { + if s == arch { + return "YES" + } + } + return "NO" +} + +// installedLabel returns the visible "[installed]" marker when present. +func installedLabel(installed bool) string { + if installed { + return "[installed]" + } + return "" +} + +// renderCustomFsList walks the install dir for "custom-*" entries and +// prints them as a separate section. These aren't in the manifest so they +// don't fit the main table. +func renderCustomFsList(a *app, f *listFlags) { + 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 f.showSize { + line += "\t" + sizeOrBlank(filepath.Join(a.paths.InstalledFsDir, e.Name())) + } + fmt.Fprintln(a.ui.Out(), line) + } +} + +func pathExists(p string) bool { + _, err := os.Stat(p) + return err == nil +} + +func sizeOrBlank(path string) string { + if !pathExists(path) { + return "" + } + n, err := rootfs.Size(path) + if err != nil { + return "" + } + return humanBytes(n) +} + +// humanBytes formats a byte count with binary-IEC suffixes (KiB/MiB/...). +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..c37c11e --- /dev/null +++ b/go-udroid/cmd/udroid/login.go @@ -0,0 +1,311 @@ +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" +) + +// loginFlags groups every CLI flag the `login` subcommand exposes. Pulling +// them into one value keeps newLoginCmd a thin wire-up and lets the run +// path read the whole user intent at a glance. +type loginFlags struct { + profile string + loginUser string + binds []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 + dryRun bool + runScript string +} + +func newLoginCmd(a *app) *cobra.Command { + f := &loginFlags{} + 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 { + return runLogin(a, f, cmd, args) + }, + } + bindLoginFlags(cmd, f) + return cmd +} + +// bindLoginFlags registers every flag against the shared struct. Lives +// next to loginFlags so adding a flag is a single-place change. +func bindLoginFlags(cmd *cobra.Command, f *loginFlags) { + pf := cmd.Flags() + pf.StringVar(&f.profile, "profile", "", "named login profile from config.yaml") + pf.StringVar(&f.loginUser, "user", "", "login user inside the rootfs (default root)") + pf.StringArrayVarP(&f.binds, "bind", "b", nil, "extra bind, e.g. /host:/guest") + pf.StringVar(&f.customDistro, "custom", "", "log into a custom (locally-installed) rootfs by name") + pf.StringVar(&f.nameOverride, "name", "", "explicit installed name (skip manifest lookup)") + pf.BoolVar(&f.isolated, "isolated", false, "skip termux/storage mounts and cwd inheritance") + pf.BoolVar(&f.fixLowPorts, "fix-low-ports", false, "allow binding to ports below 1024") + pf.BoolVar(&f.ashmemMemfd, "ashmem-memfd", false, "experimental memfd via ashmem") + pf.BoolVar(&f.noSharedTmp, "no-shared-tmp", false, "use rootfs /tmp instead of termux $PREFIX/tmp") + pf.BoolVar(&f.noLink2Symlink, "no-link2symlink", false, "disable proot link2symlink") + pf.BoolVar(&f.noSysVIPC, "no-sysvipc", false, "disable sysvipc emulation") + pf.BoolVar(&f.noKillOnExit, "no-kill-on-exit", false, "disable kill-on-exit") + pf.BoolVar(&f.noFakeRootID, "no-fake-root-id", false, "disable --root-id") + pf.BoolVar(&f.noCapLastCap, "no-cap-last-cap", false, "disable cap_last_cap fix mount") + pf.BoolVar(&f.reinstallFixes, "reinstall-fixes", false, "re-apply proot-fixes before login") + pf.BoolVar(&f.noPulseServer, "no-pulseserver", false, "skip starting host pulseaudio") + pf.BoolVar(&f.dryRun, "dry-run", false, "print the proot argv (one per line) and exit without executing") + pf.StringVar(&f.runScript, "run-script", "", "host-side script to run inside the rootfs") +} + +// runLogin is the full login flow: resolve target rootfs, optionally +// re-apply proot fixes, build proot.Options, then either dry-run-print or +// exec proot. The function reads in three named stages. +func runLogin(a *app, f *loginFlags, cmd *cobra.Command, args []string) error { + cmdArgs, passthrough := splitAtDash(cmd, args) + + distroName, err := resolveLoginTarget(a, cmdArgs, f.customDistro, f.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 f.reinstallFixes { + if err := reapplyFixes(a, rootFS, f.loginUser); err != nil { + return err + } + } + + opts, err := buildLoginOptions(a, f, rootFS, passthrough) + if err != nil { + return err + } + + a.ui.Title("> LOGIN " + distroName) + if f.dryRun { + printArgv(a, opts) + return nil + } + return proot.Login(opts) +} + +// splitAtDash splits cobra's positional args at `--` so anything after is +// treated as the command to run inside the rootfs. +func splitAtDash(cmd *cobra.Command, args []string) (head, tail []string) { + idx := cmd.ArgsLenAtDash() + if idx < 0 { + return args, nil + } + return args[:idx], args[idx:] +} + +// reapplyFixes re-runs rootfs.ApplyFixes against an existing install — +// the --reinstall-fixes escape hatch users hit when an upgrade leaves the +// rootfs missing one of the fake /proc files or the profile snippet. +func reapplyFixes(a *app, rootFS, loginUser string) error { + groups, _ := rootfs.HostAndroidGroups() + return rootfs.ApplyFixes(rootFS, rootfs.FixesOptions{ + TermuxPrefix: a.paths.Prefix, + AndroidGroups: groups, + LoginUser: loginUser, + }) +} + +// buildLoginOptions composes the proot.Options the user actually wants by +// layering: built-in defaults → config.defaults → named profile → +// CLI flags → per-fs mounts file. Each later layer overrides the earlier. +func buildLoginOptions(a *app, f *loginFlags, rootFS string, passthrough []string) (proot.Options, error) { + opts := proot.DefaultOptions(rootFS) + opts.HostPrefix = a.paths.Prefix + opts.HostHome = a.paths.Home + opts.AndroidPackage = a.paths.Package + + if f.profile != "" { + prof, ok := a.cfg.Profile(f.profile) + if !ok { + return opts, fmt.Errorf("profile %q not found in config", f.profile) + } + applyProfile(&opts, prof) + } else if a.cfg != nil { + applyProfile(&opts, a.cfg.Defaults) + } + + applyLoginFlags(&opts, f) + opts.Binds = append(opts.Binds, readPerFSMounts(rootFS)...) + if len(passthrough) > 0 { + opts.Command = passthrough + } + return opts, nil +} + +// applyLoginFlags overlays the user's CLI choices on top of the profile- +// merged Options. Boolean "no-FOO" flags flip features off; affirmative +// flags flip them on. +func applyLoginFlags(opts *proot.Options, f *loginFlags) { + if f.loginUser != "" { + opts.LoginUser = f.loginUser + } + for _, b := range f.binds { + opts.Binds = append(opts.Binds, parseBindFlag(b)) + } + if f.isolated { + opts.Isolated = true + opts.CWD = "/root" + } + if f.fixLowPorts { + opts.FixLowPorts = true + } + if f.ashmemMemfd { + opts.AshmemMemfd = true + } + if f.noSharedTmp { + opts.SharedTmp = false + } + if f.noLink2Symlink { + opts.Link2Symlink = false + } + if f.noSysVIPC { + opts.SysVIPC = false + } + if f.noKillOnExit { + opts.KillOnExit = false + } + if f.noFakeRootID { + opts.FakeRootID = false + } + if f.noCapLastCap { + opts.CapLastCapFix = false + } + if f.noPulseServer { + opts.PulseServer = false + } + if f.runScript != "" { + opts.RunScript = f.runScript + } +} + +// printArgv dumps the argv one entry per line. Useful for diagnosing what +// proot will see without actually launching it. +func printArgv(a *app, opts proot.Options) { + argv := append([]string{"proot"}, proot.BuildArgs(opts)...) + for _, s := range argv { + fmt.Fprintln(a.ui.Out(), s) + } +} + +// resolveLoginTarget turns the user-supplied identifier into the on-disk +// rootfs directory name. Honors --name (explicit), --custom (custom-fs +// installs), or parses a normal "suite:variant" reference. +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 +} + +// parseBindFlag accepts "src" or "src:dst" forms. +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]} +} + +// applyProfile merges a config-defined LoginProfile into Options. Bool +// fields on the profile are pointers so we can tell "user explicitly set +// false" from "user said nothing"; nil falls back to whatever was on +// Options already. +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. Lets users persist extra +// binds per-install without editing the global config. +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/pull.go b/go-udroid/cmd/udroid/pull.go new file mode 100644 index 0000000..7f1f8f8 --- /dev/null +++ b/go-udroid/cmd/udroid/pull.go @@ -0,0 +1,47 @@ +package main + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/RandomCoderOrg/fs-manager-udroid/go-udroid/internal/manifest" +) + +// newPullCmd downloads a variant tarball into the local cache without +// installing it. Useful for offline prep — afterwards `install` can run +// without a network round trip. +func newPullCmd(a *app) *cobra.Command { + var ( + noVerify bool + alwaysRetry bool + ) + cmd := &cobra.Command{ + Use: "pull :", + Short: "download a variant tarball to the cache (no install)", + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return fmt.Errorf("pull: : required") + } + ref, err := manifest.ParseRef(args[0]) + if err != nil { + return err + } + ctx := cmd.Context() + variant, err := resolveInstallVariant(ctx, a, ref) + if err != nil { + return err + } + a.ui.Title("> PULL " + variant.Name) + path, err := fetchTarball(ctx, a, variant, noVerify, alwaysRetry) + if err != nil { + return err + } + a.ui.Info("✔ cached at " + path) + return nil + }, + } + 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") + return cmd +} 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/rmi.go b/go-udroid/cmd/udroid/rmi.go new file mode 100644 index 0000000..21828e1 --- /dev/null +++ b/go-udroid/cmd/udroid/rmi.go @@ -0,0 +1,64 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/RandomCoderOrg/fs-manager-udroid/go-udroid/internal/manifest" +) + +// newRmiCmd removes one or more cached variant tarballs. Targets the +// download cache only — installed rootfs are unaffected (use `remove`). +func newRmiCmd(a *app) *cobra.Command { + return &cobra.Command{ + Use: "rmi : [:...]", + Short: "remove cached tarball(s) from the download cache", + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return fmt.Errorf("rmi: at least one : required") + } + mf, err := loadManifest(cmd.Context(), a, manifest.ModeOffline, false) + if err != nil { + return err + } + for _, raw := range args { + if err := removeCachedTarball(a, mf, raw); err != nil { + a.ui.Warn(fmt.Sprintf("%s: %v", raw, err)) + } + } + return nil + }, + } +} + +// removeCachedTarball resolves one suite:variant ref, finds the matching +// cache file(s), and unlinks them. Globs against .tar* so we +// catch every supported compression ext without depending on variant.URL +// (which is empty on the host's arch when the variant isn't supported). +func removeCachedTarball(a *app, mf *manifest.Manifest, raw string) error { + ref, err := manifest.ParseRef(raw) + if err != nil { + return err + } + if ref.Suite == "" || ref.Variant == "" { + return fmt.Errorf("need explicit suite:variant") + } + v, err := mf.Variant(ref.Suite, ref.Variant, a.arch) + if err != nil { + return err + } + matches, _ := filepath.Glob(filepath.Join(a.paths.DownloadCache, v.Name+".tar*")) + if len(matches) == 0 { + return fmt.Errorf("no cached tarball") + } + for _, m := range matches { + if err := os.Remove(m); err != nil { + return err + } + a.ui.Info("removed " + filepath.Base(m)) + } + return nil +} diff --git a/go-udroid/cmd/udroid/root.go b/go-udroid/cmd/udroid/root.go new file mode 100644 index 0000000..a69db52 --- /dev/null +++ b/go-udroid/cmd/udroid/root.go @@ -0,0 +1,137 @@ +package main + +import ( + "fmt" + "log/slog" + + "github.com/spf13/cobra" + + "github.com/RandomCoderOrg/fs-manager-udroid/go-udroid/internal/config" + "github.com/RandomCoderOrg/fs-manager-udroid/go-udroid/internal/logging" + "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 + logger *slog.Logger + close func() error +} + +func newRootCmd() *cobra.Command { + var ( + configFile string + verbose bool + logLevel string + logFile string + logFormat string + ) + 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 + } + logOpts := logging.Options{ + Level: pickStr(logLevel, cfg.Log.Level), + File: pickStr(logFile, cfg.Log.File), + Format: logging.Format(pickStr(logFormat, cfg.Log.Format)), + Verbose: verbose, + } + logger, closer, err := logging.Setup(logOpts) + if err != nil { + return err + } + state.logger = logger + state.close = closer + + 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() + logger.Debug("startup", + slog.String("arch", string(arch)), + slog.String("prefix", paths.Prefix), + slog.String("installed_fs_dir", paths.InstalledFsDir), + ) + return nil + }, + PersistentPostRunE: func(cmd *cobra.Command, args []string) error { + if state.close != nil { + return state.close() + } + return nil + }, + } + pf := root.PersistentFlags() + pf.StringVar(&configFile, "config", "", "path to config.yaml") + pf.BoolVarP(&verbose, "verbose", "v", false, "mirror log output to stderr") + pf.StringVar(&logLevel, "log-level", "", "log level: debug|info|warn|error (default info)") + pf.StringVar(&logFile, "log-file", "", "log file path (default $TMPDIR/udroid.log)") + pf.StringVar(&logFormat, "log-format", "", "log format: text|json (default text)") + + root.AddCommand( + newInstallCmd(state), + newLoginCmd(state), + newRemoveCmd(state), + newResetCmd(state), + newListCmd(state), + newCacheCmd(state), + newPullCmd(state), + newRmiCmd(state), + newExecCmd(state), + newInspectCmd(state), + newInfoCmd(state), + newSearchCmd(state), + ) + return root +} + +// pickStr returns the first non-empty value — CLI flag wins over config. +func pickStr(flag, fromConfig string) string { + if flag != "" { + return flag + } + return fromConfig +} + +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/cmd/udroid/search.go b/go-udroid/cmd/udroid/search.go new file mode 100644 index 0000000..d9910a2 --- /dev/null +++ b/go-udroid/cmd/udroid/search.go @@ -0,0 +1,84 @@ +package main + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/olekukonko/tablewriter" + "github.com/spf13/cobra" + + "github.com/RandomCoderOrg/fs-manager-udroid/go-udroid/internal/manifest" +) + +// newSearchCmd does a case-insensitive substring match across the manifest: +// suite names, variant names, and the upstream "FriendlyName" field. Output +// is the same shape as `list` so the two commands feel related. +func newSearchCmd(a *app) *cobra.Command { + return &cobra.Command{ + Use: "search ", + Short: "search the distro manifest by suite/variant/friendly name", + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return fmt.Errorf("search: required") + } + term := strings.ToLower(args[0]) + mf, err := loadManifest(cmd.Context(), a, manifest.ModeOffline, false) + if err != nil { + return err + } + renderSearchTable(a, mf, term) + return nil + }, + } +} + +// renderSearchTable walks every suite:variant pair and prints the ones +// whose suite/variant/friendly-name contains term. Reuses the same column +// shape as `list` so users can read either output without re-learning. +func renderSearchTable(a *app, mf *manifest.Manifest, term string) { + t := tablewriter.NewWriter(a.ui.Out()) + t.SetHeader([]string{"suite:variant", "friendly name", "arch supported", "status"}) + t.SetAutoWrapText(false) + + hits := 0 + 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 + } + if !searchMatches(term, suiteName, vName, v.FriendlyName) { + continue + } + installPath := filepath.Join(a.paths.InstalledFsDir, v.Name) + t.Append([]string{ + suiteName + ":" + vName, + v.FriendlyName, + archSupportedLabel(v.SupportedArchs, string(a.arch)), + installedLabel(pathExists(installPath)), + }) + hits++ + } + } + if hits == 0 { + a.ui.Warn("no matches") + return + } + t.Render() +} + +// searchMatches returns true when any of the candidate strings contains the +// (already lower-cased) term. Substring match — same UX as docker search. +func searchMatches(term string, candidates ...string) bool { + for _, c := range candidates { + if strings.Contains(strings.ToLower(c), term) { + return true + } + } + return false +} diff --git a/go-udroid/config.example.yaml b/go-udroid/config.example.yaml new file mode 100644 index 0000000..7033cb3 --- /dev/null +++ b/go-udroid/config.example.yaml @@ -0,0 +1,63 @@ +# 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 + +log: + level: info # debug | info | warn | error + # file: /data/data/com.termux/files/usr/var/log/udroid.log # default: $TMPDIR/udroid.log + format: text # text | 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. +# Bool fields use Go-style pointers internally, so leaving a key off is +# different from setting it to false: omitted = inherit, false = force off. +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 + + # `everything` shows every supported key with its default value. Use it as + # a template — copy + delete what you don't need rather than leaving + # explicit values in (omitted keys inherit; that's usually what you want). + everything: + user: root + binds: + - /sdcard:/sdcard + command: [] # non-empty replaces the interactive shell + run_script: "" # host path; copied into rootfs and exec'd + isolated: false + link2symlink: true # proot --link2symlink + sysvipc: true # proot --sysvipc + kill_on_exit: true # proot --kill-on-exit + fake_root_id: true # proot --root-id + cap_last_cap_fix: true # bind /dev/null over /proc/sys/kernel/cap_last_cap + shared_tmp: true # bind $PREFIX/tmp to /tmp + fix_low_ports: false # proot -p + ashmem_memfd: false # proot --ashmem-memfd (experimental) + pulse_server: true # start host pulseaudio with TCP loopback 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/install.sh b/go-udroid/install.sh new file mode 100755 index 0000000..023e047 --- /dev/null +++ b/go-udroid/install.sh @@ -0,0 +1,199 @@ +#!/usr/bin/env bash +# Build go-udroid from source and install it as `udroid-go` so it can live +# alongside the bash `udroid` binary. Termux-first; falls back to a generic +# Linux flow when run outside Termux. +# +# Usage: +# ./install.sh [-y] [--prefix=DIR] [--bin-name=NAME] [--no-install] +# +# -y, --yes Non-interactive: assume yes for any prompt. +# --prefix=DIR Install into DIR/bin/ instead of the auto-detected +# location ($PREFIX/bin on Termux, /usr/local on Linux). +# --bin-name=NAME Output binary name (default: udroid-go). +# --no-install Build only; skip the install step. +# --skip-deps Skip the dependency check entirely (assume caller has +# `go`, `proot`, `tar` already on PATH). +# -h, --help Show this help. + +set -euo pipefail + +BIN_NAME=udroid-go +ASSUME_YES=0 +DO_INSTALL=1 +SKIP_DEPS=0 +INSTALL_PREFIX="" + +# usage prints the help block parsed from the top-of-file comment so help +# and the source stay in lockstep. +usage() { + sed -n '/^# Build go-udroid/,/^$/{s/^# \{0,1\}//;p;}' "$0" +} + +# parse_args walks $@ exactly once, populating the globals above. Unknown +# args are a hard error rather than a warning — silent typos in install +# scripts cause more grief than they save. +parse_args() { + while [ $# -gt 0 ]; do + case "$1" in + -y|--yes) ASSUME_YES=1 ;; + --no-install) DO_INSTALL=0 ;; + --skip-deps) SKIP_DEPS=1 ;; + --prefix=*) INSTALL_PREFIX="${1#--prefix=}" ;; + --bin-name=*) BIN_NAME="${1#--bin-name=}" ;; + -h|--help) usage; exit 0 ;; + *) echo "unknown arg: $1" >&2; usage >&2; exit 2 ;; + esac + shift + done +} + +# detect_host fingerprints the environment so the install/build logic knows +# which package manager (if any) to suggest. Termux exposes $TERMUX_VERSION; +# regular distros are matched via /etc/os-release for `apt`/`pacman`. +detect_host() { + if [ -n "${TERMUX_VERSION:-}" ] || [ -n "${PREFIX:-}" ] && [ -x "${PREFIX:-/nonexistent}/bin/pkg" ]; then + HOST=termux + DEFAULT_BIN_DIR="${PREFIX}/bin" + return + fi + DEFAULT_BIN_DIR="/usr/local/bin" + if [ -r /etc/os-release ]; then + # shellcheck disable=SC1091 + . /etc/os-release + case "${ID:-}${ID_LIKE:-}" in + *debian*|*ubuntu*) HOST=debian ;; + *arch*) HOST=arch ;; + *) HOST=other ;; + esac + return + fi + HOST=other +} + +# missing_deps lists which required tools aren't on PATH. `go` is needed to +# build; `proot` and `tar` are needed at runtime (proot is what the rootfs +# install/login flow shells out to). Prints nothing when all deps are +# present, so the caller can read the result with a portable while-loop. +missing_deps() { + local missing=() + command -v go >/dev/null 2>&1 || missing+=(go) + command -v proot >/dev/null 2>&1 || missing+=(proot) + command -v tar >/dev/null 2>&1 || missing+=(tar) + [ ${#missing[@]} -eq 0 ] && return + printf '%s\n' "${missing[@]}" +} + +# install_deps tries the host's package manager. Maps the canonical names +# from missing_deps to per-distro package names. On `other` hosts we bail +# out with instructions rather than guessing. +install_deps() { + local pkgs=("$@") + [ ${#pkgs[@]} -eq 0 ] && return 0 + + case "$HOST" in + termux) + run_pkg_install pkg "${pkgs[@]/#go/golang}" ;; + debian) + local mapped=() + for p in "${pkgs[@]}"; do + case "$p" in + go) mapped+=(golang-go) ;; + *) mapped+=("$p") ;; + esac + done + run_pkg_install "apt-get -y" "${mapped[@]}" ;; + arch) + run_pkg_install pacman "${pkgs[@]}" ;; + *) + echo "no package manager mapping for this host; please install: ${pkgs[*]}" >&2 + exit 1 ;; + esac +} + +# run_pkg_install confirms with the user (or honours --yes) and then runs +# the install with sudo when not root. The first positional is the install +# command up to but not including the package list. +run_pkg_install() { + local cmd="$1"; shift + local pkgs=("$@") + echo "missing deps: ${pkgs[*]}" + if [ "$ASSUME_YES" -eq 0 ]; then + printf "install with '%s'? [y/N] " "$cmd" + local ans + read -r ans + case "$ans" in y|Y|yes|YES) ;; *) echo "skipping dep install"; return 0 ;; esac + fi + local sudo="" + [ "$HOST" != termux ] && [ "$(id -u)" -ne 0 ] && sudo="sudo" + case "$cmd" in + pkg) $sudo pkg install -y "${pkgs[@]}" ;; + "apt-get -y") $sudo apt-get update && $sudo apt-get install -y "${pkgs[@]}" ;; + pacman) $sudo pacman -S --noconfirm "${pkgs[@]}" ;; + esac +} + +# build_binary stamps in the version from `git describe` when available so +# `udroid-go info` shows a real tag rather than "dev". CGO is disabled for +# a fully static binary — important on Termux where libc paths shift. +build_binary() { + local src_dir; src_dir="$(cd "$(dirname "$0")" && pwd)" + cd "$src_dir" + + local version=dev + if command -v git >/dev/null 2>&1 && git -C "$src_dir" rev-parse --git-dir >/dev/null 2>&1; then + version="$(git -C "$src_dir" describe --tags --always --dirty 2>/dev/null || echo dev)" + fi + + echo "building $BIN_NAME (version=$version)" + CGO_ENABLED=0 go build \ + -ldflags="-s -w -X main.Version=${version}" \ + -o "$BIN_NAME" \ + ./cmd/udroid + echo "built ./$BIN_NAME" +} + +# install_binary copies the built artifact into the chosen bin dir. Uses +# sudo only if the dir isn't writable as the current user — keeps the +# Termux path prompt-free. +install_binary() { + local bin_dir + if [ -n "$INSTALL_PREFIX" ]; then + bin_dir="${INSTALL_PREFIX}/bin" + else + bin_dir="$DEFAULT_BIN_DIR" + fi + mkdir -p "$bin_dir" 2>/dev/null || true + + local sudo="" + if [ ! -w "$bin_dir" ]; then + sudo="sudo" + fi + $sudo install -m 0755 "$BIN_NAME" "$bin_dir/$BIN_NAME" + echo "installed $bin_dir/$BIN_NAME" +} + +main() { + parse_args "$@" + detect_host + + if [ "$SKIP_DEPS" -eq 0 ]; then + # Read missing deps with a while-loop instead of mapfile so the + # script works on macOS's bash 3.2 (mapfile was added in bash 4). + local missing=() + while IFS= read -r line; do + missing+=("$line") + done < <(missing_deps) + if [ ${#missing[@]} -gt 0 ]; then + install_deps "${missing[@]}" + fi + fi + + build_binary + if [ "$DO_INSTALL" -eq 1 ]; then + install_binary + else + echo "skipping install (--no-install)" + fi +} + +main "$@" diff --git a/go-udroid/internal/config/config.go b/go-udroid/internal/config/config.go new file mode 100644 index 0000000..650934d --- /dev/null +++ b/go-udroid/internal/config/config.go @@ -0,0 +1,142 @@ +// 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"` + Log LogConfig `mapstructure:"log"` +} + +// LogConfig configures the slog-backed logger. All fields are optional; +// CLI flags override anything set here. +type LogConfig struct { + Level string `mapstructure:"level"` // debug|info|warn|error + File string `mapstructure:"file"` // path; empty = $TMPDIR/udroid.log + Format string `mapstructure:"format"` // text|json +} + +// 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/logging/logging.go b/go-udroid/internal/logging/logging.go new file mode 100644 index 0000000..8653c1b --- /dev/null +++ b/go-udroid/internal/logging/logging.go @@ -0,0 +1,130 @@ +// Package logging builds a slog.Logger from user-controllable knobs +// (level, output file, format) and installs it as the slog default so +// every package can emit structured events without an extra parameter. +// +// Two output targets are supported in one logger: +// - the log file (always written, defaults to $TMPDIR/udroid.log) +// - the terminal (stderr; only enabled when verbose is set or when the +// level is debug, so normal runs stay quiet) +package logging + +import ( + "errors" + "fmt" + "io" + "log/slog" + "os" + "path/filepath" + "strings" + "sync" +) + +// Format selects the on-disk encoding. +type Format string + +const ( + FormatText Format = "text" + FormatJSON Format = "json" +) + +// Options describes everything the factory needs. +type Options struct { + Level string // debug/info/warn/error (case-insensitive); empty defaults to "info" + File string // log file path; empty defaults to $TMPDIR/udroid.log + Format Format // text or json; empty defaults to text + Verbose bool // also mirror to stderr at the same level +} + +// Setup builds the logger, installs it as the slog default, and returns a +// close function the caller should defer to flush the file. +// +// If Level is empty and Verbose is true, Level is promoted to "debug" so +// `--verbose` alone produces useful diagnostic output without the user +// having to remember a second flag. +func Setup(opts Options) (*slog.Logger, func() error, error) { + if opts.Level == "" && opts.Verbose { + opts.Level = "debug" + } + level, err := parseLevel(opts.Level) + if err != nil { + return nil, nil, err + } + path := opts.File + if path == "" { + path = defaultLogPath() + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return nil, nil, fmt.Errorf("prepare log dir: %w", err) + } + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return nil, nil, fmt.Errorf("open log file: %w", err) + } + + var out io.Writer = f + if opts.Verbose || level <= slog.LevelDebug { + out = io.MultiWriter(f, os.Stderr) + } + + handlerOpts := &slog.HandlerOptions{Level: level} + var handler slog.Handler + switch opts.Format { + case FormatJSON: + handler = slog.NewJSONHandler(out, handlerOpts) + default: + handler = slog.NewTextHandler(out, handlerOpts) + } + logger := slog.New(handler) + slog.SetDefault(logger) + logger.Debug("logger initialised", + slog.String("file", path), + slog.String("level", level.String()), + slog.Bool("verbose", opts.Verbose), + ) + + closer := closerOnce(f.Close) + return logger, closer, nil +} + +func parseLevel(s string) (slog.Level, error) { + switch strings.ToLower(strings.TrimSpace(s)) { + case "", "info": + return slog.LevelInfo, nil + case "debug": + return slog.LevelDebug, nil + case "warn", "warning": + return slog.LevelWarn, nil + case "error": + return slog.LevelError, nil + } + return 0, fmt.Errorf("unknown log level %q (want debug|info|warn|error)", s) +} + +func defaultLogPath() string { + dir := os.Getenv("TMPDIR") + if dir == "" { + dir = "/tmp" + } + return filepath.Join(dir, "udroid.log") +} + +// closerOnce returns a func that runs f at most once, returning the same +// error on subsequent calls. Lets callers defer the close without worrying +// about double-frees. +func closerOnce(f func() error) func() error { + var ( + once sync.Once + err error + done bool + ) + return func() error { + once.Do(func() { + err = f() + done = true + }) + if !done { + return errors.New("logger already closed") + } + return err + } +} diff --git a/go-udroid/internal/manifest/fetch.go b/go-udroid/internal/manifest/fetch.go new file mode 100644 index 0000000..93a2b65 --- /dev/null +++ b/go-udroid/internal/manifest/fetch.go @@ -0,0 +1,114 @@ +package manifest + +import ( + "context" + "fmt" + "io" + "log/slog" + "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 + } + slog.Warn("manifest fetch failed; using cached copy", + slog.String("url", f.URL), + slog.Any("err", err), + ) + } + case !cached: + // offline + no cache — fall through to a single fetch. + slog.Info("offline mode but no manifest cached; fetching anyway", + slog.String("cache", f.CachePath), + ) + 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 { + slog.Debug("manifest refresh begin", slog.String("url", f.URL)) + 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 + } + if err := os.Rename(tmpName, f.CachePath); err != nil { + return err + } + slog.Debug("manifest refresh ok", slog.String("path", f.CachePath)) + return nil +} 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..bd70f87 --- /dev/null +++ b/go-udroid/internal/proot/args.go @@ -0,0 +1,331 @@ +package proot + +import ( + "errors" + "io" + "os" + "path/filepath" +) + +// BuildArgs is a pure transform from typed Options to the argv handed to +// `exec.Command("proot", ...)`. +// +// The function is intentionally a list of phase calls. Each phase appends +// a contiguous slice of args and has a single concern. Order matches bash +// udroid's final argv: termux/android binds, fake /proc binds, user binds, +// shared-tmp/shm, core /sys + /proc + /dev, session flags, --rootfs=, +// launcher. Matching bash here is load-bearing — proot's overlay handling +// is sensitive to where /proc gets bound relative to the fake /proc/ +// binds. +func BuildArgs(o Options) []string { + a := make([]string, 0, 48) + a = append(a, termuxBinds(o)...) + a = append(a, fakeProcBinds(o)...) + for _, b := range o.Binds { + a = append(a, b.String()) + } + a = append(a, sharedTmpBinds(o)...) + a = append(a, coreBinds(o)...) + a = append(a, sessionFlags(o)...) + a = append(a, "--rootfs="+o.RootFS) + a = append(a, buildLauncher(o)...) + return a +} + +// termuxBinds emits the host-side android paths the rootfs needs to see: +// /vendor, /system, $TERMUX_PREFIX, ld.config, /apex, /storage*, $HOME, +// termux app cache, and the dalvik cache. Skipped entirely when Isolated. +// +// Probes mirror bash udroid: stat for files, "ls -1U" semantics for +// directories that may exist-but-not-be-readable under selinux (e.g. +// /storage on locked-down devices). +func termuxBinds(o Options) []string { + if !o.TermuxMounts || o.Isolated { + return nil + } + pkg := o.AndroidPackage + if pkg == "" { + pkg = "com.termux" + } + var a []string + for _, f := range []string{"/property_contexts", "/plat_property_contexts"} { + if fileExists(f) { + a = append(a, "--bind="+f) + } + } + if fileExists("/vendor") { + a = append(a, "--bind=/vendor") + } + if fileExists("/system") { + a = append(a, "--bind=/system") + } + if o.HostPrefix != "" { + a = append(a, "--bind="+o.HostPrefix) + } + if fileExists("/linkerconfig/ld.config.txt") { + a = append(a, "--bind=/linkerconfig/ld.config.txt") + } + if fileExists("/apex") { + a = append(a, "--bind=/apex") + } + if readableDir("/storage") { + a = append(a, "--bind=/storage") + } + if bind := pickSharedStorage(); bind != "" { + a = append(a, bind) + } + if o.HostHome != "" { + a = append(a, "--bind="+o.HostHome) + } + if fileExists("/data/data/" + pkg + "/files/apps") { + a = append(a, "--bind=/data/data/"+pkg+"/files/apps") + } + a = append(a, + "--bind=/data/data/"+pkg+"/cache", + "--bind=/data/dalvik-cache", + ) + return a +} + +// pickSharedStorage returns the first readable shared-storage path mapped +// to /sdcard inside the rootfs. Android exposes the same content under +// several mount points; whichever resolves first wins. +func pickSharedStorage() string { + candidates := []struct{ src, dst string }{ + {"/storage/self/primary", "/sdcard"}, + {"/storage/emulated/0", "/sdcard"}, + {"/sdcard", "/sdcard"}, + } + for _, c := range candidates { + if fileExists(c.src) { + return "--bind=" + c.src + ":" + c.dst + } + } + return "" +} + +// fakeProcBinds shadows kernel-provided /proc files with the static +// snapshots written by rootfs.ApplyFixes. Only emitted for files the host +// kernel won't let us read at runtime — bash udroid does the same, and +// stacking these on top of a working /proc/ confuses proot. +func fakeProcBinds(o Options) []string { + if !o.FakeProcFiles { + return nil + } + names := []string{"loadavg", "stat", "uptime", "version", "vmstat"} + var a []string + for _, name := range names { + if hostProcReadable("/proc/" + name) { + continue + } + src := filepath.Join(o.RootFS, "proc", "."+name) + a = append(a, "--bind="+src+":/proc/"+name) + } + return a +} + +// sharedTmpBinds bridges termux $PREFIX/tmp into /tmp and gives the rootfs +// a writable /dev/shm. When SharedTmp is off we fall back to reusing the +// rootfs's own /tmp as /dev/shm. +func sharedTmpBinds(o Options) []string { + if o.SharedTmp && o.HostPrefix != "" { + return []string{ + "--bind=" + o.RootFS + "/dev/shm:/dev/shm", + "--bind=" + o.HostPrefix + "/tmp:/tmp", + } + } + return []string{"--bind=" + o.RootFS + "/tmp:/dev/shm"} +} + +// coreBinds wires up the always-needed kernel surfaces: /sys, the three +// std-fd-as-device tricks, /proc, /dev/random, /dev. +func coreBinds(o Options) []string { + if !o.CoreMounts { + return nil + } + return []string{ + "--bind=/sys", + "--bind=/proc/self/fd/2:/dev/stderr", + "--bind=/proc/self/fd/1:/dev/stdout", + "--bind=/proc/self/fd/0:/dev/stdin", + "--bind=/proc/self/fd:/dev/fd", + "--bind=/proc", + "--bind=/dev/urandom:/dev/random", + "--bind=/dev", + } +} + +// sessionFlags emits the proot session toggles in bash's order: +// --root-id, cap_last_cap shim, --cwd, -L, kernel release, sysvipc, +// link2symlink, kill-on-exit, fix-low-ports, ashmem-memfd. +func sessionFlags(o Options) []string { + var a []string + if o.FakeRootID { + a = append(a, "--root-id") + } + if o.CapLastCapFix { + a = append(a, "--bind=/dev/null:/proc/sys/kernel/cap_last_cap") + } + if cwd := pickCWD(o); cwd != "" { + a = append(a, "--cwd="+cwd) + } + if o.FollowSymlinks { + a = append(a, "-L") + } + if o.KernelRelease != "" { + a = append(a, "--kernel-release="+o.KernelRelease) + } + if o.SysVIPC { + a = append(a, "--sysvipc") + } + if o.Link2Symlink { + a = append(a, "--link2symlink") + } + if o.KillOnExit { + a = append(a, "--kill-on-exit") + } + if o.FixLowPorts { + a = append(a, "-p") + } + if o.AshmemMemfd { + a = append(a, "--ashmem-memfd") + } + return a +} + +// pickCWD resolves the working directory for the inner process. Explicit +// CWD wins; Isolated forces /root; otherwise inherit the host PWD so the +// user lands in the same directory they ran udroid from. +func pickCWD(o Options) string { + switch { + case o.CWD != "": + return o.CWD + case o.Isolated: + return "/root" + default: + return o.HostPWD + } +} + +// readableDir returns true when path is a directory and the caller can +// actually enumerate it. Mirrors bash udroid's `ls -1U` probe — needed +// because Android selinux often makes /storage stattable but unreadable. +func readableDir(path string) bool { + f, err := os.Open(path) + if err != nil { + return false + } + defer f.Close() + _, err = f.Readdirnames(1) + return err == nil || errors.Is(err, io.EOF) +} + +// hostProcReadable returns true when the kernel can satisfy a one-byte +// read of path. Used to decide whether a fake /proc/ bind is needed. +func hostProcReadable(path string) bool { + f, err := os.Open(path) + if err != nil { + return false + } + defer f.Close() + buf := make([]byte, 1) + _, err = f.Read(buf) + return err == nil +} + +// fileExists is a one-arg os.Stat error check, used heavily by the bind +// probes for paths that may legitimately be absent on some devices. +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// buildLauncher returns the trailing `/usr/bin/env -i ... /bin/su -l user` +// piece. Shell selection mirrors bash udroid: prefer /bin/su, fall back +// to /bin/bash, then /bin/sh — probed against the host-side rootfs path +// before proot chroots so we never hand proot a launcher it can't exec. +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} + shell, useSu := pickShell(o.RootFS) + cmd := launcherCommand(o) + + switch { + case cmd != "" && useSu: + return append(env, shell, "-l", user, "-c", cmd) + case cmd != "": + return append(env, shell, "-l", "-c", cmd) + case useSu: + return append(env, shell, "-l", user) + default: + return append(env, shell, "-l") + } +} + +// launcherCommand collapses RunScript and Command into the single `-c` +// argument the shell will run. Empty means interactive login. +func launcherCommand(o Options) string { + if o.RunScript != "" { + return "/" + filepath.Base(o.RunScript) + } + if len(o.Command) > 0 { + return shellJoin(o.Command) + } + return "" +} + +// pickShell returns the launcher binary to exec and whether to invoke it +// in su-style (passing the target user as a positional arg). Probes are +// done against the host-side rootfs path before proot chroots. +func pickShell(rootFS string) (path string, isSu bool) { + if rootFS != "" { + if fileExecutable(filepath.Join(rootFS, "bin/su")) { + return "/bin/su", true + } + if fileExecutable(filepath.Join(rootFS, "bin/bash")) { + return "/bin/bash", false + } + } + return "/bin/sh", false +} + +func fileExecutable(path string) bool { + st, err := os.Stat(path) + if err != nil { + return false + } + return !st.IsDir() && st.Mode()&0o111 != 0 +} + +// shellJoin quotes each token so the resulting string is safe to hand to +// `sh -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..8d21203 --- /dev/null +++ b/go-udroid/internal/proot/args_test.go @@ -0,0 +1,151 @@ +package proot + +import ( + "os" + "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) + } +} + +// TestBuildArgs_ShellFallback verifies the launcher falls back to +// /bin/bash and then /bin/sh when /bin/su is absent inside the rootfs. +// Catches regressions where a rootfs without su would fail outright. +func TestBuildArgs_ShellFallback(t *testing.T) { + dir := t.TempDir() + mustMkdir(t, dir+"/bin") + // no su, no bash → /bin/sh + args := BuildArgs(DefaultOptions(dir)) + if got := args[len(args)-2]; got != "/bin/sh" { + t.Errorf("expected /bin/sh fallback, got %q (full: %v)", got, args[len(args)-3:]) + } + // add bash → /bin/bash + mustWriteExec(t, dir+"/bin/bash") + args = BuildArgs(DefaultOptions(dir)) + if got := args[len(args)-2]; got != "/bin/bash" { + t.Errorf("expected /bin/bash with no su, got %q", got) + } + // add su → /bin/su (with user arg) + mustWriteExec(t, dir+"/bin/su") + args = BuildArgs(DefaultOptions(dir)) + if got := args[len(args)-3]; got != "/bin/su" { + t.Errorf("expected /bin/su when present, got %q", got) + } +} + +// TestBuildArgs_FakeProcSkippedWhenHostReadable: on a host where +// /proc/loadavg is readable (every linux + macOS dev box), the fake +// /proc bind for it must not be emitted. Bash udroid skips them under +// the same condition; stacking the fake on a working /proc bind throws +// proot off and was the cause of /bin/su misexec on first port. +func TestBuildArgs_FakeProcSkippedWhenHostReadable(t *testing.T) { + if _, err := os.Stat("/proc/loadavg"); err != nil { + t.Skip("host has no /proc/loadavg — test would be vacuous") + } + o := DefaultOptions("/x") + got := strings.Join(BuildArgs(o), " ") + if strings.Contains(got, "proc/.loadavg:/proc/loadavg") { + t.Errorf("fake /proc/loadavg bind should be skipped when host /proc/loadavg is readable; got:\n%s", got) + } +} + +func mustMkdir(t *testing.T, p string) { + t.Helper() + if err := os.MkdirAll(p, 0o755); err != nil { + t.Fatal(err) + } +} + +func mustWriteExec(t *testing.T, p string) { + t.Helper() + if err := os.WriteFile(p, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } +} diff --git a/go-udroid/internal/proot/exec.go b/go-udroid/internal/proot/exec.go new file mode 100644 index 0000000..4e0753e --- /dev/null +++ b/go-udroid/internal/proot/exec.go @@ -0,0 +1,147 @@ +package proot + +import ( + "context" + "fmt" + "io" + "log/slog" + "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) + } + slog.Info("proot login exec", + slog.String("rootfs", o.RootFS), + slog.String("user", o.LoginUser), + slog.Int("argc", len(args)), + ) + slog.Debug("proot argv", slog.Any("args", args)) + // Strip Termux's LD_PRELOAD before handing control to proot. Termux + // sets LD_PRELOAD to a host-side shim (libtermux-exec.so) which has + // no equivalent inside the rootfs; if it leaks through, the dynamic + // loader inside the rootfs fails to load it, the kernel ends up + // returning ENOEXEC for the target binary, and execvp's "interpret + // as shell script" fallback hits dash with raw ELF bytes — surfaces + // as `/bin/su: 1: Syntax error: ")" unexpected`. Matches bash udroid + // which runs `unset LD_PRELOAD` at the top of login(). + env := filterEnv(os.Environ(), "LD_PRELOAD") + return syscall.Exec(bin, args, env) +} + +// 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 logged at +// debug level so non-audio installs aren't bothered. +func startPulseAudio() { + bin, err := exec.LookPath("pulseaudio") + if err != nil { + slog.Debug("pulseaudio not found; skipping", slog.Any("err", err)) + return + } + if err := exec.Command(bin, + "--start", + `--load=module-native-protocol-tcp auth-ip-acl=127.0.0.1 auth-anonymous=1`, + "--exit-idle-time=-1", + ).Run(); err != nil { + slog.Debug("pulseaudio start failed", slog.Any("err", err)) + return + } + slog.Debug("pulseaudio started") +} + +// stageRunScript copies a host script into the rootfs root so the inner +// `su -c /