Compare commits

...
Author SHA1 Message Date
sixsmith 309fc72c38 temp 2026-10-10 01:04:50 -04:00
sixsmith 455b5fef21 revert Makefile change, moved to another CL 2026-10-09 21:58:43 -04:00
sixsmith d8742e728c Merge remote-tracking branch 'toolshed/main' 2026-10-09 21:56:00 -04:00
sixsmith eaec0cc733 update digest pinning work
- formatter.RemoveSha
- make pindigests TRUE by default
- more strict matching of digests
- unit and integration tests
2026-10-09 21:51:51 -04:00
renovate-bot 5b42b3d306 chore(deps): update golang docker tag
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2026-10-09 17:01:20 +00:00
renovate-bot d99e86fb0e chore(deps): update module golang.org/x/term to v0.47.0
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2026-10-09 16:01:52 +00:00
renovate-bot ade646abbc chore(deps): update golang docker tag
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2026-10-09 14:01:30 +00:00
renovate-bot 80b3b90905 chore(deps): update module golang.org/x/sys to v0.49.0
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2026-10-09 13:01:52 +00:00
renovate-bot fab3f0b97b chore(deps): update golang docker tag
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2026-10-04 16:01:27 +00:00
renovate-bot 3819ee38f5 chore(deps): update module github.com/go-git/go-git/v5 to v5.19.3
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2026-10-04 15:02:06 +00:00
renovate-bot 74261a7a01 chore(deps): update otel/weaver docker tag to v0.27.0
continuous-integration/drone/push Build is passing
2026-10-04 01:01:15 +00:00
renovate-bot ca39ff6e29 chore(deps): update nginx docker tag to v1.31.6
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2026-09-15 23:01:13 +00:00
sixsmith 0f7d991709 add lint rule for images with digest 2026-09-10 00:13:35 -04:00
sixsmith 39050a85ef run make-i18n 2026-09-10 00:13:35 -04:00
sixsmith 92bab8637d add --pindigests flag
* handle digests for the recipe subcommand
* add make parameter to pass args
2026-09-10 00:11:52 -04:00
sixsmith 7932356746 add regclient to fetch image manifest/digest
* also run `go mod vendor`
2026-09-10 00:11:52 -04:00
sixsmith 91fe901264 add docs and test 2026-09-10 00:11:52 -04:00
renovate-bot 5d00b3c932 chore(deps): update golang docker tag
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2026-09-08 19:01:07 +00:00
renovate-bot a380b5e8ae chore(deps): update module golang.org/x/term to v0.46.0
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build was killed
2026-09-08 18:03:14 +00:00
renovate-bot 9ff5024a58 chore(deps): update golang docker tag
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is passing
2026-09-08 15:04:36 +00:00
renovate-bot d6dd082e59 chore(deps): update module golang.org/x/sys to v0.48.0
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2026-09-08 14:02:32 +00:00
Weblate 0531f30590 chore: update translation files
continuous-integration/drone/push Build is passing
Updated by "Update PO files to match POT (msgmerge)" add-on in Weblate.

Translation: Co-op Cloud/abra
Translate-URL: https://translate.coopcloud.tech/projects/co-op-cloud/abra/
2026-09-07 14:34:36 +00:00
moritz 27ca0cd21e test(app new): cover a commit that only exists on another branch
continuous-integration/drone/push Build is passing
2026-09-07 14:34:27 +00:00
moritz 920a898955 fix(git): clone every branch so commits outside the default branch resolve 2026-09-07 14:34:27 +00:00
sixsmith 7cbc974763 chore: mark vendor directory as generated
continuous-integration/drone/push Build is passing
2026-09-03 05:30:34 +00:00
renovate-bot c71b764546 chore(deps): update otel/weaver docker tag to v0.26.1
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2026-09-03 02:01:10 +00:00
renovate-bot 5db8430a73 chore(deps): update nginx docker tag to v1.31.5
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2026-09-02 23:01:08 +00:00
renovate-bot 091c4a2ac1 chore(deps): update otel/weaver docker tag to v0.26.0
continuous-integration/drone/push Build is passing
2026-09-02 19:01:09 +00:00
ChasquiLabo 8e935d08a6 chore: translation using Weblate (Spanish)
continuous-integration/drone/push Build is passing
Currently translated at 90.6% (1046 of 1154 strings)

Translation: Co-op Cloud/abra
Translate-URL: https://translate.coopcloud.tech/projects/co-op-cloud/abra/es/
2026-09-01 22:35:20 +00:00
ChasquiLabo b959c55852 chore: translation using Weblate (Spanish)
continuous-integration/drone/push Build is passing
Currently translated at 90.6% (1046 of 1154 strings)

Translation: Co-op Cloud/abra
Translate-URL: https://translate.coopcloud.tech/projects/co-op-cloud/abra/es/
2026-09-01 21:54:01 +00:00
ChasquiLabo bab9b21882 chore: translation using Weblate (Spanish)
continuous-integration/drone/push Build is passing
Currently translated at 90.6% (1046 of 1154 strings)

Translation: Co-op Cloud/abra
Translate-URL: https://translate.coopcloud.tech/projects/co-op-cloud/abra/es/
2026-09-01 21:42:51 +00:00
ChasquiLabo 703fbf5c8a chore: translation using Weblate (Spanish)
continuous-integration/drone/push Build is passing
Currently translated at 90.6% (1046 of 1154 strings)

Translation: Co-op Cloud/abra
Translate-URL: https://translate.coopcloud.tech/projects/co-op-cloud/abra/es/
2026-09-01 21:41:22 +00:00
ChasquiLabo aaaf855a6e chore: translation using Weblate (Spanish)
continuous-integration/drone/push Build is passing
Currently translated at 90.6% (1046 of 1154 strings)

Translation: Co-op Cloud/abra
Translate-URL: https://translate.coopcloud.tech/projects/co-op-cloud/abra/es/
2026-09-01 21:39:07 +00:00
ChasquiLabo a6666be477 chore: translation using Weblate (Spanish)
continuous-integration/drone/push Build is passing
Currently translated at 90.6% (1046 of 1154 strings)

Translation: Co-op Cloud/abra
Translate-URL: https://translate.coopcloud.tech/projects/co-op-cloud/abra/es/
2026-09-01 19:30:19 +00:00
ChasquiLabo ba90b04624 chore: translation using Weblate (Spanish)
continuous-integration/drone/push Build is passing
Currently translated at 90.6% (1046 of 1154 strings)

Translation: Co-op Cloud/abra
Translate-URL: https://translate.coopcloud.tech/projects/co-op-cloud/abra/es/
2026-09-01 19:03:00 +00:00
ChasquiLabo debbdde5d1 chore: translation using Weblate (Spanish)
continuous-integration/drone/push Build is passing
Currently translated at 90.6% (1046 of 1154 strings)

Translation: Co-op Cloud/abra
Translate-URL: https://translate.coopcloud.tech/projects/co-op-cloud/abra/es/
2026-09-01 19:00:21 +00:00
ChasquiLabo 48f62f541f chore: translation using Weblate (Spanish)
continuous-integration/drone/push Build is passing
Currently translated at 90.6% (1046 of 1154 strings)

Translation: Co-op Cloud/abra
Translate-URL: https://translate.coopcloud.tech/projects/co-op-cloud/abra/es/
2026-09-01 18:56:40 +00:00
ChasquiLabo e6296e4ce4 chore: translation using Weblate (Spanish)
continuous-integration/drone/push Build is passing
Currently translated at 90.6% (1046 of 1154 strings)

Translation: Co-op Cloud/abra
Translate-URL: https://translate.coopcloud.tech/projects/co-op-cloud/abra/es/
2026-09-01 18:53:08 +00:00
ChasquiLabo 423650e8b5 chore: translation using Weblate (Spanish)
continuous-integration/drone/push Build is passing
Currently translated at 90.6% (1046 of 1154 strings)

Translation: Co-op Cloud/abra
Translate-URL: https://translate.coopcloud.tech/projects/co-op-cloud/abra/es/
2026-09-01 18:50:14 +00:00
ChasquiLabo d0c42e37a7 chore: translation using Weblate (Spanish)
continuous-integration/drone/push Build is failing
Currently translated at 90.6% (1046 of 1154 strings)

Translation: Co-op Cloud/abra
Translate-URL: https://translate.coopcloud.tech/projects/co-op-cloud/abra/es/
2026-09-01 16:56:31 +00:00
ChasquiLabo 6afd17f1ed chore: translation using Weblate (Spanish)
continuous-integration/drone/push Build is running
Currently translated at 90.6% (1046 of 1154 strings)

Translation: Co-op Cloud/abra
Translate-URL: https://translate.coopcloud.tech/projects/co-op-cloud/abra/es/
2026-09-01 16:53:56 +00:00
ChasquiLabo cb0eec2203 chore: translation using Weblate (Spanish)
continuous-integration/drone/push Build is passing
Currently translated at 90.6% (1046 of 1154 strings)

Translation: Co-op Cloud/abra
Translate-URL: https://translate.coopcloud.tech/projects/co-op-cloud/abra/es/
2026-09-01 16:52:45 +00:00
ChasquiLabo f5900b726b chore: translation using Weblate (Spanish)
continuous-integration/drone/push Build is failing
Currently translated at 90.6% (1046 of 1154 strings)

Translation: Co-op Cloud/abra
Translate-URL: https://translate.coopcloud.tech/projects/co-op-cloud/abra/es/
2026-09-01 16:50:52 +00:00
ChasquiLabo 0d9e777c18 chore: translation using Weblate (Spanish)
continuous-integration/drone/push Build is passing
Currently translated at 90.6% (1046 of 1154 strings)

Translation: Co-op Cloud/abra
Translate-URL: https://translate.coopcloud.tech/projects/co-op-cloud/abra/es/
2026-09-01 16:39:47 +00:00
ChasquiLabo f5cc61c211 chore: translation using Weblate (Spanish)
continuous-integration/drone/push Build is passing
Currently translated at 90.6% (1046 of 1154 strings)

Translation: Co-op Cloud/abra
Translate-URL: https://translate.coopcloud.tech/projects/co-op-cloud/abra/es/
2026-09-01 16:27:24 +00:00
ChasquiLabo 786ba43b83 chore: translation using Weblate (Spanish)
continuous-integration/drone/push Build is running
Currently translated at 90.6% (1046 of 1154 strings)

Translation: Co-op Cloud/abra
Translate-URL: https://translate.coopcloud.tech/projects/co-op-cloud/abra/es/
2026-09-01 16:24:37 +00:00
ChasquiLabo ee80cfa27f chore: translation using Weblate (Spanish)
continuous-integration/drone/push Build is failing
Currently translated at 90.6% (1046 of 1154 strings)

Translation: Co-op Cloud/abra
Translate-URL: https://translate.coopcloud.tech/projects/co-op-cloud/abra/es/
2026-09-01 16:00:49 +00:00
ChasquiLabo bbaca64d36 chore: translation using Weblate (Spanish)
Currently translated at 90.6% (1046 of 1154 strings)

Translation: Co-op Cloud/abra
Translate-URL: https://translate.coopcloud.tech/projects/co-op-cloud/abra/es/
2026-09-01 15:56:33 +00:00
ChasquiLabo b608ee3985 chore: translation using Weblate (Spanish)
continuous-integration/drone/push Build is pending
Currently translated at 90.6% (1046 of 1154 strings)

Translation: Co-op Cloud/abra
Translate-URL: https://translate.coopcloud.tech/projects/co-op-cloud/abra/es/
2026-09-01 15:44:56 +00:00
ChasquiLabo 5e72e28ba6 chore: translation using Weblate (Spanish)
continuous-integration/drone/push Build is running
Currently translated at 90.6% (1046 of 1154 strings)

Translation: Co-op Cloud/abra
Translate-URL: https://translate.coopcloud.tech/projects/co-op-cloud/abra/es/
2026-09-01 15:37:17 +00:00
ChasquiLabo 84b18c1a7b chore: translation using Weblate (Spanish)
Currently translated at 90.6% (1046 of 1154 strings)

Translation: Co-op Cloud/abra
Translate-URL: https://translate.coopcloud.tech/projects/co-op-cloud/abra/es/
2026-09-01 15:26:49 +00:00
ChasquiLabo 249851a556 chore: translation using Weblate (Spanish)
continuous-integration/drone/push Build is failing
Currently translated at 90.6% (1046 of 1154 strings)

Translation: Co-op Cloud/abra
Translate-URL: https://translate.coopcloud.tech/projects/co-op-cloud/abra/es/
2026-09-01 14:16:43 +00:00
ChasquiLabo 344b4c8d3d chore: translation using Weblate (Spanish)
continuous-integration/drone/push Build is passing
Currently translated at 90.6% (1046 of 1154 strings)

Translation: Co-op Cloud/abra
Translate-URL: https://translate.coopcloud.tech/projects/co-op-cloud/abra/es/
2026-09-01 13:12:53 +00:00
renovate-bot 0dd314b53c chore(deps): update nginx docker tag to v1.31.4
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2026-08-19 21:01:10 +00:00
renovate-bot e9f51526f6 chore(deps): update golang docker tag
continuous-integration/drone/pr Build encountered an error
continuous-integration/drone/push Build encountered an error
2026-08-19 20:01:03 +00:00
renovate-bot ae2080fd9d chore(deps): update golang docker tag
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2026-08-19 15:01:28 +00:00
renovate-bot 8f6ebac2ce chore(deps): update module github.com/stretchr/testify to v1.12.1
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2026-08-19 14:01:40 +00:00
renovate-bot 23d67677c6 chore(deps): update golang docker tag
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2026-08-17 10:01:49 +00:00
renovate-bot 00d64556bc chore(deps): update module github.com/stretchr/testify to v1.12.0
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is passing
2026-08-17 09:04:16 +00:00
ChasquiLabo 70c0accf7f chore: translation using Weblate (Spanish)
continuous-integration/drone/push Build is passing
Currently translated at 90.6% (1046 of 1154 strings)

Translation: Co-op Cloud/abra
Translate-URL: https://translate.coopcloud.tech/projects/co-op-cloud/abra/es/
2026-08-11 06:51:47 +00:00
ChasquiLabo 82ae4cb10f chore: translation using Weblate (Spanish)
continuous-integration/drone/push Build is passing
Currently translated at 90.6% (1046 of 1154 strings)

Translation: Co-op Cloud/abra
Translate-URL: https://translate.coopcloud.tech/projects/co-op-cloud/abra/es/
2026-08-10 22:00:01 +00:00
ChasquiLabo 99d3cc87b2 chore: translation using Weblate (Spanish)
continuous-integration/drone/push Build is passing
Currently translated at 90.6% (1046 of 1154 strings)

Translation: Co-op Cloud/abra
Translate-URL: https://translate.coopcloud.tech/projects/co-op-cloud/abra/es/
2026-08-10 02:00:46 +00:00
ChasquiLabo 232d6f39e4 chore: translation using Weblate (Spanish)
continuous-integration/drone/push Build is passing
Currently translated at 90.6% (1046 of 1154 strings)

Translation: Co-op Cloud/abra
Translate-URL: https://translate.coopcloud.tech/projects/co-op-cloud/abra/es/
2026-08-09 21:49:50 +00:00
renovate-bot 26089f0886 chore(deps): update golang docker tag
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2026-07-29 14:00:59 +00:00
renovate-bot a1de95a8ce chore(deps): update module github.com/go-git/go-git/v5 to v5.19.2
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2026-07-29 12:02:00 +00:00
renovate-bot f4861bf3c1 chore(deps): update otel/weaver docker tag to v0.25.1
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2026-07-29 02:00:47 +00:00
renovate-bot 7fb3b2f591 chore(deps): update otel/weaver docker tag to v0.25.0
continuous-integration/drone/push Build is passing
2026-07-24 20:01:06 +00:00
renovate-bot 0dec68294f chore(deps): update nginx docker tag to v1.31.3
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2026-07-16 02:01:04 +00:00
renovate-bot 69ba548dab chore(deps): update dependency codespell to v2.4.3
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2026-07-15 12:00:52 +00:00
renovate-bot cfa9ff15bb chore(deps): update golang docker tag
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2026-07-08 18:01:07 +00:00
renovate-bot ddf7441152 chore(deps): update module golang.org/x/term to v0.45.0
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2026-07-08 17:01:50 +00:00
renovate-bot 20b4a3fb84 chore(deps): update golang docker tag
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2026-07-08 14:01:06 +00:00
renovate-bot 81f4420a67 chore(deps): update module golang.org/x/sys to v0.47.0
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2026-07-08 13:01:44 +00:00
renovate-bot b13b10be9a chore(deps): update golang docker tag
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2026-06-30 21:00:55 +00:00
renovate-bot 8137e56d1e chore(deps): update module github.com/schollz/progressbar/v3 to v3.19.1
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2026-06-30 19:01:51 +00:00
renovate-bot f3943d25d1 chore(deps): update otel/weaver docker tag to v0.24.2
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2026-06-23 18:00:57 +00:00
renovate-bot 9859ff46f5 chore(deps): update otel/weaver docker tag to v0.24.1
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2026-06-21 21:01:00 +00:00
renovate-bot 3109a0da82 chore(deps): update otel/weaver docker tag to v0.24.0
continuous-integration/drone/push Build is passing
2026-06-20 00:01:07 +00:00
renovate-bot b1146a707e chore(deps): update nginx docker tag to v1.31.2
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2026-06-17 23:01:19 +00:00
Weblate 6d13d14d75 chore: update translation files
continuous-integration/drone/push Build is passing
Updated by "Update PO files to match POT (msgmerge)" add-on in Weblate.

Translation: Co-op Cloud/abra
Translate-URL: https://translate.coopcloud.tech/projects/co-op-cloud/abra/
2026-06-16 17:41:06 +00:00
Linus Gasser 427ec22cab Running i18n
continuous-integration/drone/push Build is passing
2026-06-16 17:40:58 +00:00
Linus Gasser 1111b69f12 Use docker login credentials from host
I had a lot of failures for pulling the docker images lately,
so I was looking for a way to connect using docker login.
This PR sends the docker login credentials from the host to
the swarm server.
2026-06-16 17:40:58 +00:00
Linus Gasser 1541e6aa6a Also use xgettext-go@latest for .drone.yml
continuous-integration/drone/push Build is passing
2026-06-14 18:37:34 +02:00
Linus Gasser 14ee80582a Use go run for xgettext-go
continuous-integration/drone/pr Build is passing
Before: on a mac, the Makefile downloaded a linux file, which did not work
for updating the i18n.

Now: use 'go run' to run the xgettext-go file. go caches it, so it compiles only
once.
2026-06-14 18:28:45 +02:00
renovate-bot e5c65b8fa0 chore(deps): update alpine docker tag to v3.24
continuous-integration/drone/push Build is passing
2026-06-09 21:00:57 +00:00
Weblate e1b10f6020 chore: update translation files
continuous-integration/drone/push Build is passing
Updated by "Update PO files to match POT (msgmerge)" add-on in Weblate.

Translation: Co-op Cloud/abra
Translate-URL: https://translate.coopcloud.tech/projects/co-op-cloud/abra/
2026-06-09 14:36:32 +00:00
moritz 8e4ed7b689 chore: make i18n
continuous-integration/drone/push Build is passing
2026-06-09 16:30:08 +02:00
moritz a44fde2df2 fix(new): checkout given recipeVersion before generating env closes #862
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2026-06-09 16:09:58 +02:00
renovate-bot e623f55852 chore(deps): update golang docker tag
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2026-06-08 17:01:04 +00:00
renovate-bot 11e6f28a60 chore(deps): update module golang.org/x/term to v0.44.0
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2026-06-08 16:01:46 +00:00
renovate-bot acec067d76 chore(deps): update golang docker tag
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2026-06-08 14:01:12 +00:00
renovate-bot f50a57af7a chore(deps): update module golang.org/x/sys to v0.46.0
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2026-06-08 13:01:32 +00:00
renovate-bot 776693acc0 chore(deps): update golang docker tag
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2026-05-22 23:01:07 +00:00
renovate-bot 5dea5f7746 chore(deps): update module golang.org/x/sys to v0.45.0
continuous-integration/drone/push Build is passing
2026-05-22 22:01:49 +00:00
renovate-bot 1d9a289888 chore(deps): update nginx docker tag to v1.31.1
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2026-05-22 21:00:50 +00:00
devydave c7bd55e371 feat: adds nix flake
continuous-integration/drone/push Build is passing
2026-05-20 22:29:47 +00:00
renovate-bot 4276337b0f chore(deps): update golang docker tag
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2026-05-18 22:01:06 +00:00
renovate-bot 90ca856b64 chore(deps): update module github.com/go-git/go-git/v5 to v5.19.1
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2026-05-18 21:01:33 +00:00
renovate-bot f2dd65491d chore(deps): update golang docker tag
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2026-05-15 16:01:27 +00:00
renovate-bot 0e902ed897 chore(deps): update coopcloud.tech/tagcmp digest to c26951b
continuous-integration/drone/push Build is passing
2026-05-15 14:01:42 +00:00
renovate-bot db001c1ba4 chore(deps): update tonistiigi/xx docker tag to v1.9.0
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2026-05-15 00:00:59 +00:00
renovate-bot e4215c09aa chore(deps): update nginx docker tag to v1.31.0
continuous-integration/drone/push Build is passing
2026-05-14 23:04:17 +00:00
renovate-bot e0e6dcb710 chore(deps): update otel/weaver docker tag to v0.23.0
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2026-05-14 23:00:58 +00:00
renovate-bot e7ddb74a08 chore(deps): update module golang.org/x/term to v0.43.0
continuous-integration/drone/push Build is passing
2026-05-14 22:04:37 +00:00
renovate-bot 24a5e6334f chore(deps): update golang docker tag
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2026-05-14 22:00:58 +00:00
renovate-bot 9d8eb2317e chore(deps): update module golang.org/x/sys to v0.44.0
continuous-integration/drone/push Build is passing
2026-05-14 21:01:45 +00:00
renovate-bot 5945ea8e1b chore(deps): update module github.com/go-git/go-git/v5 to v5.19.0
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2026-05-14 20:01:30 +00:00
renovate-bot e170d1c971 chore(deps): update alpine docker tag to v3.23
continuous-integration/drone/push Build is passing
2026-05-14 19:04:12 +00:00
renovate-bot 5eba3abb1b chore(deps): update golang docker tag
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2026-05-14 19:01:12 +00:00
renovate-bot df5a38e887 chore(deps): update module github.com/decentral1se/cobra to v1.10.2
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2026-05-14 18:01:50 +00:00
554 changed files with 49013 additions and 33155 deletions
+13 -8
View File
@@ -3,17 +3,22 @@ kind: pipeline
name: coopcloud.tech/abra
steps:
- name: make check
image: golang:1.26
image: golang:1.27
commands:
- make check
- name: xgettext-go
image: git.coopcloud.tech/toolshed/drone-xgettext-go:latest
settings:
keyword: i18n.G
keyword_ctx: i18n.GC
out: pkg/i18n/locales/abra.pot
comments_tag: translators
image: golang:1.27
environment:
GOPRIVATE: coopcloud.tech
commands:
- go run git.coopcloud.tech/toolshed/xgettext-go@latest
-o pkg/i18n/locales/abra.pot
--keyword=i18n.G
--keyword-ctx=i18n.GC
--sort-output
--add-comments-tag="translators"
$(find . -name "*.go" -not -path "*vendor*" | sort)
depends_on:
- make check
when:
@@ -38,7 +43,7 @@ steps:
- tag
- name: make test
image: golang:1.26
image: golang:1.27
environment:
ABRA_DIR: /root/.abra_test
commands:
+1
View File
@@ -0,0 +1 @@
vendor/** linguist-generated=true
+1
View File
@@ -7,3 +7,4 @@
/bin
dist/
tests/integration/.bats
tests/resources/path_to_repo
+2 -2
View File
@@ -1,5 +1,5 @@
# Build image
FROM golang:1.26-alpine AS build
FROM golang:1.27-alpine AS build
ENV GOPRIVATE=coopcloud.tech
@@ -15,7 +15,7 @@ WORKDIR /app
RUN CGO_ENABLED=0 make build
FROM alpine:3.22
FROM alpine:3.24
RUN apk add --no-cache \
ca-certificates \
+53 -8
View File
@@ -1,5 +1,5 @@
ABRA := ./cmd/abra
XGETTEXT := ./bin/xgettext-go
XGETTEXT := go run git.coopcloud.tech/toolshed/xgettext-go@latest
COMMIT := $(shell git rev-list -1 HEAD)
GOPATH := $(shell go env GOPATH)
GOVERSION := 1.26
@@ -62,8 +62,8 @@ update-po:
done
.PHONY: update-pot
update-pot: $(XGETTEXT)
@${XGETTEXT} \
update-pot:
@$(XGETTEXT) \
-o pkg/i18n/locales/$(DOMAIN).pot \
--keyword=i18n.G \
--keyword-ctx=i18n.GC \
@@ -71,11 +71,6 @@ update-pot: $(XGETTEXT)
--add-comments-tag="translators" \
$$(find . -name "*.go" -not -path "*vendor*" | sort)
${XGETTEXT}:
@mkdir -p ./bin && \
wget -O ./bin/xgettext-go https://git.coopcloud.tech/toolshed/xgettext-go/raw/branch/main/xgettext-go && \
chmod +x ./bin/xgettext-go
.PHONY: update-pot-po-metadata
update-pot-po-metadata:
@sed -i "s/charset=CHARSET/charset=UTF-8/g" pkg/i18n/locales/*.po pkg/i18n/locales/*.pot
@@ -89,3 +84,53 @@ build-mo:
release:
@goreleaser release --clean
CLIENT_VM := abra-client
vm-client-status:
sudo systemctl status microvm@$(CLIENT_VM).service
vm-client-create:test-integration-hosts
sudo microvm -f git+file://$$(pwd) -c $(CLIENT_VM)
vm-client-start:
sudo systemctl start microvm@$(CLIENT_VM).service
vm-client-update:test-integration-hosts
sudo microvm -R -f git+file://$$(pwd) -u $(CLIENT_VM)
vm-client-run:
sudo microvm -f git+file://$$(pwd) -r $(CLIENT_VM)
vm-client-stop:
sudo systemctl stop microvm@$(CLIENT_VM)
vm-client-delete: vm-client-stop
sudo rm -rf /var/lib/microvms/$(CLIENT_VM)
vm-client-connect:
ssh abra@10.0.0.2 -i ./tests/resources/local_integration_ssh
SERVER_VM := abra-server
vm-server-status:
sudo systemctl status microvm@$(SERVER_VM).service
vm-server-create:
sudo microvm -f git+file://$$(pwd) -c $(SERVER_VM)
vm-server-start:
sudo systemctl start microvm@$(SERVER_VM).service
vm-server-update:
sudo microvm -R -f git+file://$$(pwd) -u $(SERVER_VM)
vm-server-run:
sudo microvm -f git+file://$$(pwd) -r $(SERVER_VM)
vm-server-stop:
sudo systemctl stop microvm@$(SERVER_VM)
vm-server-delete: vm-server-stop
sudo rm -rf /var/lib/microvms/$(SERVER_VM)
vm-server-connect:
ssh abra@10.0.0.3 -i ./tests/resources/local_integration_ssh
test-integration-hosts:
./scripts/tests/extra_hosts
# TEST_INTEGRATION_FILES overrides the default of running all test files
ifdef TEST_INTEGRATION_FILES
test_files := $(TEST_INTEGRATION_FILES);
else
test_files := *;
endif
test-integration: build
chmod 600 ./tests/resources/local_integration_ssh
ssh-add ./tests/resources/local_integration_ssh
@bats -Tp --verbose-run tests/integration/$(test_files)
+6 -5
View File
@@ -151,11 +151,12 @@ checkout as-is. Recipe commit hashes are also supported as values for
stackName := app.StackName()
deployOpts := stack.Deploy{
Composefiles: composeFiles,
Namespace: stackName,
Prune: false,
ResolveImage: stack.ResolveImageAlways,
Detach: false,
Composefiles: composeFiles,
Namespace: stackName,
Prune: false,
ResolveImage: stack.ResolveImageAlways,
Detach: false,
SendRegistryAuth: true,
}
compose, err := appPkg.GetAppComposeConfig(app.Name, deployOpts, app.Env)
if err != nil {
+5 -1
View File
@@ -112,7 +112,11 @@ var AppNewCommand = &cobra.Command{
}
}
if len(recipeVersions) > 0 {
if recipeVersion != "" {
if _, err := recipe.EnsureVersion(recipeVersion); err != nil {
log.Fatal(err)
}
} else if len(recipeVersions) > 0 {
latest := recipeVersions[len(recipeVersions)-1]
for tag := range latest {
recipeVersion = tag
+6 -5
View File
@@ -166,11 +166,12 @@ beforehand. See "abra app backup" for more.`),
stackName := app.StackName()
deployOpts := stack.Deploy{
Composefiles: composeFiles,
Namespace: stackName,
Prune: false,
ResolveImage: stack.ResolveImageAlways,
Detach: false,
Composefiles: composeFiles,
Namespace: stackName,
Prune: false,
ResolveImage: stack.ResolveImageAlways,
Detach: false,
SendRegistryAuth: true,
}
compose, err := appPkg.GetAppComposeConfig(app.Name, deployOpts, app.Env)
+6 -5
View File
@@ -178,11 +178,12 @@ beforehand. See "abra app backup" for more.`),
stackName := app.StackName()
deployOpts := stack.Deploy{
Composefiles: composeFiles,
Namespace: stackName,
Prune: false,
ResolveImage: stack.ResolveImageAlways,
Detach: false,
Composefiles: composeFiles,
Namespace: stackName,
Prune: false,
ResolveImage: stack.ResolveImageAlways,
Detach: false,
SendRegistryAuth: true,
}
compose, err := appPkg.GetAppComposeConfig(app.Name, deployOpts, app.Env)
+1
View File
@@ -19,5 +19,6 @@ var (
Minor bool
NoDomainChecks bool
Patch bool
PinDigests bool
ShowUnchanged bool
)
+5 -4
View File
@@ -92,7 +92,7 @@ your private key and enter your passphrase beforehand.
log.Fatal(err)
}
mainAppVersion := imagesTmp[mainApp]
mainAppVersion := formatter.RemoveSha(imagesTmp[mainApp])
if mainAppVersion == "" {
log.Fatal(i18n.G("main app service version for %s is empty?", recipe.Name))
}
@@ -192,6 +192,9 @@ likely to change.
serviceMeta := serviceVersions[serviceName]
existingImageTag := fmt.Sprintf("%s:%s", serviceMeta.Image, serviceMeta.Tag)
if serviceMeta.Digest != "" {
existingImageTag += "@" + serviceMeta.Digest
}
newImageTag := fmt.Sprintf("%s:%s", serviceMeta.Image, imagesTmp[serviceMeta.Image])
if existingImageTag == newImageTag {
@@ -331,10 +334,8 @@ func GetImageVersions(recipe recipePkg.Recipe) (map[string]string, error) {
path = formatter.StripTagMeta(path)
var tag string
switch img.(type) {
case reference.NamedTagged:
tag = img.(reference.NamedTagged).Tag()
case reference.Named:
if service.Name == "app" {
missingTag = true
@@ -342,7 +343,7 @@ func GetImageVersions(recipe recipePkg.Recipe) (map[string]string, error) {
continue
}
services[path] = tag
services[path] = formatter.GetTagAndDigest(img)
}
if missingTag {
+42 -8
View File
@@ -130,7 +130,7 @@ interface.`),
}
for _, service := range config.Services {
img, err := reference.ParseNormalizedNamed(service.Image)
img, err := reference.ParseNormalizedNamed(formatter.RemoveSha(service.Image))
if err != nil {
log.Fatal(err)
}
@@ -178,9 +178,14 @@ interface.`),
sort.Sort(tagcmp.ByTagDesc(compatible))
var upgradeTag string
if len(compatible) == 0 && !allTags {
log.Info(i18n.G("no new versions available for %s, assuming %s is the latest (use -a/--all-tags to see all anyway)", image, tag))
continue // skip on to the next tag and don't update any compose files
if !shouldPin(service.Image, internal.PinDigests) {
continue
}
upgradeTag = tag.String()
}
catlVersions, err := recipePkg.VersionsOfService(recipe.Name, service.Name, internal.Offline)
@@ -203,9 +208,8 @@ interface.`),
log.Debug(i18n.G("detected compatible upgradable tags %s for %s", compatibleStrings, service.Name))
var upgradeTag string
_, ok := servicePins[service.Name]
if versionsPresent && ok {
if upgradeTag == "" && versionsPresent && ok {
pinnedTag := servicePins[service.Name].version
if tag.IsLessThan(pinnedTag) {
pinnedTagString := pinnedTag.String()
@@ -227,7 +231,7 @@ interface.`),
log.Fatal(i18n.G("service %s is at version %s, but pinned to %s, please correct your compose.yml file manually!", service.Name, tag.String(), pinnedTag.String()))
continue
}
} else {
} else if upgradeTag == "" {
if bumpType != 0 {
for _, upTag := range compatible {
upElement, err := tag.UpgradeDelta(upTag)
@@ -242,7 +246,10 @@ interface.`),
}
if upgradeTag == "" {
log.Warn(i18n.G("not upgrading from %s to %s for %s, because the upgrade type is more serious than what user wants", tag.String(), compatible[0].String(), image))
continue
if !shouldPin(service.Image, internal.PinDigests) {
continue
}
upgradeTag = tag.String()
}
} else {
msg := i18n.G("upgrade to which tag? (service: %s, image: %s, tag: %s)", service.Name, image, tag)
@@ -293,12 +300,25 @@ interface.`),
}
}
if upgradeTag != "skip" {
ok, err := recipe.UpdateTag(image, upgradeTag)
var resolvedDigest string
if shouldPin(service.Image, internal.PinDigests) {
resolvedDigest, err = client.GetImageMultiplatformDigest(img, upgradeTag)
if err != nil {
log.Fatal(i18n.G("failed to resolve digest for %s:%s: %s", image, upgradeTag, err.Error()))
}
}
ok, err := recipe.UpdateTag(service.Image, upgradeTag, resolvedDigest)
if err != nil {
log.Fatal(err)
}
if ok {
log.Info(i18n.G("tag upgraded from %s to %s for %s", tag.String(), upgradeTag, image))
if shouldPin(service.Image, internal.PinDigests) {
log.Info(i18n.G("tag upgraded and pinned from %s to %s@%s for %s", tag.String(), upgradeTag, resolvedDigest, image))
} else {
log.Info(i18n.G("tag upgraded from %s to %s for %s", tag.String(), upgradeTag, image))
}
}
} else {
if !internal.NoInput {
@@ -364,6 +384,12 @@ interface.`),
},
}
// shouldPin reports whether a service image should be pinned to a digest.
// An image that is already pinned stays pinned, even without --pindigests.
func shouldPin(image string, pinDigestsFlag bool) bool {
return pinDigestsFlag || strings.Contains(image, "@sha256:")
}
var (
allTags bool
createCommit bool
@@ -417,4 +443,12 @@ func init() {
false,
i18n.G("commit changes"),
)
RecipeUpgradeCommand.Flags().BoolVarP(
&internal.PinDigests,
i18n.G("pindigests"),
i18n.GC("p", "pin container image by digest"),
true,
i18n.G("pin the container image version by manifest digest (image:tag@sha256:...)"),
)
}
+22
View File
@@ -0,0 +1,22 @@
package recipe
import "testing"
func TestShouldPin(t *testing.T) {
tests := []struct {
image string
pinDigests bool
want bool
}{
{"nginx:1.21.0", false, false},
{"nginx:1.21.0", true, true},
{"nginx:1.21.0@sha256:2bcabc23b45489fb0885d69a06ba1d648aeda973fae7bb981bafbb884165e514", false, true},
{"nginx:1.21.0@sha256:2bcabc23b45489fb0885d69a06ba1d648aeda973fae7bb981bafbb884165e514", true, true},
}
for _, test := range tests {
if got := shouldPin(test.image, test.pinDigests); got != test.want {
t.Errorf("shouldPin(%q, %v) = %v, want %v", test.image, test.pinDigests, got, test.want)
}
}
}
Generated
+99
View File
@@ -0,0 +1,99 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"microvm": {
"inputs": {
"nixpkgs": [
"nixpkgs"
],
"spectrum": "spectrum"
},
"locked": {
"lastModified": 1784666190,
"narHash": "sha256-xgfS6slV7J3baMooNN1UuBi51RIgg9y0DbCxfSA0668=",
"owner": "astro",
"repo": "microvm.nix",
"rev": "fa5340ac684cdce8a22b6d4a0bcebb0cc999275e",
"type": "github"
},
"original": {
"owner": "astro",
"repo": "microvm.nix",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1778443072,
"narHash": "sha256-zi7/fsqM/kFdNuED//4WOCUtezGtKKqRNORjMvfwjnA=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "da5ad661ba4e5ef59ba743f0d112cbc30e474f32",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"microvm": "microvm",
"nixpkgs": "nixpkgs"
}
},
"spectrum": {
"flake": false,
"locked": {
"lastModified": 1783694892,
"narHash": "sha256-xO8f7Qng+18FK2UlB9vcrkxCaQMCt5WjCH24aW/11eg=",
"ref": "refs/heads/main",
"rev": "24c4346e30fdea8d8e80f34aec3554a15a667d24",
"revCount": 1410,
"type": "git",
"url": "https://spectrum-os.org/git/spectrum"
},
"original": {
"type": "git",
"url": "https://spectrum-os.org/git/spectrum"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
+106
View File
@@ -0,0 +1,106 @@
{
description = "The Co-op Cloud utility belt";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
microvm = {
url = "github:astro/microvm.nix";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs =
{
self,
nixpkgs,
microvm,
flake-utils,
}:
let
system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system};
publicKey = builtins.readFile ./tests/resources/local_integration_ssh.pub;
privateKey = builtins.readFile ./tests/resources/local_integration_ssh;
pathToRepo = builtins.readFile ./tests/resources/path_to_repo;
# local DNS aliases for the server host
extraHosts = builtins.readFile ./tests/resources/extra_hosts;
username = "abra";
password = "abra";
defaultDNS = [
# Quad9.net
"9.9.9.9"
"149.112.112.112"
"2620:fe::fe"
"2620:fe::9"
];
in
{
nixosConfigurations = {
abra-client = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
microvm.nixosModules.microvm
./nix/hosts/client/configuration.nix
];
specialArgs = {
inherit
publicKey
privateKey
username
password
defaultDNS
extraHosts
pathToRepo
;
};
};
abra-server = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
microvm.nixosModules.microvm
./nix/hosts/server/configuration.nix
];
specialArgs = {
inherit
publicKey
privateKey
username
password
defaultDNS
;
};
};
};
nixosModules = rec {
host = {
imports = [
microvm.nixosModules.host
./nix/modules/host.nix
];
};
default = host;
};
packages = rec {
abra = pkgs.callPackage ./nix/package.nix { };
default = abra;
};
apps = rec {
abra = flake-utils.lib.mkApp { drv = self.packages.${system}.abra; };
default = abra;
};
devShells.${system}.default = pkgs.mkShell {
# testing env variables
BATS_LIB_PATH = "~/.local/share/bats/";
TEST_SERVER = "abra.local";
ABRA_DIR = "$HOME/.abra_test";
packages = with pkgs; [
go_1_26
gnumake
gopls
];
};
};
}
+19 -20
View File
@@ -3,7 +3,7 @@ module coopcloud.tech/abra
go 1.26.0
require (
coopcloud.tech/tagcmp v0.0.0-20250818180036-0ec1b205b5ca
coopcloud.tech/tagcmp v0.0.0-20260515102403-c26951b55977
git.coopcloud.tech/toolshed/godotenv v1.5.2-0.20250103171850-4d0ca41daa5c
github.com/AlecAivazis/survey/v2 v2.3.7
github.com/charmbracelet/bubbletea v1.3.10
@@ -13,14 +13,15 @@ require (
github.com/docker/cli v28.4.0+incompatible
github.com/docker/docker v28.5.2+incompatible
github.com/docker/go-units v0.5.0
github.com/go-git/go-git/v5 v5.17.2
github.com/go-git/go-git/v5 v5.19.3
github.com/google/go-cmp v0.7.0
github.com/leonelquinteros/gotext v1.7.2
github.com/moby/sys/signal v0.7.1
github.com/moby/term v0.5.2
github.com/pkg/errors v0.9.1
github.com/schollz/progressbar/v3 v3.19.0
golang.org/x/term v0.41.0
github.com/regclient/regclient v0.11.5
github.com/schollz/progressbar/v3 v3.19.1
golang.org/x/term v0.47.0
gopkg.in/yaml.v3 v3.0.1
gotest.tools/v3 v3.5.2
)
@@ -50,7 +51,6 @@ require (
github.com/containerd/platforms v0.2.1 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/docker/distribution v2.8.3+incompatible // indirect
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-metrics v0.0.1 // indirect
@@ -60,7 +60,7 @@ require (
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/ghodss/yaml v1.0.0 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.8.0 // indirect
github.com/go-git/go-billy/v5 v5.9.2 // indirect
github.com/go-logfmt/logfmt v0.6.1 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
@@ -73,11 +73,10 @@ require (
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/kevinburke/ssh_config v1.6.0 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/klauspost/compress v1.18.6 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.21 // indirect
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
@@ -96,8 +95,7 @@ require (
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/runc v1.1.13 // indirect
github.com/opencontainers/runtime-spec v1.1.0 // indirect
github.com/pjbgf/sha1cd v0.5.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/pjbgf/sha1cd v0.7.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.20.1 // indirect
@@ -106,6 +104,7 @@ require (
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/skeema/knownhosts v1.3.2 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/ulikunitz/xz v0.5.15 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
@@ -123,11 +122,11 @@ require (
go.opentelemetry.io/otel/trace v1.42.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/text v0.35.0 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/crypto v0.56.0 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/text v0.41.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
@@ -151,13 +150,13 @@ require (
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/spf13/cobra v1.10.1
github.com/stretchr/testify v1.11.1
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.12.1
github.com/theupdateframework/notary v0.7.0 // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
golang.org/x/sys v0.42.0
golang.org/x/sys v0.49.0
)
replace github.com/docker/cli v28.4.0+incompatible => git.coopcloud.tech/toolshed/docker-cli v28.5.3-0.20260202112816-30df2d0b3a00+incompatible
replace github.com/spf13/cobra => github.com/decentral1se/cobra v1.10.2-i18n
replace github.com/spf13/cobra => github.com/decentral1se/cobra v1.10.2
+42 -36
View File
@@ -22,8 +22,8 @@ cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIA
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
coopcloud.tech/tagcmp v0.0.0-20250818180036-0ec1b205b5ca h1:gSD53tBAsbIGq4SnFfq+mEep6foekQ2a5ea7b38qkm0=
coopcloud.tech/tagcmp v0.0.0-20250818180036-0ec1b205b5ca/go.mod h1:ESVm0wQKcbcFi06jItF3rI7enf4Jt2PvbkWpDDHk1DQ=
coopcloud.tech/tagcmp v0.0.0-20260515102403-c26951b55977 h1:J7I0HFjwVAj/kkX6lwSTHmlXDRjQRsdIFNUUqu55ADY=
coopcloud.tech/tagcmp v0.0.0-20260515102403-c26951b55977/go.mod h1:ESVm0wQKcbcFi06jItF3rI7enf4Jt2PvbkWpDDHk1DQ=
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
@@ -306,10 +306,9 @@ github.com/d2g/dhcp4client v1.0.0/go.mod h1:j0hNfjhrt2SxUOw55nL0ATM/z4Yt3t2Kd1mW
github.com/d2g/dhcp4server v0.0.0-20181031114812-7d4a0a7f59a5/go.mod h1:Eo87+Kg/IX2hfWJfwxMzLyuSZyxSoAug2nGa1G2QAi8=
github.com/d2g/hardwareaddr v0.0.0-20190221164911-e7d9fbe030e4/go.mod h1:bMl4RjIciD2oAxI7DmWRx6gbeqrkoLqv3MV0vzNad+I=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decentral1se/cobra v1.10.2-i18n h1:XR+6AHHfnf4k5NM9f09oLMrEVwz3rkQIAIcqgL8R08g=
github.com/decentral1se/cobra v1.10.2-i18n/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/decentral1se/cobra v1.10.2 h1:MZ8Ifi/jRels9sZrpSccDbUlK++3b2HlBODfv0Bh6x0=
github.com/decentral1se/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/decentral1se/passgen v1.0.1 h1:j2AxK/kHKxDHWZZfkJj8Wgae9+O+DYEqR5sjKthIYKA=
github.com/decentral1se/passgen v1.0.1/go.mod h1:530V+lNoPhKtkrX2fIVsIfLhkl47CuiOM7HRgi7C+SU=
github.com/denisenkom/go-mssqldb v0.0.0-20191128021309-1d7a30a10f73/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
@@ -389,12 +388,12 @@ github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDzZG0=
github.com/go-git/go-billy/v5 v5.8.0/go.mod h1:RpvI/rw4Vr5QA+Z60c6d6LXH0rYJo0uD5SqfmrrheCY=
github.com/go-git/go-billy/v5 v5.9.2 h1:OXFSRyz4g20upsGDJgQG9Bak1l/ZEv8GHVYB52O71sE=
github.com/go-git/go-billy/v5 v5.9.2/go.mod h1:ExsU+jcGwXTBOnyilvAnEM1wug1IxHr4yP2ZXsNRtV0=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.17.2 h1:B+nkdlxdYrvyFK4GPXVU8w1U+YkbsgciIR7f2sZJ104=
github.com/go-git/go-git/v5 v5.17.2/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQqO7oZADXpo=
github.com/go-git/go-git/v5 v5.19.3 h1:qHttbZ+Am7wEjdTpR1XMQ4rl42FK9SIXzZ4o9x7s6M4=
github.com/go-git/go-git/v5 v5.19.3/go.mod h1:Ye4C8dVigkqrv9UsB4NMdSLV4yAIkLiIhW61gcOyhl4=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
@@ -427,6 +426,8 @@ github.com/go-sql-driver/mysql v1.3.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godbus/dbus v0.0.0-20151105175453-c7fdd8b5cd55/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw=
github.com/godbus/dbus v0.0.0-20180201030542-885f9cc04c9c/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw=
github.com/godbus/dbus v0.0.0-20190422162347-ade71ed3457e/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4=
@@ -584,10 +585,8 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o
github.com/klauspost/compress v1.11.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
github.com/klauspost/compress v1.11.13/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
github.com/klauspost/compress v1.14.2/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/pgzip v1.2.5/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
@@ -623,8 +622,8 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
@@ -699,6 +698,8 @@ github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+
github.com/ncw/swift v1.0.47/go.mod h1:23YIA4yWVnGwv2dQlN4bB7egfYX6YLn0Yo/S6zZO/ZM=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/olareg/olareg v0.2.1 h1:RPHGIaqlVWPbKAsOYUj7e2WfEEW5M7F8I6hKMrwd4jU=
github.com/olareg/olareg v0.2.1/go.mod h1:dhr8QetC7U7jJ2m93oxDhEEOKCRbPgOK1oGyKfB4QNo=
github.com/olekukonko/tablewriter v0.0.0-20170122224234-a0225b3f23b5/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo=
github.com/onsi/ginkgo v0.0.0-20151202141238-7f8ab55aaf3b/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v0.0.0-20170829012221-11459a886d9c/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
@@ -753,14 +754,13 @@ github.com/opencontainers/selinux v1.10.0/go.mod h1:2i0OySw99QjzBBQByd1Gr9gSjvuh
github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
github.com/pelletier/go-toml v1.8.1/go.mod h1:T2/BmBdy8dvIRq1a/8aqjN41wvWlN4lrapLU/GW4pbc=
github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU=
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
github.com/pjbgf/sha1cd v0.7.0 h1:ZRNPKHj+gfkLBf0KJv/p1Hmz+7bqCT5o311YX0nq+DA=
github.com/pjbgf/sha1cd v0.7.0/go.mod h1:pKR5Li+qTCo+ebqITZfwPlJGoCFCvSO00qxjbNtTUFQ=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1-0.20171018195549-f15c970de5b7/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/cachecontrol v0.0.0-20171018203845-0dec1b30a021/go.mod h1:prYjPmNq4d1NPVmpShWobRqXY3q7Vp+80DqgxxUrUIA=
github.com/prometheus/client_golang v0.0.0-20180209125602-c332b6f63c06/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
@@ -796,6 +796,8 @@ github.com/prometheus/procfs v0.2.0/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4O
github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/regclient/regclient v0.11.5 h1:OHRsXO0F3qHGfa4HEUv+EkMH9NXNcCTBKjNzyC/UhIA=
github.com/regclient/regclient v0.11.5/go.mod h1:DZUOfIT14WFTK2Pj4vjd93avy9O4Fdpjrf9ir23TbRE=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
@@ -808,8 +810,8 @@ github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/safchain/ethtool v0.0.0-20190326074333-42ed695e3de8/go.mod h1:Z0q5wiBQGYcxhMZ6gUqHn6pYNLypFAvaL3UvgZLR0U4=
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
github.com/schollz/progressbar/v3 v3.19.0 h1:Ea18xuIRQXLAUidVDox3AbwfUhD0/1IvohyTutOIFoc=
github.com/schollz/progressbar/v3 v3.19.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec=
github.com/schollz/progressbar/v3 v3.19.1 h1:iv8BgwOvdML/S3p84uBpy/IMigv4U9594vPZYa2EdrU=
github.com/schollz/progressbar/v3 v3.19.1/go.mod h1:LFL7jqimKxfhero4K1eCkUr/6R39AgQeiPCJtlTWIW8=
github.com/sclevine/spec v1.2.0/go.mod h1:W4J29eT/Kzv7/b9IWLB055Z+qvVC9vt0Arko24q7p+U=
github.com/seccomp/libseccomp-golang v0.9.1/go.mod h1:GbW5+tmTXfcxTToHLXlScSlAvWlF4P2Ca7zGrPiEpWo=
github.com/seccomp/libseccomp-golang v0.9.2-0.20210429002308-3879420cc921/go.mod h1:JA8cRccbGaA1s33RQf7Y1+q9gHmZX1yB/z9WDN1C6fg=
@@ -857,8 +859,10 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/sudo-bmitch/oci-digest v0.1.2 h1:are0qzWTsFZGZ3Uvdi9OSztJszSWaab6iqquMEEB7rw=
github.com/sudo-bmitch/oci-digest v0.1.2/go.mod h1:SH6l5OIe0islKBZBedjiPOeET/0QwGL+/oYfQt51uQo=
github.com/syndtr/gocapability v0.0.0-20170704070218-db04d3cc01c8/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww=
github.com/syndtr/gocapability v0.0.0-20180916011248-d98352740cb2/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww=
github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635 h1:kdXcSzyDtseVEc4yCz2qF8ZrQvIDBJLl4S1c3GCXmoI=
@@ -870,6 +874,8 @@ github.com/theupdateframework/notary v0.7.0/go.mod h1:c9DRxcmhHmVLDay4/2fUYdISnH
github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
github.com/ulikunitz/xz v0.5.10/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/urfave/cli v0.0.0-20171014202726-7bc6a0acffa5/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
@@ -946,8 +952,9 @@ go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.0.0-20171113213409-9f005a07e0d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20181009213950-7c1a557ab941/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
@@ -966,8 +973,8 @@ golang.org/x/crypto v0.0.0-20201117144127-c1f2f97bffc9/go.mod h1:jdWPYTVW3xRLrWP
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
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.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
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=
@@ -978,8 +985,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@@ -1043,8 +1050,8 @@ golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96b
golang.org/x/net v0.0.0-20210825183410-e898025ed96a/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
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.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
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=
@@ -1139,14 +1146,13 @@ golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
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-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
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.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
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=
@@ -1156,8 +1162,8 @@ golang.org/x/text v0.3.4/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.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
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=
+32
View File
@@ -9,6 +9,8 @@ import (
"github.com/containers/image/docker"
"github.com/containers/image/types"
"github.com/distribution/reference"
"github.com/regclient/regclient"
"github.com/regclient/regclient/types/ref"
)
// GetRegistryTags retrieves all tags of an image from a container registry.
@@ -28,3 +30,33 @@ func GetRegistryTags(img reference.Named) ([]string, error) {
return tags, nil
}
// GetImageDigest resolves the content digest (sha256:...) for a specific
// image:tag reference by querying the registry manifest. This gives us a
// digest to pin to while keeping support for multi-arch deployments
// See: https://docs.docker.com/build/building/multi-platform/#why-multi-platform-builds
// and: https://docs.docker.com/dhi/explore/security-concepts/digests/#multi-platform-images-and-manifests
func GetImageMultiplatformDigest(img reference.Named, tag string) (string, error) {
tagged, err := reference.WithTag(reference.TrimNamed(img), tag)
if err != nil {
return "", errors.New(i18n.G("failed to parse image %s:%s, saw: %s", img.Name(), tag, err.Error()))
}
r, err := ref.New(tagged.String())
if err != nil {
return "", errors.New(i18n.G("failed to parse image %s:%s, saw: %s", img.Name(), tag, err.Error()))
}
rc := regclient.New(regclient.WithDockerCreds())
m, err := rc.ManifestHead(context.Background(), r)
if err != nil {
return "", errors.New(i18n.G("failed to fetch manifest for %s:%s, saw: %s", img.Name(), tag, err.Error()))
}
digest := m.GetDescriptor().Digest.String()
if digest == "" {
return "", errors.New(i18n.G("registry did not return a digest for %s:%s", img.Name(), tag))
}
return digest, nil
}
+6 -2
View File
@@ -171,7 +171,7 @@ func GetImagesForStack(cl *dockerClient.Client, app appPkg.App) (map[string]stri
images[imageBaseName] = ""
continue
}
imageTag := namedTag.Tag()
imageTag := formatter.GetTagAndDigest(namedTag)
existingImageVersion, ok := images[imageBaseName]
if !ok {
@@ -294,7 +294,7 @@ func GatherImagesForDeploy(cl *dockerClient.Client, app appPkg.App, compose *com
newImages[imageBaseName] = ""
continue
}
imageTag := namedTag.Tag()
imageTag := formatter.GetTagAndDigest(namedTag)
existingImageVersion, ok := newImages[imageBaseName]
if !ok {
@@ -312,6 +312,10 @@ func GatherImagesForDeploy(cl *dockerClient.Client, app appPkg.App, compose *com
var imageInfo []string
for newImageName, newImageVersion := range newImages {
if currentVersion, exists := currentImages[newImageName]; exists {
// Swarm adds a resolved digest even for unpinned compose images.
if !strings.Contains(newImageVersion, "@") {
currentVersion = formatter.RemoveSha(currentVersion)
}
if currentVersion == newImageVersion {
if showUnchanged {
imageInfo = append(imageInfo, i18n.G("%s: %s (unchanged)", formatter.StripTagMeta(newImageName), newImageVersion))
+17 -2
View File
@@ -11,6 +11,7 @@ import (
"coopcloud.tech/abra/pkg/i18n"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/table"
"github.com/distribution/reference"
"github.com/docker/go-units"
"golang.org/x/term"
@@ -34,11 +35,24 @@ func SmallSHA(hash string) string {
return hash[:8]
}
// RemoveSha remove image sha from a string that are added in some docker outputs
// RemoveSha remove image sha (digest suffix) from a string that are added in some docker outputs
// e.g., "image:tag@sha256:..." -> "image:tag"
func RemoveSha(str string) string {
return strings.Split(str, "@")[0]
}
// GetTagAndDigest returns the tag and any digest for comparison and display.
func GetTagAndDigest(image reference.Named) string {
var version string
if tagged, ok := image.(reference.NamedTagged); ok {
version = tagged.Tag()
}
if pinned, ok := image.(reference.Digested); ok {
version += "@" + pinned.Digest().String()
}
return version
}
// HumanDuration from docker/cli RunningFor() to be accessible outside of the class
func HumanDuration(timestamp int64) string {
date := time.Unix(timestamp, 0)
@@ -222,7 +236,8 @@ func CreateProgressbar(length int, title string) *progressbar.ProgressBar {
)
}
// StripTagMeta strips front-matter image tag data that we don't need for parsing.
// StripTagMeta strips front-matter image tag data that we don't need for parsing,
// like registry host and library prefix.
func StripTagMeta(image string) string {
originalImage := image
+26
View File
@@ -3,9 +3,35 @@ package formatter
import (
"testing"
"github.com/distribution/reference"
"github.com/stretchr/testify/assert"
)
func TestRemoveSha(t *testing.T) {
assert.Equal(t, "nginx:latest", RemoveSha("nginx:latest@sha256:1234567890abcdef"))
assert.Equal(t, "nginx:latest", RemoveSha("nginx:latest"))
assert.Equal(t, "my-repo/my-image:v1", RemoveSha("my-repo/my-image:v1@sha256:abcdef1234567890"))
assert.Equal(t, "my-repo/my-image", RemoveSha("my-repo/my-image@sha256:abcdef1234567890"))
}
func TestBoldDirtyDefault(t *testing.T) {
assert.Equal(t, "foo", BoldDirtyDefault("foo"))
}
func TestGetTagAndDigest(t *testing.T) {
digest := "sha256:908dc23e643a1447dbfb2e189ed268bfde6a51a5bf9a34d3dd3440a24f58ccf7"
for _, tc := range []struct{ image, want string }{
{"nginx:1.21.6", "1.21.6"},
{"ghcr.io/foo/bar:1.0@" + digest, "1.0@" + digest},
{"nginx@" + digest, "@" + digest},
{"nginx", ""},
} {
img, err := reference.ParseNormalizedNamed(tc.image)
if err != nil {
t.Fatal(err)
}
if got := GetTagAndDigest(img); got != tc.want {
t.Errorf("%s: got %s, want %s", tc.image, got, tc.want)
}
}
}
-2
View File
@@ -52,7 +52,6 @@ func Clone(dir, url string) error {
URL: url,
Tags: git.AllTags,
ReferenceName: plumbing.ReferenceName("refs/heads/main"),
SingleBranch: true,
})
if err != nil && gitCloneIgnoreErr(err) {
@@ -71,7 +70,6 @@ func Clone(dir, url string) error {
URL: url,
Tags: git.AllTags,
ReferenceName: plumbing.ReferenceName("refs/heads/master"),
SingleBranch: true,
})
if err != nil && gitCloneIgnoreErr(err) {
+276 -210
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.
+904 -481
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -125,6 +125,13 @@ var LintRules = map[string][]LintRule{
HowToResolve: i18n.G("reduce length of secret names to 12 chars"),
Function: LintSecretLengths,
},
{
Ref: "R016",
Level: i18n.G("warn"),
Description: i18n.G("all images use a digest"),
HowToResolve: i18n.G("use a digest for all images"),
Function: LintAllImagesTaggedWithDigest,
},
},
"error": {
{
@@ -333,6 +340,28 @@ func LintAllImagesTagged(recipe recipe.Recipe) (bool, error) {
return true, nil
}
func LintAllImagesTaggedWithDigest(recipe recipe.Recipe) (bool, error) {
config, err := recipe.GetComposeConfig(nil)
if err != nil {
return false, err
}
for _, service := range config.Services {
img, err := reference.ParseNormalizedNamed(service.Image)
if err != nil {
return false, err
}
if reference.IsNameOnly(img) {
return false, nil
}
_, ok := img.(reference.Digested)
if !ok {
return false, nil
}
}
return true, nil
}
func LintNoUnstableTags(recipe recipe.Recipe) (bool, error) {
config, err := recipe.GetComposeConfig(nil)
if err != nil {
+82
View File
@@ -0,0 +1,82 @@
package recipe
import (
"encoding/json"
"strings"
"testing"
"coopcloud.tech/abra/pkg/config"
gitPkg "coopcloud.tech/abra/pkg/git"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
)
// Use the test recipe at the given commit, in a temporary recipes directory.
func setupTestRecipe(t *testing.T, commit string) Recipe {
t.Helper()
// Other tests in this package use the real RECIPES_DIR.
recipesDir := config.RECIPES_DIR
config.RECIPES_DIR = t.TempDir()
t.Cleanup(func() { config.RECIPES_DIR = recipesDir })
r := Get("abra-test-recipe")
if err := gitPkg.Clone(r.Dir, "https://git.coopcloud.tech/toolshed/abra-test-recipe"); err != nil {
t.Fatal(err)
}
if _, err := r.EnsureVersion(commit); err != nil {
t.Fatal(err)
}
return r
}
func TestGetRecipeVersionsIncludesDigest(t *testing.T) {
if testing.Short() {
t.Skip("skipping e2e test in short mode")
}
// https://git.coopcloud.tech/toolshed/abra-test-recipe/commit/4f0642719d2e2a568f4350cf7eea182438d58be3
r := setupTestRecipe(t, "4f0642719d2e2a568f4350cf7eea182438d58be3")
repo, err := git.PlainOpen(r.Dir)
if err != nil {
t.Fatal(err)
}
for version, commit := range map[string]string{
"9.0.0+1.21.5": "4f0642719d2e2a568f4350cf7eea182438d58be3",
"9.0.1+1.21.6": "77d752b3c61a71bfb2c45f40ec07c9d626a0e580",
} {
if _, err := repo.CreateTag(version, plumbing.NewHash(commit), nil); err != nil {
t.Fatal(err)
}
}
versions, _, err := r.GetRecipeVersions()
if err != nil {
t.Fatal(err)
}
found := map[string]ServiceMeta{}
for _, version := range versions {
for tag, services := range version {
if strings.HasPrefix(tag, "9.0.") {
found[tag] = services["app"]
}
}
}
pinned := found["9.0.0+1.21.5"]
if pinned.Tag != "1.21.5" || pinned.Digest != "sha256:0d17b565c37bcbd895e9d92315a05c1c3c9a29f762b011a10c54a66cd53c9b31" {
t.Fatalf("got pinned metadata %+v", pinned)
}
unpinned := found["9.0.1+1.21.6"]
if unpinned.Tag != "1.21.6" || unpinned.Digest != "" {
t.Fatalf("got unpinned metadata %+v", unpinned)
}
for _, tc := range []struct {
meta ServiceMeta
wantDigest bool
}{{pinned, true}, {unpinned, false}} {
encoded, err := json.Marshal(tc.meta)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(encoded), `"digest"`) != tc.wantDigest {
t.Fatalf("unexpected JSON %s", encoded)
}
}
}
+42 -13
View File
@@ -6,6 +6,7 @@ import (
"io/ioutil"
"os"
"path/filepath"
"regexp"
"strings"
"coopcloud.tech/abra/pkg/formatter"
@@ -110,10 +111,23 @@ func (r Recipe) GetVersionLabelLocal() (string, error) {
return label, nil
}
var sha256Digest = regexp.MustCompile(`^sha256:[a-f0-9]{64}$`)
// UpdateTag updates an image tag in-place on file system local compose files.
func (r Recipe) UpdateTag(image, tag string) (bool, error) {
// A non-empty digest pins the tag (image:tag@sha256:...).
func (r Recipe) UpdateTag(image, tag, digest string) (bool, error) {
// Optional parameter, but must be a valid sha256 digest if provided.
if digest != "" && !sha256Digest.MatchString(digest) {
return false, errors.New(i18n.G("invalid digest %s, want sha256:<64 hex digits>", digest))
}
fullPattern := fmt.Sprintf("%s/compose**yml", r.Dir)
image = formatter.StripTagMeta(image)
source, parseErr := reference.ParseNormalizedNamed(image)
_, exactImage := source.(reference.NamedTagged)
if parseErr != nil {
return false, parseErr
}
imageName := formatter.StripTagMeta(reference.Path(source))
composeFiles, err := filepath.Glob(fullPattern)
if err != nil {
@@ -122,6 +136,7 @@ func (r Recipe) UpdateTag(image, tag string) (bool, error) {
log.Debug(i18n.G("considering %s config(s) for tag update", strings.Join(composeFiles, ", ")))
updated := false
for _, composeFile := range composeFiles {
opts := stack.Deploy{Composefiles: []string{composeFile}}
@@ -140,44 +155,58 @@ func (r Recipe) UpdateTag(image, tag string) (bool, error) {
continue // may be a compose.$optional.yml file
}
img, _ := reference.ParseNormalizedNamed(service.Image)
img, err := reference.ParseNormalizedNamed(formatter.RemoveSha(service.Image))
if err != nil {
return false, err
}
var composeTag string
switch img.(type) {
case reference.NamedTagged:
composeTag = img.(reference.NamedTagged).Tag()
default:
log.Debug(i18n.G("unable to parse %s, skipping", img))
continue
}
// Found image that needs replacing
composeImage := formatter.StripTagMeta(reference.Path(img))
log.Debug(i18n.G("parsed %s from %s", composeTag, service.Image))
if image == composeImage {
if imageName == composeImage && (!exactImage || service.Image == image) {
bytes, err := ioutil.ReadFile(composeFile)
if err != nil {
return false, err
}
old := fmt.Sprintf("%s:%s", composeImage, composeTag)
new := fmt.Sprintf("%s:%s", composeImage, tag)
replacedBytes := strings.Replace(string(bytes), old, new, -1)
old := service.Image
tagged, err := reference.WithTag(reference.TrimNamed(img), tag)
if err != nil {
return false, err
}
new := reference.FamiliarString(tagged)
if digest != "" {
new += "@" + digest
}
if old == new {
continue
}
// Don't confuse digests if the image isn't an exact match. E.g. if old is postgres:13, don't
// match postgres:13-alpine
pattern := regexp.MustCompile(`(?m)(^\s*image:\s*["']?)` + regexp.QuoteMeta(old) + `(["']?\s*(?:#.*)?$)`)
replacedBytes := pattern.ReplaceAllString(string(bytes), "${1}"+new+"${2}")
if replacedBytes == string(bytes) {
continue
}
log.Debug(i18n.G("updating %s to %s in %s", old, new, compose.Filename))
if err := os.WriteFile(compose.Filename, []byte(replacedBytes), 0o764); err != nil {
return false, err
}
updated = true
}
}
}
return false, nil
return updated, nil
}
// UpdateLabel updates a label in-place on file system local compose files.
+14 -7
View File
@@ -16,6 +16,7 @@ import (
"coopcloud.tech/tagcmp"
"github.com/distribution/reference"
"github.com/go-git/go-git/v5"
gitCfg "github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing"
)
@@ -163,7 +164,7 @@ func (r Recipe) EnsureVersion(version string) (bool, error) {
hash, err := repo.ResolveRevision(plumbing.Revision(version))
if err != nil {
log.Fatal(i18n.G("unable to resolve '%s': %s", version, err))
return isChaosCommit, errors.New(i18n.G("unable to resolve '%s': %s", version, err))
}
opts = &git.CheckoutOptions{Hash: *hash, Create: false, Force: true}
@@ -174,11 +175,11 @@ func (r Recipe) EnsureVersion(version string) (bool, error) {
worktree, err := repo.Worktree()
if err != nil {
return isChaosCommit, nil
return isChaosCommit, err
}
if err := worktree.Checkout(opts); err != nil {
return isChaosCommit, nil
return isChaosCommit, err
}
log.Debug(i18n.G("successfully checked %s out to %s in %s", r.Name, tagRef.Short(), r.Dir))
@@ -262,7 +263,12 @@ func (r Recipe) EnsureUpToDate() error {
return errors.New(i18n.G("unable to check out default branch in %s: %s", r.Dir, err))
}
fetchOpts := &git.FetchOptions{Tags: git.AllTags}
// the refspec is passed explicitly, because a repository cloned by an older abra has a
// single-branch refspec stored in its config and would otherwise never see other branches
fetchOpts := &git.FetchOptions{
Tags: git.AllTags,
RefSpecs: []gitCfg.RefSpec{"+refs/heads/*:refs/remotes/origin/*"},
}
if err := repo.Fetch(fetchOpts); err != nil {
if !strings.Contains(err.Error(), "already up-to-date") {
return errors.New(i18n.G("unable to fetch tags in %s: %s", r.Dir, err))
@@ -446,10 +452,11 @@ func (r Recipe) GetRecipeVersions() (RecipeVersions, []string, error) {
continue
}
versionMeta[service.Name] = ServiceMeta{
Image: path,
Tag: tag,
meta := ServiceMeta{Image: path, Tag: tag}
if pinned, ok := img.(reference.Digested); ok {
meta.Digest = pinned.Digest().String()
}
versionMeta[service.Name] = meta
}
versions = append(versions, map[string]map[string]ServiceMeta{tag: versionMeta})
+3 -2
View File
@@ -39,8 +39,9 @@ type service = string
// ServiceMeta represents meta info associated with a service.
type ServiceMeta struct {
Image string `json:"image"`
Tag string `json:"tag"`
Image string `json:"image"`
Tag string `json:"tag"`
Digest string `json:"digest,omitempty"`
}
// RecipeVersions are the versions associated with a recipe.
+1 -1
View File
@@ -3,7 +3,7 @@ version: "3.8"
services:
app:
image: nginx:1.21.0
image: nginx:1.31.6
secrets:
- test_pass_one
- test_pass_two
+2 -1
View File
@@ -7,4 +7,5 @@
* Integration tests are in `./tests/integration`. Please see [these
docs](https://docs.coopcloud.tech/abra/hack/#integration-tests) for
instructions and tips on how to run them.
instructions and tips on how to run them. If you want to run it locally via nix,
read the [nix testing docs](../nix/hosts/README.md).
+73 -5
View File
@@ -598,8 +598,7 @@ teardown(){
run $ABRA app deploy "$TEST_APP_DOMAIN" --no-input
assert_success
run docker inspect --format='{{range .Config.Env}}{{println .}}{{end}}' \
$(docker ps -f name="$TEST_APP_DOMAIN_$TEST_SERVER" -q)
_inspect_env_test_app
assert_success
assert_output --partial "WITH_COMMENT=foo"
@@ -610,8 +609,7 @@ teardown(){
run $ABRA app deploy "$TEST_APP_DOMAIN" --no-input --force
assert_success
run docker inspect --format='{{range .Config.Env}}{{println .}}{{end}}' \
$(docker ps -f name="$TEST_APP_DOMAIN_$TEST_SERVER" -q)
_inspect_env_test_app
assert_success
refute_output --partial "WITH_COMMENT=foo"
assert_output --partial "WITH_COMMENT=bar"
@@ -629,8 +627,14 @@ teardown(){
run $ABRA app deploy "$TEST_APP_DOMAIN" --no-input
assert_success
run docker service inspect --format '{{ range .Endpoint.Ports }}{{ .Protocol }}={{ .PublishedPort }}{{ end }}' \
if _is_local;
then
run docker service inspect --format "{{ range .Endpoint.Ports }}{{ .Protocol }}={{ .PublishedPort }}{{ end }}" \
"${TEST_APP_DOMAIN//./_}_app"
else
run ssh "$TEST_SERVER" "docker service inspect --format '{{ range .Endpoint.Ports }}{{ .Protocol }}={{ .PublishedPort }}{{ end }}' ${TEST_APP_DOMAIN//./_}_app"
fi
assert_success
assert_output --partial "tcp=1312"
assert_output --partial "udp=1312"
@@ -702,3 +706,67 @@ teardown(){
assert_failure
assert_output --partial "unexpected character"
}
# Deploy pinned 1.21.5, upgrade to pinned 1.21.6, then roll back to 1.21.5.
# bats test_tags=slow
@test "deploy, upgrade and rollback digest-pinned images" {
recipeDir="$ABRA_DIR/recipes/$TEST_RECIPE"
service="${TEST_APP_DOMAIN//./_}_app"
# Manually upgrade the unpinned 0.3.5 recipe to 1.21.5 and pin it.
run git -C "$recipeDir" checkout "0.3.5+1.21.0"
assert_success
run docker pull nginx:1.21.5
assert_success
run docker image inspect --format '{{index .RepoDigests 0}}' nginx:1.21.5
assert_success
oldImage="nginx:1.21.5@${output#*@}"
run sed -i "s|image: nginx:1.21.0|image: $oldImage|" "$recipeDir/compose.yml"
assert_success
# Label and tag the local recipe release so app commands can track it.
run sed -i 's/version=0.3.5+1.21.0/version=0.4.0+1.21.5/' "$recipeDir/compose.yml"
assert_success
_git_commit
run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" tag \
-a "0.4.0+1.21.5" -m "fake: 0.4.0+1.21.5"
assert_success
# Manually upgrade and pin the next release to 1.21.6.
run docker pull nginx:1.21.6
assert_success
run docker image inspect --format '{{index .RepoDigests 0}}' nginx:1.21.6
assert_success
newImage="nginx:1.21.6@${output#*@}"
run sed -i "s|image: $oldImage|image: $newImage|" "$recipeDir/compose.yml"
assert_success
# Update the release label to match the next recipe tag.
run sed -i 's/version=0.4.0+1.21.5/version=0.4.1+1.21.6/' "$recipeDir/compose.yml"
assert_success
_git_commit
run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" tag \
-a "0.4.1+1.21.6" -m "fake: 0.4.1+1.21.6"
assert_success
# Deploy recipe 0.4.0 with the exact pinned 1.21.5 image.
run $ABRA app deploy "$TEST_APP_DOMAIN" "0.4.0+1.21.5" --offline --no-input --no-converge-checks
assert_success
run docker service inspect --format '{{.Spec.TaskTemplate.ContainerSpec.Image}}' "$service"
assert_success
assert_output "$oldImage"
# Upgrade to recipe 0.4.1 and the exact pinned 1.21.6 image.
run $ABRA app upgrade "$TEST_APP_DOMAIN" "0.4.1+1.21.6" --offline --no-input --no-converge-checks
assert_success
run docker service inspect --format '{{.Spec.TaskTemplate.ContainerSpec.Image}}' "$service"
assert_success
assert_output "$newImage"
# Roll back to recipe 0.4.0 and its original pinned image.
run $ABRA app rollback "$TEST_APP_DOMAIN" "0.4.0+1.21.5" --offline --no-input --no-converge-checks
assert_success
run docker service inspect --format '{{.Spec.TaskTemplate.ContainerSpec.Image}}' "$service"
assert_success
assert_output "$oldImage"
}
@@ -133,8 +133,7 @@ teardown(){
run $ABRA app deploy "$TEST_APP_DOMAIN" "0.1.0+1.20.0" --no-input
assert_success
run docker inspect --format='{{range .Config.Env}}{{println .}}{{end}}' \
$(docker ps -f name="$TEST_APP_DOMAIN_$TEST_SERVER" -q)
_inspect_env_test_app
assert_success
assert_output --partial "$TEST_RECIIPE:0.1.0+1.20.0"
}
+53
View File
@@ -61,6 +61,16 @@ teardown(){
run grep -q "TYPE=$TEST_RECIPE:0.3.0+1.21.0" \
"$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
assert_success
# 0.3.0-only
run grep -q "NETWORK_WITH_COMMENT=BAZ" \
"$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
assert_success
# latest-only
run grep -q "TIMEOUT=120" \
"$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
assert_failure
}
@test "ensure recipe is up-to-date" {
@@ -100,6 +110,49 @@ teardown(){
run grep -q "TYPE=$TEST_RECIPE:${tagHash}" \
"$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
assert_success
# 0.3.0-only
run grep -q "NETWORK_WITH_COMMENT=BAZ" \
"$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
assert_success
# latest-only
run grep -q "TIMEOUT=120" \
"$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
assert_failure
}
@test "create new app with commit from another branch" {
branchHash=$(_get_other_branch_hash)
if [[ -z "$branchHash" ]]; then
skip "$TEST_RECIPE has no branch besides main"
fi
# re-clone the recipe the way an older abra did, with a single-branch refspec. --no-local
# forces a real transfer, a local clone would hardlink the whole object database and leave
# the commit reachable
run rm -rf "$ABRA_DIR/recipes/$TEST_RECIPE"
assert_success
run git clone -q --no-local --single-branch --branch main \
"$ABRA_DIR/origin-recipes/$TEST_RECIPE.git" "$ABRA_DIR/recipes/$TEST_RECIPE"
assert_success
# the commit has to be genuinely missing, otherwise this test passes for the wrong reason
run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" rev-parse --verify "$branchHash^{commit}"
assert_failure
run $ABRA app new "$TEST_RECIPE" "$branchHash" \
--no-input \
--server "$TEST_SERVER" \
--domain "$TEST_APP_DOMAIN"
assert_success
assert_exists "$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
# the recipe names itself in TYPE, which differs per branch, so only the version is checked
run grep -q "TYPE=.*:${branchHash}$" \
"$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
assert_success
}
@test "does not overwrite existing env files" {
+11 -4
View File
@@ -140,15 +140,22 @@ teardown(){
_undeploy_app
sanitisedDomainName="${TEST_APP_DOMAIN//./_}"
run docker config create "${sanitisedDomainName}_test_conf_v99" "$ABRA_DIR/recipes/abra-test-recipe/abra.sh"
assert_success
remote_ssh_command=""
if _is_local;
then
run docker config create "${sanitisedDomainName}_test_conf_v99" "$ABRA_DIR/recipes/abra-test-recipe/abra.sh"
else
remote_ssh_command="ssh '$TEST_SERVER'"
run cat "$ABRA_DIR/recipes/abra-test-recipe/abra.sh" | ssh "$TEST_SERVER" docker config create "${sanitisedDomainName}_test_conf_v99 -"
assert_success
fi
assert bash -c "docker config ls | grep -q test_conf_v99"
assert bash -c "$remote_ssh_command docker config ls | grep -q test_conf_v99"
run $ABRA app rm "$TEST_APP_DOMAIN" --no-input
assert_success
refute bash -c "docker config ls | grep -q test_conf_v99"
refute bash -c "$remote_ssh_command docker config ls | grep -q test_conf_v99"
}
@test "remove .env file" {
@@ -54,8 +54,7 @@ teardown(){
--no-input
assert_success
run docker inspect --format='{{range .Config.Env}}{{println .}}{{end}}' \
$(docker ps -f name="$TEST_APP_DOMAIN_$TEST_SERVER" -q)
_inspect_env_test_app
assert_success
assert_output --partial "$TEST_RECIIPE:0.1.0+1.20.0"
}
@@ -53,8 +53,7 @@ teardown(){
run $ABRA app upgrade "$TEST_APP_DOMAIN" "0.2.0+1.21.0" --no-input
assert_success
run docker inspect --format='{{range .Config.Env}}{{println .}}{{end}}' \
$(docker ps -f name="$TEST_APP_DOMAIN_$TEST_SERVER" -q)
_inspect_env_test_app
assert_success
assert_output --partial "$TEST_RECIIPE:0.2.0+1.21.0"
assert_output --partial "$TEST_RECIPE:0.2.0+1.21.0"
}
+11 -2
View File
@@ -31,9 +31,18 @@ _ensure_ssh_agent() {
exit 1
fi
export SSH_AUTH_SOCK="$HOME/.ssh/ssh_auth_sock"
if [ ! -S ~/.ssh/ssh_auth_sock ]; then
if [[ ! -v SSH_AUTH_SOCK ]]; then
export SSH_AUTH_SOCK="$HOME/.ssh/ssh_auth_sock"
eval `ssh-agent`
ln -sf "$SSH_AUTH_SOCK" ~/.ssh/ssh_auth_sock
fi
}
_is_local() {
if [[ "$TEST_SERVER" == "default" ]];
then
return 0
else
return 1
fi
}
+30 -6
View File
@@ -1,13 +1,37 @@
#!/usr/bin/env bash
_ensure_swarm() {
if [ "$(docker info | grep Swarm | sed 's/Swarm: //g' | tr -d ' ')" == "inactive" ]; then
run docker swarm init --advertise-addr 127.0.0.1:2377
assert_success
if [ "$(docker info | grep Swarm | sed 's/Swarm: //g' | tr -d ' ')" == "inactive" ]; then
run docker swarm init --advertise-addr 127.0.0.1:2377
assert_success
fi
if ! $(docker network ls | grep -q 'proxy'); then
run docker network create -d overlay proxy
assert_success
if ! docker network ls | grep -q 'proxy'; then
run docker network create -d overlay proxy
assert_success
fi
if ! _is_local;
then
if [ "$(ssh "$TEST_SERVER" docker info | grep Swarm | sed 's/Swarm: //g' | tr -d ' ')" == "inactive" ]; then
run ssh "$TEST_SERVER" docker swarm init --advertise-addr 127.0.0.1:2377
assert_success
fi
if ! ssh "$TEST_SERVER" docker network ls | grep -q 'proxy'; then
run ssh "$TEST_SERVER" docker network create -d overlay proxy
assert_success
fi
fi
}
_inspect_env_test_app() {
if _is_local;
then
containerId="$(docker ps -f name="$TEST_APP_DOMAIN_$TEST_SERVER" -q)"
run docker inspect --format='{{range .Config.Env}}{{println .}}{{end}}' "$containerId"
else
containerId="$(ssh "$TEST_SERVER" docker ps -f name="$TEST_APP_DOMAIN" -q)"
run ssh "$TEST_SERVER" "docker inspect --format='{{range .Config.Env}}{{println .}}{{end}}' '$containerId'"
fi
}
+7
View File
@@ -56,6 +56,13 @@ _get_tag_hash() {
echo $(git -C "$ABRA_DIR/recipes/$TEST_RECIPE" rev-list -n 1 "$1")
}
_get_other_branch_hash() {
# asked from the origin mirror, not from the recipe checkout, so that the result does not
# depend on what has been fetched. empty when the recipe only has a default branch
echo $(git ls-remote "$ABRA_DIR/origin-recipes/$TEST_RECIPE.git" \
| grep 'refs/heads/' | grep -v 'refs/heads/main$' | head -1 | cut -f1)
}
_get_head_hash() {
echo $(git -C "$ABRA_DIR/recipes/$TEST_RECIPE" show -s --format="%H" HEAD)
}
-2
View File
@@ -38,8 +38,6 @@ teardown(){
run $ABRA recipe upgrade "custom-html" --no-input
assert_success
assert_output --partial 'can upgrade service: app'
assert_exists "$ABRA_DIR/recipes/custom-html"
}
+3 -3
View File
@@ -27,7 +27,7 @@ teardown(){
assert bash -c "docker context ls | grep -q $TEST_SERVER"
server_dir_perms=$(stat -c "%a" "$ABRA_DIR/servers/$TEST_SERVER")
assert_equal $server_dir_perms "700"
assert_equal "$server_dir_perms" "700"
}
@test "error if using name and --local together" {
@@ -43,8 +43,8 @@ teardown(){
assert bash -c "docker context ls | grep -q default"
assert_output --partial 'local server successfully added'
server_dir_perms=$(stat -c "%a" "$ABRA_DIR/servers/$TEST_SERVER")
assert_equal $server_dir_perms "700"
server_dir_perms=$(stat -c "%a" "$ABRA_DIR/servers/default")
assert_equal "$server_dir_perms" "700"
}
@test "create local server fails when no docker swarm" {
+1 -1
View File
@@ -3,7 +3,7 @@ version: "3.8"
services:
app:
image: nginx:1.29.0
image: nginx:1.31.6
networks:
- proxy
deploy:
+3 -3
View File
@@ -3,16 +3,16 @@ kind: pipeline
name: coopcloud.tech/tagcmp
steps:
- name: gofmt
image: golang:1.21
image: golang:1.27
commands:
- test -z "$(gofmt -l .)"
- name: go build
image: golang:1.21
image: golang:1.27
commands:
- go build -v .
- name: go test
image: golang:1.21
image: golang:1.27
commands:
- go test . -cover
+6
View File
@@ -0,0 +1,6 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended"
]
}
+4
View File
@@ -5,6 +5,10 @@ Billy implements an interface based on the `os` standard library, allowing to de
Billy was born as part of [go-git/go-git](https://github.com/go-git/go-git) project.
## Version support
go-billy v5 is in maintenance mode. Users should upgrade to [go-billy v6](https://pkg.go.dev/github.com/go-git/go-billy/v6) where possible.
## Installation
```go
+198 -10
View File
@@ -3,19 +3,25 @@ package chroot
import (
"errors"
"os"
"path"
"path/filepath"
"strings"
"syscall"
"github.com/go-git/go-billy/v5"
"github.com/go-git/go-billy/v5/helper/polyfill"
)
// ChrootHelper is a helper to implement billy.Chroot.
// It is not a security boundary, callers that need containment should use a
// filesystem implementation that enforces paths at the OS boundary instead.
type ChrootHelper struct {
underlying billy.Filesystem
base string
}
const maxFollowedSymlinks = 8 // Aligns with POSIX_SYMLOOP_MAX
// New creates a new filesystem wrapping up the given 'fs'.
// The created filesystem has its base in the given ChrootHelperectory of the
// underlying filesystem.
@@ -34,15 +40,184 @@ func (fs *ChrootHelper) underlyingPath(filename string) (string, error) {
return fs.Join(fs.Root(), filename), nil
}
func isCrossBoundaries(path string) bool {
path = filepath.ToSlash(path)
path = filepath.Clean(path)
func (fs *ChrootHelper) followedPath(filename string, followFinal bool, op string) (string, error) {
fullpath, err := fs.underlyingPath(filename)
if err != nil {
return "", err
}
return strings.HasPrefix(path, ".."+string(filepath.Separator))
sl, ok := fs.underlying.(billy.Symlink)
if !ok {
return fullpath, nil
}
rel, err := fs.relativeToRoot(fullpath)
if err != nil {
return "", err
}
fullpath, err = fs.resolveFollowedPath(rel, followFinal, op, sl)
if errors.Is(err, billy.ErrNotSupported) {
return fs.underlyingPath(filename)
}
return fullpath, err
}
func (fs *ChrootHelper) resolveFollowedPath(rel string, followFinal bool, op string, sl billy.Symlink) (string, error) {
if rel == "" {
return fs.resolveFollowedRoot(followFinal, op, sl)
}
parts := splitRelativePath(rel)
resolved := ""
followed := 0
for len(parts) > 0 {
part := parts[0]
parts = parts[1:]
currentRel := joinRelativePath(resolved, part)
currentPath := fs.Join(fs.Root(), currentRel)
if len(parts) == 0 && !followFinal {
return currentPath, nil
}
fi, err := sl.Lstat(currentPath)
if err != nil {
if os.IsNotExist(err) {
return fs.Join(fs.Root(), joinRelativePath(append([]string{currentRel}, parts...)...)), nil
}
return "", err
}
if fi.Mode()&os.ModeSymlink == 0 {
resolved = currentRel
continue
}
followed++
if followed > maxFollowedSymlinks {
return "", symlinkLoopError(op, currentPath)
}
target, err := sl.Readlink(currentPath)
if err != nil {
return "", err
}
targetRel, err := fs.linkTargetRel(currentPath, target)
if err != nil {
return "", err
}
if targetRel == currentRel {
return "", symlinkLoopError(op, currentPath)
}
parts = append(splitRelativePath(targetRel), parts...)
resolved = ""
}
return fs.Join(fs.Root(), resolved), nil
}
func symlinkLoopError(op, path string) error {
return &os.PathError{Op: op, Path: path, Err: syscall.ELOOP}
}
func (fs *ChrootHelper) resolveFollowedRoot(followFinal bool, op string, sl billy.Symlink) (string, error) {
root := fs.Join(fs.Root(), "")
if !followFinal {
return root, nil
}
fi, err := sl.Lstat(root)
if err != nil {
if os.IsNotExist(err) {
return root, nil
}
return "", err
}
if fi.Mode()&os.ModeSymlink == 0 {
return root, nil
}
target, err := sl.Readlink(root)
if err != nil {
return "", err
}
targetRel, err := fs.linkTargetRel(root, target)
if err != nil {
return root, err
}
if targetRel == "" {
return "", symlinkLoopError(op, root)
}
return fs.resolveFollowedPath(targetRel, followFinal, op, sl)
}
func (fs *ChrootHelper) relativeToRoot(filename string) (string, error) {
rel, err := filepath.Rel(filepath.Clean(fs.Root()), filepath.Clean(filename))
if err != nil || isCrossBoundaries(rel) {
return "", billy.ErrCrossedBoundary
}
if rel == "." {
return "", nil
}
return rel, nil
}
func (fs *ChrootHelper) linkTargetRel(linkPath, target string) (string, error) {
target = filepath.FromSlash(target)
if filepath.IsAbs(target) || strings.HasPrefix(target, string(filepath.Separator)) {
return fs.relativeToRoot(target)
}
return fs.relativeToRoot(fs.Join(filepath.Dir(linkPath), target))
}
func splitRelativePath(filename string) []string {
filename = filepath.Clean(filename)
if filename == "" || filename == "." {
return nil
}
return strings.Split(filepath.ToSlash(filename), "/")
}
func joinRelativePath(elem ...string) string {
parts := make([]string, 0, len(elem))
for _, part := range elem {
if part == "" || part == "." {
continue
}
parts = append(parts, part)
}
if len(parts) == 0 {
return ""
}
return filepath.Join(parts...)
}
func isCreateExclusive(flag int) bool {
return flag&os.O_CREATE != 0 && flag&os.O_EXCL != 0
}
func isCrossBoundaries(name string) bool {
name = filepath.ToSlash(name)
name = strings.TrimLeft(name, "/")
name = path.Clean(name)
return name == ".." || strings.HasPrefix(name, "../")
}
func (fs *ChrootHelper) Create(filename string) (billy.File, error) {
fullpath, err := fs.underlyingPath(filename)
fullpath, err := fs.followedPath(filename, true, "create")
if err != nil {
return nil, err
}
@@ -56,7 +231,7 @@ func (fs *ChrootHelper) Create(filename string) (billy.File, error) {
}
func (fs *ChrootHelper) Open(filename string) (billy.File, error) {
fullpath, err := fs.underlyingPath(filename)
fullpath, err := fs.followedPath(filename, true, "open")
if err != nil {
return nil, err
}
@@ -70,7 +245,7 @@ func (fs *ChrootHelper) Open(filename string) (billy.File, error) {
}
func (fs *ChrootHelper) OpenFile(filename string, flag int, mode os.FileMode) (billy.File, error) {
fullpath, err := fs.underlyingPath(filename)
fullpath, err := fs.followedPath(filename, !isCreateExclusive(flag), "open")
if err != nil {
return nil, err
}
@@ -84,12 +259,16 @@ func (fs *ChrootHelper) OpenFile(filename string, flag int, mode os.FileMode) (b
}
func (fs *ChrootHelper) Stat(filename string) (os.FileInfo, error) {
fullpath, err := fs.underlyingPath(filename)
fullpath, err := fs.followedPath(filename, true, "stat")
if err != nil {
return nil, err
}
return fs.underlying.Stat(fullpath)
fi, err := fs.underlying.Stat(fullpath)
if err != nil {
return nil, err
}
return fileInfo{FileInfo: fi, name: filepath.Base(filename)}, nil
}
func (fs *ChrootHelper) Rename(from, to string) error {
@@ -135,7 +314,7 @@ func (fs *ChrootHelper) TempFile(dir, prefix string) (billy.File, error) {
}
func (fs *ChrootHelper) ReadDir(path string) ([]os.FileInfo, error) {
fullpath, err := fs.underlyingPath(path)
fullpath, err := fs.followedPath(path, true, "readdir")
if err != nil {
return nil, err
}
@@ -241,6 +420,11 @@ type file struct {
name string
}
type fileInfo struct {
os.FileInfo
name string
}
func newFile(fs billy.Filesystem, f billy.File, filename string) billy.File {
filename = fs.Join(fs.Root(), filename)
filename, _ = filepath.Rel(fs.Root(), filename)
@@ -254,3 +438,7 @@ func newFile(fs billy.Filesystem, f billy.File, filename string) billy.File {
func (f *file) Name() string {
return f.name
}
func (fi fileInfo) Name() string {
return fi.name
}
+5
View File
@@ -24,6 +24,9 @@ var Default = &ChrootOS{}
// New returns a new OS filesystem.
// By default paths are deduplicated, but still enforced
// under baseDir. For more info refer to WithDeduplicatePath.
//
// New returns ChrootOS by default for v5 compatibility. Users should prefer
// New with WithBoundOS.
func New(baseDir string, opts ...Option) billy.Filesystem {
o := &options{
deduplicatePath: true,
@@ -47,6 +50,8 @@ func WithBoundOS() Option {
}
// WithChrootOS returns the option of using a Chroot filesystem OS.
//
// Deprecated: use WithBoundOS instead.
func WithChrootOS() Option {
return func(o *options) {
o.Type = ChrootOSFS
+85 -15
View File
@@ -20,6 +20,7 @@
package osfs
import (
"errors"
"fmt"
"os"
"path/filepath"
@@ -29,6 +30,31 @@ import (
"github.com/go-git/go-billy/v5"
)
var (
// ErrBaseDirCannotBeRemoved is returned when removing the BoundOS base dir.
ErrBaseDirCannotBeRemoved = errors.New("base dir cannot be removed")
// ErrBaseDirCannotBeRenamed is returned when renaming the BoundOS base dir.
ErrBaseDirCannotBeRenamed = errors.New("base dir cannot be renamed")
dotPrefixes = dotPathPrefixes()
dotSeparators = dotPathSeparators()
)
func dotPathPrefixes() []string {
if filepath.Separator == '\\' {
return []string{"./", ".\\"}
}
return []string{"./"}
}
func dotPathSeparators() string {
if filepath.Separator == '\\' {
return `/\`
}
return `/`
}
// BoundOS is a fs implementation based on the OS filesystem which is bound to
// a base dir.
// Prefer this fs implementation over ChrootOS.
@@ -54,6 +80,7 @@ func (fs *BoundOS) Create(filename string) (billy.File, error) {
}
func (fs *BoundOS) OpenFile(filename string, flag int, perm os.FileMode) (billy.File, error) {
filename = fs.expandDot(filename)
fn, err := fs.abs(filename)
if err != nil {
return nil, err
@@ -62,6 +89,7 @@ func (fs *BoundOS) OpenFile(filename string, flag int, perm os.FileMode) (billy.
}
func (fs *BoundOS) ReadDir(path string) ([]os.FileInfo, error) {
path = fs.expandDot(path)
dir, err := fs.abs(path)
if err != nil {
return nil, err
@@ -71,6 +99,12 @@ func (fs *BoundOS) ReadDir(path string) ([]os.FileInfo, error) {
}
func (fs *BoundOS) Rename(from, to string) error {
if fs.isBaseDir(from) {
return ErrBaseDirCannotBeRenamed
}
from = fs.expandDot(from)
to = fs.expandDot(to)
f, err := fs.abs(from)
if err != nil {
return err
@@ -89,6 +123,7 @@ func (fs *BoundOS) Rename(from, to string) error {
}
func (fs *BoundOS) MkdirAll(path string, perm os.FileMode) error {
path = fs.expandDot(path)
dir, err := fs.abs(path)
if err != nil {
return err
@@ -101,6 +136,7 @@ func (fs *BoundOS) Open(filename string) (billy.File, error) {
}
func (fs *BoundOS) Stat(filename string) (os.FileInfo, error) {
filename = fs.expandDot(filename)
filename, err := fs.abs(filename)
if err != nil {
return nil, err
@@ -109,6 +145,11 @@ func (fs *BoundOS) Stat(filename string) (os.FileInfo, error) {
}
func (fs *BoundOS) Remove(filename string) error {
if fs.isBaseDir(filename) {
return ErrBaseDirCannotBeRemoved
}
filename = fs.expandDot(filename)
fn, err := fs.abs(filename)
if err != nil {
return err
@@ -122,6 +163,7 @@ func (fs *BoundOS) Remove(filename string) error {
func (fs *BoundOS) TempFile(dir, prefix string) (billy.File, error) {
if dir != "" {
var err error
dir = fs.expandDot(dir)
dir, err = fs.abs(dir)
if err != nil {
return nil, err
@@ -144,6 +186,11 @@ func (fs *BoundOS) Join(elem ...string) string {
}
func (fs *BoundOS) RemoveAll(path string) error {
if fs.isBaseDir(path) {
return ErrBaseDirCannotBeRemoved
}
path = fs.expandDot(path)
dir, err := fs.abs(path)
if err != nil {
return err
@@ -152,6 +199,7 @@ func (fs *BoundOS) RemoveAll(path string) error {
}
func (fs *BoundOS) Symlink(target, link string) error {
link = fs.expandDot(link)
ln, err := fs.abs(link)
if err != nil {
return err
@@ -164,6 +212,7 @@ func (fs *BoundOS) Symlink(target, link string) error {
}
func (fs *BoundOS) Lstat(filename string) (os.FileInfo, error) {
filename = fs.expandDot(filename)
filename = filepath.Clean(filename)
if !filepath.IsAbs(filename) {
filename = filepath.Join(fs.baseDir, filename)
@@ -175,6 +224,7 @@ func (fs *BoundOS) Lstat(filename string) (os.FileInfo, error) {
}
func (fs *BoundOS) Readlink(link string) (string, error) {
link = fs.expandDot(link)
if !filepath.IsAbs(link) {
link = filepath.Clean(filepath.Join(fs.baseDir, link))
}
@@ -185,6 +235,7 @@ func (fs *BoundOS) Readlink(link string) (string, error) {
}
func (fs *BoundOS) Chmod(path string, mode os.FileMode) error {
path = fs.expandDot(path)
abspath, err := fs.abs(path)
if err != nil {
return err
@@ -199,7 +250,7 @@ func (fs *BoundOS) Chroot(path string) (billy.Filesystem, error) {
if err != nil {
return nil, err
}
return New(joined), nil
return New(joined, WithBoundOS()), nil
}
// Root returns the current base dir of the billy.Filesystem.
@@ -220,6 +271,37 @@ func (fs *BoundOS) createDir(fullpath string) error {
return nil
}
func (fs *BoundOS) expandDot(path string) string {
if path == "." {
return fs.baseDir
}
for _, prefix := range dotPrefixes {
if strings.HasPrefix(path, prefix) {
path = strings.TrimLeft(strings.TrimPrefix(path, prefix), dotSeparators)
if path == "" {
return fs.baseDir
}
return path
}
}
return path
}
func (fs *BoundOS) isBaseDir(path string) bool {
if path == "" || filepath.Clean(path) == "." {
return true
}
path = fs.expandDot(path)
if filepath.Clean(path) == filepath.Clean(fs.baseDir) {
return true
}
abspath, err := fs.abs(path)
if err != nil {
return false
}
return filepath.Clean(abspath) == filepath.Clean(fs.baseDir)
}
// abs transforms filename to an absolute path, taking into account the base dir.
// Relative paths won't be allowed to ascend the base dir, so `../file` will become
// `/working-dir/file`.
@@ -233,7 +315,7 @@ func (fs *BoundOS) abs(filename string) (string, error) {
path, err := securejoin.SecureJoin(fs.baseDir, filename)
if err != nil {
return "", nil
return "", err
}
if fs.deduplicatePath {
@@ -246,24 +328,12 @@ func (fs *BoundOS) abs(filename string) (string, error) {
return path, nil
}
// insideBaseDir checks whether filename is located within
// the fs.baseDir.
func (fs *BoundOS) insideBaseDir(filename string) (bool, error) {
if filename == fs.baseDir {
return true, nil
}
if !strings.HasPrefix(filename, fs.baseDir+string(filepath.Separator)) {
return false, fmt.Errorf("path outside base dir")
}
return true, nil
}
// insideBaseDirEval checks whether filename is contained within
// a dir that is within the fs.baseDir, by first evaluating any symlinks
// that either filename or fs.baseDir may contain.
func (fs *BoundOS) insideBaseDirEval(filename string) (bool, error) {
// "/" contains all others.
if fs.baseDir == "/" {
if fs.baseDir == "/" || fs.baseDir == filename {
return true, nil
}
dir, err := filepath.EvalSymlinks(filepath.Dir(filename))
+10
View File
@@ -14,6 +14,8 @@ import (
// ChrootOS is a legacy filesystem based on a "soft chroot" of the os filesystem.
// Although this is still the default os filesystem, consider using BoundOS instead.
//
// Deprecated: use New with WithBoundOS instead.
//
// Behaviours of note:
// 1. A "soft chroot" translates the base dir to "/" for the purposes of the
// fs abstraction.
@@ -24,6 +26,14 @@ import (
type ChrootOS struct{}
func newChrootOS(baseDir string) billy.Filesystem {
if baseDir != "" {
resolved, err := filepath.EvalSymlinks(baseDir)
if err != nil {
return chroot.New(&ChrootOS{}, baseDir)
}
baseDir = resolved
}
return chroot.New(&ChrootOS{}, baseDir)
}
+19 -24
View File
@@ -16,8 +16,6 @@ import (
// can but returns the first error it encounters. If the path does not exist,
// RemoveAll returns nil (no error).
func RemoveAll(fs billy.Basic, path string) error {
fs, path = getUnderlyingAndPath(fs, path)
if r, ok := fs.(removerAll); ok {
return r.RemoveAll(path)
}
@@ -39,7 +37,7 @@ func removeAll(fs billy.Basic, path string) error {
}
// Otherwise, is this a directory we need to recurse into?
dir, serr := fs.Stat(path)
dir, serr := lstat(fs, path)
if serr != nil {
if errors.Is(serr, os.ErrNotExist) {
return nil
@@ -48,8 +46,8 @@ func removeAll(fs billy.Basic, path string) error {
return serr
}
if !dir.IsDir() {
// Not a directory; return the error from Remove.
if dir.Mode()&os.ModeSymlink != 0 || !dir.IsDir() {
// Not a directory we should recurse into; return the error from Remove.
return err
}
@@ -62,7 +60,7 @@ func removeAll(fs billy.Basic, path string) error {
fis, err := dirfs.ReadDir(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
// Race. It was deleted between the Lstat and Open.
// Race. It was deleted between the Lstat and ReadDir.
// Return nil per RemoveAll's docs.
return nil
}
@@ -91,7 +89,18 @@ func removeAll(fs billy.Basic, path string) error {
}
return err
}
func lstat(filesystem billy.Basic, path string) (os.FileInfo, error) {
if sl, ok := filesystem.(billy.Symlink); ok {
// Avoid following a symlink substituted after the initial Remove fails.
fi, err := sl.Lstat(path)
if err == nil || !errors.Is(err, billy.ErrNotSupported) {
return fi, err
}
}
return filesystem.Stat(path)
}
// WriteFile writes data to a file named by filename in the given filesystem.
@@ -123,8 +132,10 @@ func WriteFile(fs billy.Basic, filename string, data []byte, perm os.FileMode) (
// We generate random temporary file names so that there's a good
// chance the file doesn't exist yet - keeps the number of tries in
// TempFile to a minimum.
var rand uint32
var randmu sync.Mutex
var (
rand uint32
randmu sync.Mutex
)
func reseed() uint32 {
return uint32(time.Now().UnixNano() + int64(os.Getpid()))
@@ -220,22 +231,6 @@ func getTempDir(fs billy.Basic) string {
return ".tmp"
}
type underlying interface {
Underlying() billy.Basic
}
func getUnderlyingAndPath(fs billy.Basic, path string) (billy.Basic, string) {
u, ok := fs.(underlying)
if !ok {
return fs, path
}
if ch, ok := fs.(billy.Chroot); ok {
path = fs.Join(ch.Root(), path)
}
return u.Underlying(), path
}
// ReadFile reads the named file and returns the contents from the given filesystem.
// A successful call returns err == nil, not err == EOF.
// Because ReadFile reads the whole file, it does not treat an EOF from Read
+1
View File
@@ -5,3 +5,4 @@ profile.out
.tmp/
.git-dist/
.vscode
build/tools/
+34 -1
View File
@@ -61,6 +61,16 @@ type Config struct {
CommentChar string
// RepositoryFormatVersion identifies the repository format and layout version.
RepositoryFormatVersion format.RepositoryFormatVersion
// ProtectNTFS controls whether NTFS-specific path protections are
// applied (e.g. rejecting .git trailing spaces/periods, alternate
// data streams, 8.3 short names). When unset, defaults to true on
// Windows.
ProtectNTFS OptBool
// ProtectHFS controls whether HFS+-specific path protections are
// applied (e.g. rejecting .git with Unicode zero-width or
// directional characters that HFS+ would normalize away).
// When unset, defaults to true on macOS.
ProtectHFS OptBool
}
User struct {
@@ -266,6 +276,8 @@ const (
repositoryFormatVersionKey = "repositoryformatversion"
objectFormat = "objectformat"
mirrorKey = "mirror"
protectNTFSKey = "protectNTFS"
protectHFSKey = "protectHFS"
// DefaultPackWindow holds the number of previous objects used to
// generate deltas. The value 10 is the same used by git command.
@@ -309,6 +321,18 @@ func (c *Config) unmarshalCore() {
c.Core.Worktree = s.Options.Get(worktreeKey)
c.Core.CommentChar = s.Options.Get(commentCharKey)
if parsed := parseConfigBool(s.Options.Get(protectNTFSKey)); parsed.IsSet() {
c.Core.ProtectNTFS = parsed
}
if parsed := parseConfigBool(s.Options.Get(protectHFSKey)); parsed.IsSet() {
c.Core.ProtectHFS = parsed
}
if s.Options.Get(repositoryFormatVersionKey) == string(format.Version_1) {
c.Core.RepositoryFormatVersion = format.Version_1
}
}
func (c *Config) unmarshalUser() {
@@ -379,7 +403,8 @@ func unmarshalSubmodules(fc *format.Config, submodules map[string]*Submodule) {
m := &Submodule{}
m.unmarshal(sub)
if m.Validate() == ErrModuleBadPath {
if err := m.Validate(); errors.Is(err, ErrModuleBadPath) ||
errors.Is(err, ErrModuleBadName) {
continue
}
@@ -436,6 +461,14 @@ func (c *Config) marshalCore() {
if c.Core.Worktree != "" {
s.SetOption(worktreeKey, c.Core.Worktree)
}
if c.Core.ProtectNTFS.IsSet() {
s.SetOption(protectNTFSKey, c.Core.ProtectNTFS.FormatBool())
}
if c.Core.ProtectHFS.IsSet() {
s.SetOption(protectHFSKey, c.Core.ProtectHFS.FormatBool())
}
}
func (c *Config) marshalExtensions() {
+52
View File
@@ -3,8 +3,11 @@ package config
import (
"bytes"
"errors"
"fmt"
"regexp"
"strings"
"github.com/go-git/go-git/v5/internal/pathutil"
format "github.com/go-git/go-git/v5/plumbing/format/config"
)
@@ -12,6 +15,7 @@ var (
ErrModuleEmptyURL = errors.New("module config: empty URL")
ErrModuleEmptyPath = errors.New("module config: empty path")
ErrModuleBadPath = errors.New("submodule has an invalid path")
ErrModuleBadName = errors.New("ignoring suspicious submodule name")
)
var (
@@ -94,6 +98,10 @@ type Submodule struct {
// Validate validates the fields and sets the default values.
func (m *Submodule) Validate() error {
if err := validSubmoduleName(m.Name); err != nil {
return fmt.Errorf("%w: %q", ErrModuleBadName, m.Name)
}
if m.Path == "" {
return ErrModuleEmptyPath
}
@@ -109,6 +117,50 @@ func (m *Submodule) Validate() error {
return nil
}
// validSubmoduleName mirrors canonical Git's check_submodule_name in
// submodule-config.c [1]: reject empty names and any name with a ".."
// path component, using both '/' and '\\' as separators so the rule
// is consistent across platforms. The component check is delegated to
// `pathutil.IsHFSDot` and `pathutil.IsNTFSDot` with `.` as the needle,
// which both cover the bare ".." case and reject components that
// resolve to ".." after HFS+ Unicode normalisation (ignored code
// points, e.g. `.<U+200C>.`) or NTFS trailing-space/dot/ADS
// canonicalisation (e.g. `.. `, `..::$INDEX_ALLOCATION`).
// `.gitmodules` is attacker-controlled by definition, so both checks
// run unconditionally regardless of host OS.
//
// The additional checks (bare ".", NUL byte, leading or trailing
// separator, drive-letter prefix) close go-git-specific edge cases
// the canonical loop does not exercise: canonical Git treats names
// as opaque C strings, while Go strings carry NULs through and the
// billy filesystem layer is path-aware in ways Git's working storage
// is not.
//
// [1]: https://github.com/git/git/blob/v2.54.0/submodule-config.c#L214-L237
func validSubmoduleName(name string) error {
if name == "" || name == "." {
return ErrModuleBadName
}
for _, seg := range strings.FieldsFunc(name, isPathSep) {
if pathutil.IsHFSDot(seg, ".") || pathutil.IsNTFSDot(seg, ".", "") {
return ErrModuleBadName
}
}
// go-git-specific defensive checks beyond canonical Git.
if strings.ContainsRune(name, 0) {
return ErrModuleBadName
}
if isPathSep(rune(name[0])) || isPathSep(rune(name[len(name)-1])) {
return ErrModuleBadName
}
if len(name) >= 2 && name[1] == ':' {
return ErrModuleBadName
}
return nil
}
func isPathSep(r rune) bool { return r == '/' || r == '\\' }
func (m *Submodule) unmarshal(s *format.Subsection) {
m.raw = s
+82
View File
@@ -0,0 +1,82 @@
package config
import (
"strconv"
"strings"
)
// OptBool is a tri-state boolean: unset, explicitly false, or explicitly true.
// Its zero value (OptBoolUnset) means the setting was not specified, which
// allows merge logic based on reflect.Value.IsZero to skip unset fields while
// still letting an explicit "false" override a previously set "true".
type OptBool byte
const (
// OptBoolUnset indicates the setting was not specified.
OptBoolUnset OptBool = iota
// OptBoolFalse indicates the setting was explicitly set to false.
OptBoolFalse
// OptBoolTrue indicates the setting was explicitly set to true.
OptBoolTrue
)
// NewOptBool converts a plain bool into an OptBool.
func NewOptBool(v bool) OptBool {
if v {
return OptBoolTrue
}
return OptBoolFalse
}
// IsTrue returns whether the value is explicitly true.
func (o OptBool) IsTrue() bool { return o == OptBoolTrue }
// IsSet returns whether the value was explicitly specified (true or false).
func (o OptBool) IsSet() bool { return o != OptBoolUnset }
func (o OptBool) String() string {
switch o {
case OptBoolTrue:
return "true"
case OptBoolFalse:
return "false"
default:
return "unset"
}
}
// FormatBool returns the strconv-formatted value. Only meaningful when IsSet.
func (o OptBool) FormatBool() string {
return strconv.FormatBool(o.IsTrue())
}
// parseConfigBool mirrors upstream Git's git_parse_maybe_bool: it
// accepts true/yes/on (→ OptBoolTrue) and false/no/off (→
// OptBoolFalse) case-insensitively, plus any decimal integer (zero
// → OptBoolFalse, non-zero → OptBoolTrue). Empty or otherwise
// unrecognised values return OptBoolUnset, leaving the caller's
// platform default in place. The empty-string handling is the only
// intentional divergence from upstream, which returns false for
// empty: in our unmarshalCore caller, an empty value means the key
// is unset and the platform default should apply.
//
// Reference: upstream Git git_parse_maybe_bool_text at parse.c
// L157-L173 and git_parse_maybe_bool at parse.c L174-L182 in tag
// v2.54.0[1].
//
// [1]: https://github.com/git/git/blob/v2.54.0/parse.c#L157-L182
func parseConfigBool(v string) OptBool {
switch strings.ToLower(v) {
case "true", "yes", "on":
return OptBoolTrue
case "false", "no", "off":
return OptBoolFalse
}
if i, err := strconv.Atoi(v); err == nil {
if i != 0 {
return OptBoolTrue
}
return OptBoolFalse
}
return OptBoolUnset
}
+21
View File
@@ -0,0 +1,21 @@
package pathutil
import "strings"
// IsDotGitName reports whether name is `.git` or its 8.3 NTFS short
// alias `git~1`, case-insensitively. Both are forbidden as path
// components (and as submodule names) because they refer to the
// repository's own metadata directory.
//
// File names that do not conform to the 8.3 format (up to eight
// characters for the basename, three for the file extension) are
// associated with a so-called "short name" on NTFS — at least on
// the `C:` drive by default — which means that `git~1/` is a valid
// way to refer to `.git/`.
func IsDotGitName(name string) bool {
switch strings.ToLower(name) {
case ".git", "git~1":
return true
}
return false
}
+99
View File
@@ -0,0 +1,99 @@
package pathutil
import "unicode"
// hfsIgnoredCodepoints contains Unicode code points that HFS+ ignores
// during path normalization. A path component containing these
// characters between the bytes of ".git" (or ".gitmodules", etc.)
// will be treated as that name by HFS+, so they have to be filtered
// out before comparison.
//
// See upstream Git utf8.c next_hfs_char in tag v2.54.0[1].
//
// [1]: https://github.com/git/git/blob/v2.54.0/utf8.c#L703-L740
var hfsIgnoredCodepoints = map[rune]struct{}{
0x200c: {}, // ZERO WIDTH NON-JOINER
0x200d: {}, // ZERO WIDTH JOINER
0x200e: {}, // LEFT-TO-RIGHT MARK
0x200f: {}, // RIGHT-TO-LEFT MARK
0x202a: {}, // LEFT-TO-RIGHT EMBEDDING
0x202b: {}, // RIGHT-TO-LEFT EMBEDDING
0x202c: {}, // POP DIRECTIONAL FORMATTING
0x202d: {}, // LEFT-TO-RIGHT OVERRIDE
0x202e: {}, // RIGHT-TO-LEFT OVERRIDE
0x206a: {}, // INHIBIT SYMMETRIC SWAPPING
0x206b: {}, // ACTIVATE SYMMETRIC SWAPPING
0x206c: {}, // INHIBIT ARABIC FORM SHAPING
0x206d: {}, // ACTIVATE ARABIC FORM SHAPING
0x206e: {}, // NATIONAL DIGIT SHAPES
0x206f: {}, // NOMINAL DIGIT SHAPES
0xfeff: {}, // ZERO WIDTH NO-BREAK SPACE
}
// IsHFSDot reports whether part would be treated as ".<needle>" on an
// HFS+ filesystem after stripping ignored Unicode code points and
// folding ASCII to lower case. The needle is the lowercase ASCII
// suffix without the leading dot (e.g. "git", "gitmodules"). It
// mirrors upstream Git's is_hfs_dot_generic and is the building
// block of IsHFSDotGit / IsHFSDotGitmodules.
//
// Reference: upstream Git utf8.c is_hfs_dot_generic at L741-L774 and
// the dotgit family at L784-L809 in tag v2.54.0[1].
//
// [1]: https://github.com/git/git/blob/v2.54.0/utf8.c#L741-L809
func IsHFSDot(part, needle string) bool {
runes := []rune(part)
i := 0
// skip ignored code points, then expect '.'
for i < len(runes) {
if _, ok := hfsIgnoredCodepoints[runes[i]]; !ok {
break
}
i++
}
if i >= len(runes) || runes[i] != '.' {
return false
}
i++
// match needle case-insensitively, skipping ignored code points
for _, expected := range needle {
for i < len(runes) {
if _, ok := hfsIgnoredCodepoints[runes[i]]; !ok {
break
}
i++
}
if i >= len(runes) {
return false
}
r := runes[i]
if r > 127 {
return false
}
if unicode.ToLower(r) != expected {
return false
}
i++
}
// skip trailing ignored code points
for i < len(runes) {
if _, ok := hfsIgnoredCodepoints[runes[i]]; !ok {
break
}
i++
}
// must be at end of component
return i == len(runes)
}
// IsHFSDotGit reports whether part is an HFS+ equivalent of ".git".
func IsHFSDotGit(part string) bool { return IsHFSDot(part, "git") }
// IsHFSDotGitmodules reports whether part is an HFS+ equivalent of
// ".gitmodules", catching attempts to plant the file via Unicode
// code points that HFS+ would strip during normalisation.
func IsHFSDotGitmodules(part string) bool { return IsHFSDot(part, "gitmodules") }
+187
View File
@@ -0,0 +1,187 @@
package pathutil
import "strings"
// IsNTFSDotGit ports upstream Git's is_ntfs_dotgit. It detects path
// components that NTFS would resolve to ".git": the canonical name
// itself and its 8.3 short-name alias "git~1", each followed by any
// number of trailing spaces or periods (which NTFS silently trims)
// and an optional Alternate Data Stream suffix (":<stream>"). The
// bare strings ".git" and "git~1" also match, mirroring upstream.
//
// Reference: upstream Git path.c is_ntfs_dotgit at L1415-L1449
// in tag v2.54.0[1].
//
// [1]: https://github.com/git/git/blob/v2.54.0/path.c#L1415-L1449
func IsNTFSDotGit(part string) bool {
var i int
switch {
case len(part) >= 4 && part[0] == '.' &&
asciiToLower(part[1]) == 'g' &&
asciiToLower(part[2]) == 'i' &&
asciiToLower(part[3]) == 't':
i = 4
case len(part) >= 5 &&
asciiToLower(part[0]) == 'g' &&
asciiToLower(part[1]) == 'i' &&
asciiToLower(part[2]) == 't' &&
part[3] == '~' && part[4] == '1':
i = 5
default:
return false
}
for ; i < len(part); i++ {
c := part[i]
if c == ':' {
return true
}
if c != '.' && c != ' ' {
return false
}
}
return true
}
// WindowsValidPath reports whether part is a valid Windows / NTFS
// path component for the worktree filesystem abstraction. It rejects
// NTFS-disguised variants of `.git` and `git~1` (trailing spaces,
// periods, Alternate Data Streams) and Windows reserved device
// names. Bare `.git` and `git~1` are allowed at this layer; the
// caller decides whether they are permissible at the current path
// position.
func WindowsValidPath(part string) bool {
if IsNTFSDotGit(part) && !IsDotGitName(part) {
return false
}
return !isWindowsReservedName(part)
}
// windowsReservedNames lists the Windows reserved device names.
// A path component is reserved if its base name (ignoring trailing
// spaces, extensions, and NTFS Alternate Data Streams) matches one of
// these case-insensitively.
//
// See upstream Git compat/mingw.c is_valid_win32_path().
var windowsReservedNames = []string{
"CON", "PRN", "AUX", "NUL",
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
"CONIN$", "CONOUT$",
}
func isWindowsReservedName(part string) bool {
for _, name := range windowsReservedNames {
if len(part) < len(name) {
continue
}
if !strings.EqualFold(part[:len(name)], name) {
continue
}
// Exact match or followed by space, dot, colon (ADS), or separator.
if len(part) == len(name) {
return true
}
switch part[len(name)] {
case ' ', '.', ':':
return true
}
}
return false
}
// IsNTFSDot ports upstream Git's is_ntfs_dot_generic. It detects NTFS
// path-component variants of a dotfile name that attackers can use to
// bypass case-insensitive comparisons against the canonical name on
// Windows. The dotgit parameter is the lowercase name without the
// leading dot (e.g. "gitmodules"); shortnamePrefix is the canonical
// 6-character NTFS short-name prefix used as a fall-back match
// (e.g. "gi7eba" for ".gitmodules").
//
// Reference: upstream Git path.c is_ntfs_dot_generic at L1451-L1507
// in tag v2.54.0[1].
//
// [1]: https://github.com/git/git/blob/v2.54.0/path.c#L1451-L1507
func IsNTFSDot(name, dotgit, shortnamePrefix string) bool {
// onlySpacesAndPeriods returns true when the suffix from start
// onwards consists only of trailing spaces and periods, possibly
// terminated by a NTFS Alternate Data Stream colon. Mirrors the
// only_spaces_and_periods label in upstream's is_ntfs_dot_generic.
onlySpacesAndPeriods := func(start int) bool {
for i := start; i < len(name); i++ {
c := name[i]
if c == ':' {
return true
}
if c != ' ' && c != '.' {
return false
}
}
return true
}
// Pattern 1: ".<dotgit>" prefix + trailing spaces / periods / ADS.
if len(name) >= len(dotgit)+1 && name[0] == '.' &&
strings.EqualFold(name[1:1+len(dotgit)], dotgit) {
if onlySpacesAndPeriods(len(dotgit) + 1) {
return true
}
}
// Pattern 2: standard NTFS short name <dotgit[:6]>~[1-4].
if len(dotgit) >= 6 && len(name) >= 8 &&
strings.EqualFold(name[:6], dotgit[:6]) &&
name[6] == '~' && name[7] >= '1' && name[7] <= '4' {
if onlySpacesAndPeriods(8) {
return true
}
}
// Pattern 3: fall-back NTFS short name keyed by shortnamePrefix.
if len(shortnamePrefix) < 6 || len(name) < 8 {
return false
}
sawTilde := false
i := 0
for i < 8 {
c := name[i]
switch {
case sawTilde:
if c < '0' || c > '9' {
return false
}
case c == '~':
i++
if i >= len(name) || name[i] < '1' || name[i] > '9' {
return false
}
sawTilde = true
case i >= 6:
return false
case c&0x80 != 0:
return false
default:
if asciiToLower(c) != shortnamePrefix[i] {
return false
}
}
i++
}
return onlySpacesAndPeriods(8)
}
// IsNTFSDotGitmodules reports whether part is an NTFS-equivalent of
// ".gitmodules" — the file name (or any of its variants that NTFS
// would resolve to it) that attackers can use to plant submodule
// configuration disguised as a symlink. The 6-character canonical
// short-name prefix "gi7eba" mirrors upstream Git's is_ntfs_dotgitmodules.
func IsNTFSDotGitmodules(part string) bool {
return IsNTFSDot(part, "gitmodules", "gi7eba")
}
func asciiToLower(c byte) byte {
if c >= 'A' && c <= 'Z' {
return c + ('a' - 'A')
}
return c
}
+66
View File
@@ -0,0 +1,66 @@
package pathutil
import (
"fmt"
"path/filepath"
"strings"
)
// ErrInvalidPath is returned by ValidTreePath when its argument is
// not a safe path to materialise into the worktree.
var ErrInvalidPath = fmt.Errorf("invalid path")
// ValidTreePath rejects path strings that, if materialised into a
// worktree, would let an attacker-controlled tree entry escape the
// worktree or rewrite repository metadata. It rejects:
//
// - control characters (< 0x20, 0x7f);
// - empty paths and "." / ".." components;
// - Windows volume name prefixes (e.g. C:);
// - .git, its 8.3 NTFS short-name git~1, plus their HFS+ and NTFS
// variants — at every position, not just the root.
//
// HFS+/NTFS variants of `.git` are always rejected at this layer
// regardless of runtime config: tree paths are canonical UTF-8 with
// no zero-width characters or NTFS short-name forms, so an entry
// that looks like a disguised `.git` is suspicious anywhere. Windows
// reserved device names (CON, NUL, etc.) are not policed here — they
// are legitimate filenames on non-Windows filesystems and upstream
// Git accepts them. The wrapper layer (validPath in package git)
// rejects them at materialisation time when core.protectNTFS is on.
//
// Mirrors upstream Git's verify_path_internal at read-cache.c#L987
// in tag v2.54.0[1] with protect_hfs / protect_ntfs treated as
// always-on for `.git`-disguise detection (tree paths are not
// application-supplied) and is_valid_win32_path left to the wrapper.
//
// [1]: https://github.com/git/git/blob/v2.54.0/read-cache.c#L987
func ValidTreePath(p string) error {
for i := 0; i < len(p); i++ {
if p[i] < 0x20 || p[i] == 0x7f {
return fmt.Errorf("%w %q: contains control character", ErrInvalidPath, p)
}
}
parts := strings.FieldsFunc(p, func(r rune) bool { return r == '\\' || r == '/' })
if len(parts) == 0 {
return fmt.Errorf("%w: %q", ErrInvalidPath, p)
}
// Volume names are not supported, in both formats: \\ and <DRIVE_LETTER>:.
if vol := filepath.VolumeName(p); vol != "" {
return fmt.Errorf("%w: %q", ErrInvalidPath, p)
}
for _, part := range parts {
if part == "." || part == ".." {
return fmt.Errorf("%w %q: cannot use %q", ErrInvalidPath, p, part)
}
if IsDotGitName(part) || IsHFSDotGit(part) || IsNTFSDotGit(part) {
return fmt.Errorf("%w component: %q", ErrInvalidPath, p)
}
}
return nil
}
+35 -2
View File
@@ -2,12 +2,14 @@ package url
import (
"regexp"
"runtime"
"strings"
)
var (
isSchemeRegExp = regexp.MustCompile(`^[^:]+://`)
// Ref: https://github.com/git/git/blob/master/Documentation/urls.txt#L37
// Ref: https://github.com/git/git/blob/v2.54.0/Documentation/urls.adoc#L41-L48
scpLikeUrlRegExp = regexp.MustCompile(`^(?:(?P<user>[^@]+)@)?(?P<host>[^:\s]+):(?:(?P<port>[0-9]{1,5}):)?(?P<path>[^\\].*)$`)
)
@@ -20,7 +22,38 @@ func MatchesScheme(url string) bool {
// MatchesScpLike returns true if the given string matches an SCP-like
// format scheme.
func MatchesScpLike(url string) bool {
return scpLikeUrlRegExp.MatchString(url)
if !scpLikeUrlRegExp.MatchString(url) {
return false
}
// Mirror canonical Git's url_is_local_not_ssh in connect.c[1] for
// the cases the regex above cannot disambiguate by itself: a URL
// is treated as a local path (not SCP-style SSH) when a `/`
// precedes the first `:` (e.g. `./relative:path`,
// `/abs/with:colon/file`), or — on Windows only — when it has a
// DOS drive prefix like `C:foo` where the host is a single
// ASCII letter.
//
// [1]: https://github.com/git/git/blob/v2.54.0/connect.c#L710-L716
if before, _, _ := strings.Cut(url, ":"); strings.Contains(before, "/") {
return false
}
if runtime.GOOS == "windows" && hasDosDrivePrefix(url) {
return false
}
return true
}
// hasDosDrivePrefix reports whether s begins with `<letter>:` (a
// Windows drive prefix such as `C:` or `c:`). Mirrors canonical Git's
// win32_has_dos_drive_prefix[1].
//
// [1]: https://github.com/git/git/blob/v2.54.0/compat/win32/path-utils.c#L20-L29
func hasDosDrivePrefix(s string) bool {
if len(s) < 2 || s[1] != ':' {
return false
}
c := s[0]
return ('A' <= c && c <= 'Z') || ('a' <= c && c <= 'z')
}
// FindScpLikeComponents returns the user, host, port and path of the
+159 -5
View File
@@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"io"
"io/fs"
"github.com/go-git/go-git/v5/plumbing/hash"
"github.com/go-git/go-git/v5/utils/binary"
@@ -25,35 +26,88 @@ const (
objectIDLength = hash.Size
)
// Byte sizes of the idx v2 layout elements, used by the size formula
// in [validateIdxV2Size]. See [gitformat-pack] for the canonical
// layout.
//
// [gitformat-pack]: https://git-scm.com/docs/gitformat-pack
const (
headerLen = 8 // magic + version
fanoutLen = fanout * 4 // uint32 per bucket
crc32Len = 4 // CRC32 per object
offset32Len = 4 // 32-bit offset per object
offset64Len = 8 // 64-bit overflow offset
trailerHashes = 2 // pack checksum + idx checksum, each hashsz
)
// statInput is the optional shape the [Decoder] probes for at the
// start of [Decoder.Decode] to learn the on-disk length of the idx
// blob, which it uses to validate the canonical-Git size formula
// before any allocations driven by the fanout table. Callers that
// pass an [*os.File] or a `billy.File` backed by an `*os.File`
// (the production call sites in `storage/filesystem`) satisfy it
// directly; arbitrary [io.Reader]s do not, and decode for them
// retains the pre-existing behaviour of erroring out at the
// truncated-payload boundary instead.
//
// The interface is intentionally unexported so the public
// [NewDecoder] signature stays compatible with v5.
type statInput interface {
Stat() (fs.FileInfo, error)
}
// Decoder reads and decodes idx files from an input stream.
type Decoder struct {
io.Reader
h hash.Hash
src io.Reader
h hash.Hash
}
// NewDecoder builds a new idx stream decoder, that reads from r.
func NewDecoder(r io.Reader) *Decoder {
h := hash.New(crypto.SHA1)
tr := io.TeeReader(r, h)
return &Decoder{tr, h}
return &Decoder{tr, r, h}
}
// Decode reads from the stream and decode the content into the MemoryIndex struct.
func (d *Decoder) Decode(idx *MemoryIndex) error {
idxSize := int64(-1)
if in, ok := d.src.(statInput); ok {
fi, err := in.Stat()
if err != nil {
return fmt.Errorf("%w: stat input: %w", ErrMalformedIdxFile, err)
}
idxSize = fi.Size()
}
if err := validateHeader(d); err != nil {
return err
}
flow := []func(*MemoryIndex, io.Reader) error{
headerFlow := []func(*MemoryIndex, io.Reader) error{
readVersion,
readFanout,
}
for _, f := range headerFlow {
if err := f(idx, d); err != nil {
return err
}
}
if idxSize >= 0 {
if err := validateIdxV2Size(idx, idxSize); err != nil {
return err
}
}
bodyFlow := []func(*MemoryIndex, io.Reader) error{
readObjectNames,
readCRC32,
readOffsets,
readPackChecksum,
}
for _, f := range flow {
for _, f := range bodyFlow {
if err := f(idx, d); err != nil {
return err
}
@@ -199,3 +253,103 @@ func readIdxChecksum(idx *MemoryIndex, r io.Reader) error {
return nil
}
// validateIdxV2Size enforces the size formula used by canonical Git
// load_idx for idx v2 files: the on-disk length must lie within
// [minSize, maxSize] where
//
// perObject = hashsz + crc32Len + offset32Len
// minSize = headerLen + fanoutLen + trailerHashes*hashsz + nr*perObject
// maxSize = minSize + (nr-1)*offset64Len when nr > 0
//
// with nr taken from the last fanout entry and hashsz fixed at
// [objectIDLength] (SHA-1 in v5). Multiplications use a self-checking
// overflow guard so inputs whose claimed object count overflows the
// formula are rejected rather than wrapping into a smaller value.
func validateIdxV2Size(idx *MemoryIndex, idxSize int64) error {
nr := int64(idx.Fanout[fanout-1])
hashsz := int64(objectIDLength)
minSize := minIdxV2Size(nr, hashsz)
maxSize := maxIdxV2Size(nr, hashsz)
if minSize < 0 || maxSize < 0 {
return fmt.Errorf("%w: object count %d is inconsistent with file size", ErrMalformedIdxFile, nr)
}
if idxSize < minSize || idxSize > maxSize {
return fmt.Errorf("%w: file size %d is inconsistent with object count %d", ErrMalformedIdxFile, idxSize, nr)
}
return nil
}
// minIdxV2Size returns the minimum on-disk size of an idx v2 file
// holding nr objects with the given hash size, mirroring the
// computation in canonical Git load_idx. Returns -1 when any
// intermediate multiplication or addition would overflow int64.
func minIdxV2Size(nr, hashsz int64) int64 {
perObject := hashsz + crc32Len + offset32Len
fixed := int64(headerLen+fanoutLen) + trailerHashes*hashsz
objects, ok := mulInt64(nr, perObject)
if !ok {
return -1
}
sum, ok := addInt64(fixed, objects)
if !ok {
return -1
}
return sum
}
// maxIdxV2Size returns the maximum on-disk size of an idx v2 file
// holding nr objects with the given hash size, mirroring the
// computation in canonical Git load_idx. Returns -1 on overflow.
func maxIdxV2Size(nr, hashsz int64) int64 {
minSize := minIdxV2Size(nr, hashsz)
if minSize < 0 {
return -1
}
if nr == 0 {
return minSize
}
overflow, ok := mulInt64(nr-1, offset64Len)
if !ok {
return -1
}
sum, ok := addInt64(minSize, overflow)
if !ok {
return -1
}
return sum
}
// mulInt64 returns a*b and whether the result fits in an int64 without
// overflow. Negative operands or overflow yield ok=false. The overflow
// check uses the standard self-inverse identity: a*b/b == a only when
// the multiplication did not wrap.
func mulInt64(a, b int64) (int64, bool) {
if a < 0 || b < 0 {
return 0, false
}
if a == 0 || b == 0 {
return 0, true
}
c := a * b
if c/b != a {
return 0, false
}
return c, true
}
// addInt64 returns a+b and whether the result fits in an int64 without
// overflow. Negative operands or overflow yield ok=false.
func addInt64(a, b int64) (int64, bool) {
if a < 0 || b < 0 {
return 0, false
}
c := a + b
if c < a {
return 0, false
}
return c, true
}
+21 -8
View File
@@ -2,6 +2,7 @@ package idxfile
import (
"bytes"
"fmt"
"io"
"sort"
"sync"
@@ -126,7 +127,10 @@ func (idx *MemoryIndex) FindOffset(h plumbing.Hash) (int64, error) {
return 0, plumbing.ErrObjectNotFound
}
offset := idx.getOffset(k, i)
offset, err := idx.getOffset(k, i)
if err != nil {
return 0, err
}
// Save the offset for reverse lookup
idx.mu.Lock()
@@ -141,17 +145,19 @@ func (idx *MemoryIndex) FindOffset(h plumbing.Hash) (int64, error) {
const isO64Mask = uint64(1) << 31
func (idx *MemoryIndex) getOffset(firstLevel, secondLevel int) uint64 {
func (idx *MemoryIndex) getOffset(firstLevel, secondLevel int) (uint64, error) {
offset := secondLevel << 2
ofs := encbin.BigEndian.Uint32(idx.Offset32[firstLevel][offset : offset+4])
if (uint64(ofs) & isO64Mask) != 0 {
offset := 8 * (uint64(ofs) & ^isO64Mask)
n := encbin.BigEndian.Uint64(idx.Offset64[offset : offset+8])
return n
if l := uint64(len(idx.Offset64)); l < 8 || offset > l-8 {
return 0, fmt.Errorf("%w: offset64 index out of range", ErrMalformedIdxFile)
}
return encbin.BigEndian.Uint64(idx.Offset64[offset : offset+8]), nil
}
return uint64(ofs)
return uint64(ofs), nil
}
// FindCRC32 implements the Index interface.
@@ -209,8 +215,11 @@ func (idx *MemoryIndex) genOffsetHash() error {
mappedFirstLevel := idx.FanoutMapping[firstLevel]
for secondLevel := uint32(0); i < fanoutValue; i++ {
copy(hash[:], idx.Names[mappedFirstLevel][secondLevel*objectIDLength:])
offset := int64(idx.getOffset(mappedFirstLevel, int(secondLevel)))
offsetHash[offset] = hash
off, err := idx.getOffset(mappedFirstLevel, int(secondLevel))
if err != nil {
return err
}
offsetHash[int64(off)] = hash
secondLevel++
}
}
@@ -291,7 +300,11 @@ func (i *idxfileEntryIter) Next() (*Entry, error) {
mappedFirstLevel := i.idx.FanoutMapping[i.firstLevel]
entry := new(Entry)
copy(entry.Hash[:], i.idx.Names[mappedFirstLevel][i.secondLevel*objectIDLength:])
entry.Offset = i.idx.getOffset(mappedFirstLevel, i.secondLevel)
var err error
entry.Offset, err = i.idx.getOffset(mappedFirstLevel, i.secondLevel)
if err != nil {
return nil, err
}
entry.CRC32 = i.idx.getCRC32(mappedFirstLevel, i.secondLevel)
i.secondLevel++
+15 -3
View File
@@ -11,9 +11,10 @@ import (
)
var (
ErrClosed = errors.New("objfile: already closed")
ErrHeader = errors.New("objfile: invalid header")
ErrNegativeSize = errors.New("objfile: negative object size")
ErrClosed = errors.New("objfile: already closed")
ErrHeader = errors.New("objfile: invalid header")
ErrHeaderNotRead = errors.New("objfile: Header must be called before Read")
ErrNegativeSize = errors.New("objfile: negative object size")
)
// Reader reads and decodes compressed objfile data from a provided io.Reader.
@@ -100,12 +101,23 @@ func (r *Reader) prepareForRead(t plumbing.ObjectType, size int64) {
//
// If Read encounters the end of the data stream it will return err == io.EOF,
// either in the current call if n > 0 or in a subsequent call.
//
// Read returns ErrHeaderNotRead if Header has not been called successfully.
func (r *Reader) Read(p []byte) (n int, err error) {
if r.multi == nil {
return 0, ErrHeaderNotRead
}
return r.multi.Read(p)
}
// Hash returns the hash of the object data stream that has been read so far.
// It returns the zero plumbing.Hash if Header has not been called
// successfully — guarding against the nil hasher that prepareForRead has
// not yet allocated.
func (r *Reader) Hash() plumbing.Hash {
if r.multi == nil {
return plumbing.ZeroHash
}
return r.hasher.Sum()
}
@@ -139,8 +139,12 @@ func (dw *deltaSelector) fixAndBreakChains(objectsToPack []*ObjectToPack) error
m[otp.Hash()] = otp
}
// visiting holds the objects on the current resolution path, so that a
// delta chain looping back on itself can be detected and broken.
visiting := make(map[plumbing.Hash]bool)
for _, otp := range objectsToPack {
if err := dw.fixAndBreakChainsOne(m, otp); err != nil {
if err := dw.fixAndBreakChainsOne(m, otp, visiting); err != nil {
return err
}
}
@@ -148,7 +152,11 @@ func (dw *deltaSelector) fixAndBreakChains(objectsToPack []*ObjectToPack) error
return nil
}
func (dw *deltaSelector) fixAndBreakChainsOne(objectsToPack map[plumbing.Hash]*ObjectToPack, otp *ObjectToPack) error {
func (dw *deltaSelector) fixAndBreakChainsOne(
objectsToPack map[plumbing.Hash]*ObjectToPack,
otp *ObjectToPack,
visiting map[plumbing.Hash]bool,
) error {
if !otp.Object.Type().IsDelta() {
return nil
}
@@ -174,7 +182,21 @@ func (dw *deltaSelector) fixAndBreakChainsOne(objectsToPack map[plumbing.Hash]*O
return dw.undeltify(otp)
}
if err := dw.fixAndBreakChainsOne(objectsToPack, base); err != nil {
// Mark this object as being resolved before looking at its base, so that
// a delta based on itself is caught by the check below.
h := otp.Hash()
visiting[h] = true
defer delete(visiting, h)
// A delta chain that loops back onto an object we are already resolving
// cannot be written: every delta needs its base written first. Break the
// chain here instead of following the cycle, which would recurse until
// the goroutine stack is exhausted.
if visiting[do.BaseHash()] {
return dw.undeltify(otp)
}
if err := dw.fixAndBreakChainsOne(objectsToPack, base, visiting); err != nil {
return err
}
@@ -19,9 +19,6 @@ const (
// https://github.com/git/git/blob/f7466e94375b3be27f229c78873f0acf8301c0a5/diff-delta.c#L428
// Max size of a copy operation (64KB).
maxCopySize = 64 * 1024
// Min size of a copy operation.
minCopySize = 4
)
// GetDelta returns an EncodedObject of type OFSDeltaObject. Base and Target object,
+7 -1
View File
@@ -78,7 +78,13 @@ func (o *FSObject) Reader() (io.ReadCloser, error) {
_ = f.Close()
return nil, err
}
return ioutil.NewReadCloserWithCloser(r, f.Close), nil
// Cap the lazy stream at the resolved object size: well-formed
// content reaches EOF inside the bound, an inflated stream that
// runs past surfaces ErrInflatedSizeMismatch on the byte just
// past the limit. For delta-resolved objects o.size is the
// expanded size, which is what the caller is reading here.
bounded := newBoundedReadCloser(r, o.size)
return ioutil.NewReadCloserWithCloser(bounded, f.Close), nil
}
r, err := p.getObjectContent(o.offset)
if err != nil {
+15 -6
View File
@@ -126,11 +126,17 @@ func (p *Packfile) nextObjectHeader() (*ObjectHeader, error) {
return h, err
}
func (p *Packfile) getDeltaObjectSize(buf *bytes.Buffer) int64 {
func (p *Packfile) getDeltaObjectSize(buf *bytes.Buffer) (int64, error) {
delta := buf.Bytes()
_, delta = decodeLEB128(delta) // skip src size
sz, _ := decodeLEB128(delta)
return int64(sz)
_, delta, err := decodeLEB128(delta) // skip src size
if err != nil {
return 0, err
}
sz, _, err := decodeLEB128(delta)
if err != nil {
return 0, err
}
return int64(sz), nil
}
func (p *Packfile) getObjectSize(h *ObjectHeader) (int64, error) {
@@ -145,7 +151,7 @@ func (p *Packfile) getObjectSize(h *ObjectHeader) (int64, error) {
return 0, err
}
return p.getDeltaObjectSize(buf), nil
return p.getDeltaObjectSize(buf)
default:
return 0, ErrInvalidObject.AddDetails("type %q", h.Type)
}
@@ -233,7 +239,10 @@ func (p *Packfile) getNextObject(h *ObjectHeader, hash plumbing.Hash) (plumbing.
return nil, err
}
size = p.getDeltaObjectSize(buf)
size, err = p.getDeltaObjectSize(buf)
if err != nil {
return nil, err
}
if size <= smallObjectThreshold {
var obj = new(plumbing.MemoryObject)
obj.SetSize(size)
+65 -7
View File
@@ -26,6 +26,45 @@ var (
ErrDeltaNotCached = errors.New("delta could not be found in cache")
)
// maxObjectPreallocBytes caps the up-front size hint passed to
// bytes.Buffer.Grow when staging an object's contents, so a malformed length
// cannot trigger a huge or out-of-range allocation. The buffer still grows
// dynamically as data is written; this is purely a hint cap.
const maxObjectPreallocBytes = 1 << 30 // 1 GiB
// maxObjectsPrealloc caps the up-front capacity reserved from the pack's
// declared object count, so a header advertising an absurd quantity cannot
// trigger a multi-gigabyte allocation. The slice and maps still grow
// organically beyond this hint.
const maxObjectsPrealloc = 1 << 16 // 64 Ki entries
// Match upstream Git's pack depth ceiling: pack-objects.h OE_DEPTH_BITS,
// enforced in builtin/pack-objects.c as (1 << OE_DEPTH_BITS) - 1.
const maxDeltaChainDepth = 4095
// growHint returns a non-negative int64 size, clamped to a sane upper bound,
// suitable for passing to bytes.Buffer.Grow.
func growHint(n int64) int {
switch {
case n <= 0:
return 0
case n > maxObjectPreallocBytes:
return maxObjectPreallocBytes
default:
return int(n)
}
}
// objectsHint returns a non-negative count, clamped to maxObjectsPrealloc,
// suitable for passing to make() as the capacity hint for slices or maps
// sized from a pack's declared object count.
func objectsHint(n uint32) int {
if n > maxObjectsPrealloc {
return maxObjectsPrealloc
}
return int(n)
}
// Observer interface is implemented by index encoders.
type Observer interface {
// OnHeader is called when a new packfile is opened.
@@ -166,9 +205,10 @@ func (p *Parser) init() error {
}
p.count = c
p.oiByHash = make(map[plumbing.Hash]*objectInfo, p.count)
p.oiByOffset = make(map[int64]*objectInfo, p.count)
p.oi = make([]*objectInfo, p.count)
hint := objectsHint(p.count)
p.oiByHash = make(map[plumbing.Hash]*objectInfo, hint)
p.oiByOffset = make(map[int64]*objectInfo, hint)
p.oi = make([]*objectInfo, 0, hint)
return nil
}
@@ -261,7 +301,7 @@ func (p *Parser) indexObjects() error {
}
if delta && !p.scanner.IsSeekable {
buf.Reset()
buf.Grow(int(oh.Length))
buf.Grow(growHint(oh.Length))
writers = append(writers, buf)
}
@@ -306,7 +346,7 @@ func (p *Parser) indexObjects() error {
}
p.oiByOffset[oh.Offset] = ota
p.oi[i] = ota
p.oi = append(p.oi, ota)
}
return nil
@@ -317,8 +357,12 @@ func (p *Parser) resolveDeltas() error {
defer sync.PutBytesBuffer(buf)
for _, obj := range p.oi {
if err := checkDeltaChainDepth(obj); err != nil {
return err
}
buf.Reset()
buf.Grow(int(obj.Length))
buf.Grow(growHint(obj.Length))
err := p.get(obj, buf)
if err != nil {
return err
@@ -337,6 +381,9 @@ func (p *Parser) resolveDeltas() error {
// create it once and reuse across all children.
r := bytes.NewReader(buf.Bytes())
for _, child := range obj.Children {
if err := checkDeltaChainDepth(child); err != nil {
return err
}
// Even though we are discarding the output, we still need to read it to
// so that the scanner can advance to the next object, and the SHA1 can be
// calculated.
@@ -356,6 +403,17 @@ func (p *Parser) resolveDeltas() error {
return nil
}
func checkDeltaChainDepth(o *objectInfo) error {
var depth int
for current := o; current != nil && current.DiskType.IsDelta(); current = current.Parent {
depth++
if depth > maxDeltaChainDepth {
return fmt.Errorf("%w: delta chain depth exceeds %d", ErrMalformedPackFile, maxDeltaChainDepth)
}
}
return nil
}
func (p *Parser) resolveExternalRef(o *objectInfo) {
if ref, ok := p.oiByHash[o.SHA1]; ok && ref.ExternalRef {
p.oiByHash[o.SHA1] = o
@@ -405,7 +463,7 @@ func (p *Parser) get(o *objectInfo, buf *bytes.Buffer) (err error) {
if o.DiskType.IsDelta() {
b := sync.GetBytesBuffer()
defer sync.PutBytesBuffer(b)
buf.Grow(int(o.Length))
buf.Grow(growHint(o.Length))
err := p.get(o.Parent, b)
if err != nil {
return err
+72 -41
View File
@@ -31,10 +31,15 @@ const (
// premptively made available for a patch operation.
maxPatchPreemptionSize uint = 65536
// minDeltaSize defines the smallest size for a delta.
minDeltaSize = 4
// minDeltaSize is the smallest valid delta: a 1-byte srcSz LEB128
// header followed by a 1-byte targetSz LEB128 header (the
// shortest case being targetSz=0 with no operations).
minDeltaSize = 2
)
// uintBits is the bit width of uint on the current platform (32 or 64).
const uintBits = 32 << (^uint(0) >> 63)
type offset struct {
mask byte
shift uint
@@ -142,7 +147,7 @@ func ReaderFromDelta(base plumbing.EncodedObject, deltaRC io.Reader) (io.ReadClo
baseBuf := bufio.NewReader(baseRd)
basePos := uint(0)
for {
for remainingTargetSz > 0 {
cmd, err := deltaBuf.ReadByte()
if err == io.EOF {
_ = dstWr.CloseWithError(ErrInvalidDelta)
@@ -166,9 +171,9 @@ func ReaderFromDelta(base plumbing.EncodedObject, deltaRC io.Reader) (io.ReadClo
return
}
if invalidSize(sz, targetSz) ||
if invalidSize(sz, remainingTargetSz) ||
invalidOffsetSize(offset, sz, srcSz) {
_ = dstWr.Close()
_ = dstWr.CloseWithError(ErrInvalidDelta)
return
}
@@ -210,7 +215,7 @@ func ReaderFromDelta(base plumbing.EncodedObject, deltaRC io.Reader) (io.ReadClo
case isCopyFromDelta(cmd):
sz := uint(cmd) // cmd is the size itself
if invalidSize(sz, targetSz) {
if invalidSize(sz, remainingTargetSz) {
_ = dstWr.CloseWithError(ErrInvalidDelta)
return
}
@@ -225,40 +230,48 @@ func ReaderFromDelta(base plumbing.EncodedObject, deltaRC io.Reader) (io.ReadClo
_ = dstWr.CloseWithError(ErrDeltaCmd)
return
}
if remainingTargetSz <= 0 {
_ = dstWr.Close()
return
}
}
// Mirror upstream's `data != top` post-loop check: every byte
// of the delta payload must be consumed.
if _, err := deltaBuf.ReadByte(); err == nil {
_ = dstWr.CloseWithError(ErrInvalidDelta)
return
} else if err != io.EOF {
_ = dstWr.CloseWithError(err)
return
}
_ = dstWr.Close()
}()
return dstRd, nil
}
func patchDelta(dst *bytes.Buffer, src, delta []byte) error {
if len(delta) < minCopySize {
return ErrInvalidDelta
srcSz, delta, err := decodeLEB128(delta)
if err != nil {
return fmt.Errorf("%w: %w", ErrInvalidDelta, err)
}
srcSz, delta := decodeLEB128(delta)
if srcSz != uint(len(src)) {
return ErrInvalidDelta
}
targetSz, delta := decodeLEB128(delta)
targetSz, delta, err := decodeLEB128(delta)
if err != nil {
return fmt.Errorf("%w: %w", ErrInvalidDelta, err)
}
remainingTargetSz := targetSz
var cmd byte
growSz := min(targetSz, maxPatchPreemptionSize)
dst.Grow(int(growSz))
for {
for remainingTargetSz > 0 {
if len(delta) == 0 {
return ErrInvalidDelta
}
cmd = delta[0]
cmd := delta[0]
delta = delta[1:]
switch {
@@ -275,16 +288,16 @@ func patchDelta(dst *bytes.Buffer, src, delta []byte) error {
return err
}
if invalidSize(sz, targetSz) ||
if invalidSize(sz, remainingTargetSz) ||
invalidOffsetSize(offset, sz, srcSz) {
break
return ErrInvalidDelta
}
dst.Write(src[offset : offset+sz])
remainingTargetSz -= sz
case isCopyFromDelta(cmd):
sz := uint(cmd) // cmd is the size itself
if invalidSize(sz, targetSz) {
if invalidSize(sz, remainingTargetSz) {
return ErrInvalidDelta
}
@@ -299,10 +312,12 @@ func patchDelta(dst *bytes.Buffer, src, delta []byte) error {
default:
return ErrDeltaCmd
}
}
if remainingTargetSz <= 0 {
break
}
// Mirror upstream's `data != top` post-loop check: every byte of
// the delta payload must be consumed.
if len(delta) != 0 {
return ErrInvalidDelta
}
return nil
@@ -354,7 +369,7 @@ func patchDeltaWriter(dst io.Writer, base io.ReaderAt, delta io.Reader,
baselr := io.LimitReader(sr, 0).(*io.LimitedReader)
deltalr := io.LimitReader(deltaBuf, 0).(*io.LimitedReader)
for {
for remainingTargetSz > 0 {
buf := *bufp
cmd, err := deltaBuf.ReadByte()
if err == io.EOF {
@@ -374,9 +389,9 @@ func patchDeltaWriter(dst io.Writer, base io.ReaderAt, delta io.Reader,
return 0, plumbing.ZeroHash, err
}
if invalidSize(sz, targetSz) ||
if invalidSize(sz, remainingTargetSz) ||
invalidOffsetSize(offset, sz, srcSz) {
return 0, plumbing.ZeroHash, err
return 0, plumbing.ZeroHash, ErrInvalidDelta
}
if _, err := sr.Seek(int64(offset), io.SeekStart); err != nil {
@@ -389,7 +404,7 @@ func patchDeltaWriter(dst io.Writer, base io.ReaderAt, delta io.Reader,
remainingTargetSz -= sz
} else if isCopyFromDelta(cmd) {
sz := uint(cmd) // cmd is the size itself
if invalidSize(sz, targetSz) {
if invalidSize(sz, remainingTargetSz) {
return 0, plumbing.ZeroHash, ErrInvalidDelta
}
deltalr.N = int64(sz)
@@ -399,30 +414,41 @@ func patchDeltaWriter(dst io.Writer, base io.ReaderAt, delta io.Reader,
remainingTargetSz -= sz
} else {
return 0, plumbing.ZeroHash, err
}
if remainingTargetSz <= 0 {
break
return 0, plumbing.ZeroHash, ErrDeltaCmd
}
}
// Mirror upstream's `data != top` post-loop check: every byte of
// the delta payload must be consumed.
if _, err := deltaBuf.ReadByte(); err == nil {
return 0, plumbing.ZeroHash, ErrInvalidDelta
} else if err != io.EOF {
return 0, plumbing.ZeroHash, err
}
return targetSz, hasher.Sum(), nil
}
// Decodes a number encoded as an unsigned LEB128 at the start of some
// binary data and returns the decoded number and the rest of the
// stream.
// binary data and returns the decoded number, the rest of the stream,
// and an error if the encoded value does not fit in a uint.
//
// This must be called twice on the delta data buffer, first to get the
// expected source buffer size, and again to get the target buffer size.
func decodeLEB128(input []byte) (uint, []byte) {
func decodeLEB128(input []byte) (uint, []byte, error) {
if len(input) == 0 {
return 0, input
return 0, input, nil
}
var num, sz uint
var b byte
for {
// A continuation byte at shift > uintBits-7 cannot contribute
// without overflowing the accumulator.
if sz*7 > uintBits-7 {
return 0, input, ErrLengthOverflow
}
b = input[sz]
num |= (uint(b) & payload) << (sz * 7) // concats 7 bits chunks
sz++
@@ -432,12 +458,16 @@ func decodeLEB128(input []byte) (uint, []byte) {
}
}
return num, input[sz:]
return num, input[sz:], nil
}
func decodeLEB128ByteReader(input io.ByteReader) (uint, error) {
var num, sz uint
for {
if sz*7 > uintBits-7 {
return 0, ErrLengthOverflow
}
b, err := input.ReadByte()
if err != nil {
return 0, err
@@ -529,8 +559,9 @@ func decodeSize(cmd byte, delta []byte) (uint, []byte, error) {
return sz, delta, nil
}
func invalidSize(sz, targetSz uint) bool {
return sz > targetSz
// invalidSize reports whether sz exceeds the remaining target size.
func invalidSize(sz, remaining uint) bool {
return sz > remaining
}
func invalidOffsetSize(offset, sz, srcSz uint) bool {
+145 -9
View File
@@ -29,8 +29,100 @@ var (
ErrSeekNotSupported = NewError("not seek support")
// ErrMalformedPackFile is returned by the parser when the pack file is corrupted.
ErrMalformedPackFile = errors.New("malformed PACK file")
// ErrLengthOverflow is returned when a variable-length integer would not
// fit into its accumulator because the input declares more continuation
// bytes than the type can hold.
ErrLengthOverflow = errors.New("variable-length integer overflow")
// ErrInflatedSizeMismatch is returned when a packfile object inflates to
// more bytes than the size declared in its object header. A well-formed
// packfile never produces more data than the declared size; exceeding it
// indicates a structurally invalid entry.
ErrInflatedSizeMismatch = errors.New("packfile: inflated object exceeds declared size")
)
// boundedWriter passes writes through to w up to limit bytes total, then
// returns ErrInflatedSizeMismatch. It is used to enforce that a packfile
// object's inflated length does not exceed the size declared in its header.
type boundedWriter struct {
w io.Writer
limit int64
n int64
}
// Write forwards p to the underlying writer while keeping the running total
// at or below limit. On overrun it forwards the legal prefix and reports
// the number of bytes actually consumed alongside ErrInflatedSizeMismatch,
// matching the contract in io.Writer. A write error from the underlying
// writer during overrun-handling is joined with ErrInflatedSizeMismatch so
// it is not silently dropped.
func (b *boundedWriter) Write(p []byte) (int, error) {
if b.n+int64(len(p)) > b.limit {
remain := int(b.limit - b.n)
err := error(ErrInflatedSizeMismatch)
if remain > 0 {
n, werr := b.w.Write(p[:remain])
b.n += int64(n)
if werr != nil {
err = errors.Join(ErrInflatedSizeMismatch, werr)
}
return n, err
}
return 0, err
}
n, err := b.w.Write(p)
b.n += int64(n)
return n, err
}
// boundedReadCloser wraps a ReadCloser and reports ErrInflatedSizeMismatch
// once more than limit bytes have been read. It is used by the on-demand
// object reader returned from FSObject.Reader so that a lazy Read of a
// packfile object cannot stream past its declared inflated size.
//
// The implementation builds on io.LimitedReader with the standard
// overrun-detection trick: request limit+1 bytes from the underlying so
// that the moment the sentinel byte materializes (LimitedReader.N drops
// to zero) we know the source produced more than limit bytes.
type boundedReadCloser struct {
lr io.LimitedReader
closer io.Closer
overrun bool
}
// newBoundedReadCloser wraps rc so that the cumulative bytes returned from
// Read never exceed limit. The first call that would have returned a byte
// past limit instead returns ErrInflatedSizeMismatch; subsequent calls
// keep returning the same error. A negative limit is treated as zero, so
// the first byte produced by rc surfaces ErrInflatedSizeMismatch.
func newBoundedReadCloser(rc io.ReadCloser, limit int64) *boundedReadCloser {
if limit < 0 {
limit = 0
}
return &boundedReadCloser{
lr: io.LimitedReader{R: rc, N: limit + 1},
closer: rc,
}
}
// Read forwards Read up to the configured byte limit. When the underlying
// stream produces the limit+1 sentinel byte, the legal prefix is returned
// alongside ErrInflatedSizeMismatch; on subsequent calls only the error
// is returned.
func (b *boundedReadCloser) Read(p []byte) (int, error) {
if b.overrun {
return 0, ErrInflatedSizeMismatch
}
n, err := b.lr.Read(p)
if b.lr.N == 0 {
b.overrun = true
return n - 1, ErrInflatedSizeMismatch
}
return n, err
}
// Close closes the underlying ReadCloser.
func (b *boundedReadCloser) Close() error { return b.closer.Close() }
// ObjectHeader contains the information related to the object, this information
// is collected from the previous bytes to the content of the object.
type ObjectHeader struct {
@@ -220,6 +312,13 @@ func (s *Scanner) nextObjectHeader() (*ObjectHeader, error) {
return nil, err
}
// An OFS-delta references a base object that appears earlier
// in the pack; the negative offset must be strictly positive
// and not larger than the current object's offset.
if no <= 0 || no > h.Offset {
return nil, fmt.Errorf("%w: invalid OFS delta offset", ErrMalformedPackFile)
}
h.OffsetReference = h.Offset - no
case plumbing.REFDeltaObject:
var err error
@@ -303,6 +402,13 @@ func (s *Scanner) readLength(first byte) (int64, error) {
shift := firstLengthBits
var err error
for c&maskContinue > 0 {
// Mirrors unpack_object_header_buffer in canonical Git's
// packfile.c: a continuation byte at shift > 64-7 cannot
// contribute without overflowing an int64.
if shift > 64-lengthBits {
return 0, fmt.Errorf("%w: %w", ErrMalformedPackFile, ErrLengthOverflow)
}
if c, err = s.r.ReadByte(); err != nil {
return 0, err
}
@@ -315,10 +421,18 @@ func (s *Scanner) readLength(first byte) (int64, error) {
}
// NextObject writes the content of the next object into the reader, returns
// the number of bytes written, the CRC32 of the content and an error, if any
// the number of bytes written, the CRC32 of the content and an error, if any.
//
// When a prior NextObjectHeader has stashed the object header in
// pendingObject, the inflated stream is bounded by the header's declared
// length and surfaces ErrInflatedSizeMismatch on overrun.
func (s *Scanner) NextObject(w io.Writer) (written int64, crc32 uint32, err error) {
declaredSize := int64(-1)
if s.pendingObject != nil {
declaredSize = s.pendingObject.Length
}
s.pendingObject = nil
written, err = s.copyObject(w)
written, err = s.copyObject(w, declaredSize)
s.r.Flush()
crc32 = s.crc.Sum32()
@@ -327,23 +441,39 @@ func (s *Scanner) NextObject(w io.Writer) (written int64, crc32 uint32, err erro
return
}
// ReadObject returns a reader for the object content and an error
// ReadObject returns a reader for the object content and an error.
//
// When a prior NextObjectHeader has stashed the object header in
// pendingObject, the returned reader is bounded by the header's declared
// length so callers cannot stream past the declared inflated size; an
// overrun surfaces ErrInflatedSizeMismatch on the byte just past the
// limit.
func (s *Scanner) ReadObject() (io.ReadCloser, error) {
declaredSize := int64(-1)
if s.pendingObject != nil {
declaredSize = s.pendingObject.Length
}
s.pendingObject = nil
zr, err := sync.GetZlibReader(s.r)
if err != nil {
return nil, fmt.Errorf("zlib reset error: %s", err)
}
return ioutil.NewReadCloserWithCloser(zr.Reader, func() error {
rc := ioutil.NewReadCloserWithCloser(zr.Reader, func() error {
sync.PutZlibReader(zr)
return nil
}), nil
})
if declaredSize >= 0 {
return newBoundedReadCloser(rc, declaredSize), nil
}
return rc, nil
}
// ReadRegularObject reads and write a non-deltified object
// from it zlib stream in an object entry in the packfile.
func (s *Scanner) copyObject(w io.Writer) (n int64, err error) {
// copyObject inflates a non-deltified object's zlib stream into w. When
// declaredSize is non-negative, the write sink is wrapped in a
// boundedWriter so an overrun surfaces ErrInflatedSizeMismatch instead
// of being silently appended.
func (s *Scanner) copyObject(w io.Writer, declaredSize int64) (n int64, err error) {
zr, err := sync.GetZlibReader(s.r)
defer sync.PutZlibReader(zr)
@@ -352,8 +482,14 @@ func (s *Scanner) copyObject(w io.Writer) (n int64, err error) {
}
defer ioutil.CheckClose(zr.Reader, &err)
sink := w
if declaredSize >= 0 {
sink = &boundedWriter{w: w, limit: declaredSize}
}
buf := sync.GetByteSlice()
n, err = io.CopyBuffer(w, zr.Reader, *buf)
n, err = io.CopyBuffer(sink, zr.Reader, *buf)
sync.PutByteSlice(buf)
return
}
+113 -94
View File
@@ -5,7 +5,7 @@ import (
"context"
"errors"
"fmt"
"io"
"slices"
"strings"
"github.com/ProtonMail/go-crypto/openpgp"
@@ -20,6 +20,7 @@ const (
beginpgp string = "-----BEGIN PGP SIGNATURE-----"
endpgp string = "-----END PGP SIGNATURE-----"
headerpgp string = "gpgsig"
headerpgp256 string = "gpgsig-sha256"
headerencoding string = "encoding"
// https://github.com/git/git/blob/bcb6cae2966cc407ca1afc77413b3ef11103c175/Documentation/gitformat-signature.txt#L153
@@ -41,6 +42,11 @@ type MessageEncoding string
// in time, such as a timestamp, the author of the changes since the last
// commit, a pointer to the previous commit(s), etc.
// http://shafiulazam.com/gitbook/1_the_git_object_model.html
//
// When a Commit is populated by Decode it retains a reference to the source
// plumbing.EncodedObject so that EncodeWithoutSignature can reproduce the
// exact bytes the signature was computed over. Refer to EncodeWithoutSignature
// for more information.
type Commit struct {
// Hash of the commit object.
Hash plumbing.Hash
@@ -66,6 +72,9 @@ type Commit struct {
ExtraHeaders []ExtraHeader
s storer.EncodedObjectStorer
// src holds the encoded object this Commit was decoded from, used by
// EncodeWithoutSignature to recover the canonical signed bytes.
src plumbing.EncodedObject
}
// ExtraHeader holds any non-standard header
@@ -98,8 +107,8 @@ func (h ExtraHeader) Format(f fmt.State, verb rune) {
func parseExtraHeader(line []byte) (ExtraHeader, bool) {
split := bytes.SplitN(line, []byte{' '}, 2)
out := ExtraHeader {
Key: string(bytes.TrimRight(split[0], "\n")),
out := ExtraHeader{
Key: string(bytes.TrimRight(split[0], "\n")),
Value: "",
}
@@ -181,6 +190,11 @@ func (c *Commit) NumParents() int {
var ErrParentNotFound = errors.New("commit parent not found")
// ErrMalformedCommit is returned when a commit object cannot be decoded
// because its standard headers (tree, parent, author, committer) are missing,
// duplicated, or out of order.
var ErrMalformedCommit = errors.New("malformed commit")
// Parent returns the ith parent of a commit.
func (c *Commit) Parent(i int) (*Commit, error) {
if len(c.ParentHashes) == 0 || i > len(c.ParentHashes)-1 {
@@ -227,14 +241,23 @@ func (c *Commit) Type() plumbing.ObjectType {
return plumbing.CommitObject
}
func (c *Commit) reset() {
storer := c.s
*c = Commit{
Encoding: defaultUtf8CommitMessageEncoding,
s: storer,
}
}
// Decode transforms a plumbing.EncodedObject into a Commit struct.
func (c *Commit) Decode(o plumbing.EncodedObject) (err error) {
if o.Type() != plumbing.CommitObject {
return ErrUnsupportedObject
}
c.reset()
c.Hash = o.Hash()
c.Encoding = defaultUtf8CommitMessageEncoding
c.src = o
reader, err := o.Reader()
if err != nil {
@@ -245,97 +268,17 @@ func (c *Commit) Decode(o plumbing.EncodedObject) (err error) {
r := sync.GetBufioReader(reader)
defer sync.PutBufioReader(r)
var message bool
var mergetag bool
var pgpsig bool
var msgbuf bytes.Buffer
var extraheader *ExtraHeader = nil
for {
line, err := r.ReadBytes('\n')
if err != nil && err != io.EOF {
s := &commitScanner{r: r, c: c}
for state := scanTree; state != nil; {
state, err = state(s)
if err != nil {
return err
}
if mergetag {
if len(line) > 0 && line[0] == ' ' {
line = bytes.TrimLeft(line, " ")
c.MergeTag += string(line)
continue
} else {
mergetag = false
}
}
if pgpsig {
if len(line) > 0 && line[0] == ' ' {
line = bytes.TrimLeft(line, " ")
c.PGPSignature += string(line)
continue
} else {
pgpsig = false
}
}
if extraheader != nil {
if len(line) > 0 && line[0] == ' ' {
extraheader.Value += string(line[1:])
continue
} else {
extraheader.Value = strings.TrimRight(extraheader.Value, "\n")
c.ExtraHeaders = append(c.ExtraHeaders, *extraheader)
extraheader = nil
}
}
if !message {
original_line := line
line = bytes.TrimSpace(line)
if len(line) == 0 {
message = true
continue
}
split := bytes.SplitN(line, []byte{' '}, 2)
var data []byte
if len(split) == 2 {
data = split[1]
}
switch string(split[0]) {
case "tree":
c.TreeHash = plumbing.NewHash(string(data))
case "parent":
c.ParentHashes = append(c.ParentHashes, plumbing.NewHash(string(data)))
case "author":
c.Author.Decode(data)
case "committer":
c.Committer.Decode(data)
case headermergetag:
c.MergeTag += string(data) + "\n"
mergetag = true
case headerencoding:
c.Encoding = MessageEncoding(data)
case headerpgp:
c.PGPSignature += string(data) + "\n"
pgpsig = true
default:
h, maybecontinued := parseExtraHeader(original_line)
if maybecontinued {
extraheader = &h
} else {
c.ExtraHeaders = append(c.ExtraHeaders, h)
}
}
} else {
msgbuf.Write(line)
}
if err == io.EOF {
break
}
}
c.Message = msgbuf.String()
if !s.sawTree {
return fmt.Errorf("%w: missing tree header", ErrMalformedCommit)
}
c.Message = s.msgbuf.String()
return nil
}
@@ -344,11 +287,73 @@ func (c *Commit) Encode(o plumbing.EncodedObject) error {
return c.encode(o, true)
}
// EncodeWithoutSignature export a Commit into a plumbing.EncodedObject without the signature (correspond to the payload of the PGP signature).
// EncodeWithoutSignature exports a Commit into a plumbing.EncodedObject
// without any signature headers, producing the payload that PGP/GPG
// signatures are computed over.
//
// Behaviour depends on how the Commit was created:
//
// - For Commits populated by Decode whose exported fields still match the
// source object, the payload is streamed from the raw source bytes with
// gpgsig and gpgsig-sha256 headers (and their continuation lines)
// stripped verbatim. This preserves the exact bytes the signature was
// computed over, regardless of any normalization performed by Decode.
//
// - For Commits constructed in memory, or for decoded Commits whose
// exported fields have been mutated, the payload is derived from the
// current struct fields. Mutation is detected by re-decoding the source
// object and comparing exported fields; if any differ, the in-memory
// representation prevails.
func (c *Commit) EncodeWithoutSignature(o plumbing.EncodedObject) error {
if c.matchesSource() {
return stripObjectSignatures(o, c.src, plumbing.CommitObject)
}
return c.encode(o, false)
}
// matchesSource reports whether c.src is set and re-decoding it produces a
// Commit whose payload-affecting exported fields are identical to those of
// c. It is the auto-detection used by EncodeWithoutSignature to decide
// between the raw bytes and the struct-encoded payload.
//
// PGPSignature is intentionally excluded from the comparison: neither path
// emits it, so mutating it must not trigger a switch to struct-encode (which
// would change the byte layout the caller is trying to verify against).
func (c *Commit) matchesSource() bool {
if c.src == nil {
return false
}
fresh := &Commit{}
if err := fresh.Decode(c.src); err != nil {
return false
}
return c.Hash == fresh.Hash &&
signatureEqual(c.Author, fresh.Author) &&
signatureEqual(c.Committer, fresh.Committer) &&
c.MergeTag == fresh.MergeTag &&
c.Message == fresh.Message &&
c.TreeHash == fresh.TreeHash &&
c.Encoding == fresh.Encoding &&
slices.Equal(c.ParentHashes, fresh.ParentHashes) &&
slices.Equal(c.ExtraHeaders, fresh.ExtraHeaders)
}
func signatureEqual(a, b Signature) bool {
return a.Name == b.Name &&
a.Email == b.Email &&
a.When.Unix() == b.When.Unix() &&
a.When.Format("-0700") == b.When.Format("-0700")
}
func isStandardHeader(key string) bool {
switch key {
case "tree", "parent", "author", "committer",
headerencoding, headermergetag, headerpgp, headerpgp256:
return true
}
return false
}
func (c *Commit) encode(o plumbing.EncodedObject, includeSig bool) (err error) {
o.SetType(plumbing.CommitObject)
w, err := o.Writer()
@@ -407,7 +412,9 @@ func (c *Commit) encode(o plumbing.EncodedObject, includeSig bool) (err error) {
}
for _, header := range c.ExtraHeaders {
if isStandardHeader(header.Key) {
continue
}
if _, err = fmt.Fprintf(w, "\n%s", header); err != nil {
return err
}
@@ -478,9 +485,21 @@ func (c *Commit) String() string {
)
}
// ErrMultipleSignatures is returned by Verify when the commit carries more
// than one armored signature block. Mirrors upstream's parse_gpg_output
// rejection of GOODSIG/BADSIG status lines after the first
// (gpg-interface.c:257-269): multi-signature commits are intentionally
// unsupported because their provenance cannot be reduced to a single
// authoritative signer.
var ErrMultipleSignatures = errors.New("commit has multiple signatures")
// Verify performs PGP verification of the commit with a provided armored
// keyring and returns openpgp.Entity associated with verifying key on success.
func (c *Commit) Verify(armoredKeyRing string) (*openpgp.Entity, error) {
if countSignatureBlocks([]byte(c.PGPSignature)) > 1 {
return nil, ErrMultipleSignatures
}
keyRingReader := strings.NewReader(armoredKeyRing)
keyring, err := openpgp.ReadArmoredKeyRing(keyRingReader)
if err != nil {
+377
View File
@@ -0,0 +1,377 @@
package object
import (
"bufio"
"bytes"
"fmt"
"io"
"strings"
"github.com/go-git/go-git/v5/plumbing"
)
// commitScanner holds the working state of the commit decoder driven by the
// stateFn loop in (*Commit).Decode. Each commitState reads one or more lines
// from r, updates the in-progress *Commit and the scanner's bookkeeping, and
// returns the state that should run next (or nil to stop).
type commitScanner struct {
r *bufio.Reader
c *Commit
msgbuf bytes.Buffer
// pending holds a line that was read but the current state decided to
// hand back to the next state, paired with the io.EOF flag that was
// returned when the line was originally read.
pending []byte
pendingErr error
// First-occurrence tracking: once the corresponding field has been
// decoded, subsequent occurrences are silently dropped (matches
// upstream's find_commit_header / first-wins semantics).
//
// gpgsig is not tracked here: upstream's parse_buffer_signed_by_header
// (commit.c:1186) accumulates every occurrence into one signature buffer,
// so we do the same on the scanner side to keep verification payloads
// byte-aligned. gpgsig-sha256 is recognized and skipped without exposing a
// new field in v5.
sawTree, sawAuthor, sawCommitter bool
sawEncoding, sawMergetag bool
// extra is the multi-line ExtraHeader currently being assembled.
extra *ExtraHeader
}
// commitState is one step of the decoder state machine. Each function reads
// the lines it needs, mutates *Commit via s.c, and returns the next state to
// run (or nil to terminate the loop).
type commitState func(*commitScanner) (commitState, error)
// readLine returns the next line from the buffer, transparently consuming any
// line that was previously pushed back by a state that decided not to handle
// it.
func (s *commitScanner) readLine() ([]byte, error) {
if s.pending != nil {
line, err := s.pending, s.pendingErr
s.pending, s.pendingErr = nil, nil
return line, err
}
line, err := s.r.ReadBytes('\n')
if err != nil && err != io.EOF {
return line, err
}
return line, err
}
// pushBack stashes an unconsumed line so the next state's readLine call sees
// it. Only one line can be pushed back at a time.
func (s *commitScanner) pushBack(line []byte, err error) {
s.pending = line
s.pendingErr = err
}
// scanTree expects the first non-empty header to be `tree HASH`. Anything
// else (or an empty buffer) is rejected with ErrMalformedCommit, matching
// upstream's `bogus commit object` check.
func scanTree(s *commitScanner) (commitState, error) {
line, err := s.readLine()
if err != nil && err != io.EOF {
return nil, err
}
if len(line) == 0 || isBlankLine(line) {
return nil, fmt.Errorf("%w: missing tree header", ErrMalformedCommit)
}
key, data := splitHeader(line)
if key != "tree" {
return nil, fmt.Errorf("%w: tree header must be first", ErrMalformedCommit)
}
h, herr := parseObjectIDHex(data, ErrMalformedCommit, "tree")
if herr != nil {
return nil, herr
}
s.c.TreeHash = h
s.sawTree = true
if err == io.EOF {
return nil, nil
}
return scanParents, nil
}
// scanParents consumes contiguous `parent HASH` lines. The first non-parent
// line ends the parent block and is handed off to scanAuthor; any later
// `parent` line is silently dropped (matches upstream's parse_commit_buffer
// exiting its parent loop at the first non-parent line and
// read_commit_extra_header_lines filtering `parent` out of extras).
func scanParents(s *commitScanner) (commitState, error) {
line, err := s.readLine()
if err != nil && err != io.EOF {
return nil, err
}
if len(line) == 0 {
return nil, nil
}
if isBlankLine(line) {
return scanMessage, nil
}
key, data := splitHeader(line)
if key == "parent" {
h, herr := parseObjectIDHex(data, ErrMalformedCommit, "parent")
if herr != nil {
return nil, herr
}
s.c.ParentHashes = append(s.c.ParentHashes, h)
if err == io.EOF {
return nil, nil
}
return scanParents, nil
}
s.pushBack(line, err)
return scanAuthor, nil
}
// scanAuthor accepts an `author` line at its canonical position immediately
// after the parent block. Any other header here is pushed back for
// scanCommitter; an out-of-place author is therefore silently dropped.
// Mirrors upstream's parse_commit_date func.
func scanAuthor(s *commitScanner) (commitState, error) {
line, err := s.readLine()
if err != nil && err != io.EOF {
return nil, err
}
if len(line) == 0 {
return nil, nil
}
if isBlankLine(line) {
return scanMessage, nil
}
key, data := splitHeader(line)
if key == "author" {
s.c.Author.Decode(data)
s.sawAuthor = true
if err == io.EOF {
return nil, nil
}
return scanCommitter, nil
}
s.pushBack(line, err)
return scanCommitter, nil
}
// scanCommitter accepts a `committer` line at its canonical position
// immediately after the author. Any other header is pushed back for
// scanHeaders. Same upstream rationale as scanAuthor.
func scanCommitter(s *commitScanner) (commitState, error) {
line, err := s.readLine()
if err != nil && err != io.EOF {
return nil, err
}
if len(line) == 0 {
return nil, nil
}
if isBlankLine(line) {
return scanMessage, nil
}
key, data := splitHeader(line)
if key == "committer" {
s.c.Committer.Decode(data)
s.sawCommitter = true
if err == io.EOF {
return nil, nil
}
return scanHeaders, nil
}
s.pushBack(line, err)
return scanHeaders, nil
}
// scanHeaders dispatches one header line. Continuation-bearing headers
// (mergetag, gpgsig, gpgsig-sha256, and unknown extras whose value is
// continued on subsequent lines) hand off to a dedicated continuation state
// that handles the `<space>...` lines and then returns here.
func scanHeaders(s *commitScanner) (commitState, error) {
line, err := s.readLine()
if err != nil && err != io.EOF {
return nil, err
}
if len(line) == 0 {
return nil, nil
}
if isBlankLine(line) {
return scanMessage, nil
}
originalLine := line
key, data := splitHeader(line)
var next commitState = scanHeaders
switch key {
case "tree", "parent", "author", "committer":
// Anything reaching scanHeaders with one of these keys is out of
// canonical position: duplicate tree, parent past the contiguous
// block, or author/committer not at their expected slot. Drop them
// the same way upstream's standard_header_field filter excludes
// them from the extras list (read_commit_extra_header_lines,
// commit.c:1520-1522).
case headerencoding:
if !s.sawEncoding {
s.c.Encoding = MessageEncoding(data)
s.sawEncoding = true
}
case headermergetag:
if s.sawMergetag {
next = scanSkipCont
} else {
s.c.MergeTag += string(data) + "\n"
s.sawMergetag = true
next = scanMergetagCont
}
case headerpgp:
s.c.PGPSignature += string(data) + "\n"
next = scanPgpCont
case headerpgp256:
next = scanSkipCont
default:
h, multiline := parseExtraHeader(originalLine)
if multiline {
s.extra = &h
next = scanExtraCont
} else {
s.c.ExtraHeaders = append(s.c.ExtraHeaders, h)
}
}
if err == io.EOF {
return nil, nil
}
return next, nil
}
// scanMergetagCont accumulates continuation lines for the first mergetag
// header. Continuations strip exactly one leading space, mirroring upstream's
// `line + 1` (commit.c:1509). The first non-continuation line is pushed back
// so scanHeaders can dispatch it.
func scanMergetagCont(s *commitScanner) (commitState, error) {
return continuationCont(s, &s.c.MergeTag, scanMergetagCont)
}
// scanPgpCont accumulates continuation lines for a signature header.
// Continuations strip exactly one leading space, mirroring upstream's
// `line + 1` (commit.c:1509). The first non-continuation line is pushed back
// so scanHeaders can dispatch it. Repeat occurrences of the same signature
// header land back here and concatenate, matching upstream's
// parse_buffer_signed_by_header (commit.c:1186).
func scanPgpCont(s *commitScanner) (commitState, error) {
return continuationCont(s, &s.c.PGPSignature, scanPgpCont)
}
func continuationCont(s *commitScanner, dst *string, self commitState) (commitState, error) {
line, err := s.readLine()
if err != nil && err != io.EOF {
return nil, err
}
if len(line) > 0 && line[0] == ' ' {
*dst += string(line[1:])
if err == io.EOF {
return nil, nil
}
return self, nil
}
if len(line) > 0 {
s.pushBack(line, err)
}
return scanHeaders, nil
}
// scanSkipCont discards continuation lines that belong to a header scanHeaders
// chose to drop.
func scanSkipCont(s *commitScanner) (commitState, error) {
line, err := s.readLine()
if err != nil && err != io.EOF {
return nil, err
}
if len(line) > 0 && line[0] == ' ' {
if err == io.EOF {
return nil, nil
}
return scanSkipCont, nil
}
if len(line) > 0 {
s.pushBack(line, err)
}
return scanHeaders, nil
}
// scanExtraCont accumulates continuation lines for an unknown ExtraHeader
// whose value spans multiple lines, then finalises the entry once the
// continuation block ends.
func scanExtraCont(s *commitScanner) (commitState, error) {
line, err := s.readLine()
if err != nil && err != io.EOF {
return nil, err
}
if len(line) > 0 && line[0] == ' ' {
s.extra.Value += string(line[1:])
if err == io.EOF {
s.finaliseExtra()
return nil, nil
}
return scanExtraCont, nil
}
s.finaliseExtra()
if len(line) > 0 {
s.pushBack(line, err)
}
return scanHeaders, nil
}
func (s *commitScanner) finaliseExtra() {
s.extra.Value = strings.TrimRight(s.extra.Value, "\n")
s.c.ExtraHeaders = append(s.c.ExtraHeaders, *s.extra)
s.extra = nil
}
// scanMessage drains the remaining bytes into the message buffer.
func scanMessage(s *commitScanner) (commitState, error) {
for {
line, err := s.readLine()
if err != nil && err != io.EOF {
return nil, err
}
if len(line) > 0 {
s.msgbuf.Write(line)
}
if err == io.EOF {
return nil, nil
}
}
}
// isBlankLine reports whether line is the canonical header/body separator:
// a single newline. Mirrors upstream's `*line == '\n'` test in
// read_commit_extra_header_lines (commit.c:1502).
func isBlankLine(line []byte) bool {
return len(line) == 1 && line[0] == '\n'
}
// splitHeader returns the header keyword (everything before the first space)
// and the value (everything after, with the trailing newline stripped). If
// the header has no value the returned data is nil.
func splitHeader(line []byte) (string, []byte) {
trimmed := bytes.TrimRight(line, "\n")
key, value, ok := bytes.Cut(trimmed, []byte{' '})
if !ok {
return string(trimmed), nil
}
return string(key), value
}
func parseObjectIDHex(data []byte, malformedErr error, header string) (plumbing.Hash, error) {
id := string(data)
if !plumbing.IsHash(id) {
return plumbing.ZeroHash, fmt.Errorf("%w: bad %s hash", malformedErr, header)
}
return plumbing.NewHash(id), nil
}
+121 -1
View File
@@ -1,6 +1,13 @@
package object
import "bytes"
import (
"bytes"
"io"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/utils/ioutil"
"github.com/go-git/go-git/v5/utils/sync"
)
const (
signatureTypeUnknown signatureType = iota
@@ -100,3 +107,116 @@ func parseSignedBytes(b []byte) (int, signatureType) {
}
return match, t
}
// countSignatureBlocks reports how many distinct armored signature blocks
// start at a line boundary in b. Used by verification paths to reject
// multi-signature payloads, matching upstream's check in gpg-interface.c
// where parse_gpg_output bails out the first time it sees a second
// exclusive status line (a second GOODSIG/BADSIG/etc.).
func countSignatureBlocks(b []byte) int {
n, count := 0, 0
for n < len(b) {
i := b[n:]
if typeForSignature(i) != signatureTypeUnknown {
count++
}
if eol := bytes.IndexByte(i, '\n'); eol >= 0 {
n += eol + 1
continue
}
break
}
return count
}
// isSignatureHeader reports whether line is a canonical "gpgsig "/
// "gpgsig-sha256 " header line. Other "gpgsig"-prefixed extra headers
// are intentionally not matched.
func isSignatureHeader(line []byte) bool {
return bytes.HasPrefix(line, []byte(headerpgp+" ")) ||
bytes.HasPrefix(line, []byte(headerpgp256+" "))
}
// stripObjectSignatures streams src into dst, producing the byte sequence
// over which a PGP/GPG signature is computed:
//
// - Canonical "gpgsig" and "gpgsig-sha256" headers (and their
// continuation lines) are dropped, mirroring upstream's
// remove_signature in commit.c.
// - For tag objects, the inline trailing PGP signature is additionally
// truncated, mirroring upstream's parse_signature in gpg-interface.c
// used by gpg_verify_tag.
//
// The returned object's type is set to objType. Used by both
// Commit.EncodeWithoutSignature and Tag.EncodeWithoutSignature to
// reproduce the exact bytes the signature was computed over.
func stripObjectSignatures(dst, src plumbing.EncodedObject, objType plumbing.ObjectType) (err error) {
dst.SetType(objType)
r, err := src.Reader()
if err != nil {
return err
}
defer ioutil.CheckClose(r, &err)
var input io.Reader = r
if objType == plumbing.TagObject {
raw, err := io.ReadAll(r)
if err != nil {
return err
}
if sm, _ := parseSignedBytes(raw); sm >= 0 {
raw = raw[:sm]
}
input = bytes.NewReader(raw)
}
w, err := dst.Writer()
if err != nil {
return err
}
defer ioutil.CheckClose(w, &err)
return stripHeaderSignatures(w, input)
}
// stripHeaderSignatures copies r to w, dropping canonical signature header
// lines (gpgsig and gpgsig-sha256) and their continuation lines. Lines
// past the blank line that closes the header block are copied verbatim.
func stripHeaderSignatures(w io.Writer, r io.Reader) error {
br := sync.GetBufioReader(r)
defer sync.PutBufioReader(br)
var inBody, skipping bool
for {
line, rerr := br.ReadBytes('\n')
if rerr != nil && rerr != io.EOF {
return rerr
}
write := true
if !inBody {
switch {
case skipping && len(line) > 0 && line[0] == ' ':
write = false
case isSignatureHeader(line):
skipping = true
write = false
case len(line) == 1 && line[0] == '\n':
skipping = false
inBody = true
default:
skipping = false
}
}
if write && len(line) > 0 {
if _, werr := w.Write(line); werr != nil {
return werr
}
}
if rerr == io.EOF {
return nil
}
}
}
+90 -45
View File
@@ -1,9 +1,8 @@
package object
import (
"bytes"
"errors"
"fmt"
"io"
"strings"
"github.com/ProtonMail/go-crypto/openpgp"
@@ -13,6 +12,10 @@ import (
"github.com/go-git/go-git/v5/utils/sync"
)
// ErrMalformedTag is returned when a tag object cannot be decoded because
// its required headers (object, type, tag) are missing or out of order.
var ErrMalformedTag = errors.New("malformed tag")
// Tag represents an annotated tag object. It points to a single git object of
// any type, but tags typically are applied to commit or blob objects. It
// provides a reference that associates the target with a tag name. It also
@@ -39,6 +42,9 @@ type Tag struct {
Target plumbing.Hash
s storer.EncodedObjectStorer
// src holds the encoded object this Tag was decoded from, used by
// EncodeWithoutSignature to recover the canonical signed bytes.
src plumbing.EncodedObject
}
// GetTag gets a tag from an object storer and decodes it.
@@ -77,13 +83,20 @@ func (t *Tag) Type() plumbing.ObjectType {
return plumbing.TagObject
}
func (t *Tag) reset() {
storer := t.s
*t = Tag{s: storer}
}
// Decode transforms a plumbing.EncodedObject into a Tag struct.
func (t *Tag) Decode(o plumbing.EncodedObject) (err error) {
if o.Type() != plumbing.TagObject {
return ErrUnsupportedObject
}
t.reset()
t.Hash = o.Hash()
t.src = o
reader, err := o.Reader()
if err != nil {
@@ -94,42 +107,15 @@ func (t *Tag) Decode(o plumbing.EncodedObject) (err error) {
r := sync.GetBufioReader(reader)
defer sync.PutBufioReader(r)
for {
var line []byte
line, err = r.ReadBytes('\n')
if err != nil && err != io.EOF {
scanner := &tagScanner{r: r, t: t}
for state := scanTagObject; state != nil; {
state, err = state(scanner)
if err != nil {
return err
}
line = bytes.TrimSpace(line)
if len(line) == 0 {
break // Start of message
}
split := bytes.SplitN(line, []byte{' '}, 2)
switch string(split[0]) {
case "object":
t.Target = plumbing.NewHash(string(split[1]))
case "type":
t.TargetType, err = plumbing.ParseObjectType(string(split[1]))
if err != nil {
return err
}
case "tag":
t.Name = string(split[1])
case "tagger":
t.Tagger.Decode(split[1])
}
if err == io.EOF {
return nil
}
}
data, err := io.ReadAll(r)
if err != nil {
return err
}
data := scanner.msgbuf.Bytes()
if sm, _ := parseSignedBytes(data); sm >= 0 {
t.PGPSignature = string(data[sm:])
data = data[:sm]
@@ -144,11 +130,54 @@ func (t *Tag) Encode(o plumbing.EncodedObject) error {
return t.encode(o, true)
}
// EncodeWithoutSignature export a Tag into a plumbing.EncodedObject without the signature (correspond to the payload of the PGP signature).
// EncodeWithoutSignature exports a Tag into a plumbing.EncodedObject without
// any signature data, producing the payload that PGP/GPG signatures are
// computed over.
//
// Behaviour mirrors Commit.EncodeWithoutSignature:
//
// - For Tags populated by Decode whose exported fields still match the
// source object, the payload is streamed from the raw source bytes with
// the inline trailing signature truncated and gpgsig/gpgsig-sha256
// headers (and their continuation lines) stripped verbatim. This
// preserves the exact bytes the signature was computed over, regardless
// of any normalization performed by Decode.
//
// - For Tags constructed in memory, or for decoded Tags whose exported
// fields have been mutated, the payload is derived from the current
// struct fields. Mutation is detected by re-decoding the source object
// and comparing exported fields; if any differ, the in-memory
// representation prevails.
func (t *Tag) EncodeWithoutSignature(o plumbing.EncodedObject) error {
if t.matchesSource() {
return stripObjectSignatures(o, t.src, plumbing.TagObject)
}
return t.encode(o, false)
}
// matchesSource reports whether t.src is set and re-decoding it produces a
// Tag whose payload-affecting exported fields are identical to those of t.
//
// PGPSignature is intentionally excluded from the comparison: neither path
// emits it as part of the verification payload, so mutating it must not
// trigger a switch to struct-encode (which would change the byte layout the
// caller is trying to verify against).
func (t *Tag) matchesSource() bool {
if t.src == nil {
return false
}
fresh := &Tag{}
if err := fresh.Decode(t.src); err != nil {
return false
}
return t.Hash == fresh.Hash &&
t.Name == fresh.Name &&
signatureEqual(t.Tagger, fresh.Tagger) &&
t.Message == fresh.Message &&
t.TargetType == fresh.TargetType &&
t.Target == fresh.Target
}
func (t *Tag) encode(o plumbing.EncodedObject, includeSig bool) (err error) {
o.SetType(plumbing.TagObject)
w, err := o.Writer()
@@ -158,16 +187,26 @@ func (t *Tag) encode(o plumbing.EncodedObject, includeSig bool) (err error) {
defer ioutil.CheckClose(w, &err)
if _, err = fmt.Fprintf(w,
"object %s\ntype %s\ntag %s\ntagger ",
"object %s\ntype %s\ntag %s\n",
t.Target.String(), t.TargetType.Bytes(), t.Name); err != nil {
return err
}
if err = t.Tagger.Encode(w); err != nil {
return err
if !isZeroSignature(t.Tagger) {
if _, err = fmt.Fprint(w, "tagger "); err != nil {
return err
}
if err = t.Tagger.Encode(w); err != nil {
return err
}
if _, err = fmt.Fprint(w, "\n"); err != nil {
return err
}
}
if _, err = fmt.Fprint(w, "\n\n"); err != nil {
if _, err = fmt.Fprint(w, "\n"); err != nil {
return err
}
@@ -175,11 +214,12 @@ func (t *Tag) encode(o plumbing.EncodedObject, includeSig bool) (err error) {
return err
}
// Note that this is highly sensitive to what it sent along in the message.
// Message *always* needs to end with a newline, or else the message and the
// signature will be concatenated into a corrupt object. Since this is a
// lower-level method, we assume you know what you are doing and have already
// done the needful on the message in the caller.
// Note that this is highly sensitive to what is sent along in the
// message. Message *always* needs to end with a newline, or else the
// message and the trailing signature will be concatenated into a
// corrupt object. Since this is a lower-level method, we assume you
// know what you are doing and have already done the needful on the
// message in the caller.
if includeSig {
if _, err = fmt.Fprint(w, t.PGPSignature); err != nil {
return err
@@ -189,6 +229,10 @@ func (t *Tag) encode(o plumbing.EncodedObject, includeSig bool) (err error) {
return err
}
func isZeroSignature(s Signature) bool {
return s.Name == "" && s.Email == "" && s.When.IsZero()
}
// Commit returns the commit pointed to by the tag. If the tag points to a
// different type of object ErrUnsupportedObject will be returned.
func (t *Tag) Commit() (*Commit, error) {
@@ -256,7 +300,8 @@ func (t *Tag) String() string {
}
// Verify performs PGP verification of the tag with a provided armored
// keyring and returns openpgp.Entity associated with verifying key on success.
// keyring and returns openpgp.Entity associated with verifying key on
// success.
func (t *Tag) Verify(armoredKeyRing string) (*openpgp.Entity, error) {
keyRingReader := strings.NewReader(armoredKeyRing)
keyring, err := openpgp.ReadArmoredKeyRing(keyRingReader)
+237
View File
@@ -0,0 +1,237 @@
package object
import (
"bufio"
"bytes"
"fmt"
"io"
"github.com/go-git/go-git/v5/plumbing"
)
// tagScanner holds the working state of the tag decoder driven by the
// stateFn loop in (*Tag).Decode. Each tagState reads one or more lines
// from r, updates the in-progress *Tag and the scanner's bookkeeping,
// and returns the state that should run next (or nil to stop).
type tagScanner struct {
r *bufio.Reader
t *Tag
msgbuf bytes.Buffer
// pending holds a line that was read but the current state decided to
// hand back to the next state, paired with the io.EOF flag returned
// when the line was originally read.
pending []byte
pendingErr error
// First-occurrence tracking: once the corresponding canonical
// header has been decoded at its expected position, subsequent
// occurrences (or out-of-position lines) are silently dropped,
// matching the strict layout enforced by upstream's
// parse_tag_buffer (tag.c:130).
//
// gpgsig-sha256 is recognized and skipped without exposing a new field
// in v5.
sawObject, sawType, sawName, sawTagger bool
}
// tagState is one step of the decoder state machine. Each function reads
// the lines it needs, mutates *Tag via s.t, and returns the next state
// to run (or nil to terminate the loop).
type tagState func(*tagScanner) (tagState, error)
// readLine returns the next line from the buffer, transparently
// consuming any line that was previously pushed back by a state that
// decided not to handle it.
func (s *tagScanner) readLine() ([]byte, error) {
if s.pending != nil {
line, err := s.pending, s.pendingErr
s.pending, s.pendingErr = nil, nil
return line, err
}
return s.r.ReadBytes('\n')
}
// pushBack stashes an unconsumed line so the next state's readLine call
// sees it. Only one line can be pushed back at a time.
func (s *tagScanner) pushBack(line []byte, err error) {
s.pending = line
s.pendingErr = err
}
// scanTagObject requires the first line to be `object HASH`, mirroring
// upstream's strict parse_tag_buffer (tag.c:151-156). Anything else
// returns ErrMalformedTag.
func scanTagObject(s *tagScanner) (tagState, error) {
line, err := s.readLine()
if err != nil && err != io.EOF {
return nil, err
}
if len(line) == 0 || isBlankLine(line) {
return nil, fmt.Errorf("%w: missing object header", ErrMalformedTag)
}
key, data := splitHeader(line)
if key != "object" {
return nil, fmt.Errorf("%w: object header must be first", ErrMalformedTag)
}
h, herr := parseObjectIDHex(data, ErrMalformedTag, "object")
if herr != nil {
return nil, herr
}
s.t.Target = h
s.sawObject = true
if err == io.EOF {
return nil, nil
}
return scanTagType, nil
}
// scanTagType requires a `type` line immediately after the object header,
// mirroring upstream's parse_tag_buffer (tag.c:158-166).
func scanTagType(s *tagScanner) (tagState, error) {
line, err := s.readLine()
if err != nil && err != io.EOF {
return nil, err
}
if len(line) == 0 || isBlankLine(line) {
return nil, fmt.Errorf("%w: missing type header", ErrMalformedTag)
}
key, data := splitHeader(line)
if key != "type" {
return nil, fmt.Errorf("%w: type header must follow object", ErrMalformedTag)
}
ot, perr := plumbing.ParseObjectType(string(data))
if perr != nil {
return nil, perr
}
s.t.TargetType = ot
s.sawType = true
if err == io.EOF {
return nil, nil
}
return scanTagName, nil
}
// scanTagName requires a `tag` line immediately after the type header,
// mirroring upstream's parse_tag_buffer (tag.c:186-194).
func scanTagName(s *tagScanner) (tagState, error) {
line, err := s.readLine()
if err != nil && err != io.EOF {
return nil, err
}
if len(line) == 0 || isBlankLine(line) {
return nil, fmt.Errorf("%w: missing tag header", ErrMalformedTag)
}
key, data := splitHeader(line)
if key != "tag" {
return nil, fmt.Errorf("%w: tag header must follow type", ErrMalformedTag)
}
s.t.Name = string(data)
s.sawName = true
if err == io.EOF {
return nil, nil
}
return scanTagTagger, nil
}
// scanTagTagger accepts a `tagger` line at its canonical position. Any
// other header is pushed back for scanTagHeaders.
func scanTagTagger(s *tagScanner) (tagState, error) {
line, err := s.readLine()
if err != nil && err != io.EOF {
return nil, err
}
if len(line) == 0 {
return nil, nil
}
if isBlankLine(line) {
return scanTagMessage, nil
}
key, data := splitHeader(line)
if key == "tagger" {
s.t.Tagger.Decode(data)
s.sawTagger = true
if err == io.EOF {
return nil, nil
}
return scanTagHeaders, nil
}
s.pushBack(line, err)
return scanTagHeaders, nil
}
// scanTagHeaders dispatches one header line. gpgsig-sha256 hands off to
// scanTagSkipCont so the continuation block can be consumed; out-of-position
// canonical fields and unknown headers are silently dropped.
func scanTagHeaders(s *tagScanner) (tagState, error) {
line, err := s.readLine()
if err != nil && err != io.EOF {
return nil, err
}
if len(line) == 0 {
return nil, nil
}
if isBlankLine(line) {
return scanTagMessage, nil
}
key, _ := splitHeader(line)
next := scanTagHeaders
switch key {
case "object", "type", "tag", "tagger":
// Out-of-canonical-position duplicates are dropped, mirroring the
// strict ordering of upstream's parse_tag_buffer.
case headerpgp256:
next = scanTagSkipCont
default:
// Unknown header: silently dropped (the Tag struct does not
// expose ExtraHeaders).
}
if err == io.EOF {
return nil, nil
}
return next, nil
}
// scanTagSkipCont discards continuation lines for a header scanTagHeaders chose
// to drop. The first non-continuation line is pushed back so scanTagHeaders can
// dispatch it.
func scanTagSkipCont(s *tagScanner) (tagState, error) {
line, err := s.readLine()
if err != nil && err != io.EOF {
return nil, err
}
if len(line) > 0 && line[0] == ' ' {
if err == io.EOF {
return nil, nil
}
return scanTagSkipCont, nil
}
if len(line) > 0 {
s.pushBack(line, err)
}
return scanTagHeaders, nil
}
// scanTagMessage drains the remaining bytes into the message buffer.
// (*Tag).Decode then runs parseSignedBytes over those bytes to peel off
// the optional inline trailing PGP signature.
func scanTagMessage(s *tagScanner) (tagState, error) {
for {
line, err := s.readLine()
if err != nil && err != io.EOF {
return nil, err
}
if len(line) > 0 {
s.msgbuf.Write(line)
}
if err == io.EOF {
return nil, nil
}
}
}
+150 -33
View File
@@ -10,6 +10,7 @@ import (
"sort"
"strings"
"github.com/go-git/go-git/v5/internal/pathutil"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/filemode"
"github.com/go-git/go-git/v5/plumbing/storer"
@@ -29,6 +30,7 @@ var (
ErrDirectoryNotFound = errors.New("directory not found")
ErrEntryNotFound = errors.New("entry not found")
ErrEntriesNotSorted = errors.New("entries in tree are not sorted")
ErrMalformedTree = errors.New("malformed tree")
)
// Tree is basically like a directory - it references a bunch of other trees
@@ -37,9 +39,9 @@ type Tree struct {
Entries []TreeEntry
Hash plumbing.Hash
s storer.EncodedObjectStorer
m map[string]*TreeEntry
t map[string]*Tree // tree path cache
s storer.EncodedObjectStorer
t map[string]*Tree // tree path cache
entriesSorted bool
}
// GetTree gets a tree from an object storer and decodes it.
@@ -117,7 +119,16 @@ func (t *Tree) Tree(path string) (*Tree, error) {
}
// TreeEntryFile returns the *File for a given *TreeEntry.
//
// The entry's name is validated against pathutil.ValidTreePath for
// the same reason FindEntry validates: TreeEntryFile is a boundary
// where attacker-controlled tree data leaves the trusted store as a
// *File whose Name a caller can hand to filesystem ops.
func (t *Tree) TreeEntryFile(e *TreeEntry) (*File, error) {
if err := pathutil.ValidTreePath(e.Name); err != nil {
return nil, err
}
blob, err := GetBlob(t.s, e.Hash)
if err != nil {
return nil, err
@@ -127,7 +138,16 @@ func (t *Tree) TreeEntryFile(e *TreeEntry) (*File, error) {
}
// FindEntry search a TreeEntry in this tree or any subtree.
//
// The lookup path is validated against pathutil.ValidTreePath to
// prevent attacker-controlled tree contents from leaking past this
// boundary as `.git`-shaped or path-traversal-shaped names. Callers
// that legitimately need to look up unsafe paths should walk the
// tree manually.
func (t *Tree) FindEntry(path string) (*TreeEntry, error) {
if err := pathutil.ValidTreePath(path); err != nil {
return nil, err
}
if t.t == nil {
t.t = make(map[string]*Tree)
}
@@ -182,16 +202,43 @@ func (t *Tree) dir(baseName string) (*Tree, error) {
}
func (t *Tree) entry(baseName string) (*TreeEntry, error) {
if t.m == nil {
t.buildMap()
}
entry, ok := t.m[baseName]
if !ok {
if t.entriesSorted {
if entry := t.searchEntry(baseName); entry != nil {
return entry, nil
}
return nil, ErrEntryNotFound
}
return entry, nil
pastName := baseName + "/"
for i := range t.Entries {
entry := &t.Entries[i]
if entry.Name == baseName {
return entry, nil
}
if treeEntrySortName(entry) > pastName {
break
}
}
return nil, ErrEntryNotFound
}
func (t *Tree) searchEntry(baseName string) *TreeEntry {
if i := t.searchEntryIndex(baseName); i < len(t.Entries) && t.Entries[i].Name == baseName {
return &t.Entries[i]
}
if i := t.searchEntryIndex(baseName + "/"); i < len(t.Entries) && t.Entries[i].Name == baseName {
return &t.Entries[i]
}
return nil
}
func (t *Tree) searchEntryIndex(name string) int {
return sort.Search(len(t.Entries), func(i int) bool {
return treeEntrySortName(&t.Entries[i]) >= name
})
}
// Files returns a FileIter allowing to iterate over the Tree
@@ -212,20 +259,25 @@ func (t *Tree) Type() plumbing.ObjectType {
return plumbing.TreeObject
}
func (t *Tree) reset() {
storer := t.s
*t = Tree{s: storer}
}
// Decode transform an plumbing.EncodedObject into a Tree struct
func (t *Tree) Decode(o plumbing.EncodedObject) (err error) {
if o.Type() != plumbing.TreeObject {
return ErrUnsupportedObject
}
t.reset()
t.Hash = o.Hash()
// assume tree is sorted as a valid tree should always be sorted.
t.entriesSorted = true
if o.Size() == 0 {
return nil
}
t.Entries = nil
t.m = nil
reader, err := o.Reader()
if err != nil {
return err
@@ -235,10 +287,14 @@ func (t *Tree) Decode(o plumbing.EncodedObject) (err error) {
r := sync.GetBufioReader(reader)
defer sync.PutBufioReader(r)
var prevSortName string
for {
str, err := r.ReadString(' ')
if err != nil {
if err == io.EOF {
if len(str) != 0 {
return fmt.Errorf("%w: missing mode terminator", ErrMalformedTree)
}
break
}
@@ -248,25 +304,41 @@ func (t *Tree) Decode(o plumbing.EncodedObject) (err error) {
mode, err := filemode.New(str)
if err != nil {
return err
return fmt.Errorf("%w: malformed mode", ErrMalformedTree)
}
mode = canonicalTreeMode(mode)
name, err := r.ReadString(0)
if err != nil && err != io.EOF {
if err != nil {
if err == io.EOF {
return fmt.Errorf("%w: missing filename terminator", ErrMalformedTree)
}
return err
}
if len(name) == 1 {
return fmt.Errorf("%w: empty filename", ErrMalformedTree)
}
var hash plumbing.Hash
if _, err = io.ReadFull(r, hash[:]); err != nil {
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
return fmt.Errorf("%w: truncated object id", ErrMalformedTree)
}
return err
}
baseName := name[:len(name)-1]
t.Entries = append(t.Entries, TreeEntry{
entry := TreeEntry{
Hash: hash,
Mode: mode,
Name: baseName,
})
}
sortName := treeEntrySortName(&entry)
if len(t.Entries) != 0 && prevSortName > sortName {
t.entriesSorted = false
}
prevSortName = sortName
t.Entries = append(t.Entries, entry)
}
return nil
@@ -279,21 +351,37 @@ func (s TreeEntrySorter) Len() int {
}
func (s TreeEntrySorter) Less(i, j int) bool {
name1 := s[i].Name
name2 := s[j].Name
if s[i].Mode == filemode.Dir {
name1 += "/"
}
if s[j].Mode == filemode.Dir {
name2 += "/"
}
return name1 < name2
return treeEntrySortName(&s[i]) < treeEntrySortName(&s[j])
}
func (s TreeEntrySorter) Swap(i, j int) {
s[i], s[j] = s[j], s[i]
}
// Git compares tree entries as if directory names had a trailing slash.
func treeEntrySortName(e *TreeEntry) string {
if e.Mode == filemode.Dir {
return e.Name + "/"
}
return e.Name
}
func canonicalTreeMode(mode filemode.FileMode) filemode.FileMode {
switch mode & 0o170000 {
case 0o040000:
return filemode.Dir
case 0o100000:
if mode&0o111 != 0 {
return filemode.Executable
}
return filemode.Regular
case 0o120000:
return filemode.Symlink
default:
return filemode.Submodule
}
}
// Encode transforms a Tree into a plumbing.EncodedObject.
// The tree entries must be sorted by name.
func (t *Tree) Encode(o plumbing.EncodedObject) (err error) {
@@ -329,13 +417,6 @@ func (t *Tree) Encode(o plumbing.EncodedObject) (err error) {
return err
}
func (t *Tree) buildMap() {
t.m = make(map[string]*TreeEntry)
for i := 0; i < len(t.Entries); i++ {
t.m[t.Entries[i].Name] = &t.Entries[i]
}
}
// Diff returns a list of changes between this tree and the provided one
func (t *Tree) Diff(to *Tree) (Changes, error) {
return t.DiffContext(context.Background(), to)
@@ -393,6 +474,14 @@ type TreeWalker struct {
recursive bool
seen map[plumbing.Hash]bool
// skipPathValidation disables the pathutil.ValidTreePath check in Next.
// It is set by inspection-only callers (e.g. the revlist object walk)
// that never funnel entry names into the filesystem and must enumerate
// trees faithfully, including entries with names upstream Git accepts
// but that are unsafe to materialise (control characters, `.git`-shaped
// names).
skipPathValidation bool
s storer.EncodedObjectStorer
t *Tree
}
@@ -415,10 +504,32 @@ func NewTreeWalker(t *Tree, recursive bool, seen map[plumbing.Hash]bool) *TreeWa
}
}
// SkipPathValidation disables the pathutil.ValidTreePath check performed by
// Next, and must be called before the first Next.
//
// It is for inspection-only walks that never funnel an entry name into the
// filesystem — enumerating which objects exist, rather than materialising
// them — and that must therefore see the tree faithfully, including entries
// whose names upstream Git accepts but that are unsafe to check out. Callers
// that hand the returned name to filesystem or archive output must not use
// it; path safety for those is enforced at the materialisation boundaries
// (FindEntry, TreeEntryFile, archive, FileIter).
func (w *TreeWalker) SkipPathValidation() {
w.skipPathValidation = true
}
// Next returns the next object from the tree. Objects are returned in order
// and subtrees are included. After the last object has been returned further
// calls to Next() will return io.EOF.
//
// Each entry's name is validated against pathutil.ValidTreePath as it
// surfaces, so callers that funnel the returned name into filesystem
// or archive output can trust it is free of `.git`-shaped components,
// HFS+/NTFS variants, Windows reserved names, and traversal sequences.
// A malformed entry stops the walk with the validator's error;
// inspection-only callers that need to enumerate raw, unvalidated
// names can read Tree.Entries directly or call SkipPathValidation.
//
// In the current implementation any objects which cannot be found in the
// underlying repository will be skipped automatically. It is possible that this
// may change in future versions.
@@ -455,6 +566,12 @@ func (w *TreeWalker) Next() (name string, entry TreeEntry, err error) {
continue
}
if !w.skipPathValidation {
if err := pathutil.ValidTreePath(entry.Name); err != nil {
return name, entry, err
}
}
if entry.Mode == filemode.Dir {
obj, err = GetTree(w.s, entry.Hash)
}
+6
View File
@@ -106,6 +106,12 @@ func transformChildren(t *Tree) ([]noder.Noder, error) {
ret := make([]noder.Noder, 0, len(t.Entries))
walker := NewTreeWalker(t, false, nil) // don't recurse
// The diff walk is read-only and never materialises entry names into the
// filesystem, so it must enumerate the tree faithfully — including entries
// with names that are unsafe to check out but valid per upstream Git (e.g.
// control characters). Path safety is enforced at materialisation
// boundaries (FindEntry, TreeEntryFile, archive, FileIter), not here.
walker.SkipPathValidation()
// don't defer walker.Close() for efficiency reasons.
for {
_, e, err = walker.Next()
+42
View File
@@ -110,6 +110,48 @@ func (r ReferenceName) IsTag() bool {
return strings.HasPrefix(string(r), refTagPrefix)
}
// IsSafe reports whether the reference name can be safely turned into a path
// under the .git directory, mirroring Git's refname_is_safe (refs.c). A name
// is safe when it is either:
//
// - under "refs/", non-empty after the prefix, containing no backslash and
// no empty, "." or ".." path component (so it cannot escape the refs/
// sub-tree, or alias another name, once turned into a path); or
// - a one-level pseudo-ref whose spelling is restricted to [A-Z_]
// (e.g. HEAD, ORIG_HEAD, FETCH_HEAD).
//
// Everything else — a lowercase or mixed one-level name such as "config" or
// "index", an absolute or drive-prefixed name, or a refs/ name that escapes —
// is unsafe, because it could resolve onto unrelated repository metadata.
func (r ReferenceName) IsSafe() bool {
s := string(r)
if s == "" {
return false
}
if rest, ok := strings.CutPrefix(s, refPrefix); ok {
// '\' is a path separator on Windows, so a refs/ name containing one
// could escape the sub-tree or alias another name once turned into a
// path; reject it outright (check_refname_format forbids '\' too).
if rest == "" || strings.Contains(rest, "\\") {
return false
}
for part := range strings.SplitSeq(rest, "/") {
if part == "" || part == "." || part == ".." {
return false
}
}
return true
}
for i := 0; i < len(s); i++ {
if (s[i] < 'A' || s[i] > 'Z') && s[i] != '_' {
return false
}
}
return true
}
func (r ReferenceName) String() string {
return string(r)
}
+7
View File
@@ -187,6 +187,13 @@ func iterateCommitTrees(
cb(tree.Hash)
treeWalker := object.NewTreeWalker(tree, true, seen)
// This walk only enumerates which objects are reachable, to decide what
// to send; it never materialises an entry name into the filesystem. It
// must therefore enumerate the tree faithfully, including entries with
// names that are unsafe to check out but valid per upstream Git (e.g.
// control characters). Path safety is enforced at materialisation
// boundaries (FindEntry, TreeEntryFile, archive, FileIter), not here.
treeWalker.SkipPathValidation()
for {
_, e, err := treeWalker.Next()
+506 -23
View File
@@ -6,9 +6,12 @@ import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/netip"
"net/url"
"reflect"
"strconv"
@@ -24,6 +27,33 @@ import (
"github.com/go-git/go-git/v5/utils/ioutil"
)
type contextKey int
const initialRequestKey contextKey = iota
// RedirectPolicy controls how the HTTP transport follows redirects.
//
// The values mirror Git's http.followRedirects config:
// "true" follows redirects for all requests, "false" treats redirects as
// errors, and "initial" follows redirects only for the initial
// /info/refs discovery request. The zero value defaults to "initial".
type RedirectPolicy string
const (
FollowInitialRedirects RedirectPolicy = "initial"
FollowRedirects RedirectPolicy = "true"
NoFollowRedirects RedirectPolicy = "false"
)
func withInitialRequest(ctx context.Context) context.Context {
return context.WithValue(ctx, initialRequestKey, true)
}
func isInitialRequest(req *http.Request) bool {
v, _ := req.Context().Value(initialRequestKey).(bool)
return v
}
// it requires a bytes.Buffer, because we need to know the length
func applyHeadersToRequest(req *http.Request, content *bytes.Buffer, host string, requestType string) {
req.Header.Add("User-Agent", capability.DefaultAgent())
@@ -47,19 +77,22 @@ func advertisedReferences(ctx context.Context, s *session, serviceName string) (
s.endpoint.String(), infoRefsPath, serviceName,
)
req, err := http.NewRequest(http.MethodGet, url, nil)
req, err := newRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
s.ApplyAuthToRequest(req)
applyHeadersToRequest(req, nil, s.endpoint.Host, serviceName)
res, err := s.client.Do(req.WithContext(ctx))
res, err := s.client.Do(req.WithContext(withInitialRequest(ctx)))
if err != nil {
return nil, err
}
s.ModifyEndpointIfRedirect(res)
if err := s.ModifyEndpointIfRedirect(res); err != nil {
_ = res.Body.Close()
return nil, err
}
defer ioutil.CheckClose(res.Body, &err)
if err = NewErr(res); err != nil {
@@ -96,6 +129,7 @@ type client struct {
client *http.Client
transports *lru.Cache
mutex sync.RWMutex
follow RedirectPolicy
}
// ClientOptions holds user configurable options for the client.
@@ -106,6 +140,11 @@ type ClientOptions struct {
// size, will result in the least recently used transport getting deleted
// before the provided transport is added to the cache.
CacheMaxEntries int
// RedirectPolicy controls redirect handling. Supported values are
// "true", "false", and "initial". The zero value defaults to
// "initial", matching Git's http.followRedirects default.
RedirectPolicy RedirectPolicy
}
var (
@@ -142,6 +181,24 @@ func NewClient(c *http.Client) transport.Transport {
// and other custom options specific to the client.
// If the net/http client is nil or empty, it will use a net/http client configured
// with http.DefaultTransport.
//
// Credentials this client adds where the transport cannot see them are not
// subject to the redirect stripping described on AuthMethod: a RoundTripper
// injects after the hop is decided, and Client.Jar is consulted after
// CheckRedirect, so a domain cookie still follows a redirect to a subdomain
// the transport counts as another origin. Apply them in an AuthMethod instead
// if that is not wanted. A CheckRedirect hook set on this client runs
// alongside the transport's own, but any header it adds when a redirect
// leaves the repository's origin is discarded the same way.
//
// A RoundTripper is therefore also how to authenticate to a new origin a
// redirect has moved the repository to: match on the request URL and inject
// the credential only for that origin, so it is not sent anywhere else. The
// transport keeps its own CheckRedirect on the copy it makes of this client,
// so the policy and the origin checks still apply.
//
// None of this applies to a RoundTripper that follows redirects itself:
// CheckRedirect is not consulted then, so no stripping happens at all.
func NewClientWithOptions(c *http.Client, opts *ClientOptions) transport.Transport {
if c == nil {
c = &http.Client{
@@ -150,12 +207,16 @@ func NewClientWithOptions(c *http.Client, opts *ClientOptions) transport.Transpo
}
cl := &client{
client: c,
follow: FollowInitialRedirects,
}
if opts != nil {
if opts.CacheMaxEntries > 0 {
cl.transports = lru.New(opts.CacheMaxEntries)
}
if opts.RedirectPolicy != "" {
cl.follow = opts.RedirectPolicy
}
}
return cl
}
@@ -289,14 +350,9 @@ func newSession(c *client, ep *transport.Endpoint, auth transport.AuthMethod) (*
}
}
httpClient = &http.Client{
Transport: transport,
CheckRedirect: c.client.CheckRedirect,
Jar: c.client.Jar,
Timeout: c.client.Timeout,
}
httpClient = c.cloneHTTPClient(transport)
} else {
httpClient = c.client
httpClient = c.cloneHTTPClient(c.client.Transport)
}
s := &session{
@@ -324,30 +380,446 @@ func (s *session) ApplyAuthToRequest(req *http.Request) {
s.auth.SetAuth(req)
}
func (s *session) ModifyEndpointIfRedirect(res *http.Response) {
func (s *session) ModifyEndpointIfRedirect(res *http.Response) error {
if res.Request == nil {
return
return nil
}
if s.endpoint == nil {
return fmt.Errorf("http redirect: nil endpoint")
}
r := res.Request
if !strings.HasSuffix(r.URL.Path, infoRefsPath) {
return
return fmt.Errorf("http redirect: target %q does not end with %s", r.URL.Path, infoRefsPath)
}
// A scheme is case-insensitive per RFC 3986, and checkRedirect folds case
// when it reads the same hop, so fold here too rather than reject a
// spelling that check let through. url.Parse and transport.NewEndpoint
// both lowercase what they parse, so only a hand-built URL or Endpoint
// arrives uppercased. The folded form is what gets stored below, so every
// later request built from the endpoint carries the canonical spelling.
scheme := strings.ToLower(r.URL.Scheme)
if scheme != "http" && scheme != "https" {
return fmt.Errorf("http redirect: unsupported scheme %q", r.URL.Scheme)
}
// schemeUpgrade rather than an inline comparison, so the one cross-scheme
// change go-git permits has a single definition shared with
// credentialsMayFollow.
if !strings.EqualFold(scheme, s.endpoint.Protocol) && !schemeUpgrade(s.endpoint.Protocol, scheme) {
return fmt.Errorf("http redirect: changes scheme from %q to %q", s.endpoint.Protocol, r.URL.Scheme)
}
h, p, err := net.SplitHostPort(r.URL.Host)
host := endpointHost(r.URL.Hostname())
port, err := endpointPort(r.URL.Port())
if err != nil {
h = r.URL.Host
return err
}
if p != "" {
port, err := strconv.Atoi(p)
if err == nil {
s.endpoint.Port = port
// The session stores the endpoint and re-applies its credentials on every
// later request, so clear them once the redirect has left the origin they
// were issued for. This uses the same predicate as stripCredentials, so
// both halves share one definition of an origin.
//
// The two are deliberately asymmetric in one respect: stripCredentials is
// sticky over the whole chain, so an origin -> evil -> origin redirect
// leaves the discovery GET's later hops unauthenticated even though the
// chain returned home. This compares the endpoint only against the final
// URL, so the same round trip leaves the session authenticated. That is
// not a leak - the final URL's origin is the original one - but it means
// such a chain can make the discovery GET anonymous while the session's
// POSTs are authenticated, which can surface as a confusing 401.
if !credentialsMayFollow(endpointURL(s.endpoint), r.URL) {
s.endpoint.User = ""
s.endpoint.Password = ""
s.auth = nil
}
s.endpoint.Host = host
s.endpoint.Port = port
s.endpoint.Protocol = scheme
s.endpoint.Path = r.URL.Path[:len(r.URL.Path)-len(infoRefsPath)]
return nil
}
func endpointHost(host string) string {
if strings.Contains(host, ":") {
return "[" + host + "]"
}
return host
}
func endpointPort(port string) (int, error) {
if port == "" {
return 0, nil
}
parsed, err := strconv.Atoi(port)
if err != nil {
return 0, fmt.Errorf("http redirect: invalid port %q", port)
}
return parsed, nil
}
// schemeUpgrade reports whether the scheme transition from one URL to another
// is the one cross-scheme change go-git permits: a plain-http origin upgrading
// to https. It strictly improves confidentiality and is how servers steer
// clients off cleartext.
//
// Permitting it at all is a deliberate deviation: curl, git and the Fetch
// standard all count scheme as part of host identity and drop credentials on
// the upgrade. Auth is sent pre-emptively here, so an http origin has already
// spent its credential in cleartext on the first request and refusing the
// upgrade would break the clone without unspending it. The host is unchanged,
// where an on-path attacker needs a valid certificate to receive anything.
func schemeUpgrade(from, to string) bool {
return strings.EqualFold(from, "http") && strings.EqualFold(to, "https")
}
// canonicalHost returns u's hostname in the form origins are compared in.
//
// An address literal is normalised by netip, so the many spellings of one
// address are one origin. Two literals are the same origin exactly when netip
// parses them to the same Addr, which is also how the WHATWG URL Standard
// compares hosts. An IPv4-mapped literal is deliberately not unmapped onto
// the IPv4 it dials: reaching the same endpoint is not the same authority,
// since net/http sends the literal as written in Host and a server may route
// the two spellings to different virtual hosts.
//
// netip also keeps a scope zone verbatim, which is what origin comparison
// needs: net resolves a zone to an interface by exact name, so folding %eth0
// onto %ETH0 would call two hosts the same origin that net dials down
// different interfaces.
//
// A registered name is ASCII-lowercased. That fold is the only liberty taken;
// every other difference in spelling is a different origin.
//
// A trailing root dot is one such difference and is kept, for the same reason
// as the IPv4-mapped literal: curl and the WHATWG URL Standard both hold
// "example.com." and "example.com" to be distinct hosts, and although
// crypto/tls and crypto/x509 fold the dot when they authenticate the peer,
// net/http sends the name as written in Host.
//
// The fold is deliberately ASCII-only. strings.ToLower and strings.EqualFold
// apply Unicode case mapping, which folds U+03C2 onto U+03C3 and so would
// call two hosts the same origin when they resolve to different servers. An
// ASCII-only fold cannot merge two names DNS keeps apart.
//
// No IDNA mapping is applied either, so a unicode hostname is a different
// origin from the punycode encoding of it, and from another Unicode case of
// itself, even though all three reach the same server. Mapping through
// golang.org/x/net/idna would join them, but it can only widen this equality,
// never narrow it, so leaving it out can cost a credential across such a
// redirect and cannot forward one. Against that cost, go-git pins x/net while
// net/http uses the copy vendored into the toolchain: the two are versioned
// separately, so a release that moves the Unicode tables under one and not
// the other would have this merge origins net/http still dials apart. That is
// the failure this comparison exists to prevent, and comparing bytes has no
// such mode.
func canonicalHost(u *url.URL) string {
host := u.Hostname()
if addr, err := netip.ParseAddr(host); err == nil {
return addr.String()
}
b := []byte(host)
for i := range b {
if b[i] >= 'A' && b[i] <= 'Z' {
b[i] += 'a' - 'A'
}
}
s.endpoint.Host = h
return string(b)
}
s.endpoint.Protocol = r.URL.Scheme
s.endpoint.Path = r.URL.Path[:len(r.URL.Path)-len(infoRefsPath)]
// effectivePort returns u's port as the connection will use it: the scheme's
// well-known port when the URL does not spell one out, and without leading
// zeroes, so "https://x", "https://x:443" and "https://x:0443" all agree.
func effectivePort(u *url.URL) string {
port := u.Port()
if port == "" {
switch strings.ToLower(u.Scheme) {
case "http":
return "80"
case "https":
return "443"
default:
return ""
}
}
if trimmed := strings.TrimLeft(port, "0"); trimmed != "" {
return trimmed
}
return "0"
}
// credentialsMayFollow reports whether credentials issued for one URL may be
// sent to another.
//
// The relation is deliberately asymmetric: scheme, host and effective port
// must all match, except that a plain http origin may upgrade to https on the
// same host (see schemeUpgrade). That exception is confined to the two
// default ports: 80 to 443 is the upgrade servers actually steer clients
// through, whereas a non-default port carries no such convention, so
// http://host:8080 to https://host:8443 is a move to another origin like any
// other port change.
//
// Host matching is exact. Unlike Go's http.Client, which forwards credentials
// from a host to any subdomain of it, a subdomain is a different origin here —
// matching canonical git and libcurl.
func credentialsMayFollow(from, to *url.URL) bool {
if canonicalHost(from) != canonicalHost(to) {
return false
}
if strings.EqualFold(from.Scheme, to.Scheme) {
return effectivePort(from) == effectivePort(to)
}
return schemeUpgrade(from.Scheme, to.Scheme) &&
effectivePort(from) == "80" && effectivePort(to) == "443"
}
// endpointURL renders an Endpoint's origin as a URL, so that the session's
// credential clearing and the per-hop stripping share one definition of an
// origin and cannot drift apart.
func endpointURL(ep *transport.Endpoint) *url.URL {
host := strings.Trim(ep.Host, "[]")
if ep.Port != 0 {
host = net.JoinHostPort(host, strconv.Itoa(ep.Port))
} else if strings.Contains(host, ":") {
host = "[" + host + "]"
}
return &url.URL{Scheme: ep.Protocol, Host: host}
}
func (c *client) cloneHTTPClient(transport http.RoundTripper) *http.Client {
return &http.Client{
Transport: transport,
CheckRedirect: wrapCheckRedirect(c.follow, c.client.CheckRedirect),
Jar: c.client.Jar,
Timeout: c.client.Timeout,
}
}
func wrapCheckRedirect(policy RedirectPolicy, next func(*http.Request, []*http.Request) error) func(*http.Request, []*http.Request) error {
return func(req *http.Request, via []*http.Request) error {
if err := checkRedirect(req, via, policy); err != nil {
return err
}
// Strip before the caller's hook so it observes what will actually
// be sent, and again afterwards so a hook of the common "preserve
// my headers across redirects" shape - which copies from via[0],
// the original unsanitized request - cannot reinstate them.
// Carrying credentials across an origin boundary is deliberately
// unsupported.
stripCredentials(req, via)
if next != nil {
if err := next(req, via); err != nil {
return err
}
}
stripCredentials(req, via)
return nil
}
}
// safeHeaders lists the headers go-git sets itself, none of which can carry a
// caller credential. stripCredentials keeps only these when a redirect leaves
// the credential's origin. Adding a name here makes it forwardable across an
// origin boundary - do not add anything a caller can put a secret in.
//
// This narrows rather than eliminates the exposure: an AuthMethod that writes
// a credential into one of these names directly - for example
// Header.Set("User-Agent", "token "+secret) - still survives a cross-origin
// redirect. Such a value is also sent to the origin and to any proxy in path,
// so it should not be placed there whether or not a redirect follows.
var safeHeaders = map[string]struct{}{
"User-Agent": {},
"Host": {},
"Accept": {},
"Content-Type": {},
"Content-Length": {},
}
func filterHeaders(h http.Header) http.Header {
filtered := make(http.Header)
for key, values := range h {
if _, ok := safeHeaders[http.CanonicalHeaderKey(key)]; ok {
filtered[key] = values
}
}
return filtered
}
// stripCredentials removes credentials from req once the redirect chain has
// left the origin of the original, credential-bearing request.
//
// CheckRedirect is the only hook that runs while a redirected request's
// headers are still mutable: http.Client.Do performs the entire chain
// internally, so anything the transport does after Do returns - including
// ModifyEndpointIfRedirect - is too late for the hops themselves.
//
// Two subtleties:
//
// - net/http rebuilds every redirect request from the original request's
// headers before calling this, so a header removed at one hop reappears
// at the next. The decision is therefore recomputed per hop.
// - The decision is sticky: once the chain has left the origin, credentials
// stay gone even if a later hop returns to it. Stickiness is derived from
// via rather than stored, because this closure is shared across a
// session's requests.
//
// Stripping keeps only the headers go-git sets itself (safeHeaders). An
// allowlist is used rather than a list of credential header names because
// caller credentials arrive under names that cannot be enumerated -
// PRIVATE-TOKEN, X-Api-Key, gateway headers - which is exactly what
// net/http's fixed list of sensitive header names gets wrong. It is also
// immune to header-name canonicalisation: an AuthMethod that writes a raw map
// key is still removed.
func stripCredentials(req *http.Request, via []*http.Request) {
if len(via) == 0 {
return
}
// net/http sets a URL on every request it builds, and req.URL is non-nil
// by construction: checkRedirect dereferences req.URL.Scheme on each path
// that returns nil, so it runs first or not at all. This nil check and
// the two in crossedOrigin are defensive, against a synthetic caller.
// Each treats a URL it cannot read as an origin crossing; removing one
// panics in canonicalHost rather than leaking.
if origin := via[0].URL; origin != nil && !crossedOrigin(origin, req, via) {
return
}
req.Header = filterHeaders(req.Header)
if req.URL != nil {
req.URL.User = nil
}
}
// crossedOrigin reports whether any hop so far, including the pending one, has
// left origin.
func crossedOrigin(origin *url.URL, req *http.Request, via []*http.Request) bool {
if req.URL == nil || !credentialsMayFollow(origin, req.URL) {
return true
}
for _, prev := range via[1:] {
if prev.URL == nil || !credentialsMayFollow(origin, prev.URL) {
return true
}
}
return false
}
// redactedURL returns the string form of u with the userinfo password
// replaced, for use in error messages. (*url.URL).String() renders the
// password verbatim, and request URLs are built from the endpoint, which
// carries whatever credentials the caller put in the clone URL.
func redactedURL(u *url.URL) string {
if u == nil {
return ""
}
if u.User == nil {
return u.String()
}
if _, hasPassword := u.User.Password(); !hasPassword {
return u.String()
}
redacted := *u
redacted.User = url.UserPassword(u.User.Username(), "REDACTED")
return redacted.String()
}
// redactedRawURL is redactedURL for a string that may not parse. Request URLs
// are assembled from Endpoint.String(), which re-emits Endpoint.Path raw, so a
// path holding a stray percent produces a string url.Parse rejects. url.Parse
// reports the input verbatim and applies no redaction of its own; only
// http.Client strips a password, and only from errors it raises itself.
func redactedRawURL(raw string) string {
i := strings.Index(raw, "://")
if i < 0 {
return raw
}
authority := raw[i+3:]
if end := strings.IndexByte(authority, '/'); end >= 0 {
authority = authority[:end]
}
at := strings.LastIndexByte(authority, '@')
if at < 0 {
return raw
}
colon := strings.IndexByte(authority[:at], ':')
if colon < 0 {
// Username only, left alone, as redactedURL leaves it.
return raw
}
return raw[:i+3+colon+1] + "REDACTED" + raw[i+3+at:]
}
// newRequest wraps http.NewRequest so that a URL it cannot parse does not
// reach the caller with the endpoint's credentials still in it.
func newRequest(method, rawURL string, body io.Reader) (*http.Request, error) {
req, err := http.NewRequest(method, rawURL, body)
if err != nil {
var uerr *url.Error
if errors.As(err, &uerr) {
uerr.URL = redactedRawURL(uerr.URL)
}
return nil, err
}
return req, nil
}
func checkRedirect(req *http.Request, via []*http.Request, policy RedirectPolicy) error {
// CheckRedirect is the only hook that runs before the next hop leaves
// the client. ModifyEndpointIfRedirect inspects the chain after
// client.Do has followed all of it, so a hop rejected there has already
// carried the request headers to its server.
//
// The wording matches the message ModifyEndpointIfRedirect produces for
// the same hop, which this check reaches first.
if len(via) != 0 {
// A hop whose scheme cannot be read cannot be shown not to have been
// https, so it is assumed to have been, and a cleartext target is
// rejected. Skipping the comparison instead would let an
// undeterminable hop turn the check off, which is the wrong default
// for a credential control; crossedOrigin fails closed the same way.
// An empty scheme is as unreadable as a nil URL, so both take the
// assumed-https default.
//
// The comparisons fold case because a scheme is case-insensitive per
// RFC 3986. net/url lowercases what it parses, so only a hand-built
// URL reaches here uppercased - the same synthetic caller the nil
// checks guard against - and for that caller "HTTPS" to "http" is
// still a downgrade.
prevScheme := "https"
if prev := via[len(via)-1]; prev.URL != nil && prev.URL.Scheme != "" {
prevScheme = prev.URL.Scheme
}
if strings.EqualFold(prevScheme, "https") && strings.EqualFold(req.URL.Scheme, "http") {
return fmt.Errorf("http redirect: changes scheme from %q to %q: %s",
prevScheme, req.URL.Scheme, redactedURL(req.URL))
}
}
switch policy {
case FollowRedirects:
case NoFollowRedirects:
return fmt.Errorf("http redirect: redirects disabled to %s", redactedURL(req.URL))
case "", FollowInitialRedirects:
if !isInitialRequest(req) {
return fmt.Errorf("http redirect: redirect on non-initial request to %s", redactedURL(req.URL))
}
default:
return fmt.Errorf("http redirect: invalid redirect policy %q", policy)
}
// Folded for the same reason as the guard above: a scheme is
// case-insensitive per RFC 3986, so a spelling the downgrade check read
// as https must not be rejected here as a scheme go-git cannot speak.
if !strings.EqualFold(req.URL.Scheme, "http") && !strings.EqualFold(req.URL.Scheme, "https") {
return fmt.Errorf("http redirect: unsupported scheme %q", req.URL.Scheme)
}
if len(via) >= 10 {
return fmt.Errorf("http redirect: too many redirects")
}
return nil
}
func (*session) Close() error {
@@ -355,6 +827,17 @@ func (*session) Close() error {
}
// AuthMethod is concrete implementation of common.AuthMethod for HTTP services
//
// Headers SetAuth adds are dropped when a redirect leaves the repository's
// origin: only the headers the transport sets itself survive that boundary.
// This applies to non-credential headers too, so an implementation that adds a
// trace or tenant header loses it on such a hop.
//
// That filter matches header names, not values. An implementation that writes
// a credential into a name the transport also uses - User-Agent, Host, Accept,
// Content-Type, Content-Length - has that value carried across the boundary
// with the name. Such a credential is also sent to the origin and to any proxy
// in path, so it should not be placed there whether or not a redirect follows.
type AuthMethod interface {
transport.AuthMethod
SetAuth(r *http.Request)
@@ -472,6 +955,6 @@ func (e *Err) StatusCode() int {
func (e *Err) Error() string {
return fmt.Sprintf("unexpected requesting %q status code: %d",
e.Response.Request.URL, e.Response.StatusCode,
redactedURL(e.Response.Request.URL), e.Response.StatusCode,
)
}
@@ -88,7 +88,7 @@ func (s *rpSession) doRequest(
body = content
}
req, err := http.NewRequest(method, url, body)
req, err := newRequest(method, url, body)
if err != nil {
return nil, plumbing.NewPermanentError(err)
}
+1 -1
View File
@@ -86,7 +86,7 @@ func (s *upSession) doRequest(
body = content
}
req, err := http.NewRequest(method, url, body)
req, err := newRequest(method, url, body)
if err != nil {
return nil, plumbing.NewPermanentError(err)
}
+33 -1
View File
@@ -252,7 +252,39 @@ func (c *command) setAuthFromEndpoint() error {
}
func endpointToCommand(cmd string, ep *transport.Endpoint) string {
return fmt.Sprintf("%s '%s'", cmd, ep.Path)
var b strings.Builder
b.WriteString(cmd)
b.WriteByte(' ')
writeShellQuote(&b, ep.Path)
return b.String()
}
// writeShellQuote writes s to b, wrapped in single quotes with
// embedded single quotes and exclamation marks escaped using the
// POSIX close-escape-reopen idiom:
//
// ' becomes '\''
// ! becomes '\!'
//
// It is a direct port of canonical Git's sq_quote_buf (quote.c).
// The bang escape keeps the result safe when re-evaluated under
// csh-derived shells that perform history expansion. The output is
// safe to pass as a single argument through any POSIX shell and
// round-trips through git-shell's sq_dequote_to_argv.
func writeShellQuote(b *strings.Builder, s string) {
b.Grow(len(s) + 2)
b.WriteByte('\'')
for i := 0; i < len(s); i++ {
c := s[i]
if c == '\'' || c == '!' {
b.WriteString(`'\`)
b.WriteByte(c)
b.WriteByte('\'')
continue
}
b.WriteByte(c)
}
b.WriteByte('\'')
}
func overrideConfig(overrides *ssh.ClientConfig, c *ssh.ClientConfig) {
+13 -1
View File
@@ -433,6 +433,7 @@ func dotGitCommonDirectory(fs billy.Filesystem) (commonDir billy.Filesystem, err
if err != nil {
return nil, err
}
defer ioutil.CheckClose(f, &err)
b, err := io.ReadAll(f)
if err != nil {
@@ -1530,7 +1531,18 @@ func (r *Repository) Worktree() (*Worktree, error) {
return nil, ErrIsBareRepository
}
return &Worktree{r: r, Filesystem: r.wt}, nil
protectNTFS := defaultProtectNTFS()
protectHFS := defaultProtectHFS()
if cfg, err := r.Config(); err == nil {
if cfg.Core.ProtectNTFS.IsSet() {
protectNTFS = cfg.Core.ProtectNTFS.IsTrue()
}
if cfg.Core.ProtectHFS.IsSet() {
protectHFS = cfg.Core.ProtectHFS.IsTrue()
}
}
return &Worktree{r: r, Filesystem: newWorktreeFilesystem(r.wt, protectNTFS, protectHFS)}, nil
}
func expand_ref(s storer.ReferenceStorer, ref plumbing.ReferenceName) (*plumbing.Reference, error) {
+74 -2
View File
@@ -16,6 +16,7 @@ import (
"strings"
"time"
"github.com/go-git/go-git/v5/internal/pathutil"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/hash"
"github.com/go-git/go-git/v5/storage"
@@ -75,8 +76,56 @@ var (
// ErrEmptyRefFile is returned when a reference file is attempted to be read,
// but the file is empty
ErrEmptyRefFile = errors.New("ref file is empty")
// ErrModuleNameEscape is returned when a submodule name would
// resolve outside the modules/ subtree, mirroring canonical Git's
// "ignoring suspicious submodule name" defence.
ErrModuleNameEscape = errors.New("submodule name escapes modules/ directory")
// ErrReferenceNameEscape is returned when a reference name would
// resolve outside its reference sub-tree once turned into a path
// under the .git directory (e.g. a name with a ".." component).
ErrReferenceNameEscape = errors.New("reference name escapes the reference storage")
)
// isPathSep reports whether r is a path separator in reference names.
// It treats both '/' and '\\' as separators to harden against cross-OS paths.
func isPathSep(r rune) bool { return r == '/' || r == '\\' }
// validReferenceName rejects reference names that cannot be safely turned into
// a path under the .git directory. A loose reference is stored verbatim at
// ".git/<name>", so a crafted name — for instance one advertised by a malicious
// remote — could climb out of its reference sub-tree and read, overwrite, or
// delete unrelated metadata such as .git/config.
//
// The storage-safety gate is plumbing.ReferenceName.IsSafe, mirroring Git's
// refname_is_safe: a name must be under refs/ without escaping it, or be a
// [A-Z_] pseudo-ref. This alone rejects absolute, drive-prefixed, escaping and
// single-level metadata names. On top of it, this adds filesystem-specific
// hardening that IsSafe's literal check does not cover: control characters, and
// components a case-insensitive/NTFS/HFS+ filesystem would fold back to "." or
// ".." (trailing dots/spaces, Alternate Data Streams, ignorable Unicode code
// points), delegated to pathutil.IsHFSDot and pathutil.IsNTFSDot with "." as
// the needle — as validSubmoduleName does — and run regardless of host OS.
func validReferenceName(name plumbing.ReferenceName) error {
if !name.IsSafe() {
return fmt.Errorf("%w: %q is not under refs/ nor a valid pseudo-ref", ErrReferenceNameEscape, string(name))
}
s := string(name)
for i := 0; i < len(s); i++ {
if s[i] < 0x20 || s[i] == 0x7f {
return fmt.Errorf("%w: %q", ErrReferenceNameEscape, s)
}
}
for _, part := range strings.FieldsFunc(s, isPathSep) {
// IsNTFSDot/IsHFSDot with a "." needle match ".." and its disguises
// but not a bare ".", so reject that component explicitly too.
if part == "." || pathutil.IsHFSDot(part, ".") || pathutil.IsNTFSDot(part, ".", "") {
return fmt.Errorf("%w: %q", ErrReferenceNameEscape, s)
}
}
return nil
}
// Options holds configuration for the storage.
type Options struct {
// ExclusiveAccess means that the filesystem is not modified externally
@@ -702,6 +751,10 @@ func (d *DotGit) checkReferenceAndTruncate(f billy.File, old *plumbing.Reference
}
func (d *DotGit) SetRef(r, old *plumbing.Reference) error {
if err := validReferenceName(r.Name()); err != nil {
return err
}
var content string
switch r.Type() {
case plumbing.SymbolicReference:
@@ -737,6 +790,10 @@ func (d *DotGit) Refs() ([]*plumbing.Reference, error) {
// Ref returns the reference for a given reference name.
func (d *DotGit) Ref(name plumbing.ReferenceName) (*plumbing.Reference, error) {
if err := validReferenceName(name); err != nil {
return nil, err
}
ref, err := d.readReferenceFile(".", name.String())
if err == nil {
return ref, nil
@@ -800,6 +857,10 @@ func (d *DotGit) packedRef(name plumbing.ReferenceName) (*plumbing.Reference, er
// RemoveRef removes a reference by name.
func (d *DotGit) RemoveRef(name plumbing.ReferenceName) error {
if err := validReferenceName(name); err != nil {
return err
}
path := d.fs.Join(".", name.String())
_, err := d.fs.Stat(path)
if err == nil {
@@ -1127,9 +1188,20 @@ func (d *DotGit) PackRefs() (err error) {
return nil
}
// Module return a billy.Filesystem pointing to the module folder
// Module returns a billy.Filesystem pointing to the module folder.
//
// As a defence in depth against submodule name path traversal,
// refuse names whose joined path leaves the modules/ subtree once
// cleaned. The config-layer parser also validates submodule names,
// but Module may be reached from any caller that constructs a
// Submodule struct programmatically and so bypasses the parser.
func (d *DotGit) Module(name string) (billy.Filesystem, error) {
return d.fs.Chroot(d.fs.Join(modulePath, name))
p := d.fs.Join(modulePath, name)
cleaned := path.Clean(filepath.ToSlash(p))
if cleaned != modulePath && !strings.HasPrefix(cleaned, modulePath+"/") {
return nil, ErrModuleNameEscape
}
return d.fs.Chroot(p)
}
func (d *DotGit) AddAlternate(remote string) error {
+74 -8
View File
@@ -6,9 +6,12 @@ import (
"errors"
"fmt"
"path"
"path/filepath"
"github.com/go-git/go-billy/v5"
"github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/internal/pathutil"
giturl "github.com/go-git/go-git/v5/internal/url"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/format/index"
"github.com/go-git/go-git/v5/plumbing/transport"
@@ -119,6 +122,16 @@ func (s *Submodule) Repository() (*Repository, error) {
exists = true
}
// s.c.Path is sourced from the worktree's .gitmodules and is
// therefore tree-controlled. Apply the strict tree-path validator
// before chroot — the wrapper's tolerant validPath would let a
// final-position .git component through (e.g. "submodule/.git"),
// which a malicious .gitmodules could use to chroot the submodule
// worktree into the repository's actual .git directory.
if err := pathutil.ValidTreePath(s.c.Path); err != nil {
return nil, err
}
var worktree billy.Filesystem
if worktree, err = s.w.Filesystem.Chroot(s.c.Path); err != nil {
return nil, err
@@ -138,18 +151,25 @@ func (s *Submodule) Repository() (*Repository, error) {
return nil, err
}
if !path.IsAbs(moduleEndpoint.Path) && moduleEndpoint.Protocol == "file" {
remotes, err := s.w.r.Remotes()
// A relative submodule URL such as "../X.git" must resolve against
// the parent repository's remote URL, not against the process CWD.
// Detect relativity from the raw configured URL because
// transport.NewEndpoint normalizes local paths to absolute form via
// filepath.Abs, which would otherwise mask the relative form here.
if giturl.IsLocalEndpoint(s.c.URL) &&
!path.IsAbs(s.c.URL) && !filepath.IsAbs(s.c.URL) {
base, err := defaultRemote(s.w.r)
if err != nil {
return nil, fmt.Errorf("resolving relative submodule URL: %w", err)
}
rootEndpoint, err := transport.NewEndpoint(base.URLs[0])
if err != nil {
return nil, err
}
rootEndpoint, err := transport.NewEndpoint(remotes[0].c.URLs[0])
if err != nil {
return nil, err
}
rootEndpoint.Path = path.Join(rootEndpoint.Path, moduleEndpoint.Path)
rootEndpoint.Path = path.Join(rootEndpoint.Path, s.c.URL)
*moduleEndpoint = *rootEndpoint
}
@@ -161,6 +181,52 @@ func (s *Submodule) Repository() (*Repository, error) {
return r, err
}
// defaultRemote returns the remote that relative submodule URLs are
// resolved against, mirroring canonical Git's repo_default_remote
// (remote.c) and resolve_relative_url (builtin/submodule--helper.c):
//
// 1. if HEAD is on a branch with branch.<name>.remote configured,
// use that remote;
// 2. else if exactly one remote is configured, use it;
// 3. otherwise fall back to DefaultRemoteName ("origin").
//
// Each rule falls through unconditionally: a branch lookup that
// finds the branch but with an empty Remote does not short-circuit
// rule (2). Returns an error when the chosen remote is not configured.
func defaultRemote(r *Repository) (*config.RemoteConfig, error) {
cfg, err := r.Config()
if err != nil {
return nil, err
}
if ref, err := r.Reference(plumbing.HEAD, false); err == nil &&
ref.Type() == plumbing.SymbolicReference &&
ref.Target().IsBranch() {
if b, ok := cfg.Branches[ref.Target().Short()]; ok && b.Remote != "" {
return lookupRemote(cfg, b.Remote)
}
}
if len(cfg.Remotes) == 1 {
for name := range cfg.Remotes {
return lookupRemote(cfg, name)
}
}
return lookupRemote(cfg, DefaultRemoteName)
}
func lookupRemote(cfg *config.Config, name string) (*config.RemoteConfig, error) {
rc, ok := cfg.Remotes[name]
if !ok {
return nil, fmt.Errorf("remote %q not found", name)
}
if len(rc.URLs) == 0 {
return nil, fmt.Errorf("remote %q has no configured URL", name)
}
return rc, nil
}
// Update the registered submodule to match what the superproject expects, the
// submodule should be initialized first calling the Init method or setting in
// the options SubmoduleUpdateOptions.Init equals true
+15
View File
@@ -5,11 +5,18 @@ package binary
import (
"bufio"
"encoding/binary"
"errors"
"io"
"math"
"github.com/go-git/go-git/v5/plumbing"
)
// ErrIntegerOverflow is returned when a Git-format variable-width integer
// would not fit into an int64 because the input declares more continuation
// bytes than the type can hold.
var ErrIntegerOverflow = errors.New("variable-width integer overflow")
// Read reads structured binary data from r into data. Bytes are read and
// decoded in BigEndian order
// https://golang.org/pkg/encoding/binary/#Read
@@ -92,6 +99,14 @@ func ReadVariableWidthInt(r io.Reader) (int64, error) {
var v = int64(c & maskLength)
for c&maskContinue > 0 {
// Reject input that, after the v++ and shift below, would
// not fit in an int64. With v < (MaxInt64-127)>>7, the
// post-increment v is at most (MaxInt64-127)>>7 and the
// final (v << 7) + (c & 0x7F) stays within int64.
if v >= (math.MaxInt64-int64(maskLength))>>lengthBits {
return 0, ErrIntegerOverflow
}
v++
if err := Read(r, &c); err != nil {
return 0, err
+71 -111
View File
@@ -7,7 +7,6 @@ import (
"io"
"os"
"path/filepath"
"runtime"
"strings"
"github.com/go-git/go-billy/v5"
@@ -458,10 +457,6 @@ func (w *Worktree) resetWorktree(t *object.Tree, files []string) error {
filesMap := buildFilePathMap(files)
for _, ch := range changes {
if err := w.validChange(ch); err != nil {
return err
}
if len(files) > 0 {
file := ""
if ch.From != nil {
@@ -489,108 +484,6 @@ func (w *Worktree) resetWorktree(t *object.Tree, files []string) error {
return w.r.Storer.SetIndex(idx)
}
// worktreeDeny is a list of paths that are not allowed
// to be used when resetting the worktree.
var worktreeDeny = map[string]struct{}{
// .git
GitDirName: {},
// For other historical reasons, file names that do not conform to the 8.3
// format (up to eight characters for the basename, three for the file
// extension, certain characters not allowed such as `+`, etc) are associated
// with a so-called "short name", at least on the `C:` drive by default.
// Which means that `git~1/` is a valid way to refer to `.git/`.
"git~1": {},
}
// validPath checks whether paths are valid.
// The rules around invalid paths could differ from upstream based on how
// filesystems are managed within go-git, but they are largely the same.
//
// For upstream rules:
// https://github.com/git/git/blob/564d0252ca632e0264ed670534a51d18a689ef5d/read-cache.c#L946
// https://github.com/git/git/blob/564d0252ca632e0264ed670534a51d18a689ef5d/path.c#L1383
func validPath(paths ...string) error {
for _, p := range paths {
parts := strings.FieldsFunc(p, func(r rune) bool { return (r == '\\' || r == '/') })
if len(parts) == 0 {
return fmt.Errorf("invalid path: %q", p)
}
if _, denied := worktreeDeny[strings.ToLower(parts[0])]; denied {
return fmt.Errorf("invalid path prefix: %q", p)
}
if runtime.GOOS == "windows" {
// Volume names are not supported, in both formats: \\ and <DRIVE_LETTER>:.
if vol := filepath.VolumeName(p); vol != "" {
return fmt.Errorf("invalid path: %q", p)
}
if !windowsValidPath(parts[0]) {
return fmt.Errorf("invalid path: %q", p)
}
}
for _, part := range parts {
if part == ".." {
return fmt.Errorf("invalid path %q: cannot use '..'", p)
}
}
}
return nil
}
// windowsPathReplacer defines the chars that need to be replaced
// as part of windowsValidPath.
var windowsPathReplacer *strings.Replacer
func init() {
windowsPathReplacer = strings.NewReplacer(" ", "", ".", "")
}
func windowsValidPath(part string) bool {
if len(part) > 3 && strings.EqualFold(part[:4], GitDirName) {
// For historical reasons, file names that end in spaces or periods are
// automatically trimmed. Therefore, `.git . . ./` is a valid way to refer
// to `.git/`.
if windowsPathReplacer.Replace(part[4:]) == "" {
return false
}
// For yet other historical reasons, NTFS supports so-called "Alternate Data
// Streams", i.e. metadata associated with a given file, referred to via
// `<filename>:<stream-name>:<stream-type>`. There exists a default stream
// type for directories, allowing `.git/` to be accessed via
// `.git::$INDEX_ALLOCATION/`.
//
// For performance reasons, _all_ Alternate Data Streams of `.git/` are
// forbidden, not just `::$INDEX_ALLOCATION`.
if len(part) > 4 && part[4:5] == ":" {
return false
}
}
return true
}
func (w *Worktree) validChange(ch merkletrie.Change) error {
action, err := ch.Action()
if err != nil {
return nil
}
switch action {
case merkletrie.Delete:
return validPath(ch.From.String())
case merkletrie.Insert:
return validPath(ch.To.String())
case merkletrie.Modify:
return validPath(ch.From.String(), ch.To.String())
}
return nil
}
func (w *Worktree) checkoutChange(ch merkletrie.Change, t *object.Tree, idx *indexBuilder) error {
a, err := ch.Action()
if err != nil {
@@ -690,6 +583,10 @@ func (w *Worktree) checkoutChangeSubmodule(name string,
return err
}
if err := w.clearBlockingSymlinks(name); err != nil {
return err
}
if err := w.Filesystem.MkdirAll(name, mode); err != nil {
return err
}
@@ -733,7 +630,70 @@ func (w *Worktree) checkoutChangeRegularFile(name string,
return nil
}
// clearBlockingSymlinks removes a symlink that is in the way of
// materialising name, so the checkout writes a real entry in its place
// instead of following the link out of the worktree. Two cases:
//
// - a leading directory component that is a symlink (e.g. "s" while
// writing "s/config", where "s" links to ".git"): OpenFile/MkdirAll
// would traverse it, so the write would land under the link's target.
// - the final component itself being a symlink (e.g. writing "s" while
// "s" links to ".git/config"): OpenFile with O_TRUNC, or Symlink,
// would follow/replace through it and clobber the target.
//
// A symlink can never be a legitimate parent of, or the destination for,
// a tracked entry, so removing it is always correct. This mirrors upstream
// Git's forced checkout, which unlinks a blocking symlink in the leading
// path (create_directories) and unlinks an existing entry before
// write_entry.
// https://github.com/git/git/blob/v2.54.0/entry.c#L50
func (w *Worktree) clearBlockingSymlinks(name string) error {
var dirs []string
for dir := filepath.Dir(name); dir != "." && dir != "" && dir != string(filepath.Separator); dir = filepath.Dir(dir) {
dirs = append(dirs, dir)
}
// Leading components, shallowest-first: removing the shallowest symlink
// invalidates every component beneath it, so a single removal is enough.
for i := len(dirs) - 1; i >= 0; i-- {
fi, err := w.Filesystem.Lstat(dirs[i])
if err != nil {
// A missing component is created as a real directory by the
// checkout. Any other error means we cannot tell whether it is
// a symlink, so surface it instead of leaving a blocking link in
// place and failing later in a harder-to-diagnose way.
if os.IsNotExist(err) {
continue
}
return err
}
if fi.Mode()&os.ModeSymlink != 0 {
return w.Filesystem.Remove(dirs[i])
}
}
// Final component: an existing symlink here would be followed by the
// subsequent OpenFile/Symlink/MkdirAll, so replace it.
fi, err := w.Filesystem.Lstat(name)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
if fi.Mode()&os.ModeSymlink != 0 {
return w.Filesystem.Remove(name)
}
return nil
}
func (w *Worktree) checkoutFile(f *object.File) (err error) {
// checkoutFile is the materialisation boundary for tracked entries.
// Remove any blocking symlink first so the subsequent OpenFile or
// Symlink call writes the entry itself instead of following a planted
// final-component link in the underlying filesystem.
if err := w.clearBlockingSymlinks(f.Name); err != nil {
return err
}
mode, err := f.Mode.ToOSFileMode()
if err != nil {
return
@@ -763,10 +723,10 @@ func (w *Worktree) checkoutFile(f *object.File) (err error) {
}
func (w *Worktree) checkoutFileSymlink(f *object.File) (err error) {
// https://github.com/git/git/commit/10ecfa76491e4923988337b2e2243b05376b40de
if strings.EqualFold(f.Name, gitmodulesFile) {
return ErrGitModulesSymlink
}
// .gitmodules symlink rejection (and its NTFS / HFS variants) is
// enforced by the worktreeFilesystem wrapper's Symlink method via
// validSymlinkName. See https://github.com/git/git/commit/10ecfa7
// for the upstream rationale.
from, err := f.Reader()
if err != nil {
+349
View File
@@ -0,0 +1,349 @@
package git
import (
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"github.com/go-git/go-billy/v5"
"github.com/go-git/go-git/v5/internal/pathutil"
)
// defaultProtectHFS returns the default value for core.protectHFS
// when not explicitly configured. Matches upstream Git's
// PROTECT_HFS_DEFAULT[1], which the Makefile sets to 1 on Darwin
// and leaves at 0 on every other platform.
//
// [1]: https://github.com/git/git/blob/v2.54.0/config.mak.uname#L146
func defaultProtectHFS() bool {
return runtime.GOOS == "darwin"
}
// defaultProtectNTFS returns the default value for core.protectNTFS
// when not explicitly configured. Matches upstream Git's
// PROTECT_NTFS_DEFAULT, which has been 1 on every platform since
// 9102f958ee5 (CVE-2019-1353)[1]: WSL allows Linux processes to
// reach NTFS-mounted worktrees on Windows hosts, so the
// is_ntfs_dotgit guard cannot safely be gated on the runtime OS.
//
// [1]: https://github.com/git/git/commit/9102f958ee5
func defaultProtectNTFS() bool {
return true
}
// worktreeFilesystem wraps a billy.Filesystem and validates every path it
// is handed, so worktree operations cannot use dangerous paths at the
// boundary. Two layers apply:
//
// - validPath rejects dangerous path *strings*: .git and its HFS+/NTFS
// variants, "..", control characters, volume names.
// - validNoLeadingSymlink rejects paths whose leading directories
// already exist on disk as symlinks, so a write or delete cannot
// follow a planted link out of the tree.
//
// Both layers run on every mutating operation (validWritePath) and every
// read (validReadPath). Chroot additionally refuses a symlink as the final
// component, so a sub-filesystem such as a submodule worktree cannot be
// scoped to a redirected target.
//
// The wrapper intentionally stops at leading-component traversal. Callers
// that need final-component no-follow semantics for materialisation
// (checkoutFile) enforce that directly by removing the blocking symlink
// before opening the destination path.
type worktreeFilesystem struct {
billy.Filesystem
protectNTFS bool
protectHFS bool
}
func newWorktreeFilesystem(fs billy.Filesystem, protectNTFS, protectHFS bool) *worktreeFilesystem {
return &worktreeFilesystem{Filesystem: fs, protectNTFS: protectNTFS, protectHFS: protectHFS}
}
func (sfs *worktreeFilesystem) Create(filename string) (billy.File, error) {
if err := sfs.validWritePath(filename); err != nil {
return nil, fmt.Errorf("create: %w", err)
}
return sfs.Filesystem.Create(filename)
}
func (sfs *worktreeFilesystem) Open(filename string) (billy.File, error) {
if err := sfs.validReadPath(filename); err != nil {
return nil, fmt.Errorf("open: %w", err)
}
return sfs.Filesystem.Open(filename)
}
func (sfs *worktreeFilesystem) OpenFile(filename string, flag int, perm os.FileMode) (billy.File, error) {
if err := sfs.validWritePath(filename); err != nil {
return nil, fmt.Errorf("openfile: %w", err)
}
return sfs.Filesystem.OpenFile(filename, flag, perm)
}
func (sfs *worktreeFilesystem) Stat(filename string) (os.FileInfo, error) {
if err := sfs.validReadPath(filename); err != nil {
return nil, fmt.Errorf("stat: %w", err)
}
return sfs.Filesystem.Stat(filename)
}
func (sfs *worktreeFilesystem) Remove(filename string) error {
if err := sfs.validWritePath(filename); err != nil {
return fmt.Errorf("remove: %w", err)
}
return sfs.Filesystem.Remove(filename)
}
func (sfs *worktreeFilesystem) Rename(from, to string) error {
if err := sfs.validWritePath(from, to); err != nil {
return fmt.Errorf("rename: %w", err)
}
return sfs.Filesystem.Rename(from, to)
}
func (sfs *worktreeFilesystem) ReadDir(path string) ([]os.FileInfo, error) {
if err := sfs.validReadPath(path); err != nil {
return nil, fmt.Errorf("readdir: %w", err)
}
return sfs.Filesystem.ReadDir(path)
}
func (sfs *worktreeFilesystem) Lstat(filename string) (os.FileInfo, error) {
if err := sfs.validReadPath(filename); err != nil {
return nil, fmt.Errorf("lstat: %w", err)
}
return sfs.Filesystem.Lstat(filename)
}
func (sfs *worktreeFilesystem) Symlink(target, link string) error {
if err := sfs.validWritePath(link); err != nil {
return fmt.Errorf("symlink: %w", err)
}
if err := sfs.validSymlinkName(link); err != nil {
return fmt.Errorf("symlink: %w", err)
}
return sfs.Filesystem.Symlink(target, link)
}
func (sfs *worktreeFilesystem) Readlink(link string) (string, error) {
if err := sfs.validReadPath(link); err != nil {
return "", fmt.Errorf("readlink: %w", err)
}
return sfs.Filesystem.Readlink(link)
}
func (sfs *worktreeFilesystem) MkdirAll(path string, perm os.FileMode) error {
// MkdirAll on the worktree root is a no-op: the root always exists,
// so there is nothing to materialise. Mirroring the tolerance that
// validReadPath gives to read-side operations avoids breaking callers
// that walk a directory tree and pass the relative-to-root prefix
// ("") through to the worktree FS.
if path == "" || path == "." || path == "/" {
return nil
}
if err := sfs.validWritePath(path); err != nil {
return fmt.Errorf("mkdirall: %w", err)
}
return sfs.Filesystem.MkdirAll(path, perm)
}
func (sfs *worktreeFilesystem) TempFile(_, _ string) (billy.File, error) {
return nil, fmt.Errorf("tempfile: %w", errUnsupportedOperation)
}
func (sfs *worktreeFilesystem) Chroot(path string) (billy.Filesystem, error) {
if err := sfs.validReadPath(path); err != nil {
return nil, fmt.Errorf("chroot: %w", err)
}
// Chroot scopes a sub-filesystem to path, so the final component must
// be a real directory too: a symlink there would silently redirect the
// scope (e.g. a submodule worktree) to a target outside the tree. This
// is the "valid path, wrong target" case that validNoLeadingSymlink,
// which only inspects leading components, does not cover.
//
// A non-existent target is fine: Chroot creates it as a real
// directory. Any other Lstat error means we cannot prove the target
// is not a symlink, so fail closed rather than scope through it.
if fi, err := sfs.Filesystem.Lstat(path); err != nil {
if !os.IsNotExist(err) {
return nil, fmt.Errorf("chroot: cannot stat %q: %w", path, err)
}
} else if fi.Mode()&os.ModeSymlink != 0 {
return nil, fmt.Errorf("chroot: invalid path %q: is a symlink", path)
}
return sfs.Filesystem.Chroot(path)
}
// validReadPath is like validWritePath but treats the empty string and "."
// as valid references to the worktree root. Read-side operations on the
// root (e.g. ReadDir(""), Lstat(".")) are legitimate. Mutating the root
// itself is not, so write-side operations reject it via validPath. Reads
// are still refused through a leading symlink, so the wrapper never
// follows a planted link even on the read surface.
func (sfs *worktreeFilesystem) validReadPath(p string) error {
if p == "" || p == "." || p == "/" {
return nil
}
if err := sfs.validPath(p); err != nil {
return err
}
return sfs.validNoLeadingSymlink(p)
}
var errUnsupportedOperation = errors.New("unsupported operation")
// isDotGitVariant reports whether part is .git, git~1, or an HFS+
// equivalent of .git (when protectHFS is true). NTFS variants of .git
// (e.g. ".git " with trailing space, ".git::$INDEX_ALLOCATION") are
// detected separately by pathutil.WindowsValidPath, which applies
// regardless of position in the path. Both validators reuse this
// helper.
func isDotGitVariant(part string, protectHFS bool) bool {
if pathutil.IsDotGitName(part) {
return true
}
if protectHFS && pathutil.IsHFSDotGit(part) {
return true
}
return false
}
// validPath checks whether paths are valid for the worktree
// filesystem abstraction. It is intentionally tolerant of .git as
// the final path component of a multi-component path
// (e.g. "submodule/.git"), so that legitimate gitlink pointer files
// can still be Stat'd, Read, and Removed via the wrapper during
// submodule cleanup. Attacker-controlled tree-entry paths are
// validated separately by pathutil.ValidTreePath at the boundaries
// where data leaves the trusted store (Tree.FindEntry, the explicit
// callers in CherryPick and Submodule.Repository).
//
// For upstream rules:
// https://github.com/git/git/blob/v2.54.0/read-cache.c#L987
// https://github.com/git/git/blob/v2.54.0/path.c#L1419
func (sfs *worktreeFilesystem) validPath(paths ...string) error {
for _, p := range paths {
for i := 0; i < len(p); i++ {
if p[i] < 0x20 || p[i] == 0x7f {
return fmt.Errorf("invalid path %q: contains control character", p)
}
}
parts := strings.FieldsFunc(p, func(r rune) bool { return (r == '\\' || r == '/') })
if len(parts) == 0 {
return fmt.Errorf("invalid path: %q", p)
}
if sfs.protectNTFS {
// Volume names are not supported, in both formats: \\ and <DRIVE_LETTER>:.
if vol := filepath.VolumeName(p); vol != "" {
return fmt.Errorf("invalid path: %q", p)
}
}
for i, part := range parts {
if part == "." || part == ".." {
return fmt.Errorf("invalid path %q: cannot use %q", p, part)
}
// Reject .git (and equivalents) as a path component when it is
// either the first component (root-level .git) or a non-final
// component (traversal into a .git directory, e.g. "a/.git/config").
// A final non-first .git component (e.g. "submodule/.git") is
// allowed because submodule worktrees contain a .git pointer file.
if isDotGitVariant(part, sfs.protectHFS) && (i == 0 || i < len(parts)-1) {
return fmt.Errorf("invalid path component: %q", p)
}
if sfs.protectNTFS && !pathutil.WindowsValidPath(part) {
return fmt.Errorf("invalid path: %q", p)
}
}
}
return nil
}
// validWritePath validates paths for mutating operations. It layers the
// filesystem-state check validNoLeadingSymlink on top of the string-only
// checks in validPath, so a write can neither name a dangerous path nor
// reach one by traversing an existing symlink. Every mutating method on
// the wrapper funnels through here, so the leading-symlink invariant holds
// for all worktree writers without each call site having to remember it.
func (sfs *worktreeFilesystem) validWritePath(paths ...string) error {
if err := sfs.validPath(paths...); err != nil {
return err
}
return sfs.validNoLeadingSymlink(paths...)
}
// validNoLeadingSymlink rejects paths whose leading directory components
// resolve through a symlink that already exists on the underlying
// filesystem. validPath guards the path string. This guards the on-disk
// state, so a write or delete cannot reach outside the worktree by
// traversing a symlink that a tree or an earlier step left in place.
//
// This is the fail-closed backstop for the whole class. Callers that want
// upstream's replace-and-continue behaviour (checkout) remove the blocking
// symlink first via clearBlockingSymlinks, so no symlink remains when the
// write reaches the wrapper. Callers that do not get a safe error,
// matching upstream Git refusing rather than following the link. See
// has_symlink_leading_path (symlinks.c) and the check_leading_path guard
// in unlink_entry (entry.c).
func (sfs *worktreeFilesystem) validNoLeadingSymlink(paths ...string) error {
for _, p := range paths {
for dir := filepath.Dir(p); dir != "." && dir != "" && dir != string(filepath.Separator); dir = filepath.Dir(dir) {
fi, err := sfs.Filesystem.Lstat(dir)
if err != nil {
// A missing ancestor is materialised as a real directory,
// so it cannot be a symlink and is safe to skip. Any other
// error (permission, I/O) means we cannot prove the
// component is not a symlink, so fail closed rather than
// let the operation traverse an unverified component.
if os.IsNotExist(err) {
continue
}
return fmt.Errorf("invalid path %q: cannot stat leading component %q: %w", p, dir, err)
}
if fi.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("invalid path %q: leading component %q is a symlink", p, dir)
}
}
}
return nil
}
// validSymlinkName checks the per-component name of a symlink for
// dotfile names that attackers can use to trick a checkout into
// writing a dangerous symlink. Each path component is compared
// against .gitmodules case-insensitively, against its NTFS variants
// (e.g. ".gitmodules .", ".gitmodules::$INDEX_ALLOCATION", or 8.3
// short-name forms) when protectNTFS is on, and against its HFS+
// variants (Unicode ignored code points folded into ".gitmodules")
// when protectHFS is on.
//
// Reference: upstream Git verify_path_internal at read-cache.c#L1004-L1024
// in tag v2.54.0[1].
//
// [1]: https://github.com/git/git/blob/v2.54.0/read-cache.c#L1004-L1024
func (sfs *worktreeFilesystem) validSymlinkName(name string) error {
parts := strings.FieldsFunc(name, func(r rune) bool {
return r == '/' || r == '\\'
})
for _, part := range parts {
if strings.EqualFold(part, gitmodulesFile) {
return ErrGitModulesSymlink
}
if sfs.protectNTFS && pathutil.IsNTFSDotGitmodules(part) {
return ErrGitModulesSymlink
}
if sfs.protectHFS && pathutil.IsHFSDotGitmodules(part) {
return ErrGitModulesSymlink
}
}
return nil
}
+10 -1
View File
@@ -10,6 +10,7 @@ import (
"strings"
"github.com/go-git/go-billy/v5/util"
"github.com/go-git/go-git/v5/internal/pathutil"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/filemode"
"github.com/go-git/go-git/v5/plumbing/format/gitignore"
@@ -370,7 +371,7 @@ func (w *Worktree) doAdd(path string, ignorePattern []gitignore.Pattern, skipSta
}
}
path = filepath.Clean(path)
path = filepath.ToSlash(filepath.Clean(path))
if err != nil || !fi.IsDir() {
added, h, err = w.doAddFile(idx, s, path, ignorePattern)
@@ -545,6 +546,14 @@ func (w *Worktree) addOrUpdateFileToIndex(idx *index.Index, filename string, h p
}
func (w *Worktree) doAddFileToIndex(idx *index.Index, filename string, h plumbing.Hash) error {
// Mirror upstream's Index.Add gate at the v5 caller boundary: the
// index feeds future trees, so a name that the tree-side
// pathutil.ValidTreePath gate would reject must not enter the
// index in the first place. v5 keeps Index.Add's existing signature
// for API compatibility, so the validation happens here.
if err := pathutil.ValidTreePath(filename); err != nil {
return err
}
return w.doUpdateFileToIndex(idx.Add(filename), filename, h)
}
+1
View File
@@ -1,2 +1,3 @@
* -text
*.bin -text -diff
*.md text eol=lf
+700 -700
View File
File diff suppressed because it is too large Load Diff
+78 -78
View File
@@ -1,79 +1,79 @@
# Finite State Entropy
This package provides Finite State Entropy encoding and decoding.
Finite State Entropy (also referenced as [tANS](https://en.wikipedia.org/wiki/Asymmetric_numeral_systems#tANS))
encoding provides a fast near-optimal symbol encoding/decoding
for byte blocks as implemented in [zstandard](https://github.com/facebook/zstd).
This can be used for compressing input with a lot of similar input values to the smallest number of bytes.
This does not perform any multi-byte [dictionary coding](https://en.wikipedia.org/wiki/Dictionary_coder) as LZ coders,
but it can be used as a secondary step to compressors (like Snappy) that does not do entropy encoding.
* [Godoc documentation](https://godoc.org/github.com/klauspost/compress/fse)
## News
* Feb 2018: First implementation released. Consider this beta software for now.
# Usage
This package provides a low level interface that allows to compress single independent blocks.
Each block is separate, and there is no built in integrity checks.
This means that the caller should keep track of block sizes and also do checksums if needed.
Compressing a block is done via the [`Compress`](https://godoc.org/github.com/klauspost/compress/fse#Compress) function.
You must provide input and will receive the output and maybe an error.
These error values can be returned:
| Error | Description |
|---------------------|-----------------------------------------------------------------------------|
| `<nil>` | Everything ok, output is returned |
| `ErrIncompressible` | Returned when input is judged to be too hard to compress |
| `ErrUseRLE` | Returned from the compressor when the input is a single byte value repeated |
| `(error)` | An internal error occurred. |
As can be seen above there are errors that will be returned even under normal operation so it is important to handle these.
To reduce allocations you can provide a [`Scratch`](https://godoc.org/github.com/klauspost/compress/fse#Scratch) object
that can be re-used for successive calls. Both compression and decompression accepts a `Scratch` object, and the same
object can be used for both.
Be aware, that when re-using a `Scratch` object that the *output* buffer is also re-used, so if you are still using this
you must set the `Out` field in the scratch to nil. The same buffer is used for compression and decompression output.
Decompressing is done by calling the [`Decompress`](https://godoc.org/github.com/klauspost/compress/fse#Decompress) function.
You must provide the output from the compression stage, at exactly the size you got back. If you receive an error back
your input was likely corrupted.
It is important to note that a successful decoding does *not* mean your output matches your original input.
There are no integrity checks, so relying on errors from the decompressor does not assure your data is valid.
For more detailed usage, see examples in the [godoc documentation](https://godoc.org/github.com/klauspost/compress/fse#pkg-examples).
# Performance
A lot of factors are affecting speed. Block sizes and compressibility of the material are primary factors.
All compression functions are currently only running on the calling goroutine so only one core will be used per block.
The compressor is significantly faster if symbols are kept as small as possible. The highest byte value of the input
is used to reduce some of the processing, so if all your input is above byte value 64 for instance, it may be
beneficial to transpose all your input values down by 64.
With moderate block sizes around 64k speed are typically 200MB/s per core for compression and
around 300MB/s decompression speed.
The same hardware typically does Huffman (deflate) encoding at 125MB/s and decompression at 100MB/s.
# Plans
At one point, more internals will be exposed to facilitate more "expert" usage of the components.
A streaming interface is also likely to be implemented. Likely compatible with [FSE stream format](https://github.com/Cyan4973/FiniteStateEntropy/blob/dev/programs/fileio.c#L261).
# Contributing
Contributions are always welcome. Be aware that adding public functions will require good justification and breaking
# Finite State Entropy
This package provides Finite State Entropy encoding and decoding.
Finite State Entropy (also referenced as [tANS](https://en.wikipedia.org/wiki/Asymmetric_numeral_systems#tANS))
encoding provides a fast near-optimal symbol encoding/decoding
for byte blocks as implemented in [zstandard](https://github.com/facebook/zstd).
This can be used for compressing input with a lot of similar input values to the smallest number of bytes.
This does not perform any multi-byte [dictionary coding](https://en.wikipedia.org/wiki/Dictionary_coder) as LZ coders,
but it can be used as a secondary step to compressors (like Snappy) that does not do entropy encoding.
* [Godoc documentation](https://godoc.org/github.com/klauspost/compress/fse)
## News
* Feb 2018: First implementation released. Consider this beta software for now.
# Usage
This package provides a low level interface that allows to compress single independent blocks.
Each block is separate, and there is no built in integrity checks.
This means that the caller should keep track of block sizes and also do checksums if needed.
Compressing a block is done via the [`Compress`](https://godoc.org/github.com/klauspost/compress/fse#Compress) function.
You must provide input and will receive the output and maybe an error.
These error values can be returned:
| Error | Description |
|---------------------|-----------------------------------------------------------------------------|
| `<nil>` | Everything ok, output is returned |
| `ErrIncompressible` | Returned when input is judged to be too hard to compress |
| `ErrUseRLE` | Returned from the compressor when the input is a single byte value repeated |
| `(error)` | An internal error occurred. |
As can be seen above there are errors that will be returned even under normal operation so it is important to handle these.
To reduce allocations you can provide a [`Scratch`](https://godoc.org/github.com/klauspost/compress/fse#Scratch) object
that can be re-used for successive calls. Both compression and decompression accepts a `Scratch` object, and the same
object can be used for both.
Be aware, that when re-using a `Scratch` object that the *output* buffer is also re-used, so if you are still using this
you must set the `Out` field in the scratch to nil. The same buffer is used for compression and decompression output.
Decompressing is done by calling the [`Decompress`](https://godoc.org/github.com/klauspost/compress/fse#Decompress) function.
You must provide the output from the compression stage, at exactly the size you got back. If you receive an error back
your input was likely corrupted.
It is important to note that a successful decoding does *not* mean your output matches your original input.
There are no integrity checks, so relying on errors from the decompressor does not assure your data is valid.
For more detailed usage, see examples in the [godoc documentation](https://godoc.org/github.com/klauspost/compress/fse#pkg-examples).
# Performance
A lot of factors are affecting speed. Block sizes and compressibility of the material are primary factors.
All compression functions are currently only running on the calling goroutine so only one core will be used per block.
The compressor is significantly faster if symbols are kept as small as possible. The highest byte value of the input
is used to reduce some of the processing, so if all your input is above byte value 64 for instance, it may be
beneficial to transpose all your input values down by 64.
With moderate block sizes around 64k speed are typically 200MB/s per core for compression and
around 300MB/s decompression speed.
The same hardware typically does Huffman (deflate) encoding at 125MB/s and decompression at 100MB/s.
# Plans
At one point, more internals will be exposed to facilitate more "expert" usage of the components.
A streaming interface is also likely to be implemented. Likely compatible with [FSE stream format](https://github.com/Cyan4973/FiniteStateEntropy/blob/dev/programs/fileio.c#L261).
# Contributing
Contributions are always welcome. Be aware that adding public functions will require good justification and breaking
changes will likely not be accepted. If in doubt open an issue before writing the PR.
+89 -89
View File
@@ -1,89 +1,89 @@
# Huff0 entropy compression
This package provides Huff0 encoding and decoding as used in zstd.
[Huff0](https://github.com/Cyan4973/FiniteStateEntropy#new-generation-entropy-coders),
a Huffman codec designed for modern CPU, featuring OoO (Out of Order) operations on multiple ALU
(Arithmetic Logic Unit), achieving extremely fast compression and decompression speeds.
This can be used for compressing input with a lot of similar input values to the smallest number of bytes.
This does not perform any multi-byte [dictionary coding](https://en.wikipedia.org/wiki/Dictionary_coder) as LZ coders,
but it can be used as a secondary step to compressors (like Snappy) that does not do entropy encoding.
* [Godoc documentation](https://godoc.org/github.com/klauspost/compress/huff0)
## News
This is used as part of the [zstandard](https://github.com/klauspost/compress/tree/master/zstd#zstd) compression and decompression package.
This ensures that most functionality is well tested.
# Usage
This package provides a low level interface that allows to compress single independent blocks.
Each block is separate, and there is no built in integrity checks.
This means that the caller should keep track of block sizes and also do checksums if needed.
Compressing a block is done via the [`Compress1X`](https://godoc.org/github.com/klauspost/compress/huff0#Compress1X) and
[`Compress4X`](https://godoc.org/github.com/klauspost/compress/huff0#Compress4X) functions.
You must provide input and will receive the output and maybe an error.
These error values can be returned:
| Error | Description |
|---------------------|-----------------------------------------------------------------------------|
| `<nil>` | Everything ok, output is returned |
| `ErrIncompressible` | Returned when input is judged to be too hard to compress |
| `ErrUseRLE` | Returned from the compressor when the input is a single byte value repeated |
| `ErrTooBig` | Returned if the input block exceeds the maximum allowed size (128 Kib) |
| `(error)` | An internal error occurred. |
As can be seen above some of there are errors that will be returned even under normal operation so it is important to handle these.
To reduce allocations you can provide a [`Scratch`](https://godoc.org/github.com/klauspost/compress/huff0#Scratch) object
that can be re-used for successive calls. Both compression and decompression accepts a `Scratch` object, and the same
object can be used for both.
Be aware, that when re-using a `Scratch` object that the *output* buffer is also re-used, so if you are still using this
you must set the `Out` field in the scratch to nil. The same buffer is used for compression and decompression output.
The `Scratch` object will retain state that allows to re-use previous tables for encoding and decoding.
## Tables and re-use
Huff0 allows for reusing tables from the previous block to save space if that is expected to give better/faster results.
The Scratch object allows you to set a [`ReusePolicy`](https://godoc.org/github.com/klauspost/compress/huff0#ReusePolicy)
that controls this behaviour. See the documentation for details. This can be altered between each block.
Do however note that this information is *not* stored in the output block and it is up to the users of the package to
record whether [`ReadTable`](https://godoc.org/github.com/klauspost/compress/huff0#ReadTable) should be called,
based on the boolean reported back from the CompressXX call.
If you want to store the table separate from the data, you can access them as `OutData` and `OutTable` on the
[`Scratch`](https://godoc.org/github.com/klauspost/compress/huff0#Scratch) object.
## Decompressing
The first part of decoding is to initialize the decoding table through [`ReadTable`](https://godoc.org/github.com/klauspost/compress/huff0#ReadTable).
This will initialize the decoding tables.
You can supply the complete block to `ReadTable` and it will return the data part of the block
which can be given to the decompressor.
Decompressing is done by calling the [`Decompress1X`](https://godoc.org/github.com/klauspost/compress/huff0#Scratch.Decompress1X)
or [`Decompress4X`](https://godoc.org/github.com/klauspost/compress/huff0#Scratch.Decompress4X) function.
For concurrently decompressing content with a fixed table a stateless [`Decoder`](https://godoc.org/github.com/klauspost/compress/huff0#Decoder) can be requested which will remain correct as long as the scratch is unchanged. The capacity of the provided slice indicates the expected output size.
You must provide the output from the compression stage, at exactly the size you got back. If you receive an error back
your input was likely corrupted.
It is important to note that a successful decoding does *not* mean your output matches your original input.
There are no integrity checks, so relying on errors from the decompressor does not assure your data is valid.
# Contributing
Contributions are always welcome. Be aware that adding public functions will require good justification and breaking
changes will likely not be accepted. If in doubt open an issue before writing the PR.
# Huff0 entropy compression
This package provides Huff0 encoding and decoding as used in zstd.
[Huff0](https://github.com/Cyan4973/FiniteStateEntropy#new-generation-entropy-coders),
a Huffman codec designed for modern CPU, featuring OoO (Out of Order) operations on multiple ALU
(Arithmetic Logic Unit), achieving extremely fast compression and decompression speeds.
This can be used for compressing input with a lot of similar input values to the smallest number of bytes.
This does not perform any multi-byte [dictionary coding](https://en.wikipedia.org/wiki/Dictionary_coder) as LZ coders,
but it can be used as a secondary step to compressors (like Snappy) that does not do entropy encoding.
* [Godoc documentation](https://godoc.org/github.com/klauspost/compress/huff0)
## News
This is used as part of the [zstandard](https://github.com/klauspost/compress/tree/master/zstd#zstd) compression and decompression package.
This ensures that most functionality is well tested.
# Usage
This package provides a low level interface that allows to compress single independent blocks.
Each block is separate, and there is no built in integrity checks.
This means that the caller should keep track of block sizes and also do checksums if needed.
Compressing a block is done via the [`Compress1X`](https://godoc.org/github.com/klauspost/compress/huff0#Compress1X) and
[`Compress4X`](https://godoc.org/github.com/klauspost/compress/huff0#Compress4X) functions.
You must provide input and will receive the output and maybe an error.
These error values can be returned:
| Error | Description |
|---------------------|-----------------------------------------------------------------------------|
| `<nil>` | Everything ok, output is returned |
| `ErrIncompressible` | Returned when input is judged to be too hard to compress |
| `ErrUseRLE` | Returned from the compressor when the input is a single byte value repeated |
| `ErrTooBig` | Returned if the input block exceeds the maximum allowed size (128 Kib) |
| `(error)` | An internal error occurred. |
As can be seen above some of there are errors that will be returned even under normal operation so it is important to handle these.
To reduce allocations you can provide a [`Scratch`](https://godoc.org/github.com/klauspost/compress/huff0#Scratch) object
that can be re-used for successive calls. Both compression and decompression accepts a `Scratch` object, and the same
object can be used for both.
Be aware, that when re-using a `Scratch` object that the *output* buffer is also re-used, so if you are still using this
you must set the `Out` field in the scratch to nil. The same buffer is used for compression and decompression output.
The `Scratch` object will retain state that allows to re-use previous tables for encoding and decoding.
## Tables and re-use
Huff0 allows for reusing tables from the previous block to save space if that is expected to give better/faster results.
The Scratch object allows you to set a [`ReusePolicy`](https://godoc.org/github.com/klauspost/compress/huff0#ReusePolicy)
that controls this behaviour. See the documentation for details. This can be altered between each block.
Do however note that this information is *not* stored in the output block and it is up to the users of the package to
record whether [`ReadTable`](https://godoc.org/github.com/klauspost/compress/huff0#ReadTable) should be called,
based on the boolean reported back from the CompressXX call.
If you want to store the table separate from the data, you can access them as `OutData` and `OutTable` on the
[`Scratch`](https://godoc.org/github.com/klauspost/compress/huff0#Scratch) object.
## Decompressing
The first part of decoding is to initialize the decoding table through [`ReadTable`](https://godoc.org/github.com/klauspost/compress/huff0#ReadTable).
This will initialize the decoding tables.
You can supply the complete block to `ReadTable` and it will return the data part of the block
which can be given to the decompressor.
Decompressing is done by calling the [`Decompress1X`](https://godoc.org/github.com/klauspost/compress/huff0#Scratch.Decompress1X)
or [`Decompress4X`](https://godoc.org/github.com/klauspost/compress/huff0#Scratch.Decompress4X) function.
For concurrently decompressing content with a fixed table a stateless [`Decoder`](https://godoc.org/github.com/klauspost/compress/huff0#Decoder) can be requested which will remain correct as long as the scratch is unchanged. The capacity of the provided slice indicates the expected output size.
You must provide the output from the compression stage, at exactly the size you got back. If you receive an error back
your input was likely corrupted.
It is important to note that a successful decoding does *not* mean your output matches your original input.
There are no integrity checks, so relying on errors from the decompressor does not assure your data is valid.
# Contributing
Contributions are always welcome. Be aware that adding public functions will require good justification and breaking
changes will likely not be accepted. If in doubt open an issue before writing the PR.
-24
View File
@@ -1,24 +0,0 @@
# Compiled Object files, Static and Dynamic libs (Shared Objects)
*.o
*.a
*.so
# Folders
_obj
_test
# Architecture specific extensions/prefixes
*.[568vq]
[568vq].out
*.cgo1.go
*.cgo2.c
_cgo_defun.c
_cgo_gotypes.go
_cgo_export.*
_testmain.go
*.exe
*.test
*.prof
-57
View File
@@ -1,57 +0,0 @@
version: 2
builds:
-
id: "cpuid"
binary: cpuid
main: ./cmd/cpuid/main.go
env:
- CGO_ENABLED=0
flags:
- -ldflags=-s -w
goos:
- aix
- linux
- freebsd
- netbsd
- windows
- darwin
goarch:
- 386
- amd64
- arm64
goarm:
- 7
archives:
-
id: cpuid
name_template: "cpuid-{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}"
format_overrides:
- goos: windows
format: zip
files:
- LICENSE
checksum:
name_template: 'checksums.txt'
changelog:
sort: asc
filters:
exclude:
- '^doc:'
- '^docs:'
- '^test:'
- '^tests:'
- '^Update\sREADME.md'
nfpms:
-
file_name_template: "cpuid_package_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}"
vendor: Klaus Post
homepage: https://github.com/klauspost/cpuid
maintainer: Klaus Post <klauspost@gmail.com>
description: CPUID Tool
license: BSD 3-Clause
formats:
- deb
- rpm
-35
View File
@@ -1,35 +0,0 @@
Developer Certificate of Origin
Version 1.1
Copyright (C) 2015- Klaus Post & Contributors.
Email: klauspost@gmail.com
Everyone is permitted to copy and distribute verbatim copies of this
license document, but changing it is not allowed.
Developer's Certificate of Origin 1.1
By making a contribution to this project, I certify that:
(a) The contribution was created in whole or in part by me and I
have the right to submit it under the open source license
indicated in the file; or
(b) The contribution is based upon previous work that, to the best
of my knowledge, is covered under an appropriate open source
license and I have the right under that license to submit that
work with modifications, whether created in whole or in part
by me, under the same open source license (unless I am
permitted to submit under a different license), as indicated
in the file; or
(c) The contribution was provided directly to me by some other
person who certified (a), (b) or (c) and I have not modified
it.
(d) I understand and agree that this project and the contribution
are public and that a record of the contribution (including all
personal information I submit with it, including my sign-off) is
maintained indefinitely and may be redistributed consistent with
this project or the open source license(s) involved.
-22
View File
@@ -1,22 +0,0 @@
The MIT License (MIT)
Copyright (c) 2015 Klaus Post
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-512
View File
@@ -1,512 +0,0 @@
# cpuid
Package cpuid provides information about the CPU running the current program.
CPU features are detected on startup, and kept for fast access through the life of the application.
Currently x86 / x64 (AMD64/i386) and ARM (ARM64) is supported, and no external C (cgo) code is used, which should make the library very easy to use.
You can access the CPU information by accessing the shared CPU variable of the cpuid library.
Package home: https://github.com/klauspost/cpuid
[![PkgGoDev](https://pkg.go.dev/badge/github.com/klauspost/cpuid)](https://pkg.go.dev/github.com/klauspost/cpuid/v2)
[![Go](https://github.com/klauspost/cpuid/actions/workflows/go.yml/badge.svg)](https://github.com/klauspost/cpuid/actions/workflows/go.yml)
## installing
`go get -u github.com/klauspost/cpuid/v2` using modules.
Drop `v2` for others.
Installing binary:
`go install github.com/klauspost/cpuid/v2/cmd/cpuid@latest`
Or download binaries from release page: https://github.com/klauspost/cpuid/releases
### Homebrew
For macOS/Linux users, you can install via [brew](https://brew.sh/)
```sh
$ brew install cpuid
```
## example
```Go
package main
import (
"fmt"
"strings"
. "github.com/klauspost/cpuid/v2"
)
func main() {
// Print basic CPU information:
fmt.Println("Name:", CPU.BrandName)
fmt.Println("PhysicalCores:", CPU.PhysicalCores)
fmt.Println("ThreadsPerCore:", CPU.ThreadsPerCore)
fmt.Println("LogicalCores:", CPU.LogicalCores)
fmt.Println("Family", CPU.Family, "Model:", CPU.Model, "Vendor ID:", CPU.VendorID)
fmt.Println("Features:", strings.Join(CPU.FeatureSet(), ","))
fmt.Println("Cacheline bytes:", CPU.CacheLine)
fmt.Println("L1 Data Cache:", CPU.Cache.L1D, "bytes")
fmt.Println("L1 Instruction Cache:", CPU.Cache.L1I, "bytes")
fmt.Println("L2 Cache:", CPU.Cache.L2, "bytes")
fmt.Println("L3 Cache:", CPU.Cache.L3, "bytes")
fmt.Println("Frequency", CPU.Hz, "hz")
// Test if we have these specific features:
if CPU.Supports(SSE, SSE2) {
fmt.Println("We have Streaming SIMD 2 Extensions")
}
}
```
Sample output:
```
>go run main.go
Name: AMD Ryzen 9 3950X 16-Core Processor
PhysicalCores: 16
ThreadsPerCore: 2
LogicalCores: 32
Family 23 Model: 113 Vendor ID: AMD
Features: ADX,AESNI,AVX,AVX2,BMI1,BMI2,CLMUL,CMOV,CX16,F16C,FMA3,HTT,HYPERVISOR,LZCNT,MMX,MMXEXT,NX,POPCNT,RDRAND,RDSEED,RDTSCP,SHA,SSE,SSE2,SSE3,SSE4,SSE42,SSE4A,SSSE3
Cacheline bytes: 64
L1 Data Cache: 32768 bytes
L1 Instruction Cache: 32768 bytes
L2 Cache: 524288 bytes
L3 Cache: 16777216 bytes
Frequency 0 hz
We have Streaming SIMD 2 Extensions
```
# usage
The `cpuid.CPU` provides access to CPU features. Use `cpuid.CPU.Supports()` to check for CPU features.
A faster `cpuid.CPU.Has()` is provided which will usually be inlined by the gc compiler.
To test a larger number of features, they can be combined using `f := CombineFeatures(CMOV, CMPXCHG8, X87, FXSR, MMX, SYSCALL, SSE, SSE2)`, etc.
This can be using with `cpuid.CPU.HasAll(f)` to quickly test if all features are supported.
Note that for some cpu/os combinations some features will not be detected.
`amd64` has rather good support and should work reliably on all platforms.
Note that hypervisors may not pass through all CPU features through to the guest OS,
so even if your host supports a feature it may not be visible on guests.
## arm64 feature detection
Not all operating systems provide ARM features directly
and there is no safe way to do so for the rest.
Currently `arm64/linux` and `arm64/freebsd` should be quite reliable.
`arm64/darwin` adds features expected from the M1 processor, but a lot remains undetected.
A `DetectARM()` can be used if you are able to control your deployment,
it will detect CPU features, but may crash if the OS doesn't intercept the calls.
A `-cpu.arm` flag for detecting unsafe ARM features can be added. See below.
Note that currently only features are detected on ARM,
no additional information is currently available.
## flags
It is possible to add flags that affects cpu detection.
For this the `Flags()` command is provided.
This must be called *before* `flag.Parse()` AND after the flags have been parsed `Detect()` must be called.
This means that any detection used in `init()` functions will not contain these flags.
Example:
```Go
package main
import (
"flag"
"fmt"
"strings"
"github.com/klauspost/cpuid/v2"
)
func main() {
cpuid.Flags()
flag.Parse()
cpuid.Detect()
// Test if we have these specific features:
if cpuid.CPU.Supports(cpuid.SSE, cpuid.SSE2) {
fmt.Println("We have Streaming SIMD 2 Extensions")
}
}
```
## commandline
Download as binary from: https://github.com/klauspost/cpuid/releases
Install from source:
`go install github.com/klauspost/cpuid/v2/cmd/cpuid@latest`
### Example
```
λ cpuid
Name: AMD Ryzen 9 3950X 16-Core Processor
Vendor String: AuthenticAMD
Vendor ID: AMD
PhysicalCores: 16
Threads Per Core: 2
Logical Cores: 32
CPU Family 23 Model: 113
Features: ADX,AESNI,AVX,AVX2,BMI1,BMI2,CLMUL,CLZERO,CMOV,CMPXCHG8,CPBOOST,CX16,F16C,FMA3,FXSR,FXSROPT,HTT,HYPERVISOR,LAHF,LZCNT,MCAOVERFLOW,MMX,MMXEXT,MOVBE,NX,OSXSAVE,POPCNT,RDRAND,RDSEED,RDTSCP,SCE,SHA,SSE,SSE2,SSE3,SSE4,SSE42,SSE4A,SSSE3,SUCCOR,X87,XSAVE
Microarchitecture level: 3
Cacheline bytes: 64
L1 Instruction Cache: 32768 bytes
L1 Data Cache: 32768 bytes
L2 Cache: 524288 bytes
L3 Cache: 16777216 bytes
```
### JSON Output:
```
λ cpuid --json
{
"BrandName": "AMD Ryzen 9 3950X 16-Core Processor",
"VendorID": 2,
"VendorString": "AuthenticAMD",
"PhysicalCores": 16,
"ThreadsPerCore": 2,
"LogicalCores": 32,
"Family": 23,
"Model": 113,
"CacheLine": 64,
"Hz": 0,
"BoostFreq": 0,
"Cache": {
"L1I": 32768,
"L1D": 32768,
"L2": 524288,
"L3": 16777216
},
"SGX": {
"Available": false,
"LaunchControl": false,
"SGX1Supported": false,
"SGX2Supported": false,
"MaxEnclaveSizeNot64": 0,
"MaxEnclaveSize64": 0,
"EPCSections": null
},
"Features": [
"ADX",
"AESNI",
"AVX",
"AVX2",
"BMI1",
"BMI2",
"CLMUL",
"CLZERO",
"CMOV",
"CMPXCHG8",
"CPBOOST",
"CX16",
"F16C",
"FMA3",
"FXSR",
"FXSROPT",
"HTT",
"HYPERVISOR",
"LAHF",
"LZCNT",
"MCAOVERFLOW",
"MMX",
"MMXEXT",
"MOVBE",
"NX",
"OSXSAVE",
"POPCNT",
"RDRAND",
"RDSEED",
"RDTSCP",
"SCE",
"SHA",
"SSE",
"SSE2",
"SSE3",
"SSE4",
"SSE42",
"SSE4A",
"SSSE3",
"SUCCOR",
"X87",
"XSAVE"
],
"X64Level": 3
}
```
### Check CPU microarch level
```
λ cpuid --check-level=3
2022/03/18 17:04:40 AMD Ryzen 9 3950X 16-Core Processor
2022/03/18 17:04:40 Microarchitecture level 3 is supported. Max level is 3.
Exit Code 0
λ cpuid --check-level=4
2022/03/18 17:06:18 AMD Ryzen 9 3950X 16-Core Processor
2022/03/18 17:06:18 Microarchitecture level 4 not supported. Max level is 3.
Exit Code 1
```
## Available flags
### x86 & amd64
| Feature Flag | Description |
|--------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| ADX | Intel ADX (Multi-Precision Add-Carry Instruction Extensions) |
| AESNI | Advanced Encryption Standard New Instructions |
| AMD3DNOW | AMD 3DNOW |
| AMD3DNOWEXT | AMD 3DNowExt |
| AMXBF16 | Tile computational operations on BFLOAT16 numbers |
| AMXINT8 | Tile computational operations on 8-bit integers |
| AMXFP16 | Tile computational operations on FP16 numbers |
| AMXFP8 | Tile computational operations on FP8 numbers |
| AMXCOMPLEX | Tile computational operations on complex numbers |
| AMXTILE | Tile architecture |
| AMXTF32 | Matrix Multiplication of TF32 Tiles into Packed Single Precision Tile |
| AMXTRANSPOSE | Tile multiply where the first operand is transposed |
| APX_F | Intel APX |
| AVX | AVX functions |
| AVX10 | If set the Intel AVX10 Converged Vector ISA is supported |
| AVX10_128 | If set indicates that AVX10 128-bit vector support is present |
| AVX10_256 | If set indicates that AVX10 256-bit vector support is present |
| AVX10_512 | If set indicates that AVX10 512-bit vector support is present |
| AVX2 | AVX2 functions |
| AVX512BF16 | AVX-512 BFLOAT16 Instructions |
| AVX512BITALG | AVX-512 Bit Algorithms |
| AVX512BW | AVX-512 Byte and Word Instructions |
| AVX512CD | AVX-512 Conflict Detection Instructions |
| AVX512DQ | AVX-512 Doubleword and Quadword Instructions |
| AVX512ER | AVX-512 Exponential and Reciprocal Instructions |
| AVX512F | AVX-512 Foundation |
| AVX512FP16 | AVX-512 FP16 Instructions |
| AVX512IFMA | AVX-512 Integer Fused Multiply-Add Instructions |
| AVX512PF | AVX-512 Prefetch Instructions |
| AVX512VBMI | AVX-512 Vector Bit Manipulation Instructions |
| AVX512VBMI2 | AVX-512 Vector Bit Manipulation Instructions, Version 2 |
| AVX512VL | AVX-512 Vector Length Extensions |
| AVX512VNNI | AVX-512 Vector Neural Network Instructions |
| AVX512VP2INTERSECT | AVX-512 Intersect for D/Q |
| AVX512VPOPCNTDQ | AVX-512 Vector Population Count Doubleword and Quadword |
| AVXIFMA | AVX-IFMA instructions |
| AVXNECONVERT | AVX-NE-CONVERT instructions |
| AVXSLOW | Indicates the CPU performs 2 128 bit operations instead of one |
| AVXVNNI | AVX (VEX encoded) VNNI neural network instructions |
| AVXVNNIINT8 | AVX-VNNI-INT8 instructions |
| AVXVNNIINT16 | AVX-VNNI-INT16 instructions |
| BHI_CTRL | Branch History Injection and Intra-mode Branch Target Injection / CVE-2022-0001, CVE-2022-0002 / INTEL-SA-00598 |
| BMI1 | Bit Manipulation Instruction Set 1 |
| BMI2 | Bit Manipulation Instruction Set 2 |
| CETIBT | Intel CET Indirect Branch Tracking |
| CETSS | Intel CET Shadow Stack |
| CLDEMOTE | Cache Line Demote |
| CLMUL | Carry-less Multiplication |
| CLZERO | CLZERO instruction supported |
| CMOV | i686 CMOV |
| CMPCCXADD | CMPCCXADD instructions |
| CMPSB_SCADBS_SHORT | Fast short CMPSB and SCASB |
| CMPXCHG8 | CMPXCHG8 instruction |
| CPBOOST | Core Performance Boost |
| CPPC | AMD: Collaborative Processor Performance Control |
| CX16 | CMPXCHG16B Instruction |
| EFER_LMSLE_UNS | AMD: =Core::X86::Msr::EFER[LMSLE] is not supported, and MBZ |
| ENQCMD | Enqueue Command |
| ERMS | Enhanced REP MOVSB/STOSB |
| F16C | Half-precision floating-point conversion |
| FLUSH_L1D | Flush L1D cache |
| FMA3 | Intel FMA 3. Does not imply AVX. |
| FMA4 | Bulldozer FMA4 functions |
| FP128 | AMD: When set, the internal FP/SIMD execution datapath is 128-bits wide |
| FP256 | AMD: When set, the internal FP/SIMD execution datapath is 256-bits wide |
| FSRM | Fast Short Rep Mov |
| FXSR | FXSAVE, FXRESTOR instructions, CR4 bit 9 |
| FXSROPT | FXSAVE/FXRSTOR optimizations |
| GFNI | Galois Field New Instructions. May require other features (AVX, AVX512VL,AVX512F) based on usage. |
| HLE | Hardware Lock Elision |
| HRESET | If set CPU supports history reset and the IA32_HRESET_ENABLE MSR |
| HTT | Hyperthreading (enabled) |
| HWA | Hardware assert supported. Indicates support for MSRC001_10 |
| HYBRID_CPU | This part has CPUs of more than one type. |
| HYPERVISOR | This bit has been reserved by Intel & AMD for use by hypervisors |
| IA32_ARCH_CAP | IA32_ARCH_CAPABILITIES MSR (Intel) |
| IA32_CORE_CAP | IA32_CORE_CAPABILITIES MSR |
| IBPB | Indirect Branch Restricted Speculation (IBRS) and Indirect Branch Predictor Barrier (IBPB) |
| IBRS | AMD: Indirect Branch Restricted Speculation |
| IBRS_PREFERRED | AMD: IBRS is preferred over software solution |
| IBRS_PROVIDES_SMP | AMD: IBRS provides Same Mode Protection |
| IBS | Instruction Based Sampling (AMD) |
| IBSBRNTRGT | Instruction Based Sampling Feature (AMD) |
| IBSFETCHSAM | Instruction Based Sampling Feature (AMD) |
| IBSFFV | Instruction Based Sampling Feature (AMD) |
| IBSOPCNT | Instruction Based Sampling Feature (AMD) |
| IBSOPCNTEXT | Instruction Based Sampling Feature (AMD) |
| IBSOPSAM | Instruction Based Sampling Feature (AMD) |
| IBSRDWROPCNT | Instruction Based Sampling Feature (AMD) |
| IBSRIPINVALIDCHK | Instruction Based Sampling Feature (AMD) |
| IBS_FETCH_CTLX | AMD: IBS fetch control extended MSR supported |
| IBS_OPDATA4 | AMD: IBS op data 4 MSR supported |
| IBS_OPFUSE | AMD: Indicates support for IbsOpFuse |
| IBS_PREVENTHOST | Disallowing IBS use by the host supported |
| IBS_ZEN4 | Fetch and Op IBS support IBS extensions added with Zen4 |
| IDPRED_CTRL | IPRED_DIS |
| INT_WBINVD | WBINVD/WBNOINVD are interruptible. |
| INVLPGB | NVLPGB and TLBSYNC instruction supported |
| KEYLOCKER | Key locker |
| KEYLOCKERW | Key locker wide |
| LAHF | LAHF/SAHF in long mode |
| LAM | If set, CPU supports Linear Address Masking |
| LBRVIRT | LBR virtualization |
| LZCNT | LZCNT instruction |
| MCAOVERFLOW | MCA overflow recovery support. |
| MCDT_NO | Processor do not exhibit MXCSR Configuration Dependent Timing behavior and do not need to mitigate it. |
| MCOMMIT | MCOMMIT instruction supported |
| MD_CLEAR | VERW clears CPU buffers |
| MMX | standard MMX |
| MMXEXT | SSE integer functions or AMD MMX ext |
| MOVBE | MOVBE instruction (big-endian) |
| MOVDIR64B | Move 64 Bytes as Direct Store |
| MOVDIRI | Move Doubleword as Direct Store |
| MOVSB_ZL | Fast Zero-Length MOVSB |
| MPX | Intel MPX (Memory Protection Extensions) |
| MOVU | MOVU SSE instructions are more efficient and should be preferred to SSE MOVL/MOVH. MOVUPS is more efficient than MOVLPS/MOVHPS. MOVUPD is more efficient than MOVLPD/MOVHPD |
| MSRIRC | Instruction Retired Counter MSR available |
| MSRLIST | Read/Write List of Model Specific Registers |
| MSR_PAGEFLUSH | Page Flush MSR available |
| NRIPS | Indicates support for NRIP save on VMEXIT |
| NX | NX (No-Execute) bit |
| OSXSAVE | XSAVE enabled by OS |
| PCONFIG | PCONFIG for Intel Multi-Key Total Memory Encryption |
| POPCNT | POPCNT instruction |
| PPIN | AMD: Protected Processor Inventory Number support. Indicates that Protected Processor Inventory Number (PPIN) capability can be enabled |
| PREFETCHI | PREFETCHIT0/1 instructions |
| PSFD | Predictive Store Forward Disable |
| RDPRU | RDPRU instruction supported |
| RDRAND | RDRAND instruction is available |
| RDSEED | RDSEED instruction is available |
| RDTSCP | RDTSCP Instruction |
| RRSBA_CTRL | Restricted RSB Alternate |
| RTM | Restricted Transactional Memory |
| RTM_ALWAYS_ABORT | Indicates that the loaded microcode is forcing RTM abort. |
| SERIALIZE | Serialize Instruction Execution |
| SEV | AMD Secure Encrypted Virtualization supported |
| SEV_64BIT | AMD SEV guest execution only allowed from a 64-bit host |
| SEV_ALTERNATIVE | AMD SEV Alternate Injection supported |
| SEV_DEBUGSWAP | Full debug state swap supported for SEV-ES guests |
| SEV_ES | AMD SEV Encrypted State supported |
| SEV_RESTRICTED | AMD SEV Restricted Injection supported |
| SEV_SNP | AMD SEV Secure Nested Paging supported |
| SGX | Software Guard Extensions |
| SGXLC | Software Guard Extensions Launch Control |
| SGXPQC | Software Guard Extensions 256-bit Encryption |
| SHA | Intel SHA Extensions |
| SME | AMD Secure Memory Encryption supported |
| SME_COHERENT | AMD Hardware cache coherency across encryption domains enforced |
| SM3_X86 | SM3 instructions |
| SM4_X86 | SM4 instructions |
| SPEC_CTRL_SSBD | Speculative Store Bypass Disable |
| SRBDS_CTRL | SRBDS mitigation MSR available |
| SSE | SSE functions |
| SSE2 | P4 SSE functions |
| SSE3 | Prescott SSE3 functions |
| SSE4 | Penryn SSE4.1 functions |
| SSE42 | Nehalem SSE4.2 functions |
| SSE4A | AMD Barcelona microarchitecture SSE4a instructions |
| SSSE3 | Conroe SSSE3 functions |
| STIBP | Single Thread Indirect Branch Predictors |
| STIBP_ALWAYSON | AMD: Single Thread Indirect Branch Prediction Mode has Enhanced Performance and may be left Always On |
| STOSB_SHORT | Fast short STOSB |
| SUCCOR | Software uncorrectable error containment and recovery capability. |
| SVM | AMD Secure Virtual Machine |
| SVMDA | Indicates support for the SVM decode assists. |
| SVMFBASID | SVM, Indicates that TLB flush events, including CR3 writes and CR4.PGE toggles, flush only the current ASID's TLB entries. Also indicates support for the extended VMCBTLB_Control |
| SVML | AMD SVM lock. Indicates support for SVM-Lock. |
| SVMNP | AMD SVM nested paging |
| SVMPF | SVM pause intercept filter. Indicates support for the pause intercept filter |
| SVMPFT | SVM PAUSE filter threshold. Indicates support for the PAUSE filter cycle count threshold |
| SYSCALL | System-Call Extension (SCE): SYSCALL and SYSRET instructions. |
| SYSEE | SYSENTER and SYSEXIT instructions |
| TBM | AMD Trailing Bit Manipulation |
| TDX_GUEST | Intel Trust Domain Extensions Guest |
| TLB_FLUSH_NESTED | AMD: Flushing includes all the nested translations for guest translations |
| TME | Intel Total Memory Encryption. The following MSRs are supported: IA32_TME_CAPABILITY, IA32_TME_ACTIVATE, IA32_TME_EXCLUDE_MASK, and IA32_TME_EXCLUDE_BASE. |
| TOPEXT | TopologyExtensions: topology extensions support. Indicates support for CPUID Fn8000_001D_EAX_x[N:0]-CPUID Fn8000_001E_EDX. |
| TSA_L1_NO | AMD only: Not vulnerable to TSA-L1 |
| TSA_SQ_NO | AMD only: Not vulnerable to TSA-SQ |
| TSA_VERW_CLEAR | AMD: If set, the memory form of the VERW instruction may be used to help mitigate TSA |
| TSCRATEMSR | MSR based TSC rate control. Indicates support for MSR TSC ratio MSRC000_0104 |
| TSXLDTRK | Intel TSX Suspend Load Address Tracking |
| VAES | Vector AES. AVX(512) versions requires additional checks. |
| VMCBCLEAN | VMCB clean bits. Indicates support for VMCB clean bits. |
| VMPL | AMD VM Permission Levels supported |
| VMSA_REGPROT | AMD VMSA Register Protection supported |
| VMX | Virtual Machine Extensions |
| VPCLMULQDQ | Carry-Less Multiplication Quadword. Requires AVX for 3 register versions. |
| VTE | AMD Virtual Transparent Encryption supported |
| WAITPKG | TPAUSE, UMONITOR, UMWAIT |
| WBNOINVD | Write Back and Do Not Invalidate Cache |
| WRMSRNS | Non-Serializing Write to Model Specific Register |
| X87 | FPU |
| XGETBV1 | Supports XGETBV with ECX = 1 |
| XOP | Bulldozer XOP functions |
| XSAVE | XSAVE, XRESTOR, XSETBV, XGETBV |
| XSAVEC | Supports XSAVEC and the compacted form of XRSTOR. |
| XSAVEOPT | XSAVEOPT available |
| XSAVES | Supports XSAVES/XRSTORS and IA32_XSS |
# ARM features:
| Feature Flag | Description |
|--------------|------------------------------------------------------------------|
| AESARM | AES instructions |
| ARMCPUID | Some CPU ID registers readable at user-level |
| ASIMD | Advanced SIMD |
| ASIMDDP | SIMD Dot Product |
| ASIMDHP | Advanced SIMD half-precision floating point |
| ASIMDRDM | Rounding Double Multiply Accumulate/Subtract (SQRDMLAH/SQRDMLSH) |
| ATOMICS | Large System Extensions (LSE) |
| CRC32 | CRC32/CRC32C instructions |
| DCPOP | Data cache clean to Point of Persistence (DC CVAP) |
| EVTSTRM | Generic timer |
| FCMA | Floatin point complex number addition and multiplication |
| FHM | FMLAL and FMLSL instructions |
| FP | Single-precision and double-precision floating point |
| FPHP | Half-precision floating point |
| GPA | Generic Pointer Authentication |
| JSCVT | Javascript-style double->int convert (FJCVTZS) |
| LRCPC | Weaker release consistency (LDAPR, etc) |
| PMULL | Polynomial Multiply instructions (PMULL/PMULL2) |
| RNDR | Random Number instructions |
| TLB | Outer Shareable and TLB range maintenance instructions |
| TS | Flag manipulation instructions |
| SHA1 | SHA-1 instructions (SHA1C, etc) |
| SHA2 | SHA-2 instructions (SHA256H, etc) |
| SHA3 | SHA-3 instructions (EOR3, RAXI, XAR, BCAX) |
| SHA512 | SHA512 instructions |
| SM3 | SM3 instructions |
| SM4 | SM4 instructions |
| SVE | Scalable Vector Extension |
# license
This code is published under an MIT license. See LICENSE file for more information.

Some files were not shown because too many files have changed in this diff Show More