packages feed

encapsule 0.5.1 → 0.6

raw patch · 13 files changed

+465/−147 lines, 13 filesdep ~simple-cmd

Dependency ranges changed: simple-cmd

Files

ChangeLog.md view
@@ -1,5 +1,15 @@ # encapsule releases +## 0.6 (2026-10-10)+- make passwordless sudo setup opt-in with `--sudo` (replaces `--no-sudo`)+- add `--root` as shorthand for `--user root`+- `enter`: add `--root/--user`+- `commit`: use podman instead of buildah+- install default capabilities in datadir/config.toml+  (can be overriden by `XDG_CONFIG_DIRS` files and augmented by user config)+- add `--network-host` to share host networking for local servers+- add `pull` command (currently just a wrapper for `podman pull`)+ ## 0.5.1 (2026-09-15) - `enter` bugfix: -e=LANG must precede container - `enter`: allow an optional command like `run` (`enter TOOLBOX -- tmux`)
README.md view
@@ -14,20 +14,26 @@ Most encapsule subcommands act on an image. - If you wish to use an existing toolbox container as a starting point you can `commit` it to an "encapsule" container image.   - Note your original toolbox container is left untouched: its system configuration and fs are just used as the base fs for the encapsule image (though its original bind mounts including $HOME will be not be included by default).-- Alternatively you can roll your own image or run a vanilla image like `fedora` (`fedora:latest`), `fedora-toolbox:44` or `ubuntu:latest`, etc.-  - However toolbox images or containers are recommended because they include `sudo` and `runuser`, but as such it doesn't have to be a toolbox container.-  - For example since the fedora base container does not include runuser it runs as `--user root` by default (since as of 0.5 util-linux is no longer-installed by default into encapsule containers: this may be addressed in future).+- Alternatively you can roll your own image or run a vanilla image like `fedora` (`fedora:latest`), `fedora-toolbox:45`, `ubuntu:latest`, etc.+  - Toolbox images or containers are generally recommended for development, but it doesn't have to be a toolbox container as such. -Encapsule images and containers are prefixed by `encapsule-`.-There is no need to use this prefix normally - it is implicit.+A typical invocation might look like: +```+$ encapsule someimage:tag --cap my-config --home ~/isolated --project projects/abc+```++(directories can be relative).++Encapsule images and containers are prefixed by `encapsule-`,+but there is no need to use this prefix normally - it is implicit.+ ## Usage  `$ encapsule --version`  ```-0.5.1+0.6 ```  `$ encapsule --help`@@ -47,6 +53,7 @@ Available commands:   list                     List encapsule images and containers   list-caps                List available capabilities+  pull                     Pull a container image with podman   rm                       Remove an encapsule container   rmi                      Remove an encapsule image   stop                     Stop an encapsule container@@ -62,20 +69,19 @@  ### `run` command -`run` starts a temporary encapsule container (removed on exit)-from a (toolbox) image or container.+`run` starts a temporary encapsule container (removed on exit) from an image.  `$ encapsule run --help`  ``` Usage: encapsule run IMAGE [-v|--volume HOST:CONTAINER[:opts]]                      [-e|--env KEY[=VALUE]] [--path DIR] [-i|--init CMD]-                     [--cap NAME] [--pull] [--user USER]+                     [--cap NAME] [--pull] [--root | --user USER]                      [(-H|--home DIR[:opts]) [--backup-home]]                      [(-p|--project DIR[:opts]) [--backup-project]]-                     [-n|--name NAME] [--readonly] [--no-network] [--no-sudo]-                     [--no-skel] [--podman-opt OPTION] [--debug] [--dryrun]-                     [[--] CMD]+                     [-n|--name NAME] [--readonly]+                     [--no-network | --network-host] [--sudo] [--no-skel]+                     [--podman-opt OPTION] [--debug] [--dryrun] [[--] CMD]    Run a temporary encapsule container @@ -88,6 +94,7 @@                            container   --cap NAME               Enable a capability from the config file   --pull                   Pull newer container image+  --root                   Run as root (shorthand for --user root)   --user USER              Override container user [default: host/image user                            with host UID]   -H,--home DIR[:opts]     Mount a directory as a writable home (created if@@ -100,7 +107,8 @@                            skip 'encapsule-' prefix)   --readonly               Make the encapsule container filesystem read-only   --no-network             Disable network access-  --no-sudo                Skip passwordless sudo setup+  --network-host           Share the host network (including localhost ports)+  --sudo                   Set up passwordless sudo for the container user   --no-skel                Don't copy /etc/skel into an empty home   --podman-opt OPTION      Pass an option directly to podman   --debug                  Show debug output@@ -108,6 +116,12 @@   -h,--help                Show this help text ``` +Use `--network-host` with `run` (or `create`) to make servers on arbitrary+container localhost ports reachable on the same host localhost ports.+This also allows access to host-local services and shares the host's port+space. It cannot be combined with `--no-network`. Without either option,+Podman's default isolated container networking applies.+ ### `create` command `create` is similar but creates a reusable container for a project and/or temp home. @@ -119,10 +133,35 @@ $ encapsule enter my-toolbox -- tmux ``` +By default `enter` uses the user matching the host UID. Use `--root` (or+`--user root`) when administrative access is needed:++```bash+$ encapsule enter --root my-toolbox+```+ ### `commit` command `commit` saves a container as an encapsule image (`encapsule-CONTAINER` by default).-Use `-n/--name NAME` for a custom image name (`encapsule-NAME`, or `^NAME` to skip the prefix).+Use `-n/--name NAME` for a custom image name (becomes `encapsule-NAME`, or `^NAME` to skip the prefix). +### `list` command+Lists the encapsule images and containers:++```+$ ncpsl list+localhost/encapsule-harness:latest  6.15 GB  12 days ago++encapsule-harness-encapsule  Up 31 hours+encapsule-fedora  Up 29 minutes+encapsule-fedora-toolbox-45  Up 5 seconds+```++### `backup` command+This is really independent of encapsule.+It provides a simple way to create a backup tarball of a (git) project or dir.+There are also runtime `--backup-project` and `--backup-home` options.++ ## Examples  ```bash@@ -175,7 +214,8 @@ ## Capabilities  Capabilities define reusable groups of volumes, environment variables,-PATH entries, and init commands in `~/.config/encapsule/config.toml`:+PATH entries, and init commands in `/usr/share/encapsule/config.toml`+and `~/.config/encapsule/config.toml`:  ```toml [capabilities.ssh]@@ -193,6 +233,10 @@ path = ["~/.cargo/bin"] ``` +There is an example user config file: `example/config.toml`.++Use `encapsule list-caps` to list all the available defined capabilities.+ Each capability can define:  - `volumes` : list of bind mount specs@@ -205,26 +249,43 @@ If the host and container paths are the same, you can use the shorthand `PATH[:opts]` instead of `PATH:PATH[:opts]`. +The default defined capabilities live in the bundled `data/config.toml` file.+These capabilities can be overridden by XDG system config files+(under `XDG_CONFIG_DIRS`): by default `/etc/xdg/encapsule/config.toml`.+User settings (under `XDG_CONFIG_HOME`) take precedence over these settings;+earlier directories in `XDG_CONFIG_DIRS` take precedence over later ones.+Nested tables are merged, so overriding one field of a capability preserves+its other system defaults. Arrays are replaced rather than appended;+use an empty array to clear a default.++Note that an existing XDG system config, even an empty file,+replaces the bundled default definitions entirely,+whereas the user config will augment or modify them.++To be quite clear, no capabilities are used by default:+they need to be explicitly enabled on the commandline.+The definitions just make them available to use.+ ## How it works -0. Commits the named toolbox container to an encapsule image using `buildah commit`.+0. Optionally commits the named toolbox container to an encapsule image (using `podman commit`). 1. Runs `podman run` with `--userns=keep-id` so you are your own user, not root-2. Drops from root with `runuser` if present, otherwise `sudo -u`-   (`enter` uses `podman exec --user`)-3. Sets up passwordless `sudo` inside the encapsule container (unless `--no-sudo`)+2. Drops from root with `runuser` if present, otherwise `sudo -u`. Explicit+   `--user root`/`--root` runs directly as root (`enter` uses+   `podman exec --user`)+3. Does not grant privilege escalation by default. `--sudo` opts into a+   passwordless sudoers entry when the image contains `sudo` 4. Bind mounts get SELinux `:z` (shared) labels automatically,    so multiple containers can safely access the same directories 5. When `-p/--project DIR` is used (and `--name` isn't), the container name    includes the project directory's name (e.g. `encapsule-mytoolbox-myproject`),    so you can run the same toolbox against different projects at the same time-   in separate encapsule containers. Though for different project paths with-   the same directory name the container name will not be differentiated.+   in separate encapsule containers. For invocations with the same name,+   the container name gets differentiated by pid.  ## Installation -A copr repo is available for Fedora and EPEL 10:--<https://copr.fedorainfracloud.org/coprs/petersen/encapsule/>+Encapsule is packaged in Fedora: <https://src.fedoraproject.org/rpms/encapsule>  ## Building from source @@ -251,8 +312,8 @@ (`--dryrun` against local images, plus an optional live `run`). It needs podman and skips missing images. -Default images are `ubuntu:latest` and `fedora:latest`.-Override with `ENCAPSULE_TEST_UBUNTU` and `ENCAPSULE_TEST_FEDORA`.+Default images are `fedora:latest` and `ubuntu:latest`.+Override with `ENCAPSULE_TEST_FEDORA` and `ENCAPSULE_TEST_UBUNTU`. Live tests need a TTY, or set `ENCAPSULE_LIVE=1` to try without one. `ENCAPSULE` selects a different encapsule binary. @@ -271,9 +332,10 @@  ## Runtime Requirements -- [podman](https://podman.io/) and [buildah](https://buildah.io/)-- An existing (toolbox) container (created with `toolbox create`) or an image.-- Alternatively other non-toolbox container/images can also work.+- [podman](https://podman.io/)+- An existing (toolbox) image (optionally created from a container with `toolbox create`).+  - Use `encapsule pull` (or `podman pull`) to get an image first.+- Encapsule has only been tested on Linux.  ## Related projects @@ -283,7 +345,7 @@  Another somewhat related project is [podenv](https://github.com/podenv/podenv), which "provides a declarative interface to manage containerized applications." -For stronger sandboxing and isolation, specially network, consider using [OpenShell](https://github.com/NVIDIA/OpenShell/). At some point this project might move to wrapping or supporting openshell possibly.+For stronger sandboxing and isolation, specially network, consider using [OpenShell](https://github.com/NVIDIA/OpenShell/). At some point this project might support openshell perhaps.  There is also [litterbox](https://github.com/Gerharddc/litterbox) which has quite a lot of features and though somewhat opinionated, for example like openshell also supports landlock confinement. 
+ data/config.toml view
@@ -0,0 +1,31 @@+# table fields can be overriden by+# - /etc/xdg/encapsule/config.toml (XDG_CONFIG_DIRS)+# - ~/.config/encapsule/config.toml (XDG_CONFIG_HOME)++[capabilities.ssh]+volumes = ["~/.ssh:ro"]++[capabilities.git]+volumes = ["~/.gitconfig:ro"]++# gtk4 needs libglvnd-gles+[capabilities.wayland]+env = ["WAYLAND_DISPLAY", "XDG_RUNTIME_DIR"]+volumes = ["$XDG_RUNTIME_DIR/$WAYLAND_DISPLAY"]+security_opts = ["label=disable"]++[capabilities.dbus]+volumes = ["/run/dbus/system_bus_socket"]++[capabilities.dconf]+volumes = ["$XDG_RUNTIME_DIR/dconf"]++[capabilities.machine-id]+volumes = ["/etc/machine-id:ro"]++[capabilities.rust]+path = ["~/.cargo/bin"]++[capabilities.ssh-agent]+env = ["XDG_RUNTIME_DIR"]+volumes = ["$XDG_RUNTIME_DIR/ssh-agent.sock"]
encapsule.cabal view
@@ -1,12 +1,12 @@ cabal-version:       2.2 name:                encapsule-version:             0.5.1+version:             0.6 synopsis:            Run isolated toolbox containers with podman description:         This tool (originally based on the toolbox-constrained project)         allows running isolated toolbox containers with podman.         Mounting of home and host integration are not enabled by default,-        but one can choose options to do so including capabilities+        but one can use options to do so including capabilities         specified in a toml configuration file. license:             Apache-2.0 license-file:        LICENSE@@ -17,6 +17,7 @@ homepage:            https://github.com/juhp/encapsule bug-reports:         https://github.com/juhp/encapsule/issues build-type:          Simple+data-files:          data/config.toml extra-doc-files:     README.md                      ChangeLog.md                      example/config.toml@@ -84,13 +85,22 @@   type:                exitcode-stdio-1.0   main-is:             Spec.hs   other-modules:       EncapsuleTest-  hs-source-dirs:      test+                       ConfigSpec+                       Config+                       Paths_encapsule+  autogen-modules:     Paths_encapsule+  hs-source-dirs:      test src   build-depends:       base < 5+                     , containers                      , directory                      , filepath                      , hspec                      , process+                     , simple-cmd+                     , text+                     , toml-reader                      , unix+                     , xdg-basedir   build-tool-depends:  encapsule:encapsule   default-language:    Haskell2010   ghc-options:         -Wall -threaded
example/config.toml view
@@ -1,30 +1,10 @@-[capabilities.ssh]-volumes = ["~/.ssh:~/.ssh:ro"]--[capabilities.git]-volumes = ["~/.gitconfig:ro"]--# gtk4 needs libglvnd-gles-[capabilities.wayland]-env = ["WAYLAND_DISPLAY", "XDG_RUNTIME_DIR"]-volumes = ["$XDG_RUNTIME_DIR/$WAYLAND_DISPLAY"]-security_opts = ["label=disable"]--[capabilities.dbus]-volumes = ["/run/dbus/system_bus_socket"]--[capabilities.dconf]-volumes = ["$XDG_RUNTIME_DIR/dconf"]--[capabilities.machine-id]-volumes = ["/etc/machine-id:ro"]--[capabilities.rust]-path = ["~/.cargo/bin"]+# example ~/.config/encapsule/config.toml  [capabilities.isolation] volumes = ["~/isolation:~:rw"] -[capabilities.ssh-agent]-env = ["XDG_RUNTIME_DIR"]-volumes = ["$XDG_RUNTIME_DIR/ssh-agent.sock"]+[capabilities.agents]+volumes = ["~/AGENTS.md:ro"]++[capabilities.unconfined]+security_opts = ["label=disable"]
src/Backup.hs view
@@ -22,6 +22,7 @@ import Expand import ShellQuote +-- FIXME add --output-dir backupCmd :: Bool -> Bool -> Maybe FilePath -> FilePath -> IO () backupCmd dryrun yes moutput dir = do   homedir <- getHomeDirectory >>= canonicalizePath@@ -55,7 +56,7 @@     else do     checkSize yes Nothing src     (if dryrun then cmdN else cmd_) "tar" $ map shellQuote args ++ [base]-  putStrLn $ "Wrote" +-+ tarball+  putStrLn $ (if dryrun then "Would write" else "Wrote") +-+ tarball  -- Prompt when backing up more than this many bytes. largeBackupBytes :: Integer
src/Config.hs view
@@ -9,29 +9,52 @@   ) where -import Data.List (intercalate)+import Control.Monad (filterM)+import Data.List (delete, intercalate) import qualified Data.Map.Strict as Map import Data.Maybe (mapMaybe) import qualified Data.Text as T import SimpleCmd (error', (+-+)) import System.Directory (doesFileExist)-import System.Environment.XDG.BaseDir (getUserConfigFile)+import System.Environment.XDG.BaseDir (getAllConfigFiles, getUserConfigFile) import TOML (Value(..), Table, renderTOMLError, decodeFile) +import Paths_encapsule (getDataFileName)+ progname :: String progname = "encapsule"  loadConfig :: IO (Maybe Table) loadConfig = do-  path <- getUserConfigFile progname "config.toml"-  exists <- doesFileExist path-  if not exists+  userPath <- getUserConfigFile progname "config.toml"+  configPaths <- getAllConfigFiles progname "config.toml" >>= filterM doesFileExist+  paths <- if null $ delete userPath configPaths+           then do+             bundledPath <- getDataFileName "data/config.toml"+             exists <- doesFileExist bundledPath+             return $ configPaths ++ [bundledPath | exists]+           else return configPaths+  if null paths     then return Nothing     else do+      tables <- mapM loadTable paths+      return $ Just $ foldr mergeTables Map.empty tables+  where+    loadTable path = do       result <- decodeFile path       case result of-        Left e -> error' $ "config parse error:" +-+ T.unpack (renderTOMLError e)-        Right table -> return (Just table)+        Left e -> error' $ "config parse error in" +-+ path ++ ":" +-++                          T.unpack (renderTOMLError e)+        Right table -> return table++-- XDG lists the user file first, followed by system files in priority order.+-- Merge nested tables, but replace other values (including arrays).+mergeTables :: Table -> Table -> Table+mergeTables = Map.unionWith mergeValue+  where+    mergeValue (Table preferred) (Table fallback) =+      Table $ mergeTables preferred fallback+    mergeValue preferred _ = preferred  getCapabilities :: Maybe Table -> Table getCapabilities Nothing = Map.empty
src/Enter.hs view
@@ -10,7 +10,7 @@ where  import Control.Monad (unless, when)-import Data.Maybe (fromMaybe, isNothing)+import Data.Maybe (fromMaybe, isJust, isNothing) import SimpleCmd (cmd, cmd_, cmdFull) import System.Directory (canonicalizePath, getHomeDirectory) import System.Exit (exitWith)@@ -20,13 +20,13 @@  import ShellQuote -enterContainer :: Bool -> Bool -> Bool -> String -> [String] -> IO ()-enterContainer dryrun debug running container command = do+enterContainer :: Bool -> Bool -> Bool -> Maybe String -> String -> [String] -> IO ()+enterContainer dryrun debug running muser container command = do   hostHome <- getHomeDirectory >>= canonicalizePath   unless running $ do     putStr "start "     cmd_ "podman" ["start", container]-  (username, mPasswdHome) <- lookupContainerUser container+  (username, mPasswdHome) <- lookupContainerUser container muser   wd <- containerWorkdir container   let userCmd = if null command then ["bash"] else command       homeDir = fromMaybe hostHome mPasswdHome@@ -38,7 +38,9 @@           "/" -> homeDir           d -> d       homeEnv =-        if isNothing mPasswdHome then ["env", "HOME=" ++ homeDir] else []+        if isJust muser || isNothing mPasswdHome+        then ["env", "HOME=" ++ homeDir]+        else []       execArgs = ["exec", "-it", "--user", username]                  ++ langEnvArgs                  ++ ["--workdir", workdir, container]@@ -72,17 +74,17 @@   | isAbsolute h = Just h   | otherwise = Nothing -lookupContainerUser :: String -> IO (String, Maybe FilePath)-lookupContainerUser container = do-  hostName <- getEffectiveUserName+lookupContainerUser :: String -> Maybe String -> IO (String, Maybe FilePath)+lookupContainerUser container muser = do+  hostUserName <- getEffectiveUserName   uid <- getEffectiveUserID   let uidStr = show (fromIntegral uid :: Integer)-      sh = passwdEntryForUidSh uidStr+      sh = maybe (passwdEntryForUidSh uidStr) passwdEntryForNameSh muser   (_, out, _) <- cmdFull "podman" ["exec", container, "/bin/sh", "-c", sh] ""   case lines out of     (n:h:_) | not (null n) -> return (n, usablePasswdHome h)     (n:_) | not (null n) -> return (n, Nothing)-    _ -> return (hostName, Nothing)+    _ -> return (fromMaybe hostUserName muser, Nothing)  containerWorkdir :: String -> IO String containerWorkdir container =
src/Main.hs view
@@ -28,6 +28,8 @@       pure listCmd     , Subcommand "list-caps" "List available capabilities" $       pure listCapsCmd+    , Subcommand "pull" "Pull a container image with podman" $+      pullCmd <$> strArg "IMAGE"     , Subcommand "rm" "Remove an encapsule container" $       removeCmd       <$> strArg "TOOLBOX"@@ -60,6 +62,7 @@       <*> pure True       <*> optional (strArg "TOOLBOX")       <*> optional projectNameOpt+      <*> optional userNameOpt       <*> many (strArg "[--] CMD")     , Subcommand "run" "Run a temporary encapsule container" $       runCmd <$> runOpts False True@@ -74,7 +77,7 @@     projectNameOpt = Project <$> projectOpt "Project name or path" <|>                      Name <$> nameOpt -    backupDirOpt s l m h =+    dirOptBackup s l m h =       let pair fs sn = (fs,sn) in         pair         <$> strOptionWith s l m h@@ -82,6 +85,10 @@      debugOpt = switchLongWith "debug" "Show debug output" +    userNameOpt =+      flagLongWith' "root" "root" "Run as root (shorthand for --user root)" <|>+      strOptionLongWith "user" "USER" "Override container user [default: host/image user with host UID]"+     runOpts keep unique =       Run.RunOpts       <$> strArg "IMAGE"@@ -91,14 +98,16 @@       <*> many (strOptionWith 'i' "init" "CMD" "A bash snippet run when creating the encapsule container")       <*> many (strOptionLongWith "cap" "NAME" "Enable a capability from the config file")       <*> switchLongWith "pull" "Pull newer container image"-      <*> optional (strOptionLongWith "user" "USER" "Override container user [default: host/image user with host UID]")-      <*> optional (backupDirOpt 'H' "home" "DIR[:opts]" "Mount a directory as a writable home (created if missing; use DIR:O to overlay)")-      <*> optional (backupDirOpt 'p' "project" "DIR[:opts]" "Mount a (project) directory as workdir (use DIR:O to overlay)")+      <*> optional userNameOpt+      <*> optional (dirOptBackup 'H' "home" "DIR[:opts]" "Mount a directory as a writable home (created if missing; use DIR:O to overlay)")+      <*> optional (dirOptBackup 'p' "project" "DIR[:opts]" "Mount a (project) directory as workdir (use DIR:O to overlay)")       <*> optional nameOpt       <*> pure keep       <*> switchLongWith "readonly" "Make the encapsule container filesystem read-only"-      <*> switchLongWith "no-network" "Disable network access"-      <*> switchLongWith "no-sudo" "Skip passwordless sudo setup"+      <*> optional+          (flagLongWith' NetNone "no-network" "Disable network access" <|>+           flagLongWith' NetHost "network-host" "Share the host network (including localhost ports)")+      <*> switchLongWith "sudo" "Set up passwordless sudo for the container user"       <*> switchLongWith "no-skel" "Don't copy /etc/skel into an empty home"       <*> pure unique       <*> many (strOptionLongWith "podman-opt" "OPTION" "Pass an option directly to podman")@@ -157,8 +166,9 @@     else warning $ "container" +-+ containerName +-+ "not found"  enterCmd :: Bool -> Bool -> Bool -> Maybe String -> Maybe ProjectName+         -> Maybe String          -> [String] -> IO ()-enterCmd dryrun debug running mbase mprojectname command = do+enterCmd dryrun debug running mbase mprojectname muser command = do   regexp <-     case mprojectname of       Nothing -> return $ progname +=+ fromMaybe "" mbase@@ -175,16 +185,21 @@     [] ->       if running       then do-        enterCmd dryrun debug False mbase mprojectname command+        enterCmd dryrun debug False mbase mprojectname muser command       else error' "encapsule container not found"     [c] -> do       unless running $         warning "no running encapsule container found"-      enterContainer dryrun debug True c command+      enterContainer dryrun debug True muser c command     _ -> error' $ "multiple" +-+ (if running then  "running" else "") +-+ "containers match:\n" ++ unlines ps  -- image management +pullCmd :: String -> IO ()+pullCmd image = do+  needPodman+  cmd_ "podman" ["pull", image]+ commitCmd :: Bool -> Maybe String -> String -> IO () commitCmd dryrun mname toolbox = do   needPodman@@ -197,9 +212,7 @@   imageExists <- cmdBool "podman" ["image", "exists", image]   unless imageExists $     putStrLn $ "creating new image:" +-+ image-  let buildah_args = ["commit", "--disable-compression", toolbox, image]-  if dryrun-    then cmdN "buildah" buildah_args-    else do-      putStr "writing image "-      cmd_ "buildah" buildah_args+  let commit_args = ["commit", toolbox, image]+  unless dryrun $+    putStr "writing image "+  (if dryrun then cmdN else cmd_) "podman" commit_args
src/Run.hs view
@@ -4,6 +4,7 @@  module Run (   ProjectName(..),+  NetworkMode(..),   RunOpts(..),   runCmd,   (+=+),@@ -46,6 +47,8 @@  data ProjectName = Project FilePath | Name String +data NetworkMode = NetNone | NetHost+ data RunOpts = RunOpts   { toolbox :: String   , vols :: [String]@@ -60,8 +63,8 @@   , mname :: Maybe String   , keep :: Bool   , readonly :: Bool-  , nonetwork :: Bool-  , nosudo :: Bool+  , networkMode :: Maybe NetworkMode+  , sudoEnable :: Bool   , noskel :: Bool   , unique :: Bool   , podmanopts :: [String]@@ -122,15 +125,15 @@             , isNothing muser             , not keep             , not readonly-            , not nonetwork-            , not nosudo+            , isNothing networkMode+            , not sudoEnable             , not noskel             , null podmanopts             ]       unless noopts $         error' "cannot give options for an existing container!"       warning "Entering existing container"-      enterContainer dryrun debugging True container command+      enterContainer dryrun debugging True Nothing container command     else do       when backupHome $         whenJust mhomeDir $ backupCmd dryrun False Nothing@@ -184,16 +187,20 @@           Nothing -> maybe getEffectiveUserName return mImageUser       debug $ "user:" +-+ username -      let switch = chooseSwitchUser haveRunuser haveSudo+      let mswitch =+            if username == "root"+            then Nothing+            else chooseSwitchUser haveRunuser haveSudo           startAsRoot =-            canSwitchUser switch+            username == "root"+            || isJust mswitch             || isNothing mhome && isNothing muser && isNothing mImageUser-          stayAsRoot = startAsRoot && not (canSwitchUser switch)+          stayAsRoot = startAsRoot && isNothing mswitch           (containerHome, overrideHome) =             if stayAsRoot             then ("/root", False)             else (fromMaybe hostHome mPasswdHome, isNothing mPasswdHome)-      debug $ "switch:" +-+ switchLabel switch+      debug $ "switch:" +-+ maybe "none" switchLabel mswitch       debug $ "container home:" +-+ containerHome        homeVol <-@@ -250,14 +257,14 @@           -- mkdir+chown when not bind-mounting --home. A passwd home may           -- already exist but not be writable (committed toolbox image).           setupArgs =-            Setup nosudo noskel (TL.pack username) progname (isNothing mhome) (TL.pack containerHome) mprojectDir+            Setup sudoEnable noskel (TL.pack username) progname (isNothing mhome) (TL.pack containerHome) mprojectDir            setupParts =-            let setup = setupScript debugging switch haveSudo setupArgs+            let setup = setupScript debugging mswitch haveSudo setupArgs             in [setup | not (null setup)] ++                [mkInitSetup allinits | not (null allinits)]           finalCmd =-            case switchUserArgs switch username of+            case maybe [] (switchUserArgs username) mswitch of               []   -> "exec" +-+ userCmd               args -> "exec" +-+ unwords args +-+ userCmd           execScript =@@ -322,7 +329,10 @@                               Nothing -> ["--tmpfs", containerHome]                               Just _ -> []                     else [])-                ++ (if nonetwork then ["--net", "none"] else [])+                ++ (case networkMode of+                      Nothing -> []+                      Just NetNone -> ["--net", "none"]+                      Just NetHost -> ["--net", "host"])                 ++ concatMap (\s -> ["--security-opt", s]) securityOpts                 ++ concatMap (\m -> ["-v", m]) (tzMounts ++ mounts)                 ++ concatMap (\e -> ["-e", e]) envVars
src/Script.hs view
@@ -8,7 +8,6 @@ module Script (   SwitchUser(..),   chooseSwitchUser,-  canSwitchUser,   switchUserArgs,   switchLabel,   setupScript,@@ -18,37 +17,31 @@  import Control.Monad (unless, when) import Control.Monad.Shell-import Data.Maybe (isNothing)+import Data.Maybe (isJust, isNothing) import qualified Data.Text.Lazy as T import System.FilePath ((</>)) import System.Posix.IO  default (T.Text) -data SwitchUser = Runuser | Sudo | None--chooseSwitchUser :: Bool -> Bool -> SwitchUser-chooseSwitchUser True _     = Runuser-chooseSwitchUser False True = Sudo-chooseSwitchUser _ _        = None+data SwitchUser = Runuser | Sudo -canSwitchUser :: SwitchUser -> Bool-canSwitchUser None = False-canSwitchUser _    = True+chooseSwitchUser :: Bool -> Bool -> Maybe SwitchUser+chooseSwitchUser True _     = Just Runuser+chooseSwitchUser False True = Just Sudo+chooseSwitchUser _ _        = Nothing  -- argv prefix; empty for None-switchUserArgs :: SwitchUser -> String -> [String]-switchUserArgs Runuser u = ["runuser", "-u", u, "--"]-switchUserArgs Sudo    u = ["sudo", "-n", "--preserve-env", "-u", u, "--"]-switchUserArgs None    _ = []+switchUserArgs :: String -> SwitchUser -> [String]+switchUserArgs u Runuser = ["runuser", "-u", u, "--"]+switchUserArgs u Sudo = ["sudo", "-n", "--preserve-env", "-u", u, "--"]  switchLabel :: SwitchUser -> String switchLabel Runuser = "runuser" switchLabel Sudo    = "sudo"-switchLabel None    = "none"  data Setup = Setup-  { nosudo :: Bool+  { sudoEnable :: Bool   , noskel :: Bool   , username :: T.Text   , program :: String@@ -57,8 +50,8 @@   , mprojectDir :: Maybe FilePath   } -setupScript :: Bool -> SwitchUser -> Bool -> Setup -> String-setupScript dbg switch haveSudo (Setup {..}) =+setupScript :: Bool -> Maybe SwitchUser -> Bool -> Setup -> String+setupScript dbg mswitch haveSudo (Setup {..}) =   T.unpack . T.replace "\t" " " . linearScript $   sudoSetup >> homeSetup   where@@ -69,24 +62,26 @@      sudoSetup =       when haveSudo $-      unless nosudo $-      let sudoers = "/etc/sudoers.d" in-          whenCmd (test $ TDirExists sudoers) $ do-          runHide "echo" [username, "ALL=(ALL) NOPASSWD:ALL"] `redir` (sudoers </> program)-          runHide "chmod" ["440", T.pack sudoers]+      when sudoEnable $+      -- FIXME handle no sudoers.d?+      let sudoersd = "/etc/sudoers.d" in+        whenCmd (test $ TDirExists sudoersd) $ do+        let sudoersfile = sudoersd </> program+        runHide "echo" [username, "ALL=(ALL) NOPASSWD:ALL"] `redir` sudoersfile+        runHide "chmod" ["440", T.pack sudoersfile]      homeSetup = do       when createhome $ do         runHide "mkdir" ["-p", homedir]         runHide "chown" [username, homedir]       unless noskel $-        when (canSwitchUser switch) $+        when (isJust mswitch) $         whenCmd         (test (TDirExists homedir)          -&&-          test (TDirExists (T.pack "/etc/skel"))) $         let cpArgs = ["-a", "--update=none", "/etc/skel/.", homedir <> "/"]-        in case map T.pack $ switchUserArgs switch (T.unpack username) of+        in case map T.pack $ maybe [] (switchUserArgs (T.unpack username)) mswitch of              prog:args -> runHide prog $ args ++ "cp" : cpArgs              [] -> return ()       when (isNothing mprojectDir && createhome) $
+ test/ConfigSpec.hs view
@@ -0,0 +1,160 @@+-- SPDX-License-Identifier: Apache-2.0++module ConfigSpec (spec) where++import Control.Exception (bracket)+import qualified Data.Map.Strict as Map+import System.Directory (createDirectoryIfMissing, removeDirectoryRecursive)+import System.Environment (lookupEnv, setEnv, unsetEnv)+import System.FilePath ((</>), takeDirectory)+import System.Posix.Temp (mkdtemp)+import Test.Hspec++import Config (getCapabilities, loadConfig, resolveCapabilities)+import EncapsuleTest (encapsule)++spec :: Spec+spec = describe "config" $ do+  it "returns Nothing when no config files exist, including bundled defaults" $+    withConfigDirs $ \_ _ _ ->+      loadConfig `shouldReturn` Nothing++  it "loads bundled defaults when no user or system config exists" $+    withConfigDirs $ \user _ _ -> do+      writeBundledConfig user "[capabilities.bundled]\nenv = ['BUNDLED']\n"+      resolve ["bundled"] `shouldReturn` ([], ["BUNDLED"], [], [], [])+      out <- encapsule ["list-caps"]+      out `shouldBe` "Available capabilities:\n  bundled\n"++  it "merges user overrides over bundled defaults without a system config" $+    withConfigDirs $ \user _ _ -> do+      writeBundledConfig user $ unlines+        [ "[capabilities.shared]"+        , "env = ['BUNDLED']"+        , "path = ['/bundled/bin']"+        , "[capabilities.bundled]"+        , "volumes = ['bundled-volume']"+        ]+      writeConfig user "[capabilities.shared]\nenv = ['USER']\n"+      resolve ["shared", "bundled"] `shouldReturn`+        (["bundled-volume"], ["USER"], ["/bundled/bin"], [], [])++  it "ignores bundled defaults when a system config exists" $+    withConfigDirs $ \user _ system -> do+      writeBundledConfig user "[capabilities.bundled]\nenv = ['BUNDLED']\n"+      writeConfig system "[capabilities.system]\nenv = ['SYSTEM']\n"+      out <- encapsule ["list-caps"]+      out `shouldBe` "Available capabilities:\n  system\n"++  it "lets an empty system config suppress bundled defaults" $+    withConfigDirs $ \user system _ -> do+      writeBundledConfig user "[capabilities.bundled]\nenv = ['BUNDLED']\n"+      writeConfig system ""+      (getCapabilities <$> loadConfig) `shouldReturn` Map.empty++  it "reports malformed bundled defaults when no system config exists" $+    withConfigDirs $ \user _ _ -> do+      writeBundledConfig user "[broken\n"+      out <- encapsule ["list-caps"]+      out `shouldContain` "config parse error in"+      out `shouldContain` (takeDirectory user </> "bundled" </> "data" </> "config.toml")++  it "ignores malformed bundled defaults when a system config exists" $+    withConfigDirs $ \user system _ -> do+      writeBundledConfig user "[broken\n"+      writeConfig system "[capabilities.system]\nenv = ['SYSTEM']\n"+      resolve ["system"] `shouldReturn` ([], ["SYSTEM"], [], [], [])++  it "loads a user config without a system config" $+    withConfigDirs $ \user _ _ -> do+      writeConfig user "[capabilities.user]\nenv = ['USER_ONLY']\n"+      resolve ["user"] `shouldReturn` ([], ["USER_ONLY"], [], [], [])++  it "loads a system config without a user config" $+    withConfigDirs $ \_ system _ -> do+      writeConfig system "[capabilities.system]\nenv = ['SYSTEM_ONLY']\n"+      resolve ["system"] `shouldReturn` ([], ["SYSTEM_ONLY"], [], [], [])++  it "merges capability fields and replaces arrays and scalar values" $+    withConfigDirs $ \user system _ -> do+      writeConfig system $ unlines+        [ "[capabilities.shared]"+        , "volumes = ['system-volume']"+        , "env = ['SYSTEM_ENV']"+        , "path = ['/system/bin']"+        , "init = 'system-init'"+        , "security_opts = ['label=disable']"+        , "[capabilities.system]"+        , "env = ['SYSTEM_ONLY']"+        ]+      writeConfig user $ unlines+        [ "[capabilities.shared]"+        , "volumes = []"+        , "env = ['USER_ENV']"+        , "init = 'user-init'"+        , "[capabilities.user]"+        , "env = ['USER_ONLY']"+        ]+      resolve ["shared"] `shouldReturn`+        ([], ["USER_ENV"], ["/system/bin"], ["user-init"], ["label=disable"])+      resolve ["system", "user"] `shouldReturn`+        ([], ["SYSTEM_ONLY", "USER_ONLY"], [], [], [])++  it "respects system directory order and user precedence" $+    withConfigDirs $ \user first second -> do+      writeConfig second "[capabilities.shared]\nenv = ['SECOND']\npath = ['/second/bin']\n"+      writeConfig first "[capabilities.shared]\nenv = ['FIRST']\n"+      resolve ["shared"] `shouldReturn` ([], ["FIRST"], ["/second/bin"], [], [])+      writeConfig user "[capabilities.shared]\nenv = ['USER']\n"+      resolve ["shared"] `shouldReturn` ([], ["USER"], ["/second/bin"], [], [])++  it "lets a user value replace a system table" $+    withConfigDirs $ \user system _ -> do+      writeConfig system "[capabilities.shared]\nenv = ['SYSTEM']\n"+      writeConfig user "capabilities = []\n"+      (getCapabilities <$> loadConfig) `shouldReturn` Map.empty++  it "reports the path of a malformed system config even with a user config" $+    withConfigDirs $ \user system _ -> do+      writeConfig user "[capabilities.user]\nenv = ['USER']\n"+      writeConfig system "[broken\n"+      out <- encapsule ["list-caps"]+      out `shouldContain` "config parse error in"+      out `shouldContain` (system </> "encapsule" </> "config.toml")++resolve :: [String] -> IO ([String], [String], [String], [String], [String])+resolve names = do+  caps <- getCapabilities <$> loadConfig+  resolveCapabilities caps names++writeConfig :: FilePath -> String -> IO ()+writeConfig dir contents = do+  let appDir = dir </> "encapsule"+  createDirectoryIfMissing True appDir+  writeFile (appDir </> "config.toml") contents++writeBundledConfig :: FilePath -> String -> IO ()+writeBundledConfig user contents = do+  let dataDir = takeDirectory user </> "bundled" </> "data"+  createDirectoryIfMissing True dataDir+  writeFile (dataDir </> "config.toml") contents++withConfigDirs :: (FilePath -> FilePath -> FilePath -> IO a) -> IO a+withConfigDirs action =+  bracket (mkdtemp "/tmp/encapsule-config-test-XXXXXX") removeDirectoryRecursive $ \tmp -> do+    let user = tmp </> "user"+        first = tmp </> "system-first"+        second = tmp </> "system-second"+    withEnv "XDG_CONFIG_HOME" user $+      withEnv "XDG_CONFIG_DIRS" (first ++ ":" ++ second) $+        withEnv "encapsule_datadir" (tmp </> "bundled") $+          action user first second++withEnv :: String -> String -> IO a -> IO a+withEnv key value action =+  bracket (lookupEnv key) restore $ \_ -> do+    setEnv key value+    action+  where+    restore Nothing = unsetEnv key+    restore (Just old) = setEnv key old
test/Spec.hs view
@@ -16,13 +16,20 @@ import Test.Hspec  import EncapsuleTest+import qualified ConfigSpec  main :: IO () main = hspec spec  spec :: Spec spec = do+  ConfigSpec.spec   describe "dryrun" $ do+    it "shares host networking with --network-host" $+      withGenericImage $ \img -> do+        out <- dryrun ["--network-host", img]+        out `shouldContain` "--net host"+     it "podman run has keep-id, TERM & LANG" $       withGenericImage $ \img -> do         out <- dryrun [img]@@ -53,15 +60,24 @@           out `shouldContain` "--name encapsule-"           out `shouldContain` "-proj" -    it "uses runuser or sudo to switch with --user root" $+    it "runs directly as root with --user root" $       withGenericImage $ \img -> do-        out <- dryrun [img]-        case debugField out "switch" of-          Just "none" -> pendingWith $ img ++ " has no runuser or sudo"-          _ -> do-            out' <- dryrun ["--user", "root", img]-            assertUserSwitch out' "root"+        out <- dryrun ["--user", "root", img]+        debugField out "user" `shouldBe` Just "root"+        debugField out "switch" `shouldBe` Just "none"+        debugField out "container home" `shouldBe` Just "/root"+        out `shouldContain` "--user=root"+        out `shouldNotContain` "runuser -u root"+        out `shouldNotContain` "sudo -n --preserve-env -u root" +    it "treats --root as shorthand for --user root" $+      withGenericImage $ \img -> do+        out <- dryrun ["--root", img]+        debugField out "user" `shouldBe` Just "root"+        debugField out "switch" `shouldBe` Just "none"+        debugField out "container home" `shouldBe` Just "/root"+        out `shouldContain` "--user=root"+   describe "ubuntu" $ do     it "uses ubuntu user and passwd home for UID 1000" $ do       img <- ubuntuImg@@ -157,18 +173,17 @@           pendingWith $ "image has uid user " ++ fromMaybe "unknown" other    describe "sudo" $ do-    it "writes sudoers when sudo is present, skips when not" $+    it "only writes sudoers when --sudo is given and sudo is present" $       withGenericImage $ \img -> do         out <- dryrun [img]+        out `shouldNotContain` "NOPASSWD:ALL"         case debugField out "sudo" of           Just "True" -> do-            out `shouldContain` "NOPASSWD:ALL"-            outNo <- dryrun ["--no-sudo", img]-            outNo `shouldNotContain` "NOPASSWD:ALL"+            outSudo <- dryrun ["--sudo", img]+            outSudo `shouldContain` "NOPASSWD:ALL"           Just "False" -> do-            out `shouldNotContain` "NOPASSWD:ALL"-            outNo <- dryrun ["--no-sudo", img]-            outNo `shouldNotContain` "NOPASSWD:ALL"+            outSudo <- dryrun ["--sudo", img]+            outSudo `shouldNotContain` "NOPASSWD:ALL"           other ->             expectationFailure $               "debug sudo line for " ++ img ++ " (got: " ++@@ -179,6 +194,12 @@       out <- encapsule ["enter", "--help"]       out `shouldContain` "[--] CMD" +    it "offers explicit user and root selection" $ do+      out <- encapsule ["enter", "--help"]+      out `shouldContain` "[--root | --user USER]"+      out `shouldContain` "--user USER"+      out `shouldContain` "--root"+   describe "commit" $ do     it "offers --name" $ do       out <- encapsule ["commit", "--help"]@@ -187,7 +208,7 @@     it "names the image encapsule-CONTAINER by default" $       withScratchContainer $ \cname -> do         out <- encapsule ["commit", "--dryrun", cname]-        out `shouldContain` "buildah commit"+        out `shouldContain` "podman commit"         out `shouldContain` ("encapsule-" ++ cname)      it "applies --name to the encapsule image" $