feat: add deployment foundation and cross-device handoff

This commit is contained in:
2026-09-25 08:49:19 +08:00
parent 8ccb8b7c15
commit e965b0943d
77 changed files with 8018 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
# Read-only relevant dpkg inventory
`Inventory(fs.FS) Snapshot` reads fixed `var/lib/dpkg/status` (regular file,
nonempty, at most 16 MiB) and checks the fixed `var/lib/dpkg/updates` directory
is empty before and after reading. It invokes no package tools and writes nothing.
The filesystem must provide metadata via `fs.StatFS`; unsupported, inaccessible,
malformed, oversized, changing or journal-busy input returns `state=unknown`, an
empty digest and empty packages array. Missing status is not a clean machine.
It validates stanza structure/identity/status before filtering. Field names are
case-insensitive; duplicate keys, malformed scalar continuations and duplicate
package/architecture records are rejected. Descriptions/other values are never
returned. Distinct multiarch records are retained; ambiguous all/unspecified
architecture duplicates fail closed. Bare not-installed selections are permitted
and still returned when relevant, so a caller cannot mistake them for no record.
An observed snapshot contains the SHA-256 of the complete status file bytes and
deterministically ordered name/version/architecture/status records for:
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 and runc.
The `Installed` type means a database record exists, not that it is fully
installed. Callers must conservatively review **every** returned record, including
hold, partial installation, residual config and not-installed selections.
OS paths/ancestors and filesystem implementation are trusted. Metadata/journal
rechecks detect ordinary changes but do not lock dpkg or produce an atomic
transaction against concurrent writes. The digest is not a host identity or
approval. This does not scan custom package databases, rootless/manual runtimes,
APT sources, package dependencies, or unrelated packages' operational health.
Format reference: [Debian control files](https://www.debian.org/doc/debian-policy/ch-controlfields.html).
+267
View File
@@ -0,0 +1,267 @@
// 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
}
+386
View File
@@ -0,0 +1,386 @@
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")
}