Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion modules/har/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ require (
github.com/harness/cli/v3 v3.6.2
github.com/hashicorp/go-retryablehttp v0.7.8
github.com/jedib0t/go-pretty/v6 v6.8.3
github.com/klauspost/compress v1.19.2
github.com/oapi-codegen/runtime v1.7.0
github.com/pterm/pterm v0.12.84
github.com/rs/zerolog v1.35.1
Expand Down Expand Up @@ -65,7 +66,6 @@ require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.19.2 // indirect
github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect
github.com/lithammer/fuzzysearch v1.1.8 // indirect
github.com/lmittmann/tint v1.2.1 // indirect
Expand Down
51 changes: 43 additions & 8 deletions modules/har/pkg/har/push_conda.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package har

import (
"archive/tar"
"archive/zip"
"compress/bzip2"
"encoding/json"
"fmt"
Expand All @@ -15,6 +16,7 @@ import (
"strings"

"github.com/harness/cli/v3/pkg/cmdctx"
"github.com/klauspost/compress/zstd"
)

// condaIndexJSON holds the fields we need from info/index.json inside a conda package.
Expand All @@ -27,8 +29,8 @@ type condaIndexJSON struct {
// pushCondaArtifact implements "push artifact <registry/name> <local-file>" for conda packages.
//
// Supported formats:
// - .tar.bz2 — fully supported
// - .conda — requires zstd decompression; not yet supported (no zstd dependency in go.mod)
// - .tar.bz2
// - .conda (zip containing an info-*.tar.zst metadata archive)
//
// The upload is a raw PUT to:
//
Expand All @@ -46,11 +48,12 @@ func pushCondaArtifact(ctx *cmdctx.Ctx) error {
fileName := filepath.Base(localFile)

lower := strings.ToLower(fileName)
var readMeta func(string) (*condaIndexJSON, error)
switch {
case strings.HasSuffix(lower, ".conda"):
return fmt.Errorf("push conda: .conda format requires zstd decompression which is not yet supported; please use .tar.bz2")
readMeta = condaMetaFromConda
case strings.HasSuffix(lower, ".tar.bz2"):
// supported below
readMeta = condaMetaFromBZ2
default:
return fmt.Errorf("push conda: unsupported file extension for %q; expected .conda or .tar.bz2", fileName)
}
Expand All @@ -63,8 +66,7 @@ func pushCondaArtifact(ctx *cmdctx.Ctx) error {
return fmt.Errorf("%q is a directory; conda push requires a file", localFile)
}

// Parse metadata from .tar.bz2
meta, err := condaMetaFromBZ2(localFile)
meta, err := readMeta(localFile)
if err != nil {
return fmt.Errorf("reading conda metadata from %q: %w", localFile, err)
}
Expand Down Expand Up @@ -126,8 +128,41 @@ func condaMetaFromBZ2(filePath string) (*condaIndexJSON, error) {
}
defer f.Close()

bzReader := bzip2.NewReader(f)
tarReader := tar.NewReader(io.LimitReader(bzReader, 1<<30)) // 1 GiB safety limit
return condaMetaFromTar(bzip2.NewReader(f))
}

// condaMetaFromConda reads index.json from the info-*.tar.zst entry of a .conda package
// (a zip archive) and returns the name, version, and subdir fields.
func condaMetaFromConda(filePath string) (*condaIndexJSON, error) {
zr, err := zip.OpenReader(filePath)
if err != nil {
return nil, fmt.Errorf("opening .conda archive: %w", err)
}
defer zr.Close()

for _, entry := range zr.File {
if !strings.HasPrefix(entry.Name, "info-") || !strings.HasSuffix(entry.Name, ".tar.zst") {
continue
}
rc, err := entry.Open()
if err != nil {
return nil, fmt.Errorf("opening %s: %w", entry.Name, err)
}
defer rc.Close()

zstdReader, err := zstd.NewReader(rc)
if err != nil {
return nil, fmt.Errorf("creating zstd reader: %w", err)
}
defer zstdReader.Close()

return condaMetaFromTar(zstdReader)
}
return nil, fmt.Errorf("info-*.tar.zst not found in .conda archive")
}

func condaMetaFromTar(r io.Reader) (*condaIndexJSON, error) {
tarReader := tar.NewReader(io.LimitReader(r, 1<<30)) // 1 GiB safety limit

for {
hdr, err := tarReader.Next()
Expand Down
129 changes: 129 additions & 0 deletions modules/har/pkg/har/push_conda_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// Copyright © 2026 Harness Inc.
// SPDX-License-Identifier: Apache-2.0

package har

import (
"archive/tar"
"archive/zip"
"bytes"
"os"
"path/filepath"
"testing"

"github.com/klauspost/compress/zstd"
)

func condaTar(t *testing.T, entries map[string]string) []byte {
t.Helper()
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
for name, body := range entries {
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(body))}); err != nil {
t.Fatal(err)
}
if _, err := tw.Write([]byte(body)); err != nil {
t.Fatal(err)
}
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}

func writeCondaZip(t *testing.T, zipEntries map[string][]byte) string {
t.Helper()
path := filepath.Join(t.TempDir(), "pkg-1.0-0.conda")
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
zw := zip.NewWriter(f)
for name, data := range zipEntries {
w, err := zw.Create(name)
if err != nil {
t.Fatal(err)
}
if _, err := w.Write(data); err != nil {
t.Fatal(err)
}
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
return path
}

func zstdCompress(t *testing.T, data []byte) []byte {
t.Helper()
var buf bytes.Buffer
zw, err := zstd.NewWriter(&buf)
if err != nil {
t.Fatal(err)
}
if _, err := zw.Write(data); err != nil {
t.Fatal(err)
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}

func TestCondaMetaFromConda(t *testing.T) {
const index = `{"name":"foo","version":"1.2.3","subdir":"linux-64"}`
infoTar := zstdCompress(t, condaTar(t, map[string]string{"info/index.json": index}))
noIndexTar := zstdCompress(t, condaTar(t, map[string]string{"info/about.json": "{}"}))

tests := []struct {
name string
path string
want condaIndexJSON
wantErr bool
}{
{
name: "valid",
path: writeCondaZip(t, map[string][]byte{
"pkg-foo-1.2.3-0.tar.zst": zstdCompress(t, condaTar(t, map[string]string{"lib/x": "x"})),
"info-foo-1.2.3-0.tar.zst": infoTar,
}),
want: condaIndexJSON{Name: "foo", Version: "1.2.3", Subdir: "linux-64"},
},
{
name: "no info entry",
path: writeCondaZip(t, map[string][]byte{"pkg-foo-1.2.3-0.tar.zst": infoTar}),
wantErr: true,
},
{
name: "info tar without index.json",
path: writeCondaZip(t, map[string][]byte{"info-foo-1.2.3-0.tar.zst": noIndexTar}),
wantErr: true,
},
{
name: "not a zip",
path: func() string {
p := filepath.Join(t.TempDir(), "bad.conda")
if err := os.WriteFile(p, []byte("not a zip"), 0o644); err != nil {
t.Fatal(err)
}
return p
}(),
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := condaMetaFromConda(tt.path)
if (err != nil) != tt.wantErr {
t.Fatalf("err = %v, wantErr %v", err, tt.wantErr)
}
if err == nil && *got != tt.want {
t.Errorf("got %+v, want %+v", *got, tt.want)
}
})
}
}
Loading