// Package plan is the Go side of the contract with the Python backend. // // Go deliberately does not read the pacman databases itself. The Python side // already does that through libalpm and is verified against `pacman -Q*` on six // dimensions; a second implementation here would eventually disagree with it, // and two tools reporting different orphan lists is worse than one slow one. // // So this package's whole job is to read JSON. package plan import ( "bytes" "context" "encoding/json" "fmt" "os" "os/exec" "strings" ) // Upgrade is a repository package moving between versions. type Upgrade struct { Name string `json:"name"` Old string `json:"old"` New string `json:"new"` Repo string `json:"repo"` Download int64 `json:"download"` Delta int64 `json:"delta"` Ignored bool `json:"ignored"` } // AurUpdate is an AUR package, carrying the tag the audit gave it. // // U upgrade D the AUR version is older than what is installed // ! needs a look !! blocked, will not be applied automatically type AurUpdate struct { Name string `json:"name"` Old string `json:"old"` New string `json:"new"` Tag string `json:"tag"` Note string `json:"note"` Details []string `json:"details"` Ignored bool `json:"ignored"` //: Whether the package could be examined at all. A pointer so that a backend //: which predates the field is treated as having audited, rather than //: painting every package as unchecked. Audited *bool `json:"audited"` } // WasAudited reports whether the package was examined. An absent field means an // older backend, which always audited. func (a AurUpdate) WasAudited() bool { return a.Audited == nil || *a.Audited } // Vcs is a version-controlled package. Reported, never rebuilt automatically. // Vcs is a devel package paru would actually rebuild. The AUR's recorded // pkgver for a -git package lags whatever paru generated when it last built, so // the version transition here comes from paru's own devel database rather than // from a comparison this side could make. type Vcs struct { Name string `json:"name"` Old string `json:"old"` New string `json:"new"` } // Step is one command the plan will run, with its full breakdown. // // Authored by the backend rather than derived here: the front end cannot know // what makepkg will do inside a PKGBUILD, and two renderers each working the // commands out from the same facts is how they drift apart. type Step struct { Index string `json:"index"` Command string `json:"command"` Detail string `json:"detail"` Details []string `json:"details"` //: the ordered commands the step will actually run Queue []Stage `json:"queue"` } // Stage is one command in a step's queue. // // Assembled by the backend from facts rather than guessed: the PKGBUILD is // parsed, the alpm hooks are read from disk with their declared triggers, and // the snapshots are just another hook. `pacman -Syu` is a summary; this is the // sequence underneath it. type Stage struct { Command string `json:"command"` What string `json:"what"` Target string `json:"target"` Level string `json:"level"` // step | note | hook | warn | danger Phase string `json:"phase"` Finding string `json:"finding"` // the audit rule that flagged it } // IsWarning reports whether a stage deserves to stand out. func (s Stage) IsWarning() bool { return s.Level == "warn" || s.Level == "danger" } // Warning is one kind of warning, with its own page. type Warning struct { Title string `json:"title"` Headline string `json:"headline"` Details []string `json:"details"` Flagged int `json:"flagged"` //: the packages this warning concerns, so it can be scoped to a selection Packages []string `json:"packages"` } // Check is one line of the pre-flight report. type Check struct { OK bool `json:"ok"` Label string `json:"label"` Detail string `json:"detail"` //: a fuller explanation, shown when the row is opened. A check with none is //: a single fact and has nothing to open. Details []string `json:"details"` //: the command that resolves this check, if there is one. Asked about //: before it runs, and shown verbatim when it is. Action []string `json:"action"` //: said out loud before an action runs Warning string `json:"warning"` } // Flatpak is a pending flatpak update. type Flatpak struct { Application string `json:"application"` Version string `json:"version"` Origin string `json:"origin"` } // StaleProcess is a process still running a binary that was replaced on disk. type StaleProcess struct { PID int `json:"pid"` Name string `json:"name"` Path string `json:"path"` } // Hygiene is the list of things that rot quietly. type Hygiene struct { CacheReclaimable int64 `json:"cache_reclaimable"` Pacnew []string `json:"pacnew"` Orphans []string `json:"orphans"` Reboot string `json:"reboot"` Stale []StaleProcess `json:"stale_processes"` } // Vulnerabilities comes from arch-audit. type Vulnerabilities struct { Fixable []string `json:"fixable"` Unfixable []string `json:"unfixable"` } // Plan is the whole document. type Plan struct { Advisories []Advisory `json:"advisories"` //: how many advisories have been acknowledged and are therefore absent from //: the list above AckedCount int `json:"acked_count"` Steps []Step `json:"steps"` Warnings []Warning `json:"warnings"` Ignored []string `json:"ignored"` ConfigPath string `json:"config_path"` DevelNote string `json:"devel_note"` Repo []Upgrade `json:"repo"` Aur []AurUpdate `json:"aur"` Vcs []Vcs `json:"vcs"` Checks []Check `json:"checks"` Flatpak []Flatpak `json:"flatpak"` Vulnerabilities Vulnerabilities `json:"vulnerabilities"` Hygiene Hygiene `json:"hygiene"` Audits map[string][]Finding `json:"audits"` } // Counts summarises the plan for the header. func (p *Plan) Counts() (repo, aur, blocked int) { for _, a := range p.Aur { if a.Tag == "!!" { blocked++ } } return len(p.Repo), len(p.Aur), blocked } // Backend knows how to reach the Python side. type Backend struct { Python string // interpreter, default "python3" Module string // module to run, default "updater" Env []string // extra environment, e.g. PYTHONPATH for an uninstalled tree Args []string // extra arguments passed through to every invocation } // DefaultBackend resolves sensible defaults from the environment. func DefaultBackend() Backend { b := Backend{Python: "python3", Module: "updater"} if v := os.Getenv("UPDATER_PYTHON"); v != "" { b.Python = v } if v := os.Getenv("UPDATER_HOME"); v != "" { b.Env = append(b.Env, "PYTHONPATH="+v) } return b } // Error carries the backend's stderr so a failure is explained, not just reported. type Error struct { Args []string Stderr string Err error } func (e *Error) Error() string { msg := strings.TrimSpace(e.Stderr) if msg == "" { msg = e.Err.Error() } return fmt.Sprintf("%s %s: %s", e.Args[0], strings.Join(e.Args[1:], " "), msg) } // Run executes the backend and returns stdout. // // stdout must be pure JSON: the backend is required to send anything human to // stderr. That rule is not cosmetic -- a stray line on stdout is a parse failure, // and flatpak already taught this codebase what "exits 0 but printed an error" // does to a check row. func (b Backend) Run(ctx context.Context, args ...string) ([]byte, error) { full := append([]string{"-m", b.Module}, args...) cmd := exec.CommandContext(ctx, b.Python, full...) cmd.Env = append(os.Environ(), b.Env...) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Run(); err != nil { return nil, &Error{Args: append([]string{b.Python}, full...), Stderr: stderr.String(), Err: err} } return stdout.Bytes(), nil } // checksDoc is the shape `updater checks --json` returns. type checksDoc struct { Checks []Check `json:"checks"` //: labels this refresh recomputed. Rows outside it are ones it cannot see, //: and must survive: the backend recomputes machine-local state, not //: whether a reboot is pending or the archive is reachable. Covers []string `json:"covers"` } // LoadChecks re-runs only the machine-local checks. // // What an action can change is this machine's state, not the archive, so a // refresh after running one asks for these alone. Re-planning would re-poll the // AUR and scan the security tracker to learn nothing new. func (b Backend) LoadChecks(ctx context.Context) ([]Check, []string, error) { out, err := b.Run(ctx, "checks", "--json") if err != nil { return nil, nil, err } var doc checksDoc if err := json.Unmarshal(out, &doc); err != nil { return nil, nil, fmt.Errorf("could not parse the checks: %w", err) } return doc.Checks, doc.Covers, nil } // Load fetches and parses the plan. func (b Backend) Load(ctx context.Context) (*Plan, error) { args := append([]string{"plan", "--json"}, b.Args...) out, err := b.Run(ctx, args...) if err != nil { return nil, err } var p Plan dec := json.NewDecoder(bytes.NewReader(out)) // tolerate fields the Go side does not know about yet, so the two halves can // be updated independently if err := dec.Decode(&p); err != nil { return nil, fmt.Errorf("could not parse backend JSON: %w", err) } return &p, nil }