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

Reply via email to