Implement controlled app launch (T-401)
This commit is contained in:
@@ -0,0 +1,319 @@
|
||||
// Package launch contains the core-only, fail-closed application startup use
|
||||
// case. Platform process, compatibility and process-creation capabilities are
|
||||
// supplied by the caller.
|
||||
package launch
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
|
||||
"softbox.local/core/internal/safepath"
|
||||
"softbox.local/core/storage"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrLaunchConfig = errors.New("invalid launch service configuration")
|
||||
ErrLaunchRequest = errors.New("invalid launch request")
|
||||
ErrAppNotInstalled = errors.New("app is not installed")
|
||||
ErrLaunchMetadata = errors.New("installed launch metadata is invalid")
|
||||
ErrLaunchTargetUnsafe = errors.New("installed launch target is unsafe")
|
||||
ErrEntrypointMissing = errors.New("installed entrypoint is missing")
|
||||
ErrCompatibilityCheck = errors.New("system compatibility check failed")
|
||||
ErrAppIncompatible = errors.New("installed app is incompatible with this system")
|
||||
ErrAuthorizationCheck = errors.New("launch authorization check failed")
|
||||
ErrLaunchUnauthorized = errors.New("launch is not authorized")
|
||||
ErrTargetStateCheck = errors.New("launch target state check failed")
|
||||
ErrAppRunning = errors.New("installed app is already running")
|
||||
ErrProcessStart = errors.New("start installed app")
|
||||
launchAppIDPattern = regexp.MustCompile(`^[a-z0-9-]+$`)
|
||||
)
|
||||
|
||||
// FailureCode is the stable, non-localized result of a launch attempt.
|
||||
type FailureCode string
|
||||
|
||||
const (
|
||||
FailureCodeNotInstalled FailureCode = "not_installed"
|
||||
FailureCodeLaunchMetadataInvalid FailureCode = "launch_metadata_invalid"
|
||||
FailureCodeLaunchTargetUnsafe FailureCode = "launch_target_unsafe"
|
||||
FailureCodeEntrypointMissing FailureCode = "entrypoint_missing"
|
||||
FailureCodeCompatibilityUnavailable FailureCode = "compatibility_unavailable"
|
||||
FailureCodeAppIncompatible FailureCode = "app_incompatible"
|
||||
FailureCodeAuthorizationFailed FailureCode = "authorization_unavailable"
|
||||
FailureCodeLaunchUnauthorized FailureCode = "not_authorized"
|
||||
FailureCodeAppRunning FailureCode = "app_running"
|
||||
FailureCodeTargetStateUnavailable FailureCode = "target_state_unavailable"
|
||||
FailureCodeLaunchFailed FailureCode = "launch_failed"
|
||||
)
|
||||
|
||||
// Error preserves a stable launch code and the diagnostic cause.
|
||||
type Error struct {
|
||||
Code FailureCode
|
||||
Err error
|
||||
}
|
||||
|
||||
func (err *Error) Error() string {
|
||||
return fmt.Sprintf("launch (%s): %v", err.Code, err.Err)
|
||||
}
|
||||
|
||||
func (err *Error) Unwrap() error {
|
||||
return err.Err
|
||||
}
|
||||
|
||||
// Request names the installed app to start. The caller cannot supply a path,
|
||||
// arguments or working directory.
|
||||
type Request struct {
|
||||
AppID string
|
||||
}
|
||||
|
||||
// Command contains only paths resolved from verified installed metadata.
|
||||
type Command struct {
|
||||
Entrypoint string
|
||||
WorkingDirectory string
|
||||
RequiresAdmin bool
|
||||
}
|
||||
|
||||
// Result is returned only after the platform accepted the process start.
|
||||
type Result struct {
|
||||
AppID string
|
||||
PID int
|
||||
}
|
||||
|
||||
// InstalledAppResolver returns an installed record paired with a validated
|
||||
// real current directory.
|
||||
type InstalledAppResolver interface {
|
||||
ResolveCurrent(appID string) (storage.InstalledApp, string, error)
|
||||
}
|
||||
|
||||
// CompatibilityChecker reports whether a recorded min_os may run here.
|
||||
type CompatibilityChecker interface {
|
||||
IsCompatible(minOS string) (bool, error)
|
||||
}
|
||||
|
||||
// AuthorizationChecker decides whether the user may launch one app. It is a
|
||||
// required boundary; license policy is implemented by the later licensing task.
|
||||
type AuthorizationChecker interface {
|
||||
IsAuthorized(appID string) (bool, error)
|
||||
}
|
||||
|
||||
// TargetStateChecker reports whether this precise entrypoint is running.
|
||||
type TargetStateChecker interface {
|
||||
IsRunning(appID string, entrypointPath string) (bool, error)
|
||||
}
|
||||
|
||||
// ProcessLauncher starts one verified command without accepting shell input.
|
||||
type ProcessLauncher interface {
|
||||
Start(command Command) (int, error)
|
||||
}
|
||||
|
||||
// ServiceConfig makes every external launch dependency explicit.
|
||||
type ServiceConfig struct {
|
||||
Records InstalledAppResolver
|
||||
Compatibility CompatibilityChecker
|
||||
Authorization AuthorizationChecker
|
||||
TargetState TargetStateChecker
|
||||
Launcher ProcessLauncher
|
||||
}
|
||||
|
||||
// Service executes the safe local launch flow.
|
||||
type Service struct {
|
||||
records InstalledAppResolver
|
||||
compatibility CompatibilityChecker
|
||||
authorization AuthorizationChecker
|
||||
targetState TargetStateChecker
|
||||
launcher ProcessLauncher
|
||||
}
|
||||
|
||||
func NewService(config ServiceConfig) (*Service, error) {
|
||||
if config.Records == nil {
|
||||
return nil, fmt.Errorf("%w: installed app resolver is required", ErrLaunchConfig)
|
||||
}
|
||||
if config.Compatibility == nil {
|
||||
return nil, fmt.Errorf("%w: compatibility checker is required", ErrLaunchConfig)
|
||||
}
|
||||
if config.Authorization == nil {
|
||||
return nil, fmt.Errorf("%w: authorization checker is required", ErrLaunchConfig)
|
||||
}
|
||||
if config.TargetState == nil {
|
||||
return nil, fmt.Errorf("%w: target state checker is required", ErrLaunchConfig)
|
||||
}
|
||||
if config.Launcher == nil {
|
||||
return nil, fmt.Errorf("%w: process launcher is required", ErrLaunchConfig)
|
||||
}
|
||||
return &Service{
|
||||
records: config.Records,
|
||||
compatibility: config.Compatibility,
|
||||
authorization: config.Authorization,
|
||||
targetState: config.TargetState,
|
||||
launcher: config.Launcher,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Start validates local metadata and filesystem identity before invoking the
|
||||
// platform process launcher.
|
||||
func (service *Service) Start(request Request) (Result, error) {
|
||||
if !launchAppIDPattern.MatchString(request.AppID) {
|
||||
return Result{}, launchError(ErrLaunchRequest)
|
||||
}
|
||||
record, current, err := service.records.ResolveCurrent(request.AppID)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return Result{}, launchError(ErrAppNotInstalled)
|
||||
}
|
||||
if errors.Is(err, storage.ErrStorageLayoutUnsafe) {
|
||||
return Result{}, launchError(fmt.Errorf("%w: %w", ErrLaunchTargetUnsafe, err))
|
||||
}
|
||||
return Result{}, launchError(fmt.Errorf("resolve installed app: %w", err))
|
||||
}
|
||||
command, err := launchCommand(record, current)
|
||||
if err != nil {
|
||||
return Result{}, launchError(err)
|
||||
}
|
||||
compatible, err := service.compatibility.IsCompatible(record.MinOS)
|
||||
if err != nil {
|
||||
return Result{}, launchError(fmt.Errorf("%w: %w", ErrCompatibilityCheck, err))
|
||||
}
|
||||
if !compatible {
|
||||
return Result{}, launchError(ErrAppIncompatible)
|
||||
}
|
||||
authorized, err := service.authorization.IsAuthorized(record.ID)
|
||||
if err != nil {
|
||||
return Result{}, launchError(fmt.Errorf("%w: %w", ErrAuthorizationCheck, err))
|
||||
}
|
||||
if !authorized {
|
||||
return Result{}, launchError(ErrLaunchUnauthorized)
|
||||
}
|
||||
running, err := service.targetState.IsRunning(record.ID, command.Entrypoint)
|
||||
if err != nil {
|
||||
return Result{}, launchError(fmt.Errorf("%w: %w", ErrTargetStateCheck, err))
|
||||
}
|
||||
if running {
|
||||
return Result{}, launchError(ErrAppRunning)
|
||||
}
|
||||
pid, err := service.launcher.Start(command)
|
||||
if err != nil {
|
||||
return Result{}, launchError(fmt.Errorf("%w: %w", ErrProcessStart, err))
|
||||
}
|
||||
if pid <= 0 {
|
||||
return Result{}, launchError(fmt.Errorf("%w: invalid process ID", ErrProcessStart))
|
||||
}
|
||||
return Result{AppID: record.ID, PID: pid}, nil
|
||||
}
|
||||
|
||||
func launchCommand(record storage.InstalledApp, current string) (Command, error) {
|
||||
if record.Entrypoint == "" || record.WorkingDirectory == "" || record.MinOS == "" {
|
||||
return Command{}, ErrLaunchMetadata
|
||||
}
|
||||
if err := safepath.ValidateRelative(record.Entrypoint); err != nil {
|
||||
return Command{}, fmt.Errorf("%w: entrypoint: %v", ErrLaunchMetadata, err)
|
||||
}
|
||||
if record.WorkingDirectory != "." {
|
||||
if err := safepath.ValidateRelative(record.WorkingDirectory); err != nil {
|
||||
return Command{}, fmt.Errorf("%w: working directory: %v", ErrLaunchMetadata, err)
|
||||
}
|
||||
}
|
||||
if !validMinOS(record.MinOS) {
|
||||
return Command{}, ErrLaunchMetadata
|
||||
}
|
||||
if !containsEntrypoint(record.Files, record.Entrypoint) {
|
||||
return Command{}, fmt.Errorf("%w: entrypoint is not in installed files", ErrLaunchMetadata)
|
||||
}
|
||||
if err := requireRealDirectory(current); err != nil {
|
||||
return Command{}, err
|
||||
}
|
||||
entrypoint, err := safepath.JoinUnder(current, record.Entrypoint)
|
||||
if err != nil {
|
||||
return Command{}, fmt.Errorf("%w: entrypoint: %v", ErrLaunchTargetUnsafe, err)
|
||||
}
|
||||
workingDirectory := current
|
||||
if record.WorkingDirectory != "." {
|
||||
workingDirectory, err = safepath.JoinUnder(current, record.WorkingDirectory)
|
||||
if err != nil {
|
||||
return Command{}, fmt.Errorf("%w: working directory: %v", ErrLaunchTargetUnsafe, err)
|
||||
}
|
||||
}
|
||||
if err := requireRealDirectory(workingDirectory); err != nil {
|
||||
return Command{}, err
|
||||
}
|
||||
if err := requireRegularFile(entrypoint); err != nil {
|
||||
return Command{}, err
|
||||
}
|
||||
return Command{
|
||||
Entrypoint: entrypoint,
|
||||
WorkingDirectory: workingDirectory,
|
||||
RequiresAdmin: record.RequiresAdmin,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func containsEntrypoint(files []storage.InstalledFile, entrypoint string) bool {
|
||||
for _, file := range files {
|
||||
if file.Path == entrypoint {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validMinOS(minOS string) bool {
|
||||
return minOS == "windows-7-sp1" || minOS == "windows-10" || minOS == "windows-11"
|
||||
}
|
||||
|
||||
func requireRealDirectory(path string) error {
|
||||
info, err := os.Lstat(path)
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("%w: %s", ErrLaunchTargetUnsafe, path)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: inspect %s: %w", ErrLaunchTargetUnsafe, path, err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||||
return fmt.Errorf("%w: %s is not a real directory", ErrLaunchTargetUnsafe, path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func requireRegularFile(path string) error {
|
||||
info, err := os.Lstat(path)
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("%w: %s", ErrEntrypointMissing, path)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: inspect %s: %w", ErrLaunchTargetUnsafe, path, err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("%w: %s is not a regular file", ErrLaunchTargetUnsafe, path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func launchError(err error) error {
|
||||
return &Error{Code: failureCodeFor(err), Err: err}
|
||||
}
|
||||
|
||||
func failureCodeFor(err error) FailureCode {
|
||||
switch {
|
||||
case errors.Is(err, ErrAppNotInstalled):
|
||||
return FailureCodeNotInstalled
|
||||
case errors.Is(err, ErrLaunchMetadata):
|
||||
return FailureCodeLaunchMetadataInvalid
|
||||
case errors.Is(err, ErrLaunchTargetUnsafe):
|
||||
return FailureCodeLaunchTargetUnsafe
|
||||
case errors.Is(err, ErrEntrypointMissing):
|
||||
return FailureCodeEntrypointMissing
|
||||
case errors.Is(err, ErrCompatibilityCheck):
|
||||
return FailureCodeCompatibilityUnavailable
|
||||
case errors.Is(err, ErrAppIncompatible):
|
||||
return FailureCodeAppIncompatible
|
||||
case errors.Is(err, ErrAuthorizationCheck):
|
||||
return FailureCodeAuthorizationFailed
|
||||
case errors.Is(err, ErrLaunchUnauthorized):
|
||||
return FailureCodeLaunchUnauthorized
|
||||
case errors.Is(err, ErrAppRunning):
|
||||
return FailureCodeAppRunning
|
||||
case errors.Is(err, ErrTargetStateCheck):
|
||||
return FailureCodeTargetStateUnavailable
|
||||
default:
|
||||
return FailureCodeLaunchFailed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
package launch
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"softbox.local/core/storage"
|
||||
)
|
||||
|
||||
func TestServiceStartsOnlyVerifiedCurrentEntrypoint(t *testing.T) {
|
||||
store, appRoot := seedInstalledApp(t)
|
||||
launcher := &recordingLauncher{pid: 42}
|
||||
service := newService(t, store, launcher)
|
||||
|
||||
result, err := service.Start(Request{AppID: "test-app"})
|
||||
if err != nil {
|
||||
t.Fatalf("Start() error = %v", err)
|
||||
}
|
||||
if result != (Result{AppID: "test-app", PID: 42}) {
|
||||
t.Fatalf("result = %#v", result)
|
||||
}
|
||||
wantEntrypoint := filepath.Join(appRoot, "current", "bin", "App.exe")
|
||||
if launcher.command.Entrypoint != wantEntrypoint ||
|
||||
launcher.command.WorkingDirectory != filepath.Join(appRoot, "current", "bin") ||
|
||||
!launcher.command.RequiresAdmin {
|
||||
t.Fatalf("command = %#v", launcher.command)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceRejectsUnsafeOrUnavailableLaunchStates(t *testing.T) {
|
||||
errCompatibility := errors.New("compatibility unavailable")
|
||||
errAuthorization := errors.New("authorization unavailable")
|
||||
errTargetState := errors.New("target state unavailable")
|
||||
errStart := errors.New("start failed")
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*storage.InstalledApp, string)
|
||||
service func(*Service)
|
||||
wantErr error
|
||||
wantCode FailureCode
|
||||
}{
|
||||
{
|
||||
name: "missing legacy metadata",
|
||||
mutate: func(record *storage.InstalledApp, _ string) {
|
||||
record.Entrypoint = ""
|
||||
},
|
||||
wantErr: ErrLaunchMetadata,
|
||||
wantCode: FailureCodeLaunchMetadataInvalid,
|
||||
},
|
||||
{
|
||||
name: "entrypoint is absent",
|
||||
mutate: func(_ *storage.InstalledApp, appRoot string) {
|
||||
if err := os.Remove(filepath.Join(appRoot, "current", "bin", "App.exe")); err != nil {
|
||||
t.Fatalf("remove entrypoint: %v", err)
|
||||
}
|
||||
},
|
||||
wantErr: ErrEntrypointMissing,
|
||||
wantCode: FailureCodeEntrypointMissing,
|
||||
},
|
||||
{
|
||||
name: "unsafe current layout",
|
||||
service: func(service *Service) {
|
||||
service.records = resolverFunc(func(string) (storage.InstalledApp, string, error) {
|
||||
return storage.InstalledApp{}, "", storage.ErrStorageLayoutUnsafe
|
||||
})
|
||||
},
|
||||
wantErr: ErrLaunchTargetUnsafe,
|
||||
wantCode: FailureCodeLaunchTargetUnsafe,
|
||||
},
|
||||
{
|
||||
name: "incompatible system",
|
||||
service: func(service *Service) {
|
||||
service.compatibility = compatibilityFunc(func(string) (bool, error) { return false, nil })
|
||||
},
|
||||
wantErr: ErrAppIncompatible,
|
||||
wantCode: FailureCodeAppIncompatible,
|
||||
},
|
||||
{
|
||||
name: "compatibility failure",
|
||||
service: func(service *Service) {
|
||||
service.compatibility = compatibilityFunc(func(string) (bool, error) { return false, errCompatibility })
|
||||
},
|
||||
wantErr: errCompatibility,
|
||||
wantCode: FailureCodeCompatibilityUnavailable,
|
||||
},
|
||||
{
|
||||
name: "unauthorized",
|
||||
service: func(service *Service) {
|
||||
service.authorization = authorizationFunc(func(string) (bool, error) { return false, nil })
|
||||
},
|
||||
wantErr: ErrLaunchUnauthorized,
|
||||
wantCode: FailureCodeLaunchUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "authorization failure",
|
||||
service: func(service *Service) {
|
||||
service.authorization = authorizationFunc(func(string) (bool, error) { return false, errAuthorization })
|
||||
},
|
||||
wantErr: errAuthorization,
|
||||
wantCode: FailureCodeAuthorizationFailed,
|
||||
},
|
||||
{
|
||||
name: "already running",
|
||||
service: func(service *Service) {
|
||||
service.targetState = targetStateFunc(func(string, string) (bool, error) { return true, nil })
|
||||
},
|
||||
wantErr: ErrAppRunning,
|
||||
wantCode: FailureCodeAppRunning,
|
||||
},
|
||||
{
|
||||
name: "target state failure",
|
||||
service: func(service *Service) {
|
||||
service.targetState = targetStateFunc(func(string, string) (bool, error) { return false, errTargetState })
|
||||
},
|
||||
wantErr: errTargetState,
|
||||
wantCode: FailureCodeTargetStateUnavailable,
|
||||
},
|
||||
{
|
||||
name: "launcher failure",
|
||||
service: func(service *Service) {
|
||||
service.launcher = &recordingLauncher{err: errStart}
|
||||
},
|
||||
wantErr: errStart,
|
||||
wantCode: FailureCodeLaunchFailed,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
store, appRoot := seedInstalledApp(t)
|
||||
record, found, err := store.Read("test-app")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("Read() found=%t err=%v", found, err)
|
||||
}
|
||||
if test.mutate != nil {
|
||||
test.mutate(&record, appRoot)
|
||||
if err := store.Write(record); err != nil {
|
||||
t.Fatalf("Write() error = %v", err)
|
||||
}
|
||||
}
|
||||
launcher := &recordingLauncher{pid: 42}
|
||||
service := newService(t, store, launcher)
|
||||
if test.service != nil {
|
||||
test.service(service)
|
||||
}
|
||||
|
||||
_, err = service.Start(Request{AppID: "test-app"})
|
||||
if !errors.Is(err, test.wantErr) {
|
||||
t.Fatalf("Start() error = %v, want %v", err, test.wantErr)
|
||||
}
|
||||
if code := launchErrorCode(t, err); code != test.wantCode {
|
||||
t.Fatalf("code = %q, want %q", code, test.wantCode)
|
||||
}
|
||||
if launcher.calls != 0 {
|
||||
t.Fatalf("launcher calls = %d, want 0", launcher.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewServiceRequiresEveryDependency(t *testing.T) {
|
||||
store, _ := seedInstalledApp(t)
|
||||
config := ServiceConfig{
|
||||
Records: store,
|
||||
Compatibility: compatibilityFunc(func(string) (bool, error) { return true, nil }),
|
||||
Authorization: authorizationFunc(func(string) (bool, error) { return true, nil }),
|
||||
TargetState: targetStateFunc(func(string, string) (bool, error) { return false, nil }),
|
||||
Launcher: &recordingLauncher{pid: 1},
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mutate func(*ServiceConfig)
|
||||
}{
|
||||
{"records", func(config *ServiceConfig) { config.Records = nil }},
|
||||
{"compatibility", func(config *ServiceConfig) { config.Compatibility = nil }},
|
||||
{"authorization", func(config *ServiceConfig) { config.Authorization = nil }},
|
||||
{"target state", func(config *ServiceConfig) { config.TargetState = nil }},
|
||||
{"launcher", func(config *ServiceConfig) { config.Launcher = nil }},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
candidate := config
|
||||
test.mutate(&candidate)
|
||||
if _, err := NewService(candidate); !errors.Is(err, ErrLaunchConfig) {
|
||||
t.Fatalf("NewService() error = %v, want %v", err, ErrLaunchConfig)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func seedInstalledApp(t *testing.T) (*storage.InstalledAppStore, string) {
|
||||
t.Helper()
|
||||
appsRoot := filepath.Join(t.TempDir(), "apps")
|
||||
store := storage.NewInstalledAppStore(appsRoot)
|
||||
record := storage.InstalledApp{
|
||||
SchemaVersion: 1,
|
||||
ID: "test-app",
|
||||
Version: "1.2.3",
|
||||
Architecture: "amd64",
|
||||
Channel: "stable",
|
||||
Entrypoint: "bin/App.exe",
|
||||
WorkingDirectory: "bin",
|
||||
MinOS: "windows-10",
|
||||
RequiresAdmin: true,
|
||||
Files: []storage.InstalledFile{{
|
||||
Path: "bin/App.exe",
|
||||
Size: 1,
|
||||
SHA256: "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
}},
|
||||
}
|
||||
if err := store.Write(record); err != nil {
|
||||
t.Fatalf("Write() error = %v", err)
|
||||
}
|
||||
appRoot := filepath.Join(appsRoot, record.ID)
|
||||
entrypoint := filepath.Join(appRoot, "current", "bin", "App.exe")
|
||||
if err := os.MkdirAll(filepath.Dir(entrypoint), 0o700); err != nil {
|
||||
t.Fatalf("MkdirAll() error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(entrypoint, []byte("x"), 0o700); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
return store, appRoot
|
||||
}
|
||||
|
||||
func newService(t *testing.T, records InstalledAppResolver, launcher ProcessLauncher) *Service {
|
||||
t.Helper()
|
||||
service, err := NewService(ServiceConfig{
|
||||
Records: records,
|
||||
Compatibility: compatibilityFunc(func(string) (bool, error) { return true, nil }),
|
||||
Authorization: authorizationFunc(func(string) (bool, error) { return true, nil }),
|
||||
TargetState: targetStateFunc(func(string, string) (bool, error) { return false, nil }),
|
||||
Launcher: launcher,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewService() error = %v", err)
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
func launchErrorCode(t *testing.T, err error) FailureCode {
|
||||
t.Helper()
|
||||
var launchErr *Error
|
||||
if !errors.As(err, &launchErr) {
|
||||
t.Fatalf("error = %v, want launch Error", err)
|
||||
}
|
||||
return launchErr.Code
|
||||
}
|
||||
|
||||
type compatibilityFunc func(string) (bool, error)
|
||||
|
||||
type resolverFunc func(string) (storage.InstalledApp, string, error)
|
||||
|
||||
func (resolver resolverFunc) ResolveCurrent(appID string) (storage.InstalledApp, string, error) {
|
||||
return resolver(appID)
|
||||
}
|
||||
|
||||
func (checker compatibilityFunc) IsCompatible(minOS string) (bool, error) {
|
||||
return checker(minOS)
|
||||
}
|
||||
|
||||
type authorizationFunc func(string) (bool, error)
|
||||
|
||||
func (checker authorizationFunc) IsAuthorized(appID string) (bool, error) {
|
||||
return checker(appID)
|
||||
}
|
||||
|
||||
type targetStateFunc func(string, string) (bool, error)
|
||||
|
||||
func (checker targetStateFunc) IsRunning(appID, entrypoint string) (bool, error) {
|
||||
return checker(appID, entrypoint)
|
||||
}
|
||||
|
||||
type recordingLauncher struct {
|
||||
command Command
|
||||
pid int
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (launcher *recordingLauncher) Start(command Command) (int, error) {
|
||||
launcher.calls++
|
||||
launcher.command = command
|
||||
if launcher.err != nil {
|
||||
return 0, launcher.err
|
||||
}
|
||||
return launcher.pid, nil
|
||||
}
|
||||
Reference in New Issue
Block a user