268 lines
7.9 KiB
Go
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
|
|
}
|