package main // The package manager's own record, /var/log/pacman.log (novox/hq research 026/05: "installs and // upgrades from the log"): every install, upgrade, downgrade, reinstall and removal since a date, // and when the machine was last fully upgraded. import ( "fmt" "regexp" "strings" "time" ) // PacmanLog is where pacman writes what it did. const PacmanLog = "/var/log/pacman.log" // Event is one package changed by a transaction. type Event struct { Time string `json:"time"` Action string `json:"action"` Package string `json:"package"` Version string `json:"version"` From string `json:"from,omitempty"` } var ( logLine = regexp.MustCompile(`^\[([^\]]+)\] \[ALPM\] (installed|upgraded|downgraded|reinstalled|removed) (\S+) \((.*)\)$`) fullUpdate = regexp.MustCompile(`^\[([^\]]+)\] \[PACMAN\] starting full system upgrade`) ) // Actions are what a history may be narrowed to. var Actions = []string{"installed", "upgraded", "downgraded", "reinstalled", "removed"} func logTime(s string) (time.Time, bool) { for _, layout := range []string{"2006-01-02T15:04:05-0700", "2006-01-02 15:04"} { if t, err := time.Parse(layout, s); err == nil { return t, true } } return time.Time{}, false } // ParseLog reads pacman.log's package events since a time, and the last full upgrade it records. func ParseLog(text string, since time.Time) (events []Event, lastUpgrade time.Time) { for _, l := range strings.Split(text, "\n") { if u := fullUpdate.FindStringSubmatch(l); u != nil { if t, ok := logTime(u[1]); ok { lastUpgrade = t } continue } e := logLine.FindStringSubmatch(l) if e == nil { continue } t, ok := logTime(e[1]) if !ok || t.Before(since) { continue } ev := Event{Time: t.Format(time.RFC3339), Action: e[2], Package: e[3], Version: e[4]} if from, to, ok := strings.Cut(e[4], " -> "); ok { ev.From, ev.Version = from, to } events = append(events, ev) } return events, lastUpgrade } // LastUpgrade is when the machine last started a full upgrade, from pacman's log. func (m *Machine) LastUpgrade() (time.Time, error) { text, err := m.ReadFile(PacmanLog) if err != nil { return time.Time{}, err } _, last := ParseLog(string(text), m.Now()) return last, nil } // History is the package events since a day (YYYY-MM-DD; thirty days ago by default), narrowed to // an action and a name, the newest last and at most limit of them. func (m *Machine) History(since, action, match string, limit int) (map[string]any, error) { from := m.Now().AddDate(0, 0, -30) if since != "" { t, err := time.ParseInLocation("2006-01-02", since, time.Local) if err != nil { return nil, fmt.Errorf("since %q is not a day as YYYY-MM-DD", since) } from = t } if action != "" && !contains(Actions, action) { return nil, fmt.Errorf("action %q is one of %s", action, strings.Join(Actions, ", ")) } text, err := m.ReadFile(PacmanLog) if err != nil { return nil, fmt.Errorf("reading %s: %w", PacmanLog, err) } all, last := ParseLog(string(text), from) events := []Event{} counts := map[string]int{} for _, e := range all { if action != "" && e.Action != action || match != "" && !strings.Contains(e.Package, match) { continue } events = append(events, e) counts[e.Action]++ } truncated := false if len(events) > limit { events, truncated = events[len(events)-limit:], true } out := map[string]any{"since": from.Format(time.RFC3339), "count": len(events), "by_action": counts, "events": events, "truncated": truncated} if !last.IsZero() { out["last_full_upgrade"] = last.Format(time.RFC3339) } return out, nil } func contains(list []string, want string) bool { for _, s := range list { if s == want { return true } } return false }