feat(assemble): exported apps and bins (#58)

* fix(assemble): start container only if the flag is provided

* test(containermanager): define mock

* feat(assemble): export apps

* test(assemble): fail on invalid app name

* feat(assemble): exported bins

* test(assemble): fail on invalid bin name
This commit is contained in:
Emanuele De Cupis
2026-06-14 13:04:28 +02:00
committed by Alessio Biancalana
parent 9c2de1e996
commit 8c0408396e
4 changed files with 403 additions and 10 deletions
+54 -8
View File
@@ -179,14 +179,60 @@ func (ac *AssembleCommand) joinHooks(hooks []string) string {
}
func (ac *AssembleCommand) setupBox(ctx context.Context, item manifest.Item) error {
_, err := ac.enterCmd.Execute(ctx, EnterOptions{
ContainerName: item.Name,
NoTTY: true,
CustomCommand: "true", // we just want to run the init hooks, so we can skip the shell
DryRun: false,
})
if err != nil {
return fmt.Errorf("failed to execute init hooks for item '%s': %w", item.Name, err)
if item.StartNow {
_, err := ac.enterCmd.Execute(ctx, EnterOptions{
ContainerName: item.Name,
NoTTY: true,
CustomCommand: "true", // we just want to run the init hooks, so we can skip the shell
DryRun: false,
})
if err != nil {
return fmt.Errorf("failed to execute init hooks for item '%s': %w", item.Name, err)
}
}
// validate app name to prevent command injection, since it's used in a custom command
var validAppName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._+\-]*$`)
for _, app := range item.ExportedApps {
if !validAppName.MatchString(app) {
return fmt.Errorf("invalid app name '%s' for item '%s': must be alphanumeric (with dots, underscores, hyphens)", app, item.Name)
}
}
for _, app := range item.ExportedApps {
cmd := fmt.Sprintf("distrobox-export --app %s", app)
_, err := ac.enterCmd.Execute(ctx, EnterOptions{
ContainerName: item.Name,
NoTTY: true,
CustomCommand: cmd,
DryRun: false,
})
if err != nil {
return fmt.Errorf("failed to export app '%s' for item '%s': %w", app, item.Name, err)
}
}
// validate bin path to prevent command injection, since it's used in a custom command
var validBinPath = regexp.MustCompile(`^/[a-zA-Z0-9._+\-/]+$`)
if len(item.ExportedBins) > 0 && !validBinPath.MatchString(item.ExportedBinsPath) {
return fmt.Errorf("invalid exported bins path '%s' for item '%s': must be an absolute path with alphanumeric characters, dots, underscores, or hyphens", item.ExportedBinsPath, item.Name)
}
// we allow slashes in bin paths, but we validate each path segment to prevent command injection
for _, bin := range item.ExportedBins {
if !validBinPath.MatchString(bin) {
return fmt.Errorf("invalid bin path '%s' for item '%s': must be an absolute path with alphanumeric characters, dots, underscores, or hyphens", bin, item.Name)
}
}
for _, bin := range item.ExportedBins {
cmd := fmt.Sprintf("distrobox-export --bin %s --export-path %s", bin, item.ExportedBinsPath)
_, err := ac.enterCmd.Execute(ctx, EnterOptions{
ContainerName: item.Name,
NoTTY: true,
CustomCommand: cmd,
DryRun: false,
})
if err != nil {
return fmt.Errorf("failed to export bin '%s' for item '%s': %w", bin, item.Name, err)
}
}
return nil
+255
View File
@@ -0,0 +1,255 @@
package commands_test
import (
"bufio"
"context"
"fmt"
"io"
"strings"
"testing"
"github.com/89luca89/distrobox/pkg/commands"
"github.com/89luca89/distrobox/pkg/containermanager"
"github.com/89luca89/distrobox/pkg/internal/testutil"
"github.com/89luca89/distrobox/pkg/manifest"
"github.com/89luca89/distrobox/pkg/ui"
)
func newTestAssembleCommand(mock *testutil.MockContainerManager) *commands.AssembleCommand {
progress := ui.NewDevNullProgress()
prompter := ui.NewPrompter(*bufio.NewReader(strings.NewReader("")), io.Discard)
printer := ui.NewPrinter(io.Discard, false)
return commands.NewAssembleCommand(mock, prompter, progress, printer)
}
func getEnterOptions(spy testutil.ContainerManagerSpy, index int) containermanager.EnterOptions {
return spy.Enter[index][0].(containermanager.EnterOptions)
}
func TestAssembleCommand_SetupBox_StartNowTrue(t *testing.T) {
mock := &testutil.MockContainerManager{}
cmd := newTestAssembleCommand(mock)
err := cmd.Execute(context.Background(), commands.AssembleOptions{
Items: []manifest.Item{
{
Name: "test-box",
Image: "ubuntu:latest",
StartNow: true,
},
},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(mock.Spy.Enter) == 0 {
t.Fatal("expected Enter to be called when StartNow is true, but it was not")
}
opts := getEnterOptions(mock.Spy, 0)
if opts.ContainerName != "test-box" {
t.Errorf("expected ContainerName %q, got %q", "test-box", opts.ContainerName)
}
if opts.CustomCommand != "true" {
t.Errorf("expected CustomCommand %q, got %q", "true", opts.CustomCommand)
}
}
func TestAssembleCommand_SetupBox_ExportedApps_Valid(t *testing.T) {
validNames := []string{
"firefox",
"org.mozilla.firefox",
"gnome-terminal",
"lib2to3",
"g++",
"my_app.v2",
"A",
}
for _, app := range validNames {
t.Run(app, func(t *testing.T) {
mock := &testutil.MockContainerManager{}
cmd := newTestAssembleCommand(mock)
apps := []string{app, "another-app"}
err := cmd.Execute(context.Background(), commands.AssembleOptions{
Items: []manifest.Item{
{
Name: "test-box",
Image: "ubuntu:latest",
ExportedApps: apps,
},
},
})
if err != nil {
t.Fatalf("expected valid app name %q to succeed, got error: %v", app, err)
}
if len(mock.Spy.Enter) != len(apps) {
t.Fatalf("expected Enter to be called %d times, got %d", len(apps), len(mock.Spy.Enter))
}
for i, a := range apps {
opts := getEnterOptions(mock.Spy, i)
expectedCmd := fmt.Sprintf("distrobox-export --app %s", a)
if opts.ContainerName != "test-box" {
t.Errorf("call %d: expected ContainerName %q, got %q", i, "test-box", opts.ContainerName)
}
if opts.CustomCommand != expectedCmd {
t.Errorf("call %d: expected CustomCommand %q, got %q", i, expectedCmd, opts.CustomCommand)
}
}
})
}
}
func TestAssembleCommand_SetupBox_ExportedApps_Invalid(t *testing.T) {
invalidNames := []string{
"--delete",
"-rf",
"",
"app name with spaces",
".hidden",
"_leading",
"app;rm -rf /",
"app$(cmd)",
}
for _, app := range invalidNames {
t.Run(app, func(t *testing.T) {
mock := &testutil.MockContainerManager{}
cmd := newTestAssembleCommand(mock)
err := cmd.Execute(context.Background(), commands.AssembleOptions{
Items: []manifest.Item{
{
Name: "test-box",
Image: "ubuntu:latest",
ExportedApps: []string{app},
},
},
})
if err == nil {
t.Fatalf("expected invalid app name %q to be rejected, but got no error", app)
}
if len(mock.Spy.Enter) != 0 {
t.Errorf("expected Enter to not be called for invalid app name %q, but it was called %d times", app, len(mock.Spy.Enter))
}
})
}
}
func TestAssembleCommand_SetupBox_ExportedBins_Valid(t *testing.T) {
validBins := []string{
"/usr/bin/vim",
"/usr/local/bin/node",
"/opt/app/bin/tool",
"/usr/bin/g++",
"/usr/bin/python3.11",
"/usr/bin/my_tool",
}
for _, bin := range validBins {
t.Run(bin, func(t *testing.T) {
mock := &testutil.MockContainerManager{}
cmd := newTestAssembleCommand(mock)
bins := []string{bin, "/usr/bin/another"}
err := cmd.Execute(context.Background(), commands.AssembleOptions{
Items: []manifest.Item{
{
Name: "test-box",
Image: "ubuntu:latest",
ExportedBins: bins,
ExportedBinsPath: "/home/user/.local/bin",
},
},
})
if err != nil {
t.Fatalf("expected valid bin path %q to succeed, got error: %v", bin, err)
}
if len(mock.Spy.Enter) != len(bins) {
t.Fatalf("expected Enter to be called %d times, got %d", len(bins), len(mock.Spy.Enter))
}
for i, b := range bins {
opts := getEnterOptions(mock.Spy, i)
expectedCmd := fmt.Sprintf("distrobox-export --bin %s --export-path /home/user/.local/bin", b)
if opts.ContainerName != "test-box" {
t.Errorf("call %d: expected ContainerName %q, got %q", i, "test-box", opts.ContainerName)
}
if opts.CustomCommand != expectedCmd {
t.Errorf("call %d: expected CustomCommand %q, got %q", i, expectedCmd, opts.CustomCommand)
}
}
})
}
}
func TestAssembleCommand_SetupBox_ExportedBins_Invalid(t *testing.T) {
invalidBins := []string{
"--delete",
"-rf",
"",
"relative/path/bin",
"/path with spaces/bin",
"/usr/bin/app;rm -rf /",
"/usr/bin/app$(cmd)",
}
for _, bin := range invalidBins {
t.Run(bin, func(t *testing.T) {
mock := &testutil.MockContainerManager{}
cmd := newTestAssembleCommand(mock)
err := cmd.Execute(context.Background(), commands.AssembleOptions{
Items: []manifest.Item{
{
Name: "test-box",
Image: "ubuntu:latest",
ExportedBins: []string{bin},
ExportedBinsPath: "/home/user/.local/bin",
},
},
})
if err == nil {
t.Fatalf("expected invalid bin path %q to be rejected, but got no error", bin)
}
if len(mock.Spy.Enter) != 0 {
t.Errorf("expected Enter to not be called for invalid bin path %q, but it was called %d times", bin, len(mock.Spy.Enter))
}
})
}
}
func TestAssembleCommand_SetupBox_ExportedBins_InvalidExportPath(t *testing.T) {
invalidPaths := []string{
"",
"relative/path",
"--some-flag",
"/path with spaces",
"/path;injection",
}
for _, path := range invalidPaths {
t.Run(path, func(t *testing.T) {
mock := &testutil.MockContainerManager{}
cmd := newTestAssembleCommand(mock)
err := cmd.Execute(context.Background(), commands.AssembleOptions{
Items: []manifest.Item{
{
Name: "test-box",
Image: "ubuntu:latest",
ExportedBins: []string{"/usr/bin/vim"},
ExportedBinsPath: path,
},
},
})
if err == nil {
t.Fatalf("expected invalid export path %q to be rejected, but got no error", path)
}
if len(mock.Spy.Enter) != 0 {
t.Errorf("expected Enter to not be called for invalid export path %q, but it was called %d times", path, len(mock.Spy.Enter))
}
})
}
}
@@ -0,0 +1,85 @@
package testutil
import (
"context"
"github.com/89luca89/distrobox/pkg/containermanager"
"github.com/89luca89/distrobox/pkg/ui"
)
// ContainerManagerSpy records all calls made to each method.
// Each field is a slice of call argument lists.
type ContainerManagerSpy struct {
Name [][]any
Enter [][]any
ListContainers [][]any
Create [][]any
Remove [][]any
Exists [][]any
Stop [][]any
InspectContainer [][]any
Commit [][]any
ImageExists [][]any
PullImage [][]any
}
// MockContainerManager is a no-op container manager for testing.
// All method calls are recorded in the Spy.
type MockContainerManager struct {
Spy ContainerManagerSpy
}
func (m *MockContainerManager) Name() string {
m.Spy.Name = append(m.Spy.Name, []any{})
return "mock"
}
func (m *MockContainerManager) Enter(_ context.Context, options containermanager.EnterOptions, progress *ui.Progress, printer *ui.Printer) error {
m.Spy.Enter = append(m.Spy.Enter, []any{options, progress, printer})
return nil
}
func (m *MockContainerManager) ListContainers(_ context.Context) ([]containermanager.Container, error) {
m.Spy.ListContainers = append(m.Spy.ListContainers, []any{})
return []containermanager.Container{}, nil
}
func (m *MockContainerManager) Create(_ context.Context, opts containermanager.CreateOptions) error {
m.Spy.Create = append(m.Spy.Create, []any{opts})
return nil
}
func (m *MockContainerManager) Remove(_ context.Context, containerName string, opts containermanager.RmOptions) error {
m.Spy.Remove = append(m.Spy.Remove, []any{containerName, opts})
return nil
}
func (m *MockContainerManager) Exists(_ context.Context, containerName string) bool {
m.Spy.Exists = append(m.Spy.Exists, []any{containerName})
return false
}
func (m *MockContainerManager) Stop(_ context.Context, containerNames []string) error {
m.Spy.Stop = append(m.Spy.Stop, []any{containerNames})
return nil
}
func (m *MockContainerManager) InspectContainer(_ context.Context, containerName string) (*containermanager.InspectResult, error) {
m.Spy.InspectContainer = append(m.Spy.InspectContainer, []any{containerName})
return &containermanager.InspectResult{}, nil
}
func (m *MockContainerManager) Commit(_ context.Context, containerID string, tag string) error {
m.Spy.Commit = append(m.Spy.Commit, []any{containerID, tag})
return nil
}
func (m *MockContainerManager) ImageExists(_ context.Context, imageName string) bool {
m.Spy.ImageExists = append(m.Spy.ImageExists, []any{imageName})
return false
}
func (m *MockContainerManager) PullImage(_ context.Context, imageName string, platform string) error {
m.Spy.PullImage = append(m.Spy.PullImage, []any{imageName, platform})
return nil
}
+9 -2
View File
@@ -12,6 +12,8 @@ import (
"strings"
"gopkg.in/ini.v1"
"github.com/89luca89/distrobox/internal/userenv"
)
// Item represents a single section in the manifest file.
@@ -65,12 +67,14 @@ func Parse(ctx context.Context, filepath string) ([]Item, error) {
return nil, fmt.Errorf("failed to expand includes: %w", err)
}
env := userenv.LoadUserEnvironment(ctx)
items := make([]Item, 0, len(cfg.Sections())-1)
for _, section := range cfg.Sections() {
if section.Name() == ini.DefaultSection {
continue
}
items = append(items, sectionToItem(section))
items = append(items, sectionToItem(section, env))
}
return items, nil
@@ -137,9 +141,12 @@ func resolveIncludes(cfg *ini.File, section *ini.Section, processing, processed
}
// sectionToItem converts an ini.Section to an Item struct.
func sectionToItem(section *ini.Section) Item { //nolint:funlen // Function length is acceptable here.
func sectionToItem(section *ini.Section, env *userenv.UserEnvironment) Item { //nolint:funlen // Function length is acceptable here.
item := Item{Name: section.Name()}
// default, to be overridden by manifest value if provided
item.ExportedBinsPath = env.Home + "/.local/bin"
for _, key := range section.Keys() {
vals := key.ValueWithShadows()
last := vals[len(vals)-1]