From 952d3ac24ecdb44ba0ef42368bd0f7192ec68b15 Mon Sep 17 00:00:00 2001 From: Sameeksha Malav Date: Thu, 10 Sep 2026 03:05:52 +0530 Subject: [PATCH 1/2] feat(har): add execute artifact:download command --- go.work.sum | 143 ++++ modules/har/pkg/har/download_artifact.go | 430 +++++++++++++ modules/har/pkg/har/download_artifact_test.go | 609 ++++++++++++++++++ modules/har/pkg/har/har.go | 1 + pkg/spec/har.spec.yaml | 62 ++ 5 files changed, 1245 insertions(+) create mode 100644 modules/har/pkg/har/download_artifact.go create mode 100644 modules/har/pkg/har/download_artifact_test.go diff --git a/go.work.sum b/go.work.sum index c984b4e..81f7d6c 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1,38 +1,65 @@ +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= charm.land/bubbles/v2 v2.1.1/go.mod h1:GE6M31gaWZVXzGw73OeuTTgy4lX+OtkH0E5ymnNsHxo= charm.land/bubbletea/v2 v2.0.8 h1:SxTJMhCAI3lbPmy4SgX5LWZ24AdINr4I6UEqzZvYJuY= charm.land/bubbletea/v2 v2.0.8/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss= +cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU= cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= github.com/AdamKorcz/go-118-fuzz-build v0.0.0-20230306123547-8075edf89bb0/go.mod h1:OahwfttHWG6eJ0clwcfBAHoDI6X/LV/15hx/wlMZSrU= +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53 h1:sR+/8Yb4slttB4vD+b9btVEnWgL3Q00OBTzVT8B9C0c= github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53/go.mod h1:+3IMCy2vIlbG1XG/0ggNQv0SvxCAIpPM5b1nCz56Xno= +github.com/CloudyKit/jet/v6 v6.2.0 h1:EpcZ6SR9n28BUGtNJSvlBqf90IpjeFr36Tizxhn/oME= github.com/CloudyKit/jet/v6 v6.2.0/go.mod h1:d3ypHeIRNo2+XyqnGA8s+aphtcVpjP5hPwP/Lzo7Ro4= +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= +github.com/Joker/jade v1.1.3 h1:Qbeh12Vq6BxURXT1qZBRHsDxeURB8ztcL6f3EXSGeHk= github.com/Joker/jade v1.1.3/go.mod h1:T+2WLyt7VH6Lp0TRxQrUYEs64nRc83wkMQrfeIQKduM= +github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= +github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= +github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= +github.com/Masterminds/vcs v1.13.3 h1:IIA2aBdXvfbIM+yl/eTnL4hb1XwdpvuQLglAix1gweE= github.com/Masterminds/vcs v1.13.3/go.mod h1:TiE7xuEjl1N4j016moRd6vezp6e6Lz23gypeXfzXeW8= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Microsoft/hcsshim v0.11.7/go.mod h1:MV8xMfmECjl5HdO7U/3/hFVnkmSBjAjmA09d4bExKcU= +github.com/NYTimes/gziphandler v1.1.1 h1:ZUDjpQae29j0ryrS0u/B8HZfJBtBQHjqw2rQ2cqUQ3I= github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c= +github.com/RaveNoX/go-jsoncommentstrip v1.0.0 h1:t527LHHE3HmiHrq74QMpNPZpGCIJzTx+apLkMKt4HC0= +github.com/Shopify/goreferrer v0.0.0-20220729165902-8cddb4f5de06 h1:KkH3I3sJuOLP3TjA/dfr4NAY8bghDwnXiU7cTKxQqo0= github.com/Shopify/goreferrer v0.0.0-20220729165902-8cddb4f5de06/go.mod h1:7erjKLwalezA0k99cWs5L11HWOAPNjdUZ6RxH1BXbbM= +github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/atomicgo/cursor v0.0.1 h1:xdogsqa6YYlLfM+GyClC/Lchf7aiMerFiZQn7soTOoU= github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/bits-and-blooms/bitset v1.24.6 h1:qcrftZUVBIwfs+m+nhoCBAPT+ZPZZjti8SbHbDQQkZ4= github.com/bits-and-blooms/bitset v1.24.6/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/bmatcuk/doublestar v1.1.1 h1:YroD6BJCZBYx06yYFEWvUuKVWQn3vLLQAVmDmvTSaiQ= +github.com/bwesterb/go-ristretto v1.2.3 h1:1w53tCkGhCQ5djbat3+MH0BAQ5Kfgbt56UZQ/JMzngw= github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= +github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318/go.mod h1:Y6kE2GzHfkyQQVCSL9r2hwokSrIlHGzZG+71+wDYSZI= github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7/go.mod h1:f/jRa757WUmaOZrbPspXymbg/GnbF+rwe4OLsG7aXYo= @@ -48,9 +75,12 @@ github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObk github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= github.com/cilium/ebpf v0.9.1/go.mod h1:+OhNOIXx/Fnu1IE8bJz2dzOA+VSfyTfdNUVdlQnxUFY= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= github.com/containerd/aufs v1.0.0/go.mod h1:kL5kd6KM5TzQjR79jljyi4olc1Vrx6XBlcyj3gNv2PU= @@ -61,6 +91,7 @@ github.com/containerd/containerd/api v1.8.0/go.mod h1:dFv4lt6S20wTu/hMcP4350RL87 github.com/containerd/continuity v0.4.4/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/containerd/fifo v1.1.0/go.mod h1:bmC4NWMbXlt2EZ0Hc7Fx7QzTFxgPID13eH0Qu+MAb2o= github.com/containerd/go-cni v1.1.9/go.mod h1:XYrZJ1d5W6E2VOvjffL3IZq0Dz6bsVlERHbekNK90PM= @@ -74,116 +105,178 @@ github.com/containerd/zfs v1.1.0/go.mod h1:oZF9wBnrnQjpWLaPKEinrx3TQ9a+W/RJO7Zb4 github.com/containernetworking/cni v1.1.2/go.mod h1:sDpYKmGVENF3s6uvMvGgldDWeG8dMxakj/u+i9ht9vw= github.com/containernetworking/plugins v1.2.0/go.mod h1:/VjX4uHecW5vVimFa1wkG4s+r/s9qIfPdqlLF4TW8c4= github.com/containers/ocicrypt v1.1.10/go.mod h1:YfzSSr06PTHQwSTUKqDSjish9BeW1E4HUmreluQcMd8= +github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4= github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec= +github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= +github.com/cyphar/filepath-securejoin v0.7.0 h1:s0Y3ITPy6sQn5xt54DuYvTF8hu134ooYLUb58DX/HjE= github.com/cyphar/filepath-securejoin v0.7.0/go.mod h1:ymLGms/u3BYaviIiuKFnUx8EkQEZeK6cInNoAPJA3o4= github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= +github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= github.com/docker/cli v29.6.2+incompatible h1:/bjePvcbbFTnRrMfWJBY7AjfICdsiLVgHn6LwTVOcqw= github.com/docker/cli v29.6.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/docker v28.2.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= +github.com/fatih/camelcase v1.0.0 h1:hxNvNX/xYBp0ovncs8WyWZrOrpBNub/JfaMvbURyft8= github.com/fatih/camelcase v1.0.0/go.mod h1:yN2Sb0lFhZJUdVvtELVWefmrXpuZESvPmqwoZc+/fpc= +github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw= +github.com/felixge/fgprof v0.9.5 h1:8+vR6yu2vvSKn08urWyEuxx75NWPEvybbkBirEpsbVY= github.com/felixge/fgprof v0.9.5/go.mod h1:yKl+ERSa++RYOs32d8K6WEXCB4uXdLls4ZaZPpayhMM= +github.com/flosch/pongo2/v4 v4.0.2 h1:gv+5Pe3vaSVmiJvh/BZa82b7/00YUGm0PIyVVLop0Hw= github.com/flosch/pongo2/v4 v4.0.2/go.mod h1:B5ObFANs/36VwxxlgKpdchIJHMvHB562PW+BWPhwZD8= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-gorp/gorp/v3 v3.1.0 h1:ItKF/Vbuj31dmV4jxA1qblpSwkl9g1typ24xoe70IGs= github.com/go-gorp/gorp/v3 v3.1.0/go.mod h1:dLEjIyyRNiXvNZ8PSmzpt1GsWAUK8kjVhEpjH8TixEw= github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= github.com/gobwas/ws v1.2.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/gomarkdown/markdown v0.0.0-20240328165702-4d01890c35c0 h1:4gjrh/PN2MuWCCElk8/I4OCKRKWCCo2zEct3VKCbibU= github.com/gomarkdown/markdown v0.0.0-20240328165702-4d01890c35c0/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= +github.com/google/cel-go v0.26.0 h1:DPGjXackMpJWH680oGY4lZhYjIameYmR+/6RBdDGmaI= github.com/google/cel-go v0.26.0/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-containerregistry v0.21.9 h1:F+D4uZ3iA3DLMJLfhaqMdHJbzeqm/216WGQq2dokuLs= github.com/google/go-containerregistry v0.21.9/go.mod h1:dP5XNKcL7kMFF/TB3LfvWmVhAcv7iqkHb3oDK8aauTo= +github.com/google/gofuzz v1.0.0 h1:A8PeW59pxE9IoFRqBp37U+mSNaQoZ46F1f0f863XSXw= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg= github.com/google/pprof v0.0.0-20240227163752-401108e1b7e7/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik= +github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= +github.com/gosuri/uitable v0.0.4 h1:IG2xLKRvErL3uhY6e1BylFzG+aJiwQviDDTfOKeKTpY= github.com/gosuri/uitable v0.0.4/go.mod h1:tKR86bXuXPZazfOTG1FIzvjIdXzd0mo4Vtn16vt0PJo= github.com/grpc-ecosystem/go-grpc-middleware v1.3.0/go.mod h1:z0ButlSOZa5vEBq9m2m2hlwIgKw+rp3sdCBRoJY+30Y= github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.1/go.mod h1:lXGCsh6c22WGtjr+qGHj1otzZpV/1kwTMAqkwZsnWRU= +github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 h1:QGLs/O40yoNK9vmy4rhUGBVyMf1lISBGtXRpsu/Qu/o= github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0/go.mod h1:hM2alZsMUni80N33RBe6J0e423LB+odMj7d3EMP9l20= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.0/go.mod h1:qOchhhIlmRcqk/O9uCo/puJlyo07YINaIqdZfZG3Jkc= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 h1:B+8ClL/kCQkRiU82d9xajRPKYMrB7E0MbtzWVi1K4ns= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3/go.mod h1:NbCUVmiS4foBGBHOYlCT25+YmGpJ32dZPi75pGEUpj4= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= +github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/ianlancetaylor/demangle v0.0.0-20210905161508-09a460cdf81d/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w= github.com/ianlancetaylor/demangle v0.0.0-20230524184225-eabc099b10ab/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= github.com/ianlancetaylor/demangle v0.0.0-20240312041847-bd984b5ce465/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= github.com/intel/goresctrl v0.5.0/go.mod h1:mIe63ggylWYr0cU/l8n11FAkesqfvuP3oktIsxvu0T0= +github.com/iris-contrib/schema v0.0.6 h1:CPSBLyx2e91H2yJzPuhGuifVRnZBBJ3pCOMbOvPZaTw= github.com/iris-contrib/schema v0.0.6/go.mod h1:iYszG0IOsuIsfzjymw1kMzTL8YQcCWlm65f3wX8J5iA= +github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= +github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d h1:c93kUJDtVAXFEhsCh5jSxyOJmFHuzcihnslQiX8Urwo= +github.com/kataras/blocks v0.0.8 h1:MrpVhoFTCR2v1iOOfGng5VJSILKeZZI+7NGfxEh3SUM= github.com/kataras/blocks v0.0.8/go.mod h1:9Jm5zx6BB+06NwA+OhTbHW1xkMOYxahnqTN5DveZ2Yg= +github.com/kataras/golog v0.1.11 h1:dGkcCVsIpqiAMWTlebn/ZULHxFvfG4K43LF1cNWSh20= github.com/kataras/golog v0.1.11/go.mod h1:mAkt1vbPowFUuUGvexyQ5NFW6djEgGyxQBIARJ0AH4A= +github.com/kataras/iris/v12 v12.2.11 h1:sGgo43rMPfzDft8rjVhPs6L3qDJy3TbBrMD/zGL1pzk= github.com/kataras/iris/v12 v12.2.11/go.mod h1:uMAeX8OqG9vqdhyrIPv8Lajo/wXTtAF43wchP9WHt2w= +github.com/kataras/pio v0.0.13 h1:x0rXVX0fviDTXOOLOmr4MUxOabu1InVSTu5itF8CXCM= github.com/kataras/pio v0.0.13/go.mod h1:k3HNuSw+eJ8Pm2lA4lRhg3DiCjVgHlP8hmXApSej3oM= +github.com/kataras/sitemap v0.0.6 h1:w71CRMMKYMJh6LR2wTgnk5hSgjVNB9KL60n5e2KHvLY= github.com/kataras/sitemap v0.0.6/go.mod h1:dW4dOCNs896OR1HmG+dMLdT7JjDk7mYBzoIRwuj5jA4= +github.com/kataras/tunnel v0.0.4 h1:sCAqWuJV7nPzGrlb0os3j49lk2JhILT0rID38NHNLpA= github.com/kataras/tunnel v0.0.4/go.mod h1:9FkU4LaeifdMWqZu7o20ojmW4B7hdhv2CMLwfnHGpYw= +github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/kr/pty v1.1.1 h1:VkoXIwSboBpnk99O/KFauAEILuNHv5DVFKZMBN/gUgw= +github.com/labstack/echo/v4 v4.15.1 h1:S9keusg26gZpjMmPqB5hOEvNKnmd1lNmcHrbbH2lnFs= github.com/labstack/echo/v4 v4.15.1/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw= github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= +github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk= github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +github.com/lithammer/dedent v1.1.0 h1:VNzHMVCBNG1j0fh3OrsFRkVUwStdDArbgBWoPAffktY= github.com/lithammer/dedent v1.1.0/go.mod h1:jrXYCQtgg0nJiN+StA2KgR7w6CiQNv9Fd/Z9BP0jIOc= github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= +github.com/mailgun/raymond/v2 v2.0.48 h1:5dmlB680ZkFG2RN/0lvTAghrSxIESeu9/2aeDqACtjw= github.com/mailgun/raymond/v2 v2.0.48/go.mod h1:lsgvL50kgt1ylcFJYZiULi5fjPBkkhNfj4KA0W54Z18= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= +github.com/mattn/go-shellwords v1.0.13 h1:DC0OMEpGjm6LfNFU4ckYcvbQKyp2vE8atyFGXNtDcf4= github.com/mattn/go-shellwords v1.0.13/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58= github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs= github.com/miekg/pkcs11 v1.1.1/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs= github.com/mistifyio/go-zfs/v3 v3.0.1/go.mod h1:CzVgeB0RvF2EGzQnytKVvVSDwmKJXxkOTUGbNrTja/k= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/locker v1.0.1/go.mod h1:S7SDdo5zpBK84bzzVlKr2V0hz+7x9hWbYC/kq7oQppc= +github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc= github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.5.1 h1:tYNaJno4c0HXz12y5BiqEDy0rVTYkWzI26lGvnTMiJw= github.com/moby/moby/client v0.5.1/go.mod h1:odLstlZ6uSnfvAgVxMpvgmb8SUdd+siH2T0GBuxVAlM= github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= +github.com/moby/spdystream v0.5.1 h1:9sNYeYZUcci9R6/w7KDaFWEWeV4LStVG78Mpyq/Zm/Y= github.com/moby/spdystream v0.5.1/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= github.com/moby/sys/mountinfo v0.6.2/go.mod h1:IJb6JQeOklcdMU9F5xQ8ZALD+CUr5VlGpwtX+VE0rpI= @@ -193,6 +286,7 @@ github.com/moby/sys/symlink v0.2.0/go.mod h1:7uZVF2dqJjG/NsClqul95CqKOBRQyYSNnJ6 github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= github.com/oapi-codegen/runtime v1.6.0 h1:7Xx+GlueD6nRuyKoCPzL434Jfi3BetbiJOrzCHp/VPU= github.com/oapi-codegen/runtime v1.6.0/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU= @@ -201,60 +295,95 @@ github.com/opencontainers/runtime-tools v0.9.1-0.20221107090550-2e043c6bd626/go. github.com/opencontainers/selinux v1.13.1/go.mod h1:S10WXZ/osk2kWOYKy1x2f/eXF5ZHJoUs8UU/2caNRbg= github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA= github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= +github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rubenv/sql-migrate v1.8.1 h1:EPNwCvjAowHI3TnZ+4fQu3a915OpnQoPAjTXCGOy2U0= github.com/rubenv/sql-migrate v1.8.1/go.mod h1:BTIKBORjzyxZDS6dzoiw6eAFYJ1iNlGAtjn4LGeVjS8= github.com/russross/blackfriday v1.6.0 h1:KqfZb0pUVN2lYqZUYRddxF4OR8ZMURnJIG5Y3VRLtww= github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY= +github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4= github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/schollz/closestmatch v2.1.0+incompatible h1:Uel2GXEpJqOWBrlyI+oY9LTiyyjYS17cCYRqP13/SHk= github.com/schollz/closestmatch v2.1.0+incompatible/go.mod h1:RtP1ddjLong6gTkbtmuhtR2uUrrJOpYzYRvbcPAid+g= github.com/segmentio/conf v1.2.0/go.mod h1:Y3B9O/PqqWqjyxyWWseyj/quPEtMu1zDp/kVbSWWaB0= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/soheilhy/cmux v0.1.5 h1:jjzc5WVemNEDTLwv9tlmemhC73tI08BNOIGwBOo10Js= github.com/soheilhy/cmux v0.1.5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE1GqG0= +github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= +github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad h1:fiWzISvDn0Csy5H0iwgAuJGQTUpVfEMJJd4nRFXogbc= github.com/stefanberger/go-pkcs11uri v0.0.0-20230803200340-78284954bff6/go.mod h1:39R/xuhNgVhi+K0/zst4TLrJrVmbm6LVgl4A0+ZFS5M= +github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww= github.com/tchap/go-patricia/v2 v2.3.1/go.mod h1:VZRHKAb53DLaG+nA9EaYYiaEx6YztwDlLElMsnSHD4k= +github.com/tdewolff/minify/v2 v2.20.19 h1:tX0SR0LUrIqGoLjXnkIzRSIbKJ7PaNnSENLD4CyH6Xo= github.com/tdewolff/minify/v2 v2.20.19/go.mod h1:ulkFoeAVWMLEyjuDz1ZIWOA31g5aWOawCFRp9R/MudM= +github.com/tdewolff/parse/v2 v2.7.12 h1:tgavkHc2ZDEQVKy1oWxwIyh5bP4F5fEh/JmBwPP/3LQ= github.com/tdewolff/parse/v2 v2.7.12/go.mod h1:3FbJWZp3XT9OWVN3Hmfp0p/a08v4h8J9W1aghka0soA= +github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75 h1:6fotK7otjonDflCTK0BCfls4SPy3NcCVb5dqqmbRknE= github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75/go.mod h1:KO6IkyS8Y3j8OdNO85qEYBsRPuteD+YciPomcXdrMnk= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/urfave/cli v1.22.16/go.mod h1:EeJR6BKodywf4zciqrdw6hpCPk68JO9z5LazXZMn5Po= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= github.com/vishvananda/netlink v1.2.1-beta.2/go.mod h1:twkDnbuQxJYemMlGd4JFIcuhgX83tXhKS2B/PRMpOho= github.com/vishvananda/netns v0.0.0-20210104183010-2eb08e3e575f/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= +github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +github.com/xiang90/probing v0.0.0-20221125231312-a49e3df8f510 h1:S2dVYn90KE98chqDkyE9Z4N61UnQd+KOfgp5Iu53llk= github.com/xiang90/probing v0.0.0-20221125231312-a49e3df8f510/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= +github.com/yosssi/ace v0.0.5 h1:tUkIP/BLdKqrlrPwcmH0shwEEhTRHoGnc1wFIWmaBUA= github.com/yosssi/ace v0.0.5/go.mod h1:ALfIzm2vT7t5ZE7uoIZqF3TQ7SAOyupFZnkrF5id+K0= +github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= +go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= go.etcd.io/etcd/api/v3 v3.6.5/go.mod h1:ob0/oWA/UQQlT1BmaEkWQzI0sJ1M0Et0mMpaABxguOQ= +go.etcd.io/etcd/api/v3 v3.6.8 h1:gqb1VN92TAI6G2FiBvWcqKtHiIjr4SU2GdXxTwyexbM= go.etcd.io/etcd/api/v3 v3.6.8/go.mod h1:qyQj1HZPUV3B5cbAL8scG62+fyz5dSxxu0w8pn28N6Q= go.etcd.io/etcd/client/pkg/v3 v3.6.5/go.mod h1:8Wx3eGRPiy0qOFMZT/hfvdos+DjEaPxdIDiCDUv/FQk= +go.etcd.io/etcd/client/pkg/v3 v3.6.8 h1:Qs/5C0LNFiqXxYf2GU8MVjYUEXJ6sZaYOz0zEqQgy50= go.etcd.io/etcd/client/pkg/v3 v3.6.8/go.mod h1:GsiTRUZE2318PggZkAo6sWb6l8JLVrnckTNfbG8PWtw= go.etcd.io/etcd/client/v3 v3.6.5/go.mod h1:ZqwG/7TAFZ0BJ0jXRPoJjKQJtbFo/9NIY8uoFFKcCyo= +go.etcd.io/etcd/client/v3 v3.6.8 h1:B3G76t1UykqAOrbio7s/EPatixQDkQBevN8/mwiplrY= go.etcd.io/etcd/client/v3 v3.6.8/go.mod h1:MVG4BpSIuumPi+ELF7wYtySETmoTWBHVcDoHdVupwt8= go.etcd.io/etcd/pkg/v3 v3.6.5/go.mod h1:uqrXrzmMIJDEy5j00bCqhVLzR5jEJIwDp5wTlLwPGOU= +go.etcd.io/etcd/pkg/v3 v3.6.8 h1:Xe+LIL974spy8b4nEx3H0KMr1ofq3r0kh6FbU3aw4es= go.etcd.io/etcd/pkg/v3 v3.6.8/go.mod h1:TRibVNe+FqJIe1abOAA1PsuQ4wqO87ZaOoprg09Tn8c= go.etcd.io/etcd/server/v3 v3.6.5/go.mod h1:PLuhyVXz8WWRhzXDsl3A3zv/+aK9e4A9lpQkqawIaH0= +go.etcd.io/etcd/server/v3 v3.6.8 h1:U2strdSEy1U8qcSzRIdkYpvOPtBy/9i/IfaaCI9flZ4= go.etcd.io/etcd/server/v3 v3.6.8/go.mod h1:88dCtwUnSirkUoJbflQxxWXqtBSZa6lSG0Kuej+dois= +go.etcd.io/raft/v3 v3.6.0 h1:5NtvbDVYpnfZWcIHgGRk9DyzkBIXOi8j+DDp1IcnUWQ= go.etcd.io/raft/v3 v3.6.0/go.mod h1:nLvLevg6+xrVtHUmVaTcTz603gQPHfh7kUAwV6YpfGo= go.mozilla.org/pkcs7 v0.0.0-20200128120323-432b2356ecb1/go.mod h1:SNgMg+EgDFwmvSmLRTNKC5fegJjB7v23qTQ0XLGUNHk= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0 h1:XmiuHzgJt067+a6kwyAzkhXooYVv3/TOw9cM2VfJgUM= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0/go.mod h1:KDgtbWKTQs4bM+VPUr6WlL9m/WXcmkCcBlIzqxPGzmI= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= @@ -283,30 +412,44 @@ golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/tools/go/expect v0.1.0-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= +golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7 h1:9zdDQZ7Thm29KFXgAX/+yaf3eVbP7djjWp/dXAppNCc= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto v0.0.0-20231211222908-989df2bf70f3/go.mod h1:5RBcpGRxr25RbDzY5w+dmaqpSEvl8Gwl1x2CICf60ic= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= helm.sh/helm/v3 v3.21.3 h1:wkamdwI3liEkW6wI1l9aGqQZGxcTKyt8kx0qJLPcmCg= helm.sh/helm/v3 v3.21.3/go.mod h1:iaJ0iNsPoTZl++7h6vzQFyT0VEVtLYJiyRBDkPOOBTs= k8s.io/apiserver v0.35.1/go.mod h1:BiL6Dd3A2I/0lBnteXfWmCFobHM39vt5+hJQd7Lbpi4= +k8s.io/apiserver v0.36.2 h1:6vMnkmHZPeBloNkHUhmZYq7Ylv8WIB8xjyEl+eSt26E= k8s.io/apiserver v0.36.2/go.mod h1:9PoQ2ikCytrZyZg11mGhLEF5m8Rgsb5FJmYJ4Wvnl1k= k8s.io/code-generator v0.35.1/go.mod h1:F2Fhm7aA69tC/VkMXLDokdovltXEF026Tb9yfQXQWKg= +k8s.io/code-generator v0.36.2 h1:iBNFYhClojQaVrF99Z3iTAad7LztQh3yCtwR8L8Ocpg= k8s.io/code-generator v0.36.2/go.mod h1:IfnsRW1IAq9iPxqs/FfOnVnWWONxS2mPDvWNR4fPlzI= k8s.io/component-helpers v0.35.1/go.mod h1:HQqMwUk68Yyxgj92dJ+J1w/qbx9M0QR0eZ680m/o+Rk= +k8s.io/component-helpers v0.36.2 h1:YsqocS183ThSUw90OXsxkKxIgdQF4qWInwrn6pZdDH8= k8s.io/component-helpers v0.36.2/go.mod h1:YrHgzezjsyXAFq9+gKw6IbgJg7IHEUVwK41eEAiTRR4= k8s.io/cri-api v0.27.1/go.mod h1:+Ts/AVYbIo04S86XbTD73UPp/DkTiYxtsFeOFEu32L0= +k8s.io/gengo/v2 v2.0.0-20250922181213-ec3ebc5fd46b h1:gMplByicHV/TJBizHd9aVEsTYoJBnnUAT5MHlTkbjhQ= k8s.io/gengo/v2 v2.0.0-20250922181213-ec3ebc5fd46b/go.mod h1:CgujABENc3KuTrcsdpGmrrASjtQsWCT7R99mEV4U/fM= k8s.io/kms v0.35.1/go.mod h1:VT+4ekZAdrZDMgShK37vvlyHUVhwI9t/9tvh0AyCWmQ= +k8s.io/kms v0.36.2 h1:o0l8zMRecm38k5NyRnO/3+2YA/MR6TMGcc3KQZA76hI= k8s.io/kms v0.36.2/go.mod h1:g91diTD9h0oJCCHkTb00krlF+Qm5HTnkWLi9Q/TpRoc= k8s.io/metrics v0.35.1/go.mod h1:9x7xWOAOiWzHA0vaqLgSE4PXF3vyT5ts5XIbx8OSjiI= +k8s.io/metrics v0.36.2 h1:yfUIe2Vwx2cQAIpVYcin1JXdabrRz98oTxP2HJTxHj8= k8s.io/metrics v0.36.2/go.mod h1:Q/dNyLLzgSxPu0/e+996Du4pjutfEyyHOKgK0lkncp0= +k8s.io/streaming v0.36.2 h1:NSKthPPg9UFSKsRauVJUVGH2Dvn8fhKmY4qrMkw/p98= k8s.io/streaming v0.36.2/go.mod h1:z6fV3D+NVkoeqRMtWwlUZK6U17SY/LqNzOxWL6GyR/s= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= +sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.34.0 h1:hSfpvjjTQXQY2Fol2CS0QHMNs/WI1MOSGzCm1KhM5ec= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.34.0/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= sigs.k8s.io/kustomize/kustomize/v5 v5.7.1/go.mod h1:+5/SrBcJ4agx1SJknGuR/c9thwRSKLxnKoI5BzXFaLU= +sigs.k8s.io/kustomize/kustomize/v5 v5.8.1 h1:Pgsg5psubpVEy7Nf6S89PARg5VmmWUC1l9dC6Dl4PG0= sigs.k8s.io/kustomize/kustomize/v5 v5.8.1/go.mod h1:0vFa5pQ/elNEQMyiAJuGku9rhAMzz7u9+61hRqFKiwY= sigs.k8s.io/structured-merge-diff/v4 v4.2.3/go.mod h1:qjx8mGObPmV2aSZepjQjbmb2ihdVs8cGKBraizNC69E= tags.cncf.io/container-device-interface v0.8.1/go.mod h1:Apb7N4VdILW0EVdEMRYXIDVRZfNJZ+kmEUss2kRRQ6Y= diff --git a/modules/har/pkg/har/download_artifact.go b/modules/har/pkg/har/download_artifact.go new file mode 100644 index 0000000..d2eac2c --- /dev/null +++ b/modules/har/pkg/har/download_artifact.go @@ -0,0 +1,430 @@ +// Copyright © 2026 Harness Inc. +// SPDX-License-Identifier: Apache-2.0 + +package har + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "os/signal" + "path/filepath" + "strconv" + "strings" + "sync" + "sync/atomic" + "syscall" + + "github.com/harness/cli/v3/pkg/auth" + "github.com/harness/cli/v3/pkg/cmdctx" +) + +const executeArtifactDownloadHandlerID = "execute_artifact_download" + +const ( + downloadDefaultConcurrency = 4 + downloadDefaultPageSize = 100 +) + +// fileSearchRequest is the POST body for /har/api/v3/files/search. +// Regex is a POSIX ERE evaluated server-side by Postgres (`~`, case-sensitive). +type fileSearchRequest struct { + Regex string `json:"regex"` + Registry string `json:"registry"` +} + +// fileSearchResponse mirrors ListFilesResponse: items and hasMore are top-level, +// not wrapped in a "data" envelope. +type fileSearchResponse struct { + Items []struct { + Name string `json:"name"` + Path string `json:"path"` + DownloadUrl *string `json:"downloadUrl"` + Size string `json:"size"` + } `json:"items"` + HasMore bool `json:"hasMore"` + Page int64 `json:"page"` + Size int64 `json:"size"` +} + +// fileItem is one result from the HAR file search API. +type fileItem struct { + Path string // registry-relative artifact path + DownloadURL string // direct URL from the API; empty means item is not downloadable + Size string +} + +func executeArtifactDownloadHandler(ctx *cmdctx.Ctx) error { + registry := cmdctx.GetString(ctx.FlagValues, "registry") + regex := cmdctx.GetString(ctx.FlagValues, "regex") + dest := cmdctx.GetString(ctx.FlagValues, "dest") + dryRun := cmdctx.GetBool(ctx.FlagValues, "dry-run") + overwrite := cmdctx.GetBool(ctx.FlagValues, "overwrite") + flatten := cmdctx.GetBool(ctx.FlagValues, "flatten") + concurrencyStr := cmdctx.GetString(ctx.FlagValues, "concurrency") + pageSizeStr := cmdctx.GetString(ctx.FlagValues, "page-size") + + concurrency := downloadDefaultConcurrency + if concurrencyStr != "" { + n, err := strconv.Atoi(concurrencyStr) + if err != nil || n < 1 { + return fmt.Errorf("invalid --concurrency %q: must be a positive integer", concurrencyStr) + } + concurrency = n + } + + pageSize := downloadDefaultPageSize + if pageSizeStr != "" { + n, err := strconv.Atoi(pageSizeStr) + if err != nil || n < 1 || n > 100 { + return fmt.Errorf("invalid --page-size %q: must be an integer between 1 and 100", pageSizeStr) + } + pageSize = n + } + + bgCtx, cancel := context.WithCancel(context.Background()) + defer cancel() + sigs := make(chan os.Signal, 1) + signal.Notify(sigs, os.Interrupt, syscall.SIGTERM) + defer signal.Stop(sigs) + go func() { + select { + case <-sigs: + fmt.Fprintln(os.Stderr, "\nInterrupted. Shutting down gracefully...") + cancel() + case <-bgCtx.Done(): + } + }() + + allFiles, err := artifactSearchFiles(bgCtx, ctx.Auth, regex, registry, pageSize) + if err != nil { + return fmt.Errorf("searching files in %q: %w", registry, err) + } + + if len(allFiles) == 0 { + fmt.Println("No files matched.") + return nil + } + + // Pre-filter: skip items with no download URL and items whose path would + // escape dest (path-traversal guard). + downloadable := make([]fileItem, 0, len(allFiles)) + skippedNoURL, skippedUnsafePath := 0, 0 + for _, item := range allFiles { + if item.DownloadURL == "" { + skippedNoURL++ + continue + } + pathForCheck := item.Path + if flatten { + pathForCheck = filepath.Base(item.Path) + } + if !isWithinDest(dest, pathForCheck) { + skippedUnsafePath++ + continue + } + downloadable = append(downloadable, item) + } + + if len(downloadable) == 0 { + return fmt.Errorf("no downloadable files: %d missing download URL, %d rejected as unsafe path", + skippedNoURL, skippedUnsafePath) + } + + // Flatten collision detection. + // Dry-run: report collisions as a warning then show the manifest (no abort). + // Real run: abort before any download starts. + var collisions []string + if flatten { + paths := make([]string, len(downloadable)) + for i, item := range downloadable { + paths[i] = item.Path + } + collisions = flattenCollisions(paths) + } + + if dryRun { + fmt.Printf("Dry run — %d file(s) matched", len(downloadable)) + if skippedNoURL > 0 || skippedUnsafePath > 0 { + fmt.Printf(" (%d no-URL, %d unsafe-path skipped)", skippedNoURL, skippedUnsafePath) + } + fmt.Println(":") + for _, item := range downloadable { + fmt.Printf(" %s → %s\n", item.Path, resolveOutPath(dest, item.Path, flatten)) + } + if len(collisions) > 0 { + fmt.Fprintf(os.Stderr, "\nWarning — %d flatten collision(s) (real run would abort):\n", len(collisions)) + for _, c := range collisions { + fmt.Fprintln(os.Stderr, " "+c) + } + } + return nil + } + + if len(collisions) > 0 { + fmt.Fprintln(os.Stderr, "Flatten name collisions — multiple paths share the same basename:") + for _, c := range collisions { + fmt.Fprintln(os.Stderr, " "+c) + } + return fmt.Errorf("use a narrower --regex or remove --flatten to resolve %d collision(s)", len(collisions)) + } + + if err := os.MkdirAll(dest, 0755); err != nil { + return fmt.Errorf("creating destination directory %q: %w", dest, err) + } + + fmt.Fprintf(os.Stderr, "Downloading %d file(s) to %q...\n", len(downloadable), dest) + + var ( + sem = make(chan struct{}, concurrency) + wg sync.WaitGroup + mu sync.Mutex + dlErrors []string + downloaded int64 + skipped int64 + ) + + dlClient := newDownloadHTTPClient() + +loop: + for _, item := range downloadable { + select { + case <-bgCtx.Done(): + break loop + default: + } + select { + case sem <- struct{}{}: + case <-bgCtx.Done(): + break loop + } + wg.Add(1) + go func(it fileItem) { + defer wg.Done() + defer func() { <-sem }() + + outPath := resolveOutPath(dest, it.Path, flatten) + + if !overwrite { + if _, statErr := os.Stat(outPath); statErr == nil { + fmt.Fprintf(os.Stderr, " skip (exists) %s\n", it.Path) + atomic.AddInt64(&skipped, 1) + return + } + } + + if dlErr := artifactDownloadFile(bgCtx, dlClient, ctx.Auth, it.DownloadURL, outPath); dlErr != nil { + mu.Lock() + dlErrors = append(dlErrors, fmt.Sprintf("%s: %v", it.Path, dlErr)) + mu.Unlock() + return + } + atomic.AddInt64(&downloaded, 1) + }(item) + } + wg.Wait() + + dl := atomic.LoadInt64(&downloaded) + sk := atomic.LoadInt64(&skipped) + fmt.Fprintf(os.Stderr, "Done. Downloaded: %d, Skipped: %d, Failed: %d", dl, sk, int64(len(dlErrors))) + if skippedNoURL > 0 || skippedUnsafePath > 0 { + fmt.Fprintf(os.Stderr, " (%d no-URL, %d unsafe-path not attempted)", skippedNoURL, skippedUnsafePath) + } + fmt.Fprintln(os.Stderr) + + if len(dlErrors) > 0 { + fmt.Fprintln(os.Stderr, "Errors:") + for _, e := range dlErrors { + fmt.Fprintln(os.Stderr, " "+e) + } + return fmt.Errorf("%d file(s) failed to download", len(dlErrors)) + } + return nil +} + +// resolveOutPath returns the filesystem path under destDir for an artifact. +// With flatten, the registry sub-path is collapsed to just the filename. +func resolveOutPath(destDir, artifactPath string, flatten bool) string { + rel := filepath.FromSlash(artifactPath) + if flatten { + rel = filepath.Base(rel) + } + return filepath.Join(destDir, rel) +} + +// isWithinDest guards against path-traversal payloads such as "../../etc/passwd". +func isWithinDest(destDir, registryPath string) bool { + destPath := filepath.Join(destDir, filepath.FromSlash(registryPath)) + rel, err := filepath.Rel(destDir, destPath) + if err != nil { + return false + } + return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} + +// artifactSearchFiles pages through the HAR file search API and returns +// matched artifacts with their direct download URLs. +func artifactSearchFiles(ctx context.Context, a *auth.ResolvedAuth, regex, registry string, pageSize int) ([]fileItem, error) { + apiBase := strings.TrimRight(a.APIUrl, "/") + + body, err := json.Marshal(fileSearchRequest{Regex: regex, Registry: registry}) + if err != nil { + return nil, fmt.Errorf("encoding search request: %w", err) + } + + var all []fileItem + page := 0 + client := newHTTPClient() + + for { + u, err := url.Parse(apiBase + "/har/api/v3/files/search") + if err != nil { + return nil, fmt.Errorf("parsing search URL: %w", err) + } + q := u.Query() + q.Set("account_identifier", a.AccountID) + // org_identifier and project_identifier are intentionally omitted here. + // The v3 files/search endpoint has a server-side bug where passing these + // params causes it to fail resolving the registry's parent space (it uses + // the account's internal numeric ID rather than the project's). The hc CLI + // spec includes these params, but they should be added back once the backend + // fixes the scoping logic. Without them, search falls back to account-level + // lookup which correctly finds project-scoped registries. + q.Set("page", strconv.Itoa(page)) + q.Set("size", strconv.Itoa(pageSize)) + u.RawQuery = q.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("building search request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + setAuthHeader(req, a) + + respBody, err := doRequest(client, req) + if err != nil { + return nil, fmt.Errorf("search request: %w", err) + } + + var result fileSearchResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("parsing search response: %w", err) + } + + for _, item := range result.Items { + // Path is the canonical field; fall back to Name if absent. + path := item.Path + if path == "" { + path = item.Name + } + dlURL := "" + if item.DownloadUrl != nil { + dlURL = *item.DownloadUrl + } + all = append(all, fileItem{Path: path, DownloadURL: dlURL, Size: item.Size}) + } + + // Guard on an empty page too: a server returning hasMore=true with no + // items would otherwise re-request the same page forever. + if !result.HasMore || len(result.Items) == 0 { + break + } + page++ + } + return all, nil +} + +// artifactDownloadFile downloads from downloadURL and writes atomically to outPath. +// Auth headers are stripped on cross-host redirects by newDownloadHTTPClient. +func artifactDownloadFile(ctx context.Context, client *http.Client, a *auth.ResolvedAuth, downloadURL, outPath string) error { + if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil { + return fmt.Errorf("creating directory: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil) + if err != nil { + return fmt.Errorf("building request: %w", err) + } + setAuthHeader(req, a) + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("download request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + b, _ := io.ReadAll(resp.Body) + return fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b))) + } + + tmp, err := os.CreateTemp(filepath.Dir(outPath), ".harness-download-*") + if err != nil { + return fmt.Errorf("creating temp file: %w", err) + } + tmpPath := tmp.Name() + + written, copyErr := io.Copy(tmp, resp.Body) + closeErr := tmp.Close() + if copyErr != nil { + os.Remove(tmpPath) + return fmt.Errorf("writing download: %w", copyErr) + } + if closeErr != nil { + os.Remove(tmpPath) + return fmt.Errorf("flushing download: %w", closeErr) + } + + if err := os.Rename(tmpPath, outPath); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("renaming to %q: %w", outPath, err) + } + + fmt.Fprintf(os.Stderr, " downloaded %s (%d bytes)\n", filepath.Base(outPath), written) + return nil +} + +// newDownloadHTTPClient extends newHTTPClient with a redirect policy that strips +// auth headers when a redirect changes host or scheme, preventing API tokens +// from reaching third-party storage providers such as presigned S3 URLs. +func newDownloadHTTPClient() *http.Client { + client := newHTTPClient() + client.CheckRedirect = func(req *http.Request, via []*http.Request) error { + if len(via) >= 10 { + return errors.New("stopped after 10 redirects") + } + if len(via) > 0 { + orig := via[0].URL + if !strings.EqualFold(req.URL.Host, orig.Host) || + !strings.EqualFold(req.URL.Scheme, orig.Scheme) { + req.Header.Del("x-api-key") + req.Header.Del("Authorization") + } + } + return nil + } + return client +} + +// flattenCollisions returns one descriptive string per basename shared by more +// than one path. Empty slice means no collisions. +func flattenCollisions(files []string) []string { + seen := map[string]string{} + var out []string + for _, f := range files { + base := filepath.Base(filepath.FromSlash(f)) + if first, ok := seen[base]; ok { + out = append(out, fmt.Sprintf("%q and %q both flatten to %q", first, f, base)) + seen[base] = f // advance so the next collision pairs with the most recent file + } else { + seen[base] = f + } + } + return out +} diff --git a/modules/har/pkg/har/download_artifact_test.go b/modules/har/pkg/har/download_artifact_test.go new file mode 100644 index 0000000..0e89c72 --- /dev/null +++ b/modules/har/pkg/har/download_artifact_test.go @@ -0,0 +1,609 @@ +// Copyright © 2026 Harness Inc. +// SPDX-License-Identifier: Apache-2.0 + +package har + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/harness/cli/v3/pkg/auth" + "github.com/harness/cli/v3/pkg/cmdctx" +) + +// --- Test helpers --- + +func downloadTestAuth(serverURL string) *auth.ResolvedAuth { + return &auth.ResolvedAuth{ + AuthType: auth.AuthTypePAT, + APIUrl: serverURL, + AccountID: "acct", + OrgID: "org", + ProjectID: "proj", + PATToken: "test-token", + } +} + +func downloadTestCtx(flags map[string]any, serverURL string) *cmdctx.Ctx { + return &cmdctx.Ctx{ + Context: context.Background(), + FlagValues: flags, + Auth: downloadTestAuth(serverURL), + } +} + +// newDownloadTestServer returns a server that: +// - POST /har/api/v3/files/search → JSON with each name as a file item whose +// downloadUrl points back to GET /dl/ on this same server +// - GET /dl/ → synthetic content "content:/dl/" +// +// The server is self-referential: it embeds srv.URL into search responses so +// the handler uses download URLs it can actually reach in tests. +func newDownloadTestServer(t *testing.T, names []string) *httptest.Server { + t.Helper() + + type item struct { + Name string `json:"name"` + Path string `json:"path"` + DownloadUrl string `json:"downloadUrl"` + Size string `json:"size"` + } + // Mirrors ar_v3.ListFilesResponse: items/hasMore are top-level. + type resp struct { + Items []item `json:"items"` + HasMore bool `json:"hasMore"` + Page int64 `json:"page"` + Size int64 `json:"size"` + } + + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "files/search") { + items := make([]item, len(names)) + for i, n := range names { + items[i] = item{ + Name: n, + Path: n, + DownloadUrl: srv.URL + "/dl/" + n, + Size: "100", + } + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp{Items: items, HasMore: false}) + return + } + if r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/dl/") { + _, _ = w.Write([]byte("content:" + r.URL.Path)) + return + } + http.NotFound(w, r) + })) + t.Cleanup(srv.Close) + return srv +} + +// --- search wire contract --- + +// TestArtifactSearchFiles_WireContract pins the exact request shape against +// ar_v3.SearchRegistryFilesRequest / SearchRegistryFilesV3Params. The registry +// belongs in the body, NOT in a registry_ref query param. +func TestArtifactSearchFiles_WireContract(t *testing.T) { + var gotBody map[string]any + var gotQuery url.Values + var gotPath string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotQuery = r.URL.Query() + _ = json.NewDecoder(r.Body).Decode(&gotBody) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"items":[],"hasMore":false}`)) + })) + t.Cleanup(srv.Close) + + if _, err := artifactSearchFiles(context.Background(), downloadTestAuth(srv.URL), "my.*regex", "myreg", 50); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if gotPath != "/har/api/v3/files/search" { + t.Errorf("path = %q, want %q", gotPath, "/har/api/v3/files/search") + } + if gotBody["regex"] != "my.*regex" { + t.Errorf("body.regex = %v, want %q", gotBody["regex"], "my.*regex") + } + if gotBody["registry"] != "myreg" { + t.Errorf("body.registry = %v, want %q", gotBody["registry"], "myreg") + } + for k, want := range map[string]string{ + "account_identifier": "acct", + "page": "0", + "size": "50", + } { + if got := gotQuery.Get(k); got != want { + t.Errorf("query %s = %q, want %q", k, got, want) + } + } + // org_identifier and project_identifier are intentionally not sent: the + // v3 endpoint has a server-side bug where scoped lookups fail. Ensure + // they are absent so a refactor doesn't accidentally add them back. + if gotQuery.Has("org_identifier") { + t.Error("org_identifier must not be sent until the server-side scoping bug is fixed") + } + if gotQuery.Has("project_identifier") { + t.Error("project_identifier must not be sent until the server-side scoping bug is fixed") + } + if gotQuery.Has("registry_ref") { + t.Error("registry_ref is not a parameter of this endpoint; registry goes in the body") + } + if gotQuery.Has("limit") { + t.Error("page size parameter is 'size', not 'limit'") + } +} + +// TestArtifactSearchFiles_NeverSendsOrgProject verifies that org_identifier and +// project_identifier are never sent (even when populated in auth) because the +// v3 endpoint has a server-side bug where these params cause registry lookups to +// fail. This test documents the intentional deviation from the OpenAPI spec until +// the backend fix lands. +func TestArtifactSearchFiles_NeverSendsOrgProject(t *testing.T) { + var gotQuery url.Values + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotQuery = r.URL.Query() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"items":[],"hasMore":false}`)) + })) + t.Cleanup(srv.Close) + + a := &auth.ResolvedAuth{ + AuthType: auth.AuthTypePAT, + APIUrl: srv.URL, + AccountID: "acct", + OrgID: "myorg", + ProjectID: "myproject", + PATToken: "t", + } + if _, err := artifactSearchFiles(context.Background(), a, ".*", "myreg", 100); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if gotQuery.Has("org_identifier") { + t.Errorf("org_identifier must not be sent (server-side scoping bug): got %q", gotQuery.Get("org_identifier")) + } + if gotQuery.Has("project_identifier") { + t.Errorf("project_identifier must not be sent (server-side scoping bug): got %q", gotQuery.Get("project_identifier")) + } +} + +// TestArtifactSearchFiles_PaginatesOnHasMore verifies the v3 paging idiom: +// loop on hasMore, incrementing page, and stop on an empty page. +func TestArtifactSearchFiles_PaginatesOnHasMore(t *testing.T) { + var pages []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + p := r.URL.Query().Get("page") + pages = append(pages, p) + w.Header().Set("Content-Type", "application/json") + switch p { + case "0": + _, _ = w.Write([]byte(`{"items":[{"path":"a.txt","downloadUrl":"u"}],"hasMore":true}`)) + case "1": + _, _ = w.Write([]byte(`{"items":[{"path":"b.txt","downloadUrl":"u"}],"hasMore":true}`)) + default: + // hasMore=true but empty items must still terminate the loop. + _, _ = w.Write([]byte(`{"items":[],"hasMore":true}`)) + } + })) + t.Cleanup(srv.Close) + + got, err := artifactSearchFiles(context.Background(), downloadTestAuth(srv.URL), ".*", "myreg", 1) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 2 { + t.Errorf("expected 2 items across pages, got %d: %v", len(got), got) + } + if len(pages) != 3 { + t.Errorf("expected 3 page requests (0,1,2), got %v", pages) + } +} + +// --- isWithinDest --- + +func TestIsWithinDest_SafeRelativePath(t *testing.T) { + if !isWithinDest("/tmp/dest", "art/1.0/file.tar.gz") { + t.Error("expected safe path to be within dest") + } +} + +func TestIsWithinDest_BareFilename(t *testing.T) { + if !isWithinDest("/tmp/dest", "file.tar.gz") { + t.Error("expected bare filename to be within dest") + } +} + +func TestIsWithinDest_TraversalEscapes(t *testing.T) { + if isWithinDest("/tmp/dest", "../../etc/passwd") { + t.Error("expected path traversal to be rejected") + } +} + +func TestIsWithinDest_SingleDotDot(t *testing.T) { + if isWithinDest("/tmp/dest", "../sibling") { + t.Error("expected single ../ traversal to be rejected") + } +} + +// --- artifactSearchFiles --- + +func TestArtifactSearchFiles_ReturnsMatchedItems(t *testing.T) { + names := []string{"foo/1.0/foo.tar.gz", "bar/2.0/bar.zip"} + srv := newDownloadTestServer(t, names) + + got, err := artifactSearchFiles(context.Background(), downloadTestAuth(srv.URL), ".*", "myreg", downloadDefaultPageSize) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 2 { + t.Fatalf("expected 2 results, got %d: %v", len(got), got) + } + if got[0].Path != names[0] { + t.Errorf("path[0]: got %q, want %q", got[0].Path, names[0]) + } + if got[0].DownloadURL == "" { + t.Error("expected DownloadURL to be populated from API response") + } +} + +func TestArtifactSearchFiles_EmptyResult(t *testing.T) { + srv := newDownloadTestServer(t, nil) + + got, err := artifactSearchFiles(context.Background(), downloadTestAuth(srv.URL), "nomatch", "myreg", downloadDefaultPageSize) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 0 { + t.Errorf("expected 0 results, got %d", len(got)) + } +} + +// --- executeArtifactDownloadHandler --- + +func TestExecuteArtifactDownload_DryRun_PrintsAndDoesNotDownload(t *testing.T) { + srv := newDownloadTestServer(t, []string{"art/1.0/art.tar.gz"}) + dest := t.TempDir() + ctx := downloadTestCtx(map[string]any{ + "registry": "myreg", + "regex": ".*", + "dest": dest, + "dry-run": true, + }, srv.URL) + + if err := executeArtifactDownloadHandler(ctx); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + entries, _ := os.ReadDir(dest) + if len(entries) != 0 { + t.Errorf("dry-run should not write files; found %d entries", len(entries)) + } +} + +func TestExecuteArtifactDownload_NoMatches_ReturnsEarly(t *testing.T) { + srv := newDownloadTestServer(t, nil) + dest := t.TempDir() + ctx := downloadTestCtx(map[string]any{ + "registry": "myreg", + "regex": "nomatch", + "dest": dest, + }, srv.URL) + + if err := executeArtifactDownloadHandler(ctx); err != nil { + t.Fatalf("unexpected error for empty result: %v", err) + } + entries, _ := os.ReadDir(dest) + if len(entries) != 0 { + t.Errorf("expected empty dest, got %d entries", len(entries)) + } +} + +func TestExecuteArtifactDownload_DownloadsFiles(t *testing.T) { + names := []string{"art/1.0/art.tar.gz", "art/2.0/art.tar.gz"} + srv := newDownloadTestServer(t, names) + dest := t.TempDir() + ctx := downloadTestCtx(map[string]any{ + "registry": "myreg", + "regex": ".*", + "dest": dest, + }, srv.URL) + + if err := executeArtifactDownloadHandler(ctx); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + for _, name := range names { + p := filepath.Join(dest, filepath.FromSlash(name)) + if _, err := os.Stat(p); os.IsNotExist(err) { + t.Errorf("expected file %q to exist", p) + } + } +} + +func TestExecuteArtifactDownload_SkipsExistingByDefault(t *testing.T) { + artifactPath := "art/1.0/art.tar.gz" + srv := newDownloadTestServer(t, []string{artifactPath}) + dest := t.TempDir() + + outPath := filepath.Join(dest, filepath.FromSlash(artifactPath)) + if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil { + t.Fatal(err) + } + original := []byte("original") + if err := os.WriteFile(outPath, original, 0644); err != nil { + t.Fatal(err) + } + + ctx := downloadTestCtx(map[string]any{ + "registry": "myreg", + "regex": ".*", + "dest": dest, + }, srv.URL) + + if err := executeArtifactDownloadHandler(ctx); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + got, _ := os.ReadFile(outPath) + if string(got) != string(original) { + t.Errorf("existing file was overwritten; got %q, want %q", got, original) + } +} + +func TestExecuteArtifactDownload_OverwriteReplaces(t *testing.T) { + artifactPath := "art/1.0/art.tar.gz" + srv := newDownloadTestServer(t, []string{artifactPath}) + dest := t.TempDir() + + outPath := filepath.Join(dest, filepath.FromSlash(artifactPath)) + if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(outPath, []byte("stale"), 0644); err != nil { + t.Fatal(err) + } + + ctx := downloadTestCtx(map[string]any{ + "registry": "myreg", + "regex": ".*", + "dest": dest, + "overwrite": true, + }, srv.URL) + + if err := executeArtifactDownloadHandler(ctx); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + got, _ := os.ReadFile(outPath) + if string(got) == "stale" { + t.Error("expected file to be overwritten but content is unchanged") + } +} + +func TestExecuteArtifactDownload_Flatten_WritesFilesFlat(t *testing.T) { + names := []string{"art/1.0/foo.tar.gz", "other/bar.zip"} + srv := newDownloadTestServer(t, names) + dest := t.TempDir() + ctx := downloadTestCtx(map[string]any{ + "registry": "myreg", + "regex": ".*", + "dest": dest, + "flatten": true, + }, srv.URL) + + if err := executeArtifactDownloadHandler(ctx); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + for _, base := range []string{"foo.tar.gz", "bar.zip"} { + p := filepath.Join(dest, base) + if _, err := os.Stat(p); os.IsNotExist(err) { + t.Errorf("expected %q to exist in dest", p) + } + } + entries, _ := os.ReadDir(dest) + for _, e := range entries { + if e.IsDir() { + t.Errorf("flatten should not create subdirectories; found dir %q", e.Name()) + } + } +} + +func TestExecuteArtifactDownload_Flatten_CollisionErrors(t *testing.T) { + names := []string{"art/1.0/art.tar.gz", "art/2.0/art.tar.gz"} + srv := newDownloadTestServer(t, names) + dest := t.TempDir() + ctx := downloadTestCtx(map[string]any{ + "registry": "myreg", + "regex": ".*", + "dest": dest, + "flatten": true, + }, srv.URL) + + if err := executeArtifactDownloadHandler(ctx); err == nil { + t.Fatal("expected error for flatten collision, got nil") + } + + entries, _ := os.ReadDir(dest) + if len(entries) != 0 { + t.Errorf("expected dest to be empty after collision error, got %d entries", len(entries)) + } +} + +func TestExecuteArtifactDownload_Flatten_DryRunShowsCollisionWarning(t *testing.T) { + // In dry-run mode, collisions are a warning, not an error. + names := []string{"art/1.0/art.tar.gz", "art/2.0/art.tar.gz"} + srv := newDownloadTestServer(t, names) + dest := t.TempDir() + ctx := downloadTestCtx(map[string]any{ + "registry": "myreg", + "regex": ".*", + "dest": dest, + "flatten": true, + "dry-run": true, + }, srv.URL) + + if err := executeArtifactDownloadHandler(ctx); err != nil { + t.Fatalf("expected nil error in dry-run despite collision, got: %v", err) + } + + entries, _ := os.ReadDir(dest) + if len(entries) != 0 { + t.Errorf("dry-run should not write files; found %d entries", len(entries)) + } +} + +func TestExecuteArtifactDownload_SkipsItemsWithNoURL(t *testing.T) { + // Server returns items with no downloadUrl field. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"items":[{"name":"art.tar.gz","path":"art/1.0/art.tar.gz"}],"hasMore":false}`)) + return + } + http.NotFound(w, r) + })) + t.Cleanup(srv.Close) + + dest := t.TempDir() + ctx := downloadTestCtx(map[string]any{ + "registry": "myreg", + "regex": ".*", + "dest": dest, + }, srv.URL) + + err := executeArtifactDownloadHandler(ctx) + if err == nil { + t.Fatal("expected error when all items have no download URL, got nil") + } + if !strings.Contains(err.Error(), "no downloadable") { + t.Errorf("unexpected error message: %v", err) + } +} + +func TestExecuteArtifactDownload_RejectsUnsafePaths(t *testing.T) { + // Server returns an item with a path-traversal payload. + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + dlURL := srv.URL + "/dl/evil" + body := fmt.Sprintf(`{"items":[{"name":"evil","path":"../../etc/passwd","downloadUrl":%q}],"hasMore":false}`, dlURL) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(body)) + return + } + http.NotFound(w, r) + })) + t.Cleanup(srv.Close) + + dest := t.TempDir() + ctx := downloadTestCtx(map[string]any{ + "registry": "myreg", + "regex": ".*", + "dest": dest, + }, srv.URL) + + err := executeArtifactDownloadHandler(ctx) + if err == nil { + t.Fatal("expected error when all paths are unsafe, got nil") + } + // Nothing should be written to dest. + entries, _ := os.ReadDir(dest) + if len(entries) != 0 { + t.Errorf("expected empty dest but found %d entries", len(entries)) + } +} + +func TestExecuteArtifactDownload_PageSizeValidation(t *testing.T) { + srv := newDownloadTestServer(t, nil) + dest := t.TempDir() + + for _, bad := range []string{"0", "101", "abc", "-5"} { + ctx := downloadTestCtx(map[string]any{ + "registry": "myreg", + "regex": ".*", + "dest": dest, + "page-size": bad, + }, srv.URL) + if err := executeArtifactDownloadHandler(ctx); err == nil { + t.Errorf("expected error for --page-size %q, got nil", bad) + } + } +} + +func TestExecuteArtifactDownload_FailedDownload_ReturnsError(t *testing.T) { + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + dlURL := srv.URL + "/dl/art.tar.gz" + body := fmt.Sprintf(`{"items":[{"name":"art/1.0/art.tar.gz","path":"art/1.0/art.tar.gz","downloadUrl":%q}],"hasMore":false}`, dlURL) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(body)) + return + } + // All GET requests return 404. + http.Error(w, "not found", http.StatusNotFound) + })) + t.Cleanup(srv.Close) + + dest := t.TempDir() + ctx := downloadTestCtx(map[string]any{ + "registry": "myreg", + "regex": ".*", + "dest": dest, + }, srv.URL) + + if err := executeArtifactDownloadHandler(ctx); err == nil { + t.Fatal("expected error when download fails, got nil") + } +} + +// --- flattenCollisions --- + +func TestFlattenCollisions_NoCollisions(t *testing.T) { + files := []string{"a/1.0/foo.tar.gz", "b/2.0/bar.zip", "c/baz.tgz"} + if got := flattenCollisions(files); len(got) != 0 { + t.Errorf("expected no collisions, got: %v", got) + } +} + +func TestFlattenCollisions_DetectsCollisions(t *testing.T) { + files := []string{"a/1.0/foo.tar.gz", "b/2.0/foo.tar.gz", "c/bar.zip", "d/bar.zip"} + got := flattenCollisions(files) + if len(got) != 2 { + t.Errorf("expected 2 collisions, got %d: %v", len(got), got) + } +} + +// TestFlattenCollisions_ThreeWayCollision guards against the bug where seen[base] +// was never updated after the first collision, causing the second collision message +// to always pair with the original first file rather than the most recent one. +func TestFlattenCollisions_ThreeWayCollision(t *testing.T) { + files := []string{"a/1.0/foo.gz", "b/2.0/foo.gz", "c/3.0/foo.gz"} + got := flattenCollisions(files) + if len(got) != 2 { + t.Fatalf("expected 2 collision messages for 3-way collision, got %d: %v", len(got), got) + } + // Second message must reference b/2.0/foo.gz and c/3.0/foo.gz, not a/1.0/foo.gz again. + if !strings.Contains(got[1], "b/2.0/foo.gz") { + t.Errorf("second collision message should reference b/2.0/foo.gz (the previous file), got: %s", got[1]) + } + if !strings.Contains(got[1], "c/3.0/foo.gz") { + t.Errorf("second collision message should reference c/3.0/foo.gz (the new file), got: %s", got[1]) + } +} diff --git a/modules/har/pkg/har/har.go b/modules/har/pkg/har/har.go index 6c16718..f2accbb 100644 --- a/modules/har/pkg/har/har.go +++ b/modules/har/pkg/har/har.go @@ -39,4 +39,5 @@ func ModuleInit(reg registry.ModuleRegistrar) { reg.RegisterWorkflow(executeArtifactMvnInstallHandlerID, executeArtifactMvnInstallHandler) reg.RegisterWorkflow(executeArtifactDotnetRestoreHandlerID, executeArtifactDotnetRestoreHandler) reg.RegisterWorkflow(bulkDeleteArtifactHandlerID, bulkDeleteArtifactHandler) + reg.RegisterWorkflow(executeArtifactDownloadHandlerID, executeArtifactDownloadHandler) } diff --git a/pkg/spec/har.spec.yaml b/pkg/spec/har.spec.yaml index c7e5ff8..f2a4bf8 100644 --- a/pkg/spec/har.spec.yaml +++ b/pkg/spec/har.spec.yaml @@ -1148,3 +1148,65 @@ commands: completion_noun: registry handler_type: workflow workflow_id: configure_registry_nuget + + - command: execute artifact:download + verb: execute + noun: artifact + noun_variant: download + short: "Download matching artifacts: harness execute artifact:download --registry --regex --dest " + no_id: true + long: | + Search a Harness Artifact Registry for files whose paths match a regular + expression and download every match into a local directory. Works for all + artifact types. + + The regex is a POSIX regular expression evaluated server-side and is + case-sensitive. It is matched against the full artifact path within the + registry, not just the filename. + + By default files are written under --dest preserving the registry path + hierarchy, and files that already exist locally are skipped. Downloads are + written atomically, so an interrupted run never leaves a partial file. + + Use --flatten to drop the registry sub-paths and write every file directly + into --dest. If two artifacts would then collide on the same filename the + run aborts without downloading anything; run with --dry-run to see the + conflicting pairs. + + Examples: + # Preview what would be downloaded, without writing anything + harness execute artifact:download --registry my-reg --regex '.*' --dest ./out --dry-run + + # Download every tarball, preserving registry paths + harness execute artifact:download --registry my-reg --regex '.*\.tar\.gz$' --dest ./out + + # Flatten into one directory and replace existing local files + harness execute artifact:download --registry my-reg --regex '.*' --dest ./out --flatten --overwrite + + # Raise parallelism for many small files + harness execute artifact:download --registry my-reg --regex '.*' --dest ./out --concurrency 16 + handler_type: workflow + workflow_id: execute_artifact_download + flags: + - name: registry + description: "Registry to search in (required)" + required: true + - name: regex + description: "POSIX regular expression matched against artifact paths, case-sensitive (required)" + required: true + - name: dest + description: "Local destination directory for downloaded files (required)" + required: true + - name: flatten + description: "Write all files directly into dest, ignoring registry sub-paths (collisions abort the run; use --dry-run to preview)" + is_bool: true + - name: dry-run + description: "List matching files to stdout without downloading" + is_bool: true + - name: overwrite + description: "Overwrite existing local files (default: skip if present)" + is_bool: true + - name: page-size + description: "Files fetched per search page; must be 1–100 (default: 100)" + - name: concurrency + description: "Number of parallel downloads (default: 4)" From fbc33f3af503750964d58fbd41d4aa80cb9dcd14 Mon Sep 17 00:00:00 2001 From: Sameeksha Malav Date: Thu, 10 Sep 2026 15:37:19 +0530 Subject: [PATCH 2/2] fix (har): fix execute artifact:download dry-run and conflict output --- .gitignore | 1 + modules/har/pkg/har/download_artifact.go | 168 +++++++++++- modules/har/pkg/har/download_artifact_test.go | 250 +++++++++++++++++- pkg/rootcmd/rootcmd.go | 4 +- pkg/spec/har.spec.yaml | 2 +- 5 files changed, 405 insertions(+), 20 deletions(-) diff --git a/.gitignore b/.gitignore index e024b1d..40b9fb8 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ internal/ old-hc/ CLAUDE.local.md internal-docs/ +dry-run-output/ *~ .claude/settings.local.md .claude/ diff --git a/modules/har/pkg/har/download_artifact.go b/modules/har/pkg/har/download_artifact.go index d2eac2c..0527ef0 100644 --- a/modules/har/pkg/har/download_artifact.go +++ b/modules/har/pkg/har/download_artifact.go @@ -20,6 +20,7 @@ import ( "sync" "sync/atomic" "syscall" + "time" "github.com/harness/cli/v3/pkg/auth" "github.com/harness/cli/v3/pkg/cmdctx" @@ -69,6 +70,7 @@ func executeArtifactDownloadHandler(ctx *cmdctx.Ctx) error { flatten := cmdctx.GetBool(ctx.FlagValues, "flatten") concurrencyStr := cmdctx.GetString(ctx.FlagValues, "concurrency") pageSizeStr := cmdctx.GetString(ctx.FlagValues, "page-size") + outFile := ctx.FormatFlags.OutFile concurrency := downloadDefaultConcurrency if concurrencyStr != "" { @@ -150,29 +152,45 @@ func executeArtifactDownloadHandler(ctx *cmdctx.Ctx) error { } if dryRun { - fmt.Printf("Dry run — %d file(s) matched", len(downloadable)) - if skippedNoURL > 0 || skippedUnsafePath > 0 { - fmt.Printf(" (%d no-URL, %d unsafe-path skipped)", skippedNoURL, skippedUnsafePath) + // Always write a JSON report file; never dump the file list to the terminal. + // Use --out to specify a custom path; otherwise auto-generate under dry-run-output/. + target := outFile + if target == "" { + ts := time.Now().Format("20060102_150405") + target = filepath.Join("dry-run-output", fmt.Sprintf("download-dryrun-output-%s.json", ts)) + } + if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { + return fmt.Errorf("creating dry-run output directory: %w", err) + } + b, err := writeDryRunJSON(io.Discard, downloadable, dest, flatten, skippedNoURL, skippedUnsafePath) + if err != nil { + return err } - fmt.Println(":") - for _, item := range downloadable { - fmt.Printf(" %s → %s\n", item.Path, resolveOutPath(dest, item.Path, flatten)) + if err := os.WriteFile(target, b, 0644); err != nil { + return fmt.Errorf("writing dry-run output to %q: %w", target, err) + } + summary := fmt.Sprintf("Dry run — %d file(s) found, %d matched", len(allFiles), len(downloadable)) + if skippedNoURL > 0 || skippedUnsafePath > 0 { + summary += fmt.Sprintf(" (%d no-URL, %d unsafe-path skipped)", skippedNoURL, skippedUnsafePath) } if len(collisions) > 0 { - fmt.Fprintf(os.Stderr, "\nWarning — %d flatten collision(s) (real run would abort):\n", len(collisions)) - for _, c := range collisions { - fmt.Fprintln(os.Stderr, " "+c) - } + summary += fmt.Sprintf(", %d flatten collision(s)", len(collisions)) } + fmt.Fprintf(os.Stderr, "%s. Output written to %s\n", summary, target) return nil } if len(collisions) > 0 { - fmt.Fprintln(os.Stderr, "Flatten name collisions — multiple paths share the same basename:") - for _, c := range collisions { - fmt.Fprintln(os.Stderr, " "+c) + // Write the collision details to a file; keep terminal output minimal. + ts := time.Now().Format("20060102_150405") + conflictPath := filepath.Join("dry-run-output", fmt.Sprintf("conflict-download-%s.json", ts)) + if err := writeConflictJSON(conflictPath, downloadable, dest); err != nil { + fmt.Fprintf(os.Stderr, "Warning: could not write conflict file: %v\n", err) + } else { + fmt.Fprintf(os.Stderr, "%d file(s) found, %d matched, %d flatten collision(s) detected. Run with --dry-run to see the full file list.\nConflict details: %s\n", + len(allFiles), len(downloadable), len(collisions), conflictPath) } - return fmt.Errorf("use a narrower --regex or remove --flatten to resolve %d collision(s)", len(collisions)) + return errors.New("") } if err := os.MkdirAll(dest, 0755); err != nil { @@ -412,13 +430,135 @@ func newDownloadHTTPClient() *http.Client { return client } +// dryRunOutput is the structured JSON representation of a dry-run result. +// Emitted when --format json (or --json) is combined with --dry-run. +type dryRunOutput struct { + Matched int `json:"matched"` + Skipped *dryRunSkipped `json:"skipped,omitempty"` + Files []dryRunFile `json:"files"` + Conflicts []dryRunConflict `json:"conflicts,omitempty"` +} + +type dryRunSkipped struct { + NoURL int `json:"no_url,omitempty"` + UnsafePath int `json:"unsafe_path,omitempty"` +} + +type dryRunFile struct { + SourcePath string `json:"source_path"` + DestPath string `json:"dest_path"` + Size string `json:"size,omitempty"` +} + +type dryRunConflict struct { + DestPath string `json:"dest_path"` + Sources []string `json:"sources"` +} + +// writeDryRunJSON marshals the dry-run result as indented JSON and writes it to w. +// Returns the marshalled bytes so callers can also write to a file. +func writeDryRunJSON(w io.Writer, items []fileItem, dest string, flatten bool, skippedNoURL, skippedUnsafePath int) ([]byte, error) { + files := make([]dryRunFile, 0, len(items)) + for _, item := range items { + files = append(files, dryRunFile{ + SourcePath: item.Path, + DestPath: resolveOutPath(dest, item.Path, flatten), + Size: item.Size, + }) + } + + var conflicts []dryRunConflict + if flatten { + paths := make([]string, len(items)) + for i, item := range items { + paths[i] = item.Path + } + for base, srcs := range flattenCollisionMap(paths) { + conflicts = append(conflicts, dryRunConflict{ + DestPath: resolveOutPath(dest, srcs[0], true), + Sources: srcs, + }) + _ = base + } + } + + var skipped *dryRunSkipped + if skippedNoURL > 0 || skippedUnsafePath > 0 { + skipped = &dryRunSkipped{NoURL: skippedNoURL, UnsafePath: skippedUnsafePath} + } + + out := dryRunOutput{ + Matched: len(items), + Skipped: skipped, + Files: files, + Conflicts: conflicts, + } + + b, err := json.MarshalIndent(out, "", " ") + if err != nil { + return nil, fmt.Errorf("marshalling dry-run output: %w", err) + } + _, err = fmt.Fprintln(w, string(b)) + return b, err +} + +// writeConflictJSON writes a JSON file listing all flatten collisions to conflictPath. +func writeConflictJSON(conflictPath string, items []fileItem, dest string) error { + if err := os.MkdirAll(filepath.Dir(conflictPath), 0755); err != nil { + return fmt.Errorf("creating conflict output directory: %w", err) + } + paths := make([]string, len(items)) + for i, item := range items { + paths[i] = item.Path + } + var conflicts []dryRunConflict + for base, srcs := range flattenCollisionMap(paths) { + conflicts = append(conflicts, dryRunConflict{ + DestPath: resolveOutPath(dest, srcs[0], true), + Sources: srcs, + }) + _ = base + } + b, err := json.MarshalIndent(struct { + Conflicts []dryRunConflict `json:"conflicts"` + }{Conflicts: conflicts}, "", " ") + if err != nil { + return fmt.Errorf("marshalling conflict output: %w", err) + } + return os.WriteFile(conflictPath, b, 0644) +} + +// flattenCollisionMap returns a map of basename → all source paths that share it, +// for basenames claimed by more than one path. Empty map means no collisions. +func flattenCollisionMap(files []string) map[string][]string { + all := make(map[string][]string, len(files)) + for _, f := range files { + base := filepath.Base(filepath.FromSlash(f)) + all[base] = append(all[base], f) + } + out := make(map[string][]string) + for base, srcs := range all { + if len(srcs) > 1 { + out[base] = srcs + } + } + return out +} + // flattenCollisions returns one descriptive string per basename shared by more // than one path. Empty slice means no collisions. func flattenCollisions(files []string) []string { + colMap := flattenCollisionMap(files) + if len(colMap) == 0 { + return nil + } seen := map[string]string{} var out []string for _, f := range files { base := filepath.Base(filepath.FromSlash(f)) + if _, hasCollision := colMap[base]; !hasCollision { + continue + } if first, ok := seen[base]; ok { out = append(out, fmt.Sprintf("%q and %q both flatten to %q", first, f, base)) seen[base] = f // advance so the next collision pairs with the most recent file diff --git a/modules/har/pkg/har/download_artifact_test.go b/modules/har/pkg/har/download_artifact_test.go index 0e89c72..9fb5853 100644 --- a/modules/har/pkg/har/download_artifact_test.go +++ b/modules/har/pkg/har/download_artifact_test.go @@ -40,6 +40,15 @@ func downloadTestCtx(flags map[string]any, serverURL string) *cmdctx.Ctx { } } +// downloadTestCtxWithOut is like downloadTestCtx but also sets OutFile on the +// Ctx (the framework populates this from --out; workflow handlers read it +// directly rather than from FlagValues). +func downloadTestCtxWithOut(flags map[string]any, serverURL, outFile string) *cmdctx.Ctx { + ctx := downloadTestCtx(flags, serverURL) + ctx.FormatFlags.OutFile = outFile + return ctx +} + // newDownloadTestServer returns a server that: // - POST /har/api/v3/files/search → JSON with each name as a file item whose // downloadUrl points back to GET /dl/ on this same server @@ -275,9 +284,17 @@ func TestArtifactSearchFiles_EmptyResult(t *testing.T) { // --- executeArtifactDownloadHandler --- -func TestExecuteArtifactDownload_DryRun_PrintsAndDoesNotDownload(t *testing.T) { +func TestExecuteArtifactDownload_DryRun_WritesReportAndDoesNotDownload(t *testing.T) { srv := newDownloadTestServer(t, []string{"art/1.0/art.tar.gz"}) dest := t.TempDir() + + runDir := t.TempDir() + origDir, _ := os.Getwd() + if err := os.Chdir(runDir); err != nil { + t.Fatalf("chdir: %v", err) + } + t.Cleanup(func() { os.Chdir(origDir) }) //nolint:errcheck + ctx := downloadTestCtx(map[string]any{ "registry": "myreg", "regex": ".*", @@ -289,9 +306,18 @@ func TestExecuteArtifactDownload_DryRun_PrintsAndDoesNotDownload(t *testing.T) { t.Fatalf("unexpected error: %v", err) } + // Dest should be empty — no actual downloads. entries, _ := os.ReadDir(dest) if len(entries) != 0 { - t.Errorf("dry-run should not write files; found %d entries", len(entries)) + t.Errorf("dry-run should not write files to dest; found %d entries", len(entries)) + } + // A report file should have been created in dry-run-output/. + reports, err := os.ReadDir(filepath.Join(runDir, "dry-run-output")) + if err != nil { + t.Fatalf("dry-run-output dir not created: %v", err) + } + if len(reports) != 1 { + t.Errorf("expected 1 report file, got %d", len(reports)) } } @@ -428,6 +454,14 @@ func TestExecuteArtifactDownload_Flatten_CollisionErrors(t *testing.T) { names := []string{"art/1.0/art.tar.gz", "art/2.0/art.tar.gz"} srv := newDownloadTestServer(t, names) dest := t.TempDir() + + runDir := t.TempDir() + origDir, _ := os.Getwd() + if err := os.Chdir(runDir); err != nil { + t.Fatalf("chdir: %v", err) + } + t.Cleanup(func() { os.Chdir(origDir) }) //nolint:errcheck + ctx := downloadTestCtx(map[string]any{ "registry": "myreg", "regex": ".*", @@ -439,17 +473,35 @@ func TestExecuteArtifactDownload_Flatten_CollisionErrors(t *testing.T) { t.Fatal("expected error for flatten collision, got nil") } + // No files should have been downloaded. entries, _ := os.ReadDir(dest) if len(entries) != 0 { t.Errorf("expected dest to be empty after collision error, got %d entries", len(entries)) } + + // A conflict file should have been written. + conflicts, err := os.ReadDir(filepath.Join(runDir, "dry-run-output")) + if err != nil { + t.Fatalf("dry-run-output dir not created: %v", err) + } + if len(conflicts) != 1 || !strings.HasPrefix(conflicts[0].Name(), "conflict-download-") { + t.Errorf("expected 1 conflict-download-*.json file, got %v", conflicts) + } } -func TestExecuteArtifactDownload_Flatten_DryRunShowsCollisionWarning(t *testing.T) { - // In dry-run mode, collisions are a warning, not an error. +func TestExecuteArtifactDownload_Flatten_DryRunReportsCollisionsInFile(t *testing.T) { + // In dry-run mode, collisions are included in the report file, not an error. names := []string{"art/1.0/art.tar.gz", "art/2.0/art.tar.gz"} srv := newDownloadTestServer(t, names) dest := t.TempDir() + + runDir := t.TempDir() + origDir, _ := os.Getwd() + if err := os.Chdir(runDir); err != nil { + t.Fatalf("chdir: %v", err) + } + t.Cleanup(func() { os.Chdir(origDir) }) //nolint:errcheck + ctx := downloadTestCtx(map[string]any{ "registry": "myreg", "regex": ".*", @@ -466,6 +518,20 @@ func TestExecuteArtifactDownload_Flatten_DryRunShowsCollisionWarning(t *testing. if len(entries) != 0 { t.Errorf("dry-run should not write files; found %d entries", len(entries)) } + + // The report file should include the collision. + reports, _ := os.ReadDir(filepath.Join(runDir, "dry-run-output")) + if len(reports) != 1 { + t.Fatalf("expected 1 report file, got %d", len(reports)) + } + raw, _ := os.ReadFile(filepath.Join(runDir, "dry-run-output", reports[0].Name())) + var out dryRunOutput + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("invalid JSON in report: %v", err) + } + if len(out.Conflicts) != 1 { + t.Errorf("expected 1 conflict in report, got %d", len(out.Conflicts)) + } } func TestExecuteArtifactDownload_SkipsItemsWithNoURL(t *testing.T) { @@ -590,6 +656,182 @@ func TestFlattenCollisions_DetectsCollisions(t *testing.T) { } } +// --- writeDryRunJSON / flattenCollisionMap --- + +func TestWriteDryRunJSON_BasicOutput(t *testing.T) { + items := []fileItem{ + {Path: "art/1.0/foo.tar.gz", DownloadURL: "http://x/foo", Size: "100"}, + {Path: "art/2.0/bar.zip", DownloadURL: "http://x/bar", Size: "200"}, + } + dest := "/tmp/out" + + var buf strings.Builder + _, err := writeDryRunJSON(&buf, items, dest, false, 0, 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var out dryRunOutput + if err := json.Unmarshal([]byte(buf.String()), &out); err != nil { + t.Fatalf("invalid JSON: %v\n%s", err, buf.String()) + } + if out.Matched != 2 { + t.Errorf("matched: got %d, want 2", out.Matched) + } + if len(out.Files) != 2 { + t.Errorf("files: got %d, want 2", len(out.Files)) + } + if out.Files[0].SourcePath != "art/1.0/foo.tar.gz" { + t.Errorf("file[0].source_path = %q", out.Files[0].SourcePath) + } + if out.Files[0].Size != "100" { + t.Errorf("file[0].size = %q", out.Files[0].Size) + } + if out.Conflicts != nil { + t.Errorf("expected no conflicts, got %v", out.Conflicts) + } + if out.Skipped != nil { + t.Errorf("expected no skipped, got %v", out.Skipped) + } +} + +func TestWriteDryRunJSON_SkippedCounts(t *testing.T) { + var buf strings.Builder + _, err := writeDryRunJSON(&buf, []fileItem{{Path: "a/b.gz", DownloadURL: "http://x", Size: "1"}}, "/out", false, 2, 1) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var out dryRunOutput + if err := json.Unmarshal([]byte(buf.String()), &out); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if out.Skipped == nil || out.Skipped.NoURL != 2 || out.Skipped.UnsafePath != 1 { + t.Errorf("skipped: got %+v", out.Skipped) + } +} + +func TestWriteDryRunJSON_FlattenConflictsIncluded(t *testing.T) { + items := []fileItem{ + {Path: "a/1.0/img.png", DownloadURL: "http://x/1", Size: "10"}, + {Path: "b/2.0/img.png", DownloadURL: "http://x/2", Size: "20"}, + {Path: "c/3.0/other.png", DownloadURL: "http://x/3", Size: "30"}, + } + var buf strings.Builder + _, err := writeDryRunJSON(&buf, items, "/out", true, 0, 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var out dryRunOutput + if err := json.Unmarshal([]byte(buf.String()), &out); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if len(out.Conflicts) != 1 { + t.Fatalf("expected 1 conflict, got %d: %v", len(out.Conflicts), out.Conflicts) + } + if len(out.Conflicts[0].Sources) != 2 { + t.Errorf("conflict should list 2 sources, got %d", len(out.Conflicts[0].Sources)) + } +} + +func TestDryRunJSON_WrittenToFile(t *testing.T) { + srv := newDownloadTestServer(t, []string{"art/1.0/art.tar.gz"}) + dest := t.TempDir() + outFile := filepath.Join(t.TempDir(), "dryrun.json") + ctx := downloadTestCtxWithOut(map[string]any{ + "registry": "myreg", + "regex": ".*", + "dest": dest, + "dry-run": true, + }, srv.URL, outFile) + + if err := executeArtifactDownloadHandler(ctx); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + raw, err := os.ReadFile(outFile) + if err != nil { + t.Fatalf("output file not written: %v", err) + } + var out dryRunOutput + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("invalid JSON in output file: %v\n%s", err, raw) + } + if out.Matched != 1 { + t.Errorf("matched: got %d, want 1", out.Matched) + } + // Ensure nothing was downloaded. + entries, _ := os.ReadDir(dest) + if len(entries) != 0 { + t.Errorf("dry-run should not write files; found %d entries", len(entries)) + } +} + +func TestDryRunJSON_AutoGeneratesFile(t *testing.T) { + // When --report is used without --out, a file should be auto-generated + // under dry-run-output/ with a timestamped name (matching hc behaviour). + srv := newDownloadTestServer(t, []string{"art/1.0/art.tar.gz"}) + dest := t.TempDir() + + // Run from a temp dir so dry-run-output/ is created there, not in the repo root. + origDir, _ := os.Getwd() + runDir := t.TempDir() + if err := os.Chdir(runDir); err != nil { + t.Fatalf("chdir: %v", err) + } + t.Cleanup(func() { os.Chdir(origDir) }) //nolint:errcheck + + ctx := downloadTestCtx(map[string]any{ + "registry": "myreg", + "regex": ".*", + "dest": dest, + "dry-run": true, + }, srv.URL) + + if err := executeArtifactDownloadHandler(ctx); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Exactly one JSON file should exist under dry-run-output/. + entries, err := os.ReadDir(filepath.Join(runDir, "dry-run-output")) + if err != nil { + t.Fatalf("dry-run-output dir not created: %v", err) + } + if len(entries) != 1 { + t.Fatalf("expected 1 output file, got %d: %v", len(entries), entries) + } + name := entries[0].Name() + if !strings.HasPrefix(name, "download-dryrun-output-") || !strings.HasSuffix(name, ".json") { + t.Errorf("unexpected filename format: %q", name) + } + + raw, _ := os.ReadFile(filepath.Join(runDir, "dry-run-output", name)) + var out dryRunOutput + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("invalid JSON in auto-generated file: %v\n%s", err, raw) + } + if out.Matched != 1 { + t.Errorf("matched: got %d, want 1", out.Matched) + } +} + +func TestFlattenCollisionMap_Empty(t *testing.T) { + m := flattenCollisionMap([]string{"a/foo.gz", "b/bar.gz"}) + if len(m) != 0 { + t.Errorf("expected no collisions, got %v", m) + } +} + +func TestFlattenCollisionMap_DetectsCollision(t *testing.T) { + m := flattenCollisionMap([]string{"a/1.0/img.png", "b/2.0/img.png", "c/other.png"}) + if len(m) != 1 { + t.Fatalf("expected 1 collision, got %d: %v", len(m), m) + } + srcs, ok := m["img.png"] + if !ok || len(srcs) != 2 { + t.Errorf("expected 2 sources for img.png, got %v", srcs) + } +} + // TestFlattenCollisions_ThreeWayCollision guards against the bug where seen[base] // was never updated after the first collision, causing the second collision message // to always pair with the original first file rather than the most recent one. diff --git a/pkg/rootcmd/rootcmd.go b/pkg/rootcmd/rootcmd.go index d8f84ff..a6057b7 100644 --- a/pkg/rootcmd/rootcmd.go +++ b/pkg/rootcmd/rootcmd.go @@ -211,7 +211,9 @@ func SetupAndExecuteRootCmd(root *cobra.Command, reg *registry.Registry) { os.Exit(1) } } - console.PrintError(err.Error()) + if msg := err.Error(); msg != "" { + console.PrintError(msg) + } if cmdctx.IsTimeout(err) { os.Exit(hbase.TimeoutExitCode) } diff --git a/pkg/spec/har.spec.yaml b/pkg/spec/har.spec.yaml index f2a4bf8..9da6e85 100644 --- a/pkg/spec/har.spec.yaml +++ b/pkg/spec/har.spec.yaml @@ -1201,7 +1201,7 @@ commands: description: "Write all files directly into dest, ignoring registry sub-paths (collisions abort the run; use --dry-run to preview)" is_bool: true - name: dry-run - description: "List matching files to stdout without downloading" + description: "Preview matching files without downloading; writes a JSON report to dry-run-output/ (or to --out if specified)" is_bool: true - name: overwrite description: "Overwrite existing local files (default: skip if present)"