diff --git a/changelog.md b/changelog.md index 0908eecfae..da34c115e2 100644 --- a/changelog.md +++ b/changelog.md @@ -22,19 +22,47 @@ and this project adheres to file, and artifacts are linked concurrently. - A `--meta` file's name, version, and description now feed the built wheel (flags take precedence over the meta file, which takes precedence over `pyproject.toml`). +- Answering `n` when `state publish` asks to confirm now offers to edit the ingredient meta data in your editor or save + it to a meta file to edit and publish later with `--meta`, instead of only cancelling. +- When `state publish --build` cannot determine the ingredient name or version, the error now says which is missing, + why `pyproject.toml` did not provide it (for example, a version listed in `[project].dynamic`), and how to set it. +- Without `--namespace`, `state publish --build` now defaults to `private//python` when the source has a + `pyproject.toml` (otherwise `private/`, as before). +- The namespace of a new ingredient can now be changed in the `state publish` editor. The new namespace is checked like + one passed with `--namespace`; if it can't be used, the previous one is kept and the reason is shown. The confirmation + and the editor now say up front which fields can't be changed and why, and when one of them is edited anyway (the + name or version with `--build`, or the namespace, name or version with `--edit`), the error names it. +- The ingredient meta data `state publish` shows for confirmation and opens in the editor no longer has a help comment + on each field. The fields are described in the publish guide instead. ### Fixed - `state publish --edit` no longer overwrites the meta file's dependencies and authors with the previous revision's, - keeps inherited dependency types and conditions, and rejects author or description changes on an existing version - up front. + keeps inherited dependency types and conditions, and rejects author changes on an existing version up front. - `state publish --edit --meta` with an exported meta file now edits the version named in the file rather than the latest version. +- `state export ingredient-meta` now writes plain YAML without help comments or line wrapping, so its output can be + passed back with `--meta` as is. +- `state export ingredient-meta` now includes the ingredient's description instead of leaving it empty. When + `state publish --edit` is given a different description for an existing version, it no longer refuses to publish: + the confirmation says the description can't be changed, shows the current one, and the change is ignored. +- `state publish` of a new ingredient without a namespace (such as with `--editor` outside a project) no longer searches + every namespace for its name, which could time out with "Could not search for ingredient" before the editor opened. +- `state publish --editor` no longer fails with "Could not publish ingredient. Error received: " when no editor + can be opened (no `EDITOR` set and no `xdg-open`, as on many headless machines). It says so and waits for the file to + be edited by hand instead. +- Dependencies and conditions without a version range (such as a bare `requests` in `pyproject.toml`) are now published + as accepting any version instead of failing with "Expecting an OBJECT with a field named 'versionRequirements'". +- Fields removed in the `state publish --editor` file are now removed from the publish instead of keeping their + previous values, and publish API errors are shown instead of ``. ### Security - Updated `github.com/klauspost/compress` to v1.18.7 to remediate CVE-2026-63209 (GHSA-259r-337f-4rfw). - Updated to Go 1.26.10 and `golang.org/x/net` v0.60.0 to remediate 14 CVEs in the Go standard library and HTTP/2 handling. +- Updated `golang.org/x/net` to v0.62.0 to remediate CVE-2026-78661 and CVE-2026-97045. +- Updated `github.com/labstack/echo/v4` to v4.16.1 to remediate GHSA-cxf3-9h37-7rr8, GHSA-9969-6676-49h5, + GHSA-f48p-c9v4-h7xv and GHSA-h7wr-284f-5qvc. ## 0.48.1 diff --git a/go.mod b/go.mod index d2b63f4abb..5f71898ed4 100644 --- a/go.mod +++ b/go.mod @@ -34,7 +34,7 @@ require ( github.com/jarcoal/httpmock v1.0.3 github.com/jessevdk/go-flags v1.4.0 github.com/kami-zh/go-capturer v0.0.0-20171211120116-e492ea43421d - github.com/labstack/echo/v4 v4.15.4 + github.com/labstack/echo/v4 v4.16.1 github.com/mash/go-tempfile-suffix v0.0.0-20150731093933-48f0f8a3a5ab github.com/mattn/go-runewidth v0.0.15 github.com/nicksnyder/go-i18n v1.10.0 @@ -51,11 +51,11 @@ require ( github.com/thoas/go-funk v0.8.0 github.com/vbauerster/mpb/v7 v7.1.5 github.com/vektah/gqlparser/v2 v2.5.16 - golang.org/x/crypto v0.57.0 // indirect - golang.org/x/net v0.60.0 - golang.org/x/sys v0.48.0 - golang.org/x/term v0.46.0 - golang.org/x/text v0.42.0 + golang.org/x/crypto v0.58.0 // indirect + golang.org/x/net v0.62.0 + golang.org/x/sys v0.49.0 + golang.org/x/term v0.47.0 + golang.org/x/text v0.43.0 gopkg.in/AlecAivazis/survey.v1 v1.8.8 gopkg.in/toast.v1 v1.0.0-20180812000517-0a84660828b2 gopkg.in/yaml.v2 v2.4.0 @@ -76,7 +76,6 @@ require ( github.com/gowebpki/jcs v1.0.1 github.com/mark3labs/mcp-go v0.33.0 github.com/mholt/archives v0.1.5 - github.com/zijiren233/yaml-comment v0.2.1 ) require ( @@ -118,7 +117,6 @@ require ( github.com/klauspost/pgzip v1.2.6 // indirect github.com/kr/pty v1.1.8 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/maruel/natural v1.1.0 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mikelolasagasti/xz v1.0.1 // indirect github.com/minio/minlz v1.0.1 // indirect @@ -136,7 +134,7 @@ require ( github.com/spf13/afero v1.15.0 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go4.org v0.0.0-20230225012048-214862532bf5 // indirect - golang.org/x/sync v0.23.0 // indirect + golang.org/x/sync v0.24.0 // indirect ) require ( @@ -191,7 +189,7 @@ require ( go.mongodb.org/mongo-driver v1.17.7 // indirect golang.org/x/mod v0.41.0 golang.org/x/time v0.15.0 // indirect - golang.org/x/tools v0.49.0 // indirect + golang.org/x/tools v0.51.0 // indirect gopkg.in/fsnotify.v1 v1.4.7 // indirect gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect diff --git a/go.sum b/go.sum index 7dab0fdf09..20b3ce1428 100644 --- a/go.sum +++ b/go.sum @@ -499,8 +499,8 @@ github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/labstack/echo/v4 v4.15.4 h1:DL45vVYa+BWE+XuW+zZNd9H0YEdZ80UAWJGcTVW4EVs= -github.com/labstack/echo/v4 v4.15.4/go.mod h1:CuMetKIRwsuO/qlAgMq+KTAalwGoB/h4tC+yPdrTj1g= +github.com/labstack/echo/v4 v4.16.1 h1:h05NuF1GvNu6jSAZxOv++AdjD/NJK+mZdGy0thiiuns= +github.com/labstack/echo/v4 v4.16.1/go.mod h1:V/EQaTVHYJH92QhbQe7kFQ4mjgcjxLQQUQtAN87zZdw= github.com/labstack/gommon v0.5.0 h1:6VSQ2NOzsnEJ5W6+84E0RbcaDDmgB6NIAzWCczTEe6c= github.com/labstack/gommon v0.5.0/go.mod h1:Rzlg7HHy1maLfzBYGg9NZcVuz1sA68HHhLjhcEllYE0= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= @@ -520,8 +520,6 @@ github.com/mark3labs/mcp-go v0.33.0 h1:naxhjnTIs/tyPZmWUZFuG0lDmdA6sUyYGGf3gsHvT github.com/mark3labs/mcp-go v0.33.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4= github.com/markbates/oncer v0.0.0-20181203154359-bf2de49a0be2/go.mod h1:Ld9puTsIW75CHf65OeIOkyKbteujpZVXDpWK6YGZbxE= github.com/markbates/safe v1.0.1/go.mod h1:nAqgmRi7cY2nqMc92/bSEeQA+R4OheNU2T1kNSCBdG0= -github.com/maruel/natural v1.1.0 h1:2z1NgP/Vae+gYrtC0VuvrTJ6U35OuyUqDdfluLqMWuQ= -github.com/maruel/natural v1.1.0/go.mod h1:eFVhYCcUOfZFxXoDZam8Ktya72wa79fNC3lc/leA0DQ= github.com/mash/go-tempfile-suffix v0.0.0-20150731093933-48f0f8a3a5ab h1:2lWb5W+cZTlTIrJUuzVMrs6vcEvY0ydkkMH66EdPaqc= github.com/mash/go-tempfile-suffix v0.0.0-20150731093933-48f0f8a3a5ab/go.mod h1:oBJAJTenVXse2frk3J4anZ7funxla2ZdJHFQShkD43k= github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= @@ -745,8 +743,6 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= -github.com/zijiren233/yaml-comment v0.2.1 h1:/ymMfauuR6zPme+c59FvGNmvxmjOS+BRZSU9YEM82g4= -github.com/zijiren233/yaml-comment v0.2.1/go.mod h1:Cps/tIAJVNoG6TpNN1Aaht/O1QGthZzOgUMQXXTxlBs= go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= go.mongodb.org/mongo-driver v1.0.3/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM= go.mongodb.org/mongo-driver v1.1.1/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM= @@ -784,8 +780,8 @@ golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= -golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= +golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -850,8 +846,8 @@ golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1 golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= -golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY= +golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -866,8 +862,8 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= -golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= +golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -914,13 +910,13 @@ golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= -golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= -golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= +golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM= +golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -931,8 +927,8 @@ golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= -golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= +golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= @@ -974,8 +970,8 @@ golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapK golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= -golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= +golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI= +golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/hail/hail_test.go b/internal/hail/hail_test.go index 286c798d15..6cf31cf100 100644 --- a/internal/hail/hail_test.go +++ b/internal/hail/hail_test.go @@ -52,11 +52,16 @@ func TestOpen(t *testing.T) { file = tempFile.Name() rcvs, err := Open(ctx, file) + require.NoError(t, err) defer func() { + // Stop the watcher and wait for it to finish before removing the file: it reads the file on every event, and + // on Windows a file that is still open can't be removed. + cancel() + for range rcvs { + } _ = tempFile.Close() assert.NoError(t, os.Remove(file)) }() - require.NoError(t, err) postOpen := time.Now() data := []byte("some data") diff --git a/internal/python/wheel/metadata.go b/internal/python/wheel/metadata.go index 83c8de47da..a1a87bbe14 100644 --- a/internal/python/wheel/metadata.go +++ b/internal/python/wheel/metadata.go @@ -4,18 +4,40 @@ import ( "os" "path/filepath" "regexp" + "slices" "strings" "github.com/ActiveState/cli/internal/errs" "github.com/BurntSushi/toml" ) +// MissingMetadataError reports which of name and version neither the caller nor +// pyproject.toml supplied, and what pyproject.toml said about them, so the user +// can be told how to provide them. +type MissingMetadataError struct { + Fields []string + // Pyproject is the pyproject.toml that was read, or empty when there is none. + Pyproject string + // Dynamic holds the missing fields pyproject.toml lists in [project].dynamic, + // which a build backend computes and so cannot be read from the file. + Dynamic []string +} + +func (e *MissingMetadataError) Error() string { + return "package " + strings.Join(e.Fields, " and ") + " could not be determined" +} + +func (e *MissingMetadataError) Is(target error) bool { + return target == ErrMissingMetadata +} + // ResolveMetadata reads srcDir's pyproject.toml [project] table and applies the // non-empty fields of override on top, producing the metadata to pack with. It -// errors when neither source supplies a name or version. Requires always comes -// from pyproject.toml's dependencies. +// returns a *MissingMetadataError when neither source supplies a name or +// version. Requires always comes from pyproject.toml's dependencies. func ResolveMetadata(srcDir string, override Metadata) (*Metadata, error) { - proj, err := readPyProject(filepath.Join(srcDir, "pyproject.toml")) + path := filepath.Join(srcDir, "pyproject.toml") + proj, found, err := readPyProject(path) if err != nil { return nil, err } @@ -26,8 +48,23 @@ func ResolveMetadata(srcDir string, override Metadata) (*Metadata, error) { Summary: firstNonEmpty(override.Summary, proj.Description), Requires: proj.Dependencies, } - if meta.Name == "" || meta.Version == "" { - return nil, ErrMissingMetadata + missing := &MissingMetadataError{} + if found { + missing.Pyproject = path + } + for field, value := range map[string]string{"name": meta.Name, "version": meta.Version} { + if value != "" { + continue + } + missing.Fields = append(missing.Fields, field) + if slices.Contains(proj.Dynamic, field) { + missing.Dynamic = append(missing.Dynamic, field) + } + } + if len(missing.Fields) > 0 { + slices.Sort(missing.Fields) // "name" before "version", regardless of map order + slices.Sort(missing.Dynamic) + return nil, missing } return &meta, nil } @@ -37,17 +74,19 @@ type pyProject struct { Version string Description string Dependencies []string + Dynamic []string } -// readPyProject reads the [project] table from a pyproject.toml. A missing file -// is not an error; caller-supplied metadata may stand in for it. -func readPyProject(path string) (pyProject, error) { +// readPyProject reads the [project] table from a pyproject.toml and reports +// whether the file exists. A missing file is not an error; caller-supplied +// metadata may stand in for it. +func readPyProject(path string) (pyProject, bool, error) { data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { - return pyProject{}, nil + return pyProject{}, false, nil } - return pyProject{}, errs.Wrap(err, "could not read pyproject.toml") + return pyProject{}, false, errs.Wrap(err, "could not read %s", path) } var parsed struct { @@ -56,17 +95,19 @@ func readPyProject(path string) (pyProject, error) { Version string `toml:"version"` Description string `toml:"description"` Dependencies []string `toml:"dependencies"` + Dynamic []string `toml:"dynamic"` } `toml:"project"` } if err := toml.Unmarshal(data, &parsed); err != nil { - return pyProject{}, errs.Wrap(err, "could not parse pyproject.toml") + return pyProject{}, true, errs.Wrap(err, "could not parse %s", path) } return pyProject{ Name: parsed.Project.Name, Version: parsed.Project.Version, Description: parsed.Project.Description, Dependencies: parsed.Project.Dependencies, - }, nil + Dynamic: parsed.Project.Dynamic, + }, true, nil } func firstNonEmpty(values ...string) string { diff --git a/internal/python/wheel/metadata_test.go b/internal/python/wheel/metadata_test.go index 2147e88ddc..73af082023 100644 --- a/internal/python/wheel/metadata_test.go +++ b/internal/python/wheel/metadata_test.go @@ -4,6 +4,7 @@ import ( "errors" "os" "path/filepath" + "slices" "testing" ) @@ -53,9 +54,33 @@ func TestResolveMetadata(t *testing.T) { t.Run("missing name and version errors", func(t *testing.T) { dir := t.TempDir() // no pyproject.toml - if _, err := ResolveMetadata(dir, Metadata{Name: "only-name"}); !errors.Is(err, ErrMissingMetadata) { + _, err := ResolveMetadata(dir, Metadata{Name: "only-name"}) + if !errors.Is(err, ErrMissingMetadata) { t.Errorf("error = %v, want ErrMissingMetadata", err) } + var missing *MissingMetadataError + if !errors.As(err, &missing) { + t.Fatalf("error = %T, want *MissingMetadataError", err) + } + if !slices.Equal(missing.Fields, []string{"version"}) || missing.Pyproject != "" { + t.Errorf("got %+v, want only version missing and no pyproject.toml", missing) + } + }) + + t.Run("dynamic fields are reported", func(t *testing.T) { + dir := t.TempDir() + writePyproject(t, dir, "[project]\ndynamic = [\"version\"]\n") + _, err := ResolveMetadata(dir, Metadata{}) + var missing *MissingMetadataError + if !errors.As(err, &missing) { + t.Fatalf("error = %v, want *MissingMetadataError", err) + } + if !slices.Equal(missing.Fields, []string{"name", "version"}) || !slices.Equal(missing.Dynamic, []string{"version"}) { + t.Errorf("got %+v", missing) + } + if missing.Pyproject != filepath.Join(dir, "pyproject.toml") { + t.Errorf("Pyproject = %q", missing.Pyproject) + } }) } diff --git a/internal/runners/export/ingredientmeta.go b/internal/runners/export/ingredientmeta.go index 2dc33b3dfe..8f1db78d82 100644 --- a/internal/runners/export/ingredientmeta.go +++ b/internal/runners/export/ingredientmeta.go @@ -1,10 +1,13 @@ package export import ( + "io" + "github.com/ActiveState/cli/internal/captain" "github.com/ActiveState/cli/internal/errs" "github.com/ActiveState/cli/internal/locale" "github.com/ActiveState/cli/internal/runners/publish" + "github.com/ActiveState/cli/pkg/platform/api/graphql/request" ) type IngredientMetaParams struct { @@ -30,11 +33,18 @@ func (i *IngredientMeta) Run(params *IngredientMetaParams) error { return errs.Wrap(err, "Could not fetch ingredient meta data") } + return writeIngredientMeta(i.prime.Output().Config().OutWriter, meta) +} + +// writeIngredientMeta writes meta in the format --meta reads. It is written as is: the outputer's Print would word-wrap +// long lines and interpret [TAGS]. +func writeIngredientMeta(w io.Writer, meta *request.PublishVariables) error { b, err := meta.MarshalYaml(false) if err != nil { return errs.Wrap(err, "Could not marshal ingredient meta data") } - - i.prime.Output().Print(string(b)) + if _, err := w.Write(b); err != nil { + return errs.Wrap(err, "Could not write ingredient meta data") + } return nil } diff --git a/internal/runners/export/ingredientmeta_test.go b/internal/runners/export/ingredientmeta_test.go new file mode 100644 index 0000000000..290d919806 --- /dev/null +++ b/internal/runners/export/ingredientmeta_test.go @@ -0,0 +1,38 @@ +package export + +import ( + "bytes" + "strings" + "testing" + + "github.com/ActiveState/cli/pkg/platform/api/graphql/request" + "gopkg.in/yaml.v3" +) + +func TestWriteIngredientMeta(t *testing.T) { + meta := &request.PublishVariables{ + Name: "mypkg", + Namespace: "private/org/python", + Version: "1.0.0", + Description: strings.Repeat("a long description [RED]with tags[/RESET] ", 10), + Dependencies: []request.PublishVariableDep{{ + Dependency: request.Dependency{Name: "requests", Namespace: "language/python", VersionRequirements: ">=2"}, + }}, + } + + var out bytes.Buffer + if err := writeIngredientMeta(&out, meta); err != nil { + t.Fatal(err) + } + if strings.Contains(out.String(), "#") { + t.Errorf("meta file contains comments:\n%s", out.String()) + } + + var got request.PublishVariables + if err := yaml.Unmarshal(out.Bytes(), &got); err != nil { + t.Fatalf("meta file is not valid yaml: %v\n%s", err, out.String()) + } + if got.Description != meta.Description || len(got.Dependencies) != 1 || got.Dependencies[0].Dependency != meta.Dependencies[0].Dependency { + t.Errorf("meta file does not round-trip:\n%s", out.String()) + } +} diff --git a/internal/runners/publish/build.go b/internal/runners/publish/build.go index 3912b05ca7..1152ae5389 100644 --- a/internal/runners/publish/build.go +++ b/internal/runners/publish/build.go @@ -3,8 +3,10 @@ package publish import ( "cmp" "context" + "errors" "os" "path/filepath" + "slices" "strings" "github.com/ActiveState/cli/internal/archiver" @@ -42,7 +44,11 @@ func (r *Runner) generateEncryptedArtifact(params *Params, metaFile request.Publ Summary: cmp.Or(params.Description, metaFile.Description), }) if err != nil { - return nil, nil, locale.WrapInputError(err, "err_publish_build_metadata", "Could not determine the ingredient name and version: {{.V0}}", errs.JoinMessage(err)) + var missing *wheel.MissingMetadataError + if errors.As(err, &missing) { + return nil, nil, missingMetadataError(missing, params.Build) + } + return nil, nil, locale.WrapInputError(err, "err_publish_build_metadata", "Could not read the ingredient metadata: {{.V0}}", errs.JoinMessage(err)) } // Fetch and validate the org key before building anything: a private publish @@ -80,6 +86,30 @@ func (r *Runner) generateEncryptedArtifact(params *Params, metaFile request.Publ return meta, cleanup, nil } +// missingMetadataError tells the user which of name and version are missing, why pyproject.toml did not supply them, +// and where to set them instead. +func missingMetadataError(missing *wheel.MissingMetadataError, srcDir string) error { + var lines []string + for _, field := range missing.Fields { + var reason string + switch { + case missing.Pyproject == "": + reason = locale.Tl("publish_metadata_no_pyproject", "there is no pyproject.toml in {{.V0}}", srcDir) + case slices.Contains(missing.Dynamic, field): + reason = locale.Tl("publish_metadata_dynamic", + "{{.V0}} lists it in [project].dynamic, so it is computed by a build backend and cannot be read from the file", + missing.Pyproject) + default: + reason = locale.Tl("publish_metadata_absent", "{{.V0}} has no '{{.V1}}' in its [project] table", missing.Pyproject, field) + } + lines = append(lines, locale.Tl("publish_metadata_missing_field", + " - [ACTIONABLE]{{.V0}}[/RESET]: {{.V1}}. Set it in pyproject.toml, with '[ACTIONABLE]--{{.V0}}[/RESET]', or as '[ACTIONABLE]{{.V0}}:[/RESET]' in a '[ACTIONABLE]--meta[/RESET]' file.", + field, reason)) + } + return locale.NewInputError("err_publish_build_metadata_missing", + "Could not determine the ingredient {{.V0}}:\n{{.V1}}", strings.Join(missing.Fields, " and "), strings.Join(lines, "\n")) +} + // requireOrgNamespace ensures ns belongs to the project owner's private org, so // an artifact encrypted under that org's key is published under that same org // and stays decryptable by its consumers. diff --git a/internal/runners/publish/build_test.go b/internal/runners/publish/build_test.go index 3bb54f1dc3..9bada6f744 100644 --- a/internal/runners/publish/build_test.go +++ b/internal/runners/publish/build_test.go @@ -193,3 +193,28 @@ func TestAddPrivateBuilderDependency(t *testing.T) { t.Errorf("private-builder was added twice: %+v", reqVars.Dependencies) } } + +func TestMissingMetadataError(t *testing.T) { + err := missingMetadataError(&wheel.MissingMetadataError{ + Fields: []string{"name", "version"}, + Pyproject: "src/pyproject.toml", + Dynamic: []string{"version"}, + }, "src") + msg := err.Error() + for _, want := range []string{ + "Could not determine the ingredient name and version", + "src/pyproject.toml has no 'name' in its [project] table", + "--name", + "src/pyproject.toml lists it in [project].dynamic", + "--version", + } { + if !strings.Contains(msg, want) { + t.Errorf("message does not contain %q:\n%s", want, msg) + } + } + + msg = missingMetadataError(&wheel.MissingMetadataError{Fields: []string{"version"}}, "src").Error() + if !strings.Contains(msg, "there is no pyproject.toml in src") { + t.Errorf("message does not explain the missing pyproject.toml:\n%s", msg) + } +} diff --git a/internal/runners/publish/confirm_test.go b/internal/runners/publish/confirm_test.go new file mode 100644 index 0000000000..42fdea8c53 --- /dev/null +++ b/internal/runners/publish/confirm_test.go @@ -0,0 +1,358 @@ +package publish + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/ActiveState/cli/internal/errs" + "github.com/ActiveState/cli/internal/prompt" + "github.com/ActiveState/cli/internal/rtutils/ptr" + "github.com/ActiveState/cli/internal/testhelpers/outputhelper" + "github.com/ActiveState/cli/pkg/platform/api/graphql/request" + "github.com/ActiveState/cli/pkg/platform/api/inventory/inventory_models" + "gopkg.in/yaml.v3" +) + +// editorContentEnv makes the test binary act as $EDITOR: it writes the variable's value to the file it is given. +const editorContentEnv = "PUBLISH_TEST_EDITOR_CONTENT" + +func TestMain(m *testing.M) { + if content, ok := os.LookupEnv(editorContentEnv); ok && len(os.Args) == 2 { + if err := os.WriteFile(os.Args[1], []byte(content), 0644); err != nil { + os.Exit(1) + } + os.Exit(0) + } + os.Exit(m.Run()) +} + +// useTestEditor points $EDITOR at the test binary, which replaces the edited file with content. +func useTestEditor(t *testing.T, content string) { + t.Helper() + exe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + t.Setenv("EDITOR", exe) + t.Setenv(editorContentEnv, content) +} + +// fakePrompter answers Confirm and Select from queues; any other prompt panics through the nil embedded Prompter. +type fakePrompter struct { + prompt.Prompter + confirms []bool + selects []int // index into the offered choices + messages []string +} + +func (f *fakePrompter) Confirm(_, message string, _, _ *bool) (bool, error) { + f.messages = append(f.messages, message) + if len(f.confirms) == 0 { + return false, errs.New("unexpected confirmation: %s", message) + } + answer := f.confirms[0] + f.confirms = f.confirms[1:] + return answer, nil +} + +func (f *fakePrompter) Select(_, message string, choices []string, _, _ *string) (string, error) { + if len(f.selects) == 0 { + return "", errs.New("unexpected selection: %s", message) + } + choice := choices[f.selects[0]] + f.selects = f.selects[1:] + return choice, nil +} + +func (f *fakePrompter) Input(_, _ string, _, _ *string, _ ...prompt.ValidatorFlag) (string, error) { + return "", nil // "Press enter when done editing" +} + +const ( + choiceEdit = iota + choiceSave + choiceCancel +) + +func newRequest() *request.PublishVariables { + return &request.PublishVariables{ + Name: "mypkg", + Namespace: "private/org/python", + Version: "1.0.0", + Dependencies: []request.PublishVariableDep{{ + Dependency: request.Dependency{Name: "requests", Namespace: "language/python", Type: request.DependencyTypeRuntime}, + Conditions: []request.Dependency{{Name: "Windows", Namespace: "kernel"}}, + }}, + } +} + +func TestConfirmRequestFillsVersionRequirements(t *testing.T) { + p := &fakePrompter{confirms: []bool{true}} + r := &Runner{out: outputhelper.NewCatcher(), prompt: p} + reqVars := newRequest() + + publish, err := r.confirmRequest(reqVars, &Params{}, false, "") + if err != nil || !publish { + t.Fatalf("publish = %v, err = %v", publish, err) + } + dep := reqVars.Dependencies[0] + if dep.VersionRequirements != ">=0" || dep.Conditions[0].VersionRequirements != ">=0" { + t.Errorf("version requirements not filled in: %+v", dep) + } + if !strings.Contains(p.messages[0], "versionRequirements: '>=0'") { + t.Errorf("confirmation does not show the filled in requirements:\n%s", p.messages[0]) + } + if strings.Contains(p.messages[0], "#") { + t.Errorf("confirmation shows comments:\n%s", p.messages[0]) + } +} + +func TestOpenInEditorWithoutEditor(t *testing.T) { + t.Setenv("EDITOR", filepath.Join(t.TempDir(), "no-such-editor")) + out := outputhelper.NewCatcher() + r := &Runner{out: out, prompt: &fakePrompter{}} + reqVars := newRequest() + + if err := r.OpenInEditor(reqVars); err != nil { + t.Fatalf("OpenInEditor failed without an editor instead of waiting for the file to be edited: %v", err) + } + if reqVars.Name != newRequest().Name { + t.Errorf("name = %q, want it unchanged", reqVars.Name) + } + if !strings.Contains(out.CombinedOutput(), "Could not open an editor") { + t.Errorf("output does not say the editor could not be opened:\n%s", out.CombinedOutput()) + } +} + +func TestConfirmRequestEditor(t *testing.T) { + t.Run("removed fields are removed", func(t *testing.T) { + useTestEditor(t, "name: mypkg\nnamespace: private/org/python\nversion: 1.0.0\n") + p := &fakePrompter{confirms: []bool{false, true}, selects: []int{choiceEdit}} + r := &Runner{out: outputhelper.NewCatcher(), prompt: p} + reqVars := newRequest() + + publish, err := r.confirmRequest(reqVars, &Params{}, false, "") + if err != nil || !publish { + t.Fatalf("publish = %v, err = %v", publish, err) + } + if len(reqVars.Dependencies) != 0 { + t.Errorf("dependencies removed in the editor were kept: %+v", reqVars.Dependencies) + } + }) + + t.Run("namespace change is kept", func(t *testing.T) { + useTestEditor(t, "name: mypkg\nnamespace: private/org/other\nversion: 1.0.0\n") + p := &fakePrompter{confirms: []bool{false, true}, selects: []int{choiceEdit}} + r := &Runner{out: outputhelper.NewCatcher(), prompt: p, versionExists: noVersions} + reqVars := newRequest() + + publish, err := r.confirmRequest(reqVars, &Params{}, false, "") + if err != nil || !publish { + t.Fatalf("publish = %v, err = %v", publish, err) + } + if reqVars.Namespace != "private/org/other" { + t.Errorf("namespace = %q, want the edited one", reqVars.Namespace) + } + }) + + for name, tt := range map[string]struct { + namespace string + params Params + exists bool + reason string + }{ + "cleared namespace is put back": {"", Params{}, false, "namespace is required"}, + "namespace with the version is put back": {"private/org/other", Params{}, true, "already exists"}, + "namespace outside the org is put back": {"private/other/python", Params{Build: "src"}, false, "requires a namespace under"}, + } { + t.Run(name, func(t *testing.T) { + useTestEditor(t, "name: mypkg\nnamespace: "+tt.namespace+"\nversion: 1.0.0\n") + out := outputhelper.NewCatcher() + p := &fakePrompter{confirms: []bool{false, true}, selects: []int{choiceEdit}} + r := &Runner{out: out, prompt: p, versionExists: func(_, _, _ string) (bool, error) { return tt.exists, nil }} + reqVars := newRequest() + + publish, err := r.confirmRequest(reqVars, &tt.params, false, "") + if err != nil || !publish { + t.Fatalf("publish = %v, err = %v", publish, err) + } + if reqVars.Namespace != "private/org/python" { + t.Errorf("namespace = %q, want the original one put back", reqVars.Namespace) + } + if !strings.Contains(out.CombinedOutput(), tt.reason) { + t.Errorf("output does not explain why (%q):\n%s", tt.reason, out.CombinedOutput()) + } + }) + } + + t.Run("name change with --build is saved instead", func(t *testing.T) { + t.Chdir(t.TempDir()) + useTestEditor(t, "name: otherpkg\nnamespace: private/org/python\nversion: 1.0.0\n") + p := &fakePrompter{confirms: []bool{false}, selects: []int{choiceEdit}} + r := &Runner{out: outputhelper.NewCatcher(), prompt: p} + + _, err := r.confirmRequest(newRequest(), &Params{Build: "src"}, false, "") + if err == nil || !strings.Contains(err.Error(), "The name can't be changed") { + t.Fatalf("err = %v, want the name named as fixed", err) + } + if !strings.Contains(p.messages[0], "name and version are built into the package") { + t.Errorf("confirmation does not say up front what is fixed:\n%s", p.messages[0]) + } + if _, err := os.Stat("otherpkg-1.0.0.meta.yaml"); err != nil { + t.Errorf("edits were not saved: %v", err) + } + }) + + t.Run("namespace change with --edit is saved instead", func(t *testing.T) { + t.Chdir(t.TempDir()) + useTestEditor(t, "name: mypkg\nnamespace: private/org/other\nversion: 1.0.0\n") + p := &fakePrompter{confirms: []bool{false}, selects: []int{choiceEdit}} + r := &Runner{out: outputhelper.NewCatcher(), prompt: p} + + _, err := r.confirmRequest(newRequest(), &Params{Edit: true}, true, "") + if err == nil || !strings.Contains(err.Error(), "The namespace can't be changed") { + t.Errorf("err = %v, want the namespace named as fixed", err) + } + }) + + t.Run("changed description on a revision is ignored", func(t *testing.T) { + useTestEditor(t, "name: mypkg\nnamespace: private/org/python\nversion: 1.0.0\ndescription: changed\n") + p := &fakePrompter{confirms: []bool{false, true}, selects: []int{choiceEdit}} + r := &Runner{out: outputhelper.NewCatcher(), prompt: p} + reqVars := newRequest() + + publish, err := r.confirmRequest(reqVars, &Params{Edit: true}, true, "current") + if err != nil || !publish { + t.Fatalf("publish = %v, err = %v, want it still published", publish, err) + } + if reqVars.Description != "current" { + t.Errorf("description = %q, want the current one kept", reqVars.Description) + } + if !strings.Contains(p.messages[1], "changed[/RESET]' is ignored and it stays '[ACTIONABLE]current") { + t.Errorf("confirmation does not say the change is ignored:\n%s", p.messages[1]) + } + }) + + t.Run("unchanged description on a revision is accepted", func(t *testing.T) { + useTestEditor(t, "name: mypkg\nnamespace: private/org/python\nversion: 1.0.0\ndescription: current\n") + p := &fakePrompter{confirms: []bool{false, true}, selects: []int{choiceEdit}} + r := &Runner{out: outputhelper.NewCatcher(), prompt: p} + + publish, err := r.confirmRequest(newRequest(), &Params{Edit: true}, true, "current") + if err != nil || !publish { + t.Errorf("publish = %v, err = %v, want the unchanged description accepted", publish, err) + } + if strings.Contains(p.messages[1], "is ignored") { + t.Errorf("confirmation notes an ignored description that wasn't changed:\n%s", p.messages[1]) + } + }) + + t.Run("private-builder is restored with --build", func(t *testing.T) { + useTestEditor(t, "name: mypkg\nnamespace: private/org/python\nversion: 1.0.0\n") + p := &fakePrompter{confirms: []bool{false, true}, selects: []int{choiceEdit}} + r := &Runner{out: outputhelper.NewCatcher(), prompt: p} + reqVars := newRequest() + + if _, err := r.confirmRequest(reqVars, &Params{Build: "src"}, false, ""); err != nil { + t.Fatal(err) + } + if len(reqVars.Dependencies) != 1 || reqVars.Dependencies[0].Name != privateBuilderName { + t.Errorf("dependencies = %+v, want only the private-builder", reqVars.Dependencies) + } + }) +} + +// noVersions is a versionExists for a platform where nothing is published yet. +func noVersions(_, _, _ string) (bool, error) { return false, nil } + +func TestDefaultNamespace(t *testing.T) { + withPyproject := t.TempDir() + if err := os.WriteFile(filepath.Join(withPyproject, "pyproject.toml"), nil, 0644); err != nil { + t.Fatal(err) + } + for _, tt := range []struct{ buildDir, want string }{ + {"", "private/org"}, + {t.TempDir(), "private/org"}, + {withPyproject, "private/org/python"}, + } { + if got := defaultNamespace("org", tt.buildDir); got != tt.want { + t.Errorf("defaultNamespace(%q) = %q, want %q", tt.buildDir, got, tt.want) + } + } +} + +func TestConfirmRequestSave(t *testing.T) { + t.Chdir(t.TempDir()) + p := &fakePrompter{confirms: []bool{false}, selects: []int{choiceSave}} + r := &Runner{out: outputhelper.NewCatcher(), prompt: p} + + publish, err := r.confirmRequest(newRequest(), &Params{Build: "src"}, false, "") + if err != nil || publish { + t.Fatalf("publish = %v, err = %v, want saved", publish, err) + } + + b, err := os.ReadFile(filepath.Join(".", "mypkg-1.0.0.meta.yaml")) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(b), "#") { + t.Errorf("saved meta file contains comments:\n%s", b) + } + var saved request.PublishVariables + if err := yaml.Unmarshal(b, &saved); err != nil { + t.Fatal(err) + } + if saved.Name != "mypkg" || saved.Version != "1.0.0" || len(saved.Dependencies) != 2 || + saved.Dependencies[0].VersionRequirements != ">=0" { + t.Errorf("saved meta file is incomplete: %+v", saved) + } +} + +func TestConfirmRequestCancel(t *testing.T) { + p := &fakePrompter{confirms: []bool{false}, selects: []int{choiceCancel}} + r := &Runner{out: outputhelper.NewCatcher(), prompt: p} + + if _, err := r.confirmRequest(newRequest(), &Params{}, false, ""); err == nil || !strings.Contains(err.Error(), "cancelled") { + t.Errorf("err = %v, want cancelled", err) + } +} + +func TestPublishFailedError(t *testing.T) { + msg := publishFailedError(errs.New("API responded with error: File to upload was not provided")).Error() + if !strings.Contains(msg, "File to upload was not provided") || strings.Contains(msg, "") { + t.Errorf("message does not include the API error:\n%s", msg) + } +} + +func TestMetaFromIngredient(t *testing.T) { + ingredient := &ParentIngredient{Version: "1.0.0", Dependencies: windowsOnlyDependency()} + + meta, err := metaFromIngredient("private/org/python", "mypkg", ingredient, "Internal helpers") + if err != nil { + t.Fatal(err) + } + if len(meta.Dependencies) != 1 || len(meta.Dependencies[0].Conditions) != 1 { + t.Fatalf("dependencies = %+v", meta.Dependencies) + } + if got := meta.Dependencies[0].Conditions[0].VersionRequirements; got != ">=0" { + t.Errorf("condition without requirements exported as %q, want >=0", got) + } + if meta.Description != "Internal helpers" { + t.Errorf("description exported as %q", meta.Description) + } +} + +// windowsOnlyDependency is a revision's dependency on requests, conditioned on Windows without a version requirement. +func windowsOnlyDependency() []inventory_models.Dependency { + return []inventory_models.Dependency{{ + Feature: ptr.To("requests"), + Namespace: ptr.To("language/python"), + Type: inventory_models.DependencyTypeRuntime, + Conditions: []*inventory_models.DependencyCondition{{Condition: inventory_models.Condition{ + Feature: ptr.To("Windows"), + Namespace: ptr.To("kernel"), + }}}, + }} +} diff --git a/internal/runners/publish/meta.go b/internal/runners/publish/meta.go index 5f157da642..2d831779ef 100644 --- a/internal/runners/publish/meta.go +++ b/internal/runners/publish/meta.go @@ -5,16 +5,18 @@ import ( "github.com/ActiveState/cli/internal/errs" "github.com/ActiveState/cli/internal/locale" + "github.com/ActiveState/cli/internal/rtutils/ptr" "github.com/ActiveState/cli/pkg/platform/api/graphql/request" "github.com/ActiveState/cli/pkg/platform/api/inventory/inventory_client/inventory_operations" "github.com/ActiveState/cli/pkg/platform/authentication" "github.com/ActiveState/cli/pkg/platform/model" + "github.com/go-openapi/strfmt" ) // FetchMeta returns the meta file contents of an existing ingredient version (the latest one when version is empty), // in the format the --meta flag accepts. Its dependencies are the complete set, so the file can be modified and -// passed back with --edit to replace them. Authors and description are left out because the platform rejects them -// on a revision of an existing version. +// passed back with --edit to replace them. Authors are left out because the platform rejects them on a revision of an +// existing version; the description is included, and --edit accepts it as long as it is unchanged. func FetchMeta(namespace, name, version string, auth *authentication.Auth) (*request.PublishVariables, error) { latestRevisionTime, err := model.FetchLatestRevisionTimeStamp(auth) if err != nil { @@ -48,9 +50,30 @@ func FetchMeta(namespace, name, version string, auth *authentication.Auth) (*req ingredient = &ParentIngredient{*i.IngredientID, *i.IngredientVersionID, *i.Version, i.Dependencies} } - meta := &request.PublishVariables{Name: name, Namespace: namespace} - if err := prepareEditRequest(ingredient, meta, true, auth); err != nil { + description, err := fetchDescription(ingredient.IngredientID, auth) + if err != nil { + return nil, err + } + return metaFromIngredient(namespace, name, ingredient, description) +} + +// fetchDescription returns an ingredient's description, which belongs to the ingredient rather than to its versions. +func fetchDescription(ingredientID strfmt.UUID, auth *authentication.Auth) (string, error) { + ingredient, err := model.FetchIngredient(&ingredientID, auth) + if err != nil { + return "", locale.WrapError(err, "err_ingredient_meta_fetch", "Could not fetch the ingredient's description") + } + return ptr.From(ingredient.Description, ""), nil +} + +// metaFromIngredient builds the meta file contents of an existing ingredient version, complete enough to be published +// back as is: dependencies and conditions without a version requirement get the any-version one. +func metaFromIngredient(namespace, name string, ingredient *ParentIngredient, description string) (*request.PublishVariables, error) { + meta := &request.PublishVariables{Name: name, Namespace: namespace, Description: description} + // As a revision, prepareEditRequest doesn't fetch authors, so it needs no auth. + if err := prepareEditRequest(ingredient, meta, true, nil); err != nil { return nil, errs.Wrap(err, "Could not read ingredient meta data") } + fillVersionRequirements(meta) return meta, nil } diff --git a/internal/runners/publish/publish.go b/internal/runners/publish/publish.go index 4ca7e621fa..f13fd25055 100644 --- a/internal/runners/publish/publish.go +++ b/internal/runners/publish/publish.go @@ -58,6 +58,8 @@ type Runner struct { project *project.Project cfg *config.Instance bp *buildplanner.BuildPlanner + // versionExists reports whether a version of an ingredient is published; tests replace it. + versionExists func(namespace, name, version string) (bool, error) } type primeable interface { @@ -70,7 +72,7 @@ type primeable interface { } func New(prime primeable) *Runner { - return &Runner{ + r := &Runner{ auth: prime.Auth(), out: prime.Output(), prompt: prime.Prompt(), @@ -78,6 +80,8 @@ func New(prime primeable) *Runner { cfg: prime.Config(), bp: buildplanner.NewBuildPlannerModel(prime.Auth(), prime.SvcModel()), } + r.versionExists = r.ingredientVersionExists + return r } type ParentIngredient struct { @@ -144,7 +148,7 @@ func (r *Runner) Run(params *Params) error { if params.Namespace != "" { reqVars.Namespace = params.Namespace } else if reqVars.Namespace == "" && r.project != nil && r.project.Owner() != "" { - reqVars.Namespace = model.NewNamespaceOrg(r.project.Owner(), "").String() + reqVars.Namespace = defaultNamespace(r.project.Owner(), params.Build) } // A --build publish is encrypted under the project org's key, so its resolved @@ -171,6 +175,10 @@ func (r *Runner) Run(params *Params) error { reqVars.Name = name } + // A new ingredient without a namespace yet has nothing to look up: a search across every namespace can time out, + // and its first match could be any ingredient with this name. The namespace is checked once it is filled in. + lookup := reqVars.Namespace != "" || params.Edit + var ingredient *ParentIngredient latestRevisionTime, err := model.FetchLatestRevisionTimeStamp(r.auth) @@ -179,7 +187,7 @@ func (r *Runner) Run(params *Params) error { } isRevision := false - if params.Version != "" { + if lookup && params.Version != "" { // Attempt to get the version if it already exists, it not existing is not an error though i, err := model.GetIngredientByNameAndVersion(reqVars.Namespace, reqVars.Name, params.Version, &latestRevisionTime, r.auth) if err != nil { @@ -193,7 +201,7 @@ func (r *Runner) Run(params *Params) error { } } - if ingredient == nil { + if lookup && ingredient == nil { // Attempt to find the existing ingredient, if we didn't already get it from the version specific call above ingredients, err := model.SearchIngredientsStrict(reqVars.Namespace, reqVars.Name, true, false, &latestRevisionTime, r.auth) var errSearch404 *model.ErrSearch404 @@ -209,6 +217,8 @@ func (r *Runner) Run(params *Params) error { } } + // The description of the ingredient a revision belongs to, which the revision can't change. + var currentDescription string if params.Edit { if ingredient == nil { return locale.NewInputError("err_uploadingredient_edit_not_found", @@ -218,6 +228,11 @@ func (r *Runner) Run(params *Params) error { if err := prepareEditRequest(ingredient, &reqVars, isRevision, r.auth); err != nil { return errs.Wrap(err, "Could not prepare edit request") } + if isRevision { + if currentDescription, err = fetchDescription(ingredient.IngredientID, r.auth); err != nil { + return err + } + } } else { if isRevision { return locale.NewInputError("err_uploadingredient_exists", @@ -239,52 +254,31 @@ func (r *Runner) Run(params *Params) error { return errs.Wrap(err, "Could not prepare request from params") } - if err := checkEditKeepsIdentity(&reqVars, params.Edit, isRevision); err != nil { - return err - } - - if params.Build != "" { - addPrivateBuilderDependency(&reqVars) - } - if params.Editor { if !r.out.Config().Interactive { return locale.NewInputError("err_uploadingredient_editor_not_supported", "Opening in editor is not supported in non-interactive mode") } - if err := r.OpenInEditor(&reqVars); err != nil { + if err := r.editRequest(&reqVars, params); err != nil { return err } } - if reqVars.Namespace == "" { - return locale.NewInputError("err_uploadingredient_namespace_required", "You have to supply the namespace when working outside of a project context") - } - - b, err := reqVars.MarshalYaml(false) + publish, err := r.confirmRequest(&reqVars, params, isRevision, currentDescription) if err != nil { - return errs.Wrap(err, "Could not marshal publish variables") + return err } - - cont, err := r.prompt.Confirm( - "", - locale.Tl("uploadingredient_confirm", `Prepared the following ingredient: - -{{.V0}} - -Do you want to publish this ingredient? -`, string(b)), ptr.To(true), nil) - if err != nil { - return errs.Wrap(err, "Not confirmed") + if !publish { + return nil // saved to a meta file instead } - if !cont { - return locale.NewInputError("uploadingredient_cancel", "Publish cancelled") + if params.Edit && isRevision { + reqVars.Description = "" // kept as it was, and the platform rejects a description on a revision } r.out.Notice(locale.Tl("uploadingredient_uploading", "Publishing ingredient...")) publishResult, err := r.bp.Publish(reqVars, params.Filepath) if err != nil { - return locale.WrapError(err, "err_uploadingredient_publish", "Could not create publish request") + return publishFailedError(err) } if publishResult.Error != "" { @@ -337,16 +331,56 @@ Do you want to publish this ingredient? var versionRegexp = regexp.MustCompile(`\d+\.\d+(\.\d+)?`) -// checkEditKeepsIdentity rejects author and description changes on a revision of an existing version, which the -// platform does not allow; it would otherwise fail the publish with an unclear API error. +// defaultNamespace is the namespace used when neither --namespace nor the meta file gives one: the owner's private +// namespace, under python when a pyproject.toml in the --build directory makes the package a Python one. +func defaultNamespace(owner, buildDir string) string { + if buildDir != "" && fileutils.FileExists(filepath.Join(buildDir, "pyproject.toml")) { + return model.NewNamespaceOrg(owner, "python").String() + } + return model.NewNamespaceOrg(owner, "").String() +} + +// ingredientVersionExists reports whether the given version of an ingredient is published. +func (r *Runner) ingredientVersionExists(namespace, name, version string) (bool, error) { + latestRevisionTime, err := model.FetchLatestRevisionTimeStamp(r.auth) + if err != nil { + return false, errs.Wrap(err, "Unable to determine latest revision time") + } + _, err = model.GetIngredientByNameAndVersion(namespace, name, version, &latestRevisionTime, r.auth) + var notFound *inventory_operations.GetNamespaceIngredientVersionNotFound + if errors.As(err, ¬Found) { + return false, nil + } + if err != nil { + return false, errs.Wrap(err, "could not get ingredient version") + } + return true, nil +} + +// checkEditKeepsIdentity rejects authors on a revision of an existing version, which the platform does not allow; it +// would otherwise fail the publish with an unclear API error. func checkEditKeepsIdentity(r *request.PublishVariables, edit, isRevision bool) error { - if edit && isRevision && (len(r.Authors) > 0 || r.Description != "") { + if edit && isRevision && len(r.Authors) > 0 { return locale.NewInputError("err_uploadingredient_edit_identity", - "Authors and description cannot be changed when editing an existing version. Remove them from the meta file and flags.") + "Authors cannot be changed when editing an existing version. Remove them from the meta file and flags.") } return nil } +// keepDescription shows a revision with its ingredient's current description, which can't be changed on an existing +// version. A different description is replaced rather than refused, and the returned note, ending in a newline, tells +// the user it is ignored; it is empty when there was nothing to ignore. +func keepDescription(r *request.PublishVariables, currentDescription string) string { + ignored := r.Description + r.Description = currentDescription + if ignored == "" || ignored == currentDescription { + return "" + } + return locale.Tl("uploadingredient_description_ignored", + "The description can't be changed on an existing version, so '[ACTIONABLE]{{.V0}}[/RESET]' is ignored and it stays '[ACTIONABLE]{{.V1}}[/RESET]'.", + ignored, currentDescription) + "\n" +} + func prepareRequestFromParams(r *request.PublishVariables, params *Params, isRevision bool) error { if params.Version != "" { r.Version = params.Version @@ -491,6 +525,256 @@ func prepareEditRequest(ingredient *ParentIngredient, r *request.PublishVariable return nil } +// confirmRequest shows the prepared request and asks the user to publish it. Until they confirm, they can edit it or +// save it to a meta file and start over with it. Each pass completes and validates the request again, since an edit +// can undo what was done to it before. It reports whether to publish: false means the request was saved instead. +func (r *Runner) confirmRequest(reqVars *request.PublishVariables, params *Params, isRevision bool, currentDescription string) (bool, error) { + for { + if params.Build != "" { + addPrivateBuilderDependency(reqVars) + } + fillVersionRequirements(reqVars) + if reqVars.Namespace == "" { + return false, locale.NewInputError("err_uploadingredient_namespace_required", "You have to supply the namespace when working outside of a project context") + } + if err := checkEditKeepsIdentity(reqVars, params.Edit, isRevision); err != nil { + return false, err + } + var descriptionNote string + if params.Edit && isRevision { + descriptionNote = keepDescription(reqVars, currentDescription) + } + + b, err := reqVars.MarshalYaml(false) + if err != nil { + return false, errs.Wrap(err, "Could not marshal publish variables") + } + + cont, err := r.prompt.Confirm( + "", + locale.Tl("uploadingredient_confirm", `Prepared the following ingredient: + +{{.V0}} +{{.V1}} +Do you want to publish this ingredient? Answer 'n' to edit it or save it to a file instead. +`, string(b), fixedFieldsNote(params)+descriptionNote), ptr.To(true), nil) + if err != nil { + return false, errs.Wrap(err, "Not confirmed") + } + if cont { + return true, nil + } + + saved, err := r.reviseRequest(reqVars, params) + if err != nil { + return false, err + } + if saved { + return false, nil + } + } +} + +// publishFailedError wraps a failed publish request. en-us.yaml's err_uploadingredient_publish ends in +// "Error received: {{.V0}}" and overrides the fallback text, so the message must be passed in, or the user sees +// "". It is also the only place some failures are shown: an error the API reports in its publish result +// (e.g. "File to upload was not provided") is a plain errs.New, which is not printed beneath this message the way a +// user-facing BuildPlannerError is. +func publishFailedError(err error) error { + return locale.WrapError(err, "err_uploadingredient_publish", "Could not create publish request", errs.JoinMessage(err)) +} + +// reviseRequest lets the user edit a request they did not confirm, or save it to a meta file to edit and publish later. +// It reports whether the request was saved, in which case nothing is published. +func (r *Runner) reviseRequest(reqVars *request.PublishVariables, params *Params) (bool, error) { + edit := locale.Tl("uploadingredient_choice_edit", "Edit it in my editor") + save := locale.Tl("uploadingredient_choice_save", "Save it to a meta file to edit and publish later") + cancel := locale.Tl("uploadingredient_choice_cancel", "Cancel") + choice, err := r.prompt.Select("", locale.Tl("uploadingredient_choice", "What would you like to do instead?"), + []string{edit, save, cancel}, &cancel, nil) + if err != nil { + return false, errs.Wrap(err, "Not selected") + } + + switch choice { + case edit: + return false, r.editRequest(reqVars, params) + case save: + path, err := r.saveMetaFile(reqVars) + if err != nil { + return false, err + } + r.out.Notice(locale.Tl("uploadingredient_saved", + "Saved the ingredient meta data to [ACTIONABLE]{{.V0}}[/RESET]. Edit it, then publish it with:\n\n [ACTIONABLE]{{.V1}}[/RESET]\n\n"+ + "The meta file takes precedence over pyproject.toml, so the dependencies it lists replace the ones there.", + path, rerunCommand(params, path))) + return true, nil + } + return false, locale.NewInputError("uploadingredient_cancel", "Publish cancelled") +} + +// editRequest opens the request in the user's editor. A new namespace is checked again and put back if it can't be +// used. Other fields that were already used can't be changed here: --build packed the name and version into the +// package, and --edit found the ingredient to revise by its namespace, name and version. Changing them would publish +// something inconsistent, so the edits are saved to a meta file instead, and the user is told to start over with it. +func (r *Runner) editRequest(reqVars *request.PublishVariables, params *Params) error { + if note := fixedFieldsNote(params); note != "" { + r.out.Notice(note + locale.Tl("uploadingredient_edit_fixed_saved", + "If you change them in the editor, your edits are saved to a meta file to publish from instead.")) + } + + before := *reqVars + if err := r.OpenInEditor(reqVars); err != nil { + return err + } + + if !params.Edit && reqVars.Namespace != before.Namespace { + if err := r.checkNamespaceEdit(reqVars, params); err != nil { + r.out.Notice(locale.Tl("uploadingredient_namespace_kept", + "Kept the namespace '[ACTIONABLE]{{.V0}}[/RESET]', because '[ACTIONABLE]{{.V1}}[/RESET]' can't be used: {{.V2}}", + before.Namespace, reqVars.Namespace, errs.JoinMessage(err))) + reqVars.Namespace = before.Namespace + } + } + + if params.Build == "" && !params.Edit { + return nil + } + var changed []string + if params.Edit && reqVars.Namespace != before.Namespace { + changed = append(changed, "namespace") + } + if reqVars.Name != before.Name { + changed = append(changed, "name") + } + if reqVars.Version != before.Version { + changed = append(changed, "version") + } + if len(changed) == 0 { + return nil + } + + path, err := r.saveMetaFile(reqVars) + if err != nil { + return err + } + reason := locale.Tl("uploadingredient_fixed_reason_build", "it was already built into the package") + if params.Edit { + reason = locale.Tl("uploadingredient_fixed_reason_edit", "it identifies the ingredient you are editing") + } + return locale.NewInputError("err_uploadingredient_edit_identity_changed", + "The {{.V0}} can't be changed at this point, because {{.V1}}. Your changes were saved to [ACTIONABLE]{{.V2}}[/RESET]. "+ + "Publish them with:\n\n [ACTIONABLE]{{.V3}}[/RESET]", + strings.Join(changed, " and "), reason, path, rerunCommand(params, path)) +} + +// fixedFieldsNote tells the user up front which fields can't be changed before publishing, and why, ending in a +// newline; it is empty when every field can be. +func fixedFieldsNote(params *Params) string { + switch { + case params.Edit: + return locale.Tl("uploadingredient_fixed_edit", + "The namespace, name and version identify the ingredient you are editing, so they can't be changed.") + "\n" + case params.Build != "": + return locale.Tl("uploadingredient_fixed_build", + "The name and version are built into the package, so they can't be changed. To change them, publish again with '[ACTIONABLE]--name[/RESET]' or '[ACTIONABLE]--version[/RESET]'.") + "\n" + } + return "" +} + +// checkNamespaceEdit checks a namespace changed in the editor the way Run checked the original one: a --build package +// must stay in the organization whose key encrypted it, and a new ingredient's version must not already exist in it. +func (r *Runner) checkNamespaceEdit(reqVars *request.PublishVariables, params *Params) error { + if reqVars.Namespace == "" { + return locale.NewInputError("err_uploadingredient_namespace_empty", "a namespace is required") + } + if params.Build != "" { + owner := "" + if r.project != nil { + owner = r.project.Owner() + } + if err := requireOrgNamespace(reqVars.Namespace, owner); err != nil { + return err + } + } + exists, err := r.versionExists(reqVars.Namespace, reqVars.Name, reqVars.Version) + if err != nil { + return err + } + if exists { + return locale.NewInputError("err_uploadingredient_namespace_version_exists", + "version {{.V0}} of '{{.V1}}' already exists in it", reqVars.Version, reqVars.Name) + } + return nil +} + +// saveMetaFile writes the request to -.meta.yaml in the working directory, in the format --meta reads, +// and returns its path. An existing file is only replaced if the user agrees. +func (r *Runner) saveMetaFile(reqVars *request.PublishVariables) (string, error) { + path, err := filepath.Abs(reqVars.Name + "-" + reqVars.Version + ".meta.yaml") + if err != nil { + return "", errs.Wrap(err, "Could not determine meta file path") + } + if fileutils.TargetExists(path) { + overwrite, err := r.prompt.Confirm("", locale.Tl("uploadingredient_meta_overwrite", + "[ACTIONABLE]{{.V0}}[/RESET] already exists. Do you want to replace it?", path), ptr.To(false), nil) + if err != nil { + return "", errs.Wrap(err, "Not confirmed") + } + if !overwrite { + return "", locale.NewInputError("uploadingredient_cancel", "Publish cancelled") + } + } + + b, err := reqVars.MarshalYaml(false) + if err != nil { + return "", errs.Wrap(err, "Could not marshal publish variables") + } + if err := fileutils.WriteFile(path, b); err != nil { + return "", locale.WrapExternalError(err, "err_uploadingredient_meta_write", "Could not write meta file: {{.V0}}", path) + } + return path, nil +} + +// rerunCommand is the publish command that picks up a saved meta file. Other flags are left out: their values are in +// the file already. +func rerunCommand(params *Params, metaPath string) string { + args := []string{"state", "publish"} + if params.Edit { + args = append(args, "--edit") + } + if params.Build != "" { + args = append(args, "--build", quoteArg(params.Build)) + } else if params.Filepath != "" { + args = append(args, quoteArg(params.Filepath)) + } + args = append(args, "--meta", quoteArg(metaPath)) + return strings.Join(args, " ") +} + +func quoteArg(arg string) string { + if strings.ContainsAny(arg, " \t") { + return `"` + arg + `"` + } + return arg +} + +// anyVersion is the version requirement the platform uses for a dependency or condition on any version. +const anyVersion = ">=0" + +// fillVersionRequirements gives every dependency and condition without a version requirement the any-version one. The +// platform rejects them otherwise, and they come about easily: a pyproject.toml dependency without a version range, a +// --depend flag without a version, or a condition inherited from a revision that has no requirements. +func fillVersionRequirements(r *request.PublishVariables) { + for i := range r.Dependencies { + dep := &r.Dependencies[i] + dep.VersionRequirements = cmp.Or(dep.VersionRequirements, anyVersion) + for j := range dep.Conditions { + dep.Conditions[j].VersionRequirements = cmp.Or(dep.Conditions[j].VersionRequirements, anyVersion) + } + } +} + func (r *Runner) OpenInEditor(pr *request.PublishVariables) error { // Prepare file for editing b, err := pr.MarshalYaml(true) @@ -505,9 +789,12 @@ func (r *Runner) OpenInEditor(pr *request.PublishVariables) error { r.out.Notice(locale.Tr("uploadingredient_editor_opening", fn)) - // Open file + // Open file. Without an editor (no $EDITOR and no xdg-open, as on many headless machines), the file can still be + // edited by hand, as the notice above says, so carry on to the confirmation instead of failing. if err := osutils.OpenEditor(fn); err != nil { - return locale.WrapError(err, "err_uploadingredient_publish", "Could not open publish request file") + logging.Debug("Could not open editor: %v", errs.JoinMessage(err)) + r.out.Notice(locale.Tl("uploadingredient_editor_open_failed", + "Could not open an editor, so edit the file above yourself, or set [ACTIONABLE]EDITOR[/RESET] to the editor to use.")) } // Wait for confirmation @@ -520,10 +807,13 @@ func (r *Runner) OpenInEditor(pr *request.PublishVariables) error { return errs.Wrap(err, "Could not read file") } - // Write changes to request - if err := pr.UnmarshalYaml(eb); err != nil { - return locale.WrapError(err, "err_uploadingredient_publish", "Could not unmarshal publish request") + // Write changes to request. Unmarshal into a fresh value, so that fields removed in the editor are removed from the + // request too instead of keeping their previous values. + var edited request.PublishVariables + if err := edited.UnmarshalYaml(eb); err != nil { + return locale.WrapInputError(err, "err_uploadingredient_editor_yaml", "The edited file [ACTIONABLE]{{.V0}}[/RESET] is not valid: {{.V1}}", fn, err.Error()) } + *pr = edited return nil } diff --git a/internal/runners/publish/publish_test.go b/internal/runners/publish/publish_test.go index 14940dfdba..ee3f7deb5c 100644 --- a/internal/runners/publish/publish_test.go +++ b/internal/runners/publish/publish_test.go @@ -94,10 +94,9 @@ func TestCheckEditKeepsIdentity(t *testing.T) { wantErr bool }{ {"author on revision", withAuthor, true, true, true}, - {"description on revision", withDescription, true, true, true}, + {"description on revision is left to keepDescription", withDescription, true, true, false}, {"no identity change on revision", plain, true, true, false}, {"author on new version via edit", withAuthor, true, false, false}, - {"description on new ingredient", withDescription, false, false, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -108,3 +107,46 @@ func TestCheckEditKeepsIdentity(t *testing.T) { }) } } + +func TestFillVersionRequirements(t *testing.T) { + r := &request.PublishVariables{Dependencies: []request.PublishVariableDep{ + { + Dependency: request.Dependency{Name: "requests", Namespace: "language/python"}, + Conditions: []request.Dependency{ + {Name: "Windows", Namespace: "kernel"}, + {Name: "python", Namespace: "language", VersionRequirements: "<3.11"}, + }, + }, + {Dependency: request.Dependency{Name: "six", Namespace: "language/python", VersionRequirements: ">=1.16"}}, + }} + fillVersionRequirements(r) + + if got := r.Dependencies[0].VersionRequirements; got != ">=0" { + t.Errorf("dependency without a version = %q, want >=0", got) + } + if got := r.Dependencies[0].Conditions[0].VersionRequirements; got != ">=0" { + t.Errorf("condition without a version = %q, want >=0", got) + } + if got := r.Dependencies[0].Conditions[1].VersionRequirements; got != "<3.11" { + t.Errorf("condition version changed to %q", got) + } + if got := r.Dependencies[1].VersionRequirements; got != ">=1.16" { + t.Errorf("dependency version changed to %q", got) + } +} + +func TestRerunCommand(t *testing.T) { + tests := []struct { + params *Params + want string + }{ + {&Params{Build: "./src", Filepath: "/tmp/built.tar.gz"}, "state publish --build ./src --meta m.yaml"}, + {&Params{Edit: true, Build: "my src"}, `state publish --edit --build "my src" --meta m.yaml`}, + {&Params{Filepath: "pkg.tar.gz", Name: "ignored"}, "state publish pkg.tar.gz --meta m.yaml"}, + } + for _, tt := range tests { + if got := rerunCommand(tt.params, "m.yaml"); got != tt.want { + t.Errorf("rerunCommand(%+v) = %q, want %q", tt.params, got, tt.want) + } + } +} diff --git a/internal/runners/publish/readme.md b/internal/runners/publish/readme.md index 1653291e1c..ed336d0e23 100644 --- a/internal/runners/publish/readme.md +++ b/internal/runners/publish/readme.md @@ -51,7 +51,8 @@ dependencies = [ ``` Each entry becomes a runtime dependency on the platform's Python package of that name. Version ranges carry over, with -`~=1.16` becoming `>=1.16,<2` and `==4.2.*` becoming `>=4.2,<4.3`. +`~=1.16` becoming `>=1.16,<2` and `==4.2.*` becoming `>=4.2,<4.3`. An entry without a range, such as `"click"`, accepts +any version (`>=0`). Platform and Python version markers become **conditions**, so the dependency is only installed where it applies: @@ -77,7 +78,8 @@ From the project checkout: state publish --build ./mypkg-src --namespace private//python ``` -The namespace must be under `private/`, the organization that owns the project. Before uploading, the State Tool +The namespace must be under `private/`, the organization that owns the project. Without `--namespace`, it is +`private//python` when the source directory has a `pyproject.toml`, and `private/` otherwise. Before uploading, the State Tool shows the ingredient it is about to publish and asks for confirmation: ``` @@ -111,12 +113,28 @@ dependencies: namespace: builder versionRequirements: '>=0' -Do you want to publish this ingredient? (Y/n) +The name and version are built into the package, so they can't be changed. To change them, publish again with +'--name' or '--version'. + +Do you want to publish this ingredient? Answer 'n' to edit it or save it to a file instead. (Y/n) ``` Check the dependency list here: it is exactly what will be published. The `private-builder` dependency is always added; it is what decrypts and installs the package, so leave it in place. +If something is wrong, answer `n` and choose one of: + +- **Edit it in my editor:** opens the meta data in `$EDITOR` (or the default program for `.yaml` files), then shows + the result for confirmation again. You can change the namespace here: it is checked the same way as `--namespace`, + and if it can't be used (it is outside your organization, or the version already exists there), the previous one is + kept and the reason is shown. The name and version can't be changed this way, because the package was already built + with them: if you change them, your edits are saved to a meta file as below. +- **Save it to a meta file to edit and publish later:** writes `mypkg-1.0.0.meta.yaml` to the current directory and + prints the command to publish it, e.g. `state publish --build ./mypkg-src --meta mypkg-1.0.0.meta.yaml`. Edit the file, + then run that command. The meta file takes precedence over `pyproject.toml`, so its name, version and dependency list + are used instead. +- **Cancel:** nothing is published. + Flags override `pyproject.toml`: `--name`, `--version` and `--description` replace the values in it, and any `--depend`, `--depend-runtime`, `--depend-build` or `--depend-test` flag replaces its whole dependency list. @@ -153,7 +171,7 @@ complete dependency list: name: mypkg namespace: private//python version: 1.0.0 -description: "" +description: Internal helpers for the data team dependencies: - dependency: name: private-builder @@ -170,6 +188,18 @@ dependencies: ... ``` +The meta file has these fields: + +| Field | Meaning | +|----------------|------------------------------------------------------------------------------------------------------| +| `name` | The name of the ingredient. | +| `namespace` | Where the ingredient is published, under `private/`, e.g. `private//python`. | +| `version` | The version, following semantic versioning. It must match the version in the source file name, if any. | +| `description` | A short description of the ingredient. It can't be changed on an existing version. | +| `authors` | Optional. The people who contributed to it, each with `name`, `email` and `websites`. They can't be changed on an existing version. | +| `dependencies` | Optional. The ingredients it requires, described below. | +| `features` | Optional. The features it provides, each with `name`, `namespace` and `version`. | + **Edit the `dependencies` list.** Change version ranges, remove entries, or add new ones. Each entry nests its fields under `dependency:`, with optional `conditions:` beside it: @@ -194,6 +224,8 @@ is `name: python`, `namespace: language`, with a requirement such as `<3.11`. state publish --edit --build ./mypkg-src --meta meta.yaml ``` +The platform requires the source with every revision, so pass `--build` even when only the meta data changes. + Things to know about `--edit`: - **The meta file replaces the whole dependency list.** Anything you delete from it is removed from the ingredient. @@ -201,9 +233,10 @@ Things to know about `--edit`: `pyproject.toml` lists any, the previous revision's dependencies are kept. - **Name, namespace and version identify what you are editing; they cannot be changed.** To publish under a new version, see step 6. -- **Authors and description cannot be changed** on an existing version. The exported file leaves authors out and the - description empty. If you fill the description in, the publish is refused with "Authors and description cannot be - changed when editing an existing version." +- **Authors and description cannot be changed** on an existing version. The exported file leaves authors out, and + adding any is refused with "Authors cannot be changed when editing an existing version." It includes the current + description. If you change it, the change is ignored: the confirmation shows the current description and says so, + and you can still publish the rest of your edits. - The `language/python` dependencies the platform adds by itself come back automatically, so it does no harm if they are missing from your file. @@ -222,7 +255,7 @@ accident. | Message | What to do | |-------------------------------------------------------------------------|------------------------------------------------------------------------------------------------| -| `Could not determine the ingredient name and version` | Add `name` and `version` to `pyproject.toml`, or pass `--name` and `--version`. | +| `Could not determine the ingredient name and version` | The message lists each missing field and why. Add it to `pyproject.toml`'s `[project]` table, pass `--name`/`--version`, or set it in a `--meta` file. A version listed in `[project].dynamic` can't be read and must be given this way. | | `The '--build' flag requires a namespace under 'private/'` | Use a namespace under the organization that owns the project you are in. | | `org key does not belong to this project's organization` | The key service returned another organization's key. Check that you are in the right project. | | `No organization key service is configured` | Configure the `privateingredient.*` settings (see Before You Start). | diff --git a/internal/testhelpers/tagsuite/tagsuite.go b/internal/testhelpers/tagsuite/tagsuite.go index 04fd9c4a5d..faa895ef43 100644 --- a/internal/testhelpers/tagsuite/tagsuite.go +++ b/internal/testhelpers/tagsuite/tagsuite.go @@ -95,21 +95,27 @@ type Suite struct { suite.Suite } -// OnlyRunForTags skips a test unless one of the given tags is asked for. -func (suite *Suite) OnlyRunForTags(tags ...string) { +// RunsForTags reports whether tests for one of the given tags are asked for. +func RunsForTags(tags ...string) bool { setTagsString, _ := os.LookupEnv("TEST_SUITE_TAGS") setTags := strings.Split(strings.ToLower(setTagsString), ":") // if no tags are defined and we're not on CI; run the test if funk.Contains(setTags, "all") || (setTagsString == "" && !condition.OnCI()) { - return + return true } for _, tag := range tags { if funk.Contains(setTags, strings.ToLower(tag)) { - return + return true } } + return false +} - suite.T().Skipf("Run only if any of the following tags are set: %s", strings.Join(tags, ", ")) +// OnlyRunForTags skips a test unless one of the given tags is asked for. +func (suite *Suite) OnlyRunForTags(tags ...string) { + if !RunsForTags(tags...) { + suite.T().Skipf("Run only if any of the following tags are set: %s", strings.Join(tags, ", ")) + } } diff --git a/pkg/platform/api/graphql/request/publish.go b/pkg/platform/api/graphql/request/publish.go index 45b7313dd2..6a1cb6a81c 100644 --- a/pkg/platform/api/graphql/request/publish.go +++ b/pkg/platform/api/graphql/request/publish.go @@ -11,7 +11,6 @@ import ( "github.com/ActiveState/cli/internal/fileutils" "github.com/ActiveState/cli/internal/gqlclient" "github.com/ActiveState/cli/internal/locale" - yamlcomment "github.com/zijiren233/yaml-comment" "gopkg.in/yaml.v3" ) @@ -54,15 +53,15 @@ func Publish(vars PublishVariables, filepath string) (*PublishInput, error) { // which is done with yaml. As such the yaml tags are used for representing data to the user, and the json is used for // inputs to graphql. type PublishVariables struct { - Name string `yaml:"name" json:"-" hc:"The name of the ingredient"` // User representation only - Namespace string `yaml:"namespace" json:"-" hc:"The namespace field should be in the format org/folder. Org can simply be your username or any organization you're a member of."` // User representation only - Version string `yaml:"version" json:"version" hc:"The version field should follow semantic versioning and match the version in the filename (if any)."` - Description string `yaml:"description" json:"description" hc:"The description field should be a short description of the ingredient."` + Name string `yaml:"name" json:"-"` // User representation only + Namespace string `yaml:"namespace" json:"-"` // User representation only + Version string `yaml:"version" json:"version"` + Description string `yaml:"description" json:"description"` // Optional - Authors []PublishVariableAuthor `yaml:"authors,omitempty" json:"authors,omitempty" hc:"A list of authors who contributed to the ingredient."` - Dependencies []PublishVariableDep `yaml:"dependencies,omitempty" json:"dependencies,omitempty" hc:"A list of dependencies that the ingredient requires."` - Features []PublishVariableFeature `yaml:"features,omitempty" json:"features,omitempty" hc:"A list of features that the ingredient provides."` + Authors []PublishVariableAuthor `yaml:"authors,omitempty" json:"authors,omitempty"` + Dependencies []PublishVariableDep `yaml:"dependencies,omitempty" json:"dependencies,omitempty"` + Features []PublishVariableFeature `yaml:"features,omitempty" json:"features,omitempty"` // GraphQL input only Path string `yaml:"-" json:"path"` @@ -123,14 +122,14 @@ func (p PublishVariables) MarshalJSON() ([]byte, error) { } func (p PublishVariables) MarshalYaml(includeExample bool) ([]byte, error) { - v, err := yamlcomment.Marshal(p) + v, err := yaml.Marshal(p) if err != nil { return nil, errs.Wrap(err, "Could not marshal publish request") } if includeExample { if len(p.Authors) == 0 { - exampleAuthorYaml, err := yamlcomment.Marshal(exampleAuthor) + exampleAuthorYaml, err := yaml.Marshal(exampleAuthor) if err != nil { return nil, errs.Wrap(err, "Could not marshal example author") } @@ -140,7 +139,7 @@ func (p PublishVariables) MarshalYaml(includeExample bool) ([]byte, error) { } if len(p.Dependencies) == 0 { - exampleDepYaml, err := yamlcomment.Marshal(exampleDep) + exampleDepYaml, err := yaml.Marshal(exampleDep) if err != nil { return nil, errs.Wrap(err, "Could not marshal example deps") } diff --git a/test/integration/performance_svc_int_test.go b/test/integration/performance_svc_int_test.go index 46f0ecf20d..837346161f 100644 --- a/test/integration/performance_svc_int_test.go +++ b/test/integration/performance_svc_int_test.go @@ -29,6 +29,9 @@ type PerformanceSvcIntegrationTestSuite struct { } func (suite *PerformanceIntegrationTestSuite) TearDownSuite() { + if !tagsuite.RunsForTags(tagsuite.Performance) { + return // no test started a service; a long branch channel's socket path can't even be dialed on macOS + } ipcClient := svcctl.NewDefaultIPCClient() err := svcctl.StopServer(ipcClient) suite.Require().NoError(err) diff --git a/test/integration/publish_int_test.go b/test/integration/publish_int_test.go index d125553263..6de1511a9a 100644 --- a/test/integration/publish_int_test.go +++ b/test/integration/publish_int_test.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "regexp" + "runtime" "testing" "time" @@ -24,7 +25,7 @@ import ( "github.com/ActiveState/cli/pkg/platform/api/graphql/request" ) -var editorFileRx = regexp.MustCompile(`file:\s*?(.*?)\.\s`) +var editorFileRx = regexp.MustCompile(`file:\s*(.*?)\.\s`) type PublishIntegrationTestSuite struct { tagsuite.Suite @@ -388,8 +389,11 @@ authors: args[k] = vp } + // The test edits the file itself, so make sure no real editor is opened: otherwise `open` or + // `xdg-open` hands it to whatever app the runner has registered, if any. cp := ts.SpawnWithOpts( e2e.OptArgs(append([]string{"publish"}, args...)...), + e2e.OptAppendEnv("EDITOR=state-test-no-editor"), ) if inv.expect.immediateOutput != "" { @@ -450,6 +454,8 @@ authors: cp.SendLine("Y") } else { cp.SendLine("n") + cp.Expect("What would you like to do instead?") + cp.SendLine("") // the default choice: Cancel cp.Expect("Publish cancelled") return } @@ -489,6 +495,9 @@ authors: // The ingredient is published under a unique, random name. Published private // ingredients cannot be deleted, so the name must never collide across runs. func (suite *PublishIntegrationTestSuite) TestPublishBuildEncrypted() { + if runtime.GOOS == "linux" && runtime.GOARCH == "arm64" { + suite.T().Skip("The Python project this installs into has no Linux/arm64 platform") + } suite.OnlyRunForTags(tagsuite.Publish) ts := e2e.New(suite.T(), false) diff --git a/vendor/github.com/labstack/echo/v4/CHANGELOG.md b/vendor/github.com/labstack/echo/v4/CHANGELOG.md index 8388531e91..c8017d19d5 100644 --- a/vendor/github.com/labstack/echo/v4/CHANGELOG.md +++ b/vendor/github.com/labstack/echo/v4/CHANGELOG.md @@ -1,5 +1,50 @@ # Changelog +## v4.16.0 - 2026-09-27 + +**Security** + +This release fixes several security issues. Upgrading is recommended. Some fixes change behavior; read "Behavior changes to check before upgrading" below. + +* **Request scheme:** `Context.Scheme()` now uses the `X-Forwarded-Proto`, `X-Forwarded-Protocol`, `X-Forwarded-Ssl` and `X-Url-Scheme` headers only when the request comes directly from a loopback, link-local or private network address or a unix socket. Before this, any client could send `X-Forwarded-Proto: https` over plain HTTP and skip `HTTPSRedirect`. When `X-Forwarded-Proto` is present, only it is used (its last value), and the scheme is returned in lowercase. the new `Echo#SchemeExtractor` field selects the strategy: `ExtractSchemeFromHeaders(...TrustOption)` (default), `ExtractSchemeDirect()` or `LegacySchemeExtractor()`. The Secure middleware now sets HSTS based on `Context.Scheme()`. The Proxy middleware always sets `X-Forwarded-Proto` from `Context.Scheme()` and removes `X-Forwarded-Ssl`, `X-Forwarded-Protocol` and `X-Url-Scheme` before forwarding. [GHSA-2ffq-g2xg-c22p](https://github.com/labstack/echo/security/advisories/GHSA-2ffq-g2xg-c22p) +* **JSONP:** `Context.JSONP` and `Context.JSONPBlob` accept only a callback that is empty, a JavaScript identifier or a dot-separated path of identifiers (ASCII letters, digits, `_` and `$`). Any other callback returns a 400 Bad Request error that wraps the new `ErrInvalidJSONPCallback`, and nothing is written. JSONP responses now carry `X-Content-Type-Options: nosniff`. JSONP lets any website read the response with the user's cookies, so do not use it for data that needs authentication. [GHSA-h9g5-28mm-hx3g](https://github.com/labstack/echo/security/advisories/GHSA-h9g5-28mm-hx3g) +* **MethodOverride:** a POST can no longer be overridden to `GET`, `HEAD`, `OPTIONS`, `TRACE` or `CONNECT`. Before this, with the `MethodFromForm` or `MethodFromQuery` getter and MethodOverride registered with `Use` before the CSRF middleware, `_method=GET` skipped the CSRF check. Register MethodOverride with `Echo#Pre`. [GHSA-r7w9-592q-9vg4](https://github.com/labstack/echo/security/advisories/GHSA-r7w9-592q-9vg4) +* **Redirects:** the trailing slash middlewares and the static directory redirect percent-encode control characters in the redirect path. Before this, `/%09/evil.example/` redirected browsers to `evil.example`. [GHSA-v753-g4cw-jm48](https://github.com/labstack/echo/security/advisories/GHSA-v753-g4cw-jm48) +* **Static files:** with the default settings, the Static middleware resolves files from the same form of the path that the router matched, so `/admin%2Fsecret.txt` or `/%61dmin/secret.txt` can no longer reach a file under a guarded `/admin/*` route. [GHSA-375p-5qhx-8wq4](https://github.com/labstack/echo/security/advisories/GHSA-375p-5qhx-8wq4) The Static middleware and `StaticDirectoryHandler` (used by `Echo.Static`, `Echo.StaticFS`, `Group.Static` and `Group.StaticFS`) no longer serve paths with a `.`, `..` or empty segment, such as `/assets/../admin/secret.txt`, also after path unescaping. [GHSA-3pmx-cf9f-34xr](https://github.com/labstack/echo/security/advisories/GHSA-3pmx-cf9f-34xr) +* **Dependencies:** update `golang.org/x/text` to v0.40.0 ([GO-2026-5970](https://pkg.go.dev/vuln/GO-2026-5970)). + +**Client IP address (no code change in v4)** + +Without `Echo#IPExtractor`, `Context.RealIP()` in v4 trusts the `X-Forwarded-For` and `X-Real-IP` headers from any client, so the rate limiter can be bypassed and the Proxy middleware forwards a spoofed `X-Real-IP` ([GHSA-246p-cpwv-v3jq](https://github.com/labstack/echo/security/advisories/GHSA-246p-cpwv-v3jq), [GHSA-99jh-6h7p-pp36](https://github.com/labstack/echo/security/advisories/GHSA-99jh-6h7p-pp36)). Changing this default in v4 would put all clients behind a proxy into one rate-limit bucket, so v4 keeps it. Set an extractor that matches your deployment: +```go +e.IPExtractor = echo.ExtractIPDirect() // no proxy in front of the app +e.IPExtractor = echo.ExtractIPFromXFFHeader() // behind proxies in private networks that set X-Forwarded-For +// behind a proxy with public addresses (e.g. a CDN), also trust its ranges: +// e.IPExtractor = echo.ExtractIPFromXFFHeader(echo.TrustIPRange(cdnRange)) +``` +v5 uses the direct peer address by default since v5.1.0. + +**Behavior changes to check before upgrading** + +* **Proxies or load balancers with public IP addresses.** If a proxy connects to your app from a public (or `100.64.0.0/10`) address, its `X-Forwarded-Proto` is now ignored: `HTTPSRedirect` redirects in a loop and the Secure middleware stops sending HSTS. This affects, for example, Cloudflare, CloudFront and Azure Front Door connecting to a public origin, the GCP external HTTP(S) load balancer including GKE Ingress (`35.191.0.0/16`, `130.211.0.0/22`), and networks that use `100.64.0.0/10` (such as Alibaba Cloud SLB or EKS custom networking). Trust the proxy's address ranges: + ```go + _, gclb1, _ := net.ParseCIDR("35.191.0.0/16") + _, gclb2, _ := net.ParseCIDR("130.211.0.0/22") + e.SchemeExtractor = echo.ExtractSchemeFromHeaders(echo.TrustIPRange(gclb1), echo.TrustIPRange(gclb2)) + ``` + Proxies on the same host, in a private network (AWS ALB, in-cluster ingress controllers such as ingress-nginx or Traefik, most PaaS routers) or on a unix socket keep working without changes. `echo.LegacySchemeExtractor()` restores the old behavior but is not safe unless every request passes through a proxy that sets these headers. Serverless adapters or middleware that set `RemoteAddr` to the client's address also make `X-Forwarded-Proto` ignored (or, if they take it from a header, spoofable). +* **Trusted proxies must set `X-Forwarded-Proto`.** A proxy on a trusted address that passes the client's `X-Forwarded-Proto` through (for example nginx without `proxy_set_header X-Forwarded-Proto $scheme;`) still lets the client choose the scheme. An invalid `X-Forwarded-Proto` value now results in `http` instead of falling back to the other scheme headers. +* **Your own tests.** `httptest.NewRequest` sets `RemoteAddr` to `192.0.2.1:1234`, which is not trusted, so tests that set `X-Forwarded-Proto` now see `http`. Set `req.RemoteAddr = "10.0.0.1:1234"` or use `e.SchemeExtractor = echo.LegacySchemeExtractor()` in such tests. +* **Proxy middleware headers.** `X-Forwarded-Ssl`, `X-Forwarded-Protocol` and `X-Url-Scheme` are no longer forwarded to the upstream; `X-Forwarded-Proto` carries the scheme. +* **MethodOverride.** Overriding a POST to `GET` (for example with `X-HTTP-Method-Override: GET` to send a long query in a POST body) is no longer done; such requests keep the POST method. +* **Static files.** Paths with a double slash or dot segment (for example `/assets//app.js`) now return 404; in HTML5 mode the index is still served. The Static middleware no longer finds file names that the client sends with non-default escaping (for example `%2C`, `%40` or lowercase hex like `%c3%a9`) unless `StaticConfig.EnablePathUnescaping` is set; `Echo.Static` has behaved this way since v4.15.4. With `StaticConfig.EnablePathUnescaping` or `Echo#EnablePathUnescapingStaticFiles`, encoded dots (`%2e%2e`) no longer traverse directories, but encoded slashes are still decoded, so do not combine these options with route-based access control. +* **JSONP.** `Context.JSONP` returns an error for callbacks that are not JavaScript identifiers. + +**Documentation** + +* Static middleware: when registered with `Echo#Use` it runs before route and group middleware, so route guards do not protect the files it serves. + + ## v4.15.4 - 2026-06-15 **Security** diff --git a/vendor/github.com/labstack/echo/v4/README.md b/vendor/github.com/labstack/echo/v4/README.md index 5e52d1d4e8..49040fd7b0 100644 --- a/vendor/github.com/labstack/echo/v4/README.md +++ b/vendor/github.com/labstack/echo/v4/README.md @@ -36,6 +36,13 @@ Help and questions: [Github Discussions](https://github.com/labstack/echo/discus ## Sponsors + +
encore icon @@ -54,7 +61,15 @@ Click [here](https://github.com/sponsors/labstack) for more information on spons // go get github.com/labstack/echo/{version} go get github.com/labstack/echo/v4 ``` -Latest version of Echo supports last four Go major [releases](https://go.dev/doc/devel/release) and might work with older versions. +Echo v4.16.1 requires Go 1.26 or later so it can use patched `golang.org/x/` dependencies. Use a supported, patched Go toolchain when building applications. + +### Security defaults in v4.16.1 + +`BodyDump` captures at most 5 MiB of the request and 5 MiB of the response by default. The application and client still receive the complete bodies. Configure `BodyDumpConfig.MaxRequestBytes` and `MaxResponseBytes` to change the capture limits; `-1` disables a limit. + +`Decompress` limits decompressed request bodies to 100 MiB by default. Reading beyond the limit returns HTTP 413, including through JSON and XML binding. Configure `DecompressConfig.MaxDecompressedSize` to change the limit; `-1` disables it. Apply any smaller application body limit after decompression. + +The proxy now validates WebSocket upgrade responses, closes both WebSocket connections when either side disconnects, and removes incoming `Forwarded` headers. Backends should use the proxy's validated forwarding headers and enforce their own authorization. ### Example @@ -99,6 +114,7 @@ Following list of middleware is maintained by Echo team. |------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | [github.com/labstack/echo-jwt](https://github.com/labstack/echo-jwt) | [JWT](https://github.com/golang-jwt/jwt) middleware | | [github.com/labstack/echo-contrib](https://github.com/labstack/echo-contrib) | [casbin](https://github.com/casbin/casbin), [gorilla/sessions](https://github.com/gorilla/sessions), [jaegertracing](https://github.com/uber/jaeger-client-go), [prometheus](https://github.com/prometheus/client_golang/), [pprof](https://pkg.go.dev/net/http/pprof), [zipkin](https://github.com/openzipkin/zipkin-go) middlewares | +| [github.com/labstack/echo-otel](https://github.com/labstack/echo-otel/tree/v4) | [OpenTelemetry](https://opentelemetry.io/) middleware for tracing and metrics (`github.com/labstack/echo-otel/v4`) | # Third-party middleware repositories diff --git a/vendor/github.com/labstack/echo/v4/bind.go b/vendor/github.com/labstack/echo/v4/bind.go index 1d4fe6f0a7..b9cffab09e 100644 --- a/vendor/github.com/labstack/echo/v4/bind.go +++ b/vendor/github.com/labstack/echo/v4/bind.go @@ -22,6 +22,13 @@ type Binder interface { } // DefaultBinder is the default implementation of the Binder interface. +// For path, query, header, and form binding, time.Time and *time.Time fields (not slices) +// may use a format tag: "date-time" uses the standard RFC3339 decoding, "date-time-local" +// accepts "2006-01-02T15:04:05" with optional fractional seconds, "date" accepts +// "2006-01-02", and other values specify Go time layouts. "date", "date-time-local", and +// layouts without a timezone give UTC times. JSON and XML decoding do not use this tag. +// HTML datetime-local inputs omit seconds by default, so bind them with a Go layout +// such as "2006-01-02T15:04" instead of "date-time-local". type DefaultBinder struct{} // BindUnmarshaler is the interface used to wrap the UnmarshalParam method. @@ -42,10 +49,11 @@ type bindMultipleUnmarshaler interface { // BindPathParams binds path params to bindable object // // Time format support: time.Time fields can use `format` tags to specify custom parsing layouts. -// Example: `param:"created" format:"2006-01-02T15:04"` for datetime-local format +// Example: `form:"created" format:"2006-01-02T15:04"` for HTML datetime-local input // Example: `param:"date" format:"2006-01-02"` for date format // Uses Go's standard time format reference time: Mon Jan 2 15:04:05 MST 2006 -// Works with form data, query parameters, and path parameters (not JSON body) +// OpenAPI format names "date-time", "date-time-local" and "date" are also supported (see DefaultBinder) +// Works with form data, query parameters, headers, and path parameters (not JSON body) // Falls back to default time.Time parsing if no format tag is specified func (b *DefaultBinder) BindPathParams(c Context, i interface{}) error { names := c.ParamNames() @@ -95,6 +103,10 @@ func (b *DefaultBinder) BindBody(c Context, i interface{}) (err error) { } case MIMEApplicationXML, MIMETextXML: if err = xml.NewDecoder(req.Body).Decode(i); err != nil { + var httpError *HTTPError + if errors.As(err, &httpError) { + return httpError + } if ute, ok := err.(*xml.UnsupportedTypeError); ok { return NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Unsupported type error: type=%v, error=%v", ute.Type, ute.Error())).SetInternal(err) } else if se, ok := err.(*xml.SyntaxError); ok { @@ -105,6 +117,10 @@ func (b *DefaultBinder) BindBody(c Context, i interface{}) (err error) { case MIMEApplicationForm: params, err := c.FormParams() if err != nil { + var httpError *HTTPError + if errors.As(err, &httpError) { + return httpError + } return NewHTTPError(http.StatusBadRequest, err.Error()).SetInternal(err) } if err = b.bindData(i, params, "form", nil); err != nil { @@ -113,6 +129,10 @@ func (b *DefaultBinder) BindBody(c Context, i interface{}) (err error) { case MIMEMultipartForm: params, err := c.MultipartForm() if err != nil { + var httpError *HTTPError + if errors.As(err, &httpError) { + return httpError + } return NewHTTPError(http.StatusBadRequest, err.Error()).SetInternal(err) } if err = b.bindData(i, params.Value, "form", params.File); err != nil { @@ -375,10 +395,19 @@ func unmarshalInputToField(valueKind reflect.Kind, val string, field reflect.Val fieldIValue := field.Addr().Interface() - // Handle time.Time with custom format tag - if formatTag != "" { + // date-time uses the same TextUnmarshaler as an untagged time.Time. + if formatTag != "" && formatTag != "date-time" { if _, isTime := fieldIValue.(*time.Time); isTime { - t, err := time.Parse(formatTag, val) + // OpenAPI dates and local date-times have no timezone. time.Parse assigns UTC, + // as it does for custom layouts without timezone information. + layout := formatTag + switch formatTag { + case "date": + layout = "2006-01-02" + case "date-time-local": + layout = "2006-01-02T15:04:05" + } + t, err := time.Parse(layout, val) if err != nil { return true, err } diff --git a/vendor/github.com/labstack/echo/v4/context.go b/vendor/github.com/labstack/echo/v4/context.go index 6500e5eef2..dd5f11c463 100644 --- a/vendor/github.com/labstack/echo/v4/context.go +++ b/vendor/github.com/labstack/echo/v4/context.go @@ -134,10 +134,17 @@ type Context interface { // JSONP sends a JSONP response with status code. It uses `callback` to construct // the JSONP payload. + // + // The callback must be empty, a JavaScript identifier or a dot-separated path of identifiers using only ASCII + // letters, digits, `_` and `$`. Otherwise nothing is written and a 400 Bad Request error wrapping + // ErrInvalidJSONPCallback is returned. The response has the `X-Content-Type-Options: nosniff` header. + // + // Any website can load a JSONP response with a