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

387 lines
16 KiB
Go

package debian
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"io/fs"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"testing/fstest"
"time"
)
const fixtureStatus = "var/lib/dpkg/status"
const fixtureUpdates = "var/lib/dpkg/updates"
func stanza(name, arch, status string) string {
return "Package: " + name + "\nStatus: " + status + "\nArchitecture: " + arch + "\nVersion: 5:28.0.1-1~ubuntu.24.04~noble\nDescription: container runtime\n continuation with Package: ignored\n .\n\tUTF-8 description: 容器\n"
}
func statusFS(raw string) fstest.MapFS {
return fstest.MapFS{
fixtureStatus: &fstest.MapFile{Data: []byte(raw), Mode: 0644},
fixtureUpdates: &fstest.MapFile{Mode: fs.ModeDir | 0755},
}
}
func requireUnknown(t *testing.T, files fs.FS) {
t.Helper()
got := Inventory(files)
if got.State != "unknown" || got.Digest != "" || got.Packages == nil || len(got.Packages) != 0 {
t.Fatal("invalid input must produce unknown, empty digest, and non-nil empty packages")
}
raw, err := json.Marshal(got)
if err != nil || string(raw) != `{"state":"unknown","digest":"","packages":[]}` {
t.Fatal("unknown result must expose only the empty public JSON shape")
}
}
func TestInventoryObservedAndExactDigest(t *testing.T) {
base := stanza("base-files", "amd64", "install ok installed")
for _, raw := range []string{base, "\n" + base + "\n\n", strings.ReplaceAll(base, "\n", "\r\n"), strings.TrimSuffix(base, "\n")} {
got := Inventory(statusFS(raw))
sum := sha256.Sum256([]byte(raw))
if got.State != "observed" || got.Packages == nil || len(got.Packages) != 0 || got.Digest != "sha256:"+hex.EncodeToString(sum[:]) {
t.Fatal("valid unrelated package must yield observed empty inventory and exact-byte digest")
}
}
}
func TestInventoryAllRelevantNames(t *testing.T) {
names := []string{"containerd", "containerd.io", "docker-buildx", "docker-buildx-plugin", "docker-ce", "docker-ce-cli", "docker-compose", "docker-compose-plugin", "docker-compose-v2", "docker-doc", "docker.io", "podman-docker", "runc"}
var raw strings.Builder
for i := len(names) - 1; i >= 0; i-- {
raw.WriteString(stanza(names[i], "amd64", "install ok installed") + "\n")
}
raw.WriteString(stanza("docker-ce-extra", "amd64", "install ok installed"))
got := Inventory(statusFS(raw.String()))
if got.State != "observed" || len(got.Packages) != len(names) {
t.Fatal("inventory must include exactly the fixed relevant package set")
}
for i, name := range names {
want := Installed{Name: name, Version: "5:28.0.1-1~ubuntu.24.04~noble", Architecture: "amd64", Status: "install ok installed"}
if got.Packages[i] != want {
t.Fatal("relevant packages must preserve projected fields and sort by name")
}
}
encoded, err := json.Marshal(got.Packages[0])
if err != nil || string(encoded) != `{"name":"containerd","version":"5:28.0.1-1~ubuntu.24.04~noble","architecture":"amd64","status":"install ok installed"}` {
t.Fatal("installed JSON must contain only the four specified fields")
}
}
func TestInventoryKeepsEveryStatusAndMultiarch(t *testing.T) {
statuses := []string{"install ok installed", "hold ok installed", "deinstall ok config-files", "install reinstreq half-installed", "install ok unpacked", "install ok half-configured", "install ok triggers-awaited", "install ok triggers-pending", "purge ok not-installed", "unknown ok not-installed"}
for _, status := range statuses {
t.Run(status, func(t *testing.T) {
raw := stanza("runc", "arm64", status) + "\n" + stanza("runc", "amd64", status)
got := Inventory(statusFS(raw))
if got.State != "observed" || len(got.Packages) != 2 || got.Packages[0].Architecture != "amd64" || got.Packages[1].Architecture != "arm64" || got.Packages[0].Status != status || got.Packages[1].Status != status {
t.Fatal("every present record must survive regardless of installation state; sort multiarch by architecture")
}
})
}
got := Inventory(statusFS("Package: docker-ce\nStatus: purge ok not-installed\n"))
if got.State != "observed" || !reflect.DeepEqual(got.Packages, []Installed{{Name: "docker-ce", Status: "purge ok not-installed"}}) {
t.Fatal("not-installed selection records may lack architecture and version but must still block")
}
}
func TestInventoryCaseInsensitiveFieldsAndWhitespace(t *testing.T) {
raw := "pAcKaGe:\tdocker.io \nSTATUS: hold\t ok installed\narchitecture: all\nversion: 1.2+dfsg-3\nDESCRIPTION: first\n second\n .\n third\n\t \n" + stanza("base-files", "amd64", "install ok installed")
got := Inventory(statusFS(raw))
if got.State != "observed" || !reflect.DeepEqual(got.Packages, []Installed{{Name: "docker.io", Version: "1.2+dfsg-3", Architecture: "all", Status: "hold ok installed"}}) {
t.Fatal("field aliases must normalize safely, including status whitespace and whitespace-only stanza separators")
}
}
func TestInventoryRejectsMalformedGlobally(t *testing.T) {
valid := stanza("base-files", "amd64", "install ok installed")
cases := map[string]string{
"empty": "", "blank": "\n \t\n", "orphan continuation": " unexpected\n" + valid,
"missing colon": valid + "broken line\n", "empty field name": valid + ": value\n",
"field whitespace": valid + "Bad Field: x\n", "field leading hyphen": valid + "-Bad: x\n",
"comment": valid + "# comment\n", "duplicate unrelated key": valid + "description: duplicate\n",
"duplicate package alias": valid + "PACKAGE: docker-ce\n", "duplicate status alias": valid + "status: purge ok not-installed\n",
"duplicate version alias": valid + "VERSION: 1\n", "duplicate architecture alias": valid + "ARCHITECTURE: arm64\n",
"duplicate irrelevant stanza": valid + "\n" + valid,
"duplicate relevant stanza": stanza("runc", "amd64", "install ok installed") + "\n" + stanza("runc", "amd64", "hold ok installed"),
"missing package": "Status: install ok installed\nArchitecture: amd64\nVersion: 1\n",
"missing status": "Package: base-files\nArchitecture: amd64\nVersion: 1\n",
"missing installed version": "Package: base-files\nStatus: install ok installed\nArchitecture: amd64\n",
"missing installed architecture": "Package: base-files\nStatus: install ok installed\nVersion: 1\n",
"unknown selection": stanza("base-files", "amd64", "selected ok installed"),
"unknown flag": stanza("base-files", "amd64", "install bad installed"),
"unknown state": stanza("base-files", "amd64", "install ok ready"),
"status suffix": stanza("base-files", "amd64", "install ok installed extra"),
"status short": stanza("base-files", "amd64", "ok installed"),
"status case": stanza("base-files", "amd64", "Install ok installed"),
"unicode status space": stanza("base-files", "amd64", "install\u00a0ok installed"),
"architecture list": stanza("base-files", "amd64 arm64", "install ok installed"),
"architecture wildcard": stanza("base-files", "any", "install ok installed"),
"architecture source": stanza("base-files", "source", "install ok installed"),
"architecture punctuation": stanza("base-files", "amd64!", "install ok installed"),
"architecture ambiguous": "Package: runc\nStatus: purge ok not-installed\n\n" + stanza("runc", "amd64", "install ok installed"),
"architecture all mixed": stanza("runc", "all", "install ok installed") + "\n" + stanza("runc", "amd64", "install ok installed"),
"scalar continuation": "Package: base-files\n unexpected\nStatus: install ok installed\nArchitecture: amd64\nVersion: 1\n",
"version continuation": "Package: base-files\nStatus: install ok installed\nArchitecture: amd64\nVersion: 1\n 2\n",
"nul": valid + "X-Note: hidden\x00value\n", "bare CR": valid + "X-Note: hidden\rvalue\n",
"invalid utf8": valid + "X-Note: \xff\n",
"unsafe version": strings.Replace(valid, "5:28.0.1-1~ubuntu.24.04~noble", "1;secret", 1),
"empty version": strings.Replace(valid, "5:28.0.1-1~ubuntu.24.04~noble", "", 1),
}
for _, name := range []string{"a", "Docker-ce", "docker_ce", "docker-ce:amd64", "-docker", "docker ce", "dockér"} {
cases["invalid package "+name] = stanza(name, "amd64", "install ok installed")
}
for name, raw := range cases {
t.Run(name, func(t *testing.T) {
requireUnknown(t, statusFS(raw))
// A valid relevant record before corrupt data must never leak a partial result.
if strings.TrimSpace(raw) != "" {
requireUnknown(t, statusFS(stanza("docker-ce", "amd64", "install ok installed")+"\n"+raw))
}
})
}
}
func TestInventoryFileAndJournalFailures(t *testing.T) {
for _, name := range []string{"missing status", "missing updates", "updates regular", "updates entry", "updates subdir", "updates hidden entry", "status directory", "status fifo", "status device", "status oversized"} {
t.Run(name, func(t *testing.T) {
files := statusFS(stanza("base-files", "amd64", "install ok installed"))
switch name {
case "missing status":
delete(files, fixtureStatus)
case "missing updates":
delete(files, fixtureUpdates)
case "updates regular":
files[fixtureUpdates].Mode = 0644
case "updates entry":
files[fixtureUpdates+"/0000"] = &fstest.MapFile{}
case "updates subdir":
files[fixtureUpdates+"/pending"] = &fstest.MapFile{Mode: fs.ModeDir | 0700}
case "updates hidden entry":
files[fixtureUpdates+"/.pending"] = &fstest.MapFile{}
case "status directory":
files[fixtureStatus].Mode = fs.ModeDir | 0755
case "status fifo":
files[fixtureStatus].Mode = fs.ModeNamedPipe | 0600
case "status device":
files[fixtureStatus].Mode = fs.ModeDevice | 0600
case "status oversized":
files[fixtureStatus].Data = []byte(strings.Repeat("x", (16<<20)+1))
}
requireUnknown(t, files)
})
}
requireUnknown(t, nil)
}
func TestInventoryBoundedLargeDescription(t *testing.T) {
// Exceed Scanner's default 64 KiB token size without exceeding the file cap.
raw := stanza("docker.io", "amd64", "install ok installed") + " " + strings.Repeat("x", 128<<10) + "\n"
if got := Inventory(statusFS(raw)); got.State != "observed" || len(got.Packages) != 1 {
t.Fatal("bounded long description continuation must not hide a relevant record")
}
// A valid file exactly at the cap is accepted; the next byte is rejected.
base := stanza("base-files", "amd64", "install ok installed")
raw = base + " " + strings.Repeat("x", (16<<20)-len(base)-2) + "\n"
if got := Inventory(statusFS(raw)); got.State != "observed" {
t.Fatal("exactly 16 MiB must be accepted")
}
requireUnknown(t, statusFS(raw+"\n"))
}
func TestInventoryRealDirectory(t *testing.T) {
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, filepath.FromSlash(fixtureUpdates)), 0755); err != nil {
t.Fatal("create fixture directory")
}
raw := stanza("docker-compose-v2", "arm64", "deinstall ok config-files")
path := filepath.Join(root, filepath.FromSlash(fixtureStatus))
if err := os.WriteFile(path, []byte(raw), 0644); err != nil {
t.Fatal("write fixture status")
}
got := Inventory(os.DirFS(root))
if got.State != "observed" || len(got.Packages) != 1 || got.Packages[0].Status != "deinstall ok config-files" {
t.Fatal("real filesystem residual record missing")
}
after, err := os.ReadFile(path)
if err != nil || string(after) != raw {
t.Fatal("inventory must not modify the database")
}
}
// Faults are confined to the FS boundary: permissions and read-time changes
// cannot be exercised portably with chmod (notably on Windows or as root).
type faultFS struct {
fstest.MapFS
fault string
journalOpens int
statusOpens int
statusStats int
}
func (f *faultFS) Stat(path string) (fs.FileInfo, error) {
if (f.fault == "status stat denied" && path == fixtureStatus) || (f.fault == "journal stat denied" && path == fixtureUpdates) {
return nil, errors.New("private stat diagnostic")
}
info, err := f.MapFS.Stat(path)
if path == fixtureStatus {
f.statusStats++
if f.fault == "path changed" && f.statusStats > 1 && err == nil {
return changedInfo{FileInfo: info, change: "time"}, nil
}
}
return info, err
}
func (f *faultFS) Open(path string) (fs.File, error) {
if path != fixtureStatus && path != fixtureUpdates {
return nil, errors.New("unexpected path")
}
if path == fixtureStatus {
f.statusOpens++
if f.fault == "status open denied" {
return nil, errors.New("private open diagnostic")
}
} else {
f.journalOpens++
if f.fault == "journal open denied" {
return nil, errors.New("private open diagnostic")
}
if f.fault == "journal becomes pending" && f.journalOpens == 2 {
f.MapFS[fixtureUpdates+"/0001"] = &fstest.MapFile{}
}
}
file, err := f.MapFS.Open(path)
if err != nil {
return nil, err
}
return &faultFile{File: file, owner: f, path: path}, nil
}
type faultFile struct {
fs.File
owner *faultFS
path string
stats int
bytesRead int
}
func (f *faultFile) Stat() (fs.FileInfo, error) {
f.stats++
info, err := f.File.Stat()
if f.path == fixtureStatus && err == nil {
switch f.owner.fault {
case "opened stat denied":
return nil, fs.ErrPermission
case "opened fifo":
return changedInfo{FileInfo: info, change: "fifo"}, nil
case "opened size changed":
return changedInfo{FileInfo: info, change: "size"}, nil
case "changed while reading":
if f.stats > 1 {
return changedInfo{FileInfo: info, change: "time"}, nil
}
}
}
return info, err
}
func (f *faultFile) Read(p []byte) (int, error) {
if f.path == fixtureStatus {
switch f.owner.fault {
case "read denied":
return 0, errors.New("private read diagnostic")
case "truncated":
return 0, io.EOF
case "growing":
// Ignore the advertised size, like a file growing after stat.
if f.bytesRead+len(p) > (16<<20)+1 {
return 0, errors.New("read exceeded bound")
}
for i := range p {
p[i] = 'x'
}
f.bytesRead += len(p)
return len(p), nil
}
}
return f.File.Read(p)
}
func (f *faultFile) ReadDir(n int) ([]fs.DirEntry, error) {
if f.owner.fault == "journal read denied" {
return nil, errors.New("private journal diagnostic")
}
return f.File.(fs.ReadDirFile).ReadDir(n)
}
func (f *faultFile) Close() error {
err := f.File.Close()
if f.owner.fault == "close failure" {
return errors.New("private close diagnostic")
}
return err
}
type changedInfo struct {
fs.FileInfo
change string
}
func (i changedInfo) Mode() fs.FileMode {
if i.change == "fifo" {
return fs.ModeNamedPipe | 0600
}
return i.FileInfo.Mode()
}
func (i changedInfo) Size() int64 {
if i.change == "size" {
return i.FileInfo.Size() + 1
}
return i.FileInfo.Size()
}
func (i changedInfo) ModTime() time.Time {
if i.change == "time" {
return i.FileInfo.ModTime().Add(time.Second)
}
return i.FileInfo.ModTime()
}
func TestInventoryFailsClosedOnReadFaultsAndChanges(t *testing.T) {
for _, fault := range []string{"status stat denied", "journal stat denied", "status open denied", "journal open denied", "opened stat denied", "opened fifo", "opened size changed", "changed while reading", "path changed", "read denied", "truncated", "growing", "journal read denied", "journal becomes pending", "close failure"} {
t.Run(fault, func(t *testing.T) {
files := &faultFS{MapFS: statusFS(stanza("runc", "amd64", "install ok installed")), fault: fault}
requireUnknown(t, files)
})
}
}
func TestInventoryStatsBeforeOpeningNonregularFiles(t *testing.T) {
for _, path := range []string{fixtureStatus, fixtureUpdates} {
files := &faultFS{MapFS: statusFS(stanza("runc", "amd64", "install ok installed"))}
files.MapFS[path].Mode = fs.ModeNamedPipe | 0600
requireUnknown(t, files)
if files.statusOpens != 0 || (path == fixtureUpdates && files.journalOpens != 0) {
t.Fatal("must reject a FIFO using metadata before opening it")
}
}
// fs.Stat would use Open on this implementation, so fail closed instead.
requireUnknown(t, openOnlyFS{})
}
type openOnlyFS struct{}
func (openOnlyFS) Open(string) (fs.File, error) {
panic("must not open without safe metadata support")
}