Files
server-deploy/internal/debian/inventory.go
T

268 lines
7.9 KiB
Go

// Package debian observes the dpkg database without executing package tools.
package debian
import (
"bufio"
"bytes"
"crypto/sha256"
"encoding/hex"
"io"
"io/fs"
"regexp"
"sort"
"strings"
"unicode/utf8"
)
type Snapshot struct {
State string `json:"state"`
Digest string `json:"digest"`
Packages []Installed `json:"packages"`
}
// Installed is a present database record, including residual or uninstalled selections.
type Installed struct {
Name string `json:"name"`
Version string `json:"version"`
Architecture string `json:"architecture"`
Status string `json:"status"`
}
const (
statusPath = "var/lib/dpkg/status"
updatesPath = "var/lib/dpkg/updates"
maxStatusBytes = 16 << 20
)
// Inventory reads only the fixed status and updates paths. An observed snapshot
// includes every relevant record, regardless of installation state; it is not
// an installation permission. Unknown never exposes a partial result or error.
// Paths and the FS implementation are trusted. Metadata and journal rechecks
// detect ordinary changes, but are not a lock or protection against a hostile
// administrator replacing paths between checks.
func Inventory(files fs.FS) Snapshot {
unknown := Snapshot{State: "unknown", Packages: []Installed{}}
// fs.Stat's fallback opens the path. Require a metadata operation so that
// checking an already-present FIFO cannot block before we reject its type.
metadata, ok := files.(fs.StatFS)
if !ok || !emptyJournal(files, metadata) {
return unknown
}
initial, err := metadata.Stat(statusPath)
if err != nil || !validStatusFile(initial) {
return unknown
}
f, err := files.Open(statusPath)
if err != nil {
return unknown
}
opened, err := f.Stat()
if err != nil || !sameMetadata(initial, opened) {
f.Close()
return unknown
}
raw, readErr := io.ReadAll(io.LimitReader(f, maxStatusBytes+1))
after, statErr := f.Stat()
closeErr := f.Close()
if readErr != nil || statErr != nil || closeErr != nil || len(raw) == 0 || len(raw) > maxStatusBytes || int64(len(raw)) != initial.Size() || !sameMetadata(initial, after) {
return unknown
}
current, err := metadata.Stat(statusPath)
if err != nil || !sameMetadata(initial, current) {
return unknown
}
packages, ok := parseStatus(raw)
if !ok || !emptyJournal(files, metadata) {
return unknown
}
sum := sha256.Sum256(raw)
return Snapshot{State: "observed", Digest: "sha256:" + hex.EncodeToString(sum[:]), Packages: packages}
}
func validStatusFile(info fs.FileInfo) bool {
return info != nil && info.Mode().IsRegular() && info.Size() > 0 && info.Size() <= maxStatusBytes
}
func sameMetadata(a, b fs.FileInfo) bool {
return a != nil && b != nil && a.Mode() == b.Mode() && a.Size() == b.Size() && a.ModTime().Equal(b.ModTime())
}
func emptyJournal(files fs.FS, metadata fs.StatFS) bool {
initial, err := metadata.Stat(updatesPath)
if err != nil || initial == nil || !initial.IsDir() {
return false
}
f, err := files.Open(updatesPath)
if err != nil {
return false
}
opened, statErr := f.Stat()
dir, ok := f.(fs.ReadDirFile)
if statErr != nil || !sameMetadata(initial, opened) || !ok {
f.Close()
return false
}
// Read at most one entry: even a hidden file or a directory is pending work.
entries, readErr := dir.ReadDir(1)
closeErr := f.Close()
return len(entries) == 0 && readErr == io.EOF && closeErr == nil
}
var packageName = regexp.MustCompile(`^[a-z0-9][a-z0-9+.-]+$`)
var architectureName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
var packageVersion = regexp.MustCompile(`^(?:[0-9]+:)?[0-9][A-Za-z0-9.+:~\-]*$`)
func relevant(name string) bool {
switch name {
case "docker-ce", "docker-ce-cli", "containerd.io", "docker-buildx-plugin", "docker-compose-plugin", "docker.io", "docker-compose", "docker-compose-v2", "docker-doc", "docker-buildx", "podman-docker", "containerd", "runc":
return true
}
return false
}
func scalarField(key string) bool {
return key == "package" || key == "status" || key == "architecture" || key == "version"
}
// Validate structure and identifying fields in ALL stanzas, before filtering.
// Other field values (including descriptions) are never part of the snapshot.
func parseStatus(raw []byte) ([]Installed, bool) {
if !utf8.Valid(raw) {
return nil, false
}
packages := []Installed{}
seen := make(map[string]map[string]bool)
fields := make(map[string]string)
last := ""
count := 0
finish := func() bool {
if len(fields) == 0 {
return true
}
p := Installed{Name: fields["package"], Version: fields["version"], Architecture: fields["architecture"]}
status, ok := normalizedStatus(fields["status"])
if !ok || !packageName.MatchString(p.Name) {
return false
}
p.Status = status
// dpkg may retain a bare selection for a never-installed package.
notInstalled := strings.HasSuffix(status, " not-installed")
if p.Architecture == "" {
if !notInstalled || hasField(fields, "architecture") {
return false
}
} else if !architectureName.MatchString(p.Architecture) || p.Architecture == "any" || p.Architecture == "source" || strings.HasPrefix(p.Architecture, "any-") || strings.HasSuffix(p.Architecture, "-any") {
return false
}
if p.Version == "" {
if !notInstalled || hasField(fields, "version") {
return false
}
} else if !packageVersion.MatchString(p.Version) || strings.HasSuffix(p.Version, "-") || strings.HasSuffix(p.Version, ":") {
return false
}
arches := seen[p.Name]
if len(arches) != 0 && (arches[p.Architecture] || arches[""] || arches["all"] || p.Architecture == "" || p.Architecture == "all") {
return false
}
if arches == nil {
arches = make(map[string]bool)
seen[p.Name] = arches
}
arches[p.Architecture] = true
if relevant(p.Name) {
packages = append(packages, p)
}
count++
fields = make(map[string]string)
last = ""
return true
}
scanner := bufio.NewScanner(bytes.NewReader(raw))
// The file cap is also the token cap; long legitimate description lines
// must not be silently lost to Scanner's default 64 KiB limit.
scanner.Buffer(make([]byte, 4096), maxStatusBytes+1)
for scanner.Scan() {
line := scanner.Text()
for _, c := range line {
if (c < 32 && c != '\t') || c == 127 {
return nil, false
}
}
if strings.Trim(line, " \t") == "" {
if !finish() {
return nil, false
}
continue
}
if line[0] == ' ' || line[0] == '\t' {
if last == "" || scalarField(last) {
return nil, false
}
continue
}
key, value, ok := strings.Cut(line, ":")
if !ok || !validFieldName(key) {
return nil, false
}
key = strings.ToLower(key)
if hasField(fields, key) {
return nil, false
}
// Retain only the values we project, but track every key for duplicates.
fields[key] = ""
if scalarField(key) {
fields[key] = strings.Trim(value, " \t")
}
last = key
}
if scanner.Err() != nil || !finish() || count == 0 {
return nil, false
}
sort.Slice(packages, func(i, j int) bool {
if packages[i].Name != packages[j].Name {
return packages[i].Name < packages[j].Name
}
return packages[i].Architecture < packages[j].Architecture
})
return packages, true
}
func hasField(fields map[string]string, key string) bool {
_, ok := fields[key]
return ok
}
func validFieldName(key string) bool {
if key == "" || key[0] == '#' || key[0] == '-' {
return false
}
for i := range len(key) {
if key[i] < 33 || key[i] > 126 || key[i] == ':' {
return false
}
}
return true
}
func normalizedStatus(value string) (string, bool) {
parts := strings.FieldsFunc(value, func(r rune) bool { return r == ' ' || r == '\t' })
if len(parts) != 3 {
return "", false
}
switch parts[0] {
case "unknown", "install", "hold", "deinstall", "purge":
default:
return "", false
}
if parts[1] != "ok" && parts[1] != "reinstreq" {
return "", false
}
switch parts[2] {
case "not-installed", "config-files", "half-installed", "unpacked", "half-configured", "triggers-awaited", "triggers-pending", "installed":
default:
return "", false
}
return strings.Join(parts, " "), true
}