diff --git a/internal/manifest/manifest.go b/internal/manifest/manifest.go new file mode 100644 index 0000000..6aa168a --- /dev/null +++ b/internal/manifest/manifest.go @@ -0,0 +1,215 @@ +// Package manifest defines the installer's on-disk state file and the +// bookkeeping operations over it. One manifest lives at the root of each install +// scope (see internal/platform) and records the installed interpreters, backups +// of any interpreters they replaced, the active interpreter, and the installed +// extension (if any). +// +// Variant keys (the map keys for interpreters/backups, and the value of +// ActiveInterpreter) are opaque strings supplied by the caller. The installer +// uses platform.VersionDirName (e.g. "8.3", "8.3-zts") so that each key maps 1:1 +// to an on-disk version directory. +package manifest + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" +) + +// SchemaVersion is the current manifest schema version. +const SchemaVersion = 1 + +// Manifest is the root state object persisted as JSON. +type Manifest struct { + SchemaVersion int `json:"schemaVersion"` + InstallRoot string `json:"installRoot,omitempty"` + BinDir string `json:"binDir,omitempty"` + ActiveInterpreter string `json:"activeInterpreter,omitempty"` + Interpreters map[string]Interpreter `json:"interpreters,omitempty"` + Backups map[string]Backup `json:"backups,omitempty"` + Extension *Extension `json:"extension,omitempty"` +} + +// Interpreter records a single installed PHP interpreter variant. +type Interpreter struct { + Series string `json:"series"` // selected major.minor, e.g. "8.3" + PHPVersion string `json:"phpVersion"` // full version reported, e.g. "8.3.10" + ZTS bool `json:"zts"` + ReleaseTag string `json:"releaseTag"` + Dir string `json:"dir"` // absolute install directory + InstalledAt time.Time `json:"installedAt"` +} + +// Backup records an interpreter that was replaced during an install, so it can +// be restored on uninstall. +type Backup struct { + OriginalPath string `json:"originalPath"` // where the replaced php lived + BackupPath string `json:"backupPath"` // where we saved a copy + CreatedAt time.Time `json:"createdAt"` +} + +// Extension records an installed debugger extension (extension-only installs). +type Extension struct { + Series string `json:"series"` // php major.minor + PHPVersion string `json:"phpVersion"` // full version of the target php + ZTS bool `json:"zts"` + ReleaseTag string `json:"releaseTag"` + SoPath string `json:"soPath"` // installed .so/.dll path + IniPath string `json:"iniPath"` // ini file holding the loader line + InstalledAt time.Time `json:"installedAt"` +} + +// New returns an empty manifest for the given install root and bin directory. +func New(root, binDir string) *Manifest { + m := newEmpty() + m.InstallRoot = root + m.BinDir = binDir + return m +} + +func newEmpty() *Manifest { + return &Manifest{ + SchemaVersion: SchemaVersion, + Interpreters: map[string]Interpreter{}, + Backups: map[string]Backup{}, + } +} + +// Load reads a manifest from path. If the file does not exist, it returns a +// fresh empty manifest and no error (first run). +func Load(path string) (*Manifest, error) { + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return newEmpty(), nil + } + if err != nil { + return nil, fmt.Errorf("reading manifest %s: %w", path, err) + } + var m Manifest + if err := json.Unmarshal(data, &m); err != nil { + return nil, fmt.Errorf("parsing manifest %s: %w", path, err) + } + if m.SchemaVersion > SchemaVersion { + return nil, fmt.Errorf("manifest %s has schema version %d, newer than supported %d", + path, m.SchemaVersion, SchemaVersion) + } + if m.SchemaVersion == 0 { + m.SchemaVersion = SchemaVersion + } + m.ensureMaps() + return &m, nil +} + +// Save writes the manifest to path atomically (write temp, then rename), +// creating the parent directory if needed. +func (m *Manifest) Save(path string) error { + m.SchemaVersion = SchemaVersion + + data, err := json.MarshalIndent(m, "", " ") + if err != nil { + return fmt.Errorf("encoding manifest: %w", err) + } + data = append(data, '\n') + + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("creating manifest directory %s: %w", dir, err) + } + + tmp, err := os.CreateTemp(dir, ".manifest-*.json.tmp") + if err != nil { + return fmt.Errorf("creating temp manifest: %w", err) + } + tmpName := tmp.Name() + if _, err := tmp.Write(data); err != nil { + tmp.Close() + os.Remove(tmpName) + return fmt.Errorf("writing temp manifest: %w", err) + } + if err := tmp.Close(); err != nil { + os.Remove(tmpName) + return fmt.Errorf("closing temp manifest: %w", err) + } + if err := os.Rename(tmpName, path); err != nil { + os.Remove(tmpName) + return fmt.Errorf("finalizing manifest %s: %w", path, err) + } + return nil +} + +func (m *Manifest) ensureMaps() { + if m.Interpreters == nil { + m.Interpreters = map[string]Interpreter{} + } + if m.Backups == nil { + m.Backups = map[string]Backup{} + } +} + +// --- Interpreter bookkeeping --- + +// SetInterpreter records (or replaces) an interpreter variant under key. +func (m *Manifest) SetInterpreter(key string, it Interpreter) { + m.ensureMaps() + m.Interpreters[key] = it +} + +// Interpreter returns the interpreter recorded under key, if any. +func (m *Manifest) Interpreter(key string) (Interpreter, bool) { + it, ok := m.Interpreters[key] + return it, ok +} + +// RemoveInterpreter deletes the interpreter under key. If it was the active +// interpreter, the active pointer is cleared to avoid dangling references. +func (m *Manifest) RemoveInterpreter(key string) { + delete(m.Interpreters, key) + if m.ActiveInterpreter == key { + m.ActiveInterpreter = "" + } +} + +// InterpreterKeys returns the recorded interpreter keys (unordered). +func (m *Manifest) InterpreterKeys() []string { + keys := make([]string, 0, len(m.Interpreters)) + for k := range m.Interpreters { + keys = append(keys, k) + } + return keys +} + +// --- Active pointer --- + +// SetActive sets the active interpreter key. +func (m *Manifest) SetActive(key string) { m.ActiveInterpreter = key } + +// Active returns the active interpreter key ("" if none). +func (m *Manifest) Active() string { return m.ActiveInterpreter } + +// --- Backup bookkeeping --- + +// SetBackup records a backup of a replaced interpreter under key. +func (m *Manifest) SetBackup(key string, b Backup) { + m.ensureMaps() + m.Backups[key] = b +} + +// Backup returns the backup recorded under key, if any. +func (m *Manifest) Backup(key string) (Backup, bool) { + b, ok := m.Backups[key] + return b, ok +} + +// RemoveBackup deletes the backup record under key (e.g. after restoring it). +func (m *Manifest) RemoveBackup(key string) { delete(m.Backups, key) } + +// --- Extension bookkeeping --- + +// SetExtension records the installed debugger extension. +func (m *Manifest) SetExtension(e Extension) { m.Extension = &e } + +// ClearExtension removes the recorded extension. +func (m *Manifest) ClearExtension() { m.Extension = nil } diff --git a/internal/manifest/manifest_test.go b/internal/manifest/manifest_test.go new file mode 100644 index 0000000..8b10c97 --- /dev/null +++ b/internal/manifest/manifest_test.go @@ -0,0 +1,213 @@ +package manifest + +import ( + "os" + "path/filepath" + "reflect" + "testing" + "time" +) + +// fixed UTC times so JSON round-trips compare equal with reflect.DeepEqual. +var ( + t1 = time.Date(2026, 7, 23, 10, 0, 0, 0, time.UTC) + t2 = time.Date(2026, 7, 20, 8, 30, 0, 0, time.UTC) +) + +func sampleManifest() *Manifest { + m := New("/opt/php-debugger", "/usr/local/bin") + m.SetInterpreter("8.3", Interpreter{ + Series: "8.3", PHPVersion: "8.3.10", ZTS: false, + ReleaseTag: "0.1.0", Dir: "/opt/php-debugger/8.3", InstalledAt: t1, + }) + m.SetInterpreter("8.2-zts", Interpreter{ + Series: "8.2", PHPVersion: "8.2.20", ZTS: true, + ReleaseTag: "0.1.0", Dir: "/opt/php-debugger/8.2-zts", InstalledAt: t2, + }) + m.SetActive("8.3") + m.SetBackup("8.3", Backup{ + OriginalPath: "/usr/bin/php", + BackupPath: "/opt/php-debugger/backups/php-8.3", + CreatedAt: t1, + }) + m.SetExtension(Extension{ + Series: "8.1", PHPVersion: "8.1.29", ZTS: false, ReleaseTag: "0.1.0", + SoPath: "/usr/lib/php/ext/php-debugger.so", IniPath: "/etc/php/8.1/conf.d/99-debugger.ini", + InstalledAt: t2, + }) + return m +} + +func TestSaveLoadRoundTrip(t *testing.T) { + path := filepath.Join(t.TempDir(), "manifest.json") + want := sampleManifest() + + if err := want.Save(path); err != nil { + t.Fatalf("Save: %v", err) + } + got, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if !reflect.DeepEqual(got, want) { + t.Errorf("round-trip mismatch:\n got = %+v\nwant = %+v", got, want) + } +} + +func TestLoadMissingReturnsEmpty(t *testing.T) { + path := filepath.Join(t.TempDir(), "does-not-exist.json") + m, err := Load(path) + if err != nil { + t.Fatalf("Load missing: unexpected error: %v", err) + } + if m.SchemaVersion != SchemaVersion { + t.Errorf("SchemaVersion = %d, want %d", m.SchemaVersion, SchemaVersion) + } + if m.Interpreters == nil || m.Backups == nil { + t.Error("maps should be initialized on empty manifest") + } + if len(m.Interpreters) != 0 || m.Active() != "" || m.Extension != nil { + t.Error("empty manifest should have no entries") + } +} + +func TestSaveCreatesParentDir(t *testing.T) { + path := filepath.Join(t.TempDir(), "a", "b", "manifest.json") + if err := New("/root", "/bin").Save(path); err != nil { + t.Fatalf("Save into nested dir: %v", err) + } + if _, err := os.Stat(path); err != nil { + t.Errorf("manifest not written: %v", err) + } +} + +func TestSaveOverwrites(t *testing.T) { + path := filepath.Join(t.TempDir(), "manifest.json") + + m := New("/root", "/bin") + m.SetInterpreter("8.3", Interpreter{Series: "8.3", InstalledAt: t1}) + if err := m.Save(path); err != nil { + t.Fatal(err) + } + + m.RemoveInterpreter("8.3") + m.SetInterpreter("8.4", Interpreter{Series: "8.4", InstalledAt: t2}) + if err := m.Save(path); err != nil { + t.Fatal(err) + } + + got, err := Load(path) + if err != nil { + t.Fatal(err) + } + if _, ok := got.Interpreter("8.3"); ok { + t.Error("8.3 should have been overwritten away") + } + if _, ok := got.Interpreter("8.4"); !ok { + t.Error("8.4 should be present after overwrite") + } +} + +func TestLoadInvalidJSON(t *testing.T) { + path := filepath.Join(t.TempDir(), "manifest.json") + if err := os.WriteFile(path, []byte("{not json"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := Load(path); err == nil { + t.Error("Load of invalid JSON: expected error") + } +} + +func TestLoadRejectsNewerSchema(t *testing.T) { + path := filepath.Join(t.TempDir(), "manifest.json") + if err := os.WriteFile(path, []byte(`{"schemaVersion": 999}`), 0o644); err != nil { + t.Fatal(err) + } + if _, err := Load(path); err == nil { + t.Error("Load of newer schema: expected error") + } +} + +func TestInterpreterBookkeeping(t *testing.T) { + m := New("/root", "/bin") + + if _, ok := m.Interpreter("8.3"); ok { + t.Error("no interpreter expected yet") + } + m.SetInterpreter("8.3", Interpreter{Series: "8.3", PHPVersion: "8.3.10"}) + got, ok := m.Interpreter("8.3") + if !ok || got.PHPVersion != "8.3.10" { + t.Errorf("Interpreter(8.3) = %+v, %v", got, ok) + } + + m.SetActive("8.3") + if m.Active() != "8.3" { + t.Errorf("Active = %q, want 8.3", m.Active()) + } + + // Removing the active interpreter clears the active pointer. + m.RemoveInterpreter("8.3") + if _, ok := m.Interpreter("8.3"); ok { + t.Error("8.3 should be removed") + } + if m.Active() != "" { + t.Errorf("Active should be cleared after removing active interpreter, got %q", m.Active()) + } +} + +func TestRemoveNonActiveKeepsActive(t *testing.T) { + m := New("/root", "/bin") + m.SetInterpreter("8.3", Interpreter{Series: "8.3"}) + m.SetInterpreter("8.2", Interpreter{Series: "8.2"}) + m.SetActive("8.3") + + m.RemoveInterpreter("8.2") + if m.Active() != "8.3" { + t.Errorf("Active should remain 8.3, got %q", m.Active()) + } +} + +func TestBackupBookkeeping(t *testing.T) { + m := New("/root", "/bin") + if _, ok := m.Backup("8.3"); ok { + t.Error("no backup expected yet") + } + m.SetBackup("8.3", Backup{OriginalPath: "/usr/bin/php", BackupPath: "/b", CreatedAt: t1}) + b, ok := m.Backup("8.3") + if !ok || b.OriginalPath != "/usr/bin/php" { + t.Errorf("Backup(8.3) = %+v, %v", b, ok) + } + m.RemoveBackup("8.3") + if _, ok := m.Backup("8.3"); ok { + t.Error("backup should be removed") + } +} + +func TestExtensionBookkeeping(t *testing.T) { + m := New("/root", "/bin") + if m.Extension != nil { + t.Error("no extension expected yet") + } + m.SetExtension(Extension{Series: "8.1", PHPVersion: "8.1.29"}) + if m.Extension == nil || m.Extension.Series != "8.1" { + t.Errorf("Extension = %+v", m.Extension) + } + m.ClearExtension() + if m.Extension != nil { + t.Error("extension should be cleared") + } +} + +func TestInterpreterKeys(t *testing.T) { + m := New("/root", "/bin") + m.SetInterpreter("8.3", Interpreter{Series: "8.3"}) + m.SetInterpreter("8.2-zts", Interpreter{Series: "8.2", ZTS: true}) + keys := m.InterpreterKeys() + if len(keys) != 2 { + t.Fatalf("expected 2 keys, got %v", keys) + } + set := map[string]bool{keys[0]: true, keys[1]: true} + if !set["8.3"] || !set["8.2-zts"] { + t.Errorf("unexpected keys: %v", keys) + } +}