Implement controlled app launch (T-401)
This commit is contained in:
@@ -39,12 +39,16 @@ type InstalledFile struct {
|
||||
|
||||
// InstalledApp is the local installed-app.json v1 protocol.
|
||||
type InstalledApp struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
ID string `json:"id"`
|
||||
Version string `json:"version"`
|
||||
Architecture string `json:"architecture"`
|
||||
Channel string `json:"channel"`
|
||||
Files []InstalledFile `json:"files"`
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
ID string `json:"id"`
|
||||
Version string `json:"version"`
|
||||
Architecture string `json:"architecture"`
|
||||
Channel string `json:"channel"`
|
||||
Entrypoint string `json:"entrypoint,omitempty"`
|
||||
WorkingDirectory string `json:"working_directory,omitempty"`
|
||||
MinOS string `json:"min_os,omitempty"`
|
||||
RequiresAdmin bool `json:"requires_admin"`
|
||||
Files []InstalledFile `json:"files"`
|
||||
}
|
||||
|
||||
// InstallationSnapshot contains disk facts without deriving UI status.
|
||||
@@ -107,6 +111,37 @@ func (store *InstalledAppStore) Read(appID string) (record InstalledApp, found b
|
||||
return store.readLocked(appID)
|
||||
}
|
||||
|
||||
// ResolveCurrent returns a validated record and the real current directory
|
||||
// below its app root. It never creates directories and rejects unsafe layouts.
|
||||
func (store *InstalledAppStore) ResolveCurrent(appID string) (InstalledApp, string, error) {
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
|
||||
record, found, err := store.readLocked(appID)
|
||||
if err != nil {
|
||||
return InstalledApp{}, "", err
|
||||
}
|
||||
if !found {
|
||||
return InstalledApp{}, "", os.ErrNotExist
|
||||
}
|
||||
appRoot, exists, err := store.inspectAppRoot(appID)
|
||||
if err != nil {
|
||||
return InstalledApp{}, "", err
|
||||
}
|
||||
if !exists {
|
||||
return InstalledApp{}, "", os.ErrNotExist
|
||||
}
|
||||
current := filepath.Join(appRoot, "current")
|
||||
if err := requireRealDirectory(current); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return InstalledApp{}, "", fmt.Errorf("%w: current directory is missing: %w", ErrStorageLayoutUnsafe, err)
|
||||
}
|
||||
return InstalledApp{}, "", err
|
||||
}
|
||||
record.Files = append([]InstalledFile(nil), record.Files...)
|
||||
return record, current, nil
|
||||
}
|
||||
|
||||
// Inspect reads the installed record and detects an unfinished install journal.
|
||||
func (store *InstalledAppStore) Inspect(appID string) (InstallationSnapshot, error) {
|
||||
store.mu.Lock()
|
||||
@@ -283,6 +318,20 @@ func (record InstalledApp) validate() error {
|
||||
if record.Channel != "stable" {
|
||||
return fmt.Errorf("%w: channel=%q", ErrInstalledAppInvalid, record.Channel)
|
||||
}
|
||||
if record.Entrypoint != "" {
|
||||
if err := safepath.ValidateRelative(record.Entrypoint); err != nil {
|
||||
return fmt.Errorf("%w: entrypoint: %v", ErrInstalledAppInvalid, err)
|
||||
}
|
||||
}
|
||||
if record.WorkingDirectory != "" && record.WorkingDirectory != "." {
|
||||
if err := safepath.ValidateRelative(record.WorkingDirectory); err != nil {
|
||||
return fmt.Errorf("%w: working_directory: %v", ErrInstalledAppInvalid, err)
|
||||
}
|
||||
}
|
||||
if record.MinOS != "" && record.MinOS != "windows-7-sp1" &&
|
||||
record.MinOS != "windows-10" && record.MinOS != "windows-11" {
|
||||
return fmt.Errorf("%w: min_os=%q", ErrInstalledAppInvalid, record.MinOS)
|
||||
}
|
||||
if record.Files == nil {
|
||||
return fmt.Errorf("%w: files must be an array", ErrInstalledAppInvalid)
|
||||
}
|
||||
|
||||
@@ -239,6 +239,66 @@ func TestInstalledAppStoreMissingRecord(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstalledAppStoreResolveCurrentRequiresRealCurrentDirectory(t *testing.T) {
|
||||
appsRoot := filepath.Join(t.TempDir(), "apps")
|
||||
store := NewInstalledAppStore(appsRoot)
|
||||
record := validInstalledApp()
|
||||
if err := store.Write(record); err != nil {
|
||||
t.Fatalf("Write() error = %v", err)
|
||||
}
|
||||
if _, _, err := store.ResolveCurrent(record.ID); !errors.Is(err, ErrStorageLayoutUnsafe) {
|
||||
t.Fatalf("ResolveCurrent(missing) error = %v, want %v", err, ErrStorageLayoutUnsafe)
|
||||
}
|
||||
|
||||
current := filepath.Join(appsRoot, record.ID, "current")
|
||||
if err := os.MkdirAll(current, 0o700); err != nil {
|
||||
t.Fatalf("MkdirAll(current) error = %v", err)
|
||||
}
|
||||
loaded, resolvedCurrent, err := store.ResolveCurrent(record.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveCurrent() error = %v", err)
|
||||
}
|
||||
if loaded.ID != record.ID || resolvedCurrent != current {
|
||||
t.Fatalf("ResolveCurrent() = (%#v, %q), want (%#v, %q)", loaded, resolvedCurrent, record, current)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstalledAppStoreValidatesOptionalLaunchMetadata(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*InstalledApp)
|
||||
}{
|
||||
{
|
||||
name: "unsafe entrypoint",
|
||||
mutate: func(record *InstalledApp) {
|
||||
record.Entrypoint = "../App.exe"
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unsafe working directory",
|
||||
mutate: func(record *InstalledApp) {
|
||||
record.WorkingDirectory = "bin/.. "
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unsupported minimum OS",
|
||||
mutate: func(record *InstalledApp) {
|
||||
record.MinOS = "windows-12"
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
record := validInstalledApp()
|
||||
test.mutate(&record)
|
||||
err := NewInstalledAppStore(filepath.Join(t.TempDir(), "apps")).Write(record)
|
||||
if !errors.Is(err, ErrInstalledAppInvalid) {
|
||||
t.Fatalf("Write() error = %v, want %v", err, ErrInstalledAppInvalid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func validInstalledApp() InstalledApp {
|
||||
return InstalledApp{
|
||||
SchemaVersion: 1,
|
||||
|
||||
Reference in New Issue
Block a user