Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package apko for openSUSE:Factory checked in at 2026-08-11 17:10:45 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/apko (Old) and /work/SRC/openSUSE:Factory/.apko.new.17972 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "apko" Tue Aug 11 17:10:45 2026 rev:134 rq:1370446 version:1.2.36 Changes: -------- --- /work/SRC/openSUSE:Factory/apko/apko.changes 2026-08-06 16:41:21.700319256 +0200 +++ /work/SRC/openSUSE:Factory/.apko.new.17972/apko.changes 2026-08-11 17:11:16.651154610 +0200 @@ -1,0 +2,14 @@ +Mon Aug 10 05:01:28 UTC 2026 - Johannes Kastl <[email protected]> + +- Update to version 1.2.36: + * apk: refuse to record a package with an empty name (#2390) + * apk: terminate the orphan walk at the filesystem root (#2389) + * build(deps): bump chainguard.dev/sdk from 0.1.164 to 0.1.170 + (#2385) + * build(deps): bump go.opentelemetry.io/otel from 1.44.0 to + 1.45.0 (#2384) + * build(deps): bump the codeql group across 1 directory with 2 + updates (#2386) + * Group codeql-action bumps in dependabot config (#2382) + +------------------------------------------------------------------- Old: ---- apko-1.2.35.obscpio New: ---- apko-1.2.36.obscpio ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ apko.spec ++++++ --- /var/tmp/diff_new_pack.8Mx008/_old 2026-08-11 17:11:18.067214594 +0200 +++ /var/tmp/diff_new_pack.8Mx008/_new 2026-08-11 17:11:18.067214594 +0200 @@ -17,7 +17,7 @@ Name: apko -Version: 1.2.35 +Version: 1.2.36 Release: 0 Summary: Build OCI images from APK packages directly without Dockerfile License: Apache-2.0 ++++++ _service ++++++ --- /var/tmp/diff_new_pack.8Mx008/_old 2026-08-11 17:11:18.171219000 +0200 +++ /var/tmp/diff_new_pack.8Mx008/_new 2026-08-11 17:11:18.179219339 +0200 @@ -3,7 +3,7 @@ <param name="url">https://github.com/chainguard-dev/apko.git</param> <param name="scm">git</param> <param name="exclude">.git</param> - <param name="revision">refs/tags/v1.2.35</param> + <param name="revision">refs/tags/v1.2.36</param> <param name="versionformat">@PARENT_TAG@</param> <param name="versionrewrite-pattern">v(.*)</param> <param name="changesgenerate">enable</param> ++++++ _servicedata ++++++ --- /var/tmp/diff_new_pack.8Mx008/_old 2026-08-11 17:11:18.203220355 +0200 +++ /var/tmp/diff_new_pack.8Mx008/_new 2026-08-11 17:11:18.219221034 +0200 @@ -3,6 +3,6 @@ <param name="url">https://github.com/chainguard-dev/apko</param> <param name="changesrevision">861f83f69e6fa9114405a2f7bb5cf6585ad00421</param></service><service name="tar_scm"> <param name="url">https://github.com/chainguard-dev/apko.git</param> - <param name="changesrevision">766682c7e7f87cc932ff02abeb889f5eab5edc9f</param></service></servicedata> + <param name="changesrevision">f413050331260961fceb34c1f4033b5c304fe7cc</param></service></servicedata> (No newline at EOF) ++++++ apko-1.2.35.obscpio -> apko-1.2.36.obscpio ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/apko-1.2.35/go.mod new/apko-1.2.36/go.mod --- old/apko-1.2.35/go.mod 2026-08-06 11:49:06.000000000 +0200 +++ new/apko-1.2.36/go.mod 2026-08-08 17:37:39.000000000 +0200 @@ -3,7 +3,7 @@ go 1.26.0 require ( - chainguard.dev/sdk v0.1.164 + chainguard.dev/sdk v0.1.170 github.com/chainguard-dev/clog v1.8.1 github.com/charmbracelet/log v1.0.0 github.com/go-git/go-git/v5 v5.19.2 @@ -23,8 +23,8 @@ github.com/tmc/dot v0.2.0 github.com/u-root/u-root v0.16.0 go.lsp.dev/uri v1.0.1 - go.opentelemetry.io/otel v1.44.0 - go.opentelemetry.io/otel/trace v1.44.0 + go.opentelemetry.io/otel v1.45.0 + go.opentelemetry.io/otel/trace v1.45.0 go.step.sm/crypto v0.87.0 golang.org/x/oauth2 v0.36.0 golang.org/x/sync v0.22.0 @@ -77,7 +77,7 @@ github.com/go-git/go-billy/v5 v5.9.0 // indirect github.com/go-jose/go-jose/v3 v3.0.5 // indirect github.com/go-logfmt/logfmt v0.6.1 // indirect - github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/s2a-go v0.1.9 // indirect @@ -122,7 +122,7 @@ go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect - go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.45.0 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.6 // indirect golang.org/x/crypto v0.54.0 // indirect golang.org/x/exp v0.0.0-20260709172345-9ea1abe57597 // indirect diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/apko-1.2.35/go.sum new/apko-1.2.36/go.sum --- old/apko-1.2.35/go.sum 2026-08-06 11:49:06.000000000 +0200 +++ new/apko-1.2.36/go.sum 2026-08-08 17:37:39.000000000 +0200 @@ -1,7 +1,7 @@ chainguard.dev/go-grpc-kit v0.17.17 h1:Jwhc0zyUwQbC2hNcsi+YMeUX/JUnM+dXVCkTw6wtPzs= chainguard.dev/go-grpc-kit v0.17.17/go.mod h1:qn0meP6RtrbLicE1bgBZnnVU9dvX95eLs0x0T6kZ+b4= -chainguard.dev/sdk v0.1.164 h1:k2Ir/l1cuHDS/vg29sLNjJYm2kInnCfR5w0uFD0Y1Hg= -chainguard.dev/sdk v0.1.164/go.mod h1:y5AGo04erWjKtEyGX7fO9zZA4vtj9cLGXZjgIYxeMdo= +chainguard.dev/sdk v0.1.170 h1:MJ1blus6XCfdfCvOwnXYgybA/gF4HXWRgFlgo3mGqiU= +chainguard.dev/sdk v0.1.170/go.mod h1:y5AGo04erWjKtEyGX7fO9zZA4vtj9cLGXZjgIYxeMdo= cloud.google.com/go/auth v0.22.0 h1:Xp9wAKkLoeaYb5pYZZoQGz4E9sdPxIbzS3gywZE3ciQ= cloud.google.com/go/auth v0.22.0/go.mod h1:M9o2Oz+YI2jAfxewJgb1vyI3vceHF+eohmxyzmrl+9s= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= @@ -99,8 +99,8 @@ github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE= github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= @@ -257,16 +257,16 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0/go.mod h1:D7J12YRapIekYyPWgGPlA/23pRmpSEZC5xJC/TTLI9U= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= -go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= -go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= -go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= -go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU= +go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ= +go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M= +go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s= go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= -go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= -go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag= +go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc= go.step.sm/crypto v0.87.0 h1:+u0pDz1OV9M/SswA6KtcQ3GSrYMMelbL//0W/ceJVYI= go.step.sm/crypto v0.87.0/go.mod h1:gBr1mpMiKs5804/Yt03uAoOBiw3DFxARZnGUKEUO82M= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/apko-1.2.35/pkg/apk/apk/empty_package_name_test.go new/apko-1.2.36/pkg/apk/apk/empty_package_name_test.go --- old/apko-1.2.35/pkg/apk/apk/empty_package_name_test.go 1970-01-01 01:00:00.000000000 +0100 +++ new/apko-1.2.36/pkg/apk/apk/empty_package_name_test.go 2026-08-08 17:37:39.000000000 +0200 @@ -0,0 +1,73 @@ +package apk + +import ( + "archive/tar" + "strings" + "testing" +) + +// A package whose name is empty renders a "P:" line with no value. +// ParseInstalled gates on pkg.Name != "" and so drops the entire record at the +// blank line, which means the package's files are present in the image while +// belonging to no package, and absent from anything derived from the database. +// Writing a record that cannot be read back is never useful. +func TestAddInstalledPackageRejectsEmptyPackageName(t *testing.T) { + a, _, err := testGetTestAPK() + if err != nil { + t.Fatalf("testGetTestAPK: %v", err) + } + before, err := a.GetInstalled() + if err != nil { + t.Fatalf("GetInstalled: %v", err) + } + + files := []tar.Header{ + {Name: "usr", Typeflag: tar.TypeDir, Mode: 0o755}, + {Name: "usr/bin", Typeflag: tar.TypeDir, Mode: 0o755}, + {Name: "usr/bin/thing", Typeflag: tar.TypeReg, Size: 5, Mode: 0o755}, + } + + _, err = a.AddInstalledPackage(&Package{Name: "", Version: "1.0", Arch: "x86_64"}, files) + if err == nil { + t.Fatal("AddInstalledPackage accepted a package with an empty name, want rejection") + } + if !strings.Contains(err.Error(), "empty name") { + t.Errorf("error = %q, want it to mention the empty name", err.Error()) + } + + after, err := a.GetInstalled() + if err != nil { + t.Fatalf("GetInstalled after rejection: %v", err) + } + if len(after) != len(before) { + t.Errorf("installed package count went %d -> %d after a rejected write", len(before), len(after)) + } +} + +// The rejection must not cost a legitimate package: a name is the only thing +// required here, and everything else may be empty. +func TestAddInstalledPackageAcceptsMinimalPackage(t *testing.T) { + a, _, err := testGetTestAPK() + if err != nil { + t.Fatalf("testGetTestAPK: %v", err) + } + before, err := a.GetInstalled() + if err != nil { + t.Fatalf("GetInstalled: %v", err) + } + + if _, err := a.AddInstalledPackage(&Package{Name: "minimal"}, nil); err != nil { + t.Fatalf("AddInstalledPackage(minimal) = %v, want success", err) + } + + after, err := a.GetInstalled() + if err != nil { + t.Fatalf("GetInstalled after add: %v", err) + } + if len(after) != len(before)+1 { + t.Fatalf("installed count = %d, want %d", len(after), len(before)+1) + } + if got := after[len(after)-1].Name; got != "minimal" { + t.Errorf("added package name = %q, want %q", got, "minimal") + } +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/apko-1.2.35/pkg/apk/apk/installed.go new/apko-1.2.36/pkg/apk/apk/installed.go --- old/apko-1.2.35/pkg/apk/apk/installed.go 2026-08-06 11:49:06.000000000 +0200 +++ new/apko-1.2.36/pkg/apk/apk/installed.go 2026-08-08 17:37:39.000000000 +0200 @@ -48,6 +48,17 @@ // AddInstalledPackage add a package to the list of installed packages and returns // the _incremental_ diff installing the package had on the idb file. func (a *APK) AddInstalledPackage(pkg *Package, files []tar.Header) ([]byte, error) { + // A record with an empty P: value is written without complaint and then + // discarded wholesale by ParseInstalled, which gates on pkg.Name != "". The + // package's files are left in the image belonging to no package at all, and + // absent from the generated SBOM. Refuse to write a record we cannot read + // back: at best it vanishes silently, and nothing downstream can tell that + // from a package that was never installed. + if pkg.Name == "" { + return nil, fmt.Errorf("refusing to add a package with an empty name: the record " + + "would be silently discarded when the installed database is read back") + } + // be sure to open the file in append mode so we add to the end installedFile, err := a.fs.OpenFile(installedFilePath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) if err != nil { @@ -68,6 +79,25 @@ if f.Typeflag == tar.TypeDir { dirName := strings.TrimSuffix(f.Name, fmt.Sprintf("%c", filepath.Separator)) + // The database spells the top-level directory as a bare "F:" with no + // value. An entry whose name is nothing but separators denotes that + // same directory, so normalise every spelling of it to the marker + // rather than emitting "F:/" or "F://" for some of them. + if strings.Trim(dirName, "/") == "" { + dirName = "" + } + // ParseInstalled rejects an "M:" line following the bare marker + // ("M entry cannot be associated with top level dir"), and that + // error aborts the whole read, so the record would take the entire + // database down rather than merely being wrong. A root entry with + // default ownership and mode emits no M: line and is representable, + // so only the non-default case has to be refused. + if dirName == "" && (perm != 0o755 || user != 0 || group != 0) { + return nil, fmt.Errorf("refusing to record ownership or permissions for the "+ + "top-level directory of package %q (entry %q, mode %04o, uid %d, gid %d): "+ + "the installed database cannot express them and the resulting record "+ + "would make the whole database unreadable", pkg.Name, f.Name, perm, user, group) + } pkgLines = append(pkgLines, fmt.Sprintf("F:%s", dirName)) if perm != 0o755 || user != 0 || group != 0 { pkgLines = append(pkgLines, fmt.Sprintf("M:%d:%d:%04o", user, group, perm)) @@ -431,7 +461,14 @@ keep = false break } - parentPath = filepath.Dir(parentPath) + parent := filepath.Dir(parentPath) + if parent == parentPath { + // filepath.Dir("/") is "/", so an absolute path would walk + // here forever. A fixed point means the root has been + // reached and the hierarchy is exhausted. + break + } + parentPath = parent } } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/apko-1.2.35/pkg/apk/apk/installed_test.go new/apko-1.2.36/pkg/apk/apk/installed_test.go --- old/apko-1.2.35/pkg/apk/apk/installed_test.go 2026-08-06 11:49:06.000000000 +0200 +++ new/apko-1.2.36/pkg/apk/apk/installed_test.go 2026-08-08 17:37:39.000000000 +0200 @@ -671,6 +671,34 @@ } } +// mustNotHang runs fn on its own goroutine and fails, rather than wedging the +// whole test binary, if it does not return. removeOrphanedEntries walks each +// entry's parent chain, and a bug in the termination condition turns that into +// an infinite loop -- which without this helper costs the package's full +// -timeout and reports a goroutine dump instead of naming the offending input. +// +// The budget is derived from the harness's own deadline so that a short +// `go test -timeout` is not consumed by the watchdog. The guarded calls take +// microseconds, so any budget above a few milliseconds is ample. On timeout the +// goroutine is deliberately leaked: the guarded functions take no context and +// cannot be cancelled, and the test is failing anyway. +func mustNotHang(t *testing.T, what string, fn func()) { + t.Helper() + budget := time.Second + if d, ok := t.Deadline(); ok { + if quarter := time.Until(d) / 4; quarter > 0 && quarter < budget { + budget = quarter + } + } + done := make(chan struct{}) + go func() { defer close(done); fn() }() + select { + case <-done: + case <-time.After(budget): + t.Fatalf("%s did not return within %s; the parent walk is not terminating", what, budget) + } +} + func TestRemoveOrphanedEntries(t *testing.T) { cases := []struct { name string @@ -728,6 +756,80 @@ headers: []tar.Header{}, expected: nil, }, + { + // filepath.Dir("/") is "/", so an absolute path never reaches "" or + // "." and the walk spun forever before the fixed-point check. These + // rows assert which entries survive, not merely that we return: + // a walk that gave up at the root and dropped everything would + // terminate just as happily. + name: "absolute root dir and a file under it", + headers: []tar.Header{ + {Name: "/x", Typeflag: tar.TypeReg}, + {Name: "/", Typeflag: tar.TypeDir}, + }, + expected: []string{"/x", "/"}, + }, + { + name: "double-slash dir alone", + headers: []tar.Header{{Name: "//", Typeflag: tar.TypeDir}}, + expected: []string{"//"}, + }, + { + name: "deep absolute hierarchy, all reachable", + headers: []tar.Header{ + {Name: "/", Typeflag: tar.TypeDir}, + {Name: "/usr", Typeflag: tar.TypeDir}, + {Name: "/usr/bin", Typeflag: tar.TypeDir}, + {Name: "/usr/bin/sh", Typeflag: tar.TypeReg}, + }, + expected: []string{"/", "/usr", "/usr/bin", "/usr/bin/sh"}, + }, + { + name: "absolute hierarchy with a gap in the middle", + headers: []tar.Header{ + {Name: "/", Typeflag: tar.TypeDir}, + {Name: "/usr", Typeflag: tar.TypeDir}, + {Name: "/usr/bin/sh", Typeflag: tar.TypeReg}, // missing /usr/bin + }, + expected: []string{"/", "/usr"}, + }, + { + name: "absolute file whose parent is absent", + headers: []tar.Header{{Name: "/usr/bin/sh", Typeflag: tar.TypeReg}}, + expected: nil, + }, + { + name: "mixed absolute and relative entries", + headers: []tar.Header{ + {Name: "/", Typeflag: tar.TypeDir}, + {Name: "usr", Typeflag: tar.TypeDir}, + {Name: "usr/bin", Typeflag: tar.TypeDir}, + {Name: "/etc", Typeflag: tar.TypeDir}, + {Name: "/etc/hosts", Typeflag: tar.TypeReg}, + }, + expected: []string{"/", "usr", "usr/bin", "/etc", "/etc/hosts"}, + }, + { + // The walk must check the WHOLE ancestor chain, not just the + // immediate parent. dirPaths is built from the original headers, so + // "usr/bin" stays in the set even though it is itself dropped -- + // a walk that stopped one level up would let the child through. + name: "grandparent missing though immediate parent is present", + headers: []tar.Header{ + {Name: "usr/bin", Typeflag: tar.TypeDir}, // missing usr + {Name: "usr/bin/cmd", Typeflag: tar.TypeReg}, + }, + expected: nil, + }, + { + name: "gap three levels up", + headers: []tar.Header{ + {Name: "usr/share/doc", Typeflag: tar.TypeDir}, // missing usr, usr/share + {Name: "usr/share/doc/x", Typeflag: tar.TypeDir}, + {Name: "usr/share/doc/x/y", Typeflag: tar.TypeReg}, + }, + expected: nil, + }, } for _, tt := range cases { @@ -735,7 +837,10 @@ headersCopy := make([]tar.Header, len(tt.headers)) copy(headersCopy, tt.headers) - newLen := removeOrphanedEntries(headersCopy) + var newLen int + mustNotHang(t, "removeOrphanedEntries", func() { + newLen = removeOrphanedEntries(headersCopy) + }) results := headersCopy[:newLen] var resultNames []string diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/apko-1.2.35/pkg/apk/apk/root_directory_entry_test.go new/apko-1.2.36/pkg/apk/apk/root_directory_entry_test.go --- old/apko-1.2.35/pkg/apk/apk/root_directory_entry_test.go 1970-01-01 01:00:00.000000000 +0100 +++ new/apko-1.2.36/pkg/apk/apk/root_directory_entry_test.go 2026-08-08 17:37:39.000000000 +0200 @@ -0,0 +1,228 @@ +package apk + +import ( + "archive/tar" + "bytes" + "path/filepath" + "strings" + "testing" + + apkfs "chainguard.dev/apko/pkg/apk/fs" +) + +// The installed database spells the top-level directory as a bare "F:" with no +// value, and ParseInstalled rejects an "M:" line following it -- an error that +// aborts the entire read, so one such record makes the whole database +// unreadable rather than merely being wrong itself. +// +// A root entry with default ownership and mode emits no M: line and is +// perfectly representable, so only the non-default case is refused. Before the +// parent-walk fix these inputs could not be reached at all: "//" hung inside +// removeOrphanedEntries, so the reject rows below are also the regression test +// for that, and they run under a watchdog because a revert would otherwise +// wedge the test binary instead of failing it. +func TestAddInstalledPackageRejectsUnrepresentableRootPermissions(t *testing.T) { + cases := []struct { + name string + file tar.Header + }{ + {"root dir, non-default mode", tar.Header{Name: "/", Typeflag: tar.TypeDir, Mode: 0o700}}, + {"root dir, non-root owner", tar.Header{Name: "/", Typeflag: tar.TypeDir, Mode: 0o755, Uid: 1000}}, + {"root dir, non-root group", tar.Header{Name: "/", Typeflag: tar.TypeDir, Mode: 0o755, Gid: 1000}}, + {"double slash, non-default mode", tar.Header{Name: "//", Typeflag: tar.TypeDir, Mode: 0o700}}, + {"empty name, non-default mode", tar.Header{Name: "", Typeflag: tar.TypeDir, Mode: 0o700}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + a, _, err := testGetTestAPK() + if err != nil { + t.Fatalf("testGetTestAPK: %v", err) + } + before, err := a.GetInstalled() + if err != nil { + t.Fatalf("GetInstalled: %v", err) + } + + var addErr error + mustNotHang(t, "AddInstalledPackage("+tc.file.Name+")", func() { + _, addErr = a.AddInstalledPackage(&Package{Name: "pkg", Version: "1.0"}, + []tar.Header{tc.file}) + }) + if addErr == nil { + t.Fatalf("AddInstalledPackage(%q, mode %04o) = nil error, want rejection", + tc.file.Name, tc.file.Mode) + } + if !strings.Contains(addErr.Error(), "top-level directory") { + t.Errorf("error = %q, want it to name the top-level directory", addErr.Error()) + } + + // The rejection must leave the database readable and unchanged. + after, err := a.GetInstalled() + if err != nil { + t.Fatalf("GetInstalled after rejection: %v; the database was left unparseable", err) + } + if len(after) != len(before) { + t.Errorf("installed count went %d -> %d after a rejected write", len(before), len(after)) + } + }) + } +} + +// A root entry with default ownership and mode is representable and was +// recorded correctly before this change, so it must keep working: rejecting it +// would be a regression, not a fix. Every spelling of the root normalises to +// the same bare "F:" marker rather than emitting "F:/" for some of them. +func TestAddInstalledPackageAcceptsRepresentableRootEntry(t *testing.T) { + cases := []struct { + name string + file tar.Header + }{ + {"root dir, default mode", tar.Header{Name: "/", Typeflag: tar.TypeDir, Mode: 0o755}}, + {"double slash, default mode", tar.Header{Name: "//", Typeflag: tar.TypeDir, Mode: 0o755}}, + {"empty name, default mode", tar.Header{Name: "", Typeflag: tar.TypeDir, Mode: 0o755}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + a, _, err := testGetTestAPK() + if err != nil { + t.Fatalf("testGetTestAPK: %v", err) + } + + var got []byte + var addErr error + mustNotHang(t, "AddInstalledPackage("+tc.file.Name+")", func() { + got, addErr = a.AddInstalledPackage(&Package{Name: "rooted", Version: "1.0"}, + []tar.Header{tc.file}) + }) + if addErr != nil { + t.Fatalf("AddInstalledPackage(%q, mode %04o) = %v, want success", + tc.file.Name, tc.file.Mode, addErr) + } + + // Every root spelling must produce the bare marker, never "F:/". + if !strings.Contains(string(got), "\nF:\n") { + t.Errorf("record does not contain the bare top-level marker:\n%s", got) + } + if strings.Contains(string(got), "F:/") { + t.Errorf("record spells the root as a named directory:\n%s", got) + } + + // And it must still be readable, which "F:" followed by "M:" is not. + pkgs, err := a.GetInstalled() + if err != nil { + t.Fatalf("GetInstalled after add: %v; the record is unreadable", err) + } + if pkgs[len(pkgs)-1].Name != "rooted" { + t.Errorf("added package = %q, want %q", pkgs[len(pkgs)-1].Name, "rooted") + } + }) + } +} + +// Ordinary directory entries must be unaffected: the guard applies only to +// names that denote the root itself. +func TestAddInstalledPackageAcceptsOrdinaryDirectoryEntries(t *testing.T) { + a, _, err := testGetTestAPK() + if err != nil { + t.Fatalf("testGetTestAPK: %v", err) + } + before, err := a.GetInstalled() + if err != nil { + t.Fatalf("GetInstalled: %v", err) + } + + files := []tar.Header{ + // "." and "./" denote the package root, not the filesystem root, and + // are ordinary entries that must keep working -- a guard widened to + // catch them would be over-eager. + {Name: "./", Typeflag: tar.TypeDir, Mode: 0o711}, + {Name: "usr", Typeflag: tar.TypeDir, Mode: 0o755}, + {Name: "usr/bin", Typeflag: tar.TypeDir, Mode: 0o700}, + {Name: "usr/bin/thing", Typeflag: tar.TypeReg, Size: 1, Mode: 0o755}, + } + got, err := a.AddInstalledPackage(&Package{Name: "ordinary", Version: "1.0"}, files) + if err != nil { + t.Fatalf("AddInstalledPackage with ordinary directories = %v, want success", err) + } + // A non-default mode on a NAMED directory still records its M: line; only + // the top-level directory cannot carry one. + if !strings.Contains(string(got), "M:0:0:0700") { + t.Errorf("record lost the M: line for usr/bin:\n%s", got) + } + // "." is a named directory, not the root, so it must render as such. The + // root normalisation must not swallow it into the bare marker. + // "." is a named directory, not the root, so it must render as such AND be + // able to carry its own M: line. Only the bare top-level marker cannot. + if !strings.Contains(string(got), "\nF:.\nM:0:0:0711\n") { + t.Errorf("the \"./\" entry did not render as F:. with its own M: line -- the root "+ + "normalisation or the guard is too broad and caught a named directory:\n%s", got) + } + + after, err := a.GetInstalled() + if err != nil { + t.Fatalf("GetInstalled after add: %v", err) + } + if len(after) != len(before)+1 { + t.Fatalf("installed count = %d, want %d", len(after), len(before)+1) + } + if got := after[len(after)-1].Name; got != "ordinary" { + t.Errorf("added package = %q, want %q", got, "ordinary") + } +} + +// The guard must also hold when reached the way a real build reaches it: +// installAPKFiles returns the headers that implementation.go hands straight to +// AddInstalledPackage, and it is that composition -- not a synthetic header +// list -- that hung before the parent-walk fix. +func TestInstallPathRejectsUnrepresentableRootPermissions(t *testing.T) { + ctx := t.Context() + base := filepath.Join(t.TempDir(), "base") + fsys := apkfs.DirFS(ctx, base, apkfs.WithCreateDir()) + if fsys == nil { + t.Fatalf("failed to create dirfs for base %s", base) + } + a, err := New(ctx, WithFS(fsys)) + if err != nil { + t.Fatalf("apk.New: %v", err) + } + + // AddInstalledPackage opens the database before validating, so without its + // parent directory the open fails first and the assertion below would be + // testing the wrong error. + if err := fsys.MkdirAll(filepath.Dir(installedFilePath), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + for _, h := range []tar.Header{ + {Name: "//", Typeflag: tar.TypeDir, Mode: 0o700}, + {Name: "etc", Typeflag: tar.TypeDir, Mode: 0o755}, + } { + if err := tw.WriteHeader(&h); err != nil { + t.Fatalf("WriteHeader(%q): %v", h.Name, err) + } + } + if err := tw.Close(); err != nil { + t.Fatalf("tar Close: %v", err) + } + + files, err := a.installAPKFiles(ctx, bytes.NewReader(buf.Bytes()), + &Package{Name: "hostile", Version: "1.0"}) + if err != nil { + t.Fatalf("installAPKFiles: %v", err) + } + + var addErr error + mustNotHang(t, "AddInstalledPackage via the install path", func() { + _, addErr = a.AddInstalledPackage(&Package{Name: "hostile", Version: "1.0"}, files) + }) + if addErr == nil { + t.Fatal("a package carrying a root directory entry with non-default mode was accepted") + } + if !strings.Contains(addErr.Error(), "top-level directory") { + t.Errorf("error = %q, want it to name the top-level directory", addErr.Error()) + } +} ++++++ apko.obsinfo ++++++ --- /var/tmp/diff_new_pack.8Mx008/_old 2026-08-11 17:11:19.099258312 +0200 +++ /var/tmp/diff_new_pack.8Mx008/_new 2026-08-11 17:11:19.103258481 +0200 @@ -1,5 +1,5 @@ name: apko -version: 1.2.35 -mtime: 1786009746 -commit: 766682c7e7f87cc932ff02abeb889f5eab5edc9f +version: 1.2.36 +mtime: 1786203459 +commit: f413050331260961fceb34c1f4033b5c304fe7cc ++++++ vendor.tar.gz ++++++ /work/SRC/openSUSE:Factory/apko/vendor.tar.gz /work/SRC/openSUSE:Factory/.apko.new.17972/vendor.tar.gz differ: char 134, line 1
