package motd import ( "encoding/json" "errors" "fmt" "os" "path/filepath" "strings" "time" "motd-assist/internal/assets" ) const ( modeEnabled os.FileMode = 0o755 modeDisabled os.FileMode = 0o644 ) // Validate prüft einen Plan gegen das Dateisystem, bevor Root-Operationen // ausgeführt werden. Alle Namen müssen einfach bleiben und im // update-motd.d-Verzeichnis liegen; fremde Dateien sind tabu. func Validate(root string, plan Plan) error { dir := MotdDir(root) if fi, err := os.Stat(dir); err != nil || !fi.IsDir() { return fmt.Errorf("%s existiert nicht", dir) } existing := make(map[string]bool) listing, err := os.ReadDir(dir) if err != nil { return err } for _, e := range listing { existing[e.Name()] = true } for _, op := range plan.Chmod { if !validName.MatchString(op.Name) { return fmt.Errorf("ungültiger Name %q", op.Name) } if !existing[op.Name] { return fmt.Errorf("chmod: %s existiert nicht", op.Name) } } for _, op := range plan.Removes { if !validName.MatchString(op.Name) { return fmt.Errorf("ungültiger Name %q", op.Name) } if !existing[op.Name] { return fmt.Errorf("remove: %s existiert nicht", op.Name) } if !isBundledFile(filepath.Join(dir, op.Name)) { return fmt.Errorf("remove: %s ist kein motd-assist-Script", op.Name) } } renamedAway := make(map[string]bool, len(plan.Renames)) for _, op := range plan.Renames { if !validName.MatchString(op.From) || !validName.MatchString(op.To) { return fmt.Errorf("ungültiger Name in Umbenennung %q → %q", op.From, op.To) } if !existing[op.From] { return fmt.Errorf("rename: %s existiert nicht", op.From) } renamedAway[op.From] = true } // Zielnamen dürfen nur frei werden, wenn die Datei dort wegrename'd oder // entfernt wird – sonst Kollision. for _, op := range plan.Renames { if existing[op.To] && !renamedAway[op.To] { isRemoved := false for _, r := range plan.Removes { if r.Name == op.To { isRemoved = true break } } if !isRemoved { return fmt.Errorf("rename: Ziel %s ist belegt", op.To) } } } for _, op := range plan.Installs { if !validName.MatchString(op.Name) { return fmt.Errorf("ungültiger Name %q", op.Name) } if strings.TrimSpace(op.Content) == "" { return fmt.Errorf("install: Vorlage %q hat keinen Inhalt – Plan ohne ResolveContents erstellt?", op.Source) } if existing[op.Name] && !isBundledFile(filepath.Join(dir, op.Name)) { return fmt.Errorf("install: %s wäre von einem fremden Script überschrieben", op.Name) } } return nil } // ResolveContents füllt die Vorlagen-Inhalte aller Installationen auf. // Muss im unprivilegierten Kontext laufen (User-Vorlagen liegen im // $HOME des aufrufenden Benutzers), bevor der Plan per sudo an Root // übergeben wird – der Root-Prozess kann die User-Konfiguration nicht lesen. func (p *Plan) ResolveContents() error { for i := range p.Installs { op := &p.Installs[i] if op.Content != "" { continue } content, ok := assets.ScriptContent(op.Source) if !ok { dir, err := assets.TemplatesDir() if err != nil || dir == "" { dir = "~/.config/motd-assist/templates" } return fmt.Errorf("Vorlage %q nicht gefunden (eigene Vorlagen ablegen in: %s)", op.Source, dir) } op.Content = content } return nil } // isBundledFile meldet, ob die Datei unter path einer gebündelten Vorlage // entspricht (oder nicht lesbar ist, dann false). func isBundledFile(path string) bool { b, err := os.ReadFile(path) if err != nil { return false } _, ok := assets.MatchScriptBytes(b) return ok } // Snapshot sichert den Zustand vor einem Apply, damit Restore möglich ist. type Snapshot struct { SavedAt time.Time `json:"saved_at"` Scripts []SnapshotScript `json:"scripts"` } // SnapshotScript hält Name, Modus und (falls gebündelt) Vorlage und // Inhalt fest, damit Restore ohne Zugriff auf die ursprüngliche // Vorlage (auch User-Vorlagen unter sudo) auskommt. type SnapshotScript struct { Name string `json:"name"` Mode uint32 `json:"mode"` Bundled bool `json:"bundled"` Source string `json:"source,omitempty"` Content string `json:"content,omitempty"` } // SnapshotPath liefert den Ablageort des letzten Snapshots. func SnapshotPath(root string) string { return filepath.Join(root, "var", "lib", "motd-assist", "last-snapshot.json") } // WriteFile schreibt den Plan als temporäre JSON-Datei, z.B. für die // Übergabe an "motd-assist apply-state" unter sudo. func (p Plan) WriteFile() (string, error) { b, err := json.Marshal(p) if err != nil { return "", err } f, err := os.CreateTemp("", "motd-assist-plan-*.json") if err != nil { return "", err } defer f.Close() if _, err := f.Write(b); err != nil { os.Remove(f.Name()) return "", err } return f.Name(), nil } // ReadPlanFile liest eine mit WriteFile erzeugte Plandatei. func ReadPlanFile(path string) (Plan, error) { var plan Plan b, err := os.ReadFile(path) if err != nil { return plan, err } if err := json.Unmarshal(b, &plan); err != nil { return plan, fmt.Errorf("ungültige Plandatei: %w", err) } return plan, nil } func writeSnapshot(root string) error { scripts, err := Discover(root) if err != nil { return err } snap := Snapshot{SavedAt: time.Now(), Scripts: make([]SnapshotScript, 0, len(scripts))} for _, s := range scripts { ss := SnapshotScript{Name: s.Name, Bundled: s.Bundled} if fi, err := os.Stat(s.Path); err == nil { ss.Mode = uint32(fi.Mode().Perm()) } if s.Bundled { if b, err := os.ReadFile(s.Path); err == nil { ss.Content = string(b) ss.Source, _ = assets.MatchScriptBytes(b) } } snap.Scripts = append(snap.Scripts, ss) } path := SnapshotPath(root) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } b, err := json.MarshalIndent(snap, "", " ") if err != nil { return err } return os.WriteFile(path, append(b, '\n'), 0o644) } // Execute führt einen validierten Plan aus: Snapshot → Removes → // Umbenennungen (zweiphasig gegen Kollisionen) → Installationen → Modi. // Erfordert Root, wenn /etc/update-motd.d Root gehört. func Execute(root string, plan Plan) error { if err := Validate(root, plan); err != nil { return err } if err := writeSnapshot(root); err != nil { return fmt.Errorf("Snapshot fehlgeschlagen: %w", err) } dir := MotdDir(root) // Reihenfolge: Removes → Chmods (alte Namen!) → Renames → Installs. for _, op := range plan.Removes { if err := os.Remove(filepath.Join(dir, op.Name)); err != nil { return err } } for _, op := range plan.Chmod { mode := modeDisabled if op.Enable { mode = modeEnabled } if err := os.Chmod(filepath.Join(dir, op.Name), mode); err != nil { return err } } // Zweiphasige Umbenennung: erst alle nach .tmp-N (Discover ignoriert // Punktnamen), dann auf die finalen Namen. if len(plan.Renames) > 0 { for i := range plan.Renames { tmp := fmt.Sprintf(".ma-tmp-%d", i) if err := os.Rename(filepath.Join(dir, plan.Renames[i].From), filepath.Join(dir, tmp)); err != nil { return err } plan.Renames[i].From = tmp } for _, op := range plan.Renames { if err := os.Rename(filepath.Join(dir, op.From), filepath.Join(dir, op.To)); err != nil { return err } } } for _, op := range plan.Installs { mode := modeDisabled if op.Enabled { mode = modeEnabled } if err := os.WriteFile(filepath.Join(dir, op.Name), []byte(op.Content), mode); err != nil { return err } } return nil } // Restore setzt den letzten Snapshot bestmöglich zurück (Modi, Namen, // gebündelte Installationen). Best-effort: Fehler werden gesammelt. func Restore(root string) error { b, err := os.ReadFile(SnapshotPath(root)) if err != nil { if errors.Is(err, os.ErrNotExist) { return errors.New("kein Snapshot vorhanden") } return err } var snap Snapshot if err := json.Unmarshal(b, &snap); err != nil { return err } current, err := Discover(root) if err != nil { return err } curBySuffix := make(map[string]Script, len(current)) for _, c := range current { curBySuffix[suffixOf(c.Name)] = c } snapBySuffix := make(map[string]SnapshotScript, len(snap.Scripts)) for _, s := range snap.Scripts { snapBySuffix[suffixOf(s.Name)] = s } var errs []error dir := MotdDir(root) // Dateien entfernen, die es vor dem Apply nicht gab (nur unsere). for _, c := range current { if _, ok := snapBySuffix[suffixOf(c.Name)]; !ok && c.Bundled { if err := os.Remove(c.Path); err != nil { errs = append(errs, err) } } } // Fehlende gebündelte Scripte aus dem Snapshot-Inhalt nachinstallieren; // als Fallback (alte Snapshots ohne Content) über den Vorlagennamen. var renames []RenameOp for _, s := range snap.Scripts { if _, ok := curBySuffix[suffixOf(s.Name)]; ok { continue } if s.Bundled { content := s.Content if content == "" && s.Source != "" { content, _ = assets.ScriptContent(s.Source) } if content == "" { errs = append(errs, fmt.Errorf("Inhalt für %s fehlt im Snapshot", s.Name)) continue } if err := os.WriteFile(filepath.Join(dir, s.Name), []byte(content), os.FileMode(s.Mode)); err != nil { errs = append(errs, err) } } } // Namen und Modi wiederherstellen. for _, c := range current { s, ok := snapBySuffix[suffixOf(c.Name)] if !ok { continue } if c.Name != s.Name { renames = append(renames, RenameOp{From: c.Name, To: s.Name}) } if err := os.Chmod(c.Path, os.FileMode(s.Mode)); err != nil { errs = append(errs, err) } } if len(renames) > 0 { for i := range renames { tmp := fmt.Sprintf(".ma-tmp-%d", i) if err := os.Rename(filepath.Join(dir, renames[i].From), filepath.Join(dir, tmp)); err != nil { errs = append(errs, err) continue } renames[i].From = tmp } for _, op := range renames { if op.From == op.To { continue } if err := os.Rename(filepath.Join(dir, op.From), filepath.Join(dir, op.To)); err != nil { errs = append(errs, err) } } } if len(errs) > 0 { return fmt.Errorf("Restore mit Fehlern: %w", errors.Join(errs...)) } return nil }