From 2d94c8d5b3bcb1225be211e53bc4f7cbc668b408 Mon Sep 17 00:00:00 2001 From: Hadi Chokr Date: Wed, 26 Aug 2026 12:50:53 +0200 Subject: [PATCH] Add export and unexport commands for desktop and CLI integration Closes #1018 Signed-off-by: Hadi Chokr --- doc/meson.build | 2 + doc/toolbox-export.1.md | 101 ++++++++ doc/toolbox-rm.1.md | 12 +- doc/toolbox-unexport.1.md | 71 ++++++ doc/toolbox.1.md | 8 + src/cmd/export.go | 427 ++++++++++++++++++++++++++++++++++ src/cmd/exportInspect.go | 300 ++++++++++++++++++++++++ src/cmd/rm.go | 10 + src/cmd/unexport.go | 228 ++++++++++++++++++ src/meson.build | 3 + test/system/109-export.bats | 243 +++++++++++++++++++ test/system/110-unexport.bats | 201 ++++++++++++++++ test/system/libs/helpers.bash | 72 ++++++ test/system/meson.build | 2 + 14 files changed, 1678 insertions(+), 2 deletions(-) create mode 100644 doc/toolbox-export.1.md create mode 100644 doc/toolbox-unexport.1.md create mode 100644 src/cmd/export.go create mode 100644 src/cmd/exportInspect.go create mode 100644 src/cmd/unexport.go create mode 100644 test/system/109-export.bats create mode 100644 test/system/110-unexport.bats diff --git a/doc/meson.build b/doc/meson.build index f5b3666f7..7b9b30f33 100644 --- a/doc/meson.build +++ b/doc/meson.build @@ -15,6 +15,8 @@ manuals = { 'toolbox-rm', 'toolbox-rmi', 'toolbox-run', + 'toolbox-export', + 'toolbox-unexport', ], '5': [ 'toolbox.conf', diff --git a/doc/toolbox-export.1.md b/doc/toolbox-export.1.md new file mode 100644 index 000000000..5bf715e9a --- /dev/null +++ b/doc/toolbox-export.1.md @@ -0,0 +1,101 @@ +% toolbox-export 1 + +## NAME +toolbox\-export - Export an application or a binary from a Toolbx container + +## SYNOPSIS +**toolbox export** [*--app APP* | *--bin BIN*] [*--container NAME*] [*--force*] + +## DESCRIPTION + +Makes a graphical application or a command line tool from inside a Toolbx +container available on the host. + +Exporting an application writes a copy of its desktop entry to +`$XDG_DATA_HOME/applications`, with the `Exec` and `TryExec` keys rewritten to +go through `toolbox run`. The basename of the desktop entry is preserved, +because Wayland compositors match the `app_id` of a window against it to find +the corresponding icon and name. Renaming the entry would break that mapping. + +Icons referenced by the entry are copied to +`$XDG_DATA_HOME/toolbx/NAME/icons/APP-ID` and referenced from the exported +entry by their absolute path. The icon themes on the host are left untouched. + +Exporting a binary writes a small shell script to `$XDG_BIN_HOME`, which +executes the binary through `toolbox run`. Note that this directory is not +part of `PATH` on every operating system. A warning is shown if it isn't. + +Applications and binaries are looked up inside the container itself, using the +container's own `XDG_DATA_HOME` and `XDG_DATA_DIRS`. The host doesn't read the +container's file system. + +Exported files record the container they came from. Desktop entries get an +`X-Toolbx-Container` key, and shell scripts get a comment in the same form. +Files that lack such a marker, or that carry the name of a different +container, aren't overwritten unless the `--force` option is used, and are +never removed by `toolbox unexport`. + +Applications are resolved by their desktop file ID with the trailing +`.desktop` being optional. If no exact match is found, an entry whose ID ends +with the given name is used, so that `--app gimp` finds `org.gimp.GIMP`. + +## OPTIONS ## + +The following options are understood: + +**--app** APP + +Export the application with the given desktop entry. Can't be used together +with `--bin`. + +**--bin** BIN + +Export the binary with the given name. The name is looked up in the +container's `PATH`. Can't be used together with `--app`. + +**--container** NAME, **-c** NAME + +Export from the Toolbx container with the given NAME. This is useful when +multiple containers are present. + +**--force** + +Overwrite an existing file even if it wasn't exported from the same container, +or wasn't exported by Toolbx at all. + +## NOTES + +Two keys of an exported desktop entry are adjusted, because they're meaningless +once the `Exec` line runs on the host: + +`DBusActivatable` is set to `false`. Otherwise the launcher tries to activate a +bus name that only exists inside the container. + +`Path` is commented out. It's the working directory that the launcher enters +before spawning the application, and it usually doesn't exist on the host. +The working directory inside the container is unaffected. + +## EXAMPLES + +### Export GIMP from the default Toolbx container + +``` +$ toolbox export --app gimp +``` + +### Export Neovim from a container called fedora-toolbox-42 + +``` +$ toolbox export --bin nvim --container fedora-toolbox-42 +``` + +### Replace an entry that was exported from a different container + +``` +$ toolbox export --app gimp --container arch-toolbox-latest --force +``` + +## SEE ALSO + +`toolbox(1)`, `toolbox-run(1)`, `toolbox-unexport(1)`, +https://specifications.freedesktop.org/desktop-entry-spec/latest/ diff --git a/doc/toolbox-rm.1.md b/doc/toolbox-rm.1.md index e4c1c52af..614a921ee 100644 --- a/doc/toolbox-rm.1.md +++ b/doc/toolbox-rm.1.md @@ -11,8 +11,15 @@ toolbox\-rm - Remove one or more Toolbx containers Removes one or more Toolbx containers from the host. The container should have been created using the `toolbox create` command. +Before a container is removed, anything that was exported from it with +`toolbox export` is removed as well, the same as running +`toolbox unexport --all --container NAME` against it first. If a file +couldn't be removed for some reason, this is reported as an error, but the +container is still removed. + A Toolbx container is an OCI container. Therefore, `toolbox rm` can be used -interchangeably with `podman rm`. +interchangeably with `podman rm`, except for the removal of exported files, +which is a Toolbx concept `podman rm` doesn't know about. ## OPTIONS ## @@ -49,4 +56,5 @@ $ toolbox rm --all --force ## SEE ALSO -`toolbox(1)`, `podman(1)`, `podman-rm(1)` +`toolbox(1)`, `toolbox-export(1)`, `toolbox-unexport(1)`, `podman(1)`, +`podman-rm(1)` diff --git a/doc/toolbox-unexport.1.md b/doc/toolbox-unexport.1.md new file mode 100644 index 000000000..4ee7f8ae6 --- /dev/null +++ b/doc/toolbox-unexport.1.md @@ -0,0 +1,71 @@ +% toolbox-unexport 1 + +## NAME +toolbox\-unexport - Remove an application or a binary exported from a Toolbx container + +## SYNOPSIS +**toolbox unexport** [*--all* | *--app APP* | *--bin BIN*] [*--container NAME*] + +## DESCRIPTION + +Removes what `toolbox export` made available on the host. + +Only files that record the given container are removed. Desktop entries are +matched by their `X-Toolbx-Container` key, and shell scripts by a comment in +the same form. A file that was written by hand, or that was exported from a +different container, is left alone and reported as an error. + +Removing an application also removes the icons that were copied along with it, +from `$XDG_DATA_HOME/toolbx/NAME/icons/APP-ID`. + +`toolbox rm` runs the equivalent of `unexport --all` on a container before +removing it, so anything exported from that container is cleaned up +automatically. Running `unexport` by hand beforehand is no longer necessary, +but is still available if only some of the exported items should be removed. + +## OPTIONS ## + +The following options are understood: + +**--all** + +Remove everything that was exported from the container. Can't be used together +with `--app` or `--bin`. + +**--app** APP + +Remove the application with the given desktop entry. The trailing `.desktop` +is optional. Can't be used together with `--bin`. + +**--bin** BIN + +Remove the binary with the given name. Can't be used together with `--app`. + +**--container** NAME, **-c** NAME + +Remove what was exported from the Toolbx container with the given NAME. This +is useful when multiple containers are present. + +## EXAMPLES + +### Remove GIMP exported from the default Toolbx container + +``` +$ toolbox unexport --app gimp +``` + +### Remove Neovim exported from a container called fedora-toolbox-42 + +``` +$ toolbox unexport --bin nvim --container fedora-toolbox-42 +``` + +### Remove a container along with everything exported from it + +``` +$ toolbox rm fedora-toolbox-42 +``` + +## SEE ALSO + +`toolbox(1)`, `toolbox-export(1)`, `toolbox-rm(1)` diff --git a/doc/toolbox.1.md b/doc/toolbox.1.md index b30efd40c..40f42171b 100644 --- a/doc/toolbox.1.md +++ b/doc/toolbox.1.md @@ -137,6 +137,10 @@ Create a new Toolbx container. Enter a Toolbx container for interactive use. +**toolbox-export(1)** + +Export an application or a binary from a Toolbx container. + **toolbox-help(1)** Display help information about Toolbx. @@ -161,6 +165,10 @@ Remove one or more Toolbx images. Run a command in an existing Toolbx container. +**toolbox-unexport(1)** + +Remove an application or a binary exported from a Toolbx container. + ## FILES ## **toolbox.conf(5)** diff --git a/src/cmd/export.go b/src/cmd/export.go new file mode 100644 index 000000000..9a8378815 --- /dev/null +++ b/src/cmd/export.go @@ -0,0 +1,427 @@ +/* + * Copyright © 2026 Hadi Chokr + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package cmd + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/containers/toolbox/pkg/utils" + "github.com/google/renameio/v2" + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" +) + +const exportContainerKey = "X-Toolbx-Container" + +type exportIcon struct { + Path string `json:"path"` + Data []byte `json:"data"` +} + +type exportManifest struct { + AppID string `json:"appId,omitempty"` + Desktop string `json:"desktop,omitempty"` + Icons []exportIcon `json:"icons,omitempty"` + Binary string `json:"binary,omitempty"` +} + +var ( + exportFlags struct { + app string + bin string + container string + force bool + } +) + +var exportCmd = &cobra.Command{ + Use: "export", + Short: "Export an application or a binary from a Toolbx container", + RunE: export, + ValidArgsFunction: completionEmpty, +} + +func init() { + flags := exportCmd.Flags() + + flags.StringVar(&exportFlags.app, "app", "", "Export the application with the given desktop entry") + + flags.StringVar(&exportFlags.bin, "bin", "", "Export the binary with the given name") + + flags.StringVarP(&exportFlags.container, + "container", + "c", + "", + "Export from the Toolbx container with the given name") + + flags.BoolVar(&exportFlags.force, + "force", + false, + "Overwrite files that weren't exported from the same container") + + if err := exportCmd.RegisterFlagCompletionFunc("container", completionContainerNames); err != nil { + panicMsg := fmt.Sprintf("failed to register flag completion function: %v", err) + panic(panicMsg) + } + + exportCmd.SetHelpFunc(exportHelp) + rootCmd.AddCommand(exportCmd) +} + +func export(cmd *cobra.Command, args []string) error { + if utils.IsInsideContainer() { + if !utils.IsInsideToolboxContainer() { + return errors.New("this is not a Toolbx container") + } + + exitCode, err := utils.ForwardToHost() + return &exitError{exitCode, err} + } + + if cmd.Flag("app").Changed && cmd.Flag("bin").Changed { + var builder strings.Builder + fmt.Fprintf(&builder, "options --app and --bin cannot be used together\n") + fmt.Fprintf(&builder, "Run '%s --help' for usage.", executableBase) + + errMsg := builder.String() + return errors.New(errMsg) + } + + if exportFlags.app == "" && exportFlags.bin == "" { + var builder strings.Builder + fmt.Fprintf(&builder, "option --app or --bin is needed\n") + fmt.Fprintf(&builder, "Run '%s --help' for usage.", executableBase) + + errMsg := builder.String() + return errors.New(errMsg) + } + + container, image, release, err := resolveContainerAndImageNames(exportFlags.container, + "--container", + "", + "", + "") + + if err != nil { + return err + } + + manifest, err := inspectExport(container, image, release) + if err != nil { + return err + } + + if exportFlags.app != "" { + return exportApp(container, manifest) + } + + return exportBin(container, manifest) +} + +func exportHelp(cmd *cobra.Command, args []string) { + if utils.IsInsideContainer() { + if !utils.IsInsideToolboxContainer() { + fmt.Fprintf(os.Stderr, "Error: this is not a Toolbx container\n") + return + } + + if _, err := utils.ForwardToHost(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %s\n", err) + return + } + + return + } + + if err := showManual("toolbox-export"); err != nil { + fmt.Fprintf(os.Stderr, "Error: %s\n", err) + return + } +} + +func inspectExport(container, image, release string) (*exportManifest, error) { + runtimeDirectory, err := utils.GetRuntimeDirectory(currentUser) + if err != nil { + return nil, err + } + + manifestPath := filepath.Join(runtimeDirectory, fmt.Sprintf("export-%d.json", os.Getpid())) + defer os.Remove(manifestPath) + + command := []string{"toolbox", "export-inspect", "--output", manifestPath} + if exportFlags.app != "" { + command = append(command, "--app", exportFlags.app) + } else { + command = append(command, "--bin", exportFlags.bin) + } + + if err := runCommand(container, false, image, release, 0, command, false, false, true); err != nil { + return nil, err + } + + data, err := os.ReadFile(manifestPath) + if err != nil { + logrus.Debugf("Reading %s failed: %s", manifestPath, err) + return nil, errors.New("failed to read the export manifest") + } + + var manifest exportManifest + if err := json.Unmarshal(data, &manifest); err != nil { + logrus.Debugf("Unmarshalling %s failed: %s", manifestPath, err) + return nil, errors.New("failed to parse the export manifest") + } + + return &manifest, nil +} + +func exportApp(container string, manifest *exportManifest) error { + applications := exportApplicationsDir() + if err := os.MkdirAll(applications, 0755); err != nil { + return fmt.Errorf("failed to create %s: %w", applications, err) + } + + target := filepath.Join(applications, manifest.AppID+".desktop") + if err := exportCheckTarget(target, container); err != nil { + return err + } + + icons := filepath.Join(exportDataHome(), "toolbx", container, "icons", manifest.AppID) + if err := os.RemoveAll(icons); err != nil { + return fmt.Errorf("failed to remove %s: %w", icons, err) + } + + icon, err := exportWriteIcons(icons, manifest.Icons) + if err != nil { + return err + } + + entry := exportRewriteEntry(container, manifest.Desktop, icon) + if err := renameio.WriteFile(target, []byte(entry), 0644); err != nil { + return fmt.Errorf("failed to write %s: %w", target, err) + } + + fmt.Printf("Exported %s from container %s\n", manifest.AppID+".desktop", container) + return nil +} + +func exportBin(container string, manifest *exportManifest) error { + directory := exportBinHome() + if err := os.MkdirAll(directory, 0755); err != nil { + return fmt.Errorf("failed to create %s: %w", directory, err) + } + + target := filepath.Join(directory, filepath.Base(manifest.Binary)) + if err := exportCheckTarget(target, container); err != nil { + return err + } + + var builder strings.Builder + fmt.Fprintf(&builder, "#!/bin/sh\n") + fmt.Fprintf(&builder, "# %s: %s\n", exportContainerKey, container) + fmt.Fprintf(&builder, "exec %s run --container %s %s \"$@\"\n", executable, container, manifest.Binary) + + if err := renameio.WriteFile(target, []byte(builder.String()), 0755); err != nil { + return fmt.Errorf("failed to write %s: %w", target, err) + } + + fmt.Printf("Exported %s from container %s\n", filepath.Base(target), container) + + if !exportIsInPath(directory) { + fmt.Fprintf(os.Stderr, "Warning: %s is not in PATH\n", directory) + } + + return nil +} + +func exportCheckTarget(path, container string) error { + owner, err := exportOwner(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + + return err + } + + if exportFlags.force || owner == container { + return nil + } + + var builder strings.Builder + if owner == "" { + fmt.Fprintf(&builder, "file %s was not exported by Toolbx\n", path) + } else { + fmt.Fprintf(&builder, "file %s was exported from container %s\n", path, owner) + } + fmt.Fprintf(&builder, "Use option '--force' to overwrite it.\n") + fmt.Fprintf(&builder, "Run '%s --help' for usage.", executableBase) + + errMsg := builder.String() + return errors.New(errMsg) +} + +func exportOwner(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + + if value, ok := strings.CutPrefix(line, exportContainerKey+"="); ok { + return strings.TrimSpace(value), nil + } + + if value, ok := strings.CutPrefix(line, "# "+exportContainerKey+":"); ok { + return strings.TrimSpace(value), nil + } + } + + return "", nil +} + +func exportRewriteEntry(container, entry, icon string) string { + prefix := fmt.Sprintf("%s run --container %s ", executable, container) + + var builder strings.Builder + var group string + + for _, line := range strings.Split(strings.TrimRight(entry, "\n"), "\n") { + trimmed := strings.TrimSpace(line) + + if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") { + if group == "Desktop Entry" { + fmt.Fprintf(&builder, "%s=%s\n", exportContainerKey, container) + } + + group = strings.Trim(trimmed, "[]") + fmt.Fprintf(&builder, "%s\n", line) + continue + } + + key, value, found := strings.Cut(trimmed, "=") + if !found { + fmt.Fprintf(&builder, "%s\n", line) + continue + } + + switch strings.TrimSpace(key) { + case "Exec": + fmt.Fprintf(&builder, "Exec=%s%s\n", prefix, strings.TrimSpace(value)) + case "TryExec": + fmt.Fprintf(&builder, "TryExec=%s\n", executable) + case "DBusActivatable": + fmt.Fprintf(&builder, "DBusActivatable=false\n") + case "Path": + fmt.Fprintf(&builder, "#Path=%s\n", strings.TrimSpace(value)) + case "Icon": + if icon == "" { + fmt.Fprintf(&builder, "%s\n", line) + } else { + fmt.Fprintf(&builder, "Icon=%s\n", icon) + } + case exportContainerKey: + default: + fmt.Fprintf(&builder, "%s\n", line) + } + } + + if group == "Desktop Entry" { + fmt.Fprintf(&builder, "%s=%s\n", exportContainerKey, container) + } + + return builder.String() +} + +func exportWriteIcons(directory string, icons []exportIcon) (string, error) { + var best string + var bestScore int + + for _, icon := range icons { + path := filepath.Join(directory, filepath.FromSlash(icon.Path)) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return "", fmt.Errorf("failed to create %s: %w", filepath.Dir(path), err) + } + + if err := os.WriteFile(path, icon.Data, 0644); err != nil { + return "", fmt.Errorf("failed to write %s: %w", path, err) + } + + if score := exportIconScore(icon.Path); best == "" || score > bestScore { + best = path + bestScore = score + } + } + + return best, nil +} + +func exportIconScore(path string) int { + if filepath.Ext(path) == ".svg" { + return 1 << 16 + } + + for _, component := range strings.Split(path, "/") { + size, _, found := strings.Cut(component, "x") + if !found { + continue + } + + if value, err := strconv.Atoi(size); err == nil { + return value + } + } + + return 0 +} + +func exportDataHome() string { + if dataHome := os.Getenv("XDG_DATA_HOME"); dataHome != "" { + return dataHome + } + + return filepath.Join(getCurrentUserHomeDir(), ".local", "share") +} + +func exportApplicationsDir() string { + return filepath.Join(exportDataHome(), "applications") +} + +func exportBinHome() string { + if binHome := os.Getenv("XDG_BIN_HOME"); binHome != "" { + return binHome + } + + return filepath.Join(getCurrentUserHomeDir(), ".local", "bin") +} + +func exportIsInPath(directory string) bool { + for _, component := range filepath.SplitList(os.Getenv("PATH")) { + if component == directory { + return true + } + } + + return false +} diff --git a/src/cmd/exportInspect.go b/src/cmd/exportInspect.go new file mode 100644 index 000000000..9f1df1760 --- /dev/null +++ b/src/cmd/exportInspect.go @@ -0,0 +1,300 @@ +/* + * Copyright © 2026 Hadi Chokr + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package cmd + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/containers/toolbox/pkg/utils" + "github.com/google/renameio/v2" + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" +) + +var exportInspectIconExtensions = []string{".png", ".svg", ".xpm"} + +var exportInspectFlags struct { + app string + bin string + output string +} + +var exportInspectCmd = &cobra.Command{ + Use: "export-inspect", + Short: "Resolve an application or a binary inside a Toolbx container", + Hidden: true, + RunE: exportInspect, +} + +func init() { + flags := exportInspectCmd.Flags() + + flags.StringVar(&exportInspectFlags.app, "app", "", "Resolve the application with the given desktop entry") + + flags.StringVar(&exportInspectFlags.bin, "bin", "", "Resolve the binary with the given name") + + flags.StringVar(&exportInspectFlags.output, "output", "", "Write the manifest to the given path") + if err := exportInspectCmd.MarkFlagRequired("output"); err != nil { + panic("Could not mark flag --output as required") + } + + rootCmd.AddCommand(exportInspectCmd) +} + +func exportInspect(cmd *cobra.Command, args []string) error { + if !utils.IsInsideContainer() { + var builder strings.Builder + fmt.Fprintf(&builder, "the 'export-inspect' command can only be used inside containers\n") + fmt.Fprintf(&builder, "Run '%s --help' for usage.", executableBase) + + errMsg := builder.String() + return errors.New(errMsg) + } + + var manifest exportManifest + var err error + + if exportInspectFlags.app != "" { + manifest, err = exportInspectApp(exportInspectFlags.app) + } else { + manifest, err = exportInspectBin(exportInspectFlags.bin) + } + + if err != nil { + return err + } + + data, err := json.Marshal(&manifest) + if err != nil { + logrus.Debugf("Marshalling the manifest failed: %s", err) + return errors.New("failed to marshal the export manifest") + } + + if err := renameio.WriteFile(exportInspectFlags.output, data, 0600); err != nil { + return fmt.Errorf("failed to write %s: %w", exportInspectFlags.output, err) + } + + return nil +} + +func exportInspectApp(app string) (exportManifest, error) { + var manifest exportManifest + + path, err := exportInspectFindEntry(app) + if err != nil { + return manifest, err + } + + data, err := os.ReadFile(path) + if err != nil { + return manifest, fmt.Errorf("failed to read %s: %w", path, err) + } + + manifest.AppID = strings.TrimSuffix(filepath.Base(path), ".desktop") + manifest.Desktop = string(data) + + icon := exportInspectEntryKey(manifest.Desktop, "Icon") + if icon != "" { + manifest.Icons = exportInspectFindIcons(icon) + } + + return manifest, nil +} + +func exportInspectBin(bin string) (exportManifest, error) { + var manifest exportManifest + + path, err := exec.LookPath(bin) + if err != nil { + logrus.Debugf("Looking up %s failed: %s", bin, err) + return manifest, fmt.Errorf("binary %s not found in the container", bin) + } + + path, err = filepath.Abs(path) + if err != nil { + return manifest, fmt.Errorf("failed to resolve %s: %w", bin, err) + } + + manifest.Binary = path + return manifest, nil +} + +func exportInspectIsExported(path string) bool { + data, err := os.ReadFile(path) + if err != nil { + return false + } + + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + + if strings.HasPrefix(line, exportContainerKey+"=") { + logrus.Debugf("Skipping %s: exported by Toolbx", path) + return true + } + } + + return false +} + +func exportInspectFindEntry(app string) (string, error) { + name := strings.TrimSuffix(app, ".desktop") + var fallback string + + for _, directory := range exportInspectDataDirs("applications") { + path := filepath.Join(directory, name+".desktop") + if utils.PathExists(path) && !exportInspectIsExported(path) { + return path, nil + } + + entries, err := os.ReadDir(directory) + if err != nil { + continue + } + + for _, entry := range entries { + id, found := strings.CutSuffix(entry.Name(), ".desktop") + if !found { + continue + } + + if fallback != "" || !strings.HasSuffix(strings.ToLower(id), "."+strings.ToLower(name)) { + continue + } + + candidate := filepath.Join(directory, entry.Name()) + if exportInspectIsExported(candidate) { + continue + } + + fallback = candidate + } + } + + if fallback != "" { + return fallback, nil + } + + return "", fmt.Errorf("application %s not found in the container", app) +} + +func exportInspectFindIcons(icon string) []exportIcon { + if filepath.IsAbs(icon) { + data, err := os.ReadFile(icon) + if err != nil { + logrus.Debugf("Reading %s failed: %s", icon, err) + return nil + } + + return []exportIcon{{Path: filepath.Base(icon), Data: data}} + } + + var icons []exportIcon + + for _, root := range append(exportInspectDataDirs("icons"), "/usr/share/pixmaps") { + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err != nil || entry.IsDir() { + return nil + } + + extension := filepath.Ext(entry.Name()) + if strings.TrimSuffix(entry.Name(), extension) != icon { + return nil + } + + var known bool + for _, candidate := range exportInspectIconExtensions { + if extension == candidate { + known = true + break + } + } + + if !known { + return nil + } + + data, err := os.ReadFile(path) + if err != nil { + return nil + } + + relative, err := filepath.Rel(root, path) + if err != nil { + return nil + } + + icons = append(icons, exportIcon{Path: filepath.ToSlash(relative), Data: data}) + return nil + }) + + if err != nil { + logrus.Debugf("Walking %s failed: %s", root, err) + } + } + + return icons +} + +func exportInspectEntryKey(entry, key string) string { + var group string + + for _, line := range strings.Split(entry, "\n") { + line = strings.TrimSpace(line) + + if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { + group = strings.Trim(line, "[]") + continue + } + + if group != "Desktop Entry" { + continue + } + + if value, ok := strings.CutPrefix(line, key+"="); ok { + return strings.TrimSpace(value) + } + } + + return "" +} + +func exportInspectDataDirs(subdirectory string) []string { + dataHome := os.Getenv("XDG_DATA_HOME") + if dataHome == "" { + dataHome = filepath.Join(getCurrentUserHomeDir(), ".local", "share") + } + + dataDirs := os.Getenv("XDG_DATA_DIRS") + if dataDirs == "" { + dataDirs = "/usr/local/share:/usr/share" + } + + directories := []string{filepath.Join(dataHome, subdirectory)} + for _, directory := range filepath.SplitList(dataDirs) { + directories = append(directories, filepath.Join(directory, subdirectory)) + } + + return directories +} diff --git a/src/cmd/rm.go b/src/cmd/rm.go index d4d0aac68..66de930f3 100644 --- a/src/cmd/rm.go +++ b/src/cmd/rm.go @@ -79,6 +79,12 @@ func rm(cmd *cobra.Command, args []string) error { for toolboxContainers.Next() { container := toolboxContainers.Get() containerID := container.ID() + containerName := container.Name() + + if _, err := unexportAll(containerName); err != nil { + fmt.Fprintf(os.Stderr, "Error: %s\n", err) + } + if err := podman.RemoveContainer(containerID, rmFlags.forceDelete); err != nil { fmt.Fprintf(os.Stderr, "Error: %s\n", err) continue @@ -106,6 +112,10 @@ func rm(cmd *cobra.Command, args []string) error { continue } + if _, err := unexportAll(containerObj.Name()); err != nil { + fmt.Fprintf(os.Stderr, "Error: %s\n", err) + } + if err := podman.RemoveContainer(container, rmFlags.forceDelete); err != nil { fmt.Fprintf(os.Stderr, "Error: %s\n", err) continue diff --git a/src/cmd/unexport.go b/src/cmd/unexport.go new file mode 100644 index 000000000..48dd6a5e8 --- /dev/null +++ b/src/cmd/unexport.go @@ -0,0 +1,228 @@ +/* + * Copyright © 2026 Hadi Chokr + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package cmd + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/containers/toolbox/pkg/utils" + "github.com/spf13/cobra" +) + +var ( + unexportFlags struct { + all bool + app string + bin string + container string + } +) + +var unexportCmd = &cobra.Command{ + Use: "unexport", + Short: "Remove an application or a binary exported from a Toolbx container", + RunE: unexport, + ValidArgsFunction: completionEmpty, +} + +func init() { + flags := unexportCmd.Flags() + + flags.BoolVar(&unexportFlags.all, "all", false, "Remove everything exported from the Toolbx container") + + flags.StringVar(&unexportFlags.app, "app", "", "Remove the application with the given desktop entry") + + flags.StringVar(&unexportFlags.bin, "bin", "", "Remove the binary with the given name") + + flags.StringVarP(&unexportFlags.container, + "container", + "c", + "", + "Remove what was exported from the Toolbx container with the given name") + + if err := unexportCmd.RegisterFlagCompletionFunc("container", completionContainerNames); err != nil { + panicMsg := fmt.Sprintf("failed to register flag completion function: %v", err) + panic(panicMsg) + } + + unexportCmd.SetHelpFunc(unexportHelp) + rootCmd.AddCommand(unexportCmd) +} + +func unexport(cmd *cobra.Command, args []string) error { + if utils.IsInsideContainer() { + if !utils.IsInsideToolboxContainer() { + return errors.New("this is not a Toolbx container") + } + + exitCode, err := utils.ForwardToHost() + return &exitError{exitCode, err} + } + + if unexportFlags.all && (cmd.Flag("app").Changed || cmd.Flag("bin").Changed) { + var builder strings.Builder + fmt.Fprintf(&builder, "option --all cannot be used with --app or --bin\n") + fmt.Fprintf(&builder, "Run '%s --help' for usage.", executableBase) + + errMsg := builder.String() + return errors.New(errMsg) + } + + if !unexportFlags.all && unexportFlags.app == "" && unexportFlags.bin == "" { + var builder strings.Builder + fmt.Fprintf(&builder, "option --all, --app or --bin is needed\n") + fmt.Fprintf(&builder, "Run '%s --help' for usage.", executableBase) + + errMsg := builder.String() + return errors.New(errMsg) + } + + container, _, _, err := resolveContainerAndImageNames(unexportFlags.container, "--container", "", "", "") + if err != nil { + return err + } + + if unexportFlags.all { + removed, err := unexportAll(container) + if err != nil { + return err + } + + fmt.Printf("Removed %d files exported from container %s\n", removed, container) + return nil + } + + if unexportFlags.app != "" { + return unexportApp(container, unexportFlags.app) + } + + return unexportBin(container, unexportFlags.bin) +} + +func unexportHelp(cmd *cobra.Command, args []string) { + if utils.IsInsideContainer() { + if !utils.IsInsideToolboxContainer() { + fmt.Fprintf(os.Stderr, "Error: this is not a Toolbx container\n") + return + } + + if _, err := utils.ForwardToHost(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %s\n", err) + return + } + + return + } + + if err := showManual("toolbox-unexport"); err != nil { + fmt.Fprintf(os.Stderr, "Error: %s\n", err) + return + } +} + +func unexportApp(container, app string) error { + appID := strings.TrimSuffix(app, ".desktop") + target := filepath.Join(exportApplicationsDir(), appID+".desktop") + + if err := exportRemove(target, container); err != nil { + return err + } + + icons := filepath.Join(exportDataHome(), "toolbx", container, "icons", appID) + if err := os.RemoveAll(icons); err != nil { + return fmt.Errorf("failed to remove %s: %w", icons, err) + } + + fmt.Printf("Removed %s exported from container %s\n", appID+".desktop", container) + return nil +} + +func unexportBin(container, bin string) error { + target := filepath.Join(exportBinHome(), filepath.Base(bin)) + + if err := exportRemove(target, container); err != nil { + return err + } + + fmt.Printf("Removed %s exported from container %s\n", filepath.Base(target), container) + return nil +} + +func unexportAll(container string) (int, error) { + var removed int + + for _, directory := range []string{exportApplicationsDir(), exportBinHome()} { + entries, err := os.ReadDir(directory) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + continue + } + + return removed, fmt.Errorf("failed to read %s: %w", directory, err) + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + + path := filepath.Join(directory, entry.Name()) + owner, err := exportOwner(path) + if err != nil || owner != container { + continue + } + + if err := os.Remove(path); err != nil { + return removed, fmt.Errorf("failed to remove %s: %w", path, err) + } + + removed++ + } + } + + data := filepath.Join(exportDataHome(), "toolbx", container) + if err := os.RemoveAll(data); err != nil { + return removed, fmt.Errorf("failed to remove %s: %w", data, err) + } + + return removed, nil +} + +func exportRemove(path, container string) error { + owner, err := exportOwner(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("file %s not found", path) + } + + return err + } + + if owner != container { + return fmt.Errorf("file %s was not exported from container %s", path, container) + } + + if err := os.Remove(path); err != nil { + return fmt.Errorf("failed to remove %s: %w", path, err) + } + + return nil +} diff --git a/src/meson.build b/src/meson.build index 6706fe28c..8fb4f2123 100644 --- a/src/meson.build +++ b/src/meson.build @@ -9,6 +9,8 @@ sources = files( 'cmd/completion.go', 'cmd/create.go', 'cmd/enter.go', + 'cmd/export.go', + 'cmd/exportInspect.go', 'cmd/help.go', 'cmd/initContainer.go', 'cmd/list.go', @@ -19,6 +21,7 @@ sources = files( 'cmd/rootMigrationPath.go', 'cmd/root_test.go', 'cmd/run.go', + 'cmd/unexport.go', 'cmd/utils.go', 'pkg/nvidia/nvidia.go', 'pkg/podman/container.go', diff --git a/test/system/109-export.bats b/test/system/109-export.bats new file mode 100644 index 000000000..8100d64b6 --- /dev/null +++ b/test/system/109-export.bats @@ -0,0 +1,243 @@ +# shellcheck shell=bats +# +# Copyright © 2025 – 2026 Hadi Chokr +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# bats file_tags=commands-options + +load 'libs/bats-support/load' +load 'libs/bats-assert/load' +load 'libs/helpers' + +setup() { + bats_require_minimum_version 1.10.0 + + # Don't let the environment of the CI leak into the look-up in the container. + XDG_DATA_DIRS="/usr/local/share:/usr/share" + export XDG_DATA_DIRS + + XDG_BIN_HOME="$HOME/.local/bin" + export XDG_BIN_HOME + + PATH="$XDG_BIN_HOME:$PATH" + export PATH + + cleanup_all + cleanup_exports + pushd "$HOME" || return 1 +} + +teardown() { + popd || return 1 + cleanup_exports + cleanup_all +} + +@test "export: Try without --app or --bin" { + run --keep-empty-lines --separate-stderr "$TOOLBX" export + + assert_failure + assert [ ${#lines[@]} -eq 0 ] + lines=("${stderr_lines[@]}") + assert_line --index 0 "Error: option --app or --bin is needed" + assert_line --index 1 "Run 'toolbox --help' for usage." + assert [ ${#stderr_lines[@]} -eq 2 ] +} + +@test "export: Try using both --app and --bin" { + run --keep-empty-lines --separate-stderr "$TOOLBX" export --app foo --bin bar + + assert_failure + assert [ ${#lines[@]} -eq 0 ] + lines=("${stderr_lines[@]}") + assert_line --index 0 "Error: options --app and --bin cannot be used together" + assert_line --index 1 "Run 'toolbox --help' for usage." + assert [ ${#stderr_lines[@]} -eq 2 ] +} + +@test "export: Export an application" { + create_default_container + + local container + container="$(get_latest_container_name)" + create_test_app "$container" + + run --keep-empty-lines --separate-stderr "$TOOLBX" export \ + --app org.example.ToolbxTest \ + --container "$container" + + assert_success + assert_line --index 0 "Exported org.example.ToolbxTest.desktop from container $container" + assert [ ${#lines[@]} -eq 1 ] + + # shellcheck disable=SC2154 + assert [ ${#stderr_lines[@]} -eq 0 ] + + assert [ -f "$XDG_DATA_HOME/applications/org.example.ToolbxTest.desktop" ] + assert [ -f "$XDG_DATA_HOME/toolbx/$container/icons/org.example.ToolbxTest/hicolor/48x48/apps/toolbx-test.png" ] +} + +@test "export: Rewrite the keys of an exported application" { + create_default_container + + local container + container="$(get_latest_container_name)" + create_test_app "$container" + + run "$TOOLBX" export --app org.example.ToolbxTest --container "$container" + assert_success + + local entry="$XDG_DATA_HOME/applications/org.example.ToolbxTest.desktop" + + run --keep-empty-lines --separate-stderr cat "$entry" + + assert_success + assert_line --index 0 "[Desktop Entry]" + assert_line --regexp "^Exec=/.+/toolbox run --container $container toolbx-test %U$" + assert_line --regexp "^TryExec=/.+/toolbox$" + assert_line "DBusActivatable=false" + assert_line "#Path=/usr/share" + assert_line "Icon=$XDG_DATA_HOME/toolbx/$container/icons/org.example.ToolbxTest/hicolor/48x48/apps/toolbx-test.png" + assert_line "X-Toolbx-Container=$container" + assert [ ${#stderr_lines[@]} -eq 0 ] +} + +@test "export: Export an application twice" { + create_default_container + + local container + container="$(get_latest_container_name)" + create_test_app "$container" + + run "$TOOLBX" export --app org.example.ToolbxTest --container "$container" + assert_success + + run --keep-empty-lines --separate-stderr "$TOOLBX" export \ + --app org.example.ToolbxTest \ + --container "$container" + + assert_success + assert_line --index 0 "Exported org.example.ToolbxTest.desktop from container $container" + assert [ ${#lines[@]} -eq 1 ] + assert [ ${#stderr_lines[@]} -eq 0 ] + + # The entry exported to the home directory is visible inside the container. + # It must not be picked up and prefixed a second time. + run --keep-empty-lines --separate-stderr grep "^Exec=" \ + "$XDG_DATA_HOME/applications/org.example.ToolbxTest.desktop" + + assert_success + assert_line --index 0 --regexp "^Exec=/.+/toolbox run --container $container toolbx-test %U$" + assert [ ${#lines[@]} -eq 1 ] + assert [ ${#stderr_lines[@]} -eq 0 ] +} + +@test "export: Export an application using a shortened name" { + create_default_container + + local container + container="$(get_latest_container_name)" + create_test_app "$container" + + run --keep-empty-lines --separate-stderr "$TOOLBX" export --app ToolbxTest --container "$container" + + assert_success + assert_line --index 0 "Exported org.example.ToolbxTest.desktop from container $container" + assert [ ${#lines[@]} -eq 1 ] + assert [ ${#stderr_lines[@]} -eq 0 ] + assert [ -f "$XDG_DATA_HOME/applications/org.example.ToolbxTest.desktop" ] +} + +@test "export: Export a binary" { + create_default_container + + local container + container="$(get_latest_container_name)" + create_test_bin "$container" + + run --keep-empty-lines --separate-stderr "$TOOLBX" export --bin toolbx-test --container "$container" + + assert_success + assert_line --index 0 "Exported toolbx-test from container $container" + assert [ ${#lines[@]} -eq 1 ] + assert [ ${#stderr_lines[@]} -eq 0 ] + assert [ -x "$XDG_BIN_HOME/toolbx-test" ] + + run --keep-empty-lines --separate-stderr "$XDG_BIN_HOME/toolbx-test" + + assert_success + assert_line --index 0 "toolbx-test" + assert [ ${#lines[@]} -eq 1 ] + assert [ ${#stderr_lines[@]} -eq 0 ] +} + +@test "export: Try to overwrite a file exported from another container" { + create_default_container + + local container + container="$(get_latest_container_name)" + create_test_app "$container" + + local entry="$XDG_DATA_HOME/applications/org.example.ToolbxTest.desktop" + + mkdir --parents "$XDG_DATA_HOME/applications" + printf "%s\n" "[Desktop Entry]" "Type=Application" "X-Toolbx-Container=other" >"$entry" + + run --keep-empty-lines --separate-stderr "$TOOLBX" export \ + --app org.example.ToolbxTest \ + --container "$container" + + assert_failure + assert [ ${#lines[@]} -eq 0 ] + lines=("${stderr_lines[@]}") + assert_line --index 0 "Error: file $entry was exported from container other" + assert_line --index 1 "Use option '--force' to overwrite it." + assert_line --index 2 "Run 'toolbox --help' for usage." + assert [ ${#stderr_lines[@]} -eq 3 ] +} + +@test "export: Try to export a non-existent application" { + create_default_container + + local container + container="$(get_latest_container_name)" + + run --keep-empty-lines --separate-stderr "$TOOLBX" export \ + --app non-existent-app \ + --container "$container" + + assert_failure + assert [ ${#lines[@]} -eq 0 ] + lines=("${stderr_lines[@]}") + assert_line --index 0 "Error: application non-existent-app not found in the container" + assert [ ${#stderr_lines[@]} -eq 1 ] +} + +@test "export: Try to export a non-existent binary" { + create_default_container + + local container + container="$(get_latest_container_name)" + + run --keep-empty-lines --separate-stderr "$TOOLBX" export \ + --bin non-existent-bin \ + --container "$container" + + assert_failure + assert [ ${#lines[@]} -eq 0 ] + lines=("${stderr_lines[@]}") + assert_line --index 0 "Error: binary non-existent-bin not found in the container" + assert [ ${#stderr_lines[@]} -eq 1 ] +} diff --git a/test/system/110-unexport.bats b/test/system/110-unexport.bats new file mode 100644 index 000000000..005020a61 --- /dev/null +++ b/test/system/110-unexport.bats @@ -0,0 +1,201 @@ +# shellcheck shell=bats +# +# Copyright © 2025 – 2026 Hadi Chokr +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# bats file_tags=commands-options + +load 'libs/bats-support/load' +load 'libs/bats-assert/load' +load 'libs/helpers' + +setup() { + bats_require_minimum_version 1.10.0 + + XDG_DATA_DIRS="/usr/local/share:/usr/share" + export XDG_DATA_DIRS + + XDG_BIN_HOME="$HOME/.local/bin" + export XDG_BIN_HOME + + PATH="$XDG_BIN_HOME:$PATH" + export PATH + + cleanup_all + cleanup_exports + pushd "$HOME" || return 1 +} + +teardown() { + popd || return 1 + cleanup_exports + cleanup_all +} + +@test "unexport: Try without --all, --app or --bin" { + run --keep-empty-lines --separate-stderr "$TOOLBX" unexport + + assert_failure + assert [ ${#lines[@]} -eq 0 ] + lines=("${stderr_lines[@]}") + assert_line --index 0 "Error: option --all, --app or --bin is needed" + assert_line --index 1 "Run 'toolbox --help' for usage." + assert [ ${#stderr_lines[@]} -eq 2 ] +} + +@test "unexport: Remove an exported application" { + create_default_container + + local container + container="$(get_latest_container_name)" + create_test_app "$container" + + run "$TOOLBX" export --app org.example.ToolbxTest --container "$container" + assert_success + + run --keep-empty-lines --separate-stderr "$TOOLBX" unexport \ + --app org.example.ToolbxTest \ + --container "$container" + + assert_success + assert_line --index 0 "Removed org.example.ToolbxTest.desktop exported from container $container" + assert [ ${#lines[@]} -eq 1 ] + + # shellcheck disable=SC2154 + assert [ ${#stderr_lines[@]} -eq 0 ] + + assert [ ! -e "$XDG_DATA_HOME/applications/org.example.ToolbxTest.desktop" ] + assert [ ! -e "$XDG_DATA_HOME/toolbx/$container/icons/org.example.ToolbxTest" ] +} + +@test "unexport: Remove an exported binary" { + create_default_container + + local container + container="$(get_latest_container_name)" + create_test_bin "$container" + + run "$TOOLBX" export --bin toolbx-test --container "$container" + assert_success + + run --keep-empty-lines --separate-stderr "$TOOLBX" unexport --bin toolbx-test --container "$container" + + assert_success + assert_line --index 0 "Removed toolbx-test exported from container $container" + assert [ ${#lines[@]} -eq 1 ] + assert [ ${#stderr_lines[@]} -eq 0 ] + assert [ ! -e "$XDG_BIN_HOME/toolbx-test" ] +} + +@test "unexport: Remove everything exported from a container" { + create_default_container + + local container + container="$(get_latest_container_name)" + create_test_app "$container" + create_test_bin "$container" + + run "$TOOLBX" export --app org.example.ToolbxTest --container "$container" + assert_success + + run "$TOOLBX" export --bin toolbx-test --container "$container" + assert_success + + run --keep-empty-lines --separate-stderr "$TOOLBX" unexport --all --container "$container" + + assert_success + assert_line --index 0 "Removed 2 files exported from container $container" + assert [ ${#lines[@]} -eq 1 ] + assert [ ${#stderr_lines[@]} -eq 0 ] + + assert [ ! -e "$XDG_DATA_HOME/applications/org.example.ToolbxTest.desktop" ] + assert [ ! -e "$XDG_BIN_HOME/toolbx-test" ] + assert [ ! -e "$XDG_DATA_HOME/toolbx/$container" ] +} + +@test "unexport: rm removes exported files along with the container" { + create_default_container + + local container + container="$(get_latest_container_name)" + create_test_app "$container" + create_test_bin "$container" + + run "$TOOLBX" export --app org.example.ToolbxTest --container "$container" + assert_success + + run "$TOOLBX" export --bin toolbx-test --container "$container" + assert_success + + run --keep-empty-lines --separate-stderr "$TOOLBX" rm --force "$container" + + assert_success + assert [ ${#lines[@]} -eq 0 ] + assert [ ${#stderr_lines[@]} -eq 0 ] + + assert [ ! -e "$XDG_DATA_HOME/applications/org.example.ToolbxTest.desktop" ] + assert [ ! -e "$XDG_BIN_HOME/toolbx-test" ] + assert [ ! -e "$XDG_DATA_HOME/toolbx/$container" ] +} + +@test "unexport: rm by ID removes exported files along with the container" { + create_default_container + + local container + container="$(get_latest_container_name)" + create_test_bin "$container" + + run "$TOOLBX" export --bin toolbx-test --container "$container" + assert_success + + local id + id="$(podman inspect --format "{{.Id}}" --type container "$container")" + + run --keep-empty-lines --separate-stderr "$TOOLBX" rm --force "$id" + + assert_success + assert [ ${#lines[@]} -eq 0 ] + assert [ ${#stderr_lines[@]} -eq 0 ] + assert [ ! -e "$XDG_BIN_HOME/toolbx-test" ] +} + +@test "unexport: Try to remove a non-exported application" { + run --keep-empty-lines --separate-stderr "$TOOLBX" unexport \ + --app non-existent-app \ + --container my-container + + assert_failure + assert [ ${#lines[@]} -eq 0 ] + lines=("${stderr_lines[@]}") + assert_line --index 0 "Error: file $XDG_DATA_HOME/applications/non-existent-app.desktop not found" + assert [ ${#stderr_lines[@]} -eq 1 ] +} + +@test "unexport: Try to remove a file exported from another container" { + local entry="$XDG_DATA_HOME/applications/org.example.ToolbxTest.desktop" + + mkdir --parents "$XDG_DATA_HOME/applications" + printf "%s\n" "[Desktop Entry]" "Type=Application" "X-Toolbx-Container=other" >"$entry" + + run --keep-empty-lines --separate-stderr "$TOOLBX" unexport \ + --app org.example.ToolbxTest \ + --container my-container + + assert_failure + assert [ ${#lines[@]} -eq 0 ] + lines=("${stderr_lines[@]}") + assert_line --index 0 "Error: file $entry was not exported from container my-container" + assert [ ${#stderr_lines[@]} -eq 1 ] +} diff --git a/test/system/libs/helpers.bash b/test/system/libs/helpers.bash index 762b1382f..428f4067d 100644 --- a/test/system/libs/helpers.bash +++ b/test/system/libs/helpers.bash @@ -697,3 +697,75 @@ function create_container_flatpak_session_helper() ( return 0 ) + +# Creates a desktop entry with an icon inside a Toolbx container +# +# The application is deliberately not tied to any real package, so that the +# tests don't depend on what a distribution ships, or on how it names its +# desktop entries. +# +# Parameters: +# =========== +# - container - name of the container +function create_test_app() { + local container + container="$1" + + local script + script='mkdir --parents /usr/share/applications /usr/share/icons/hicolor/48x48/apps +printf "%s\n" \ + "[Desktop Entry]" \ + "Type=Application" \ + "Name=Toolbx Test" \ + "Exec=toolbx-test %U" \ + "TryExec=/usr/local/bin/toolbx-test" \ + "Icon=toolbx-test" \ + "DBusActivatable=true" \ + "Path=/usr/share" >/usr/share/applications/org.example.ToolbxTest.desktop +printf "%s\n" "toolbx-test-icon" >/usr/share/icons/hicolor/48x48/apps/toolbx-test.png' + + "$TOOLBX" run --container "$container" sudo sh -c "$script" >/dev/null \ + || fail "Toolbx couldn't create the test application in container '$container'" +} + + +# Creates a binary inside a Toolbx container +# +# Parameters: +# =========== +# - container - name of the container +function create_test_bin() { + local container + container="$1" + + local script + script='printf "%s\n" "#!/bin/sh" "echo toolbx-test" >/usr/local/bin/toolbx-test +chmod 755 /usr/local/bin/toolbx-test' + + "$TOOLBX" run --container "$container" sudo sh -c "$script" >/dev/null \ + || fail "Toolbx couldn't create the test binary in container '$container'" +} + + +# Removes the files that the tests exported to the host +# +# Exported files live in the home directory, not inside the container, so they +# aren't affected by cleanup_all and would otherwise leak from one test into +# the next. Note that a test that fails midway never reaches its teardown, +# which is why this is called from setup as well. +function cleanup_exports() { + local data_home + data_home="${XDG_DATA_HOME:-$HOME/.local/share}" + + local bin_home + bin_home="${XDG_BIN_HOME:-$HOME/.local/bin}" + + # Only the entries that the tests are known to create are removed, so that a + # stray application on the host isn't taken down along with them. + rm --force "$data_home/applications/org.example.ToolbxTest.desktop" + rm --force "$bin_home/toolbx-test" + + rm --force --recursive "$data_home/toolbx" + + return 0 +} diff --git a/test/system/meson.build b/test/system/meson.build index c53add0cc..c9609ad46 100644 --- a/test/system/meson.build +++ b/test/system/meson.build @@ -9,6 +9,8 @@ test_system = files( '106-rm.bats', '107-rmi.bats', '108-completion.bats', + '109-export.bats', + '110-unexport.bats', '201-ipc.bats', '203-network.bats', '206-user.bats',