// 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 }