Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package arkade for openSUSE:Factory checked in at 2026-06-23 17:40:42 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/arkade (Old) and /work/SRC/openSUSE:Factory/.arkade.new.1956 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "arkade" Tue Jun 23 17:40:42 2026 rev:82 rq:1361250 version:0.11.102 Changes: -------- --- /work/SRC/openSUSE:Factory/arkade/arkade.changes 2026-06-18 18:44:53.439723590 +0200 +++ /work/SRC/openSUSE:Factory/.arkade.new.1956/arkade.changes 2026-06-23 17:43:06.142972855 +0200 @@ -1,0 +2,14 @@ +Tue Jun 23 04:59:59 UTC 2026 - Johannes Kastl <[email protected]> + +- Update to version 0.11.102: + * Add pluto CLI to find deprecated K8s APIs + +------------------------------------------------------------------- +Tue Jun 23 04:56:18 UTC 2026 - Johannes Kastl <[email protected]> + +- Update to version 0.11.101: + * Make symlink extraction in UntarNested conditional + * Fix symlink path-traversal escape in UntarNested + * Remove stray file + +------------------------------------------------------------------- Old: ---- arkade-0.11.100.obscpio New: ---- arkade-0.11.102.obscpio ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ arkade.spec ++++++ --- /var/tmp/diff_new_pack.mq8tIL/_old 2026-06-23 17:43:08.995072833 +0200 +++ /var/tmp/diff_new_pack.mq8tIL/_new 2026-06-23 17:43:09.007073254 +0200 @@ -17,7 +17,7 @@ Name: arkade -Version: 0.11.100 +Version: 0.11.102 Release: 0 Summary: Open Source Kubernetes Marketplace License: Apache-2.0 ++++++ _service ++++++ --- /var/tmp/diff_new_pack.mq8tIL/_old 2026-06-23 17:43:09.495090361 +0200 +++ /var/tmp/diff_new_pack.mq8tIL/_new 2026-06-23 17:43:09.535091763 +0200 @@ -3,7 +3,7 @@ <param name="url">https://github.com/alexellis/arkade</param> <param name="scm">git</param> <param name="exclude">.git</param> - <param name="revision">0.11.100</param> + <param name="revision">0.11.102</param> <param name="versionformat">@PARENT_TAG@</param> <param name="versionrewrite-pattern">v(.*)</param> <param name="changesgenerate">enable</param> ++++++ _servicedata ++++++ --- /var/tmp/diff_new_pack.mq8tIL/_old 2026-06-23 17:43:09.735098774 +0200 +++ /var/tmp/diff_new_pack.mq8tIL/_new 2026-06-23 17:43:09.755099475 +0200 @@ -1,6 +1,6 @@ <servicedata> <service name="tar_scm"> <param name="url">https://github.com/alexellis/arkade</param> - <param name="changesrevision">db87cbea02bb931f4277719629597a627c4d4b5e</param></service></servicedata> + <param name="changesrevision">37af31c7b58de8f16a10067051e3bbe7ecb6aa79</param></service></servicedata> (No newline at EOF) ++++++ arkade-0.11.100.obscpio -> arkade-0.11.102.obscpio ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/arkade-0.11.100/README.md new/arkade-0.11.102/README.md --- old/arkade-0.11.100/README.md 2026-06-16 19:06:19.000000000 +0200 +++ new/arkade-0.11.102/README.md 2026-06-22 16:31:10.000000000 +0200 @@ -918,6 +918,7 @@ | [osm](https://github.com/openservicemesh/osm) | Open Service Mesh uniformly manages, secures, and gets out-of-the-box observability features. | | [pack](https://github.com/buildpacks/pack) | Build apps using Cloud Native Buildpacks. | | [packer](https://github.com/hashicorp/packer) | Build identical machine images for multiple platforms from a single source configuration. | +| [pluto](https://github.com/FairwindsOps/pluto) | Find deprecated Kubernetes apiVersions in code repositories and helm releases. | | [polaris](https://github.com/FairwindsOps/polaris) | Run checks to ensure Kubernetes pods and controllers are configured using best practices. | | [popeye](https://github.com/derailed/popeye) | Scans live Kubernetes cluster and reports potential issues with deployed resources and configurations. | | [porter](https://github.com/getporter/porter) | With Porter you can package your application artifact, tools, etc. as a bundle that can distribute and install. | diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/arkade-0.11.100/cmd/oci/install.go new/arkade-0.11.102/cmd/oci/install.go --- old/arkade-0.11.100/cmd/oci/install.go 2026-06-16 19:06:19.000000000 +0200 +++ new/arkade-0.11.102/cmd/oci/install.go 2026-06-22 16:31:10.000000000 +0200 @@ -56,6 +56,7 @@ command.Flags().BoolP("gzipped", "g", false, "Is this a gzipped tarball?") command.Flags().Bool("quiet", false, "Suppress progress output") + command.Flags().Bool("symlink", false, "Write symlinks when unpacking OCI image, only use with trusted sources") // Hide the deprecated --path flag command.Flags().MarkHidden("path") @@ -68,6 +69,7 @@ version, _ := cmd.Flags().GetString("version") gzipped, _ := cmd.Flags().GetBool("gzipped") quiet, _ := cmd.Flags().GetBool("quiet") + allowSymlinks, _ := cmd.Flags().GetBool("symlink") showProgress, _ := cmd.Flags().GetBool("progress") if len(args) < 1 { @@ -260,7 +262,7 @@ // When the alt-screen is active, suppress UntarNested's // per-file logging so it doesn't corrupt the live frame. untarQuiet := quiet || (tty && renderLive) - if uErr := archive.UntarNested(tarFile, installPath, gzipped, untarQuiet); uErr != nil { + if uErr := archive.UntarNested(tarFile, installPath, gzipped, untarQuiet, allowSymlinks); uErr != nil { workErr = fmt.Errorf("failed to untar %s: %w", tempFile.Name(), uErr) } } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/arkade-0.11.100/cmd/system/actions_runner.go new/arkade-0.11.102/cmd/system/actions_runner.go --- old/arkade-0.11.100/cmd/system/actions_runner.go 2026-06-16 19:06:19.000000000 +0200 +++ new/arkade-0.11.102/cmd/system/actions_runner.go 2026-06-22 16:31:10.000000000 +0200 @@ -100,7 +100,7 @@ fmt.Printf("Unpacking Actions Runner to: %s\n", path.Join(installPath, "actions-runner")) if err := spinWhile("Unpacking Actions Runner", func() error { - return archive.UntarNested(f, installPath, true, true) + return archive.UntarNested(f, installPath, true, true, true) }); err != nil { return err } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/arkade-0.11.100/cmd/system/containerd.go new/arkade-0.11.102/cmd/system/containerd.go --- old/arkade-0.11.100/cmd/system/containerd.go 2026-06-16 19:06:19.000000000 +0200 +++ new/arkade-0.11.102/cmd/system/containerd.go 2026-06-22 16:31:10.000000000 +0200 @@ -120,7 +120,7 @@ tempDirName := os.TempDir() + "/containerd" if err := spinWhile("Unpacking containerd", func() error { - return archive.UntarNested(f, tempDirName, true, true) + return archive.UntarNested(f, tempDirName, true, true, true) }); err != nil { return err } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/arkade-0.11.100/cmd/system/go.go new/arkade-0.11.102/cmd/system/go.go --- old/arkade-0.11.100/cmd/system/go.go 2026-06-16 19:06:19.000000000 +0200 +++ new/arkade-0.11.102/cmd/system/go.go 2026-06-22 16:31:10.000000000 +0200 @@ -96,7 +96,7 @@ fmt.Printf("Unpacking Go to: %s\n", path.Join(installPath, "go")) if err := spinWhile("Unpacking Go", func() error { - return archive.UntarNested(f, installPath, true, true) + return archive.UntarNested(f, installPath, true, true, true) }); err != nil { return err } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/arkade-0.11.100/cmd/system/node.go new/arkade-0.11.102/cmd/system/node.go --- old/arkade-0.11.100/cmd/system/node.go 2026-06-16 19:06:19.000000000 +0200 +++ new/arkade-0.11.102/cmd/system/node.go 2026-06-22 16:31:10.000000000 +0200 @@ -149,7 +149,7 @@ fmt.Printf("Unpacking binaries to: %s\n", tempUnpackPath) } if err = spinWhile("Unpacking Node.js", func() error { - return archive.UntarNested(f, tempUnpackPath, true, true) + return archive.UntarNested(f, tempUnpackPath, true, true, true) }); err != nil { return err } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/arkade-0.11.100/cmd/system/registry.go new/arkade-0.11.102/cmd/system/registry.go --- old/arkade-0.11.100/cmd/system/registry.go 2026-06-16 19:06:19.000000000 +0200 +++ new/arkade-0.11.102/cmd/system/registry.go 2026-06-22 16:31:10.000000000 +0200 @@ -135,7 +135,7 @@ tempDirName := fmt.Sprintf("%s/%s", os.TempDir(), toolName) defer os.RemoveAll(tempDirName) if err := spinWhile("Unpacking "+toolName, func() error { - return archive.UntarNested(f, tempDirName, true, true) + return archive.UntarNested(f, tempDirName, true, true, true) }); err != nil { return err } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/arkade-0.11.100/pkg/archive/untar.go new/arkade-0.11.102/pkg/archive/untar.go --- old/arkade-0.11.100/pkg/archive/untar.go 2026-06-16 19:06:19.000000000 +0200 +++ new/arkade-0.11.102/pkg/archive/untar.go 2026-06-22 16:31:10.000000000 +0200 @@ -118,8 +118,13 @@ } func validRelPath(p string) bool { - if p == "" || strings.Contains(p, `\`) || strings.HasPrefix(p, "/") || strings.Contains(p, "../") { + if p == "" || strings.Contains(p, `\`) || strings.HasPrefix(p, "/") { return false } + for _, part := range strings.Split(p, "/") { + if part == ".." { + return false + } + } return true } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/arkade-0.11.100/pkg/archive/untar_nested.go new/arkade-0.11.102/pkg/archive/untar_nested.go --- old/arkade-0.11.100/pkg/archive/untar_nested.go 2026-06-16 19:06:19.000000000 +0200 +++ new/arkade-0.11.102/pkg/archive/untar_nested.go 2026-06-22 16:31:10.000000000 +0200 @@ -8,18 +8,21 @@ "log" "os" "path/filepath" + "strings" "time" ) // UntarNested reads the gzip-compressed tar file from r and writes it into dir. +// When allowSymlinks is false, any symlink entry in the archive causes an +// error; when true, symlinks are extracted subject to containment checks. // Copyright 2017 The Go Authors. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -func UntarNested(r io.Reader, dir string, gzipped, quiet bool) error { - return untarNested(r, dir, gzipped, quiet) +func UntarNested(r io.Reader, dir string, gzipped, quiet, allowSymlinks bool) error { + return untarNested(r, dir, gzipped, quiet, allowSymlinks) } -func untarNested(r io.Reader, dir string, gzipped, quiet bool) (err error) { +func untarNested(r io.Reader, dir string, gzipped, quiet, allowSymlinks bool) (err error) { t0 := time.Now() nFiles := 0 madeDir := map[string]bool{} @@ -34,17 +37,28 @@ } }() - reader := r - if gzipped { zr, err := gzip.NewReader(r) if err != nil { return fmt.Errorf("requires gzip-compressed body: %v", err) } - reader = zr + r = zr + } + + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + + // Resolve dir to its real path so containment checks are not confused by a + // symlinked install directory (e.g. /usr/local/bin on some systems). + resolvedDir, err := filepath.EvalSymlinks(dir) + if err != nil { + return err } + dir = resolvedDir + cleanDir := filepath.Clean(dir) - tr := tar.NewReader(reader) + tr := tar.NewReader(r) loggedChtimesError := false for { f, err := tr.Next() @@ -68,16 +82,29 @@ } switch { case mode.IsRegular(): - // Make the directory. This is redundant because it should - // already be made by a directory entry in the tar - // beforehand. Thus, don't check for errors; the next - // write will fail with the same error. - dir := filepath.Dir(abs) - if !madeDir[dir] { - if err := os.MkdirAll(filepath.Dir(abs), 0755); err != nil { + parent := filepath.Dir(abs) + if !madeDir[parent] { + // Guard before MkdirAll: it follows a pre-existing symlink and + // would otherwise create directories outside root. + if err := assertExistingPrefixWithinRoot(cleanDir, parent); err != nil { return err } - madeDir[dir] = true + if err := os.MkdirAll(parent, 0755); err != nil { + return err + } + madeDir[parent] = true + } + // Resolve the physical parent (containment already guaranteed above) + // to locate the write, allowing write-through of internal symlinks. + resolvedParent, err := filepath.EvalSymlinks(parent) + if err != nil { + return fmt.Errorf("cannot resolve parent of %s: %v", abs, err) + } + abs = filepath.Join(resolvedParent, filepath.Base(abs)) + // Don't write through a pre-existing symlink at the leaf; O_CREATE + // would follow it outside root. + if fi, err := os.Lstat(abs); err == nil && fi.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("refusing to write through symlink %q", abs) } wf, err := os.OpenFile(abs, os.O_RDWR|os.O_CREATE|os.O_TRUNC, mode.Perm()) if err != nil { @@ -114,13 +141,48 @@ } nFiles++ case mode.IsDir(): + // Guard before MkdirAll, as with regular files. + if err := assertExistingPrefixWithinRoot(cleanDir, abs); err != nil { + return err + } if err := os.MkdirAll(abs, 0755); err != nil { return err } madeDir[abs] = true - // Introduced via - // https://github.com/alexellis/arkade/pull/675/files - case os.ModeSymlink != 0: + case mode.Type() == os.ModeSymlink: + if !allowSymlinks { + return fmt.Errorf("tar file entry %s is a symlink, but symlink extraction is disabled", f.Name) + } + parent := filepath.Dir(abs) + if !madeDir[parent] { + if err := assertExistingPrefixWithinRoot(cleanDir, parent); err != nil { + return err + } + if err := os.MkdirAll(parent, 0755); err != nil { + return err + } + madeDir[parent] = true + } + // Resolve the physical parent (containment already guaranteed above) + // to locate where the symlink is created. + resolvedParent, err := filepath.EvalSymlinks(parent) + if err != nil { + return fmt.Errorf("cannot resolve parent of %s: %v", abs, err) + } + abs = filepath.Join(resolvedParent, filepath.Base(abs)) + // Validate the link target stays within root. resolvedParent is + // symlink-free, so this lexical check matches the physical location. + target := f.Linkname + if !filepath.IsAbs(target) { + target = filepath.Join(resolvedParent, target) + } + if !inDir(filepath.Clean(target), cleanDir) { + return fmt.Errorf("refusing symlink %q -> %q (escapes %q)", abs, f.Linkname, dir) + } + // ...and physically (pre-existing symlink in the target path). + if err := assertExistingPrefixWithinRoot(cleanDir, target); err != nil { + return err + } if err := os.Symlink(f.Linkname, abs); err != nil { return err } @@ -130,3 +192,34 @@ } return nil } + +// assertExistingPrefixWithinRoot resolves the longest existing ancestor of p and +// returns an error if it does not stay within root. +func assertExistingPrefixWithinRoot(root, p string) error { + cur := filepath.Clean(p) + for { + if _, err := os.Lstat(cur); err == nil { + break + } + parent := filepath.Dir(cur) + if parent == cur { + // Reached the filesystem root without an existing component. + return nil + } + cur = parent + } + resolved, err := filepath.EvalSymlinks(cur) + if err != nil { + return err + } + if !inDir(filepath.Clean(resolved), root) { + return fmt.Errorf("refusing to create %q: existing path %q resolves to %q outside %q", p, cur, resolved, root) + } + return nil +} + +// inDir reports whether path is equal to root or a direct descendant. +// Both arguments must be clean paths (output of filepath.Clean or filepath.EvalSymlinks). +func inDir(path, root string) bool { + return path == root || strings.HasPrefix(path, root+string(os.PathSeparator)) +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/arkade-0.11.100/pkg/archive/untar_nested_test.go new/arkade-0.11.102/pkg/archive/untar_nested_test.go --- old/arkade-0.11.100/pkg/archive/untar_nested_test.go 1970-01-01 01:00:00.000000000 +0100 +++ new/arkade-0.11.102/pkg/archive/untar_nested_test.go 2026-06-22 16:31:10.000000000 +0200 @@ -0,0 +1,417 @@ +package archive + +import ( + "archive/tar" + "bytes" + "os" + "path/filepath" + "testing" +) + +type tarEntry struct { + hdr tar.Header + body []byte +} + +func buildTar(t *testing.T, entries []tarEntry) []byte { + t.Helper() + var b bytes.Buffer + tw := tar.NewWriter(&b) + for _, e := range entries { + h := e.hdr + if len(e.body) > 0 { + h.Size = int64(len(e.body)) + } + if err := tw.WriteHeader(&h); err != nil { + t.Fatalf("write header %q: %v", h.Name, err) + } + if len(e.body) > 0 { + if _, err := tw.Write(e.body); err != nil { + t.Fatalf("write body %q: %v", h.Name, err) + } + } + } + if err := tw.Close(); err != nil { + t.Fatalf("close tar: %v", err) + } + return b.Bytes() +} + +// A symlink whose target is an absolute path outside the install dir must be rejected, +// even when a subsequent entry attempts to write through it. +func Test_UntarNested_RejectsAbsoluteSymlinkWriteThrough(t *testing.T) { + baseDir, err := os.MkdirTemp("", "arkade-untar-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(baseDir) + + installDir := filepath.Join(baseDir, "install") + if err := os.MkdirAll(installDir, 0755); err != nil { + t.Fatal(err) + } + outsideDir := filepath.Join(baseDir, "outside") + if err := os.MkdirAll(outsideDir, 0755); err != nil { + t.Fatal(err) + } + + data := buildTar(t, []tarEntry{ + {hdr: tar.Header{Name: "escape-link", Typeflag: tar.TypeSymlink, Linkname: outsideDir, Mode: 0777}}, + {hdr: tar.Header{Name: "escape-link/escape.txt", Typeflag: tar.TypeReg, Mode: 0644}, body: []byte("escaped\n")}, + }) + + if err := UntarNested(bytes.NewReader(data), installDir, false, true, true); err == nil { + t.Fatal("want error, got nil") + } + + if content, err := os.ReadFile(filepath.Join(outsideDir, "escape.txt")); err == nil { + t.Fatalf("file written outside install dir: content=%q", string(content)) + } +} + +// A symlink whose target is a relative path that escapes the install dir must be rejected, +// even when a subsequent entry attempts to write through it. +func Test_UntarNested_RejectsRelativeSymlinkWriteThrough(t *testing.T) { + baseDir, err := os.MkdirTemp("", "arkade-untar-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(baseDir) + + installDir := filepath.Join(baseDir, "install") + if err := os.MkdirAll(installDir, 0755); err != nil { + t.Fatal(err) + } + outsideDir := filepath.Join(baseDir, "outside") + if err := os.MkdirAll(outsideDir, 0755); err != nil { + t.Fatal(err) + } + + data := buildTar(t, []tarEntry{ + {hdr: tar.Header{Name: "escape-link", Typeflag: tar.TypeSymlink, Linkname: "../outside", Mode: 0777}}, + {hdr: tar.Header{Name: "escape-link/escape.txt", Typeflag: tar.TypeReg, Mode: 0644}, body: []byte("escaped\n")}, + }) + + if err := UntarNested(bytes.NewReader(data), installDir, false, true, true); err == nil { + t.Fatal("want error, got nil") + } + + if content, err := os.ReadFile(filepath.Join(outsideDir, "escape.txt")); err == nil { + t.Fatalf("file written outside install dir: content=%q", string(content)) + } +} + +// A chain of symlinks that appears valid lexically but escapes the install dir +// at runtime must be rejected; hop1 -> "." (resolves to install), hop1/hop2 -> ".." (escapes to base). +func Test_UntarNested_RejectsChainedSymlinkEscape(t *testing.T) { + baseDir, err := os.MkdirTemp("", "arkade-untar-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(baseDir) + + installDir := filepath.Join(baseDir, "install") + if err := os.MkdirAll(installDir, 0755); err != nil { + t.Fatal(err) + } + + data := buildTar(t, []tarEntry{ + {hdr: tar.Header{Name: "hop1", Typeflag: tar.TypeSymlink, Linkname: ".", Mode: 0777}}, + {hdr: tar.Header{Name: "hop1/hop2", Typeflag: tar.TypeSymlink, Linkname: "..", Mode: 0777}}, + {hdr: tar.Header{Name: "hop1/hop2/outside/escape.txt", Typeflag: tar.TypeReg, Mode: 0644}, body: []byte("escaped\n")}, + }) + + if err := UntarNested(bytes.NewReader(data), installDir, false, true, true); err == nil { + t.Fatal("want error, got nil") + } + + if content, err := os.ReadFile(filepath.Join(baseDir, "outside", "escape.txt")); err == nil { + t.Fatalf("file written outside install dir: content=%q", string(content)) + } +} + +// A symlink that resolves outside the install dir must not be left on disk, +// even without a subsequent write; hop1 -> "." (resolves to install), hop1/hop2 -> ".." (escapes to base). +func Test_UntarNested_RejectsPlantedEscapingSymlink(t *testing.T) { + baseDir, err := os.MkdirTemp("", "arkade-untar-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(baseDir) + + installDir := filepath.Join(baseDir, "install") + if err := os.MkdirAll(installDir, 0755); err != nil { + t.Fatal(err) + } + + data := buildTar(t, []tarEntry{ + {hdr: tar.Header{Name: "hop1", Typeflag: tar.TypeSymlink, Linkname: ".", Mode: 0777}}, + {hdr: tar.Header{Name: "hop1/hop2", Typeflag: tar.TypeSymlink, Linkname: "..", Mode: 0777}}, + }) + + if err := UntarNested(bytes.NewReader(data), installDir, false, true, true); err == nil { + t.Fatal("want error, got nil") + } + + planted := filepath.Join(installDir, "hop2") + if fi, err := os.Lstat(planted); err == nil && fi.Mode()&os.ModeSymlink != 0 { + target, _ := os.Readlink(planted) + t.Fatalf("escaping symlink left on disk: %s -> %q", planted, target) + } +} + +// A symlink whose target traverses a pre-existing symlink (inside the install dir +// but pointing outside) must be rejected and not left on disk. The lexical target +// check alone passes here, so this exercises the physical-prefix resolution. +func Test_UntarNested_RejectsSymlinkTargetViaPreExistingSymlink(t *testing.T) { + baseDir, err := os.MkdirTemp("", "arkade-untar-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(baseDir) + + installDir := filepath.Join(baseDir, "install") + if err := os.MkdirAll(installDir, 0755); err != nil { + t.Fatal(err) + } + outsideDir := filepath.Join(baseDir, "outside") + if err := os.MkdirAll(outsideDir, 0755); err != nil { + t.Fatal(err) + } + // Pre-existing symlink inside the install dir that points outside. + if err := os.Symlink(outsideDir, filepath.Join(installDir, "safe")); err != nil { + t.Fatal(err) + } + + // "safe/file" is lexically under installDir, but "safe" resolves outside. + data := buildTar(t, []tarEntry{ + {hdr: tar.Header{Name: "planted", Typeflag: tar.TypeSymlink, Linkname: "safe/file", Mode: 0777}}, + }) + + if err := UntarNested(bytes.NewReader(data), installDir, false, true, true); err == nil { + t.Fatalf("expected extraction to be rejected, got nil error") + } + + planted := filepath.Join(installDir, "planted") + if fi, err := os.Lstat(planted); err == nil && fi.Mode()&os.ModeSymlink != 0 { + target, _ := os.Readlink(planted) + t.Fatalf("escaping symlink left on disk: %s -> %q", planted, target) + } +} + +// Ordinary nested directories and files must extract correctly. +func Test_UntarNested_AllowsValidNestedFiles(t *testing.T) { + installDir, err := os.MkdirTemp("", "arkade-untar-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(installDir) + + data := buildTar(t, []tarEntry{ + {hdr: tar.Header{Name: "bin", Typeflag: tar.TypeDir, Mode: 0755}}, + {hdr: tar.Header{Name: "bin/tool", Typeflag: tar.TypeReg, Mode: 0755}, body: []byte("#!/bin/sh\n")}, + {hdr: tar.Header{Name: "README.md", Typeflag: tar.TypeReg, Mode: 0644}, body: []byte("hello\n")}, + }) + + if err := UntarNested(bytes.NewReader(data), installDir, false, true, true); err != nil { + t.Fatalf("expected clean extraction, got: %v", err) + } + for _, rel := range []string{"bin/tool", "README.md"} { + if _, err := os.Stat(filepath.Join(installDir, rel)); err != nil { + t.Fatalf("expected %q to exist: %v", rel, err) + } + } +} + +// A file written through an internal symlink (one whose target stays within the install dir) +// must land at the symlink's target location. +func Test_UntarNested_AllowsWriteThroughInternalSymlink(t *testing.T) { + installDir, err := os.MkdirTemp("", "arkade-untar-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(installDir) + + data := buildTar(t, []tarEntry{ + {hdr: tar.Header{Name: "subdir", Typeflag: tar.TypeDir, Mode: 0755}}, + {hdr: tar.Header{Name: "link", Typeflag: tar.TypeSymlink, Linkname: "subdir", Mode: 0777}}, + {hdr: tar.Header{Name: "link/file.txt", Typeflag: tar.TypeReg, Mode: 0644}, body: []byte("hello\n")}, + }) + + if err := UntarNested(bytes.NewReader(data), installDir, false, true, true); err != nil { + t.Fatalf("expected clean extraction with write through internal symlink, got: %v", err) + } + if _, err := os.Stat(filepath.Join(installDir, "subdir", "file.txt")); err != nil { + t.Fatalf("expected file to exist at symlink target: %v", err) + } +} + +// A directory created through an internal symlink must land at the symlink's target location. +func Test_UntarNested_AllowsWriteThroughInternalSymlinkDir(t *testing.T) { + installDir, err := os.MkdirTemp("", "arkade-untar-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(installDir) + + data := buildTar(t, []tarEntry{ + {hdr: tar.Header{Name: "subdir", Typeflag: tar.TypeDir, Mode: 0755}}, + {hdr: tar.Header{Name: "link", Typeflag: tar.TypeSymlink, Linkname: "subdir", Mode: 0777}}, + {hdr: tar.Header{Name: "link/newdir", Typeflag: tar.TypeDir, Mode: 0755}}, + }) + + if err := UntarNested(bytes.NewReader(data), installDir, false, true, true); err != nil { + t.Fatalf("expected clean extraction with dir write-through internal symlink, got: %v", err) + } + if _, err := os.Stat(filepath.Join(installDir, "subdir", "newdir")); err != nil { + t.Fatalf("expected directory to exist at symlink target: %v", err) + } +} + +// A pre-existing symlink inside the extraction root that points outside must not cause +// MkdirAll to create directories outside the root, even though no file is written there. +func Test_UntarNested_PreExistingSymlinkDoesNotCreateDirOutsideRoot(t *testing.T) { + baseDir, err := os.MkdirTemp("", "arkade-untar-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(baseDir) + + installDir := filepath.Join(baseDir, "install") + if err := os.MkdirAll(installDir, 0755); err != nil { + t.Fatal(err) + } + outsideDir := filepath.Join(baseDir, "outside") + if err := os.MkdirAll(outsideDir, 0755); err != nil { + t.Fatal(err) + } + + // Plant a symlink inside the extraction root pointing outside — pre-existing, not from tar. + if err := os.Symlink(outsideDir, filepath.Join(installDir, "link")); err != nil { + t.Fatal(err) + } + + // Tar only contains a regular file whose parent traverses the pre-existing symlink. + data := buildTar(t, []tarEntry{ + {hdr: tar.Header{Name: "link/subdir/escape.txt", Typeflag: tar.TypeReg, Mode: 0644}, body: []byte("escaped\n")}, + }) + + if err := UntarNested(bytes.NewReader(data), installDir, false, true, true); err == nil { + t.Fatal("want error, got nil") + } + + // MkdirAll must not follow the symlink and create outsideDir/subdir before EvalSymlinks catches the escape. + if _, err := os.Stat(filepath.Join(outsideDir, "subdir")); err == nil { + t.Fatal("directory created outside extraction root via pre-existing symlink") + } +} + +// A pre-existing symlink at the final path component must not be written through; +// a regular-file entry of the same name must not redirect the write outside root. +func Test_UntarNested_RejectsLeafSymlinkWriteThrough(t *testing.T) { + baseDir, err := os.MkdirTemp("", "arkade-untar-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(baseDir) + + installDir := filepath.Join(baseDir, "install") + if err := os.MkdirAll(installDir, 0755); err != nil { + t.Fatal(err) + } + outsideTarget := filepath.Join(baseDir, "target.txt") + if err := os.WriteFile(outsideTarget, []byte("ORIGINAL"), 0644); err != nil { + t.Fatal(err) + } + // Plant a leaf symlink inside the root pointing at a file outside the root. + if err := os.Symlink(outsideTarget, filepath.Join(installDir, "evil")); err != nil { + t.Fatal(err) + } + + data := buildTar(t, []tarEntry{ + {hdr: tar.Header{Name: "evil", Typeflag: tar.TypeReg, Mode: 0644}, body: []byte("HACKED")}, + }) + + if err := UntarNested(bytes.NewReader(data), installDir, false, true, true); err == nil { + t.Fatal("want error, got nil") + } + + if b, _ := os.ReadFile(outsideTarget); string(b) != "ORIGINAL" { + t.Fatalf("file outside root overwritten through leaf symlink: now %q", string(b)) + } +} + +// A symlink whose target stays within the install dir must be created. +func Test_UntarNested_AllowsValidInternalSymlink(t *testing.T) { + installDir, err := os.MkdirTemp("", "arkade-untar-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(installDir) + + data := buildTar(t, []tarEntry{ + {hdr: tar.Header{Name: "tool-v1", Typeflag: tar.TypeReg, Mode: 0755}, body: []byte("bin\n")}, + {hdr: tar.Header{Name: "tool", Typeflag: tar.TypeSymlink, Linkname: "tool-v1", Mode: 0777}}, + }) + + if err := UntarNested(bytes.NewReader(data), installDir, false, true, true); err != nil { + t.Fatalf("expected clean extraction with internal symlink, got: %v", err) + } + linkPath := filepath.Join(installDir, "tool") + fi, err := os.Lstat(linkPath) + if err != nil { + t.Fatalf("expected symlink %q to exist: %v", linkPath, err) + } + if fi.Mode()&os.ModeSymlink == 0 { + t.Fatalf("expected %q to be a symlink", linkPath) + } +} + +// A symlink entry whose parent directory is not listed as its own entry in the tar +// must still be created; the parent directory is made on demand. +func Test_UntarNested_CreatesParentDirForSymlink(t *testing.T) { + installDir, err := os.MkdirTemp("", "arkade-untar-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(installDir) + + // No "nested" directory entry precedes the symlink; the target stays within root. + data := buildTar(t, []tarEntry{ + {hdr: tar.Header{Name: "nested/link", Typeflag: tar.TypeSymlink, Linkname: "tool-v1", Mode: 0777}}, + }) + + if err := UntarNested(bytes.NewReader(data), installDir, false, true, true); err != nil { + t.Fatalf("expected clean extraction with on-demand parent dir for symlink, got: %v", err) + } + linkPath := filepath.Join(installDir, "nested", "link") + fi, err := os.Lstat(linkPath) + if err != nil { + t.Fatalf("expected symlink %q to exist: %v", linkPath, err) + } + if fi.Mode()&os.ModeSymlink == 0 { + t.Fatalf("expected %q to be a symlink", linkPath) + } +} + +// When allowSymlinks is false, any symlink entry in the tar must be rejected, +// even when the link target stays safely within root. +func Test_UntarNested_RejectsSymlinkWhenDisabled(t *testing.T) { + installDir, err := os.MkdirTemp("", "arkade-untar-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(installDir) + + data := buildTar(t, []tarEntry{ + {hdr: tar.Header{Name: "tool-v1", Typeflag: tar.TypeReg, Mode: 0755}, body: []byte("bin\n")}, + {hdr: tar.Header{Name: "tool", Typeflag: tar.TypeSymlink, Linkname: "tool-v1", Mode: 0777}}, + }) + + if err := UntarNested(bytes.NewReader(data), installDir, false, true, false); err == nil { + t.Fatalf("expected error when extracting symlink with symlinks disabled, got nil") + } + if _, err := os.Lstat(filepath.Join(installDir, "tool")); err == nil { + t.Fatalf("expected symlink not to be created when symlinks disabled") + } +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/arkade-0.11.100/pkg/get/get_test.go new/arkade-0.11.102/pkg/get/get_test.go --- old/arkade-0.11.100/pkg/get/get_test.go 2026-06-16 19:06:19.000000000 +0200 +++ new/arkade-0.11.102/pkg/get/get_test.go 2026-06-22 16:31:10.000000000 +0200 @@ -3426,6 +3426,60 @@ } +func Test_DownloadPlutoCli(t *testing.T) { + tools := MakeTools() + name := "pluto" + + tool := getTool(name, tools) + + tests := []test{ + { + os: "darwin", + arch: arch64bit, + version: "3.12.0", + url: `https://github.com/FairwindsOps/pluto/releases/download/3.12.0/pluto_3.12.0_darwin_amd64.tar.gz`, + }, + { + os: "linux", + arch: arch64bit, + version: "3.12.0", + url: `https://github.com/FairwindsOps/pluto/releases/download/3.12.0/pluto_3.12.0_linux_amd64.tar.gz`, + }, + { + os: "linux", + arch: archARM64, + version: "3.12.0", + url: `https://github.com/FairwindsOps/pluto/releases/download/3.12.0/pluto_3.12.0_linux_arm64.tar.gz`, + }, + { + os: "darwin", + arch: archDarwinARM64, + version: "3.12.0", + url: `https://github.com/FairwindsOps/pluto/releases/download/3.12.0/pluto_3.12.0_darwin_arm64.tar.gz`, + }, + { + os: "linux", + arch: archARM7, + version: "3.12.0", + url: `https://github.com/FairwindsOps/pluto/releases/download/3.12.0/pluto_3.12.0_linux_armv7.tar.gz`, + }, + } + + for _, tc := range tests { + t.Run(tc.os+" "+tc.arch+" "+tc.version, func(r *testing.T) { + + got, _, err := tool.GetURL(tc.os, tc.arch, tc.version, false) + if err != nil { + t.Fatal(err) + } + if got != tc.url { + t.Errorf("want: %s, got: %s", tc.url, got) + } + }) + } + +} + func Test_DownloadKubetailCli(t *testing.T) { tools := MakeTools() name := "kubetail" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/arkade-0.11.100/pkg/get/tools.go new/arkade-0.11.102/pkg/get/tools.go --- old/arkade-0.11.100/pkg/get/tools.go 2026-06-16 19:06:19.000000000 +0200 +++ new/arkade-0.11.102/pkg/get/tools.go 2026-06-22 16:31:10.000000000 +0200 @@ -2200,6 +2200,34 @@ tools = append(tools, Tool{ + Owner: "FairwindsOps", + Repo: "pluto", + Name: "pluto", + Description: "Find deprecated Kubernetes apiVersions in code repositories and helm releases.", + BinaryTemplate: ` + {{$arch := "amd64"}} + {{if eq .Arch "armv7l" -}} + {{$arch = "armv7"}} + {{- else if eq .Arch "aarch64" -}} + {{$arch = "arm64"}} + {{- else if eq .Arch "arm64" -}} + {{$arch = "arm64"}} + {{- end -}} + + {{$osString:= .OS}} + {{ if HasPrefix .OS "darwin" -}} + {{$osString = "darwin"}} + {{- else if eq .OS "linux" -}} + {{$osString = "linux"}} + {{- end -}} + {{$ext := ".tar.gz"}} + + {{.Version}}/{{.Name}}_{{.VersionNumber}}_{{$osString}}_{{$arch}}{{$ext}} + `, + }) + + tools = append(tools, + Tool{ Owner: "johanhaleby", Repo: "kubetail", Name: "kubetail", ++++++ arkade.obsinfo ++++++ --- /var/tmp/diff_new_pack.mq8tIL/_old 2026-06-23 17:43:12.039179541 +0200 +++ /var/tmp/diff_new_pack.mq8tIL/_new 2026-06-23 17:43:12.043179682 +0200 @@ -1,5 +1,5 @@ name: arkade -version: 0.11.100 -mtime: 1781629579 -commit: db87cbea02bb931f4277719629597a627c4d4b5e +version: 0.11.102 +mtime: 1782138670 +commit: 37af31c7b58de8f16a10067051e3bbe7ecb6aa79 ++++++ vendor.tar.gz ++++++
