mirror of
https://github.com/89luca89/distrobox.git
synced 2026-08-19 01:14:49 -05:00
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:
committed by
Alessio Biancalana
parent
9c2de1e996
commit
8c0408396e
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user