Compare commits

..

48 Commits

Author SHA1 Message Date
sixsmith 9b7952bdf4 add --pindigests flag 2026-07-27 06:05:14 +00:00
sixsmith 8839ad17ba add regclient to fetch image manifest/digest
* also run `go mod vendor`
2026-07-27 06:05:14 +00:00
sixsmith abe3d4d4dd chore: mark vendor directory as generated 2026-07-27 06:05:14 +00:00
sixsmith 57ce169160 handle image digests in compose.yml 2026-07-27 05:45:37 +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
450 changed files with 39778 additions and 27849 deletions
+11 -6
View File
@@ -8,12 +8,17 @@ steps:
- 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.26
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:
+1
View File
@@ -0,0 +1 @@
vendor/** linguist-generated=true
+1 -1
View File
@@ -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 \
+3 -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
+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
)
+1 -1
View File
@@ -98,7 +98,7 @@ func GetMainAppImage(recipe recipe.Recipe) (string, error) {
}
for _, service := range config.Services {
if service.Name == "app" {
img, err := reference.ParseNormalizedNamed(service.Image)
img, err := reference.ParseNormalizedNamed(formatter.StripDigest(service.Image))
if err != nil {
return "", err
}
+1 -1
View File
@@ -322,7 +322,7 @@ func GetImageVersions(recipe recipePkg.Recipe) (map[string]string, error) {
continue
}
img, err := reference.ParseNormalizedNamed(service.Image)
img, err := reference.ParseNormalizedNamed(formatter.StripDigest(service.Image))
if err != nil {
return services, err
}
+30 -3
View File
@@ -130,7 +130,7 @@ interface.`),
}
for _, service := range config.Services {
img, err := reference.ParseNormalizedNamed(service.Image)
img, err := reference.ParseNormalizedNamed(formatter.StripDigest(service.Image))
if err != nil {
log.Fatal(err)
}
@@ -293,12 +293,31 @@ interface.`),
}
}
if upgradeTag != "skip" {
ok, err := recipe.UpdateTag(image, upgradeTag)
var ok bool
var err error
var resolvedDigest string
if internal.PinDigests {
digest, dErr := client.GetImageDigest(img, upgradeTag)
if dErr != nil {
log.Fatal(i18n.G("failed to resolve digest for %s:%s: %s", image, upgradeTag, dErr.Error()))
}
resolvedDigest = digest
ok, err = recipe.UpdatePinnedTag(image, upgradeTag, digest)
} else {
ok, err = recipe.UpdateTag(image, upgradeTag)
}
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 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 {
@@ -417,4 +436,12 @@ func init() {
false,
i18n.G("commit changes"),
)
RecipeUpgradeCommand.Flags().BoolVarP(
&internal.PinDigests,
i18n.G("pindigests"),
i18n.GC("p", "pin container image by digest"),
false,
i18n.G("pin the container image version by manifest digest (image:tag@sha256:...)"),
)
}
Generated
+61
View File
@@ -0,0 +1,61 @@
{
"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"
}
},
"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",
"nixpkgs": "nixpkgs"
}
},
"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
}
+37
View File
@@ -0,0 +1,37 @@
{
description = "The Co-op Cloud utility belt";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs =
{
self,
nixpkgs,
flake-utils,
}:
flake-utils.lib.eachDefaultSystem (
system:
let
pkgs = nixpkgs.legacyPackages.${system};
in
{
packages = rec {
abra = pkgs.callPackage ./package.nix { };
default = abra;
};
apps = rec {
abra = flake-utils.lib.mkApp { drv = self.packages.${system}.abra; };
default = abra;
};
devShells.default = pkgs.mkShell {
packages = with pkgs; [
go_1_26
gnumake
];
};
}
);
}
+30 -28
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.18.0
github.com/go-git/go-git/v5 v5.19.1
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.42.0
github.com/regclient/regclient v0.11.5
github.com/schollz/progressbar/v3 v3.19.1
golang.org/x/term v0.45.0
gopkg.in/yaml.v3 v3.0.1
gotest.tools/v3 v3.5.2
)
@@ -37,7 +38,7 @@ require (
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/x/ansi v0.11.7 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
@@ -52,7 +53,7 @@ require (
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.7.0 // indirect
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-metrics v0.0.1 // indirect
github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
@@ -60,14 +61,14 @@ 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.0 // 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
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
@@ -79,12 +80,12 @@ require (
github.com/mattn/go-colorable v0.1.14 // 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.23 // indirect
github.com/mattn/go-runewidth v0.0.21 // indirect
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
github.com/miekg/pkcs11 v1.1.1 // indirect
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.2.0 // indirect
github.com/moby/go-archive v0.1.0 // indirect
github.com/moby/sys/atomicwriter v0.1.0 // indirect
github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
@@ -106,31 +107,32 @@ 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
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
go.opentelemetry.io/otel v1.42.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/sdk v1.43.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.42.0 // indirect
go.opentelemetry.io/otel/sdk v1.42.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.42.0 // indirect
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.50.0 // indirect
golang.org/x/crypto v0.52.0 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/text v0.36.0 // indirect
golang.org/x/net v0.54.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260427160629-7cedc36a6bc4 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 // 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
google.golang.org/grpc v1.80.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
@@ -141,7 +143,7 @@ require (
github.com/containers/image v3.0.2+incompatible
github.com/containers/storage v1.38.2 // indirect
github.com/decentral1se/passgen v1.0.1
github.com/docker/docker-credential-helpers v0.9.6 // indirect
github.com/docker/docker-credential-helpers v0.9.5 // indirect
github.com/fvbommel/sortorder v1.1.0 // indirect
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
github.com/gorilla/mux v1.8.1 // indirect
@@ -151,13 +153,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/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
github.com/theupdateframework/notary v0.7.0 // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
golang.org/x/sys v0.43.0
golang.org/x/sys v0.47.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
+30 -80
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=
@@ -130,7 +130,6 @@ github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyY
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
@@ -145,8 +144,6 @@ github.com/charmbracelet/log v1.0.0 h1:HVVVMmfOorfj3BA9i8X8UL69Hoz9lI0PYwXfJvOdR
github.com/charmbracelet/log v1.0.0/go.mod h1:uYgY3SmLpwJWxmlrPwXvzVYujxis1vAKRV/0VQB7yWA=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
@@ -311,8 +308,8 @@ github.com/d2g/hardwareaddr v0.0.0-20190221164911-e7d9fbe030e4/go.mod h1:bMl4RjI
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=
@@ -334,15 +331,11 @@ github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bc
github.com/docker/docker-credential-helpers v0.6.3/go.mod h1:WRaJzqw3CTB9bk10avuGsjVBZsD05qeibJ1/TYlvc0Y=
github.com/docker/docker-credential-helpers v0.9.5 h1:EFNN8DHvaiK8zVqFA2DT6BjXE0GzfLOZ38ggPTKePkY=
github.com/docker/docker-credential-helpers v0.9.5/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c=
github.com/docker/docker-credential-helpers v0.9.6 h1:cT2PbRPSlnMmNTfT2TDMXRyQ1KMWHG7xoTLBcn1ZNv0=
github.com/docker/docker-credential-helpers v0.9.6/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c=
github.com/docker/go v1.5.1-1.0.20160303222718-d30aec9fd63c h1:lzqkGL9b3znc+ZUgi7FlLnqjQhcXxkNM/quxIjBVMD0=
github.com/docker/go v1.5.1-1.0.20160303222718-d30aec9fd63c/go.mod h1:CADgU4DSXK5QUlFslkQu2yW2TKzFZcXq/leZfM0UH5Q=
github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
github.com/docker/go-events v0.0.0-20170721190031-9461782956ad/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA=
github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA=
github.com/docker/go-metrics v0.0.0-20180209012529-399ea8c73916/go.mod h1:/u0gXw0Gay3ceNrsHubL3BtdOL2fHf93USgMTe0W5dI=
@@ -396,14 +389,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.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA=
github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw=
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.18.0 h1:O831KI+0PR51hM2kep6T8k+w0/LIAD490gvqMCvL5hM=
github.com/go-git/go-git/v5 v5.18.0/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQqO7oZADXpo=
github.com/go-git/go-git/v5 v5.19.1 h1:nX27AnaU43/K5bKktKwgBmR9lawoYVe1Ckg0rgzzN00=
github.com/go-git/go-git/v5 v5.19.1/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ=
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=
@@ -436,6 +427,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=
@@ -534,12 +527,9 @@ github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:Fecb
github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
github.com/grpc-ecosystem/grpc-gateway v1.9.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo=
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed/go.mod h1:tMWxXQ9wFIaZeTI9F+hmhFiGpFmhOHzyShyFUhRm0H4=
github.com/hashicorp/errwrap v0.0.0-20141028054710-7554cd9344ce/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
@@ -596,8 +586,6 @@ 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/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
@@ -637,8 +625,6 @@ 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=
@@ -646,8 +632,6 @@ github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+Ei
github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-shellwords v1.0.3/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vqg+NOMyg4B2o=
github.com/mattn/go-shellwords v1.0.6/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vqg+NOMyg4B2o=
github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
@@ -672,8 +656,6 @@ github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3N
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ=
github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo=
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
github.com/moby/locker v1.0.1/go.mod h1:S7SDdo5zpBK84bzzVlKr2V0hz+7x9hWbYC/kq7oQppc=
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
@@ -719,6 +701,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=
@@ -773,8 +757,6 @@ 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.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU=
github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@@ -818,6 +800,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=
@@ -825,15 +809,13 @@ github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6L
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday v1.6.0 h1:KqfZb0pUVN2lYqZUYRddxF4OR8ZMURnJIG5Y3VRLtww=
github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
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=
@@ -883,6 +865,8 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
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/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=
@@ -894,6 +878,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=
@@ -941,42 +927,24 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0 h1:MdKucPl/HbzckWWEisiNqMPhRrAOQX8r4jTuGr636gk=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0/go.mod h1:RolT8tWtfHcjajEH5wFIZ4Dgh5jpPdFXYV9pTAk/qjc=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 h1:8UQVDcZxOJLtX6gxtDt3vY2WTgvZqMQRzjsqiIHQdkc=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0/go.mod h1:2lmweYCiHYpEjQ/lSJBYhj9jP1zvCvQW4BqL9dnT7FQ=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 h1:THuZiwpQZuHPul65w4WcwEnkX2QIuMT+UFoOrygtoJw=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0/go.mod h1:J2pvYM5NGHofZ2/Ru6zw/TNWnEQp5crgyDeSrYpXkAw=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 h1:zWWrB1U6nqhS/k6zYB74CjRpuiitRtLLi68VcgmOEto=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0/go.mod h1:2qXPNBX1OVRC0IwOnfo1ljoid+RD0QK3443EaqVlsOU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU=
go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo=
go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=
go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
@@ -1008,10 +976,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.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
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=
@@ -1022,8 +988,6 @@ 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=
@@ -1089,10 +1053,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.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
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=
@@ -1187,18 +1149,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.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
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.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
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=
@@ -1208,10 +1165,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.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
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=
@@ -1317,12 +1272,8 @@ google.golang.org/genproto v0.0.0-20200527145253-8367513e4ece/go.mod h1:jDfRM7Fc
google.golang.org/genproto v0.0.0-20201110150050-8816d57aaa9a/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
google.golang.org/genproto/googleapis/api v0.0.0-20260427160629-7cedc36a6bc4 h1:yOzSCGPx+cp5VO7IxvZ9SBFF7j1tZVcNtlHR2iYKtVo=
google.golang.org/genproto/googleapis/api v0.0.0-20260427160629-7cedc36a6bc4/go.mod h1:Q9HWtNeE7tM9npdIsEvqXj1QJIvVoeAV3rtXtS715Cw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 h1:tEkOQcXgF6dH1G+MVKZrfpYvozGrzb91k6ha7jireSM=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v0.0.0-20160317175043-d3ddb4469d5a/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
google.golang.org/grpc v1.0.5/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
@@ -1396,7 +1347,6 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk=
gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8=
+54
View File
@@ -0,0 +1,54 @@
{
buildGo126Module,
fetchgit,
lib,
installShellFiles,
}:
buildGo126Module rec {
pname = "abra";
version = "0.13.0-beta";
rev = "06a57ded025a43c80f94d4e65299add8a31830dc";
src = fetchgit {
url = "https://git.coopcloud.tech/toolshed/abra.git";
tag = version;
hash = "sha256-rgoK0TY0WLSQ39lPvVM80zW/qJF40VFBSxYDOaKXZQo=";
};
vendorHash = null;
nativeBuildInputs = [
installShellFiles
];
env.CGO_ENABLED = 0;
buildPhase = ''
runHook preBuild
go build -ldflags="-s -w -X 'main.Commit=${rev}' -X 'main.Version=${version}'" ./cmd/abra
runHook postBuild
'';
installPhase = ''
runHook preInstall
install -D abra $out/bin/abra
runHook postInstall
'';
postInstall = ''
export ABRA_DIR="$out"
$out/bin/abra autocomplete bash >abra.bash
$out/bin/abra autocomplete fish >abra.fish
$out/bin/abra autocomplete zsh >abra.zsh
installShellCompletion abra.{bash,fish,zsh}
'';
meta = with lib; {
description = "The Co-op Cloud utility belt";
homepage = "https://docs.coopcloud.tech/abra";
changelog = "https://git.coopcloud.tech/toolshed/abra/releases/tag/${version}";
mainProgram = "abra";
license = licenses.gpl3Plus;
maintainers = "devydave";
};
}
+24
View File
@@ -9,8 +9,12 @@ 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"
)
var rc = regclient.New()
// GetRegistryTags retrieves all tags of an image from a container registry.
func GetRegistryTags(img reference.Named) ([]string, error) {
var tags []string
@@ -28,3 +32,23 @@ 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: when
// the tag points at a manifest list/OCI index, the digest returned is the
// digest of that top-level index, so the pin still resolves correctly
// regardless of the deploying host's architecture.
func GetImageDigest(img reference.Named, tag string) (string, error) {
r, err := ref.New(fmt.Sprintf("%s:%s", img.Name(), tag))
if err != nil {
return "", fmt.Errorf("parsing reference %s:%s: %w", img.Name(), tag, err)
}
m, err := rc.ManifestHead(context.Background(), r)
if err != nil {
return "", fmt.Errorf("fetching manifest for %s:%s: %w", img.Name(), tag, err)
}
return m.GetDescriptor().Digest.String(), nil
}
+2 -2
View File
@@ -158,7 +158,7 @@ func GetImagesForStack(cl *dockerClient.Client, app appPkg.App) (map[string]stri
if service.Spec.TaskTemplate.ContainerSpec != nil {
imageName := service.Spec.TaskTemplate.ContainerSpec.Image
imageParsed, err := reference.ParseNormalizedNamed(imageName)
imageParsed, err := reference.ParseNormalizedNamed(formatter.StripDigest(imageName))
if err != nil {
log.Warn(err)
continue
@@ -281,7 +281,7 @@ func GatherImagesForDeploy(cl *dockerClient.Client, app appPkg.App, compose *com
newImages := make(map[string]string)
for _, service := range compose.Services {
imageParsed, err := reference.ParseNormalizedNamed(service.Image)
imageParsed, err := reference.ParseNormalizedNamed(formatter.StripDigest(service.Image))
if err != nil {
log.Warn(err)
continue
+5
View File
@@ -34,6 +34,11 @@ func SmallSHA(hash string) string {
return hash[:8]
}
// StripDigest removes the digest suffix from an image reference (e.g., "image:tag@sha256:..." -> "image:tag").
func StripDigest(image string) string {
return strings.Split(image, "@")[0]
}
// RemoveSha remove image sha from a string that are added in some docker outputs
func RemoveSha(str string) string {
return strings.Split(str, "@")[0]
+7
View File
@@ -6,6 +6,13 @@ import (
"github.com/stretchr/testify/assert"
)
func TestStripDigest(t *testing.T) {
assert.Equal(t, "ubuntu:latest", StripDigest("ubuntu:latest@sha256:1234567890abcdef"))
assert.Equal(t, "ubuntu:latest", StripDigest("ubuntu:latest"))
assert.Equal(t, "my-repo/my-image:v1", StripDigest("my-repo/my-image:v1@sha256:abcdef1234567890"))
assert.Equal(t, "my-repo/my-image", StripDigest("my-repo/my-image@sha256:abcdef1234567890"))
}
func TestBoldDirtyDefault(t *testing.T) {
assert.Equal(t, "foo", BoldDirtyDefault("foo"))
}
+83 -83
View File
@@ -7,7 +7,7 @@
msgid ""
msgstr "Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-04-11 11:34+0200\n"
"POT-Creation-Date: 2026-06-14 17:56+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -214,7 +214,7 @@ msgstr ""
msgid "%s already exists?"
msgstr ""
#: ./cli/app/new.go:202
#: ./cli/app/new.go:206
#, c-format
msgid "%s created (version: %s)"
msgstr ""
@@ -299,7 +299,7 @@ msgstr ""
msgid "%s has no published versions?"
msgstr ""
#: ./cli/app/new.go:311
#: ./cli/app/new.go:315
#, c-format
msgid "%s has no secrets to generate, skipping..."
msgstr ""
@@ -339,17 +339,17 @@ msgstr ""
msgid "%s is missing the TYPE env var?"
msgstr ""
#: ./cli/app/rollback.go:309 ./cli/app/rollback.go:313
#: ./cli/app/rollback.go:310 ./cli/app/rollback.go:314
#, c-format
msgid "%s is not a downgrade for %s?"
msgstr ""
#: ./cli/app/upgrade.go:429 ./cli/app/upgrade.go:433
#: ./cli/app/upgrade.go:430 ./cli/app/upgrade.go:434
#, c-format
msgid "%s is not an upgrade for %s?"
msgstr ""
#: ./cli/app/env.go:146 ./cli/app/logs.go:65 ./cli/app/ps.go:62 ./cli/app/restart.go:100 ./cli/app/services.go:55 ./cli/app/undeploy.go:66 ./cli/app/upgrade.go:450
#: ./cli/app/env.go:146 ./cli/app/logs.go:65 ./cli/app/ps.go:62 ./cli/app/restart.go:100 ./cli/app/services.go:55 ./cli/app/undeploy.go:66 ./cli/app/upgrade.go:451
#, c-format
msgid "%s is not deployed?"
msgstr ""
@@ -369,7 +369,7 @@ msgstr ""
msgid "%s missing context, run \"abra server add %s\"?"
msgstr ""
#: ./cli/app/deploy.go:194 ./cli/app/upgrade.go:210
#: ./cli/app/deploy.go:195 ./cli/app/upgrade.go:211
#, c-format
msgid "%s missing from %s.env"
msgstr ""
@@ -404,17 +404,17 @@ msgstr ""
msgid "%s removed from pass store"
msgstr ""
#: ./cli/app/new.go:220
#: ./cli/app/new.go:224
#, c-format
msgid "%s requires secret generation before deploy, run \"abra app secret generate %s --all\""
msgstr ""
#: ./cli/app/new.go:224
#: ./cli/app/new.go:228
#, c-format
msgid "%s requires secret insertion before deploy (#generate=false)"
msgstr ""
#: ./cli/app/new.go:151
#: ./cli/app/new.go:155
#, c-format
msgid "%s sanitised as %s for new app"
msgstr ""
@@ -549,12 +549,12 @@ msgstr ""
msgid "%s: waiting %d seconds before next retry"
msgstr ""
#: ./cli/app/upgrade.go:424
#: ./cli/app/upgrade.go:425
#, c-format
msgid "'%s' is not a known version"
msgstr ""
#: ./cli/app/rollback.go:304 ./cli/app/upgrade.go:419
#: ./cli/app/rollback.go:305 ./cli/app/upgrade.go:420
#, c-format
msgid "'%s' is not a known version for %s"
msgstr ""
@@ -626,7 +626,7 @@ msgstr ""
msgid "Both local recipe and live deployment labels are shown."
msgstr ""
#: ./cli/app/backup.go:319 ./cli/app/backup.go:335 ./cli/app/check.go:95 ./cli/app/cmd.go:285 ./cli/app/cp.go:385 ./cli/app/deploy.go:414 ./cli/app/labels.go:143 ./cli/app/list.go:335 ./cli/app/new.go:407 ./cli/app/ps.go:213 ./cli/app/restart.go:163 ./cli/app/restore.go:138 ./cli/app/secret.go:569 ./cli/app/secret.go:609 ./cli/app/secret.go:633 ./cli/app/secret.go:641 ./cli/catalogue/catalogue.go:318 ./cli/recipe/lint.go:137
#: ./cli/app/backup.go:319 ./cli/app/backup.go:335 ./cli/app/check.go:95 ./cli/app/cmd.go:285 ./cli/app/cp.go:385 ./cli/app/deploy.go:415 ./cli/app/labels.go:143 ./cli/app/list.go:335 ./cli/app/new.go:411 ./cli/app/ps.go:213 ./cli/app/restart.go:163 ./cli/app/restore.go:138 ./cli/app/secret.go:569 ./cli/app/secret.go:609 ./cli/app/secret.go:633 ./cli/app/secret.go:641 ./cli/catalogue/catalogue.go:318 ./cli/recipe/lint.go:137
msgid "C"
msgstr ""
@@ -767,7 +767,7 @@ msgid "Creates a new app from a default recipe.\n"
"on your $PATH."
msgstr ""
#: ./cli/app/deploy.go:430 ./cli/app/new.go:383 ./cli/app/rollback.go:361 ./cli/app/upgrade.go:470
#: ./cli/app/deploy.go:431 ./cli/app/new.go:387 ./cli/app/rollback.go:362 ./cli/app/upgrade.go:471
msgid "D"
msgstr ""
@@ -878,7 +878,7 @@ msgid "Generate a report of all managed apps.\n"
"Use \"--status/-S\" flag to query all servers for the live deployment status."
msgstr ""
#: ./cli/app/new.go:317
#: ./cli/app/new.go:321
msgid "Generate app secrets?"
msgstr ""
@@ -1257,7 +1257,7 @@ msgstr ""
msgid "Run app commands"
msgstr ""
#: ./cli/app/backup.go:303 ./cli/app/list.go:296 ./cli/app/logs.go:109 ./cli/app/new.go:399
#: ./cli/app/backup.go:303 ./cli/app/list.go:296 ./cli/app/logs.go:109 ./cli/app/new.go:403
msgid "S"
msgstr ""
@@ -1265,7 +1265,7 @@ msgstr ""
msgid "SECRETS"
msgstr ""
#: ./cli/app/new.go:234
#: ./cli/app/new.go:238
msgid "SECRETS OVERVIEW"
msgstr ""
@@ -1298,7 +1298,7 @@ msgstr ""
msgid "STATUS"
msgstr ""
#: ./cli/app/new.go:342
#: ./cli/app/new.go:346
msgid "Select app server:"
msgstr ""
@@ -1330,7 +1330,7 @@ msgstr ""
msgid "Specify a server name"
msgstr ""
#: ./cli/app/new.go:293
#: ./cli/app/new.go:297
msgid "Specify app domain"
msgstr ""
@@ -1462,7 +1462,7 @@ msgid "To load completions:\n"
" # and source this file from your PowerShell profile."
msgstr ""
#: ./cli/app/deploy.go:454 ./cli/app/rollback.go:377 ./cli/app/upgrade.go:494
#: ./cli/app/deploy.go:455 ./cli/app/rollback.go:378 ./cli/app/upgrade.go:495
msgid "U"
msgstr ""
@@ -1835,7 +1835,7 @@ msgstr ""
msgid "attempting to run %s"
msgstr ""
#: ./cli/app/deploy.go:283 ./cli/app/upgrade.go:296
#: ./cli/app/deploy.go:284 ./cli/app/upgrade.go:297
#, c-format
msgid "attempting to run post deploy commands, saw: %s"
msgstr ""
@@ -1865,7 +1865,7 @@ msgstr ""
msgid "autocomplete failed: %s"
msgstr ""
#: ./cli/app/new.go:401
#: ./cli/app/new.go:405
msgid "automatically generate secrets"
msgstr ""
@@ -1915,7 +1915,7 @@ msgstr ""
#. no spaces in between
#. translators: `abra app cp` aliases. use a comma separated list of aliases with
#. no spaces in between
#: ./cli/app/backup.go:148 ./cli/app/cp.go:30 ./cli/app/deploy.go:438 ./cli/app/rollback.go:369 ./cli/app/upgrade.go:478
#: ./cli/app/backup.go:148 ./cli/app/cp.go:30 ./cli/app/deploy.go:439 ./cli/app/rollback.go:370 ./cli/app/upgrade.go:479
msgid "c"
msgstr ""
@@ -1971,7 +1971,7 @@ msgstr ""
msgid "cannot redeploy previous chaos version (%s), did you mean to use \"--chaos\"?"
msgstr ""
#: ./cli/app/deploy.go:388
#: ./cli/app/deploy.go:389
#, c-format
msgid "cannot redeploy previous chaos version (%s), did you mean to use \"--chaos\"?\n"
" to return to a regular release, specify a release tag, commit SHA or use \"--latest\""
@@ -1990,7 +1990,7 @@ msgstr ""
msgid "cannot use '[secret] [version]' and '--all' together"
msgstr ""
#: ./cli/app/deploy.go:330
#: ./cli/app/deploy.go:331
msgid "cannot use --chaos and --latest together"
msgstr ""
@@ -2014,11 +2014,11 @@ msgstr ""
msgid "cannot use [service] and --all-services/-a together"
msgstr ""
#: ./cli/app/deploy.go:322 ./cli/app/new.go:80
#: ./cli/app/deploy.go:323 ./cli/app/new.go:80
msgid "cannot use [version] and --chaos together"
msgstr ""
#: ./cli/app/deploy.go:326
#: ./cli/app/deploy.go:327
msgid "cannot use [version] and --latest together"
msgstr ""
@@ -2054,7 +2054,7 @@ msgstr ""
msgid "cfg"
msgstr ""
#: ./cli/app/backup.go:318 ./cli/app/backup.go:334 ./cli/app/check.go:94 ./cli/app/cmd.go:284 ./cli/app/cp.go:384 ./cli/app/deploy.go:413 ./cli/app/labels.go:142 ./cli/app/list.go:334 ./cli/app/new.go:406 ./cli/app/ps.go:212 ./cli/app/restart.go:162 ./cli/app/restore.go:137 ./cli/app/secret.go:568 ./cli/app/secret.go:608 ./cli/app/secret.go:632 ./cli/app/secret.go:640 ./cli/catalogue/catalogue.go:317 ./cli/recipe/lint.go:136
#: ./cli/app/backup.go:318 ./cli/app/backup.go:334 ./cli/app/check.go:94 ./cli/app/cmd.go:284 ./cli/app/cp.go:384 ./cli/app/deploy.go:414 ./cli/app/labels.go:142 ./cli/app/list.go:334 ./cli/app/new.go:410 ./cli/app/ps.go:212 ./cli/app/restart.go:162 ./cli/app/restore.go:137 ./cli/app/secret.go:568 ./cli/app/secret.go:608 ./cli/app/secret.go:632 ./cli/app/secret.go:640 ./cli/catalogue/catalogue.go:317 ./cli/recipe/lint.go:136
msgid "chaos"
msgstr ""
@@ -2068,7 +2068,7 @@ msgstr ""
msgid "check <domain> [flags]"
msgstr ""
#: ./cli/app/deploy.go:95 ./cli/app/undeploy.go:58 ./cli/app/upgrade.go:442
#: ./cli/app/deploy.go:95 ./cli/app/undeploy.go:58 ./cli/app/upgrade.go:443
#, c-format
msgid "checking whether %s is already deployed"
msgstr ""
@@ -2371,7 +2371,7 @@ msgstr ""
msgid "critical errors present in %s config"
msgstr ""
#: ./cli/app/rollback.go:299
#: ./cli/app/rollback.go:300
#, c-format
msgid "current deployment '%s' is not a known version for %s"
msgstr ""
@@ -2422,7 +2422,7 @@ msgstr ""
msgid "deploy labels stanza present"
msgstr ""
#: ./cli/app/deploy.go:448
#: ./cli/app/deploy.go:449
msgid "deploy latest recipe version"
msgstr ""
@@ -2524,11 +2524,11 @@ msgstr ""
msgid "dirty: %v, "
msgstr ""
#: ./cli/app/deploy.go:440 ./cli/app/rollback.go:371 ./cli/app/upgrade.go:480
#: ./cli/app/deploy.go:441 ./cli/app/rollback.go:372 ./cli/app/upgrade.go:481
msgid "disable converge logic checks"
msgstr ""
#: ./cli/app/deploy.go:432 ./cli/app/rollback.go:363 ./cli/app/upgrade.go:472
#: ./cli/app/deploy.go:433 ./cli/app/rollback.go:364 ./cli/app/upgrade.go:473
msgid "disable public DNS checks"
msgstr ""
@@ -2544,11 +2544,11 @@ msgstr ""
msgid "docker: is the daemon running / your user has docker permissions?"
msgstr ""
#: ./cli/app/new.go:382
#: ./cli/app/new.go:386
msgid "domain"
msgstr ""
#: ./cli/app/new.go:385
#: ./cli/app/new.go:389
msgid "domain name for app"
msgstr ""
@@ -2737,7 +2737,7 @@ msgstr ""
#. translators: `abra recipe fetch` aliases. use a comma separated list of aliases
#. with no spaces in between
#: ./cli/app/deploy.go:422 ./cli/app/env.go:325 ./cli/app/remove.go:163 ./cli/app/rollback.go:353 ./cli/app/secret.go:593 ./cli/app/upgrade.go:462 ./cli/app/volume.go:217 ./cli/recipe/fetch.go:20 ./cli/recipe/fetch.go:138
#: ./cli/app/deploy.go:423 ./cli/app/env.go:325 ./cli/app/remove.go:163 ./cli/app/rollback.go:354 ./cli/app/secret.go:593 ./cli/app/upgrade.go:463 ./cli/app/volume.go:217 ./cli/recipe/fetch.go:20 ./cli/recipe/fetch.go:138
msgid "f"
msgstr ""
@@ -2898,7 +2898,7 @@ msgstr ""
msgid "failed to resize tty, using default size"
msgstr ""
#: ./cli/app/new.go:134
#: ./cli/app/new.go:138
#, c-format
msgid "failed to retrieve latest commit for %s: %s"
msgstr ""
@@ -2988,7 +2988,7 @@ msgstr ""
msgid "final merged env values for %s are: %s"
msgstr ""
#: ./cli/app/deploy.go:421 ./cli/app/env.go:324 ./cli/app/remove.go:162 ./cli/app/rollback.go:352 ./cli/app/upgrade.go:461 ./cli/app/volume.go:216 ./cli/recipe/fetch.go:137
#: ./cli/app/deploy.go:422 ./cli/app/env.go:324 ./cli/app/remove.go:162 ./cli/app/rollback.go:353 ./cli/app/upgrade.go:462 ./cli/app/volume.go:216 ./cli/recipe/fetch.go:137
msgid "force"
msgstr ""
@@ -3202,7 +3202,7 @@ msgstr ""
msgid "id: %s, "
msgstr ""
#: ./cli/app/backup.go:321 ./cli/app/backup.go:337 ./cli/app/check.go:97 ./cli/app/cmd.go:287 ./cli/app/cp.go:387 ./cli/app/deploy.go:416 ./cli/app/labels.go:145 ./cli/app/list.go:337 ./cli/app/new.go:409 ./cli/app/ps.go:215 ./cli/app/restart.go:165 ./cli/app/restore.go:140 ./cli/app/secret.go:571 ./cli/app/secret.go:611 ./cli/app/secret.go:635 ./cli/app/secret.go:643 ./cli/catalogue/catalogue.go:320 ./cli/recipe/lint.go:139
#: ./cli/app/backup.go:321 ./cli/app/backup.go:337 ./cli/app/check.go:97 ./cli/app/cmd.go:287 ./cli/app/cp.go:387 ./cli/app/deploy.go:417 ./cli/app/labels.go:145 ./cli/app/list.go:337 ./cli/app/new.go:413 ./cli/app/ps.go:215 ./cli/app/restart.go:165 ./cli/app/restore.go:140 ./cli/app/secret.go:571 ./cli/app/secret.go:611 ./cli/app/secret.go:635 ./cli/app/secret.go:643 ./cli/catalogue/catalogue.go:320 ./cli/recipe/lint.go:139
msgid "ignore uncommitted recipes changes"
msgstr ""
@@ -3400,7 +3400,7 @@ msgstr ""
#. no spaces in between
#. translators: `abra recipe lint` aliases. use a comma separated list of
#. aliases with no spaces in between
#: ./cli/app/cmd.go:261 ./cli/app/deploy.go:446 ./cli/app/logs.go:20 ./cli/recipe/lint.go:17 ./cli/server/add.go:207
#: ./cli/app/cmd.go:261 ./cli/app/deploy.go:447 ./cli/app/logs.go:20 ./cli/recipe/lint.go:17 ./cli/server/add.go:207
msgid "l"
msgstr ""
@@ -3415,7 +3415,7 @@ msgstr ""
msgid "labels <domain> [flags]"
msgstr ""
#: ./cli/app/deploy.go:445 ./cli/app/list.go:186
#: ./cli/app/deploy.go:446 ./cli/app/list.go:186
msgid "latest"
msgstr ""
@@ -3752,7 +3752,7 @@ msgstr ""
msgid "no containers matching the %v filter found?"
msgstr ""
#: ./cli/app/new.go:302 ./cli/internal/validate.go:129
#: ./cli/app/new.go:306 ./cli/internal/validate.go:129
msgid "no domain provided"
msgstr ""
@@ -3779,7 +3779,7 @@ msgstr ""
msgid "no recipe name provided"
msgstr ""
#: ./cli/app/upgrade.go:241
#: ./cli/app/upgrade.go:242
#, c-format
msgid "no release notes for upgrading from %s to %s"
msgstr ""
@@ -3810,7 +3810,7 @@ msgstr ""
msgid "no secrets to remove?"
msgstr ""
#: ./cli/app/new.go:351 ./cli/internal/validate.go:167
#: ./cli/app/new.go:355 ./cli/internal/validate.go:167
msgid "no server provided"
msgstr ""
@@ -3868,11 +3868,11 @@ msgstr ""
msgid "no volumes to remove"
msgstr ""
#: ./cli/app/deploy.go:437 ./cli/app/rollback.go:368 ./cli/app/upgrade.go:477
#: ./cli/app/deploy.go:438 ./cli/app/rollback.go:369 ./cli/app/upgrade.go:478
msgid "no-converge-checks"
msgstr ""
#: ./cli/app/deploy.go:429 ./cli/app/rollback.go:360 ./cli/app/upgrade.go:469
#: ./cli/app/deploy.go:430 ./cli/app/rollback.go:361 ./cli/app/upgrade.go:470
msgid "no-domain-checks"
msgstr ""
@@ -3940,7 +3940,7 @@ msgstr ""
msgid "only show errors"
msgstr ""
#: ./cli/app/upgrade.go:488
#: ./cli/app/upgrade.go:489
msgid "only show release notes"
msgstr ""
@@ -3952,7 +3952,7 @@ msgstr ""
#. with no spaces in between
#. translators: `abra server prune` aliases. use a comma separated list of
#. aliases with no spaces in between
#: ./cli/app/backup.go:295 ./cli/app/new.go:391 ./cli/app/ps.go:29 ./cli/app/secret.go:561 ./cli/app/secret.go:585 ./cli/app/secret.go:625 ./cli/app/undeploy.go:169 ./cli/catalogue/catalogue.go:294 ./cli/recipe/list.go:112 ./cli/server/prune.go:18
#: ./cli/app/backup.go:295 ./cli/app/new.go:395 ./cli/app/ps.go:29 ./cli/app/secret.go:561 ./cli/app/secret.go:585 ./cli/app/secret.go:625 ./cli/app/undeploy.go:169 ./cli/catalogue/catalogue.go:294 ./cli/recipe/list.go:112 ./cli/server/prune.go:18
msgid "p"
msgstr ""
@@ -3971,27 +3971,27 @@ msgstr ""
msgid "parsed following command arguments: %s"
msgstr ""
#: ./cli/app/upgrade.go:345
#: ./cli/app/upgrade.go:346
#, c-format
msgid "parsing chosen upgrade version failed: %s"
msgstr ""
#: ./cli/app/upgrade.go:389
#: ./cli/app/upgrade.go:390
#, c-format
msgid "parsing deployed version failed: %s"
msgstr ""
#: ./cli/app/upgrade.go:350
#: ./cli/app/upgrade.go:351
#, c-format
msgid "parsing deployment version failed: %s"
msgstr ""
#: ./cli/app/upgrade.go:356 ./cli/app/upgrade.go:395
#: ./cli/app/upgrade.go:357 ./cli/app/upgrade.go:396
#, c-format
msgid "parsing recipe version failed: %s"
msgstr ""
#: ./cli/app/new.go:390 ./cli/app/secret.go:560 ./cli/app/secret.go:584 ./cli/app/secret.go:624
#: ./cli/app/new.go:394 ./cli/app/secret.go:560 ./cli/app/secret.go:584 ./cli/app/secret.go:624
msgid "pass"
msgstr ""
@@ -4011,7 +4011,7 @@ msgstr ""
msgid "pattern"
msgstr ""
#: ./cli/app/deploy.go:424 ./cli/app/env.go:327 ./cli/app/remove.go:165 ./cli/app/rollback.go:355 ./cli/app/upgrade.go:464 ./cli/app/volume.go:219
#: ./cli/app/deploy.go:425 ./cli/app/env.go:327 ./cli/app/remove.go:165 ./cli/app/rollback.go:356 ./cli/app/upgrade.go:465 ./cli/app/volume.go:219
msgid "perform action without further prompt"
msgstr ""
@@ -4021,22 +4021,22 @@ msgstr ""
msgid "pl,p"
msgstr ""
#: ./cli/app/rollback.go:267
#: ./cli/app/rollback.go:268
#, c-format
msgid "please select a downgrade (version: %s):"
msgstr ""
#: ./cli/app/rollback.go:272
#: ./cli/app/rollback.go:273
#, c-format
msgid "please select a downgrade (version: %s, chaos: %s):"
msgstr ""
#: ./cli/app/upgrade.go:312
#: ./cli/app/upgrade.go:313
#, c-format
msgid "please select an upgrade (version: %s):"
msgstr ""
#: ./cli/app/upgrade.go:317
#: ./cli/app/upgrade.go:318
#, c-format
msgid "please select an upgrade (version: %s, chaos: %s):"
msgstr ""
@@ -4119,7 +4119,7 @@ msgstr ""
#. with no spaces in between
#. translators: `abra recipe` aliases. use a comma separated list of aliases
#. with no spaces in between
#: ./cli/app/backup.go:327 ./cli/app/list.go:304 ./cli/app/move.go:350 ./cli/app/run.go:23 ./cli/app/upgrade.go:486 ./cli/catalogue/catalogue.go:302 ./cli/recipe/recipe.go:12 ./cli/recipe/release.go:624
#: ./cli/app/backup.go:327 ./cli/app/list.go:304 ./cli/app/move.go:350 ./cli/app/run.go:23 ./cli/app/upgrade.go:487 ./cli/catalogue/catalogue.go:302 ./cli/recipe/recipe.go:12 ./cli/recipe/release.go:624
msgid "r"
msgstr ""
@@ -4239,7 +4239,7 @@ msgstr ""
msgid "release failed. any changes made have been reverted"
msgstr ""
#: ./cli/app/upgrade.go:485
#: ./cli/app/upgrade.go:486
msgid "releasenotes"
msgstr ""
@@ -4543,7 +4543,7 @@ msgstr ""
msgid "run command locally"
msgstr ""
#: ./cli/app/deploy.go:281 ./cli/app/upgrade.go:293
#: ./cli/app/deploy.go:282 ./cli/app/upgrade.go:294
#, c-format
msgid "run the following post-deploy commands: %s"
msgstr ""
@@ -4584,7 +4584,7 @@ msgstr ""
#. no spaces in between
#. translators: `abra server` aliases. use a comma separated list of aliases
#. with no spaces in between
#: ./cli/app/backup.go:198 ./cli/app/backup.go:263 ./cli/app/backup.go:287 ./cli/app/env.go:333 ./cli/app/list.go:327 ./cli/app/logs.go:101 ./cli/app/new.go:368 ./cli/app/restore.go:114 ./cli/app/secret.go:535 ./cli/catalogue/catalogue.go:27 ./cli/catalogue/catalogue.go:310 ./cli/recipe/fetch.go:130 ./cli/server/server.go:12
#: ./cli/app/backup.go:198 ./cli/app/backup.go:263 ./cli/app/backup.go:287 ./cli/app/env.go:333 ./cli/app/list.go:327 ./cli/app/logs.go:101 ./cli/app/new.go:372 ./cli/app/restore.go:114 ./cli/app/secret.go:535 ./cli/catalogue/catalogue.go:27 ./cli/catalogue/catalogue.go:310 ./cli/recipe/fetch.go:130 ./cli/server/server.go:12
msgid "s"
msgstr ""
@@ -4626,12 +4626,12 @@ msgstr ""
msgid "secret not found: %s"
msgstr ""
#: ./cli/app/deploy.go:358
#: ./cli/app/deploy.go:359
#, c-format
msgid "secret not generated: %s"
msgstr ""
#: ./cli/app/deploy.go:356
#: ./cli/app/deploy.go:357
#, c-format
msgid "secret not inserted (#generate=false): %s"
msgstr ""
@@ -4641,27 +4641,27 @@ msgstr ""
msgid "secret: %s removed"
msgstr ""
#: ./cli/app/backup.go:302 ./cli/app/new.go:398
#: ./cli/app/backup.go:302 ./cli/app/new.go:402
msgid "secrets"
msgstr ""
#: ./cli/app/new.go:238
#: ./cli/app/new.go:242
#, c-format
msgid "secrets are %s shown again, please save them %s"
msgstr ""
#: ./cli/app/deploy.go:306
#: ./cli/app/deploy.go:307
#, c-format
msgid "selected latest recipe version: %s (from %d available versions)"
msgstr ""
#: ./cli/app/new.go:121
#: ./cli/app/new.go:125
#, c-format
msgid "selected recipe version: %s (from %d available versions)"
msgstr ""
#. translators: `abra server` command for autocompletion
#: ./cli/app/env.go:332 ./cli/app/env.go:339 ./cli/app/list.go:326 ./cli/app/list.go:341 ./cli/app/new.go:367 ./cli/app/new.go:374 ./cli/run.go:101
#: ./cli/app/env.go:332 ./cli/app/env.go:339 ./cli/app/list.go:326 ./cli/app/list.go:341 ./cli/app/new.go:371 ./cli/app/new.go:378 ./cli/run.go:101
msgid "server"
msgstr ""
@@ -4775,7 +4775,7 @@ msgstr ""
msgid "severity"
msgstr ""
#: ./cli/app/deploy.go:456 ./cli/app/rollback.go:379 ./cli/app/upgrade.go:496
#: ./cli/app/deploy.go:457 ./cli/app/rollback.go:380 ./cli/app/upgrade.go:497
msgid "show all configs & images, including unchanged ones"
msgstr ""
@@ -4799,7 +4799,7 @@ msgstr ""
msgid "show debug messages"
msgstr ""
#: ./cli/app/deploy.go:453 ./cli/app/rollback.go:376 ./cli/app/upgrade.go:493
#: ./cli/app/deploy.go:454 ./cli/app/rollback.go:377 ./cli/app/upgrade.go:494
msgid "show-unchanged"
msgstr ""
@@ -4807,7 +4807,7 @@ msgstr ""
msgid "since"
msgstr ""
#: ./cli/app/new.go:336
#: ./cli/app/new.go:340
#, c-format
msgid "single server detected, choosing %s automatically"
msgstr ""
@@ -4847,11 +4847,11 @@ msgstr ""
msgid "skipping converge logic checks"
msgstr ""
#: ./cli/app/deploy.go:208
#: ./cli/app/deploy.go:209
msgid "skipping domain checks"
msgstr ""
#: ./cli/app/deploy.go:205
#: ./cli/app/deploy.go:206
msgid "skipping domain checks, no DOMAIN=... configured"
msgstr ""
@@ -4907,7 +4907,7 @@ msgstr ""
msgid "specify secret value"
msgstr ""
#: ./cli/app/new.go:370
#: ./cli/app/new.go:374
msgid "specify server for new app"
msgstr ""
@@ -4965,7 +4965,7 @@ msgstr ""
msgid "store generated secrets in a local pass store"
msgstr ""
#: ./cli/app/new.go:393
#: ./cli/app/new.go:397
msgid "store secrets in a local pass store"
msgstr ""
@@ -5115,7 +5115,7 @@ msgstr ""
msgid "trim input"
msgstr ""
#: ./cli/app/new.go:263 ./pkg/app/app.go:141
#: ./cli/app/new.go:267 ./pkg/app/app.go:141
#, c-format
msgid "trimming %s to %s to avoid runtime limits"
msgstr ""
@@ -5653,27 +5653,27 @@ msgstr ""
msgid "version wiped from %s.env"
msgstr ""
#: ./cli/app/deploy.go:372
#: ./cli/app/deploy.go:373
#, c-format
msgid "version: taking chaos version: %s"
msgstr ""
#: ./cli/app/deploy.go:398
#: ./cli/app/deploy.go:399
#, c-format
msgid "version: taking deployed version: %s"
msgstr ""
#: ./cli/app/deploy.go:403
#: ./cli/app/deploy.go:404
#, c-format
msgid "version: taking new recipe version: %s"
msgstr ""
#: ./cli/app/deploy.go:392
#: ./cli/app/deploy.go:393
#, c-format
msgid "version: taking version from .env file: %s"
msgstr ""
#: ./cli/app/deploy.go:378
#: ./cli/app/deploy.go:379
#, c-format
msgid "version: taking version from cli arg: %s"
msgstr ""
@@ -5801,7 +5801,7 @@ msgstr ""
msgid "writer: %v, "
msgstr ""
#: ./cli/app/deploy.go:288 ./cli/app/new.go:251 ./cli/app/rollback.go:256 ./cli/app/undeploy.go:120 ./cli/app/upgrade.go:301
#: ./cli/app/deploy.go:289 ./cli/app/new.go:255 ./cli/app/rollback.go:257 ./cli/app/undeploy.go:120 ./cli/app/upgrade.go:302
#, c-format
msgid "writing recipe version failed: %s"
msgstr ""
+92 -92
View File
@@ -2,7 +2,7 @@ msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-04-11 11:34+0200\n"
"POT-Creation-Date: 2026-06-14 17:56+0200\n"
"PO-Revision-Date: 2026-02-28 13:52+0000\n"
"Last-Translator: chasqui <chasqui@cryptolab.net>\n"
"Language-Team: Spanish <https://translate.coopcloud.tech/projects/co-op-cloud/abra/es/>\n"
@@ -318,7 +318,7 @@ msgstr "%s ya existe"
msgid "%s already exists?"
msgstr "%s ¿ya existe?"
#: cli/app/new.go:202
#: cli/app/new.go:206
#, c-format
msgid "%s created (version: %s)"
msgstr "%s creado (versión: %s)"
@@ -403,7 +403,7 @@ msgstr "¿%s no tiene una aplicación principal?"
msgid "%s has no published versions?"
msgstr "¿%s no tiene versiones publicadas?"
#: cli/app/new.go:311
#: cli/app/new.go:315
#, c-format
msgid "%s has no secrets to generate, skipping..."
msgstr "%s no tiene secretos para generar, omitiendo..."
@@ -443,19 +443,19 @@ msgstr "¿%s no tiene un archivo compose.yml o compose.*.yml?"
msgid "%s is missing the TYPE env var?"
msgstr "¿A %s le falta la variable de entorno TYPE?"
#: cli/app/rollback.go:309 cli/app/rollback.go:313
#: cli/app/rollback.go:310 cli/app/rollback.go:314
#, c-format
msgid "%s is not a downgrade for %s?"
msgstr "¿%s no es un downgrade para %s?"
#: cli/app/upgrade.go:429 cli/app/upgrade.go:433
#: cli/app/upgrade.go:430 cli/app/upgrade.go:434
#, c-format
msgid "%s is not an upgrade for %s?"
msgstr "¿%s no es una actualización para %s?"
#: cli/app/env.go:146 cli/app/logs.go:65 cli/app/ps.go:62
#: cli/app/restart.go:100 cli/app/services.go:55 cli/app/undeploy.go:66
#: cli/app/upgrade.go:450
#: cli/app/upgrade.go:451
#, c-format
msgid "%s is not deployed?"
msgstr "¿%s no está desplegado?"
@@ -475,7 +475,7 @@ msgstr "%s todavía está desplegado. Ejecuta \"abra aplicacion plegar %s\""
msgid "%s missing context, run \"abra server add %s\"?"
msgstr "falta contexto %s, ¿ejecutar \"abra servidor agregar %s\"?"
#: cli/app/deploy.go:194 cli/app/upgrade.go:210
#: cli/app/deploy.go:195 cli/app/upgrade.go:211
#, c-format
msgid "%s missing from %s.env"
msgstr "falta %s de %s.env"
@@ -510,17 +510,17 @@ msgstr "notas de la versión %s:"
msgid "%s removed from pass store"
msgstr "%s eliminado del almacén de contraseñas"
#: cli/app/new.go:220
#: cli/app/new.go:224
#, c-format
msgid "%s requires secret generation before deploy, run \"abra app secret generate %s --all\""
msgstr "%s requiere generación de secretos antes del despliegue, ejecuta \"abra aplicacion secreto generar %s --all\""
#: cli/app/new.go:224
#: cli/app/new.go:228
#, c-format
msgid "%s requires secret insertion before deploy (#generate=false)"
msgstr "%s requiere inserción de secretos antes del despliegue (#generate=false)"
#: cli/app/new.go:151
#: cli/app/new.go:155
#, c-format
msgid "%s sanitised as %s for new app"
msgstr "%s sanitisado como %s para nueva aplicación"
@@ -655,12 +655,12 @@ msgstr "%s: no se puede resolver la dirección IPv4: %s"
msgid "%s: waiting %d seconds before next retry"
msgstr "%s: esperando %d segundos antes del siguiente reintento"
#: cli/app/upgrade.go:424
#: cli/app/upgrade.go:425
#, c-format
msgid "'%s' is not a known version"
msgstr "'%s' no es una versión conocida"
#: cli/app/rollback.go:304 cli/app/upgrade.go:419
#: cli/app/rollback.go:305 cli/app/upgrade.go:420
#, c-format
msgid "'%s' is not a known version for %s"
msgstr "'%s' no es una versión conocida para %s"
@@ -759,8 +759,8 @@ msgid "Both local recipe and live deployment labels are shown."
msgstr "Se muestran tanto la receta local como las etiquetas del despliegue en vivo."
#: cli/app/backup.go:319 cli/app/backup.go:335 cli/app/check.go:95
#: cli/app/cmd.go:285 cli/app/cp.go:385 cli/app/deploy.go:414
#: cli/app/labels.go:143 cli/app/list.go:335 cli/app/new.go:407
#: cli/app/cmd.go:285 cli/app/cp.go:385 cli/app/deploy.go:415
#: cli/app/labels.go:143 cli/app/list.go:335 cli/app/new.go:411
#: cli/app/ps.go:213 cli/app/restart.go:163 cli/app/restore.go:138
#: cli/app/secret.go:569 cli/app/secret.go:609 cli/app/secret.go:633
#: cli/app/secret.go:641 cli/catalogue/catalogue.go:318 cli/recipe/lint.go:137
@@ -959,8 +959,8 @@ msgstr ""
"en un almacén pass (ver passwordstore.org). El comando pass debe estar \n"
"disponible en tu $PATH."
#: cli/app/deploy.go:430 cli/app/new.go:383 cli/app/rollback.go:361
#: cli/app/upgrade.go:470
#: cli/app/deploy.go:431 cli/app/new.go:387 cli/app/rollback.go:362
#: cli/app/upgrade.go:471
msgid "D"
msgstr ""
@@ -1112,7 +1112,7 @@ msgstr ""
"\n"
"Usa la opción \"--estado/-S\" para consultar en todos los servidores sobre el estado de despliegue."
#: cli/app/new.go:317
#: cli/app/new.go:321
msgid "Generate app secrets?"
msgstr "¿Generar secretos de la aplicación?"
@@ -1579,7 +1579,7 @@ msgid "Run app commands"
msgstr "Ejecutar 💻 comando dentro una 🚀aplicación"
#: cli/app/backup.go:303 cli/app/list.go:296 cli/app/logs.go:109
#: cli/app/new.go:399
#: cli/app/new.go:403
msgid "S"
msgstr ""
@@ -1587,7 +1587,7 @@ msgstr ""
msgid "SECRETS"
msgstr "🥷 SECRETOS"
#: cli/app/new.go:234
#: cli/app/new.go:238
msgid "SECRETS OVERVIEW"
msgstr "RESUMEN DE LOS 🥷 SECRETOS"
@@ -1621,7 +1621,7 @@ msgstr ""
msgid "STATUS"
msgstr "🩻 ESTADO"
#: cli/app/new.go:342
#: cli/app/new.go:346
msgid "Select app server:"
msgstr "Seleccionar servidor para la aplicación:"
@@ -1659,7 +1659,7 @@ msgstr "Especifica un 🌐 nombre de dominio (DNS)"
msgid "Specify a server name"
msgstr "Especifica un nombre de servidor"
#: cli/app/new.go:293
#: cli/app/new.go:297
msgid "Specify app domain"
msgstr "Especifica un 🌐 nombre de dominio (DNS) para la aplicación"
@@ -1881,7 +1881,7 @@ msgstr ""
" PS> abra autocomplete powershell > abra.ps1\n"
" # y fuente (source) ese archivo desde tu perfil de PowerShell."
#: cli/app/deploy.go:454 cli/app/rollback.go:377 cli/app/upgrade.go:494
#: cli/app/deploy.go:455 cli/app/rollback.go:378 cli/app/upgrade.go:495
msgid "U"
msgstr ""
@@ -2368,7 +2368,7 @@ msgstr "Intentando generar y almacenar %s en %s"
msgid "attempting to run %s"
msgstr "Intentando ejecutar %s"
#: cli/app/deploy.go:283 cli/app/upgrade.go:296
#: cli/app/deploy.go:284 cli/app/upgrade.go:297
#, c-format
msgid "attempting to run post deploy commands, saw: %s"
msgstr "Intentando ejecutar los comandos posteriores al despliegue: %s"
@@ -2404,7 +2404,7 @@ msgstr "autocompletar [bash|zsh|fish|powershell]"
msgid "autocomplete failed: %s"
msgstr "autocompletar falló: %s"
#: cli/app/new.go:401
#: cli/app/new.go:405
msgid "automatically generate secrets"
msgstr "generar secretos automáticamente"
@@ -2454,8 +2454,8 @@ msgstr "enlace symlink roto en tus carpetas de configuración de abra: %s"
#. no spaces in between
#. translators: `abra app cp` aliases. use a comma separated list of aliases with
#. no spaces in between
#: cli/app/backup.go:148 cli/app/cp.go:30 cli/app/deploy.go:438
#: cli/app/rollback.go:369 cli/app/upgrade.go:478
#: cli/app/backup.go:148 cli/app/cp.go:30 cli/app/deploy.go:439
#: cli/app/rollback.go:370 cli/app/upgrade.go:479
msgid "c"
msgstr ""
@@ -2511,7 +2511,7 @@ msgstr "no se puede obtener la etiqueta %s para %s"
msgid "cannot redeploy previous chaos version (%s), did you mean to use \"--chaos\"?"
msgstr "no se puede redeplegar la versión anterior de caos (%s), ¿Era tu intención usar \"--caos\"?"
#: cli/app/deploy.go:388
#: cli/app/deploy.go:389
#, c-format
msgid ""
"cannot redeploy previous chaos version (%s), did you mean to use \"--chaos\"?\n"
@@ -2533,7 +2533,7 @@ msgstr "no se puede especificar una etiqueta y un tipo de incremento al mismo ti
msgid "cannot use '[secret] [version]' and '--all' together"
msgstr "no se puede usar '[secreto] [versión]' y '--todos' juntos"
#: cli/app/deploy.go:330
#: cli/app/deploy.go:331
msgid "cannot use --chaos and --latest together"
msgstr "no se puede usar --caos y --latest juntos"
@@ -2557,11 +2557,11 @@ msgstr "no se puede usar [server] y --local al mismo tiempo"
msgid "cannot use [service] and --all-services/-a together"
msgstr "no se puede usar [servicio] y --todos-los-servicios/-a juntos"
#: cli/app/deploy.go:322 cli/app/new.go:80
#: cli/app/deploy.go:323 cli/app/new.go:80
msgid "cannot use [version] and --chaos together"
msgstr "no se puede usar [versión] y --caos juntos"
#: cli/app/deploy.go:326
#: cli/app/deploy.go:327
msgid "cannot use [version] and --latest together"
msgstr "no se puede usar [versión] y --latest juntos"
@@ -2598,8 +2598,8 @@ msgid "cfg"
msgstr ""
#: cli/app/backup.go:318 cli/app/backup.go:334 cli/app/check.go:94
#: cli/app/cmd.go:284 cli/app/cp.go:384 cli/app/deploy.go:413
#: cli/app/labels.go:142 cli/app/list.go:334 cli/app/new.go:406
#: cli/app/cmd.go:284 cli/app/cp.go:384 cli/app/deploy.go:414
#: cli/app/labels.go:142 cli/app/list.go:334 cli/app/new.go:410
#: cli/app/ps.go:212 cli/app/restart.go:162 cli/app/restore.go:137
#: cli/app/secret.go:568 cli/app/secret.go:608 cli/app/secret.go:632
#: cli/app/secret.go:640 cli/catalogue/catalogue.go:317 cli/recipe/lint.go:136
@@ -2616,7 +2616,7 @@ msgstr ""
msgid "check <domain> [flags]"
msgstr "verificar <aplicacion> [opciones]"
#: cli/app/deploy.go:95 cli/app/undeploy.go:58 cli/app/upgrade.go:442
#: cli/app/deploy.go:95 cli/app/undeploy.go:58 cli/app/upgrade.go:443
#, c-format
msgid "checking whether %s is already deployed"
msgstr "verificando si %s ya está desplegado"
@@ -2919,7 +2919,7 @@ msgstr "crítico"
msgid "critical errors present in %s config"
msgstr "errores críticos presentes en la configuración de %s"
#: cli/app/rollback.go:299
#: cli/app/rollback.go:300
#, c-format
msgid "current deployment '%s' is not a known version for %s"
msgstr "el despliegue actual '%s' no es una versión conocida para %s"
@@ -2971,7 +2971,7 @@ msgstr "despliegue en proceso 🟠"
msgid "deploy labels stanza present"
msgstr "stanza de etiquetas de despliegue presente"
#: cli/app/deploy.go:448
#: cli/app/deploy.go:449
msgid "deploy latest recipe version"
msgstr "desplegar la última versión de la receta"
@@ -3073,11 +3073,11 @@ msgstr "el directorio está vacío: %s"
msgid "dirty: %v, "
msgstr ""
#: cli/app/deploy.go:440 cli/app/rollback.go:371 cli/app/upgrade.go:480
#: cli/app/deploy.go:441 cli/app/rollback.go:372 cli/app/upgrade.go:481
msgid "disable converge logic checks"
msgstr "desactivar comprobaciones de lógica de convergencia"
#: cli/app/deploy.go:432 cli/app/rollback.go:363 cli/app/upgrade.go:472
#: cli/app/deploy.go:433 cli/app/rollback.go:364 cli/app/upgrade.go:473
msgid "disable public DNS checks"
msgstr "desactivar comprobaciones de DNS público"
@@ -3093,11 +3093,11 @@ msgstr "no solicitar un TTY"
msgid "docker: is the daemon running / your user has docker permissions?"
msgstr "docker: ¿está corriendo el daemon / tu usuario tiene permisos de Docker?"
#: cli/app/new.go:382
#: cli/app/new.go:386
msgid "domain"
msgstr "dominio"
#: cli/app/new.go:385
#: cli/app/new.go:389
msgid "domain name for app"
msgstr "nombre de dominio para la aplicación"
@@ -3288,8 +3288,8 @@ msgstr "extrayendo secreto %s en %s"
#. translators: `abra recipe fetch` aliases. use a comma separated list of aliases
#. with no spaces in between
#: cli/app/deploy.go:422 cli/app/env.go:325 cli/app/remove.go:163
#: cli/app/rollback.go:353 cli/app/secret.go:593 cli/app/upgrade.go:462
#: cli/app/deploy.go:423 cli/app/env.go:325 cli/app/remove.go:163
#: cli/app/rollback.go:354 cli/app/secret.go:593 cli/app/upgrade.go:463
#: cli/app/volume.go:217 cli/recipe/fetch.go:20 cli/recipe/fetch.go:138
msgid "f"
msgstr ""
@@ -3451,7 +3451,7 @@ msgstr "🛑 No se pudo eliminar algunos recursos del stock: %s"
msgid "failed to resize tty, using default size"
msgstr "🛑 No se pudo redimensionar el TTY; se usará el tamaño predeterminado"
#: cli/app/new.go:134
#: cli/app/new.go:138
#, c-format
msgid "failed to retrieve latest commit for %s: %s"
msgstr "🛑 No se pudo obtener el último commit de %s: %s"
@@ -3541,8 +3541,8 @@ msgstr "Filtrar por receta"
msgid "final merged env values for %s are: %s"
msgstr "Los valores finales combinados de entorno para %s son: %s"
#: cli/app/deploy.go:421 cli/app/env.go:324 cli/app/remove.go:162
#: cli/app/rollback.go:352 cli/app/upgrade.go:461 cli/app/volume.go:216
#: cli/app/deploy.go:422 cli/app/env.go:324 cli/app/remove.go:162
#: cli/app/rollback.go:353 cli/app/upgrade.go:462 cli/app/volume.go:216
#: cli/recipe/fetch.go:137
msgid "force"
msgstr "forzar"
@@ -3758,8 +3758,8 @@ msgid "id: %s, "
msgstr ""
#: cli/app/backup.go:321 cli/app/backup.go:337 cli/app/check.go:97
#: cli/app/cmd.go:287 cli/app/cp.go:387 cli/app/deploy.go:416
#: cli/app/labels.go:145 cli/app/list.go:337 cli/app/new.go:409
#: cli/app/cmd.go:287 cli/app/cp.go:387 cli/app/deploy.go:417
#: cli/app/labels.go:145 cli/app/list.go:337 cli/app/new.go:413
#: cli/app/ps.go:215 cli/app/restart.go:165 cli/app/restore.go:140
#: cli/app/secret.go:571 cli/app/secret.go:611 cli/app/secret.go:635
#: cli/app/secret.go:643 cli/catalogue/catalogue.go:320 cli/recipe/lint.go:139
@@ -3960,7 +3960,7 @@ msgstr "versión %s especificada inválida"
#. no spaces in between
#. translators: `abra recipe lint` aliases. use a comma separated list of
#. aliases with no spaces in between
#: cli/app/cmd.go:261 cli/app/deploy.go:446 cli/app/logs.go:20
#: cli/app/cmd.go:261 cli/app/deploy.go:447 cli/app/logs.go:20
#: cli/recipe/lint.go:17 cli/server/add.go:207
msgid "l"
msgstr ""
@@ -3976,7 +3976,7 @@ msgstr ""
msgid "labels <domain> [flags]"
msgstr "etiquetas <aplicacion> [opciones]"
#: cli/app/deploy.go:445 cli/app/list.go:186
#: cli/app/deploy.go:446 cli/app/list.go:186
msgid "latest"
msgstr "ultima"
@@ -4325,7 +4325,7 @@ msgstr "no hay configuraciones para eliminar"
msgid "no containers matching the %v filter found?"
msgstr "no se encontraron contenedores que coincidan con el filtro %v?"
#: cli/app/new.go:302 cli/internal/validate.go:129
#: cli/app/new.go:306 cli/internal/validate.go:129
msgid "no domain provided"
msgstr "no se proporcionó dominio"
@@ -4352,7 +4352,7 @@ msgstr "¿no existe la receta '%s'?"
msgid "no recipe name provided"
msgstr "no se proporcionó el nombre de la receta"
#: cli/app/upgrade.go:241
#: cli/app/upgrade.go:242
#, fuzzy, c-format
msgid "no release notes for upgrading from %s to %s"
msgstr "ejecución de prueba: mover la nota de la versión de 'next' a %s"
@@ -4383,7 +4383,7 @@ msgstr "no hay secretos para eliminar"
msgid "no secrets to remove?"
msgstr "¿No hay secretos para eliminar?"
#: cli/app/new.go:351 cli/internal/validate.go:167
#: cli/app/new.go:355 cli/internal/validate.go:167
msgid "no server provided"
msgstr "no se proporcionó servidor"
@@ -4441,11 +4441,11 @@ msgstr "no se eliminaron volúmenes"
msgid "no volumes to remove"
msgstr "no hay volúmenes para eliminar"
#: cli/app/deploy.go:437 cli/app/rollback.go:368 cli/app/upgrade.go:477
#: cli/app/deploy.go:438 cli/app/rollback.go:369 cli/app/upgrade.go:478
msgid "no-converge-checks"
msgstr "sin-verificaciones-de-convergencia"
#: cli/app/deploy.go:429 cli/app/rollback.go:360 cli/app/upgrade.go:469
#: cli/app/deploy.go:430 cli/app/rollback.go:361 cli/app/upgrade.go:470
msgid "no-domain-checks"
msgstr "sin-verificaciones-de-dominio"
@@ -4513,7 +4513,7 @@ msgstr "solo se usaron etiquetas anotadas para la versión de la receta"
msgid "only show errors"
msgstr "mostrar solo errores"
#: cli/app/upgrade.go:488
#: cli/app/upgrade.go:489
msgid "only show release notes"
msgstr "solo mostrar notas de la versión"
@@ -4525,7 +4525,7 @@ msgstr "solo cola de stderr"
#. with no spaces in between
#. translators: `abra server prune` aliases. use a comma separated list of
#. aliases with no spaces in between
#: cli/app/backup.go:295 cli/app/new.go:391 cli/app/ps.go:29
#: cli/app/backup.go:295 cli/app/new.go:395 cli/app/ps.go:29
#: cli/app/secret.go:561 cli/app/secret.go:585 cli/app/secret.go:625
#: cli/app/undeploy.go:169 cli/catalogue/catalogue.go:294
#: cli/recipe/list.go:112 cli/server/prune.go:18
@@ -4547,27 +4547,27 @@ msgstr "parseado %s de %s"
msgid "parsed following command arguments: %s"
msgstr "parseados los siguientes argumentos del comando: %s"
#: cli/app/upgrade.go:345
#: cli/app/upgrade.go:346
#, c-format
msgid "parsing chosen upgrade version failed: %s"
msgstr "falló la actualización de versión elegida para el parsing: %s"
#: cli/app/upgrade.go:389
#: cli/app/upgrade.go:390
#, c-format
msgid "parsing deployed version failed: %s"
msgstr "parsing fallido de la versión desplegada: %s"
#: cli/app/upgrade.go:350
#: cli/app/upgrade.go:351
#, c-format
msgid "parsing deployment version failed: %s"
msgstr "parsing fallido de la versión de despliegue: %s"
#: cli/app/upgrade.go:356 cli/app/upgrade.go:395
#: cli/app/upgrade.go:357 cli/app/upgrade.go:396
#, c-format
msgid "parsing recipe version failed: %s"
msgstr "parsing fallido de la versión de la receta: %s"
#: cli/app/new.go:390 cli/app/secret.go:560 cli/app/secret.go:584
#: cli/app/new.go:394 cli/app/secret.go:560 cli/app/secret.go:584
#: cli/app/secret.go:624
msgid "pass"
msgstr ""
@@ -4590,8 +4590,8 @@ msgstr "ruta"
msgid "pattern"
msgstr "patrón"
#: cli/app/deploy.go:424 cli/app/env.go:327 cli/app/remove.go:165
#: cli/app/rollback.go:355 cli/app/upgrade.go:464 cli/app/volume.go:219
#: cli/app/deploy.go:425 cli/app/env.go:327 cli/app/remove.go:165
#: cli/app/rollback.go:356 cli/app/upgrade.go:465 cli/app/volume.go:219
msgid "perform action without further prompt"
msgstr "realizar acción sin más avisos"
@@ -4601,22 +4601,22 @@ msgstr "realizar acción sin más avisos"
msgid "pl,p"
msgstr ""
#: cli/app/rollback.go:267
#: cli/app/rollback.go:268
#, c-format
msgid "please select a downgrade (version: %s):"
msgstr "por favor, selecciona una versión anterior (downgrade) (versión: %s):"
#: cli/app/rollback.go:272
#: cli/app/rollback.go:273
#, c-format
msgid "please select a downgrade (version: %s, chaos: %s):"
msgstr "por favor, selecciona una versión anterior (downgrade) (versión: %s, caos: %s):"
#: cli/app/upgrade.go:312
#: cli/app/upgrade.go:313
#, c-format
msgid "please select an upgrade (version: %s):"
msgstr "por favor, selecciona una actualización (versión: %s):"
#: cli/app/upgrade.go:317
#: cli/app/upgrade.go:318
#, c-format
msgid "please select an upgrade (version: %s, chaos: %s):"
msgstr "por favor, selecciona una actualización (versión: %s, caos: %s):"
@@ -4702,7 +4702,7 @@ msgstr "consultando servidores remotos..."
#. translators: `abra recipe` aliases. use a comma separated list of aliases
#. with no spaces in between
#: cli/app/backup.go:327 cli/app/list.go:304 cli/app/move.go:350
#: cli/app/run.go:23 cli/app/upgrade.go:486 cli/catalogue/catalogue.go:302
#: cli/app/run.go:23 cli/app/upgrade.go:487 cli/catalogue/catalogue.go:302
#: cli/recipe/recipe.go:12 cli/recipe/release.go:624
msgid "r"
msgstr ""
@@ -4827,7 +4827,7 @@ msgstr "publicar <receta> [version] [opciones]"
msgid "release failed. any changes made have been reverted"
msgstr ""
#: cli/app/upgrade.go:485
#: cli/app/upgrade.go:486
msgid "releasenotes"
msgstr "notas de la versión"
@@ -5132,7 +5132,7 @@ msgstr "ejecutar comando como usuario"
msgid "run command locally"
msgstr "ejecutar comando localmente"
#: cli/app/deploy.go:281 cli/app/upgrade.go:293
#: cli/app/deploy.go:282 cli/app/upgrade.go:294
#, c-format
msgid "run the following post-deploy commands: %s"
msgstr "Ejecutar los siguientes comandos después del despliegue: %s"
@@ -5175,7 +5175,7 @@ msgstr "ejecutando el comando posterior '%s %s' en el contenedor %s"
#. with no spaces in between
#: cli/app/backup.go:198 cli/app/backup.go:263 cli/app/backup.go:287
#: cli/app/env.go:333 cli/app/list.go:327 cli/app/logs.go:101
#: cli/app/new.go:368 cli/app/restore.go:114 cli/app/secret.go:535
#: cli/app/new.go:372 cli/app/restore.go:114 cli/app/secret.go:535
#: cli/catalogue/catalogue.go:27 cli/catalogue/catalogue.go:310
#: cli/recipe/fetch.go:130 cli/server/server.go:12
msgid "s"
@@ -5219,12 +5219,12 @@ msgstr "datos del secreto no proporcionados en la línea de comandos o stdin, so
msgid "secret not found: %s"
msgstr "secreto no encontrado: %s"
#: cli/app/deploy.go:358
#: cli/app/deploy.go:359
#, c-format
msgid "secret not generated: %s"
msgstr "secreto no generado: %s"
#: cli/app/deploy.go:356
#: cli/app/deploy.go:357
#, c-format
msgid "secret not inserted (#generate=false): %s"
msgstr "secreto no insertado (#generate=false): %s"
@@ -5234,28 +5234,28 @@ msgstr "secreto no insertado (#generate=false): %s"
msgid "secret: %s removed"
msgstr "secreto: %s eliminado"
#: cli/app/backup.go:302 cli/app/new.go:398
#: cli/app/backup.go:302 cli/app/new.go:402
msgid "secrets"
msgstr "secretos"
#: cli/app/new.go:238
#: cli/app/new.go:242
#, c-format
msgid "secrets are %s shown again, please save them %s"
msgstr "los secretos se muestran %s nuevamente, por favor guárdalos %s"
#: cli/app/deploy.go:306
#: cli/app/deploy.go:307
#, c-format
msgid "selected latest recipe version: %s (from %d available versions)"
msgstr "versión de receta más reciente seleccionada: %s (de %d versiones disponibles)"
#: cli/app/new.go:121
#: cli/app/new.go:125
#, c-format
msgid "selected recipe version: %s (from %d available versions)"
msgstr "versión de receta seleccionada: %s (de %d versiones disponibles)"
#. translators: `abra server` command for autocompletion
#: cli/app/env.go:332 cli/app/env.go:339 cli/app/list.go:326
#: cli/app/list.go:341 cli/app/new.go:367 cli/app/new.go:374 cli/run.go:101
#: cli/app/list.go:341 cli/app/new.go:371 cli/app/new.go:378 cli/run.go:101
msgid "server"
msgstr "servidor"
@@ -5369,7 +5369,7 @@ msgstr "establecer referencia: %s"
msgid "severity"
msgstr "gravedad"
#: cli/app/deploy.go:456 cli/app/rollback.go:379 cli/app/upgrade.go:496
#: cli/app/deploy.go:457 cli/app/rollback.go:380 cli/app/upgrade.go:497
msgid "show all configs & images, including unchanged ones"
msgstr "mostrar todas las configuraciones e imágenes, incluidas las no cambiadas"
@@ -5393,7 +5393,7 @@ msgstr "mostrar aplicaciones de un servidor específico"
msgid "show debug messages"
msgstr "mostrar mensajes para el debugeo"
#: cli/app/deploy.go:453 cli/app/rollback.go:376 cli/app/upgrade.go:493
#: cli/app/deploy.go:454 cli/app/rollback.go:377 cli/app/upgrade.go:494
msgid "show-unchanged"
msgstr "mostrar-sin-cambios"
@@ -5401,7 +5401,7 @@ msgstr "mostrar-sin-cambios"
msgid "since"
msgstr "desde"
#: cli/app/new.go:336
#: cli/app/new.go:340
#, c-format
msgid "single server detected, choosing %s automatically"
msgstr "se ha detectado un único servidor, eligiendo %s automáticamente"
@@ -5441,11 +5441,11 @@ msgstr "omitiendo según lo solicitado, plegado aún está en progreso 🟠"
msgid "skipping converge logic checks"
msgstr "omitiendo comprobaciones de lógica de convergencia"
#: cli/app/deploy.go:208
#: cli/app/deploy.go:209
msgid "skipping domain checks"
msgstr "omitiendo comprobaciones de dominio"
#: cli/app/deploy.go:205
#: cli/app/deploy.go:206
msgid "skipping domain checks, no DOMAIN=... configured"
msgstr "omitiendo comprobaciones de dominio, no DOMAIN=... configurado"
@@ -5501,7 +5501,7 @@ msgstr "especificar archivo de secreto"
msgid "specify secret value"
msgstr "especificar valor de secreto"
#: cli/app/new.go:370
#: cli/app/new.go:374
msgid "specify server for new app"
msgstr "especificar servidor para la nueva aplicación"
@@ -5559,7 +5559,7 @@ msgstr ""
msgid "store generated secrets in a local pass store"
msgstr "almacenar secretos generados en un almacén de contraseñas local"
#: cli/app/new.go:393
#: cli/app/new.go:397
msgid "store secrets in a local pass store"
msgstr "almacenar secretos en un almacén de contraseñas local"
@@ -5710,7 +5710,7 @@ msgstr "recortar"
msgid "trim input"
msgstr "recortar entrada"
#: cli/app/new.go:263 pkg/app/app.go:141
#: cli/app/new.go:267 pkg/app/app.go:141
#, c-format
msgid "trimming %s to %s to avoid runtime limits"
msgstr "recortando %s a %s para evitar límites de tiempo de ejecución"
@@ -6255,27 +6255,27 @@ msgstr "la versión parece inválida: %s"
msgid "version wiped from %s.env"
msgstr "versión eliminada de %s.env"
#: cli/app/deploy.go:372
#: cli/app/deploy.go:373
#, c-format
msgid "version: taking chaos version: %s"
msgstr "versión: tomando la versión de caos: %s"
#: cli/app/deploy.go:398
#: cli/app/deploy.go:399
#, c-format
msgid "version: taking deployed version: %s"
msgstr "versión: tomando la versión desplegada: %s"
#: cli/app/deploy.go:403
#: cli/app/deploy.go:404
#, c-format
msgid "version: taking new recipe version: %s"
msgstr "versión: tomando la nueva versión de la receta: %s"
#: cli/app/deploy.go:392
#: cli/app/deploy.go:393
#, c-format
msgid "version: taking version from .env file: %s"
msgstr "versión: tomando la versión desde el archivo .env: %s"
#: cli/app/deploy.go:378
#: cli/app/deploy.go:379
#, c-format
msgid "version: taking version from cli arg: %s"
msgstr "versión: tomando la versión desde el argumento de la línea de comandos: %s"
@@ -6406,8 +6406,8 @@ msgstr "el directorio de trabajo no está limpio en %s, abortando"
msgid "writer: %v, "
msgstr ""
#: cli/app/deploy.go:288 cli/app/new.go:251 cli/app/rollback.go:256
#: cli/app/undeploy.go:120 cli/app/upgrade.go:301
#: cli/app/deploy.go:289 cli/app/new.go:255 cli/app/rollback.go:257
#: cli/app/undeploy.go:120 cli/app/upgrade.go:302
#, c-format
msgid "writing recipe version failed: %s"
msgstr "escritura de la versión de la receta fallida: %s"
+4 -3
View File
@@ -7,6 +7,7 @@ import (
"os"
"path"
"coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/i18n"
"coopcloud.tech/abra/pkg/log"
"coopcloud.tech/abra/pkg/recipe"
@@ -321,7 +322,7 @@ func LintAllImagesTagged(recipe recipe.Recipe) (bool, error) {
return false, err
}
for _, service := range config.Services {
img, err := reference.ParseNormalizedNamed(service.Image)
img, err := reference.ParseNormalizedNamed(formatter.StripDigest(service.Image))
if err != nil {
return false, err
}
@@ -339,7 +340,7 @@ func LintNoUnstableTags(recipe recipe.Recipe) (bool, error) {
return false, err
}
for _, service := range config.Services {
img, err := reference.ParseNormalizedNamed(service.Image)
img, err := reference.ParseNormalizedNamed(formatter.StripDigest(service.Image))
if err != nil {
return false, err
}
@@ -366,7 +367,7 @@ func LintSemverLikeTags(recipe recipe.Recipe) (bool, error) {
return false, err
}
for _, service := range config.Services {
img, err := reference.ParseNormalizedNamed(service.Image)
img, err := reference.ParseNormalizedNamed(formatter.StripDigest(service.Image))
if err != nil {
return false, err
}
+68 -2
View File
@@ -122,6 +122,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,7 +141,7 @@ func (r Recipe) UpdateTag(image, tag string) (bool, error) {
continue // may be a compose.$optional.yml file
}
img, _ := reference.ParseNormalizedNamed(service.Image)
img, _ := reference.ParseNormalizedNamed(formatter.StripDigest(service.Image))
if err != nil {
return false, err
}
@@ -166,6 +167,10 @@ func (r Recipe) UpdateTag(image, tag string) (bool, error) {
old := fmt.Sprintf("%s:%s", composeImage, composeTag)
new := fmt.Sprintf("%s:%s", composeImage, tag)
if old == new {
continue
}
replacedBytes := strings.Replace(string(bytes), old, new, -1)
log.Debug(i18n.G("updating %s to %s in %s", old, new, compose.Filename))
@@ -173,11 +178,72 @@ func (r Recipe) UpdateTag(image, tag string) (bool, error) {
if err := os.WriteFile(compose.Filename, []byte(replacedBytes), 0o764); err != nil {
return false, err
}
updated = true
}
}
}
return false, nil
return updated, nil
}
// UpdatePinnedTag updates an image reference to a pinned version (image:tag@sha256:...) in-place on local compose files.
func (r Recipe) UpdatePinnedTag(image, tag, digest string) (bool, error) {
glob := fmt.Sprintf("%s/compose**yml", r.Dir)
image = formatter.StripTagMeta(image)
composeFiles, err := filepath.Glob(glob)
if err != nil {
return false, err
}
updated := false
for _, composeFile := range composeFiles {
opts := stack.Deploy{Composefiles: []string{composeFile}}
sampleEnv, err := r.SampleEnv()
if err != nil {
return false, err
}
compose, err := loader.LoadComposefile(opts, sampleEnv)
if err != nil {
return false, err
}
for _, service := range compose.Services {
if service.Image == "" {
continue
}
img, _ := reference.ParseNormalizedNamed(formatter.StripDigest(service.Image))
composeImage := formatter.StripTagMeta(reference.Path(img))
if image == composeImage {
bytes, err := ioutil.ReadFile(composeFile)
if err != nil {
return false, err
}
old := service.Image
new := fmt.Sprintf("%s:%s@%s", composeImage, tag, digest)
if old == new {
continue
}
replacedBytes := strings.Replace(string(bytes), old, new, -1)
log.Debug(i18n.G("updating pinned reference %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 updated, nil
}
// UpdateLabel updates a label in-place on file system local compose files.
+1 -1
View File
@@ -426,7 +426,7 @@ func (r Recipe) GetRecipeVersions() (RecipeVersions, []string, error) {
versionMeta := make(map[string]ServiceMeta)
for _, service := range config.Services {
img, err := reference.ParseNormalizedNamed(service.Image)
img, err := reference.ParseNormalizedNamed(formatter.StripDigest(service.Image))
if err != nil {
log.Debug(i18n.G("failed to parse image for %s in %s: %s", service.Name, tag, err))
warnMsg = append(warnMsg, i18n.G("skipping tag %s: invalid image reference in service %s: %s", tag, service.Name, err))
+1 -1
View File
@@ -3,7 +3,7 @@ version: "3.8"
services:
app:
image: nginx:1.21.0
image: nginx:1.31.3
secrets:
- test_pass_one
- test_pass_two
+20
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,16 @@ 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 "does not overwrite existing env files" {
+1 -1
View File
@@ -3,7 +3,7 @@ version: "3.8"
services:
app:
image: nginx:1.29.0
image: nginx:1.31.3
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.26
commands:
- test -z "$(gofmt -l .)"
- name: go build
image: golang:1.21
image: golang:1.26
commands:
- go build -v .
- name: go test
image: golang:1.21
image: golang:1.26
commands:
- go test . -cover
+6
View File
@@ -0,0 +1,6 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended"
]
}
+1 -1
View File
@@ -87,7 +87,7 @@ func stringWidth(m Method, s string) int {
for i := 0; i < len(s); i++ {
state, action := parser.Table.Transition(pstate, s[i])
if action == parser.PrintAction || state == parser.Utf8State {
if state == parser.Utf8State {
cluster, w := FirstGraphemeCluster(s[i:], m)
width += w
+36 -36
View File
@@ -27,15 +27,19 @@ type PortSet map[Port]struct{}
type Port string
// NewPort creates a new instance of a Port given a protocol and port number or port range
func NewPort(proto, portOrRange string) (Port, error) {
start, end, err := parsePortRange(portOrRange)
func NewPort(proto, port string) (Port, error) {
// Check for parsing issues on "port" now so we can avoid having
// to check it later on.
portStartInt, portEndInt, err := ParsePortRangeToInt(port)
if err != nil {
return "", err
}
if start == end {
return Port(fmt.Sprintf("%d/%s", start, proto)), nil
if portStartInt == portEndInt {
return Port(fmt.Sprintf("%d/%s", portStartInt, proto)), nil
}
return Port(fmt.Sprintf("%d-%d/%s", start, end, proto)), nil
return Port(fmt.Sprintf("%d-%d/%s", portStartInt, portEndInt, proto)), nil
}
// ParsePort parses the port number string and returns an int
@@ -43,53 +47,49 @@ func ParsePort(rawPort string) (int, error) {
if rawPort == "" {
return 0, nil
}
port, err := parsePortNumber(rawPort)
port, err := strconv.ParseUint(rawPort, 10, 16)
if err != nil {
return 0, fmt.Errorf("invalid port '%s': %w", rawPort, err)
return 0, fmt.Errorf("invalid port '%s': %w", rawPort, errors.Unwrap(err))
}
return port, nil
return int(port), nil
}
// ParsePortRangeToInt parses the port range string and returns start/end ints
func ParsePortRangeToInt(rawPort string) (startPort, endPort int, _ error) {
func ParsePortRangeToInt(rawPort string) (int, int, error) {
if rawPort == "" {
// TODO(thaJeztah): consider making this an error; this was kept to keep existing behavior.
return 0, 0, nil
}
return parsePortRange(rawPort)
start, end, err := ParsePortRange(rawPort)
if err != nil {
return 0, 0, err
}
return int(start), int(end), nil
}
// Proto returns the protocol of a Port
func (p Port) Proto() string {
_, proto, _ := strings.Cut(string(p), "/")
if proto == "" {
proto = "tcp"
}
proto, _ := SplitProtoPort(string(p))
return proto
}
// Port returns the port number of a Port
func (p Port) Port() string {
port, _, _ := strings.Cut(string(p), "/")
_, port := SplitProtoPort(string(p))
return port
}
// Int returns the port number of a Port as an int. It assumes [Port]
// is valid, and returns 0 otherwise.
// Int returns the port number of a Port as an int
func (p Port) Int() int {
portStr := p.Port()
// We don't need to check for an error because we're going to
// assume that any error would have been found, and reported, in [NewPort]
port, _ := parsePortNumber(p.Port())
// assume that any error would have been found, and reported, in NewPort()
port, _ := ParsePort(portStr)
return port
}
// Range returns the start/end port numbers of a Port range as ints
func (p Port) Range() (int, int, error) {
portRange := p.Port()
if portRange == "" {
return 0, 0, nil
}
return parsePortRange(portRange)
return ParsePortRangeToInt(p.Port())
}
// SplitProtoPort splits a port(range) and protocol, formatted as "<portnum>/[<proto>]"
@@ -173,10 +173,6 @@ func splitParts(rawport string) (hostIP, hostPort, containerPort string) {
func ParsePortSpec(rawPort string) ([]PortMapping, error) {
ip, hostPort, containerPort := splitParts(rawPort)
proto, containerPort := SplitProtoPort(containerPort)
if containerPort == "" {
return nil, fmt.Errorf("no port specified: %s<empty>", rawPort)
}
proto = strings.ToLower(proto)
if err := validateProto(proto); err != nil {
return nil, err
@@ -193,15 +189,18 @@ func ParsePortSpec(rawPort string) ([]PortMapping, error) {
if ip != "" && net.ParseIP(ip) == nil {
return nil, errors.New("invalid IP address: " + ip)
}
if containerPort == "" {
return nil, fmt.Errorf("no port specified: %s<empty>", rawPort)
}
startPort, endPort, err := parsePortRange(containerPort)
startPort, endPort, err := ParsePortRange(containerPort)
if err != nil {
return nil, errors.New("invalid containerPort: " + containerPort)
}
var startHostPort, endHostPort int
var startHostPort, endHostPort uint64
if hostPort != "" {
startHostPort, endHostPort, err = parsePortRange(hostPort)
startHostPort, endHostPort, err = ParsePortRange(hostPort)
if err != nil {
return nil, errors.New("invalid hostPort: " + hostPort)
}
@@ -218,18 +217,19 @@ func ParsePortSpec(rawPort string) ([]PortMapping, error) {
count := endPort - startPort + 1
ports := make([]PortMapping, 0, count)
for i := range count {
for i := uint64(0); i < count; i++ {
cPort := Port(strconv.FormatUint(startPort+i, 10) + "/" + proto)
hPort := ""
if hostPort != "" {
hPort = strconv.Itoa(startHostPort + i)
hPort = strconv.FormatUint(startHostPort+i, 10)
// Set hostPort to a range only if there is a single container port
// and a dynamic host port.
if count == 1 && startHostPort != endHostPort {
hPort += "-" + strconv.Itoa(endHostPort)
hPort += "-" + strconv.FormatUint(endHostPort, 10)
}
}
ports = append(ports, PortMapping{
Port: Port(strconv.Itoa(startPort+i) + "/" + proto),
Port: cPort,
Binding: PortBinding{HostIP: ip, HostPort: hPort},
})
}
+16 -43
View File
@@ -2,59 +2,32 @@ package nat
import (
"errors"
"fmt"
"strconv"
"strings"
)
// ParsePortRange parses and validates the specified string as a port range (e.g., "8000-9000").
func ParsePortRange(ports string) (startPort, endPort uint64, _ error) {
start, end, err := parsePortRange(ports)
return uint64(start), uint64(end), err
}
// parsePortRange parses and validates the specified string as a port range (e.g., "8000-9000").
func parsePortRange(ports string) (startPort, endPort int, _ error) {
// ParsePortRange parses and validates the specified string as a port-range (8000-9000)
func ParsePortRange(ports string) (uint64, uint64, error) {
if ports == "" {
return 0, 0, errors.New("empty string specified for ports")
}
start, end, ok := strings.Cut(ports, "-")
startPort, err := parsePortNumber(start)
if err != nil {
return 0, 0, fmt.Errorf("invalid start port '%s': %w", start, err)
}
if !ok || start == end {
return startPort, startPort, nil
if !strings.Contains(ports, "-") {
start, err := strconv.ParseUint(ports, 10, 16)
end := start
return start, end, err
}
endPort, err = parsePortNumber(end)
parts := strings.Split(ports, "-")
start, err := strconv.ParseUint(parts[0], 10, 16)
if err != nil {
return 0, 0, fmt.Errorf("invalid end port '%s': %w", end, err)
return 0, 0, err
}
if endPort < startPort {
return 0, 0, errors.New("invalid port range: " + ports)
end, err := strconv.ParseUint(parts[1], 10, 16)
if err != nil {
return 0, 0, err
}
return startPort, endPort, nil
}
// parsePortNumber parses rawPort into an int, unwrapping strconv errors
// and returning a single "out of range" error for any value outside 065535.
func parsePortNumber(rawPort string) (int, error) {
if rawPort == "" {
return 0, errors.New("value is empty")
}
port, err := strconv.ParseInt(rawPort, 10, 0)
if err != nil {
var numErr *strconv.NumError
if errors.As(err, &numErr) {
err = numErr.Err
}
return 0, err
}
if port < 0 || port > 65535 {
return 0, errors.New("value out of range (065535)")
}
return int(port), nil
if end < start {
return 0, 0, errors.New("invalid range specified for port: " + ports)
}
return start, end, nil
}
+15 -27
View File
@@ -34,10 +34,8 @@ func Sort(ports []Port, predicate func(i, j Port) bool) {
}
type portMapEntry struct {
port Port
binding *PortBinding
portInt int
portProto string
port Port
binding PortBinding
}
type portMapSorter []portMapEntry
@@ -50,36 +48,23 @@ func (s portMapSorter) Swap(i, j int) { s[i], s[j] = s[j], s[i] }
// 2. larger port
// 3. port with tcp protocol
func (s portMapSorter) Less(i, j int) bool {
pi, pj := s[i].portInt, s[j].portInt
var hpi, hpj int
if s[i].binding != nil {
hpi = toInt(s[i].binding.HostPort)
}
if s[j].binding != nil {
hpj = toInt(s[j].binding.HostPort)
}
return hpi > hpj || pi > pj || (pi == pj && strings.EqualFold(s[i].portProto, "tcp"))
pi, pj := s[i].port, s[j].port
hpi, hpj := toInt(s[i].binding.HostPort), toInt(s[j].binding.HostPort)
return hpi > hpj || pi.Int() > pj.Int() || (pi.Int() == pj.Int() && strings.ToLower(pi.Proto()) == "tcp")
}
// SortPortMap sorts the list of ports and their respected mapping. The ports
// will explicit HostPort will be placed first.
func SortPortMap(ports []Port, bindings map[Port][]PortBinding) {
func SortPortMap(ports []Port, bindings PortMap) {
s := portMapSorter{}
for _, p := range ports {
portInt, portProto := p.Int(), p.Proto()
if binding, ok := bindings[p]; ok && len(binding) > 0 {
for _, b := range binding {
s = append(s, portMapEntry{
port: p, binding: &b,
portInt: portInt, portProto: portProto,
})
s = append(s, portMapEntry{port: p, binding: b})
}
bindings[p] = []PortBinding{}
} else {
s = append(s, portMapEntry{
port: p,
portInt: portInt, portProto: portProto,
})
s = append(s, portMapEntry{port: p})
}
}
@@ -96,13 +81,16 @@ func SortPortMap(ports []Port, bindings map[Port][]PortBinding) {
i++
}
// reorder bindings for this port
if entry.binding != nil {
bindings[entry.port] = append(bindings[entry.port], *entry.binding)
if _, ok := bindings[entry.port]; ok {
bindings[entry.port] = append(bindings[entry.port], entry.binding)
}
}
}
func toInt(s string) int {
i, _, _ := parsePortRange(s)
func toInt(s string) uint64 {
i, _, err := ParsePortRange(s)
if err != nil {
i = 0
}
return i
}
+30 -30
View File
@@ -1,57 +1,48 @@
package sockets
import (
"errors"
"net"
"sync"
)
var errClosed = errors.New("use of closed network connection")
// InmemSocket implements net.Listener using in-memory only connections.
type InmemSocket struct {
chConn chan net.Conn
chClose chan struct{}
addr string
mu sync.Mutex
}
// dummyAddr is used to satisfy net.Addr for the in-mem socket
// it is just stored as a string and returns the string for all calls
type dummyAddr string
// Network returns the addr string, satisfies net.Addr
func (a dummyAddr) Network() string {
return string(a)
}
// String returns the string form
func (a dummyAddr) String() string {
return string(a)
}
// InmemSocket implements [net.Listener] using in-memory only connections.
type InmemSocket struct {
chConn chan net.Conn
chClose chan struct{}
addr dummyAddr
mu sync.Mutex
}
// NewInmemSocket creates an in-memory only [net.Listener]. The addr argument
// can be any string, but is used to satisfy the [net.Listener.Addr] part
// of the [net.Listener] interface
// NewInmemSocket creates an in-memory only net.Listener
// The addr argument can be any string, but is used to satisfy the `Addr()` part
// of the net.Listener interface
func NewInmemSocket(addr string, bufSize int) *InmemSocket {
return &InmemSocket{
chConn: make(chan net.Conn, bufSize),
chClose: make(chan struct{}),
addr: dummyAddr(addr),
addr: addr,
}
}
// Addr returns the socket's addr string to satisfy net.Listener
func (s *InmemSocket) Addr() net.Addr {
return s.addr
return dummyAddr(s.addr)
}
// Accept implements the Accept method in the Listener interface; it waits
// for the next call and returns a generic Conn. It returns a [net.ErrClosed]
// if the connection is already closed.
// Accept implements the Accept method in the Listener interface; it waits for the next call and returns a generic Conn.
func (s *InmemSocket) Accept() (net.Conn, error) {
select {
case conn := <-s.chConn:
return conn, nil
case <-s.chClose:
return nil, net.ErrClosed
return nil, errClosed
}
}
@@ -67,15 +58,24 @@ func (s *InmemSocket) Close() error {
return nil
}
// Dial is used to establish a connection with the in-mem server.
// It returns a [net.ErrClosed] if the connection is already closed.
// Dial is used to establish a connection with the in-mem server
func (s *InmemSocket) Dial(network, addr string) (net.Conn, error) {
srvConn, clientConn := net.Pipe()
select {
case s.chConn <- srvConn:
case <-s.chClose:
return nil, net.ErrClosed
return nil, errClosed
}
return clientConn, nil
}
// Network returns the addr string, satisfies net.Addr
func (a dummyAddr) Network() string {
return string(a)
}
// String returns the string form
func (a dummyAddr) String() string {
return string(a)
}
+31
View File
@@ -0,0 +1,31 @@
package sockets
import (
"net"
"os"
"strings"
)
// GetProxyEnv allows access to the uppercase and the lowercase forms of
// proxy-related variables. See the Go specification for details on these
// variables. https://golang.org/pkg/net/http/
//
// Deprecated: this function was used as helper for [DialerFromEnvironment] and is no longer used. It will be removed in the next release.
func GetProxyEnv(key string) string {
proxyValue := os.Getenv(strings.ToUpper(key))
if proxyValue == "" {
return os.Getenv(strings.ToLower(key))
}
return proxyValue
}
// DialerFromEnvironment was previously used to configure a net.Dialer to route
// connections through a SOCKS proxy.
//
// Deprecated: SOCKS proxies are now supported by configuring only
// http.Transport.Proxy, and no longer require changing http.Transport.Dial.
// Therefore, only [sockets.ConfigureTransport] needs to be called, and any
// [sockets.DialerFromEnvironment] calls can be dropped.
func DialerFromEnvironment(direct *net.Dialer) (*net.Dialer, error) {
return direct, nil
}
+12 -12
View File
@@ -27,19 +27,11 @@ var ErrProtocolNotAvailable = errors.New("protocol not available")
// make sure you do it _after_ any subsequent calls to ConfigureTransport is made against the same
// [http.Transport].
func ConfigureTransport(tr *http.Transport, proto, addr string) error {
if tr.MaxIdleConns == 0 {
// prevent long-lived processes from leaking connections
// due to idle connections not being released.
//
// TODO: see if we can also address this from the server side; see: https://github.com/moby/moby/issues/45539
tr.MaxIdleConns = 6
tr.IdleConnTimeout = 30 * time.Second
}
switch proto {
case "unix":
return configureUnixTransport(tr, addr)
return configureUnixTransport(tr, proto, addr)
case "npipe":
return configureNpipeTransport(tr, addr)
return configureNpipeTransport(tr, proto, addr)
default:
tr.Proxy = http.ProxyFromEnvironment
tr.DisableCompression = false
@@ -50,7 +42,15 @@ func ConfigureTransport(tr *http.Transport, proto, addr string) error {
return nil
}
func configureUnixTransport(tr *http.Transport, addr string) error {
// DialPipe connects to a Windows named pipe. It is not supported on
// non-Windows platforms.
//
// Deprecated: use [github.com/Microsoft/go-winio.DialPipe] or [github.com/Microsoft/go-winio.DialPipeContext].
func DialPipe(addr string, timeout time.Duration) (net.Conn, error) {
return dialPipe(addr, timeout)
}
func configureUnixTransport(tr *http.Transport, proto, addr string) error {
if len(addr) > maxUnixSocketPathSize {
return fmt.Errorf("unix socket path %q is too long", addr)
}
@@ -60,7 +60,7 @@ func configureUnixTransport(tr *http.Transport, addr string) error {
Timeout: defaultTimeout,
}
tr.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) {
return dialer.DialContext(ctx, "unix", addr)
return dialer.DialContext(ctx, proto, addr)
}
return nil
}
+12 -1
View File
@@ -2,6 +2,17 @@
package sockets
func configureNpipeTransport(any, string) error {
import (
"net"
"net/http"
"syscall"
"time"
)
func configureNpipeTransport(tr *http.Transport, proto, addr string) error {
return ErrProtocolNotAvailable
}
func dialPipe(_ string, _ time.Duration) (net.Conn, error) {
return nil, syscall.EAFNOSUPPORT
}
+6 -1
View File
@@ -4,11 +4,12 @@ import (
"context"
"net"
"net/http"
"time"
"github.com/Microsoft/go-winio"
)
func configureNpipeTransport(tr *http.Transport, addr string) error {
func configureNpipeTransport(tr *http.Transport, proto, addr string) error {
// No need for compression in local communications.
tr.DisableCompression = true
tr.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) {
@@ -16,3 +17,7 @@ func configureNpipeTransport(tr *http.Transport, addr string) error {
}
return nil
}
func dialPipe(addr string, timeout time.Duration) (net.Conn, error) {
return winio.DialPipe(addr, &timeout)
}
+1 -123
View File
@@ -1,128 +1,6 @@
package sockets
import (
"errors"
"fmt"
"net"
"strings"
"github.com/Microsoft/go-winio"
"golang.org/x/sys/windows"
)
// BasePermissions defines the default DACL, which allows Administrators
// and LocalSystem full access (similar to defaults used in [moby]);
//
// - D:P: DACL without inheritance (protected, (P)).
// - (A;;GA;;;BA): Allow full access (GA) for built-in Administrators (BA).
// - (A;;GA;;;SY); Allow full access (GA) for LocalSystem (SY).
// - Any other user is denied access.
//
// [moby]: https://github.com/moby/moby/blob/6b45c76a233b1b8b56465f76c21c09fd7920e82d/daemon/listeners/listeners_windows.go#L53-L59
const BasePermissions = "D:P(A;;GA;;;BA)(A;;GA;;;SY)"
// WithBasePermissions sets a default DACL, which allows Administrators
// and LocalSystem full access (similar to defaults used in [moby]);
//
// - D:P: DACL without inheritance (protected, (P)).
// - (A;;GA;;;BA): Allow full access (GA) for built-in Administrators (BA).
// - (A;;GA;;;SY); Allow full access (GA) for LocalSystem (SY).
// - Any other user is denied access.
//
// [moby]: https://github.com/moby/moby/blob/6b45c76a233b1b8b56465f76c21c09fd7920e82d/daemon/listeners/listeners_windows.go#L53-L59
func WithBasePermissions() SockOption {
return withSDDL(BasePermissions)
}
// WithAdditionalUsersAndGroups modifies the socket file's DACL to grant
// access to additional users and groups.
//
// It sets [BasePermissions] on the socket path and grants the given additional
// users and groups to generic read (GR) and write (GW) access. It returns
// an error if no groups were given, when failing to resolve any of the
// additional users and groups, or when failing to apply the ACL.
func WithAdditionalUsersAndGroups(additionalUsersAndGroups []string) SockOption {
return func(path string) error {
if len(additionalUsersAndGroups) == 0 {
return errors.New("no additional users specified")
}
sd, err := getSecurityDescriptor(additionalUsersAndGroups...)
if err != nil {
return fmt.Errorf("looking up SID: %w", err)
}
return withSDDL(sd)(path)
}
}
// withSDDL applies the given SDDL to the socket. It returns an error
// when failing parse the SDDL, or if the DACL was defaulted.
//
// TODO(thaJeztah); this is not exported yet, as some of the checks may need review if they're not too opinionated.
func withSDDL(sddl string) SockOption {
return func(path string) error {
sd, err := windows.SecurityDescriptorFromString(sddl)
if err != nil {
return fmt.Errorf("parsing SDDL: %w", err)
}
dacl, defaulted, err := sd.DACL()
if err != nil {
return fmt.Errorf("extracting DACL: %w", err)
}
if dacl == nil || defaulted {
// should never be hit with our [DefaultPermissions],
// as it contains "D:" and "P" (protected, don't inherit).
return errors.New("no DACL found in security descriptor or defaulted")
}
return windows.SetNamedSecurityInfo(
path,
windows.SE_FILE_OBJECT,
windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION,
nil, // do not change the owner
nil, // do not change the owner
dacl,
nil,
)
}
}
// NewUnixSocket creates a new unix socket.
//
// It sets [BasePermissions] on the socket path and grants the given additional
// users and groups to generic read (GR) and write (GW) access. It returns
// an error when failing to resolve any of the additional users and groups,
// or when failing to apply the ACL.
func NewUnixSocket(path string, additionalUsersAndGroups []string) (net.Listener, error) {
var opts []SockOption
if len(additionalUsersAndGroups) > 0 {
opts = append(opts, WithAdditionalUsersAndGroups(additionalUsersAndGroups))
} else {
opts = append(opts, WithBasePermissions())
}
return NewUnixSocketWithOpts(path, opts...)
}
// getSecurityDescriptor returns the DACL for the Unix socket.
//
// By default, it grants [BasePermissions], but allows for additional
// users and groups to get generic read (GR) and write (GW) access. It
// returns an error when failing to resolve any of the additional users
// and groups.
func getSecurityDescriptor(additionalUsersAndGroups ...string) (string, error) {
sddl := BasePermissions
// Grant generic read (GR) and write (GW) access to whatever
// additional users or groups were specified.
//
// TODO(thaJeztah): should we fail on, or remove duplicates?
for _, g := range additionalUsersAndGroups {
sid, err := winio.LookupSidByName(strings.TrimSpace(g))
if err != nil {
return "", fmt.Errorf("looking up SID: %w", err)
}
sddl += fmt.Sprintf("(A;;GRGW;;;%s)", sid)
}
return sddl, nil
}
import "net"
func listenUnix(path string) (net.Listener, error) {
return net.Listen("unix", path)
+11 -7
View File
@@ -1,12 +1,16 @@
package tlsconfig
import "crypto/x509"
import (
"crypto/x509"
"runtime"
)
// SystemCertPool returns a copy of the system cert pool.
//
// Deprecated: use [x509.SystemCertPool] instead.
//
//go:fix inline
// SystemCertPool returns a copy of the system cert pool,
// returns an error if failed to load or empty pool on windows.
func SystemCertPool() (*x509.CertPool, error) {
return x509.SystemCertPool()
certpool, err := x509.SystemCertPool()
if err != nil && runtime.GOOS == "windows" {
return x509.NewCertPool(), nil
}
return certpool, err
}
+8 -20
View File
@@ -34,9 +34,6 @@ type Options struct {
// the system pool will be used.
ExclusiveRootPools bool
MinVersion uint16
// systemCertPool allows mocking the system cert-pool for testing.
systemCertPool func() (*x509.CertPool, error)
}
// DefaultServerAcceptedCiphers should be uses by code which already has a crypto/tls
@@ -50,8 +47,6 @@ var defaultCipherSuites = []uint16{
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256,
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256,
}
// ServerDefault returns a secure-enough TLS configuration for the server TLS configuration.
@@ -80,33 +75,26 @@ func defaultConfig(ops ...func(*tls.Config)) *tls.Config {
}
// certPool returns an X.509 certificate pool from `caFile`, the certificate file.
func certPool(opts Options) (*x509.CertPool, error) {
func certPool(caFile string, exclusivePool bool) (*x509.CertPool, error) {
// If we should verify the server, we need to load a trusted ca
var (
pool *x509.CertPool
err error
)
if opts.ExclusiveRootPools {
if exclusivePool {
pool = x509.NewCertPool()
} else {
if opts.systemCertPool != nil {
pool, err = opts.systemCertPool()
} else {
pool, err = x509.SystemCertPool()
}
pool, err = SystemCertPool()
if err != nil {
return nil, fmt.Errorf("failed to read system certificates: %v", err)
}
}
if opts.CAFile == "" {
return pool, nil
}
pemData, err := os.ReadFile(opts.CAFile)
pemData, err := os.ReadFile(caFile)
if err != nil {
return nil, fmt.Errorf("could not read CA certificate %q: %v", opts.CAFile, err)
return nil, fmt.Errorf("could not read CA certificate %q: %v", caFile, err)
}
if !pool.AppendCertsFromPEM(pemData) {
return nil, fmt.Errorf("failed to append certificates from PEM file: %q", opts.CAFile)
return nil, fmt.Errorf("failed to append certificates from PEM file: %q", caFile)
}
return pool, nil
}
@@ -209,7 +197,7 @@ func Client(options Options) (*tls.Config, error) {
tlsConfig := defaultConfig()
tlsConfig.InsecureSkipVerify = options.InsecureSkipVerify
if !options.InsecureSkipVerify && options.CAFile != "" {
CAs, err := certPool(options)
CAs, err := certPool(options.CAFile, options.ExclusiveRootPools)
if err != nil {
return nil, err
}
@@ -242,7 +230,7 @@ func Server(options Options) (*tls.Config, error) {
}
tlsConfig.Certificates = []tls.Certificate{tlsCert}
if options.ClientAuth >= tls.VerifyClientCertIfGiven && options.CAFile != "" {
CAs, err := certPool(options)
CAs, err := certPool(options.CAFile, options.ExclusiveRootPools)
if err != nil {
return nil, err
}
+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
+30 -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,14 @@ 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
}
}
func (c *Config) unmarshalUser() {
@@ -379,7 +399,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 +457,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()
}
@@ -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
}
}
}
+118 -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)
@@ -455,6 +536,10 @@ func (w *TreeWalker) Next() (name string, entry TreeEntry, err error) {
continue
}
if err := pathutil.ValidTreePath(entry.Name); err != nil {
return name, entry, err
}
if entry.Mode == filemode.Dir {
obj, err = GetTree(w.s, entry.Hash)
}
+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) {
+12 -1
View File
@@ -1530,7 +1530,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) {
+17 -2
View File
@@ -75,6 +75,10 @@ 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")
)
// Options holds configuration for the storage.
@@ -1127,9 +1131,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
+4 -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 {
@@ -763,10 +656,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 {
+264
View File
@@ -0,0 +1,264 @@
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 passed
// to a mutating operation. This prevents writing to, or deleting from,
// dangerous locations (e.g. .git/*, ../) regardless of which worktree
// code path triggers the operation.
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.validPath(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.validPath(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.validPath(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.validPath(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.validPath(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.validPath(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)
}
return sfs.Filesystem.Chroot(path)
}
// validReadPath is like validPath 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 continue to use validPath directly.
func (sfs *worktreeFilesystem) validReadPath(p string) error {
if p == "" || p == "." || p == "/" {
return nil
}
return sfs.validPath(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
}
// 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
}
+9
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"
@@ -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 -17
View File
@@ -71,7 +71,6 @@ type ServeMux struct {
streamErrorHandler StreamErrorHandlerFunc
routingErrorHandler RoutingErrorHandlerFunc
disablePathLengthFallback bool
disableHTTPMethodOverride bool
unescapingMode UnescapingMode
writeContentLength bool
disableChunkedEncoding bool
@@ -272,19 +271,6 @@ func WithDisablePathLengthFallback() ServeMuxOption {
}
}
// WithDisableHTTPMethodOverride returns a ServeMuxOption that disables the
// X-HTTP-Method-Override header handling.
//
// When this option is used, the mux will no longer allow POST requests with
// the X-HTTP-Method-Override header to override the HTTP method. The path
// length fallback (POST with application/x-www-form-urlencoded falling back
// to a matching GET handler) is not affected by this option.
func WithDisableHTTPMethodOverride() ServeMuxOption {
return func(serveMux *ServeMux) {
serveMux.disableHTTPMethodOverride = true
}
}
// WithWriteContentLength returns a ServeMuxOption to enable writing content length on non-streaming responses
func WithWriteContentLength() ServeMuxOption {
return func(serveMux *ServeMux) {
@@ -419,7 +405,7 @@ func (s *ServeMux) ServeHTTP(w http.ResponseWriter, r *http.Request) {
path = r.URL.RawPath
}
if override := r.Header.Get("X-HTTP-Method-Override"); override != "" && !s.disableHTTPMethodOverride && s.isPathLengthFallback(r) {
if override := r.Header.Get("X-HTTP-Method-Override"); override != "" && s.isPathLengthFallback(r) {
if err := r.ParseForm(); err != nil {
_, outboundMarshaler := MarshalerForRequest(s, r)
sterr := status.Error(codes.InvalidArgument, err.Error())
@@ -481,7 +467,6 @@ func (s *ServeMux) ServeHTTP(w http.ResponseWriter, r *http.Request) {
HTTPStatus: http.StatusBadRequest,
Err: mse,
})
return
}
continue
}
@@ -524,7 +509,6 @@ func (s *ServeMux) ServeHTTP(w http.ResponseWriter, r *http.Request) {
HTTPStatus: http.StatusBadRequest,
Err: mse,
})
return
}
continue
}
+22 -76
View File
@@ -3,7 +3,6 @@ package runewidth
import (
"os"
"strings"
"unicode/utf8"
"github.com/clipperhouse/uax29/v2/graphemes"
)
@@ -24,48 +23,10 @@ var (
}
)
var (
zerowidth table // combining + nonprint merged for faster zero-width lookup
widewidth table // ambiguous + doublewidth merged for EA path
)
func init() {
zerowidth = mergeIntervals(combining, nonprint)
widewidth = mergeIntervals(ambiguous, doublewidth)
handleEnv()
}
func mergeIntervals(t1, t2 table) table {
merged := make(table, 0, len(t1)+len(t2))
i, j := 0, 0
for i < len(t1) && j < len(t2) {
if t1[i].first <= t2[j].first {
merged = append(merged, t1[i])
i++
} else {
merged = append(merged, t2[j])
j++
}
}
merged = append(merged, t1[i:]...)
merged = append(merged, t2[j:]...)
if len(merged) == 0 {
return merged
}
result := merged[:1]
for _, iv := range merged[1:] {
last := &result[len(result)-1]
if iv.first <= last.last+1 {
if iv.last > last.last {
last.last = iv.last
}
} else {
result = append(result, iv)
}
}
return result
}
func handleEnv() {
env := os.Getenv("RUNEWIDTH_EASTASIAN")
if env == "" {
@@ -90,6 +51,15 @@ type interval struct {
type table []interval
func inTables(r rune, ts ...table) bool {
for _, t := range ts {
if inTable(r, t) {
return true
}
}
return false
}
func inTable(r rune, t table) bool {
if r < t[0].first {
return false
@@ -160,7 +130,9 @@ func (c *Condition) RuneWidth(r rune) int {
return 0
case r < 0x300:
return 1
case inTable(r, zerowidth):
case inTable(r, narrow):
return 1
case inTables(r, nonprint, combining):
return 0
case inTable(r, doublewidth):
return 2
@@ -169,13 +141,13 @@ func (c *Condition) RuneWidth(r rune) int {
}
} else {
switch {
case inTable(r, zerowidth):
case inTables(r, nonprint, combining):
return 0
case inTable(r, narrow):
return 1
case inTable(r, widewidth):
case inTables(r, ambiguous, doublewidth):
return 2
case !c.StrictEmojiNeutral && inTable(r, emoji):
case !c.StrictEmojiNeutral && inTables(r, ambiguous, emoji, narrow):
return 2
default:
return 1
@@ -206,22 +178,6 @@ func (c *Condition) CreateLUT() {
// StringWidth return width as you can see
func (c *Condition) StringWidth(s string) (width int) {
if len(s) > 0 && len(s) <= utf8.UTFMax {
r, size := utf8.DecodeRuneInString(s)
if size == len(s) {
return c.RuneWidth(r)
}
}
// ASCII fast path: no grapheme clustering needed for pure ASCII
if isAllASCII(s) {
for i := 0; i < len(s); i++ {
b := s[i]
if b >= 0x20 && b != 0x7F {
width++
}
}
return
}
g := graphemes.FromString(s)
for g.Next() {
var chWidth int
@@ -236,15 +192,6 @@ func (c *Condition) StringWidth(s string) (width int) {
return
}
func isAllASCII(s string) bool {
for i := 0; i < len(s); i++ {
if s[i] >= 0x80 {
return false
}
}
return true
}
// Truncate return string truncated with w cells
func (c *Condition) Truncate(s string, w int, tail string) string {
if c.StringWidth(s) <= w {
@@ -310,25 +257,24 @@ func (c *Condition) TruncateLeft(s string, w int, prefix string) string {
// Wrap return string wrapped with w cells
func (c *Condition) Wrap(s string, w int) string {
width := 0
var out strings.Builder
out.Grow(len(s) + len(s)/w + 1)
out := ""
for _, r := range s {
cw := c.RuneWidth(r)
if r == '\n' {
out.WriteRune(r)
out += string(r)
width = 0
continue
} else if width+cw > w {
out.WriteByte('\n')
out += "\n"
width = 0
out.WriteRune(r)
out += string(r)
width += cw
continue
}
out.WriteRune(r)
out += string(r)
width += cw
}
return out.String()
return out
}
// FillLeft return string filled in left by spaces in w cells
@@ -367,7 +313,7 @@ func RuneWidth(r rune) int {
// IsAmbiguousWidth returns whether is ambiguous width or not.
func IsAmbiguousWidth(r rune) bool {
return inTable(r, private) || inTable(r, ambiguous)
return inTables(r, private, ambiguous)
}
// IsCombiningWidth returns whether is combining width or not.
+40
View File
@@ -36,6 +36,10 @@ import (
const ImpliedDirectoryMode = 0o755
type (
// Compression is the state represents if compressed or not.
//
// Deprecated: use [compression.Compression].
Compression = compression.Compression
// WhiteoutFormat is the format of whiteouts unpacked
WhiteoutFormat int
@@ -91,6 +95,14 @@ func NewDefaultArchiver() *Archiver {
// in order for the test to pass.
type breakoutError error
const (
Uncompressed = compression.None // Deprecated: use [compression.None].
Bzip2 = compression.Bzip2 // Deprecated: use [compression.Bzip2].
Gzip = compression.Gzip // Deprecated: use [compression.Gzip].
Xz = compression.Xz // Deprecated: use [compression.Xz].
Zstd = compression.Zstd // Deprecated: use [compression.Zstd].
)
const (
AUFSWhiteoutFormat WhiteoutFormat = 0 // AUFSWhiteoutFormat is the default format for whiteouts
OverlayWhiteoutFormat WhiteoutFormat = 1 // OverlayWhiteoutFormat formats whiteout according to the overlay standard.
@@ -114,6 +126,27 @@ func IsArchivePath(path string) bool {
return err == nil
}
// DetectCompression detects the compression algorithm of the source.
//
// Deprecated: use [compression.Detect].
func DetectCompression(source []byte) compression.Compression {
return compression.Detect(source)
}
// DecompressStream decompresses the archive and returns a ReaderCloser with the decompressed archive.
//
// Deprecated: use [compression.DecompressStream].
func DecompressStream(archive io.Reader) (io.ReadCloser, error) {
return compression.DecompressStream(archive)
}
// CompressStream compresses the dest with specified compression algorithm.
//
// Deprecated: use [compression.CompressStream].
func CompressStream(dest io.Writer, comp compression.Compression) (io.WriteCloser, error) {
return compression.CompressStream(dest, comp)
}
// TarModifierFunc is a function that can be passed to ReplaceFileTarWrapper to
// modify the contents or header of an entry in the archive. If the file already
// exists in the archive the TarModifierFunc will be called with the Header and
@@ -202,6 +235,13 @@ func ReplaceFileTarWrapper(inputTarStream io.ReadCloser, mods map[string]TarModi
return pipeReader
}
// FileInfoHeaderNoLookups creates a partially-populated tar.Header from fi.
//
// Deprecated: use [tarheader.FileInfoHeaderNoLookups].
func FileInfoHeaderNoLookups(fi os.FileInfo, link string) (*tar.Header, error) {
return tarheader.FileInfoHeaderNoLookups(fi, link)
}
// FileInfoHeader creates a populated Header from fi.
//
// Compared to the archive/tar package, this function fills in less information
+1 -1
View File
@@ -1,4 +1,4 @@
//go:build darwin || freebsd || netbsd
//go:build !linux && !windows
package archive
+15
View File
@@ -0,0 +1,15 @@
*
!.git/
!build/root.tgz
!cmd/
!config/
!internal/
!mod/
!pkg/
!regclient/
!scheme/
!types/
!vendor/
!go.*
!*.go
!Makefile
+5
View File
@@ -0,0 +1,5 @@
artifacts/
bin/
output/
vendor/
.regctl_conf_ci.json
+19
View File
@@ -0,0 +1,19 @@
# all lists use a `-`
MD004:
style: dash
# allow tabs in code blocks (for Go)
MD010:
code_blocks: false
# disable line length, prefer one sentence per line for PRs
MD013: false
# emphasis with underscore (`_emphasis_`)
MD049:
style: "underscore"
# bold with asterisk (`**bold**`)
MD050:
style: "asterisk"
+1
View File
@@ -0,0 +1 @@
GoVersionOverride = "1.26.3"
+53
View File
@@ -0,0 +1,53 @@
{"name":"docker-arg-alpine-digest","key":"docker.io/library/alpine:3.23.4","version":"sha256:5b10f432ef3da1b8d4c7eb6c487f2f5a8f096bc91145e68878dd4a5019afde11"}
{"name":"docker-arg-alpine-tag","key":"docker.io/library/alpine","version":"3.23.4"}
{"name":"docker-arg-ecr","key":"https://github.com/awslabs/amazon-ecr-credential-helper.git","version":"v0.12.0"}
{"name":"docker-arg-gcr","key":"https://github.com/GoogleCloudPlatform/docker-credential-gcr.git","version":"v2.1.32"}
{"name":"docker-arg-go-digest","key":"docker.io/library/golang:1.26.3-alpine","version":"sha256:91eda9776261207ea25fd06b5b7fed8d397dd2c0a283e77f2ab6e91bfa71079d"}
{"name":"docker-arg-go-tag","key":"docker.io/library/golang","version":"1.26.3"}
{"name":"docker-arg-lunajson","key":"https://github.com/grafi-tt/lunajson.git:master","version":"e3a9666eb1275741e887e29926b144f8daee3bef"}
{"name":"docker-arg-semver","key":"https://github.com/kikito/semver.lua.git:master","version":"a4b708ba243208d46e575da870af969dca46a94d"}
{"name":"gha-alpine-digest","key":"docker.io/library/alpine:3.23.4","version":"sha256:5b10f432ef3da1b8d4c7eb6c487f2f5a8f096bc91145e68878dd4a5019afde11"}
{"name":"gha-alpine-tag-base","key":"docker.io/library/alpine","version":"3"}
{"name":"gha-alpine-tag-comment","key":"docker.io/library/alpine","version":"3.23.4"}
{"name":"gha-cosign-version","key":"https://github.com/sigstore/cosign.git","version":"v3.0.6"}
{"name":"gha-golang-matrix","key":"golang-matrix","version":"[\"1.25\", \"1.26\"]"}
{"name":"gha-golang-release","key":"golang-latest","version":"1.26"}
{"name":"gha-syft-version","key":"docker.io/anchore/syft","version":"v1.44.0"}
{"name":"gha-uses-commit","key":"https://github.com/actions/checkout.git:v6.0.2","version":"de0fac2e4500dabe0009e67214ff5f5447ce83dd"}
{"name":"gha-uses-commit","key":"https://github.com/actions/setup-go.git:v6.4.0","version":"4a3601121dd01d1626a1e23e37211e3254c1c06c"}
{"name":"gha-uses-commit","key":"https://github.com/actions/stale.git:v10.3.0","version":"eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899"}
{"name":"gha-uses-commit","key":"https://github.com/actions/upload-artifact.git:v7.0.1","version":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a"}
{"name":"gha-uses-commit","key":"https://github.com/anchore/sbom-action.git:v0.24.0","version":"e22c389904149dbc22b58101806040fa8d37a610"}
{"name":"gha-uses-commit","key":"https://github.com/docker/build-push-action.git:v7.2.0","version":"f9f3042f7e2789586610d6e8b85c8f03e5195baf"}
{"name":"gha-uses-commit","key":"https://github.com/docker/login-action.git:v4.2.0","version":"650006c6eb7dba73a995cc03b0b2d7f5ca915bee"}
{"name":"gha-uses-commit","key":"https://github.com/docker/setup-buildx-action.git:v4.1.0","version":"d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5"}
{"name":"gha-uses-commit","key":"https://github.com/regclient/actions.git:main","version":"c70ad64367908075211b10dcd2ab9fad4bfa1816"}
{"name":"gha-uses-commit","key":"https://github.com/sigstore/cosign-installer.git:v4.1.2","version":"6f9f17788090df1f26f669e9d70d6ae9567deba6"}
{"name":"gha-uses-commit","key":"https://github.com/softprops/action-gh-release.git:v3.0.0","version":"b4309332981a82ec1c5618f44dd2e27cc8bfbfda"}
{"name":"gha-uses-semver","key":"https://github.com/actions/checkout.git","version":"v6.0.2"}
{"name":"gha-uses-semver","key":"https://github.com/actions/setup-go.git","version":"v6.4.0"}
{"name":"gha-uses-semver","key":"https://github.com/actions/stale.git","version":"v10.3.0"}
{"name":"gha-uses-semver","key":"https://github.com/actions/upload-artifact.git","version":"v7.0.1"}
{"name":"gha-uses-semver","key":"https://github.com/anchore/sbom-action.git","version":"v0.24.0"}
{"name":"gha-uses-semver","key":"https://github.com/docker/build-push-action.git","version":"v7.2.0"}
{"name":"gha-uses-semver","key":"https://github.com/docker/login-action.git","version":"v4.2.0"}
{"name":"gha-uses-semver","key":"https://github.com/docker/setup-buildx-action.git","version":"v4.1.0"}
{"name":"gha-uses-semver","key":"https://github.com/sigstore/cosign-installer.git","version":"v4.1.2"}
{"name":"gha-uses-semver","key":"https://github.com/softprops/action-gh-release.git","version":"v3.0.0"}
{"name":"go-mod-golang-release","key":"golang-oldest","version":"1.25.0"}
{"name":"makefile-ci-distribution","key":"docker.io/library/registry","version":"3.1.1"}
{"name":"makefile-ci-zot","key":"ghcr.io/project-zot/zot-linux-amd64","version":"v2.1.17"}
{"name":"makefile-go-vulncheck","key":"https://go.googlesource.com/vuln.git","version":"v1.3.0"}
{"name":"makefile-gofumpt","key":"https://github.com/mvdan/gofumpt.git","version":"v0.10.0"}
{"name":"makefile-gomajor","key":"https://github.com/icholy/gomajor.git","version":"v0.15.0"}
{"name":"makefile-gosec","key":"https://github.com/securego/gosec.git","version":"v2.26.1"}
{"name":"makefile-markdown-lint","key":"docker.io/davidanson/markdownlint-cli2","version":"v0.22.1"}
{"name":"makefile-osv-scanner","key":"https://github.com/google/osv-scanner.git","version":"v2.3.8"}
{"name":"makefile-staticcheck","key":"https://github.com/dominikh/go-tools.git","version":"v0.7.0"}
{"name":"makefile-syft-container-digest","key":"anchore/syft:v1.44.0","version":"sha256:86fde6445b483d902fe011dd9f68c4987dd94e07da1e9edc004e3c2422650de6"}
{"name":"makefile-syft-container-tag","key":"anchore/syft","version":"v1.44.0"}
{"name":"makefile-syft-version","key":"docker.io/anchore/syft","version":"v1.44.0"}
{"name":"osv-golang-release","key":"docker.io/library/golang","version":"1.26.3"}
{"name":"shell-alpine-digest","key":"docker.io/library/alpine:3.23.4","version":"sha256:5b10f432ef3da1b8d4c7eb6c487f2f5a8f096bc91145e68878dd4a5019afde11"}
{"name":"shell-alpine-tag-base","key":"docker.io/library/alpine","version":"3"}
{"name":"shell-alpine-tag-comment","key":"docker.io/library/alpine","version":"3.23.4"}
+346
View File
@@ -0,0 +1,346 @@
files:
"build/Dockerfile*":
processors:
- docker-arg-alpine-tag
- docker-arg-alpine-digest
- docker-arg-go-tag
- docker-arg-go-digest
- docker-arg-ecr
- docker-arg-gcr
- docker-arg-lunajson
- docker-arg-semver
"build/oci-image.sh":
processors:
- shell-alpine-tag-base
- shell-alpine-tag-comment
- shell-alpine-digest
".github/workflows/*.yml":
processors:
- gha-golang-matrix
- gha-golang-release
- gha-uses-vx
- gha-uses-semver
- gha-uses-commit
- gha-syft-version
- gha-cosign-version
- gha-alpine-tag-base
- gha-alpine-tag-comment
- gha-alpine-digest
"Makefile":
processors:
- makefile-gofumpt
- makefile-gomajor
- makefile-go-vulncheck
- makefile-markdown-lint
- makefile-gosec
- makefile-osv-scanner
- makefile-staticcheck
- makefile-syft-version
- makefile-syft-container-tag
- makefile-syft-container-digest
- makefile-ci-distribution
- makefile-ci-zot
"go.mod":
processors:
- go-mod-golang-release
".osv-scanner.toml":
processors:
- osv-golang-release
x-processor-tmpl:
git-commit: &git-commit
key: "{{ .SourceArgs.url }}:{{ .SourceArgs.ref }}"
scan: "regexp"
source: "git-commit"
filter:
expr: "^{{ .SourceArgs.ref }}$"
git-tag-semver: &git-tag-semver
key: "{{ .SourceArgs.url }}"
scan: "regexp"
source: "git-tag"
filter:
expr: '^v?\d+\.\d+\.\d+$'
sort:
method: "semver"
registry-digest: &registry-digest
key: "{{ .SourceArgs.image }}"
scan: "regexp"
source: "registry-digest"
registry-tag-semver: &registry-tag-semver
key: "{{ .SourceArgs.repo }}"
scan: "regexp"
source: "registry-tag"
filter:
expr: '^v?\d+\.\d+\.\d+$'
sort:
method: "semver"
processors:
docker-arg-alpine-tag:
<<: *registry-tag-semver
scanArgs:
regexp: '^ARG ALPINE_VER=(?P<Version>v?\d+\.\d+\.\d+)@(?P<SHA>sha256:[0-9a-f]+)\s*$'
sourceArgs:
repo: "docker.io/library/alpine"
docker-arg-alpine-digest:
<<: *registry-digest
scanArgs:
regexp: '^ARG ALPINE_VER=(?P<Tag>v?\d+\.\d+\.\d+)@(?P<Version>sha256:[0-9a-f]+)\s*$'
sourceArgs:
image: "docker.io/library/alpine:{{.ScanMatch.Tag}}"
docker-arg-go-tag:
<<: *registry-tag-semver
scanArgs:
regexp: '^ARG GO_VER=(?P<Version>[a-z0-9\-\.]+)-alpine@(?P<SHA>sha256:[0-9a-f]+)\s*$'
sourceArgs:
repo: "docker.io/library/golang"
docker-arg-go-digest:
<<: *registry-digest
scanArgs:
regexp: '^ARG GO_VER=(?P<Tag>[a-z0-9\-\.]+)@(?P<Version>sha256:[0-9a-f]+)\s*$'
sourceArgs:
image: "docker.io/library/golang:{{.ScanMatch.Tag}}"
docker-arg-ecr:
<<: *git-tag-semver
scanArgs:
regexp: '^ARG ECR_HELPER_VER=(?P<Version>v?\d+\.\d+\.\d+)\s*$'
sourceArgs:
url: "https://github.com/awslabs/amazon-ecr-credential-helper.git"
# get the version for the ecr-login nested package in the repo
filter:
expr: '^ecr-login/v?\d+\.\d+\.\d+$'
# sort and output only the version number without the package name prefix
sort:
method: "semver"
template: '{{ index (split . "/") 1 }}'
template: '{{ index (split .Version "/") 1 }}'
docker-arg-gcr:
<<: *git-tag-semver
scanArgs:
regexp: '^ARG GCR_HELPER_VER=(?P<Version>v?\d+\.\d+\.\d+)\s*$'
sourceArgs:
url: "https://github.com/GoogleCloudPlatform/docker-credential-gcr.git"
docker-arg-lunajson:
<<: *git-commit
scanArgs:
regexp: '^ARG LUNAJSON_COMMIT=(?P<Version>[0-9a-f]+)\s*$'
sourceArgs:
url: "https://github.com/grafi-tt/lunajson.git"
ref: master
docker-arg-semver:
<<: *git-commit
scanArgs:
regexp: '^ARG SEMVER_COMMIT=(?P<Version>[0-9a-f]+)\s*$'
sourceArgs:
url: "https://github.com/kikito/semver.lua.git"
ref: master
gha-alpine-digest:
<<: *registry-digest
scanArgs:
regexp: '^\s*ALPINE_DIGEST: "(?P<Version>sha256:[0-9a-f]+)"\s*#\s*(?P<Tag>\d+\.\d+\.\d+)\s*$'
sourceArgs:
image: "docker.io/library/alpine:{{ .ScanMatch.Tag }}"
gha-alpine-tag-base:
<<: *registry-tag-semver
scanArgs:
regexp: '^\s*ALPINE_NAME: "alpine:(?P<Version>v?\d+)"\s*$'
sourceArgs:
repo: "docker.io/library/alpine"
# only return the major version number in the tag to support detecting a change in the base image
template: '{{ index ( split .Version "." ) 0 }}'
gha-alpine-tag-comment:
<<: *registry-tag-semver
scanArgs:
regexp: '^\s*ALPINE_DIGEST: "(?P<Digest>sha256:[0-9a-f]+)"\s*#\s*(?P<Version>v?\d+\.\d+\.\d+)\s*$'
sourceArgs:
repo: "docker.io/library/alpine"
gha-cosign-version:
<<: *git-tag-semver
scanArgs:
regexp: '^\s*cosign-release: "(?P<Version>v?[0-9\.]+)"\s*$'
sourceArgs:
url: "https://github.com/sigstore/cosign.git"
filter:
expr: '^v?3\.\d+\.\d+$' # pin to v3, v4 will remove support for older clients
gha-golang-matrix:
<<: *registry-tag-semver
key: "golang-matrix"
scanArgs:
regexp: '^\s*gover: (?P<Version>\[["0-9, \.]+\])\s*$'
sourceArgs:
repo: "docker.io/library/golang"
filter:
expr: '^v?\d+\.\d+$'
template: '["{{ index .VerMap ( index .VerList 1 ) }}", "{{ index .VerMap ( index .VerList 0 ) }}"]'
gha-golang-release:
<<: *registry-tag-semver
key: "golang-latest"
scanArgs:
regexp: '^\s*RELEASE_GO_VER: "(?P<Version>v?[0-9\.]+)"\s*$'
sourceArgs:
repo: "docker.io/library/golang"
filter:
expr: '^v?\d+\.\d+$'
gha-syft-version:
<<: *registry-tag-semver
scanArgs:
regexp: '^\s*syft-version: "(?P<Version>v?[0-9\.]+)"\s*$'
sourceArgs:
repo: "docker.io/anchore/syft"
gha-uses-vx:
<<: *git-tag-semver
scanArgs:
regexp: '^\s+-?\s+uses: (?P<Repo>[^@/]+/[^@/]+)[^@]*@(?P<Commit>[0-9a-f]+)\s+#\s+(?P<Version>v?\d+)\s*$'
sourceArgs:
url: "https://github.com/{{ .ScanMatch.Repo }}.git"
filter:
expr: '^v?\d+$'
gha-uses-semver:
<<: *git-tag-semver
scanArgs:
regexp: '^\s+-?\s+uses: (?P<Repo>[^@/]+/[^@/]+)[^@]*@(?P<Commit>[0-9a-f]+)\s+#\s+(?P<Version>v?\d+\.\d+\.\d+)\s*$'
sourceArgs:
url: "https://github.com/{{ .ScanMatch.Repo }}.git"
gha-uses-commit:
<<: *git-commit
scanArgs:
regexp: '^\s+-?\s+uses: (?P<Repo>[^@/]+/[^@/]+)[^@]*@(?P<Version>[0-9a-f]+)\s+#\s+(?P<Ref>[\w\d\.]+)\s*$'
sourceArgs:
url: "https://github.com/{{ .ScanMatch.Repo }}.git"
ref: "{{ .ScanMatch.Ref }}"
go-mod-golang-release:
<<: *registry-tag-semver
key: "golang-oldest"
scanArgs:
regexp: '^go (?P<Version>[0-9\.]+)\s*$'
sourceArgs:
repo: "docker.io/library/golang"
filter:
expr: '^\d+\.\d+$'
template: '{{ index .VerMap ( index .VerList 1 ) }}.0'
makefile-ci-distribution:
<<: *registry-tag-semver
scanArgs:
regexp: '^CI_DISTRIBUTION_VER\?=(?P<Version>v?[0-9\.]+)\s*$'
sourceArgs:
repo: "docker.io/library/registry"
makefile-ci-zot:
<<: *registry-tag-semver
scanArgs:
regexp: '^CI_ZOT_VER\?=(?P<Version>v?[0-9\.]+)\s*$'
sourceArgs:
repo: "ghcr.io/project-zot/zot-linux-amd64"
makefile-gofumpt:
<<: *git-tag-semver
scanArgs:
regexp: '^GOFUMPT_VER\?=(?P<Version>v?[0-9\.]+)\s*$'
sourceArgs:
url: "https://github.com/mvdan/gofumpt.git"
makefile-gomajor:
<<: *git-tag-semver
scanArgs:
regexp: '^GOMAJOR_VER\?=(?P<Version>v?[0-9\.]+)\s*$'
sourceArgs:
url: "https://github.com/icholy/gomajor.git"
makefile-gosec:
<<: *git-tag-semver
scanArgs:
regexp: '^GOSEC_VER\?=(?P<Version>v?[0-9\.]+)\s*$'
sourceArgs:
url: "https://github.com/securego/gosec.git"
makefile-go-vulncheck:
<<: *git-tag-semver
scanArgs:
regexp: '^GO_VULNCHECK_VER\?=(?P<Version>v?[0-9\.]+)\s*$'
sourceArgs:
url: "https://go.googlesource.com/vuln.git"
makefile-markdown-lint:
<<: *registry-tag-semver
scanArgs:
regexp: '^MARKDOWN_LINT_VER\?=(?P<Version>v?[0-9\.]+)\s*$'
sourceArgs:
repo: "docker.io/davidanson/markdownlint-cli2"
makefile-osv-scanner:
<<: *git-tag-semver
scanArgs:
regexp: '^OSV_SCANNER_VER\?=(?P<Version>v?[0-9\.]+)\s*$'
sourceArgs:
url: "https://github.com/google/osv-scanner.git"
makefile-staticcheck:
<<: *git-tag-semver
scanArgs:
regexp: '^STATICCHECK_VER\?=(?P<Version>v?[0-9\.]+)\s*$'
sourceArgs:
url: "https://github.com/dominikh/go-tools.git"
filter:
# repo also has dated tags, ignore versions without a preceding "v"
expr: '^v\d+\.\d+\.\d+$'
makefile-syft-container-tag:
<<: *registry-tag-semver
scanArgs:
regexp: '^SYFT_CONTAINER\?=(?P<Repo>[^:]*):(?P<Version>v?[0-9\.]+)@(?P<Digest>sha256:[0-9a-f]+)\s*$'
sourceArgs:
repo: "{{ .ScanMatch.Repo }}"
makefile-syft-container-digest:
<<: *registry-digest
scanArgs:
regexp: '^SYFT_CONTAINER\?=(?P<Image>[^:]*):(?P<Tag>v?[0-9\.]+)@(?P<Version>sha256:[0-9a-f]+)\s*$'
sourceArgs:
image: "{{ .ScanMatch.Image }}:{{.ScanMatch.Tag}}"
makefile-syft-version:
<<: *registry-tag-semver
scanArgs:
regexp: '^SYFT_VERSION\?=(?P<Version>v[0-9\.]+)\s*$'
sourceArgs:
repo: "docker.io/anchore/syft"
osv-golang-release:
<<: *registry-tag-semver
scanArgs:
regexp: '^GoVersionOverride = "(?P<Version>v?[0-9\.]+)"\s*$'
sourceArgs:
repo: "docker.io/library/golang"
shell-alpine-tag-base:
<<: *registry-tag-semver
scanArgs:
regexp: '^\s*ALPINE_NAME="alpine:(?P<Version>v?\d+)"\s*$'
sourceArgs:
repo: "docker.io/library/alpine"
# only return the major version number in the tag to support detecting a change in the base image
template: '{{ index ( split .Version "." ) 0 }}'
shell-alpine-tag-comment:
<<: *registry-tag-semver
scanArgs:
regexp: '^\s*ALPINE_DIGEST="(?P<Digest>sha256:[0-9a-f]+)"\s*#\s*(?P<Version>v?\d+\.\d+\.\d+)\s*$'
sourceArgs:
repo: "docker.io/library/alpine"
shell-alpine-digest:
<<: *registry-digest
scanArgs:
regexp: '^\s*ALPINE_DIGEST="(?P<Version>sha256:[0-9a-f]+)"\s*#\s*(?P<Tag>\d+\.\d+\.\d+)\s*$'
sourceArgs:
image: "docker.io/library/alpine:{{ .ScanMatch.Tag }}"
scans:
regexp:
type: "regexp"
sources:
git-commit:
type: "git"
args:
type: "commit"
git-tag:
type: "git"
args:
type: "tag"
registry-digest:
type: "registry"
registry-tag:
type: "registry"
args:
type: "tag"
+134
View File
@@ -0,0 +1,134 @@
# Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, caste, color, religion, or sexual
identity and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
- Focusing on what is best not just for us as individuals, but for the overall
community
Examples of unacceptable behavior include:
- The use of sexualized language or imagery, and sexual attention or advances of
any kind
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or email address,
without their explicit permission
- Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
<git@bmitch.net> or slack (I'm found on the CNCF, Docker, OCI, and OpenSSF
slacks).
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series of
actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or permanent
ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within the
community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.1, available at
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
Community Impact Guidelines were inspired by
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
For answers to common questions about this code of conduct, see the FAQ at
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
[https://www.contributor-covenant.org/translations][translations].
[homepage]: https://www.contributor-covenant.org
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
[Mozilla CoC]: https://github.com/mozilla/diversity
[FAQ]: https://www.contributor-covenant.org/faq
[translations]: https://www.contributor-covenant.org/translations
+80
View File
@@ -0,0 +1,80 @@
# Contributing
## Reporting security issues
Please see [SECURITY.md](security.md) for the process to report security issues.
## Reporting other issues
Please search for similar issues and if none are seen, report an issue at [github.com/regclient/regclient/issues](https://github.com/regclient/regclient/issues).
## Code style
This project attempts to follow these principles:
- Code is canonical Go, following styles and patterns commonly used by the Go community.
- Dependencies outside of the Go standard library should be minimized.
- Dependencies should be pinned to a specific digest and tracked by Go or version-check.
- Unit tests are strongly encouraged with a focus on test coverage of the successful path and common errors.
- Linters and other style formatting tools are used, please run `make all` before committing any changes.
## LLM Policy
This project expects all contributions to be developed by a human or created with a reproducible tool.
Developers using an AI/LLM tool to generate their contribution are expected to fully understand the entire contribution and the logic behind its design.
Contributions that appear to have been generated by an AI/LLM without a human review may result in a ban from future contributions to the project.
## Pull requests
PRs are welcome following the below guides:
- For anything beyond a minor fix, opening an issue is suggested to discuss possible solutions.
- Changes should be rebased on the main branch.
- Changes should be squashed to a single commit per logical change.
All changes must be signed (`git commit -s`) to indicate you agree to the [Developer Certificate or Origin](https://developercertificate.org/):
```text
Developer Certificate of Origin
Version 1.1
Copyright (C) 2004, 2006 The Linux Foundation and its contributors.
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.
```
The sign-off will include the following message in your commit:
```text
Signed-off-by: Your Name <your-email@example.org>
```
This needs to be your real name, no aliases please.
+191
View File
@@ -0,0 +1,191 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
Copyright 2020 The regclient Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+285
View File
@@ -0,0 +1,285 @@
COMMANDS?=regctl regsync regbot
BINARIES?=$(addprefix bin/,$(COMMANDS))
IMAGES?=$(addprefix docker-,$(COMMANDS))
ARTIFACT_PLATFORMS?=linux-amd64 linux-arm64 linux-ppc64le linux-s390x linux-riscv64 darwin-amd64 darwin-arm64 windows-amd64.exe freebsd-amd64
ARTIFACTS?=$(foreach cmd,$(addprefix artifacts/,$(COMMANDS)),$(addprefix $(cmd)-,$(ARTIFACT_PLATFORMS)))
IMAGE_PLATFORMS?=linux/386,linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x,linux/riscv64
VCS_REPO?="https://github.com/regclient/regclient.git"
VCS_REF?=$(shell git rev-list -1 HEAD)
ifneq ($(shell git status --porcelain 2>/dev/null),)
VCS_REF := $(VCS_REF)-dirty
endif
VCS_VERSION?=$(shell vcs_describe="$$(git describe --all)"; \
vcs_version="(devel)"; \
if [ "$${vcs_describe}" != "$${vcs_describe#tags/}" ]; then \
vcs_version="$${vcs_describe#tags/}"; \
elif [ "$${vcs_describe}" != "$${vcs_describe#heads/}" ]; then \
vcs_version="$${vcs_describe#heads/}"; \
if [ "main" = "$${vcs_version}" ]; then vcs_version=edge; fi; \
fi; \
echo "$${vcs_version}" | sed -r 's#/+#-#g')
VCS_TAG?=$(shell git describe --tags --abbrev=0 2>/dev/null || true)
VCS_SEC?=$(shell git log -1 --format=%ct)
VCS_DATE?=$(shell date -d "@$(VCS_SEC)" +%Y-%m-%dT%H:%M:%SZ --utc)
LD_FLAGS?=-s -w -extldflags -static -buildid= -X \"github.com/regclient/regclient/internal/version.vcsTag=$(VCS_TAG)\"
GO_BUILD_FLAGS?=-trimpath -ldflags "$(LD_FLAGS)"
DOCKERFILE_EXT?=$(shell if docker build --help 2>/dev/null | grep -q -- '--progress'; then echo ".buildkit"; fi)
DOCKER_ARGS?=--build-arg "VCS_REF=$(VCS_REF)" --build-arg "VCS_VERSION=$(VCS_VERSION)" --build-arg "SOURCE_DATE_EPOCH=$(VCS_SEC)" --build-arg "BUILD_DATE=$(VCS_DATE)"
GOPATH?=$(shell go env GOPATH)
PWD:=$(shell pwd)
VER_BUMP?=$(shell command -v version-bump 2>/dev/null)
VER_BUMP_CONTAINER?=sudobmitch/version-bump:edge
ifeq "$(strip $(VER_BUMP))" ''
VER_BUMP=docker run --rm \
-v "$(shell pwd)/:$(shell pwd)/" -w "$(shell pwd)" \
-u "$(shell id -u):$(shell id -g)" \
$(VER_BUMP_CONTAINER)
endif
MARKDOWN_LINT_VER?=v0.22.1
GOFUMPT_VER?=v0.10.0
GOMAJOR_VER?=v0.15.0
GOSEC_VER?=v2.26.1
GO_VULNCHECK_VER?=v1.3.0
OSV_SCANNER_VER?=v2.3.8
SYFT?=$(shell command -v syft 2>/dev/null)
SYFT_CMD_VER:=$(shell [ -x "$(SYFT)" ] && echo "v$$($(SYFT) version | awk '/^Version: / {print $$2}')" || echo "0")
SYFT_VERSION?=v1.44.0
SYFT_CONTAINER?=anchore/syft:v1.44.0@sha256:86fde6445b483d902fe011dd9f68c4987dd94e07da1e9edc004e3c2422650de6
ifneq "$(SYFT_CMD_VER)" "$(SYFT_VERSION)"
SYFT=docker run --rm \
-v "$(shell pwd)/:$(shell pwd)/" -w "$(shell pwd)" \
-u "$(shell id -u):$(shell id -g)" \
$(SYFT_CONTAINER)
endif
STATICCHECK_VER?=v0.7.0
CI_DISTRIBUTION_VER?=3.1.1
CI_ZOT_VER?=v2.1.17
.PHONY: .FORCE
.FORCE:
.PHONY: all
all: fmt gofumpt gofix goimports vet test lint binaries ## Full build of Go binaries (including fmt, vet, test, and lint)
.PHONY: fmt
fmt: ## go fmt
go fmt ./...
.PHONY: gofumpt
gofumpt: $(GOPATH)/bin/gofumpt ## gofumpt is a stricter alternative to go fmt
gofumpt -l -w .
.PHONY: gofix
gofix: ## go fix
go fix ./...
goimports: $(GOPATH)/bin/goimports
$(GOPATH)/bin/goimports -w -format-only -local github.com/regclient .
.PHONY: vet
vet: ## go vet
go vet ./...
.PHONY: test
test: ## go test
go test -cover -race ./...
.PHONY: lint
lint: lint-go lint-goimports lint-md lint-gosec ## Run all linting
.PHONY: lint-go
lint-go: $(GOPATH)/bin/gofumpt $(GOPATH)/bin/staticcheck .FORCE ## Run linting for Go
$(GOPATH)/bin/staticcheck -checks all ./...
$(GOPATH)/bin/gofumpt -l -d .
errors=$$(go fix -diff ./...); if [ "$${errors}" != "" ]; then echo "$${errors}"; exit 1; fi
lint-goimports: $(GOPATH)/bin/goimports
@if [ -n "$$($(GOPATH)/bin/goimports -l -format-only -local github.com/regclient .)" ]; then \
echo $(GOPATH)/bin/goimports -d -format-only -local github.com/regclient .; \
$(GOPATH)/bin/goimports -d -format-only -local github.com/regclient .; \
exit 1; \
fi
# excluding types/platform pending resultion to https://github.com/securego/gosec/issues/1116
.PHONY: lint-gosec
lint-gosec: $(GOPATH)/bin/gosec .FORCE ## Run gosec
$(GOPATH)/bin/gosec -terse -exclude-dir types/platform ./...
.PHONY: lint-md
lint-md: .FORCE ## Run linting for markdown
docker run --rm -v "$(PWD):/workdir:ro" davidanson/markdownlint-cli2:$(MARKDOWN_LINT_VER) \
"**/*.md" "#vendor"
.PHONY: vulnerability-scan
vulnerability-scan: osv-scanner vulncheck-go ## Run all vulnerability scanners
.PHONY: osv-scanner
osv-scanner: $(GOPATH)/bin/osv-scanner .FORCE ## Run OSV Scanner
$(GOPATH)/bin/osv-scanner scan --config .osv-scanner.toml -r --licenses="Apache-2.0,BSD-3-Clause,MIT,CC-BY-SA-4.0,UNKNOWN" .
.PHONY: vulncheck-go
vulncheck-go: $(GOPATH)/bin/govulncheck .FORCE ## Run govulncheck
$(GOPATH)/bin/govulncheck ./...
.PHONY: vendor
vendor: ## Vendor Go modules
go mod vendor
.PHONY: binaries
binaries: $(BINARIES) ## Build Go binaries
bin/%: .FORCE
CGO_ENABLED=0 go build ${GO_BUILD_FLAGS} -o bin/$* ./cmd/$*
.PHONY: docker
docker: $(IMAGES) ## Build Docker images
docker-%: .FORCE
docker build -t regclient/$* -f build/Dockerfile.$*$(DOCKERFILE_EXT) $(DOCKER_ARGS) .
docker build -t regclient/$*:alpine -f build/Dockerfile.$*$(DOCKERFILE_EXT) --target release-alpine $(DOCKER_ARGS) .
.PHONY: oci-image
oci-image: $(addprefix oci-image-,$(COMMANDS)) ## Build reproducible images to an OCI Layout
oci-image-%: bin/regctl .FORCE
PATH="$(PWD)/bin:$(PATH)" build/oci-image.sh -r scratch -i "$*" -p "$(IMAGE_PLATFORMS)"
PATH="$(PWD)/bin:$(PATH)" build/oci-image.sh -r alpine -i "$*" -p "$(IMAGE_PLATFORMS)" -b "alpine:3"
.PHONY: test-docker
test-docker: $(addprefix test-docker-,$(COMMANDS)) ## Build multi-platform docker images (but do not tag)
test-docker-%:
docker buildx build --platform="$(IMAGE_PLATFORMS)" -f build/Dockerfile.$*.buildkit .
docker buildx build --platform="$(IMAGE_PLATFORMS)" -f build/Dockerfile.$*.buildkit --target release-alpine .
.PHONY: ci
ci: ci-distribution ci-zot ## Run CI tests against self hosted registries
.PHONY: ci-distribution
ci-distribution:
docker run --rm -d -p 5000 \
--label regclient-ci=true --name regclient-ci-distribution \
-e "REGISTRY_STORAGE_DELETE_ENABLED=true" \
docker.io/library/registry:$(CI_DISTRIBUTION_VER)
./build/ci-test.sh -t localhost:$$(docker port regclient-ci-distribution 5000 | head -1 | cut -f2 -d:)/test-ci
docker stop regclient-ci-distribution
.PHONY: ci-zot
ci-zot:
docker run --rm -d -p 5000 \
--label regclient-ci=true --name regclient-ci-zot \
-v "$$(pwd)/build/zot-config.json:/etc/zot/config.json:ro" \
ghcr.io/project-zot/zot-linux-amd64:$(CI_ZOT_VER)
./build/ci-test.sh -t localhost:$$(docker port regclient-ci-zot 5000 | head -1 | cut -f2 -d:)/test-ci
docker stop regclient-ci-zot
.PHONY: artifacts
artifacts: $(ARTIFACTS) ## Generate artifacts
.PHONY: artifact-pre
artifact-pre:
mkdir -p artifacts
artifacts/%: artifact-pre .FORCE
@set -e; \
target="$*"; \
command="$${target%%-*}"; \
platform_ext="$${target#*-}"; \
platform="$${platform_ext%.*}"; \
export GOOS="$${platform%%-*}"; \
export GOARCH="$${platform#*-}"; \
echo export GOOS=$${GOOS}; \
echo export GOARCH=$${GOARCH}; \
echo go build ${GO_BUILD_FLAGS} -o "$@" ./cmd/$${command}/; \
CGO_ENABLED=0 go build ${GO_BUILD_FLAGS} -o "$@" ./cmd/$${command}/; \
$(SYFT) scan -q "file:$@" --source-name "$${command}" -o cyclonedx-json >"artifacts/$${command}-$${platform}.cyclonedx.json"; \
$(SYFT) scan -q "file:$@" --source-name "$${command}" -o spdx-json >"artifacts/$${command}-$${platform}.spdx.json"
.PHONY: clean
clean: ## delete generated content
[ ! -d artifacts ] || rm -r artifacts
[ ! -d bin ] || rm -r bin
[ ! -d output ] || rm -r output
[ ! -d vendor ] || rm -r vendor
.PHONY: plugin-user
plugin-user:
mkdir -p ${HOME}/.docker/cli-plugins/
cp docker-plugin/docker-regclient ${HOME}/.docker/cli-plugins/docker-regctl
.PHONY: plugin-host
plugin-host:
sudo cp docker-plugin/docker-regclient /usr/libexec/docker/cli-plugins/docker-regctl
.PHONY: util-golang-major
util-golang-major: $(GOPATH)/bin/gomajor ## check for major dependency updates
$(GOPATH)/bin/gomajor list
.PHONY: util-golang-update
util-golang-update: ## update go module versions
go get -u -t ./...
go mod tidy
[ ! -d vendor ] || go mod vendor
.PHONY: util-release-preview
util-release-preview: $(GOPATH)/bin/gorelease ## preview changes for next release
git checkout main
./.github/release.sh -d
gorelease
.PHONY: util-release-run
util-release-run: ## generate a new release
git checkout main
./.github/release.sh
.PHONY: util-version-check
util-version-check: ## check all dependencies for updates
$(VER_BUMP) check
.PHONY: util-version-update
util-version-update: ## update versions on all dependencies
$(VER_BUMP) update
$(GOPATH)/bin/gofumpt: .FORCE
@[ -f "$(GOPATH)/bin/gofumpt" ] \
&& [ "$$($(GOPATH)/bin/gofumpt -version | cut -f 1 -d ' ')" = "$(GOFUMPT_VER)" ] \
|| go install mvdan.cc/gofumpt@$(GOFUMPT_VER)
$(GOPATH)/bin/gomajor: .FORCE
@[ -f "$(GOPATH)/bin/gomajor" ] \
&& [ "$$($(GOPATH)/bin/gomajor version | grep '^version' | cut -f 2 -d ' ')" = "$(GOMAJOR_VER)" ] \
|| go install github.com/icholy/gomajor@$(GOMAJOR_VER)
$(GOPATH)/bin/goimports: .FORCE
@[ -f "$(GOPATH)/bin/goimports" ] && [ "$$(go version | cut -f3 -d' ')" = "$$(go version $(GOPATH)/bin/goimports | cut -f2 -d' ')" ] \
|| go install golang.org/x/tools/cmd/goimports@latest
$(GOPATH)/bin/gorelease: .FORCE
@[ -f "$(GOPATH)/bin/gorelease" ] && [ "$$(go version | cut -f3 -d' ')" = "$$(go version $(GOPATH)/bin/gorelease | cut -f2 -d' ')" ] \
|| go install golang.org/x/exp/cmd/gorelease@latest
$(GOPATH)/bin/gosec: .FORCE
@[ -f $(GOPATH)/bin/gosec ] \
&& [ "$$($(GOPATH)/bin/gosec -version | grep '^Version' | cut -f 2 -d ' ')" = "$(GOSEC_VER)" ] \
|| go install -ldflags '-X main.Version=$(GOSEC_VER) -X main.GitTag=$(GOSEC_VER)' \
github.com/securego/gosec/v2/cmd/gosec@$(GOSEC_VER)
$(GOPATH)/bin/staticcheck: .FORCE
@[ -f $(GOPATH)/bin/staticcheck ] \
&& [ "$$($(GOPATH)/bin/staticcheck -version | cut -f 3 -d ' ' | tr -d '()')" = "$(STATICCHECK_VER)" ] \
|| go install "honnef.co/go/tools/cmd/staticcheck@$(STATICCHECK_VER)"
$(GOPATH)/bin/govulncheck: .FORCE
@[ -f $(GOPATH)/bin/govulncheck ] \
&& [ $$(go version -m $(GOPATH)/bin/govulncheck | \
awk -F ' ' '{ if ($$1 == "mod" && $$2 == "golang.org/x/vuln") { printf "%s\n", $$3 } }') = "$(GO_VULNCHECK_VER)" ] \
|| CGO_ENABLED=0 go install "golang.org/x/vuln/cmd/govulncheck@$(GO_VULNCHECK_VER)"
$(GOPATH)/bin/osv-scanner: .FORCE
@[ -f $(GOPATH)/bin/osv-scanner ] \
&& [ "$$(osv-scanner --version | awk -F ': ' '{ if ($$1 == "osv-scanner version") { printf "%s\n", $$2 } }')" = "$(OSV_SCANNER_VER)" ] \
|| CGO_ENABLED=0 go install "github.com/google/osv-scanner/v2/cmd/osv-scanner@$(OSV_SCANNER_VER)"
.PHONY: help
help: # Display help
@awk -F ':|##' '/^[^\t].+?:.*?##/ { printf "\033[36m%-30s\033[0m %s\n", $$1, $$NF }' $(MAKEFILE_LIST)
+116
View File
@@ -0,0 +1,116 @@
# regclient
[![Go Workflow Status](https://img.shields.io/github/actions/workflow/status/regclient/regclient/go.yml?branch=main&label=Go%20build)](https://github.com/regclient/regclient/actions/workflows/go.yml)
[![Docker Workflow Status](https://img.shields.io/github/actions/workflow/status/regclient/regclient/docker.yml?branch=main&label=Docker%20build)](https://github.com/regclient/regclient/actions/workflows/docker.yml)
[![Dependency Workflow Status](https://img.shields.io/github/actions/workflow/status/regclient/regclient/version-check.yml?branch=main&label=Dependency%20check)](https://github.com/regclient/regclient/actions/workflows/version-check.yml)
[![Vulnerability Workflow Status](https://img.shields.io/github/actions/workflow/status/regclient/regclient/vulnscans.yml?branch=main&label=Vulnerability%20check)](https://github.com/regclient/regclient/actions/workflows/vulnscans.yml)
[![Go Reference](https://pkg.go.dev/badge/github.com/regclient/regclient.svg)](https://pkg.go.dev/github.com/regclient/regclient)
![License](https://img.shields.io/github/license/regclient/regclient)
[![Go Report Card](https://goreportcard.com/badge/github.com/regclient/regclient)](https://goreportcard.com/report/github.com/regclient/regclient)
[![GitHub Downloads](https://img.shields.io/github/downloads/regclient/regclient/total?label=GitHub%20downloads)](https://github.com/regclient/regclient/releases)
regclient is a client interface to OCI conformant registries and content shipped with the OCI Image Layout.
It includes a Go library and several CLI commands.
## regclient Go Library Features
- Runs without a container runtime and without privileged access to the local host.
- Querying for a tag listing, repository listing, and remotely inspecting the contents of images.
- Efficiently copying and retagging images, only pulling layers when required, and without changing the image digest.
- Support for multi-platform images.
- Support for querying, creating, and copying OCI Artifacts, allowing arbitrary data to be stored in an OCI registry.
- Support for packaging OCI Artifacts with an Index of multiple artifacts, which can be used for platform specific artifacts.
- Support for querying OCI referrers, copying referrers, and pushing content with an OCI subject field, associating artifacts with other content on the registry.
- Support for the “digest tags” used by projects like sigstore/cosign, allowing the content to be included when copying images.
- Efficiently query for an image digest.
- Efficiently query for pull rate limits used by Docker Hub.
- Import and export content into OCI Layouts and Docker formatted tar files.
- Support OCI Layouts in all commands as a local disk equivalent of a repository.
- Support for deleting tags, manifests, and blobs.
- Ability to mutate existing images, including:
- Settings annotations or labels
- Deleting content from layers
- Changing timestamps for reproducibility
- Converting between Docker and OCI media types
- Replacing the base image layers
- Add or remove volumes and exposed ports
- Change digest algorithms
- Support for registry warning headers, which may be used to notify users of issues with the server or content they are using.
- Automatically import logins from the docker CLI, and registry certificates from the docker engine.
- Automatic retry, and fallback to a chunked blob push, when network issues are encountered.
The full Go references is available on [pkg.go.dev](https://pkg.go.dev/github.com/regclient/regclient).
## regctl Features
`regctl` is a CLI interface to the `regclient` library.
In addition to the features listed for `regclient`, `regctl` adds the following abilities:
- Generating multi-platform manifests from multiple images that may have been separately built.
- Repackage a multi-platform image with only the requested platforms.
- Push and pull arbitrary OCI artifacts.
- Recursively list all content associated with an image.
- Extract files from a layer or image.
- Compare images, showing the differences between manifests, the config, and layers.
- Formatted output using Go templates.
The project website includes [usage instructions](https://regclient.org/usage/regctl/) and a [CLI reference](https://regclient.org/cli/regctl/).
## regsync features
`regsync` is an image mirroring tool.
It will copy images between two locations with the following additional features:
- Ability to run on a cron schedule, one time synchronization, or only report stale images.
- Uses a yaml configuration.
- Each source may be an entire registry (not recommended), a repository, or a single image, with the ability to filter repositories and tags.
- Support for multi-platform images, OCI referrers, “digest tags”, and copying to or from an OCI Layout (for maintaining a mirror over an air-gap).
- Ability to mirror multiple images concurrently.
- Support for copying a single platform from multi-platform images.
- Ability to backup an existing image before overwriting the tag.
- Ability to postpone mirror step when rate limit (used by Docker Hub) is below a threshold.
- Can use users docker configuration for user credentials and registry certificates.
The project website includes [usage instructions](https://regclient.org/usage/regsync/) and a [CLI reference](https://regclient.org/cli/regsync/).
## regbot features
`regbot` is a scripting tool on top of the `regclient` API with the following features:
- Ability to run on a cron schedule, one time execution, or test with a dry-run mode.
- Uses a yaml configuration.
- Scripts are written in Lua and executed directly in Go.
- Built-in functions include:
- Repository list
- Tag list
- Image manifest (either head or get, and optional resolving multi-platform reference)
- Image config (this includes the creation time, labels, and other details shown in a docker image inspect)
- Image rate limit and a wait function to delay the script when rate limit remaining is below a threshold
- Image copy
- Manifest delete
- Tag delete
The project website includes [usage instructions](https://regclient.org/usage/regbot/) and a [CLI reference](https://regclient.org/cli/regbot/).
## Development Status
This project is using v0 version numbers due to Go's backwards compatibility requirements of a v1 release.
The library and commands are stable for external use.
Minor version updates may contain breaking changes, however effort is made to first deprecate and provide warnings to give users time to move off of older APIs and commands.
## Installing
See the [installation instructions](https://regclient.org/install/) on the project website for the various ways to download or build CLI binaries.
## Usage
See the [project documentation](https://regclient.org/usage/).
## Contributors
<a href="https://github.com/regclient/regclient/graphs/contributors">
<img src="https://contrib.rocks/image?repo=regclient/regclient" alt="contributor list"/>
</a>
<!-- markdownlint-disable-file MD033 -->
+5
View File
@@ -0,0 +1,5 @@
# Reporting security issues
Please report security issues directly in GitHub at <https://github.com/regclient/regclient/security/advisories/new> or alternatively email <git@bmitch.net>.
We will typically respond within 7 working days of your report. If the issue is confirmed as a vulnerability, we will open a Security Advisory and acknowledge your contributions as part of it. This project follows a 90 day disclosure timeline.
+283
View File
@@ -0,0 +1,283 @@
package regclient
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"log/slog"
"time"
"github.com/regclient/regclient/internal/pqueue"
"github.com/regclient/regclient/internal/reqmeta"
"github.com/regclient/regclient/scheme"
"github.com/regclient/regclient/types"
"github.com/regclient/regclient/types/blob"
"github.com/regclient/regclient/types/descriptor"
"github.com/regclient/regclient/types/errs"
"github.com/regclient/regclient/types/ref"
"github.com/regclient/regclient/types/warning"
)
const blobCBFreq = time.Millisecond * 100
type blobOpt struct {
callback func(kind types.CallbackKind, instance string, state types.CallbackState, cur, total int64)
readerHook func(*blob.BReader) (*blob.BReader, error)
}
// BlobOpts define options for the Image* commands.
type BlobOpts func(*blobOpt)
// BlobWithCallback provides progress data to a callback function.
func BlobWithCallback(callback func(kind types.CallbackKind, instance string, state types.CallbackState, cur, total int64)) BlobOpts {
return func(opts *blobOpt) {
opts.callback = callback
}
}
// BlobWithReaderHook is called in [RegClient.BlobCopy] with the blob source.
// The returned [blob.BReader] is pushed to the target.
// If the hook returns an error, the copy will fail.
func BlobWithReaderHook(hook func(*blob.BReader) (*blob.BReader, error)) BlobOpts {
return func(opts *blobOpt) {
opts.readerHook = hook
}
}
// BlobCopy copies a blob between two locations.
// If the blob already exists in the target, the copy is skipped.
// A server side cross repository blob mount is attempted.
func (rc *RegClient) BlobCopy(ctx context.Context, refSrc ref.Ref, refTgt ref.Ref, d descriptor.Descriptor, opts ...BlobOpts) error {
if !refSrc.IsSetRepo() {
return fmt.Errorf("refSrc is not set: %s%.0w", refSrc.CommonName(), errs.ErrInvalidReference)
}
if !refTgt.IsSetRepo() {
return fmt.Errorf("refTgt is not set: %s%.0w", refTgt.CommonName(), errs.ErrInvalidReference)
}
var opt blobOpt
for _, optFn := range opts {
optFn(&opt)
}
// dedup warnings
if w := warning.FromContext(ctx); w == nil {
ctx = warning.NewContext(ctx, &warning.Warning{Hook: warning.DefaultHook()})
}
tDesc := d
tDesc.URLs = []string{} // ignore URLs when pushing to target
if opt.callback != nil {
opt.callback(types.CallbackBlob, d.Digest.String(), types.CallbackStarted, 0, d.Size)
}
// for the same repository, there's nothing to copy
if ref.EqualRepository(refSrc, refTgt) {
if opt.callback != nil {
opt.callback(types.CallbackBlob, d.Digest.String(), types.CallbackSkipped, 0, d.Size)
}
rc.slog.Debug("Blob copy skipped, same repo",
slog.String("src", refSrc.Reference),
slog.String("tgt", refTgt.Reference),
slog.String("digest", string(d.Digest)))
return nil
}
// check if layer already exists
if _, err := rc.BlobHead(ctx, refTgt, tDesc); err == nil {
if opt.callback != nil {
opt.callback(types.CallbackBlob, d.Digest.String(), types.CallbackSkipped, 0, d.Size)
}
rc.slog.Debug("Blob copy skipped, already exists",
slog.String("src", refSrc.Reference),
slog.String("tgt", refTgt.Reference),
slog.String("digest", string(d.Digest)))
return nil
}
// acquire throttle for both src and tgt to avoid deadlocks
tList := []*pqueue.Queue[reqmeta.Data]{}
schemeSrcAPI, err := rc.schemeGet(refSrc.Scheme)
if err != nil {
return err
}
schemeTgtAPI, err := rc.schemeGet(refTgt.Scheme)
if err != nil {
return err
}
if tSrc, ok := schemeSrcAPI.(scheme.Throttler); ok {
tList = append(tList, tSrc.Throttle(refSrc, false)...)
}
if tTgt, ok := schemeTgtAPI.(scheme.Throttler); ok {
tList = append(tList, tTgt.Throttle(refTgt, true)...)
}
if len(tList) > 0 {
ctxMulti, done, err := pqueue.AcquireMulti[reqmeta.Data](ctx, reqmeta.Data{Kind: reqmeta.Blob, Size: d.Size}, tList...)
if err != nil {
return err
}
if done != nil {
defer done()
}
ctx = ctxMulti
}
// try mounting blob from the source repo is the registry is the same
if ref.EqualRegistry(refSrc, refTgt) {
err := rc.BlobMount(ctx, refSrc, refTgt, d)
if err == nil {
if opt.callback != nil {
opt.callback(types.CallbackBlob, d.Digest.String(), types.CallbackSkipped, 0, d.Size)
}
rc.slog.Debug("Blob copy performed server side with registry mount",
slog.String("src", refSrc.Reference),
slog.String("tgt", refTgt.Reference),
slog.String("digest", string(d.Digest)))
return nil
}
rc.slog.Warn("Failed to mount blob",
slog.String("src", refSrc.Reference),
slog.String("tgt", refTgt.Reference),
slog.String("err", err.Error()))
}
// fast options failed, download layer from source and push to target
blobIO, err := rc.BlobGet(ctx, refSrc, d)
if err != nil {
if !errors.Is(err, context.Canceled) {
rc.slog.Warn("Failed to retrieve blob",
slog.String("src", refSrc.Reference),
slog.String("digest", string(d.Digest)),
slog.String("err", err.Error()))
}
return err
}
if opt.callback != nil {
opt.callback(types.CallbackBlob, d.Digest.String(), types.CallbackStarted, 0, d.Size)
ticker := time.NewTicker(blobCBFreq)
done := make(chan bool)
defer func() {
close(done)
ticker.Stop()
if ctx.Err() == nil {
opt.callback(types.CallbackBlob, d.Digest.String(), types.CallbackFinished, d.Size, d.Size)
}
}()
go func() {
for {
select {
case <-done:
return
case <-ticker.C:
offset, err := blobIO.Seek(0, io.SeekCurrent)
if err == nil && offset > 0 {
opt.callback(types.CallbackBlob, d.Digest.String(), types.CallbackActive, offset, d.Size)
}
}
}
}()
}
if opt.readerHook != nil {
blobIO, err = opt.readerHook(blobIO)
if err != nil {
rc.slog.Warn("Failed to apply reader hook to blob",
slog.String("src", refSrc.Reference),
slog.String("err", err.Error()))
return err
}
}
defer blobIO.Close()
if _, err := rc.BlobPut(ctx, refTgt, blobIO.GetDescriptor(), blobIO); err != nil {
if !errors.Is(err, context.Canceled) {
rc.slog.Warn("Failed to push blob",
slog.String("src", refSrc.Reference),
slog.String("tgt", refTgt.Reference),
slog.String("err", err.Error()))
}
return err
}
return nil
}
// BlobDelete removes a blob from the registry.
// This method should only be used to repair a damaged registry.
// Typically a server side garbage collection should be used to purge unused blobs.
func (rc *RegClient) BlobDelete(ctx context.Context, r ref.Ref, d descriptor.Descriptor) error {
if !r.IsSetRepo() {
return fmt.Errorf("ref is not set: %s%.0w", r.CommonName(), errs.ErrInvalidReference)
}
schemeAPI, err := rc.schemeGet(r.Scheme)
if err != nil {
return err
}
return schemeAPI.BlobDelete(ctx, r, d)
}
// BlobGet retrieves a blob, returning a reader.
// This reader must be closed to free up resources that limit concurrent pulls.
func (rc *RegClient) BlobGet(ctx context.Context, r ref.Ref, d descriptor.Descriptor) (blob.Reader, error) {
data, err := d.GetData()
if err == nil {
return blob.NewReader(blob.WithDesc(d), blob.WithRef(r), blob.WithReader(bytes.NewReader(data))), nil
}
if !r.IsSetRepo() {
return nil, fmt.Errorf("ref is not set: %s%.0w", r.CommonName(), errs.ErrInvalidReference)
}
schemeAPI, err := rc.schemeGet(r.Scheme)
if err != nil {
return nil, err
}
return schemeAPI.BlobGet(ctx, r, d)
}
// BlobGetOCIConfig retrieves an OCI config from a blob, automatically extracting the JSON.
func (rc *RegClient) BlobGetOCIConfig(ctx context.Context, r ref.Ref, d descriptor.Descriptor) (blob.OCIConfig, error) {
if !r.IsSetRepo() {
return nil, fmt.Errorf("ref is not set: %s%.0w", r.CommonName(), errs.ErrInvalidReference)
}
b, err := rc.BlobGet(ctx, r, d)
if err != nil {
return nil, err
}
return b.ToOCIConfig()
}
// BlobHead is used to verify if a blob exists and is accessible.
func (rc *RegClient) BlobHead(ctx context.Context, r ref.Ref, d descriptor.Descriptor) (blob.Reader, error) {
if !r.IsSetRepo() {
return nil, fmt.Errorf("ref is not set: %s%.0w", r.CommonName(), errs.ErrInvalidReference)
}
schemeAPI, err := rc.schemeGet(r.Scheme)
if err != nil {
return nil, err
}
return schemeAPI.BlobHead(ctx, r, d)
}
// BlobMount attempts to perform a server side copy/mount of the blob between repositories.
func (rc *RegClient) BlobMount(ctx context.Context, refSrc ref.Ref, refTgt ref.Ref, d descriptor.Descriptor) error {
if !refSrc.IsSetRepo() {
return fmt.Errorf("ref is not set: %s%.0w", refSrc.CommonName(), errs.ErrInvalidReference)
}
if !refTgt.IsSetRepo() {
return fmt.Errorf("ref is not set: %s%.0w", refTgt.CommonName(), errs.ErrInvalidReference)
}
schemeAPI, err := rc.schemeGet(refSrc.Scheme)
if err != nil {
return err
}
return schemeAPI.BlobMount(ctx, refSrc, refTgt, d)
}
// BlobPut uploads a blob to a repository.
// Descriptor is optional, leave size and digest to zero value if unknown.
// Reader must also be an [io.Seeker] to support chunked upload fallback.
//
// This will attempt an anonymous blob mount first which some registries may support.
// It will then try doing a full put of the blob without chunking (most widely supported).
// If the full put fails, it will fall back to a chunked upload (useful for flaky networks).
func (rc *RegClient) BlobPut(ctx context.Context, r ref.Ref, d descriptor.Descriptor, rdr io.Reader) (descriptor.Descriptor, error) {
if !r.IsSetRepo() {
return descriptor.Descriptor{}, fmt.Errorf("ref is not set: %s%.0w", r.CommonName(), errs.ErrInvalidReference)
}
schemeAPI, err := rc.schemeGet(r.Scheme)
if err != nil {
return descriptor.Descriptor{}, err
}
return schemeAPI.BlobPut(ctx, r, d, rdr)
}
+100
View File
@@ -0,0 +1,100 @@
package config
import (
"bytes"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"strings"
)
// credHelper wraps a command that manages user credentials.
type credHelper struct {
prog string
env map[string]string
}
func newCredHelper(prog string, env map[string]string) *credHelper {
return &credHelper{prog: prog, env: env}
}
func (ch *credHelper) run(arg string, input io.Reader) ([]byte, error) {
//#nosec G204 only untrusted arg is a hostname which the executed command should not trust
cmd := exec.Command(ch.prog, arg)
cmd.Env = os.Environ()
if ch.env != nil {
for k, v := range ch.env {
cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", k, v))
}
}
cmd.Stderr = os.Stderr
cmd.Stdin = input
return cmd.Output()
}
type credStore struct {
ServerURL string `json:"ServerURL"`
Username string `json:"Username"`
Secret string `json:"Secret"` //#nosec G117 exported struct intentionally holds secrets
}
// get requests a credential from the helper for a given host.
func (ch *credHelper) get(host *Host) error {
hostname := host.Hostname
if host.CredHost != "" {
hostname = host.CredHost
}
hostIn := strings.NewReader(hostname)
credOut := credStore{
Username: host.User,
Secret: host.Pass,
}
outB, err := ch.run("get", hostIn)
if err != nil {
outS := strings.TrimSpace(string(outB))
return fmt.Errorf("error getting credentials, output: %s, error: %w", outS, err)
}
err = json.NewDecoder(bytes.NewReader(outB)).Decode(&credOut)
if err != nil {
return fmt.Errorf("error reading credentials: %w", err)
}
if credOut.Username == tokenUser {
host.User = ""
host.Pass = ""
host.Token = credOut.Secret
} else {
host.User = credOut.Username
host.Pass = credOut.Secret
host.Token = ""
}
return nil
}
// list returns a list of hosts supported by the credential helper.
func (ch *credHelper) list() ([]Host, error) {
credList := map[string]string{}
outB, err := ch.run("list", bytes.NewReader([]byte{}))
if err != nil {
outS := strings.TrimSpace(string(outB))
return nil, fmt.Errorf("error getting credential list, output: %s, error: %w", outS, err)
}
err = json.NewDecoder(bytes.NewReader(outB)).Decode(&credList)
if err != nil {
return nil, fmt.Errorf("error reading credential list: %w", err)
}
hostList := []Host{}
for host, user := range credList {
if !HostValidate(host) {
continue
}
h := HostNewName(host)
h.User = user
h.CredHelper = ch.prog
hostList = append(hostList, *h)
}
return hostList, nil
}
// TODO: store method not implemented
+210
View File
@@ -0,0 +1,210 @@
package config
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"os"
"strings"
"github.com/regclient/regclient/internal/conffile"
"github.com/regclient/regclient/types/errs"
)
const (
// dockerEnv is the environment variable used to look for Docker's config.json.
dockerEnv = "DOCKER_CONFIG"
// dockerEnvConfig is used to inject the config as an environment variable.
dockerEnvConfig = "DOCKER_AUTH_CONFIG"
// dockerDir is the directory name for Docker's config (inside the users home directory).
dockerDir = ".docker"
// dockerConfFile is the name of Docker's config file.
dockerConfFile = "config.json"
// dockerHelperPre is the prefix of docker credential helpers.
dockerHelperPre = "docker-credential-"
)
// dockerConfig is used to parse the ~/.docker/config.json
type dockerConfig struct {
AuthConfigs map[string]dockerAuthConfig `json:"auths"`
HTTPHeaders map[string]string `json:"HttpHeaders,omitempty"`
DetachKeys string `json:"detachKeys,omitempty"`
CredentialsStore string `json:"credsStore,omitempty"`
CredentialHelpers map[string]string `json:"credHelpers,omitempty"`
Proxies map[string]dockerProxyConfig `json:"proxies,omitempty"`
}
// dockerProxyConfig contains proxy configuration settings
type dockerProxyConfig struct {
HTTPProxy string `json:"httpProxy,omitempty"`
HTTPSProxy string `json:"httpsProxy,omitempty"`
NoProxy string `json:"noProxy,omitempty"`
FTPProxy string `json:"ftpProxy,omitempty"`
AllProxy string `json:"allProxy,omitempty"`
}
// dockerAuthConfig contains the auths
type dockerAuthConfig struct {
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"` //#nosec G117 exported struct intentionally holds secrets
Auth string `json:"auth,omitempty"`
ServerAddress string `json:"serveraddress,omitempty"`
// IdentityToken is used to authenticate the user and get
// an access token for the registry.
IdentityToken string `json:"identitytoken,omitempty"`
// RegistryToken is a bearer token to be sent to a registry
RegistryToken string `json:"registrytoken,omitempty"`
}
// DockerLoad returns a slice of hosts from the users docker config.
// This will search for the config.json in either the DOCKER_CONFIG identified directory or the default .docker directory.
// It also includes hosts extracted from the DOCKER_AUTH_CONFIG variable.
// If the config file is missing and no value is injected using an environment variable, an empty list is returned.
func DockerLoad() ([]Host, error) {
hosts := []Host{}
errList := []error{}
// load from a file
cf := conffile.New(
conffile.WithHomeDir(dockerDir, dockerConfFile, true),
conffile.WithEnvDir(dockerEnv, dockerConfFile),
)
rdr, err := cf.Open()
if err != nil && !errors.Is(err, fs.ErrNotExist) {
errList = append(errList, err)
} else if err == nil {
defer rdr.Close()
hostsFile, err := dockerParse(rdr)
if err != nil {
errList = append(errList, err)
} else {
hosts = append(hosts, hostsFile...)
}
}
// load from an env var
hostsEnv, err := DockerLoadEnv(dockerEnvConfig)
if err != nil && !errors.Is(err, errs.ErrNotFound) {
errList = append(errList, err)
} else if err == nil {
hosts = append(hosts, hostsEnv...)
}
// return the concatenated result, only wrapping an error list if necessary
if len(errList) == 1 {
return hosts, errList[0]
} else {
return hosts, errors.Join(errList...)
}
}
// DockerLoadFile returns a slice of hosts from a named docker config file.
func DockerLoadFile(fname string) ([]Host, error) {
//#nosec G304 scoping file operations to a directory is not yet a feature of regclient.
rdr, err := os.Open(fname)
if err != nil && errors.Is(err, fs.ErrNotExist) {
return []Host{}, nil
} else if err != nil {
return nil, err
}
defer rdr.Close()
return dockerParse(rdr)
}
// DockerLoadEnv returns a slice of hosts extracted from the config injected in an environment variable.
func DockerLoadEnv(envName string) ([]Host, error) {
envVal := os.Getenv(envName)
if envVal == "" {
return []Host{}, errs.ErrNotFound
}
return dockerParse(strings.NewReader(envVal))
}
// dockerParse parses a docker config into a slice of Hosts.
func dockerParse(rdr io.Reader) ([]Host, error) {
dc := dockerConfig{}
if err := json.NewDecoder(rdr).Decode(&dc); err != nil && !errors.Is(err, io.EOF) {
return nil, err
}
hosts := []Host{}
for name, auth := range dc.AuthConfigs {
if !HostValidate(name) {
continue
}
h, err := dockerAuthToHost(name, dc, auth)
if err != nil {
continue
}
hosts = append(hosts, h)
}
// also include default entries for credential helpers
for name, helper := range dc.CredentialHelpers {
if !HostValidate(name) {
continue
}
h := HostNewName(name)
h.CredHelper = dockerHelperPre + helper
if _, ok := dc.AuthConfigs[name]; ok {
continue // skip fields with auth config
}
hosts = append(hosts, *h)
}
// add credStore entries
if dc.CredentialsStore != "" {
ch := newCredHelper(dockerHelperPre+dc.CredentialsStore, map[string]string{})
csHosts, err := ch.list()
if err == nil {
hosts = append(hosts, csHosts...)
}
}
return hosts, nil
}
// dockerAuthToHost parses an auth entry from a docker config into a Host.
func dockerAuthToHost(name string, conf dockerConfig, auth dockerAuthConfig) (Host, error) {
helper := ""
if conf.CredentialHelpers != nil && conf.CredentialHelpers[name] != "" {
helper = dockerHelperPre + conf.CredentialHelpers[name]
}
// parse base64 auth into user/pass
if auth.Auth != "" {
var err error
auth.Username, auth.Password, err = decodeAuth(auth.Auth)
if err != nil {
return Host{}, err
}
}
if (auth.Username == "" || auth.Password == "") && auth.IdentityToken == "" && helper == "" {
return Host{}, fmt.Errorf("no credentials found for %s", name)
}
h := HostNewName(name)
// ignore unknown names
if h.Name != DockerRegistry && !strings.HasSuffix(strings.TrimSuffix(name, "/"), h.Name) {
return Host{}, fmt.Errorf("rejecting entry with repository: %s", name)
}
h.User = auth.Username
h.Pass = auth.Password
h.Token = auth.IdentityToken
h.CredHelper = helper
return *h, nil
}
// decodeAuth extracts a base64 encoded user:pass into the username and password.
func decodeAuth(authStr string) (string, string, error) {
if authStr == "" {
return "", "", nil
}
decoded, err := base64.StdEncoding.DecodeString(authStr)
if err != nil {
return "", "", err
}
userPass := strings.SplitN(string(decoded), ":", 2)
if len(userPass) != 2 {
return "", "", fmt.Errorf("invalid auth configuration file")
}
return userPass[0], strings.Trim(userPass[1], "\x00"), nil
}
+521
View File
@@ -0,0 +1,521 @@
// Package config is used for all regclient configuration settings.
package config
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"maps"
"slices"
"strings"
"time"
"github.com/regclient/regclient/internal/timejson"
)
// TLSConf specifies whether TLS is enabled and verified for a host.
type TLSConf int
const (
// TLSUndefined indicates TLS is not passed, defaults to Enabled.
TLSUndefined TLSConf = iota
// TLSEnabled uses TLS (https) for the connection.
TLSEnabled
// TLSInsecure uses TLS but does not verify CA.
TLSInsecure
// TLSDisabled does not use TLS (http).
TLSDisabled
)
const (
// DockerRegistry is the name resolved in docker images on Hub.
DockerRegistry = "docker.io"
// DockerRegistryAuth is the name provided in docker's config for Hub.
DockerRegistryAuth = "https://index.docker.io/v1/"
// DockerRegistryDNS is the host to connect to for Hub.
DockerRegistryDNS = "registry-1.docker.io"
// defaultExpire is the default time to expire a credential and force re-authentication.
defaultExpire = time.Hour * 1
// defaultCredHelperRetry is the time to refresh a credential from a failed credential helper command.
defaultCredHelperRetry = time.Second * 5
// defaultConcurrent is the default number of concurrent registry connections.
defaultConcurrent = 3
// defaultReqPerSec is the default maximum frequency to send requests to a registry.
defaultReqPerSec = 0
// tokenUser is the username returned by credential helpers that indicates the password is an identity token.
tokenUser = "<token>"
)
// MarshalJSON converts TLSConf to a json string using MarshalText.
func (t TLSConf) MarshalJSON() ([]byte, error) {
s, err := t.MarshalText()
if err != nil {
return []byte(""), err
}
return json.Marshal(string(s))
}
// MarshalText converts TLSConf to a string.
func (t TLSConf) MarshalText() ([]byte, error) {
var s string
switch t {
default:
s = ""
case TLSEnabled:
s = "enabled"
case TLSInsecure:
s = "insecure"
case TLSDisabled:
s = "disabled"
}
return []byte(s), nil
}
// UnmarshalJSON converts TLSConf from a json string.
func (t *TLSConf) UnmarshalJSON(b []byte) error {
var s string
if err := json.Unmarshal(b, &s); err != nil {
return err
}
return t.UnmarshalText([]byte(s))
}
// UnmarshalText converts TLSConf from a string.
func (t *TLSConf) UnmarshalText(b []byte) error {
switch strings.ToLower(string(b)) {
default:
return fmt.Errorf("unknown TLS value \"%s\"", b)
case "":
*t = TLSUndefined
case "enabled":
*t = TLSEnabled
case "insecure":
*t = TLSInsecure
case "disabled":
*t = TLSDisabled
}
return nil
}
// Host defines settings for connecting to a registry.
type Host struct {
Name string `json:"-" yaml:"registry,omitempty"` // Name of the registry (required) (yaml configs pass this as a field, json provides this from the object key)
TLS TLSConf `json:"tls,omitempty" yaml:"tls"` // TLS setting: enabled (default), disabled, insecure
RegCert string `json:"regcert,omitempty" yaml:"regcert"` // public pem cert of registry
ClientCert string `json:"clientCert,omitempty" yaml:"clientCert"` // public pem cert for client (mTLS)
ClientKey string `json:"clientKey,omitempty" yaml:"clientKey"` //#nosec G117 private pem cert for client (mTLS)
Hostname string `json:"hostname,omitempty" yaml:"hostname"` // hostname of registry, default is the registry name
User string `json:"user,omitempty" yaml:"user"` // username, not used with credHelper
Pass string `json:"pass,omitempty" yaml:"pass"` //#nosec G117 password, not used with credHelper
Token string `json:"token,omitempty" yaml:"token"` // token, experimental for specific APIs
CredHelper string `json:"credHelper,omitempty" yaml:"credHelper"` // credential helper command for requesting logins
CredExpire timejson.Duration `json:"credExpire,omitempty" yaml:"credExpire"` // time until credential expires
CredHost string `json:"credHost,omitempty" yaml:"credHost"` // used when a helper hostname doesn't match Hostname
PathPrefix string `json:"pathPrefix,omitempty" yaml:"pathPrefix"` // used for mirrors defined within a repository namespace
Mirrors []string `json:"mirrors,omitempty" yaml:"mirrors"` // list of other Host Names to use as mirrors
Priority uint `json:"priority,omitempty" yaml:"priority"` // priority when sorting mirrors, higher priority attempted first
RepoAuth bool `json:"repoAuth,omitempty" yaml:"repoAuth"` // tracks a separate auth per repo
API string `json:"api,omitempty" yaml:"api"` // Deprecated: registry API to use
APIOpts map[string]string `json:"apiOpts,omitempty" yaml:"apiOpts"` // options for APIs
BlobChunk int64 `json:"blobChunk,omitempty" yaml:"blobChunk"` // size of each blob chunk
BlobMax int64 `json:"blobMax,omitempty" yaml:"blobMax"` // threshold to switch to chunked upload, -1 to disable, 0 for regclient.blobMaxPut
ReqPerSec float64 `json:"reqPerSec,omitempty" yaml:"reqPerSec"` // requests per second
ReqConcurrent int64 `json:"reqConcurrent,omitempty" yaml:"reqConcurrent"` // concurrent requests, default is defaultConcurrent(3)
Scheme string `json:"scheme,omitempty" yaml:"scheme"` // Deprecated: use TLS instead
credRefresh time.Time `json:"-" yaml:"-"` // internal use, when to refresh credentials
}
// Cred defines a user credential for accessing a registry.
type Cred struct {
User, Password, Token string //#nosec G117 exported struct intentionally holds secrets
}
// HostNew creates a default Host entry.
func HostNew() *Host {
h := Host{
TLS: TLSEnabled,
APIOpts: map[string]string{},
ReqConcurrent: int64(defaultConcurrent),
ReqPerSec: float64(defaultReqPerSec),
}
return &h
}
// HostNewName creates a default Host with a hostname.
func HostNewName(name string) *Host {
return HostNewDefName(nil, name)
}
// HostNewDefName creates a host using provided defaults and hostname.
func HostNewDefName(def *Host, name string) *Host {
var h Host
if def == nil {
h = *HostNew()
} else {
h = *def
// configure required defaults
if h.TLS == TLSUndefined {
h.TLS = TLSEnabled
}
if h.APIOpts == nil {
h.APIOpts = map[string]string{}
}
if h.ReqConcurrent == 0 {
h.ReqConcurrent = int64(defaultConcurrent)
}
if h.ReqPerSec == 0 {
h.ReqPerSec = float64(defaultReqPerSec)
}
// copy any fields that are not passed by value
if len(h.APIOpts) > 0 {
orig := h.APIOpts
h.APIOpts = map[string]string{}
maps.Copy(h.APIOpts, orig)
}
if h.Mirrors != nil {
orig := h.Mirrors
h.Mirrors = make([]string, len(orig))
copy(h.Mirrors, orig)
}
}
// configure host
scheme, registry, _ := parseName(name)
if scheme == "http" {
h.TLS = TLSDisabled
}
// Docker Hub is a special case
if registry == DockerRegistry {
h.Name = DockerRegistry
h.Hostname = DockerRegistryDNS
h.CredHost = DockerRegistryAuth
return &h
}
h.Name = registry
h.Hostname = registry
if name != registry {
h.CredHost = name
}
return &h
}
// HostValidate returns true if the scheme is missing or a known value, and the path is not set.
func HostValidate(name string) bool {
scheme, _, path := parseName(name)
return path == "" && (scheme == "https" || scheme == "http")
}
// GetCred returns the credential, fetching from a credential helper if needed.
func (host *Host) GetCred() Cred {
// refresh from credHelper if needed
if host.CredHelper != "" && (host.credRefresh.IsZero() || time.Now().After(host.credRefresh)) {
host.refreshHelper()
}
return Cred{User: host.User, Password: host.Pass, Token: host.Token}
}
func (host *Host) refreshHelper() {
if host.CredHelper == "" {
return
}
if host.CredExpire <= 0 {
host.CredExpire = timejson.Duration(defaultExpire)
}
// run a cred helper, calling get method
ch := newCredHelper(host.CredHelper, map[string]string{})
err := ch.get(host)
if err != nil {
host.credRefresh = time.Now().Add(defaultCredHelperRetry)
} else {
host.credRefresh = time.Now().Add(time.Duration(host.CredExpire))
}
}
// IsZero returns true if the struct is set to the zero value or the result of [HostNew].
func (host Host) IsZero() bool {
if (host.TLS != TLSUndefined && host.TLS != TLSEnabled) ||
host.RegCert != "" ||
host.ClientCert != "" ||
host.ClientKey != "" ||
(host.Hostname != "" && host.Hostname != host.Name) ||
host.User != "" ||
host.Pass != "" ||
host.Token != "" ||
host.CredHelper != "" ||
host.CredExpire != 0 ||
host.CredHost != "" ||
host.PathPrefix != "" ||
len(host.Mirrors) != 0 ||
host.Priority != 0 ||
host.RepoAuth ||
len(host.APIOpts) != 0 ||
host.BlobChunk != 0 ||
host.BlobMax != 0 ||
(host.ReqPerSec != 0 && host.ReqPerSec != float64(defaultReqPerSec)) ||
(host.ReqConcurrent != 0 && host.ReqConcurrent != int64(defaultConcurrent)) ||
!host.credRefresh.IsZero() {
return false
}
return true
}
// Merge adds fields from a new config host entry.
func (host *Host) Merge(newHost Host, log *slog.Logger) error {
name := newHost.Name
if name == "" {
name = host.Name
}
if log == nil {
log = slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{}))
}
// merge the existing and new config host
if host.Name == "" {
// only set the name if it's not initialized, this shouldn't normally change
host.Name = newHost.Name
}
if newHost.CredHelper == "" && (newHost.Pass != "" || host.Token != "") {
// unset existing cred helper for user/pass or token
host.CredHelper = ""
host.CredExpire = 0
}
if newHost.CredHelper != "" && newHost.User == "" && newHost.Pass == "" && newHost.Token == "" {
// unset existing user/pass/token for cred helper
host.User = ""
host.Pass = ""
host.Token = ""
}
if newHost.User != "" {
if host.User != "" && host.User != newHost.User {
log.Warn("Changing login user for registry",
slog.String("orig", host.User),
slog.String("new", newHost.User),
slog.String("host", name))
}
host.User = newHost.User
}
if newHost.Pass != "" {
if host.Pass != "" && host.Pass != newHost.Pass {
log.Warn("Changing login password for registry",
slog.String("host", name))
}
host.Pass = newHost.Pass
}
if newHost.Token != "" {
if host.Token != "" && host.Token != newHost.Token {
log.Warn("Changing login token for registry",
slog.String("host", name))
}
host.Token = newHost.Token
}
if newHost.CredHelper != "" {
if host.CredHelper != "" && host.CredHelper != newHost.CredHelper {
log.Warn("Changing credential helper for registry",
slog.String("host", name),
slog.String("orig", host.CredHelper),
slog.String("new", newHost.CredHelper))
}
host.CredHelper = newHost.CredHelper
}
if newHost.CredExpire != 0 {
if host.CredExpire != 0 && host.CredExpire != newHost.CredExpire {
log.Warn("Changing credential expire for registry",
slog.String("host", name),
slog.Any("orig", host.CredExpire),
slog.Any("new", newHost.CredExpire))
}
host.CredExpire = newHost.CredExpire
}
if newHost.CredHost != "" {
if host.CredHost != "" && host.CredHost != newHost.CredHost {
log.Warn("Changing credential host for registry",
slog.String("host", name),
slog.String("orig", host.CredHost),
slog.String("new", newHost.CredHost))
}
host.CredHost = newHost.CredHost
}
if newHost.TLS != TLSUndefined {
if host.TLS != TLSUndefined && host.TLS != newHost.TLS {
tlsOrig, _ := host.TLS.MarshalText()
tlsNew, _ := newHost.TLS.MarshalText()
log.Warn("Changing TLS settings for registry",
slog.String("orig", string(tlsOrig)),
slog.String("new", string(tlsNew)),
slog.String("host", name))
}
host.TLS = newHost.TLS
}
if newHost.RegCert != "" {
if host.RegCert != "" && host.RegCert != newHost.RegCert {
log.Warn("Changing certificate settings for registry",
slog.String("orig", host.RegCert),
slog.String("new", newHost.RegCert),
slog.String("host", name))
}
host.RegCert = newHost.RegCert
}
if newHost.ClientCert != "" {
if host.ClientCert != "" && host.ClientCert != newHost.ClientCert {
log.Warn("Changing client certificate settings for registry",
slog.String("orig", host.ClientCert),
slog.String("new", newHost.ClientCert),
slog.String("host", name))
}
host.ClientCert = newHost.ClientCert
}
if newHost.ClientKey != "" {
if host.ClientKey != "" && host.ClientKey != newHost.ClientKey {
log.Warn("Changing client certificate key settings for registry",
slog.String("host", name))
}
host.ClientKey = newHost.ClientKey
}
if newHost.Hostname != "" {
if host.Hostname != "" && host.Hostname != newHost.Hostname {
log.Warn("Changing hostname settings for registry",
slog.String("orig", host.Hostname),
slog.String("new", newHost.Hostname),
slog.String("host", name))
}
host.Hostname = newHost.Hostname
}
if newHost.PathPrefix != "" {
newHost.PathPrefix = strings.Trim(newHost.PathPrefix, "/") // leading and trailing / are not needed
if host.PathPrefix != "" && host.PathPrefix != newHost.PathPrefix {
log.Warn("Changing path prefix settings for registry",
slog.String("orig", host.PathPrefix),
slog.String("new", newHost.PathPrefix),
slog.String("host", name))
}
host.PathPrefix = newHost.PathPrefix
}
if len(newHost.Mirrors) > 0 {
if len(host.Mirrors) > 0 && !slices.Equal(host.Mirrors, newHost.Mirrors) {
log.Warn("Changing mirror settings for registry",
slog.Any("orig", host.Mirrors),
slog.Any("new", newHost.Mirrors),
slog.String("host", name))
}
host.Mirrors = newHost.Mirrors
}
if newHost.Priority != 0 {
if host.Priority != 0 && host.Priority != newHost.Priority {
log.Warn("Changing priority settings for registry",
slog.Uint64("orig", uint64(host.Priority)),
slog.Uint64("new", uint64(newHost.Priority)),
slog.String("host", name))
}
host.Priority = newHost.Priority
}
if newHost.RepoAuth {
host.RepoAuth = newHost.RepoAuth
}
// TODO: eventually delete
if newHost.API != "" {
log.Warn("API field has been deprecated",
slog.String("api", newHost.API),
slog.String("host", name))
}
if len(newHost.APIOpts) > 0 {
if len(host.APIOpts) > 0 {
merged := maps.Clone(host.APIOpts)
for k, v := range newHost.APIOpts {
if host.APIOpts[k] != "" && host.APIOpts[k] != v {
log.Warn("Changing APIOpts setting for registry",
slog.String("orig", host.APIOpts[k]),
slog.String("new", newHost.APIOpts[k]),
slog.String("opt", k),
slog.String("host", name))
}
merged[k] = v
}
host.APIOpts = merged
} else {
host.APIOpts = newHost.APIOpts
}
}
if newHost.BlobChunk > 0 {
if host.BlobChunk != 0 && host.BlobChunk != newHost.BlobChunk {
log.Warn("Changing blobChunk settings for registry",
slog.Int64("orig", host.BlobChunk),
slog.Int64("new", newHost.BlobChunk),
slog.String("host", name))
}
host.BlobChunk = newHost.BlobChunk
}
if newHost.BlobMax != 0 {
if host.BlobMax != 0 && host.BlobMax != newHost.BlobMax {
log.Warn("Changing blobMax settings for registry",
slog.Int64("orig", host.BlobMax),
slog.Int64("new", newHost.BlobMax),
slog.String("host", name))
}
host.BlobMax = newHost.BlobMax
}
if newHost.ReqPerSec != 0 {
if host.ReqPerSec != 0 && host.ReqPerSec != newHost.ReqPerSec {
log.Warn("Changing reqPerSec settings for registry",
slog.Float64("orig", host.ReqPerSec),
slog.Float64("new", newHost.ReqPerSec),
slog.String("host", name))
}
host.ReqPerSec = newHost.ReqPerSec
}
if newHost.ReqConcurrent > 0 {
if host.ReqConcurrent != 0 && host.ReqConcurrent != newHost.ReqConcurrent {
log.Warn("Changing reqPerSec settings for registry",
slog.Int64("orig", host.ReqConcurrent),
slog.Int64("new", newHost.ReqConcurrent),
slog.String("host", name))
}
host.ReqConcurrent = newHost.ReqConcurrent
}
return nil
}
// parseName splits a registry into the scheme, hostname, and repository/path.
func parseName(name string) (string, string, string) {
scheme := "https"
path := ""
// Docker Hub is a special case
if name == DockerRegistryAuth || name == DockerRegistryDNS || name == DockerRegistry {
return scheme, DockerRegistry, ""
}
// handle http/https prefix
i := strings.Index(name, "://")
if i > 0 {
scheme = name[:i]
name = name[i+3:]
}
// trim any repository path
i = strings.Index(name, "/")
if i > 0 {
path = name[i+1:]
name = name[:i]
}
return scheme, name, path
}

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