Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 324993fd25 | |||
| a1de95a8ce | |||
| f4861bf3c1 | |||
| 7fb3b2f591 | |||
| 0dec68294f | |||
| 69ba548dab | |||
| cfa9ff15bb | |||
| ddf7441152 | |||
| 20b4a3fb84 | |||
| 81f4420a67 | |||
| b13b10be9a | |||
| 8137e56d1e | |||
| f3943d25d1 | |||
| 9859ff46f5 | |||
| 3109a0da82 | |||
| b1146a707e | |||
| 6d13d14d75 | |||
| 427ec22cab | |||
| 1111b69f12 | |||
| 1541e6aa6a | |||
| 14ee80582a | |||
| e5c65b8fa0 | |||
| e1b10f6020 | |||
|
8e4ed7b689
|
|||
|
a44fde2df2
|
|||
| e623f55852 | |||
| 11e6f28a60 | |||
| acec067d76 | |||
| f50a57af7a | |||
| 776693acc0 | |||
| 5dea5f7746 | |||
| 1d9a289888 | |||
| c7bd55e371 | |||
| 4276337b0f | |||
| 90ca856b64 | |||
| f2dd65491d | |||
| 0e902ed897 | |||
| db001c1ba4 | |||
| e4215c09aa | |||
| e0e6dcb710 | |||
| e7ddb74a08 | |||
| 24a5e6334f | |||
| 9d8eb2317e | |||
| 5945ea8e1b | |||
| e170d1c971 | |||
| 5eba3abb1b | |||
| df5a38e887 |
+11
-6
@@ -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
-1
@@ -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 \
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Generated
+61
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
];
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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,14 @@ require (
|
||||
github.com/docker/cli v28.4.0+incompatible
|
||||
github.com/docker/docker v28.5.2+incompatible
|
||||
github.com/docker/go-units v0.5.0
|
||||
github.com/go-git/go-git/v5 v5.17.2
|
||||
github.com/go-git/go-git/v5 v5.19.2
|
||||
github.com/google/go-cmp v0.7.0
|
||||
github.com/leonelquinteros/gotext v1.7.2
|
||||
github.com/moby/sys/signal v0.7.1
|
||||
github.com/moby/term v0.5.2
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/schollz/progressbar/v3 v3.19.0
|
||||
golang.org/x/term v0.41.0
|
||||
github.com/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
|
||||
)
|
||||
@@ -60,7 +60,7 @@ require (
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/ghodss/yaml v1.0.0 // indirect
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
||||
github.com/go-git/go-billy/v5 v5.8.0 // indirect
|
||||
github.com/go-git/go-billy/v5 v5.9.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
|
||||
@@ -77,7 +77,7 @@ require (
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.21 // indirect
|
||||
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
|
||||
@@ -96,7 +96,7 @@ require (
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/runc v1.1.13 // indirect
|
||||
github.com/opencontainers/runtime-spec v1.1.0 // indirect
|
||||
github.com/pjbgf/sha1cd v0.5.0 // indirect
|
||||
github.com/pjbgf/sha1cd v0.6.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
@@ -124,10 +124,10 @@ require (
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.49.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
golang.org/x/crypto v0.53.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
golang.org/x/text v0.39.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||
@@ -155,9 +155,9 @@ require (
|
||||
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.42.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
|
||||
|
||||
@@ -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=
|
||||
@@ -308,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=
|
||||
@@ -389,12 +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.19.2 h1:wkfn7vOlUBu8ivAWKBWisTiwJK4jYHzTF8Ndv1LyGqY=
|
||||
github.com/go-git/go-git/v5 v5.19.2/go.mod h1:QqCBE1EFN5ddFmrliLQ3/ntRCUjZU3EJuwuB/jWEHjk=
|
||||
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=
|
||||
@@ -623,8 +623,8 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
|
||||
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
|
||||
@@ -753,8 +753,8 @@ 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=
|
||||
github.com/pkg/errors v0.8.1-0.20171018195549-f15c970de5b7/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
@@ -808,8 +808,8 @@ github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/safchain/ethtool v0.0.0-20190326074333-42ed695e3de8/go.mod h1:Z0q5wiBQGYcxhMZ6gUqHn6pYNLypFAvaL3UvgZLR0U4=
|
||||
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
|
||||
github.com/schollz/progressbar/v3 v3.19.0 h1:Ea18xuIRQXLAUidVDox3AbwfUhD0/1IvohyTutOIFoc=
|
||||
github.com/schollz/progressbar/v3 v3.19.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec=
|
||||
github.com/schollz/progressbar/v3 v3.19.1 h1:iv8BgwOvdML/S3p84uBpy/IMigv4U9594vPZYa2EdrU=
|
||||
github.com/schollz/progressbar/v3 v3.19.1/go.mod h1:LFL7jqimKxfhero4K1eCkUr/6R39AgQeiPCJtlTWIW8=
|
||||
github.com/sclevine/spec v1.2.0/go.mod h1:W4J29eT/Kzv7/b9IWLB055Z+qvVC9vt0Arko24q7p+U=
|
||||
github.com/seccomp/libseccomp-golang v0.9.1/go.mod h1:GbW5+tmTXfcxTToHLXlScSlAvWlF4P2Ca7zGrPiEpWo=
|
||||
github.com/seccomp/libseccomp-golang v0.9.2-0.20210429002308-3879420cc921/go.mod h1:JA8cRccbGaA1s33RQf7Y1+q9gHmZX1yB/z9WDN1C6fg=
|
||||
@@ -966,8 +966,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.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||
@@ -978,8 +978,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0
|
||||
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
|
||||
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
|
||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
|
||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
||||
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
@@ -1043,8 +1043,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.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
@@ -1139,14 +1139,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.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.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=
|
||||
@@ -1156,8 +1155,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.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
|
||||
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
|
||||
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=
|
||||
|
||||
+54
@@ -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";
|
||||
};
|
||||
}
|
||||
+83
-83
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -3,7 +3,7 @@ version: "3.8"
|
||||
|
||||
services:
|
||||
app:
|
||||
image: nginx:1.29.0
|
||||
image: nginx:1.31.3
|
||||
networks:
|
||||
- proxy
|
||||
deploy:
|
||||
|
||||
+3
-3
@@ -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
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"config:recommended"
|
||||
]
|
||||
}
|
||||
+4
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -16,8 +16,6 @@ import (
|
||||
// can but returns the first error it encounters. If the path does not exist,
|
||||
// RemoveAll returns nil (no error).
|
||||
func RemoveAll(fs billy.Basic, path string) error {
|
||||
fs, path = getUnderlyingAndPath(fs, path)
|
||||
|
||||
if r, ok := fs.(removerAll); ok {
|
||||
return r.RemoveAll(path)
|
||||
}
|
||||
@@ -39,7 +37,7 @@ func removeAll(fs billy.Basic, path string) error {
|
||||
}
|
||||
|
||||
// Otherwise, is this a directory we need to recurse into?
|
||||
dir, serr := fs.Stat(path)
|
||||
dir, serr := lstat(fs, path)
|
||||
if serr != nil {
|
||||
if errors.Is(serr, os.ErrNotExist) {
|
||||
return nil
|
||||
@@ -48,8 +46,8 @@ func removeAll(fs billy.Basic, path string) error {
|
||||
return serr
|
||||
}
|
||||
|
||||
if !dir.IsDir() {
|
||||
// Not a directory; return the error from Remove.
|
||||
if dir.Mode()&os.ModeSymlink != 0 || !dir.IsDir() {
|
||||
// Not a directory we should recurse into; return the error from Remove.
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -62,7 +60,7 @@ func removeAll(fs billy.Basic, path string) error {
|
||||
fis, err := dirfs.ReadDir(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
// Race. It was deleted between the Lstat and Open.
|
||||
// Race. It was deleted between the Lstat and ReadDir.
|
||||
// Return nil per RemoveAll's docs.
|
||||
return nil
|
||||
}
|
||||
@@ -91,7 +89,18 @@ func removeAll(fs billy.Basic, path string) error {
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func lstat(filesystem billy.Basic, path string) (os.FileInfo, error) {
|
||||
if sl, ok := filesystem.(billy.Symlink); ok {
|
||||
// Avoid following a symlink substituted after the initial Remove fails.
|
||||
fi, err := sl.Lstat(path)
|
||||
if err == nil || !errors.Is(err, billy.ErrNotSupported) {
|
||||
return fi, err
|
||||
}
|
||||
}
|
||||
|
||||
return filesystem.Stat(path)
|
||||
}
|
||||
|
||||
// WriteFile writes data to a file named by filename in the given filesystem.
|
||||
@@ -123,8 +132,10 @@ func WriteFile(fs billy.Basic, filename string, data []byte, perm os.FileMode) (
|
||||
// We generate random temporary file names so that there's a good
|
||||
// chance the file doesn't exist yet - keeps the number of tries in
|
||||
// TempFile to a minimum.
|
||||
var rand uint32
|
||||
var randmu sync.Mutex
|
||||
var (
|
||||
rand uint32
|
||||
randmu sync.Mutex
|
||||
)
|
||||
|
||||
func reseed() uint32 {
|
||||
return uint32(time.Now().UnixNano() + int64(os.Getpid()))
|
||||
@@ -220,22 +231,6 @@ func getTempDir(fs billy.Basic) string {
|
||||
return ".tmp"
|
||||
}
|
||||
|
||||
type underlying interface {
|
||||
Underlying() billy.Basic
|
||||
}
|
||||
|
||||
func getUnderlyingAndPath(fs billy.Basic, path string) (billy.Basic, string) {
|
||||
u, ok := fs.(underlying)
|
||||
if !ok {
|
||||
return fs, path
|
||||
}
|
||||
if ch, ok := fs.(billy.Chroot); ok {
|
||||
path = fs.Join(ch.Root(), path)
|
||||
}
|
||||
|
||||
return u.Underlying(), path
|
||||
}
|
||||
|
||||
// ReadFile reads the named file and returns the contents from the given filesystem.
|
||||
// A successful call returns err == nil, not err == EOF.
|
||||
// Because ReadFile reads the whole file, it does not treat an EOF from Read
|
||||
|
||||
+1
@@ -5,3 +5,4 @@ profile.out
|
||||
.tmp/
|
||||
.git-dist/
|
||||
.vscode
|
||||
build/tools/
|
||||
|
||||
+30
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
-3
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
+42
@@ -110,6 +110,48 @@ func (r ReferenceName) IsTag() bool {
|
||||
return strings.HasPrefix(string(r), refTagPrefix)
|
||||
}
|
||||
|
||||
// IsSafe reports whether the reference name can be safely turned into a path
|
||||
// under the .git directory, mirroring Git's refname_is_safe (refs.c). A name
|
||||
// is safe when it is either:
|
||||
//
|
||||
// - under "refs/", non-empty after the prefix, containing no backslash and
|
||||
// no empty, "." or ".." path component (so it cannot escape the refs/
|
||||
// sub-tree, or alias another name, once turned into a path); or
|
||||
// - a one-level pseudo-ref whose spelling is restricted to [A-Z_]
|
||||
// (e.g. HEAD, ORIG_HEAD, FETCH_HEAD).
|
||||
//
|
||||
// Everything else — a lowercase or mixed one-level name such as "config" or
|
||||
// "index", an absolute or drive-prefixed name, or a refs/ name that escapes —
|
||||
// is unsafe, because it could resolve onto unrelated repository metadata.
|
||||
func (r ReferenceName) IsSafe() bool {
|
||||
s := string(r)
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
if rest, ok := strings.CutPrefix(s, refPrefix); ok {
|
||||
// '\' is a path separator on Windows, so a refs/ name containing one
|
||||
// could escape the sub-tree or alias another name once turned into a
|
||||
// path; reject it outright (check_refname_format forbids '\' too).
|
||||
if rest == "" || strings.Contains(rest, "\\") {
|
||||
return false
|
||||
}
|
||||
for part := range strings.SplitSeq(rest, "/") {
|
||||
if part == "" || part == "." || part == ".." {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
for i := 0; i < len(s); i++ {
|
||||
if (s[i] < 'A' || s[i] > 'Z') && s[i] != '_' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (r ReferenceName) String() string {
|
||||
return string(r)
|
||||
}
|
||||
|
||||
+147
-21
@@ -7,7 +7,6 @@ import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"reflect"
|
||||
@@ -24,6 +23,33 @@ import (
|
||||
"github.com/go-git/go-git/v5/utils/ioutil"
|
||||
)
|
||||
|
||||
type contextKey int
|
||||
|
||||
const initialRequestKey contextKey = iota
|
||||
|
||||
// RedirectPolicy controls how the HTTP transport follows redirects.
|
||||
//
|
||||
// The values mirror Git's http.followRedirects config:
|
||||
// "true" follows redirects for all requests, "false" treats redirects as
|
||||
// errors, and "initial" follows redirects only for the initial
|
||||
// /info/refs discovery request. The zero value defaults to "initial".
|
||||
type RedirectPolicy string
|
||||
|
||||
const (
|
||||
FollowInitialRedirects RedirectPolicy = "initial"
|
||||
FollowRedirects RedirectPolicy = "true"
|
||||
NoFollowRedirects RedirectPolicy = "false"
|
||||
)
|
||||
|
||||
func withInitialRequest(ctx context.Context) context.Context {
|
||||
return context.WithValue(ctx, initialRequestKey, true)
|
||||
}
|
||||
|
||||
func isInitialRequest(req *http.Request) bool {
|
||||
v, _ := req.Context().Value(initialRequestKey).(bool)
|
||||
return v
|
||||
}
|
||||
|
||||
// it requires a bytes.Buffer, because we need to know the length
|
||||
func applyHeadersToRequest(req *http.Request, content *bytes.Buffer, host string, requestType string) {
|
||||
req.Header.Add("User-Agent", capability.DefaultAgent())
|
||||
@@ -54,12 +80,15 @@ func advertisedReferences(ctx context.Context, s *session, serviceName string) (
|
||||
|
||||
s.ApplyAuthToRequest(req)
|
||||
applyHeadersToRequest(req, nil, s.endpoint.Host, serviceName)
|
||||
res, err := s.client.Do(req.WithContext(ctx))
|
||||
res, err := s.client.Do(req.WithContext(withInitialRequest(ctx)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.ModifyEndpointIfRedirect(res)
|
||||
if err := s.ModifyEndpointIfRedirect(res); err != nil {
|
||||
_ = res.Body.Close()
|
||||
return nil, err
|
||||
}
|
||||
defer ioutil.CheckClose(res.Body, &err)
|
||||
|
||||
if err = NewErr(res); err != nil {
|
||||
@@ -96,6 +125,7 @@ type client struct {
|
||||
client *http.Client
|
||||
transports *lru.Cache
|
||||
mutex sync.RWMutex
|
||||
follow RedirectPolicy
|
||||
}
|
||||
|
||||
// ClientOptions holds user configurable options for the client.
|
||||
@@ -106,6 +136,11 @@ type ClientOptions struct {
|
||||
// size, will result in the least recently used transport getting deleted
|
||||
// before the provided transport is added to the cache.
|
||||
CacheMaxEntries int
|
||||
|
||||
// RedirectPolicy controls redirect handling. Supported values are
|
||||
// "true", "false", and "initial". The zero value defaults to
|
||||
// "initial", matching Git's http.followRedirects default.
|
||||
RedirectPolicy RedirectPolicy
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -150,12 +185,16 @@ func NewClientWithOptions(c *http.Client, opts *ClientOptions) transport.Transpo
|
||||
}
|
||||
cl := &client{
|
||||
client: c,
|
||||
follow: FollowInitialRedirects,
|
||||
}
|
||||
|
||||
if opts != nil {
|
||||
if opts.CacheMaxEntries > 0 {
|
||||
cl.transports = lru.New(opts.CacheMaxEntries)
|
||||
}
|
||||
if opts.RedirectPolicy != "" {
|
||||
cl.follow = opts.RedirectPolicy
|
||||
}
|
||||
}
|
||||
return cl
|
||||
}
|
||||
@@ -289,14 +328,9 @@ func newSession(c *client, ep *transport.Endpoint, auth transport.AuthMethod) (*
|
||||
}
|
||||
}
|
||||
|
||||
httpClient = &http.Client{
|
||||
Transport: transport,
|
||||
CheckRedirect: c.client.CheckRedirect,
|
||||
Jar: c.client.Jar,
|
||||
Timeout: c.client.Timeout,
|
||||
}
|
||||
httpClient = c.cloneHTTPClient(transport)
|
||||
} else {
|
||||
httpClient = c.client
|
||||
httpClient = c.cloneHTTPClient(c.client.Transport)
|
||||
}
|
||||
|
||||
s := &session{
|
||||
@@ -324,30 +358,122 @@ func (s *session) ApplyAuthToRequest(req *http.Request) {
|
||||
s.auth.SetAuth(req)
|
||||
}
|
||||
|
||||
func (s *session) ModifyEndpointIfRedirect(res *http.Response) {
|
||||
func (s *session) ModifyEndpointIfRedirect(res *http.Response) error {
|
||||
if res.Request == nil {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
if s.endpoint == nil {
|
||||
return fmt.Errorf("http redirect: nil endpoint")
|
||||
}
|
||||
|
||||
r := res.Request
|
||||
if !strings.HasSuffix(r.URL.Path, infoRefsPath) {
|
||||
return
|
||||
return fmt.Errorf("http redirect: target %q does not end with %s", r.URL.Path, infoRefsPath)
|
||||
}
|
||||
if r.URL.Scheme != "http" && r.URL.Scheme != "https" {
|
||||
return fmt.Errorf("http redirect: unsupported scheme %q", r.URL.Scheme)
|
||||
}
|
||||
if r.URL.Scheme != s.endpoint.Protocol &&
|
||||
!(s.endpoint.Protocol == "http" && r.URL.Scheme == "https") {
|
||||
return fmt.Errorf("http redirect: changes scheme from %q to %q", s.endpoint.Protocol, r.URL.Scheme)
|
||||
}
|
||||
|
||||
h, p, err := net.SplitHostPort(r.URL.Host)
|
||||
host := endpointHost(r.URL.Hostname())
|
||||
port, err := endpointPort(r.URL.Port())
|
||||
if err != nil {
|
||||
h = r.URL.Host
|
||||
return err
|
||||
}
|
||||
if p != "" {
|
||||
port, err := strconv.Atoi(p)
|
||||
if err == nil {
|
||||
s.endpoint.Port = port
|
||||
}
|
||||
|
||||
if host != s.endpoint.Host || effectivePort(r.URL.Scheme, port) != effectivePort(s.endpoint.Protocol, s.endpoint.Port) {
|
||||
s.endpoint.User = ""
|
||||
s.endpoint.Password = ""
|
||||
s.auth = nil
|
||||
}
|
||||
s.endpoint.Host = h
|
||||
|
||||
s.endpoint.Host = host
|
||||
s.endpoint.Port = port
|
||||
|
||||
s.endpoint.Protocol = r.URL.Scheme
|
||||
s.endpoint.Path = r.URL.Path[:len(r.URL.Path)-len(infoRefsPath)]
|
||||
return nil
|
||||
}
|
||||
|
||||
func endpointHost(host string) string {
|
||||
if strings.Contains(host, ":") {
|
||||
return "[" + host + "]"
|
||||
}
|
||||
|
||||
return host
|
||||
}
|
||||
|
||||
func endpointPort(port string) (int, error) {
|
||||
if port == "" {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
parsed, err := strconv.Atoi(port)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("http redirect: invalid port %q", port)
|
||||
}
|
||||
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func effectivePort(scheme string, port int) int {
|
||||
if port != 0 {
|
||||
return port
|
||||
}
|
||||
|
||||
switch strings.ToLower(scheme) {
|
||||
case "http":
|
||||
return 80
|
||||
case "https":
|
||||
return 443
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func (c *client) cloneHTTPClient(transport http.RoundTripper) *http.Client {
|
||||
return &http.Client{
|
||||
Transport: transport,
|
||||
CheckRedirect: wrapCheckRedirect(c.follow, c.client.CheckRedirect),
|
||||
Jar: c.client.Jar,
|
||||
Timeout: c.client.Timeout,
|
||||
}
|
||||
}
|
||||
|
||||
func wrapCheckRedirect(policy RedirectPolicy, next func(*http.Request, []*http.Request) error) func(*http.Request, []*http.Request) error {
|
||||
return func(req *http.Request, via []*http.Request) error {
|
||||
if err := checkRedirect(req, via, policy); err != nil {
|
||||
return err
|
||||
}
|
||||
if next != nil {
|
||||
return next(req, via)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func checkRedirect(req *http.Request, via []*http.Request, policy RedirectPolicy) error {
|
||||
switch policy {
|
||||
case FollowRedirects:
|
||||
case NoFollowRedirects:
|
||||
return fmt.Errorf("http redirect: redirects disabled to %s", req.URL)
|
||||
case "", FollowInitialRedirects:
|
||||
if !isInitialRequest(req) {
|
||||
return fmt.Errorf("http redirect: redirect on non-initial request to %s", req.URL)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("http redirect: invalid redirect policy %q", policy)
|
||||
}
|
||||
if req.URL.Scheme != "http" && req.URL.Scheme != "https" {
|
||||
return fmt.Errorf("http redirect: unsupported scheme %q", req.URL.Scheme)
|
||||
}
|
||||
if len(via) >= 10 {
|
||||
return fmt.Errorf("http redirect: too many redirects")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*session) Close() error {
|
||||
|
||||
+33
-1
@@ -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
@@ -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) {
|
||||
|
||||
+74
-2
@@ -16,6 +16,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-git/go-git/v5/internal/pathutil"
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
"github.com/go-git/go-git/v5/plumbing/hash"
|
||||
"github.com/go-git/go-git/v5/storage"
|
||||
@@ -75,8 +76,56 @@ var (
|
||||
// ErrEmptyRefFile is returned when a reference file is attempted to be read,
|
||||
// but the file is empty
|
||||
ErrEmptyRefFile = errors.New("ref file is empty")
|
||||
// ErrModuleNameEscape is returned when a submodule name would
|
||||
// resolve outside the modules/ subtree, mirroring canonical Git's
|
||||
// "ignoring suspicious submodule name" defence.
|
||||
ErrModuleNameEscape = errors.New("submodule name escapes modules/ directory")
|
||||
// ErrReferenceNameEscape is returned when a reference name would
|
||||
// resolve outside its reference sub-tree once turned into a path
|
||||
// under the .git directory (e.g. a name with a ".." component).
|
||||
ErrReferenceNameEscape = errors.New("reference name escapes the reference storage")
|
||||
)
|
||||
|
||||
// isPathSep reports whether r is a path separator in reference names.
|
||||
// It treats both '/' and '\\' as separators to harden against cross-OS paths.
|
||||
func isPathSep(r rune) bool { return r == '/' || r == '\\' }
|
||||
|
||||
// validReferenceName rejects reference names that cannot be safely turned into
|
||||
// a path under the .git directory. A loose reference is stored verbatim at
|
||||
// ".git/<name>", so a crafted name — for instance one advertised by a malicious
|
||||
// remote — could climb out of its reference sub-tree and read, overwrite, or
|
||||
// delete unrelated metadata such as .git/config.
|
||||
//
|
||||
// The storage-safety gate is plumbing.ReferenceName.IsSafe, mirroring Git's
|
||||
// refname_is_safe: a name must be under refs/ without escaping it, or be a
|
||||
// [A-Z_] pseudo-ref. This alone rejects absolute, drive-prefixed, escaping and
|
||||
// single-level metadata names. On top of it, this adds filesystem-specific
|
||||
// hardening that IsSafe's literal check does not cover: control characters, and
|
||||
// components a case-insensitive/NTFS/HFS+ filesystem would fold back to "." or
|
||||
// ".." (trailing dots/spaces, Alternate Data Streams, ignorable Unicode code
|
||||
// points), delegated to pathutil.IsHFSDot and pathutil.IsNTFSDot with "." as
|
||||
// the needle — as validSubmoduleName does — and run regardless of host OS.
|
||||
func validReferenceName(name plumbing.ReferenceName) error {
|
||||
if !name.IsSafe() {
|
||||
return fmt.Errorf("%w: %q is not under refs/ nor a valid pseudo-ref", ErrReferenceNameEscape, string(name))
|
||||
}
|
||||
|
||||
s := string(name)
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] < 0x20 || s[i] == 0x7f {
|
||||
return fmt.Errorf("%w: %q", ErrReferenceNameEscape, s)
|
||||
}
|
||||
}
|
||||
for _, part := range strings.FieldsFunc(s, isPathSep) {
|
||||
// IsNTFSDot/IsHFSDot with a "." needle match ".." and its disguises
|
||||
// but not a bare ".", so reject that component explicitly too.
|
||||
if part == "." || pathutil.IsHFSDot(part, ".") || pathutil.IsNTFSDot(part, ".", "") {
|
||||
return fmt.Errorf("%w: %q", ErrReferenceNameEscape, s)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Options holds configuration for the storage.
|
||||
type Options struct {
|
||||
// ExclusiveAccess means that the filesystem is not modified externally
|
||||
@@ -702,6 +751,10 @@ func (d *DotGit) checkReferenceAndTruncate(f billy.File, old *plumbing.Reference
|
||||
}
|
||||
|
||||
func (d *DotGit) SetRef(r, old *plumbing.Reference) error {
|
||||
if err := validReferenceName(r.Name()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var content string
|
||||
switch r.Type() {
|
||||
case plumbing.SymbolicReference:
|
||||
@@ -737,6 +790,10 @@ func (d *DotGit) Refs() ([]*plumbing.Reference, error) {
|
||||
|
||||
// Ref returns the reference for a given reference name.
|
||||
func (d *DotGit) Ref(name plumbing.ReferenceName) (*plumbing.Reference, error) {
|
||||
if err := validReferenceName(name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ref, err := d.readReferenceFile(".", name.String())
|
||||
if err == nil {
|
||||
return ref, nil
|
||||
@@ -800,6 +857,10 @@ func (d *DotGit) packedRef(name plumbing.ReferenceName) (*plumbing.Reference, er
|
||||
|
||||
// RemoveRef removes a reference by name.
|
||||
func (d *DotGit) RemoveRef(name plumbing.ReferenceName) error {
|
||||
if err := validReferenceName(name); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
path := d.fs.Join(".", name.String())
|
||||
_, err := d.fs.Stat(path)
|
||||
if err == nil {
|
||||
@@ -1127,9 +1188,20 @@ func (d *DotGit) PackRefs() (err error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Module return a billy.Filesystem pointing to the module folder
|
||||
// Module returns a billy.Filesystem pointing to the module folder.
|
||||
//
|
||||
// As a defence in depth against submodule name path traversal,
|
||||
// refuse names whose joined path leaves the modules/ subtree once
|
||||
// cleaned. The config-layer parser also validates submodule names,
|
||||
// but Module may be reached from any caller that constructs a
|
||||
// Submodule struct programmatically and so bypasses the parser.
|
||||
func (d *DotGit) Module(name string) (billy.Filesystem, error) {
|
||||
return d.fs.Chroot(d.fs.Join(modulePath, name))
|
||||
p := d.fs.Join(modulePath, name)
|
||||
cleaned := path.Clean(filepath.ToSlash(p))
|
||||
if cleaned != modulePath && !strings.HasPrefix(cleaned, modulePath+"/") {
|
||||
return nil, ErrModuleNameEscape
|
||||
}
|
||||
return d.fs.Chroot(p)
|
||||
}
|
||||
|
||||
func (d *DotGit) AddAlternate(remote string) error {
|
||||
|
||||
+74
-8
@@ -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
@@ -5,11 +5,18 @@ package binary
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"io"
|
||||
"math"
|
||||
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
)
|
||||
|
||||
// ErrIntegerOverflow is returned when a Git-format variable-width integer
|
||||
// would not fit into an int64 because the input declares more continuation
|
||||
// bytes than the type can hold.
|
||||
var ErrIntegerOverflow = errors.New("variable-width integer overflow")
|
||||
|
||||
// Read reads structured binary data from r into data. Bytes are read and
|
||||
// decoded in BigEndian order
|
||||
// https://golang.org/pkg/encoding/binary/#Read
|
||||
@@ -92,6 +99,14 @@ func ReadVariableWidthInt(r io.Reader) (int64, error) {
|
||||
|
||||
var v = int64(c & maskLength)
|
||||
for c&maskContinue > 0 {
|
||||
// Reject input that, after the v++ and shift below, would
|
||||
// not fit in an int64. With v < (MaxInt64-127)>>7, the
|
||||
// post-increment v is at most (MaxInt64-127)>>7 and the
|
||||
// final (v << 7) + (c & 0x7F) stays within int64.
|
||||
if v >= (math.MaxInt64-int64(maskLength))>>lengthBits {
|
||||
return 0, ErrIntegerOverflow
|
||||
}
|
||||
|
||||
v++
|
||||
if err := Read(r, &c); err != nil {
|
||||
return 0, err
|
||||
|
||||
+71
-111
@@ -7,7 +7,6 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/go-git/go-billy/v5"
|
||||
@@ -458,10 +457,6 @@ func (w *Worktree) resetWorktree(t *object.Tree, files []string) error {
|
||||
|
||||
filesMap := buildFilePathMap(files)
|
||||
for _, ch := range changes {
|
||||
if err := w.validChange(ch); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(files) > 0 {
|
||||
file := ""
|
||||
if ch.From != nil {
|
||||
@@ -489,108 +484,6 @@ func (w *Worktree) resetWorktree(t *object.Tree, files []string) error {
|
||||
return w.r.Storer.SetIndex(idx)
|
||||
}
|
||||
|
||||
// worktreeDeny is a list of paths that are not allowed
|
||||
// to be used when resetting the worktree.
|
||||
var worktreeDeny = map[string]struct{}{
|
||||
// .git
|
||||
GitDirName: {},
|
||||
|
||||
// For other historical reasons, file names that do not conform to the 8.3
|
||||
// format (up to eight characters for the basename, three for the file
|
||||
// extension, certain characters not allowed such as `+`, etc) are associated
|
||||
// with a so-called "short name", at least on the `C:` drive by default.
|
||||
// Which means that `git~1/` is a valid way to refer to `.git/`.
|
||||
"git~1": {},
|
||||
}
|
||||
|
||||
// validPath checks whether paths are valid.
|
||||
// The rules around invalid paths could differ from upstream based on how
|
||||
// filesystems are managed within go-git, but they are largely the same.
|
||||
//
|
||||
// For upstream rules:
|
||||
// https://github.com/git/git/blob/564d0252ca632e0264ed670534a51d18a689ef5d/read-cache.c#L946
|
||||
// https://github.com/git/git/blob/564d0252ca632e0264ed670534a51d18a689ef5d/path.c#L1383
|
||||
func validPath(paths ...string) error {
|
||||
for _, p := range paths {
|
||||
parts := strings.FieldsFunc(p, func(r rune) bool { return (r == '\\' || r == '/') })
|
||||
if len(parts) == 0 {
|
||||
return fmt.Errorf("invalid path: %q", p)
|
||||
}
|
||||
|
||||
if _, denied := worktreeDeny[strings.ToLower(parts[0])]; denied {
|
||||
return fmt.Errorf("invalid path prefix: %q", p)
|
||||
}
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
// Volume names are not supported, in both formats: \\ and <DRIVE_LETTER>:.
|
||||
if vol := filepath.VolumeName(p); vol != "" {
|
||||
return fmt.Errorf("invalid path: %q", p)
|
||||
}
|
||||
|
||||
if !windowsValidPath(parts[0]) {
|
||||
return fmt.Errorf("invalid path: %q", p)
|
||||
}
|
||||
}
|
||||
|
||||
for _, part := range parts {
|
||||
if part == ".." {
|
||||
return fmt.Errorf("invalid path %q: cannot use '..'", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// windowsPathReplacer defines the chars that need to be replaced
|
||||
// as part of windowsValidPath.
|
||||
var windowsPathReplacer *strings.Replacer
|
||||
|
||||
func init() {
|
||||
windowsPathReplacer = strings.NewReplacer(" ", "", ".", "")
|
||||
}
|
||||
|
||||
func windowsValidPath(part string) bool {
|
||||
if len(part) > 3 && strings.EqualFold(part[:4], GitDirName) {
|
||||
// For historical reasons, file names that end in spaces or periods are
|
||||
// automatically trimmed. Therefore, `.git . . ./` is a valid way to refer
|
||||
// to `.git/`.
|
||||
if windowsPathReplacer.Replace(part[4:]) == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// For yet other historical reasons, NTFS supports so-called "Alternate Data
|
||||
// Streams", i.e. metadata associated with a given file, referred to via
|
||||
// `<filename>:<stream-name>:<stream-type>`. There exists a default stream
|
||||
// type for directories, allowing `.git/` to be accessed via
|
||||
// `.git::$INDEX_ALLOCATION/`.
|
||||
//
|
||||
// For performance reasons, _all_ Alternate Data Streams of `.git/` are
|
||||
// forbidden, not just `::$INDEX_ALLOCATION`.
|
||||
if len(part) > 4 && part[4:5] == ":" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (w *Worktree) validChange(ch merkletrie.Change) error {
|
||||
action, err := ch.Action()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch action {
|
||||
case merkletrie.Delete:
|
||||
return validPath(ch.From.String())
|
||||
case merkletrie.Insert:
|
||||
return validPath(ch.To.String())
|
||||
case merkletrie.Modify:
|
||||
return validPath(ch.From.String(), ch.To.String())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Worktree) checkoutChange(ch merkletrie.Change, t *object.Tree, idx *indexBuilder) error {
|
||||
a, err := ch.Action()
|
||||
if err != nil {
|
||||
@@ -690,6 +583,10 @@ func (w *Worktree) checkoutChangeSubmodule(name string,
|
||||
return err
|
||||
}
|
||||
|
||||
if err := w.clearBlockingSymlinks(name); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := w.Filesystem.MkdirAll(name, mode); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -733,7 +630,70 @@ func (w *Worktree) checkoutChangeRegularFile(name string,
|
||||
return nil
|
||||
}
|
||||
|
||||
// clearBlockingSymlinks removes a symlink that is in the way of
|
||||
// materialising name, so the checkout writes a real entry in its place
|
||||
// instead of following the link out of the worktree. Two cases:
|
||||
//
|
||||
// - a leading directory component that is a symlink (e.g. "s" while
|
||||
// writing "s/config", where "s" links to ".git"): OpenFile/MkdirAll
|
||||
// would traverse it, so the write would land under the link's target.
|
||||
// - the final component itself being a symlink (e.g. writing "s" while
|
||||
// "s" links to ".git/config"): OpenFile with O_TRUNC, or Symlink,
|
||||
// would follow/replace through it and clobber the target.
|
||||
//
|
||||
// A symlink can never be a legitimate parent of, or the destination for,
|
||||
// a tracked entry, so removing it is always correct. This mirrors upstream
|
||||
// Git's forced checkout, which unlinks a blocking symlink in the leading
|
||||
// path (create_directories) and unlinks an existing entry before
|
||||
// write_entry.
|
||||
// https://github.com/git/git/blob/v2.54.0/entry.c#L50
|
||||
func (w *Worktree) clearBlockingSymlinks(name string) error {
|
||||
var dirs []string
|
||||
for dir := filepath.Dir(name); dir != "." && dir != "" && dir != string(filepath.Separator); dir = filepath.Dir(dir) {
|
||||
dirs = append(dirs, dir)
|
||||
}
|
||||
// Leading components, shallowest-first: removing the shallowest symlink
|
||||
// invalidates every component beneath it, so a single removal is enough.
|
||||
for i := len(dirs) - 1; i >= 0; i-- {
|
||||
fi, err := w.Filesystem.Lstat(dirs[i])
|
||||
if err != nil {
|
||||
// A missing component is created as a real directory by the
|
||||
// checkout. Any other error means we cannot tell whether it is
|
||||
// a symlink, so surface it instead of leaving a blocking link in
|
||||
// place and failing later in a harder-to-diagnose way.
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
if fi.Mode()&os.ModeSymlink != 0 {
|
||||
return w.Filesystem.Remove(dirs[i])
|
||||
}
|
||||
}
|
||||
// Final component: an existing symlink here would be followed by the
|
||||
// subsequent OpenFile/Symlink/MkdirAll, so replace it.
|
||||
fi, err := w.Filesystem.Lstat(name)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if fi.Mode()&os.ModeSymlink != 0 {
|
||||
return w.Filesystem.Remove(name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Worktree) checkoutFile(f *object.File) (err error) {
|
||||
// checkoutFile is the materialisation boundary for tracked entries.
|
||||
// Remove any blocking symlink first so the subsequent OpenFile or
|
||||
// Symlink call writes the entry itself instead of following a planted
|
||||
// final-component link in the underlying filesystem.
|
||||
if err := w.clearBlockingSymlinks(f.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
mode, err := f.Mode.ToOSFileMode()
|
||||
if err != nil {
|
||||
return
|
||||
@@ -763,10 +723,10 @@ func (w *Worktree) checkoutFile(f *object.File) (err error) {
|
||||
}
|
||||
|
||||
func (w *Worktree) checkoutFileSymlink(f *object.File) (err error) {
|
||||
// https://github.com/git/git/commit/10ecfa76491e4923988337b2e2243b05376b40de
|
||||
if strings.EqualFold(f.Name, gitmodulesFile) {
|
||||
return ErrGitModulesSymlink
|
||||
}
|
||||
// .gitmodules symlink rejection (and its NTFS / HFS variants) is
|
||||
// enforced by the worktreeFilesystem wrapper's Symlink method via
|
||||
// validSymlinkName. See https://github.com/git/git/commit/10ecfa7
|
||||
// for the upstream rationale.
|
||||
|
||||
from, err := f.Reader()
|
||||
if err != nil {
|
||||
|
||||
+349
@@ -0,0 +1,349 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/go-git/go-billy/v5"
|
||||
|
||||
"github.com/go-git/go-git/v5/internal/pathutil"
|
||||
)
|
||||
|
||||
// defaultProtectHFS returns the default value for core.protectHFS
|
||||
// when not explicitly configured. Matches upstream Git's
|
||||
// PROTECT_HFS_DEFAULT[1], which the Makefile sets to 1 on Darwin
|
||||
// and leaves at 0 on every other platform.
|
||||
//
|
||||
// [1]: https://github.com/git/git/blob/v2.54.0/config.mak.uname#L146
|
||||
func defaultProtectHFS() bool {
|
||||
return runtime.GOOS == "darwin"
|
||||
}
|
||||
|
||||
// defaultProtectNTFS returns the default value for core.protectNTFS
|
||||
// when not explicitly configured. Matches upstream Git's
|
||||
// PROTECT_NTFS_DEFAULT, which has been 1 on every platform since
|
||||
// 9102f958ee5 (CVE-2019-1353)[1]: WSL allows Linux processes to
|
||||
// reach NTFS-mounted worktrees on Windows hosts, so the
|
||||
// is_ntfs_dotgit guard cannot safely be gated on the runtime OS.
|
||||
//
|
||||
// [1]: https://github.com/git/git/commit/9102f958ee5
|
||||
func defaultProtectNTFS() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// worktreeFilesystem wraps a billy.Filesystem and validates every path it
|
||||
// is handed, so worktree operations cannot use dangerous paths at the
|
||||
// boundary. Two layers apply:
|
||||
//
|
||||
// - validPath rejects dangerous path *strings*: .git and its HFS+/NTFS
|
||||
// variants, "..", control characters, volume names.
|
||||
// - validNoLeadingSymlink rejects paths whose leading directories
|
||||
// already exist on disk as symlinks, so a write or delete cannot
|
||||
// follow a planted link out of the tree.
|
||||
//
|
||||
// Both layers run on every mutating operation (validWritePath) and every
|
||||
// read (validReadPath). Chroot additionally refuses a symlink as the final
|
||||
// component, so a sub-filesystem such as a submodule worktree cannot be
|
||||
// scoped to a redirected target.
|
||||
//
|
||||
// The wrapper intentionally stops at leading-component traversal. Callers
|
||||
// that need final-component no-follow semantics for materialisation
|
||||
// (checkoutFile) enforce that directly by removing the blocking symlink
|
||||
// before opening the destination path.
|
||||
type worktreeFilesystem struct {
|
||||
billy.Filesystem
|
||||
protectNTFS bool
|
||||
protectHFS bool
|
||||
}
|
||||
|
||||
func newWorktreeFilesystem(fs billy.Filesystem, protectNTFS, protectHFS bool) *worktreeFilesystem {
|
||||
return &worktreeFilesystem{Filesystem: fs, protectNTFS: protectNTFS, protectHFS: protectHFS}
|
||||
}
|
||||
|
||||
func (sfs *worktreeFilesystem) Create(filename string) (billy.File, error) {
|
||||
if err := sfs.validWritePath(filename); err != nil {
|
||||
return nil, fmt.Errorf("create: %w", err)
|
||||
}
|
||||
return sfs.Filesystem.Create(filename)
|
||||
}
|
||||
|
||||
func (sfs *worktreeFilesystem) Open(filename string) (billy.File, error) {
|
||||
if err := sfs.validReadPath(filename); err != nil {
|
||||
return nil, fmt.Errorf("open: %w", err)
|
||||
}
|
||||
return sfs.Filesystem.Open(filename)
|
||||
}
|
||||
|
||||
func (sfs *worktreeFilesystem) OpenFile(filename string, flag int, perm os.FileMode) (billy.File, error) {
|
||||
if err := sfs.validWritePath(filename); err != nil {
|
||||
return nil, fmt.Errorf("openfile: %w", err)
|
||||
}
|
||||
return sfs.Filesystem.OpenFile(filename, flag, perm)
|
||||
}
|
||||
|
||||
func (sfs *worktreeFilesystem) Stat(filename string) (os.FileInfo, error) {
|
||||
if err := sfs.validReadPath(filename); err != nil {
|
||||
return nil, fmt.Errorf("stat: %w", err)
|
||||
}
|
||||
return sfs.Filesystem.Stat(filename)
|
||||
}
|
||||
|
||||
func (sfs *worktreeFilesystem) Remove(filename string) error {
|
||||
if err := sfs.validWritePath(filename); err != nil {
|
||||
return fmt.Errorf("remove: %w", err)
|
||||
}
|
||||
return sfs.Filesystem.Remove(filename)
|
||||
}
|
||||
|
||||
func (sfs *worktreeFilesystem) Rename(from, to string) error {
|
||||
if err := sfs.validWritePath(from, to); err != nil {
|
||||
return fmt.Errorf("rename: %w", err)
|
||||
}
|
||||
return sfs.Filesystem.Rename(from, to)
|
||||
}
|
||||
|
||||
func (sfs *worktreeFilesystem) ReadDir(path string) ([]os.FileInfo, error) {
|
||||
if err := sfs.validReadPath(path); err != nil {
|
||||
return nil, fmt.Errorf("readdir: %w", err)
|
||||
}
|
||||
return sfs.Filesystem.ReadDir(path)
|
||||
}
|
||||
|
||||
func (sfs *worktreeFilesystem) Lstat(filename string) (os.FileInfo, error) {
|
||||
if err := sfs.validReadPath(filename); err != nil {
|
||||
return nil, fmt.Errorf("lstat: %w", err)
|
||||
}
|
||||
return sfs.Filesystem.Lstat(filename)
|
||||
}
|
||||
|
||||
func (sfs *worktreeFilesystem) Symlink(target, link string) error {
|
||||
if err := sfs.validWritePath(link); err != nil {
|
||||
return fmt.Errorf("symlink: %w", err)
|
||||
}
|
||||
if err := sfs.validSymlinkName(link); err != nil {
|
||||
return fmt.Errorf("symlink: %w", err)
|
||||
}
|
||||
return sfs.Filesystem.Symlink(target, link)
|
||||
}
|
||||
|
||||
func (sfs *worktreeFilesystem) Readlink(link string) (string, error) {
|
||||
if err := sfs.validReadPath(link); err != nil {
|
||||
return "", fmt.Errorf("readlink: %w", err)
|
||||
}
|
||||
return sfs.Filesystem.Readlink(link)
|
||||
}
|
||||
|
||||
func (sfs *worktreeFilesystem) MkdirAll(path string, perm os.FileMode) error {
|
||||
// MkdirAll on the worktree root is a no-op: the root always exists,
|
||||
// so there is nothing to materialise. Mirroring the tolerance that
|
||||
// validReadPath gives to read-side operations avoids breaking callers
|
||||
// that walk a directory tree and pass the relative-to-root prefix
|
||||
// ("") through to the worktree FS.
|
||||
if path == "" || path == "." || path == "/" {
|
||||
return nil
|
||||
}
|
||||
if err := sfs.validWritePath(path); err != nil {
|
||||
return fmt.Errorf("mkdirall: %w", err)
|
||||
}
|
||||
return sfs.Filesystem.MkdirAll(path, perm)
|
||||
}
|
||||
|
||||
func (sfs *worktreeFilesystem) TempFile(_, _ string) (billy.File, error) {
|
||||
return nil, fmt.Errorf("tempfile: %w", errUnsupportedOperation)
|
||||
}
|
||||
|
||||
func (sfs *worktreeFilesystem) Chroot(path string) (billy.Filesystem, error) {
|
||||
if err := sfs.validReadPath(path); err != nil {
|
||||
return nil, fmt.Errorf("chroot: %w", err)
|
||||
}
|
||||
// Chroot scopes a sub-filesystem to path, so the final component must
|
||||
// be a real directory too: a symlink there would silently redirect the
|
||||
// scope (e.g. a submodule worktree) to a target outside the tree. This
|
||||
// is the "valid path, wrong target" case that validNoLeadingSymlink,
|
||||
// which only inspects leading components, does not cover.
|
||||
//
|
||||
// A non-existent target is fine: Chroot creates it as a real
|
||||
// directory. Any other Lstat error means we cannot prove the target
|
||||
// is not a symlink, so fail closed rather than scope through it.
|
||||
if fi, err := sfs.Filesystem.Lstat(path); err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("chroot: cannot stat %q: %w", path, err)
|
||||
}
|
||||
} else if fi.Mode()&os.ModeSymlink != 0 {
|
||||
return nil, fmt.Errorf("chroot: invalid path %q: is a symlink", path)
|
||||
}
|
||||
return sfs.Filesystem.Chroot(path)
|
||||
}
|
||||
|
||||
// validReadPath is like validWritePath but treats the empty string and "."
|
||||
// as valid references to the worktree root. Read-side operations on the
|
||||
// root (e.g. ReadDir(""), Lstat(".")) are legitimate. Mutating the root
|
||||
// itself is not, so write-side operations reject it via validPath. Reads
|
||||
// are still refused through a leading symlink, so the wrapper never
|
||||
// follows a planted link even on the read surface.
|
||||
func (sfs *worktreeFilesystem) validReadPath(p string) error {
|
||||
if p == "" || p == "." || p == "/" {
|
||||
return nil
|
||||
}
|
||||
if err := sfs.validPath(p); err != nil {
|
||||
return err
|
||||
}
|
||||
return sfs.validNoLeadingSymlink(p)
|
||||
}
|
||||
|
||||
var errUnsupportedOperation = errors.New("unsupported operation")
|
||||
|
||||
// isDotGitVariant reports whether part is .git, git~1, or an HFS+
|
||||
// equivalent of .git (when protectHFS is true). NTFS variants of .git
|
||||
// (e.g. ".git " with trailing space, ".git::$INDEX_ALLOCATION") are
|
||||
// detected separately by pathutil.WindowsValidPath, which applies
|
||||
// regardless of position in the path. Both validators reuse this
|
||||
// helper.
|
||||
func isDotGitVariant(part string, protectHFS bool) bool {
|
||||
if pathutil.IsDotGitName(part) {
|
||||
return true
|
||||
}
|
||||
if protectHFS && pathutil.IsHFSDotGit(part) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// validPath checks whether paths are valid for the worktree
|
||||
// filesystem abstraction. It is intentionally tolerant of .git as
|
||||
// the final path component of a multi-component path
|
||||
// (e.g. "submodule/.git"), so that legitimate gitlink pointer files
|
||||
// can still be Stat'd, Read, and Removed via the wrapper during
|
||||
// submodule cleanup. Attacker-controlled tree-entry paths are
|
||||
// validated separately by pathutil.ValidTreePath at the boundaries
|
||||
// where data leaves the trusted store (Tree.FindEntry, the explicit
|
||||
// callers in CherryPick and Submodule.Repository).
|
||||
//
|
||||
// For upstream rules:
|
||||
// https://github.com/git/git/blob/v2.54.0/read-cache.c#L987
|
||||
// https://github.com/git/git/blob/v2.54.0/path.c#L1419
|
||||
func (sfs *worktreeFilesystem) validPath(paths ...string) error {
|
||||
for _, p := range paths {
|
||||
for i := 0; i < len(p); i++ {
|
||||
if p[i] < 0x20 || p[i] == 0x7f {
|
||||
return fmt.Errorf("invalid path %q: contains control character", p)
|
||||
}
|
||||
}
|
||||
|
||||
parts := strings.FieldsFunc(p, func(r rune) bool { return (r == '\\' || r == '/') })
|
||||
if len(parts) == 0 {
|
||||
return fmt.Errorf("invalid path: %q", p)
|
||||
}
|
||||
|
||||
if sfs.protectNTFS {
|
||||
// Volume names are not supported, in both formats: \\ and <DRIVE_LETTER>:.
|
||||
if vol := filepath.VolumeName(p); vol != "" {
|
||||
return fmt.Errorf("invalid path: %q", p)
|
||||
}
|
||||
}
|
||||
|
||||
for i, part := range parts {
|
||||
if part == "." || part == ".." {
|
||||
return fmt.Errorf("invalid path %q: cannot use %q", p, part)
|
||||
}
|
||||
|
||||
// Reject .git (and equivalents) as a path component when it is
|
||||
// either the first component (root-level .git) or a non-final
|
||||
// component (traversal into a .git directory, e.g. "a/.git/config").
|
||||
// A final non-first .git component (e.g. "submodule/.git") is
|
||||
// allowed because submodule worktrees contain a .git pointer file.
|
||||
if isDotGitVariant(part, sfs.protectHFS) && (i == 0 || i < len(parts)-1) {
|
||||
return fmt.Errorf("invalid path component: %q", p)
|
||||
}
|
||||
|
||||
if sfs.protectNTFS && !pathutil.WindowsValidPath(part) {
|
||||
return fmt.Errorf("invalid path: %q", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validWritePath validates paths for mutating operations. It layers the
|
||||
// filesystem-state check validNoLeadingSymlink on top of the string-only
|
||||
// checks in validPath, so a write can neither name a dangerous path nor
|
||||
// reach one by traversing an existing symlink. Every mutating method on
|
||||
// the wrapper funnels through here, so the leading-symlink invariant holds
|
||||
// for all worktree writers without each call site having to remember it.
|
||||
func (sfs *worktreeFilesystem) validWritePath(paths ...string) error {
|
||||
if err := sfs.validPath(paths...); err != nil {
|
||||
return err
|
||||
}
|
||||
return sfs.validNoLeadingSymlink(paths...)
|
||||
}
|
||||
|
||||
// validNoLeadingSymlink rejects paths whose leading directory components
|
||||
// resolve through a symlink that already exists on the underlying
|
||||
// filesystem. validPath guards the path string. This guards the on-disk
|
||||
// state, so a write or delete cannot reach outside the worktree by
|
||||
// traversing a symlink that a tree or an earlier step left in place.
|
||||
//
|
||||
// This is the fail-closed backstop for the whole class. Callers that want
|
||||
// upstream's replace-and-continue behaviour (checkout) remove the blocking
|
||||
// symlink first via clearBlockingSymlinks, so no symlink remains when the
|
||||
// write reaches the wrapper. Callers that do not get a safe error,
|
||||
// matching upstream Git refusing rather than following the link. See
|
||||
// has_symlink_leading_path (symlinks.c) and the check_leading_path guard
|
||||
// in unlink_entry (entry.c).
|
||||
func (sfs *worktreeFilesystem) validNoLeadingSymlink(paths ...string) error {
|
||||
for _, p := range paths {
|
||||
for dir := filepath.Dir(p); dir != "." && dir != "" && dir != string(filepath.Separator); dir = filepath.Dir(dir) {
|
||||
fi, err := sfs.Filesystem.Lstat(dir)
|
||||
if err != nil {
|
||||
// A missing ancestor is materialised as a real directory,
|
||||
// so it cannot be a symlink and is safe to skip. Any other
|
||||
// error (permission, I/O) means we cannot prove the
|
||||
// component is not a symlink, so fail closed rather than
|
||||
// let the operation traverse an unverified component.
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("invalid path %q: cannot stat leading component %q: %w", p, dir, err)
|
||||
}
|
||||
if fi.Mode()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("invalid path %q: leading component %q is a symlink", p, dir)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validSymlinkName checks the per-component name of a symlink for
|
||||
// dotfile names that attackers can use to trick a checkout into
|
||||
// writing a dangerous symlink. Each path component is compared
|
||||
// against .gitmodules case-insensitively, against its NTFS variants
|
||||
// (e.g. ".gitmodules .", ".gitmodules::$INDEX_ALLOCATION", or 8.3
|
||||
// short-name forms) when protectNTFS is on, and against its HFS+
|
||||
// variants (Unicode ignored code points folded into ".gitmodules")
|
||||
// when protectHFS is on.
|
||||
//
|
||||
// Reference: upstream Git verify_path_internal at read-cache.c#L1004-L1024
|
||||
// in tag v2.54.0[1].
|
||||
//
|
||||
// [1]: https://github.com/git/git/blob/v2.54.0/read-cache.c#L1004-L1024
|
||||
func (sfs *worktreeFilesystem) validSymlinkName(name string) error {
|
||||
parts := strings.FieldsFunc(name, func(r rune) bool {
|
||||
return r == '/' || r == '\\'
|
||||
})
|
||||
for _, part := range parts {
|
||||
if strings.EqualFold(part, gitmodulesFile) {
|
||||
return ErrGitModulesSymlink
|
||||
}
|
||||
if sfs.protectNTFS && pathutil.IsNTFSDotGitmodules(part) {
|
||||
return ErrGitModulesSymlink
|
||||
}
|
||||
if sfs.protectHFS && pathutil.IsHFSDotGitmodules(part) {
|
||||
return ErrGitModulesSymlink
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+10
-1
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/go-git/go-billy/v5/util"
|
||||
"github.com/go-git/go-git/v5/internal/pathutil"
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
"github.com/go-git/go-git/v5/plumbing/filemode"
|
||||
"github.com/go-git/go-git/v5/plumbing/format/gitignore"
|
||||
@@ -370,7 +371,7 @@ func (w *Worktree) doAdd(path string, ignorePattern []gitignore.Pattern, skipSta
|
||||
}
|
||||
}
|
||||
|
||||
path = filepath.Clean(path)
|
||||
path = filepath.ToSlash(filepath.Clean(path))
|
||||
|
||||
if err != nil || !fi.IsDir() {
|
||||
added, h, err = w.doAddFile(idx, s, path, ignorePattern)
|
||||
@@ -545,6 +546,14 @@ func (w *Worktree) addOrUpdateFileToIndex(idx *index.Index, filename string, h p
|
||||
}
|
||||
|
||||
func (w *Worktree) doAddFileToIndex(idx *index.Index, filename string, h plumbing.Hash) error {
|
||||
// Mirror upstream's Index.Add gate at the v5 caller boundary: the
|
||||
// index feeds future trees, so a name that the tree-side
|
||||
// pathutil.ValidTreePath gate would reject must not enter the
|
||||
// index in the first place. v5 keeps Index.Add's existing signature
|
||||
// for API compatibility, so the validation happens here.
|
||||
if err := pathutil.ValidTreePath(filename); err != nil {
|
||||
return err
|
||||
}
|
||||
return w.doUpdateFileToIndex(idx.Add(filename), filename, h)
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
//go:build (appengine || js || nacl || tinygo || wasm) && !windows
|
||||
// +build appengine js nacl tinygo wasm
|
||||
//go:build (appengine || js || nacl || tinygo || wasm || wasip1 || wasip2) && !windows
|
||||
// +build appengine js nacl tinygo wasm wasip1 wasip2
|
||||
// +build !windows
|
||||
|
||||
package isatty
|
||||
|
||||
+13
-2
@@ -31,6 +31,10 @@ func init() {
|
||||
if procGetFileInformationByHandleEx.Find() != nil {
|
||||
procGetFileInformationByHandleEx = nil
|
||||
}
|
||||
// Check if NtQueryObject is available.
|
||||
if procNtQueryObject.Find() != nil {
|
||||
procNtQueryObject = nil
|
||||
}
|
||||
}
|
||||
|
||||
// IsTerminal return true if the file descriptor is terminal.
|
||||
@@ -43,6 +47,7 @@ func IsTerminal(fd uintptr) bool {
|
||||
// Check pipe name is used for cygwin/msys2 pty.
|
||||
// Cygwin/MSYS2 PTY has a name like:
|
||||
// \{cygwin,msys}-XXXXXXXXXXXXXXXX-ptyN-{from,to}-master
|
||||
// On Windows 7 a trailing suffix (e.g. "-nat") may be appended.
|
||||
func isCygwinPipeName(name string) bool {
|
||||
token := strings.Split(name, "-")
|
||||
if len(token) < 5 {
|
||||
@@ -72,13 +77,19 @@ func isCygwinPipeName(name string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, t := range token[5:] {
|
||||
if t == "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// getFileNameByHandle use the undocomented ntdll NtQueryObject to get file full name from file handler
|
||||
// getFileNameByHandle use the undocumented ntdll NtQueryObject to get file full name from file handler
|
||||
// since GetFileInformationByHandleEx is not available under windows Vista and still some old fashion
|
||||
// guys are using Windows XP, this is a workaround for those guys, it will also work on system from
|
||||
// Windows vista to 10
|
||||
// Windows Vista to 10
|
||||
// see https://stackoverflow.com/a/18792477 for details
|
||||
func getFileNameByHandle(fd uintptr) (string, error) {
|
||||
if procNtQueryObject == nil {
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM golang:1.24@sha256:14fd8a55e59a560704e5fc44970b301d00d344e45d6b914dda228e09f359a088
|
||||
FROM golang:1.25@sha256:31c1e53dfc1cc2d269deec9c83f58729fa3c53dc9a576f6426109d1e319e9e9a
|
||||
|
||||
ENV GOOS=linux
|
||||
ENV GOARCH=arm
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM golang:1.24@sha256:14fd8a55e59a560704e5fc44970b301d00d344e45d6b914dda228e09f359a088
|
||||
FROM golang:1.25@sha256:31c1e53dfc1cc2d269deec9c83f58729fa3c53dc9a576f6426109d1e319e9e9a
|
||||
|
||||
ENV GOOS=linux
|
||||
ENV GOARCH=arm64
|
||||
|
||||
-5
@@ -12,7 +12,6 @@ package sha1cd
|
||||
// Original: https://github.com/golang/go/blob/master/src/crypto/sha1/sha1.go
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"hash"
|
||||
@@ -20,10 +19,6 @@ import (
|
||||
shared "github.com/pjbgf/sha1cd/internal"
|
||||
)
|
||||
|
||||
func init() {
|
||||
crypto.RegisterHash(crypto.SHA1, New)
|
||||
}
|
||||
|
||||
// The size of a SHA-1 checksum in bytes.
|
||||
const Size = shared.Size
|
||||
|
||||
|
||||
+2
-2
@@ -37,9 +37,9 @@ func block(dig *digest, p []byte) {
|
||||
chunk := p[:shared.Chunk]
|
||||
|
||||
blockAMD64(dig.h[:], chunk, m1[:], cs[:])
|
||||
rectifyCompressionState(m1, &cs)
|
||||
rectifyCompressionState(&m1, &cs)
|
||||
|
||||
col := checkCollision(m1, cs, dig.h)
|
||||
col := checkCollision(&m1, &cs, &dig.h)
|
||||
if col {
|
||||
dig.col = true
|
||||
|
||||
|
||||
+4
-4
@@ -11,11 +11,11 @@
|
||||
// Reference implementations:
|
||||
// - https://github.com/golang/go/blob/master/src/crypto/sha1/sha1block_amd64.s
|
||||
|
||||
// Reverse the dword order in abcd via PSHUFD then store the 16 bytes in one
|
||||
// move, instead of issuing four VPEXTRD's that each go through the store port.
|
||||
#define LOADCS(abcd, e, index, target) \
|
||||
VPEXTRD $3, abcd, ((index*20)+0)(target); \
|
||||
VPEXTRD $2, abcd, ((index*20)+4)(target); \
|
||||
VPEXTRD $1, abcd, ((index*20)+8)(target); \
|
||||
VPEXTRD $0, abcd, ((index*20)+12)(target); \
|
||||
VPSHUFD $0x1B, abcd, X8; \
|
||||
VMOVDQU X8, ((index*20)+0)(target); \
|
||||
MOVL e, ((index*20)+16)(target);
|
||||
|
||||
#define LOADM1(m1, index, target) \
|
||||
|
||||
+2
-2
@@ -34,8 +34,8 @@ func block(dig *digest, p []byte) {
|
||||
|
||||
blockARM64(dig.h[:], chunk, m1[:], cs[:])
|
||||
|
||||
rectifyCompressionState(m1, &cs)
|
||||
col := checkCollision(m1, cs, dig.h)
|
||||
rectifyCompressionState(&m1, &cs)
|
||||
col := checkCollision(&m1, &cs, &dig.h)
|
||||
if col {
|
||||
dig.col = true
|
||||
|
||||
|
||||
+13
-12
@@ -127,7 +127,8 @@ func blockGeneric(dig *digest, p []byte) {
|
||||
}
|
||||
|
||||
if hi == 1 {
|
||||
col := checkCollision(m1, cs, [shared.WordBuffers]uint32{h0, h1, h2, h3, h4})
|
||||
h := [shared.WordBuffers]uint32{h0, h1, h2, h3, h4}
|
||||
col := checkCollision(&m1, &cs, &h)
|
||||
if col {
|
||||
dig.col = true
|
||||
hi++
|
||||
@@ -143,23 +144,23 @@ func blockGeneric(dig *digest, p []byte) {
|
||||
|
||||
//go:noinline
|
||||
func checkCollision(
|
||||
m1 [shared.Rounds]uint32,
|
||||
cs [shared.PreStepState][shared.WordBuffers]uint32,
|
||||
h [shared.WordBuffers]uint32,
|
||||
m1 *[shared.Rounds]uint32,
|
||||
cs *[shared.PreStepState][shared.WordBuffers]uint32,
|
||||
h *[shared.WordBuffers]uint32,
|
||||
) bool {
|
||||
if mask := ubc.CalculateDvMask(m1); mask != 0 {
|
||||
dvs := ubc.SHA1_dvs()
|
||||
|
||||
for i := 0; dvs[i].DvType != 0; i++ {
|
||||
if (mask & ((uint32)(1) << uint32(dvs[i].MaskB))) != 0 {
|
||||
var csState [shared.WordBuffers]uint32
|
||||
var csState *[shared.WordBuffers]uint32
|
||||
switch dvs[i].TestT {
|
||||
case 58:
|
||||
csState = cs[1]
|
||||
csState = &cs[1]
|
||||
case 65:
|
||||
csState = cs[2]
|
||||
csState = &cs[2]
|
||||
case 0:
|
||||
csState = cs[0]
|
||||
csState = &cs[0]
|
||||
default:
|
||||
panic(fmt.Sprintf("dvs data is trying to use a testT that isn't available: %d", dvs[i].TestT))
|
||||
}
|
||||
@@ -168,7 +169,7 @@ func checkCollision(
|
||||
dvs[i].TestT, // testT is the step number
|
||||
// m2 is a secondary message created XORing with
|
||||
// ubc's DM prior to the SHA recompression step.
|
||||
m1, dvs[i].Dm,
|
||||
m1, &dvs[i].Dm,
|
||||
csState,
|
||||
h)
|
||||
|
||||
@@ -182,8 +183,8 @@ func checkCollision(
|
||||
}
|
||||
|
||||
//go:nosplit
|
||||
func hasCollided(step uint32, m1, dm [shared.Rounds]uint32,
|
||||
state [shared.WordBuffers]uint32, h [shared.WordBuffers]uint32) bool {
|
||||
func hasCollided(step uint32, m1, dm *[shared.Rounds]uint32,
|
||||
state *[shared.WordBuffers]uint32, h *[shared.WordBuffers]uint32) bool {
|
||||
// Intermediary Hash Value.
|
||||
ihv := [shared.WordBuffers]uint32{}
|
||||
|
||||
@@ -282,7 +283,7 @@ func hasCollided(step uint32, m1, dm [shared.Rounds]uint32,
|
||||
//
|
||||
//go:nosplit
|
||||
func rectifyCompressionState(
|
||||
m1 [shared.Rounds]uint32,
|
||||
m1 *[shared.Rounds]uint32,
|
||||
cs *[shared.PreStepState][shared.WordBuffers]uint32,
|
||||
) {
|
||||
if cs == nil {
|
||||
|
||||
+4
-1
@@ -29,7 +29,10 @@ type DvInfo struct {
|
||||
// bitconditions for that DV have been met.
|
||||
//
|
||||
//go:nosplit
|
||||
func CalculateDvMask(W [80]uint32) uint32 {
|
||||
func CalculateDvMask(W *[80]uint32) uint32 {
|
||||
if W == nil {
|
||||
return 0
|
||||
}
|
||||
mask := uint32(0xFFFFFFFF)
|
||||
mask &= (((((W[44] ^ W[45]) >> 29) & 1) - 1) | ^(DV_I_48_0_bit | DV_I_51_0_bit | DV_I_52_0_bit | DV_II_45_0_bit | DV_II_46_0_bit | DV_II_50_0_bit | DV_II_51_0_bit))
|
||||
mask &= (((((W[49] ^ W[50]) >> 29) & 1) - 1) | ^(DV_I_46_0_bit | DV_II_45_0_bit | DV_II_50_0_bit | DV_II_51_0_bit | DV_II_55_0_bit | DV_II_56_0_bit))
|
||||
|
||||
+89
-54
@@ -81,6 +81,8 @@ type config struct {
|
||||
colorCodes bool
|
||||
// custom colors to use for colorCodes
|
||||
customColors map[string]string
|
||||
// color to apply to spinner
|
||||
spinnerColorCode string
|
||||
|
||||
// show rate of change in kB/sec or MB/sec
|
||||
showBytes bool
|
||||
@@ -290,6 +292,13 @@ func OptionSetCustomColorCodes(customColors map[string]string) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// OptionSetSpinnerColorCode sets color code for spinner
|
||||
func OptionSetSpinnerColorCode(colorCode string) Option {
|
||||
return func(p *ProgressBar) {
|
||||
p.config.spinnerColorCode = colorCode
|
||||
}
|
||||
}
|
||||
|
||||
// OptionSetElapsedTime will enable elapsed time. Always enabled if OptionSetPredictTime is true.
|
||||
func OptionSetElapsedTime(elapsedTime bool) Option {
|
||||
return func(p *ProgressBar) {
|
||||
@@ -1114,6 +1123,74 @@ func getStringWidth(c config, str string) int {
|
||||
return stringWidth
|
||||
}
|
||||
|
||||
func renderDeterminateProgressBar(c config, s *state, bar, stats, leftBrac, rightBrac string) string {
|
||||
if rightBrac == "" {
|
||||
str := fmt.Sprintf("%4d%% %s %s",
|
||||
s.currentPercent,
|
||||
bar,
|
||||
stats)
|
||||
if (s.currentPercent == 100 && c.showElapsedTimeOnFinish) || c.elapsedTime {
|
||||
str = fmt.Sprintf("%s [%s]", str, leftBrac)
|
||||
}
|
||||
|
||||
if c.showDescriptionAtLineEnd {
|
||||
return fmt.Sprintf("\r%s %s ", str, c.description)
|
||||
}
|
||||
return fmt.Sprintf("\r%s%s ", c.description, str)
|
||||
}
|
||||
|
||||
if s.currentPercent == 100 {
|
||||
str := fmt.Sprintf("%4d%% %s %s",
|
||||
s.currentPercent,
|
||||
bar,
|
||||
stats)
|
||||
|
||||
if c.showElapsedTimeOnFinish {
|
||||
str = fmt.Sprintf("%s [%s]", str, leftBrac)
|
||||
}
|
||||
|
||||
if c.showDescriptionAtLineEnd {
|
||||
return fmt.Sprintf("\r%s %s", str, c.description)
|
||||
}
|
||||
return fmt.Sprintf("\r%s%s", c.description, str)
|
||||
}
|
||||
|
||||
str := fmt.Sprintf("%4d%% %s %s [%s:%s]",
|
||||
s.currentPercent,
|
||||
bar,
|
||||
stats,
|
||||
leftBrac,
|
||||
rightBrac)
|
||||
|
||||
if c.showDescriptionAtLineEnd {
|
||||
return fmt.Sprintf("\r%s %s", str, c.description)
|
||||
}
|
||||
return fmt.Sprintf("\r%s%s", c.description, str)
|
||||
}
|
||||
|
||||
func fitProgressBarWidth(c config, s *state, barStart, barEnd, stats, leftBrac, rightBrac string) int {
|
||||
if c.fullWidth || c.ignoreLength || c.width <= 0 {
|
||||
return c.width
|
||||
}
|
||||
|
||||
terminalWidth, err := termWidth(c.writer)
|
||||
if err != nil || terminalWidth <= 0 {
|
||||
return c.width
|
||||
}
|
||||
|
||||
bar := barStart + strings.Repeat(c.theme.SaucerPadding, c.width) + barEnd
|
||||
lineWidth := getStringWidth(c, renderDeterminateProgressBar(c, s, bar, stats, leftBrac, rightBrac))
|
||||
if lineWidth <= terminalWidth {
|
||||
return c.width
|
||||
}
|
||||
|
||||
fitWidth := c.width - (lineWidth - terminalWidth)
|
||||
if fitWidth < 0 {
|
||||
return 0
|
||||
}
|
||||
return fitWidth
|
||||
}
|
||||
|
||||
func renderProgressBar(c config, s *state) (int, error) {
|
||||
var sb strings.Builder
|
||||
|
||||
@@ -1243,6 +1320,10 @@ func renderProgressBar(c config, s *state) (int, error) {
|
||||
if (s.currentSaucerSize > 0 || s.currentPercent > 0) && c.theme.BarStartFilled != "" {
|
||||
barStart = c.theme.BarStartFilled
|
||||
}
|
||||
c.width = fitProgressBarWidth(c, s, barStart, barEnd, sb.String(), leftBrac, rightBrac)
|
||||
if !c.ignoreLength {
|
||||
s.currentSaucerSize = int(float64(s.currentPercent) / 100.0 * float64(c.width))
|
||||
}
|
||||
if s.currentSaucerSize > 0 {
|
||||
if c.ignoreLength {
|
||||
saucer = strings.Repeat(c.theme.SaucerPadding, s.currentSaucerSize-1)
|
||||
@@ -1294,6 +1375,10 @@ func renderProgressBar(c config, s *state) (int, error) {
|
||||
spinner = selectedSpinner[s.spinnerIdx]
|
||||
s.spinnerIdx = (s.spinnerIdx + 1) % len(selectedSpinner)
|
||||
}
|
||||
// if set add spinner color code
|
||||
if c.spinnerColorCode != "" && c.colorCodes {
|
||||
spinner = "[" + c.spinnerColorCode + "]" + spinner + "[reset]"
|
||||
}
|
||||
if c.elapsedTime {
|
||||
if c.showDescriptionAtLineEnd {
|
||||
str = fmt.Sprintf("\r%s %s [%s] %s ",
|
||||
@@ -1322,61 +1407,11 @@ func renderProgressBar(c config, s *state) (int, error) {
|
||||
}
|
||||
}
|
||||
} else if rightBrac == "" {
|
||||
str = fmt.Sprintf("%4d%% %s%s%s%s%s %s",
|
||||
s.currentPercent,
|
||||
barStart,
|
||||
saucer,
|
||||
saucerHead,
|
||||
strings.Repeat(c.theme.SaucerPadding, repeatAmount),
|
||||
barEnd,
|
||||
sb.String())
|
||||
if (s.currentPercent == 100 && c.showElapsedTimeOnFinish) || c.elapsedTime {
|
||||
str = fmt.Sprintf("%s [%s]", str, leftBrac)
|
||||
}
|
||||
|
||||
if c.showDescriptionAtLineEnd {
|
||||
str = fmt.Sprintf("\r%s %s ", str, c.description)
|
||||
} else {
|
||||
str = fmt.Sprintf("\r%s%s ", c.description, str)
|
||||
}
|
||||
bar := barStart + saucer + saucerHead + strings.Repeat(c.theme.SaucerPadding, repeatAmount) + barEnd
|
||||
str = renderDeterminateProgressBar(c, s, bar, sb.String(), leftBrac, rightBrac)
|
||||
} else {
|
||||
if s.currentPercent == 100 {
|
||||
str = fmt.Sprintf("%4d%% %s%s%s%s%s %s",
|
||||
s.currentPercent,
|
||||
barStart,
|
||||
saucer,
|
||||
saucerHead,
|
||||
strings.Repeat(c.theme.SaucerPadding, repeatAmount),
|
||||
barEnd,
|
||||
sb.String())
|
||||
|
||||
if c.showElapsedTimeOnFinish {
|
||||
str = fmt.Sprintf("%s [%s]", str, leftBrac)
|
||||
}
|
||||
|
||||
if c.showDescriptionAtLineEnd {
|
||||
str = fmt.Sprintf("\r%s %s", str, c.description)
|
||||
} else {
|
||||
str = fmt.Sprintf("\r%s%s", c.description, str)
|
||||
}
|
||||
} else {
|
||||
str = fmt.Sprintf("%4d%% %s%s%s%s%s %s [%s:%s]",
|
||||
s.currentPercent,
|
||||
barStart,
|
||||
saucer,
|
||||
saucerHead,
|
||||
strings.Repeat(c.theme.SaucerPadding, repeatAmount),
|
||||
barEnd,
|
||||
sb.String(),
|
||||
leftBrac,
|
||||
rightBrac)
|
||||
|
||||
if c.showDescriptionAtLineEnd {
|
||||
str = fmt.Sprintf("\r%s %s", str, c.description)
|
||||
} else {
|
||||
str = fmt.Sprintf("\r%s%s", c.description, str)
|
||||
}
|
||||
}
|
||||
bar := barStart + saucer + saucerHead + strings.Repeat(c.theme.SaucerPadding, repeatAmount) + barEnd
|
||||
str = renderDeterminateProgressBar(c, s, bar, sb.String(), leftBrac, rightBrac)
|
||||
}
|
||||
|
||||
if c.colorCodes {
|
||||
|
||||
+5
-11
@@ -925,15 +925,10 @@ func (c *Command) execute(a []string) (err error) {
|
||||
// Also say we need help if the command isn't runnable.
|
||||
helpVal, err := c.Flags().GetBool(helpFlagName)
|
||||
if err != nil {
|
||||
// NOTE(d1): temporarily hardcoding "ayuda" as a replacement for "help"
|
||||
// source of the pain: https://github.com/spf13/cobra/issues/2359
|
||||
helpVal, err = c.Flags().GetBool("ayuda")
|
||||
if err != nil {
|
||||
// should be impossible to get here as we always declare a help
|
||||
// flag in InitDefaultHelpFlag()
|
||||
c.Println("\"help\" flag declared as non-bool. Please correct your code")
|
||||
return err
|
||||
}
|
||||
// should be impossible to get here as we always declare a help
|
||||
// flag in InitDefaultHelpFlag()
|
||||
c.Println("\"help\" flag declared as non-bool. Please correct your code")
|
||||
return err
|
||||
}
|
||||
|
||||
if helpVal {
|
||||
@@ -1231,8 +1226,7 @@ func (c *Command) InitDefaultHelpFlag() {
|
||||
} else {
|
||||
usage += name
|
||||
}
|
||||
// NOTE(d1): do not assume "help" exists in the context of translation
|
||||
// c.Flags().BoolP(helpFlagName, "h", false, usage)
|
||||
c.Flags().BoolP(helpFlagName, "h", false, usage)
|
||||
_ = c.Flags().SetAnnotation(helpFlagName, FlagSetByCobraAnnotation, []string{"true"})
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
codespell==2.4.2
|
||||
codespell==2.4.3
|
||||
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
// Copyright 2025 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build go1.25
|
||||
|
||||
package blake2b
|
||||
|
||||
import "hash"
|
||||
|
||||
var _ hash.XOF = (*xof)(nil)
|
||||
+13
-8
@@ -11,6 +11,7 @@
|
||||
package hkdf
|
||||
|
||||
import (
|
||||
"crypto/hkdf"
|
||||
"crypto/hmac"
|
||||
"errors"
|
||||
"hash"
|
||||
@@ -24,15 +25,19 @@ import (
|
||||
// Expand invocations and different context values. Most common scenarios,
|
||||
// including the generation of multiple keys, should use New instead.
|
||||
func Extract(hash func() hash.Hash, secret, salt []byte) []byte {
|
||||
if salt == nil {
|
||||
salt = make([]byte, hash().Size())
|
||||
// Use the stdlib Extract, which disables FIPS 140 enforcement of the HMAC
|
||||
// key (which in HKDF is the salt). The only possible error is FIPS 140
|
||||
// enforcement of the hash, which had to panic under this API anyway. We
|
||||
// don't use the stdlib Expand, because it switched to returning a []byte
|
||||
// instead of an io.Reader, and Expand uses the HMAC key as a key.
|
||||
out, err := hkdf.Extract(hash, secret, salt)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
extractor := hmac.New(hash, salt)
|
||||
extractor.Write(secret)
|
||||
return extractor.Sum(nil)
|
||||
return out
|
||||
}
|
||||
|
||||
type hkdf struct {
|
||||
type hkdfReader struct {
|
||||
expander hash.Hash
|
||||
size int
|
||||
|
||||
@@ -43,7 +48,7 @@ type hkdf struct {
|
||||
buf []byte
|
||||
}
|
||||
|
||||
func (f *hkdf) Read(p []byte) (int, error) {
|
||||
func (f *hkdfReader) Read(p []byte) (int, error) {
|
||||
// Check whether enough data can be generated
|
||||
need := len(p)
|
||||
remains := len(f.buf) + int(255-f.counter+1)*f.size
|
||||
@@ -84,7 +89,7 @@ func (f *hkdf) Read(p []byte) (int, error) {
|
||||
// 3.3. Most common scenarios will want to use New instead.
|
||||
func Expand(hash func() hash.Hash, pseudorandomKey, info []byte) io.Reader {
|
||||
expander := hmac.New(hash, pseudorandomKey)
|
||||
return &hkdf{expander, expander.Size(), info, 1, nil, nil}
|
||||
return &hkdfReader{expander, expander.Size(), info, 1, nil, nil}
|
||||
}
|
||||
|
||||
// New returns a Reader, from which keys can be read, using the given hash,
|
||||
|
||||
+233
-3
@@ -26,6 +26,7 @@ import (
|
||||
"io"
|
||||
"math/big"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
@@ -307,17 +308,50 @@ func parseKey(in []byte) (out *Key, rest []byte, err error) {
|
||||
}, record.Rest, nil
|
||||
}
|
||||
|
||||
// pipelineMaxInFlight is the maximum number of outstanding requests the
|
||||
// client will pipeline to the agent before applying backpressure.
|
||||
const pipelineMaxInFlight = 32
|
||||
|
||||
// client is a client for an ssh-agent process.
|
||||
//
|
||||
// Exactly one of pipeline / (mu, conn) is set, chosen by NewClient
|
||||
// based on whether the underlying transport implements io.Closer.
|
||||
type client struct {
|
||||
// conn is typically a *net.UnixConn
|
||||
// pipeline, if non-nil, dispatches requests over a pipelined
|
||||
// connection: requests are written as soon as the wire is
|
||||
// available and responses are routed back to per-call reply
|
||||
// channels in FIFO order by a background reader goroutine.
|
||||
pipeline *pipeline
|
||||
|
||||
// mu and conn are used in fully-serialized mode, when the
|
||||
// transport does not implement io.Closer. Each call takes mu,
|
||||
// writes its request, reads the matching response, and releases
|
||||
// mu before returning. There is no background goroutine.
|
||||
mu sync.Mutex
|
||||
conn io.ReadWriter
|
||||
// mu is used to prevent concurrent access to the agent
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewClient returns an Agent that talks to an ssh-agent process over
|
||||
// the given connection.
|
||||
//
|
||||
// If rw also implements io.Closer (like *net.UnixConn and ssh.Channel
|
||||
// do), the returned client pipelines concurrent requests over the
|
||||
// connection: callers can issue Sign and other operations from
|
||||
// multiple goroutines and they will be written to the agent as soon
|
||||
// as the wire is available, rather than waiting for the previous
|
||||
// responses. The ssh-agent protocol still requires responses to be
|
||||
// returned in request order, so a slow request delays subsequent
|
||||
// responses on the same connection (head-of-line blocking).
|
||||
//
|
||||
// Pipelining requires io.Closer because, on a Write error, the
|
||||
// background reader goroutine must be unblocked by closing the
|
||||
// underlying connection. When rw does not implement io.Closer
|
||||
// this is not possible, so NewClient falls back to fully
|
||||
// serializing each request: a single in-flight call at a time.
|
||||
func NewClient(rw io.ReadWriter) ExtendedAgent {
|
||||
if rwc, ok := rw.(io.ReadWriteCloser); ok {
|
||||
return &client{pipeline: newPipeline(rwc)}
|
||||
}
|
||||
return &client{conn: rw}
|
||||
}
|
||||
|
||||
@@ -340,6 +374,16 @@ func (c *client) call(req []byte) (reply interface{}, err error) {
|
||||
// bytes of the response are returned; no unmarshalling is
|
||||
// performed on the response.
|
||||
func (c *client) callRaw(req []byte) (reply []byte, err error) {
|
||||
if c.pipeline != nil {
|
||||
return c.pipeline.call(req)
|
||||
}
|
||||
return c.serialCall(req)
|
||||
}
|
||||
|
||||
// serialCall implements the fully-serialized request/response path
|
||||
// used when the transport is not an io.Closer. It writes req under mu
|
||||
// and reads the matching response before returning.
|
||||
func (c *client) serialCall(req []byte) (reply []byte, err error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
@@ -577,6 +621,9 @@ func (c *client) insertKey(s interface{}, comment string, constraints []byte) er
|
||||
Constraints: constraints,
|
||||
})
|
||||
case ed25519.PrivateKey:
|
||||
if len(k) != ed25519.PrivateKeySize {
|
||||
return fmt.Errorf("agent: bad ED25519 key size: %d", len(k))
|
||||
}
|
||||
req = ssh.Marshal(ed25519KeyMsg{
|
||||
Type: ssh.KeyAlgoED25519,
|
||||
Pub: []byte(k)[32:],
|
||||
@@ -588,6 +635,9 @@ func (c *client) insertKey(s interface{}, comment string, constraints []byte) er
|
||||
// general idiom is to pass ed25519.PrivateKey by value, not by pointer.
|
||||
// We still support the pointer variant for backwards compatibility.
|
||||
case *ed25519.PrivateKey:
|
||||
if len(*k) != ed25519.PrivateKeySize {
|
||||
return fmt.Errorf("agent: bad ED25519 key size: %d", len(*k))
|
||||
}
|
||||
req = ssh.Marshal(ed25519KeyMsg{
|
||||
Type: ssh.KeyAlgoED25519,
|
||||
Pub: []byte(*k)[32:],
|
||||
@@ -663,6 +713,13 @@ func (c *client) Add(key AddedKey) error {
|
||||
constraints = append(constraints, agentConstrainConfirm)
|
||||
}
|
||||
|
||||
for _, ext := range key.ConstraintExtensions {
|
||||
constraints = append(constraints, ssh.Marshal(constrainExtensionAgentMsg{
|
||||
ExtensionName: ext.ExtensionName,
|
||||
ExtensionDetails: ext.ExtensionDetails,
|
||||
})...)
|
||||
}
|
||||
|
||||
cert := key.Certificate
|
||||
if cert == nil {
|
||||
return c.insertKey(key.PrivateKey, key.Comment, constraints)
|
||||
@@ -705,6 +762,9 @@ func (c *client) insertCert(s interface{}, cert *ssh.Certificate, comment string
|
||||
Constraints: constraints,
|
||||
})
|
||||
case ed25519.PrivateKey:
|
||||
if len(k) != ed25519.PrivateKeySize {
|
||||
return fmt.Errorf("agent: bad ED25519 key size: %d", len(k))
|
||||
}
|
||||
req = ssh.Marshal(ed25519CertMsg{
|
||||
Type: cert.Type(),
|
||||
CertBytes: cert.Marshal(),
|
||||
@@ -717,6 +777,9 @@ func (c *client) insertCert(s interface{}, cert *ssh.Certificate, comment string
|
||||
// general idiom is to pass ed25519.PrivateKey by value, not by pointer.
|
||||
// We still support the pointer variant for backwards compatibility.
|
||||
case *ed25519.PrivateKey:
|
||||
if len(*k) != ed25519.PrivateKeySize {
|
||||
return fmt.Errorf("agent: bad ED25519 key size: %d", len(*k))
|
||||
}
|
||||
req = ssh.Marshal(ed25519CertMsg{
|
||||
Type: cert.Type(),
|
||||
CertBytes: cert.Marshal(),
|
||||
@@ -854,3 +917,170 @@ func (c *client) Extension(extensionType string, contents []byte) ([]byte, error
|
||||
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
// pipelineResult carries either a raw agent reply or an error back to a
|
||||
// caller waiting on the response channel.
|
||||
type pipelineResult struct {
|
||||
reply []byte
|
||||
err error
|
||||
}
|
||||
|
||||
// pipeline implements request pipelining over a single agent connection.
|
||||
//
|
||||
// Writers serialize on writeMu to both register a reply channel in the
|
||||
// pending FIFO queue and write the request bytes on the wire; the two
|
||||
// must be atomic so the queue order matches the wire order. A single
|
||||
// reader goroutine decodes responses from the connection and dispatches
|
||||
// each one to the channel at the head of the queue.
|
||||
//
|
||||
// pending is a chan-of-chan acting as a FIFO queue with a fixed
|
||||
// capacity of pipelineMaxInFlight. The outer channel provides ordering
|
||||
// (reads happen in send order) and natural backpressure (a full queue
|
||||
// blocks new writers). Each inner channel is buffered with capacity
|
||||
// one and is sent to exactly once: either by the reader goroutine
|
||||
// with the agent reply, or by shutdown with the terminal error during
|
||||
// drain. The cap-one buffer makes the producer's send non-blocking,
|
||||
// so the reader and shutdown never have to wait for the caller to be
|
||||
// scheduled on the receive.
|
||||
//
|
||||
// When the reader goroutine exits (on read error or protocol
|
||||
// violation), it closes exitCh to wake any writer blocked on the
|
||||
// pending queue, then serializes with any in-flight writer to close
|
||||
// the pending channel, and finally drains the remaining entries
|
||||
// delivering the terminal error to each waiting caller. The
|
||||
// pipeline relies on conn implementing io.Closer so a writer that
|
||||
// hits a Write error can close the connection to unblock the reader
|
||||
// goroutine; NewClient is responsible for only constructing a
|
||||
// pipeline when this guarantee holds.
|
||||
type pipeline struct {
|
||||
conn io.ReadWriteCloser
|
||||
|
||||
writeMu sync.Mutex
|
||||
// pending is the FIFO queue of reply channels with capacity
|
||||
// pipelineMaxInFlight. See type-level documentation.
|
||||
pending chan chan pipelineResult
|
||||
exitCh chan struct{}
|
||||
|
||||
// err carries the terminal error to callers blocked on a closed
|
||||
// pipeline. It is stored exactly once by the reader goroutine
|
||||
// before exitCh is closed; every read happens after observing
|
||||
// exitCh closed, so the load synchronises through the close and
|
||||
// is guaranteed to return the stored value (never nil).
|
||||
err atomic.Pointer[error]
|
||||
}
|
||||
|
||||
func newPipeline(conn io.ReadWriteCloser) *pipeline {
|
||||
p := &pipeline{
|
||||
conn: conn,
|
||||
pending: make(chan chan pipelineResult, pipelineMaxInFlight),
|
||||
exitCh: make(chan struct{}),
|
||||
}
|
||||
go p.readLoop()
|
||||
return p
|
||||
}
|
||||
|
||||
// readLoop decodes responses from conn and dispatches them in FIFO order
|
||||
// to reply channels in pending. On any failure it invokes shutdown.
|
||||
func (p *pipeline) readLoop() {
|
||||
var finalErr error
|
||||
for {
|
||||
var sizeBuf [4]byte
|
||||
if _, err := io.ReadFull(p.conn, sizeBuf[:]); err != nil {
|
||||
finalErr = err
|
||||
break
|
||||
}
|
||||
respSize := binary.BigEndian.Uint32(sizeBuf[:])
|
||||
if respSize > maxAgentResponseBytes {
|
||||
finalErr = errors.New("response too large")
|
||||
break
|
||||
}
|
||||
buf := make([]byte, respSize)
|
||||
if _, err := io.ReadFull(p.conn, buf); err != nil {
|
||||
finalErr = err
|
||||
break
|
||||
}
|
||||
// Successful writes always enqueue before sending bytes, so
|
||||
// pending has a waiting channel for this response.
|
||||
ch := <-p.pending
|
||||
// The reply channel is buffered with capacity 1 and is only
|
||||
// ever written to once, so this send cannot block.
|
||||
ch <- pipelineResult{reply: buf}
|
||||
}
|
||||
p.shutdown(clientErr(finalErr))
|
||||
}
|
||||
|
||||
// shutdown is called exactly once, from readLoop, when the reader is
|
||||
// terminating. It unblocks pending writers and fails all in-flight
|
||||
// requests with finalErr.
|
||||
func (p *pipeline) shutdown(finalErr error) {
|
||||
// Publish the terminal error before closing exitCh so any
|
||||
// writer that subsequently observes exitCh closed sees err.
|
||||
p.err.Store(&finalErr)
|
||||
|
||||
// Wake any writer blocked waiting for a slot in the pending queue.
|
||||
close(p.exitCh)
|
||||
|
||||
// Wait for any writer currently inside its critical section to
|
||||
// complete. After this lock, no new writer can reach the send on
|
||||
// pending: they will observe exitCh closed in the select and bail
|
||||
// out before attempting the send.
|
||||
p.writeMu.Lock()
|
||||
close(p.pending)
|
||||
p.writeMu.Unlock()
|
||||
|
||||
// Drain entries that were enqueued but never answered, delivering
|
||||
// the terminal error to their waiting callers. The reply channels
|
||||
// are buffered (cap 1) and written to exactly once, so these sends
|
||||
// cannot block.
|
||||
for ch := range p.pending {
|
||||
ch <- pipelineResult{err: finalErr}
|
||||
}
|
||||
}
|
||||
|
||||
// call sends req to the agent and returns the matching raw response.
|
||||
func (p *pipeline) call(req []byte) ([]byte, error) {
|
||||
replyCh := make(chan pipelineResult, 1)
|
||||
|
||||
p.writeMu.Lock()
|
||||
|
||||
// Priority check: if the reader has already finished shutdown,
|
||||
// pending is closed and sending to it would panic. Bail out now.
|
||||
// Once we pass this check while holding writeMu, shutdown cannot
|
||||
// complete close(pending) until we release writeMu, so the send
|
||||
// below is safe against concurrent closure.
|
||||
select {
|
||||
case <-p.exitCh:
|
||||
p.writeMu.Unlock()
|
||||
return nil, *p.err.Load()
|
||||
default:
|
||||
}
|
||||
|
||||
// Enqueue the reply channel before writing the request, so FIFO
|
||||
// order on the wire matches FIFO order in the pending queue. The
|
||||
// exitCh arm handles the case where the reader errors while we
|
||||
// block on a full queue.
|
||||
select {
|
||||
case p.pending <- replyCh:
|
||||
case <-p.exitCh:
|
||||
p.writeMu.Unlock()
|
||||
return nil, *p.err.Load()
|
||||
}
|
||||
|
||||
msg := make([]byte, 4+len(req))
|
||||
binary.BigEndian.PutUint32(msg, uint32(len(req)))
|
||||
copy(msg[4:], req)
|
||||
_, werr := p.conn.Write(msg)
|
||||
p.writeMu.Unlock()
|
||||
|
||||
if werr != nil {
|
||||
// The connection is in an undefined state. Close it so the
|
||||
// reader unblocks promptly and triggers shutdown for every
|
||||
// other in-flight caller. NewClient guarantees conn is a
|
||||
// real io.Closer when the pipeline is in use.
|
||||
p.conn.Close()
|
||||
return nil, clientErr(werr)
|
||||
}
|
||||
|
||||
res := <-replyCh
|
||||
return res.reply, res.err
|
||||
}
|
||||
|
||||
+18
-5
@@ -32,8 +32,10 @@ type keyring struct {
|
||||
|
||||
var errLocked = errors.New("agent: locked")
|
||||
|
||||
// NewKeyring returns an Agent that holds keys in memory. It is safe
|
||||
// for concurrent use by multiple goroutines.
|
||||
// NewKeyring returns an Agent that holds keys in memory. It is safe for
|
||||
// concurrent use by multiple goroutines.
|
||||
//
|
||||
// The returned Agent only supports the "lifetime" constraint.
|
||||
func NewKeyring() Agent {
|
||||
return &keyring{}
|
||||
}
|
||||
@@ -143,15 +145,26 @@ func (r *keyring) List() ([]*Key, error) {
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// Insert adds a private key to the keyring. If a certificate
|
||||
// is given, that certificate is added as public key. Note that
|
||||
// any constraints given are ignored.
|
||||
// Add adds a private key to the keyring. If a certificate is given, that
|
||||
// certificate is added as public key.
|
||||
//
|
||||
// Add returns an error if key contains ConstraintExtensions or
|
||||
// ConfirmBeforeUse.
|
||||
func (r *keyring) Add(key AddedKey) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.locked {
|
||||
return errLocked
|
||||
}
|
||||
|
||||
if key.ConfirmBeforeUse {
|
||||
return errors.New("agent: confirm before use constraint is not supported")
|
||||
}
|
||||
|
||||
if len(key.ConstraintExtensions) > 0 {
|
||||
return errors.New("agent: constraint extensions are present but not supported")
|
||||
}
|
||||
|
||||
signer, err := ssh.NewSignerFromKey(key.PrivateKey)
|
||||
|
||||
if err != nil {
|
||||
|
||||
+32
-4
@@ -240,13 +240,35 @@ func setConstraints(key *AddedKey, constraintBytes []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkRSAKeyParams enforces the same bounds as parseRSA in the ssh
|
||||
// package, and additionally caps the prime factors. Without this,
|
||||
// the rsa.PrivateKey built from an Add request would call Precompute()
|
||||
// on arbitrary inputs; the CRT coefficient recomputation is cubic in
|
||||
// |p| and can consume excessive CPU on oversized keys.
|
||||
func checkRSAKeyParams(N, E, P, Q *big.Int) error {
|
||||
if N.BitLen() > 8192 {
|
||||
return errors.New("agent: RSA modulus too large")
|
||||
}
|
||||
if P.BitLen() > 4096 || Q.BitLen() > 4096 {
|
||||
return errors.New("agent: RSA prime too large")
|
||||
}
|
||||
if E.BitLen() > 24 {
|
||||
return errors.New("agent: RSA public exponent too large")
|
||||
}
|
||||
e := E.Int64()
|
||||
if e < 3 || e&1 == 0 {
|
||||
return errors.New("agent: incorrect RSA public exponent")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseRSAKey(req []byte) (*AddedKey, error) {
|
||||
var k rsaKeyMsg
|
||||
if err := ssh.Unmarshal(req, &k); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if k.E.BitLen() > 30 {
|
||||
return nil, errors.New("agent: RSA public exponent too large")
|
||||
if err := checkRSAKeyParams(k.N, k.E, k.P, k.Q); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
priv := &rsa.PrivateKey{
|
||||
PublicKey: rsa.PublicKey{
|
||||
@@ -270,6 +292,9 @@ func parseEd25519Key(req []byte) (*AddedKey, error) {
|
||||
if err := ssh.Unmarshal(req, &k); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(k.Priv) != ed25519.PrivateKeySize {
|
||||
return nil, fmt.Errorf("agent: bad ED25519 key size: %d", len(k.Priv))
|
||||
}
|
||||
priv := ed25519.PrivateKey(k.Priv)
|
||||
|
||||
addedKey := &AddedKey{PrivateKey: &priv, Comment: k.Comments}
|
||||
@@ -336,6 +361,9 @@ func parseEd25519Cert(req []byte) (*AddedKey, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(k.Priv) != ed25519.PrivateKeySize {
|
||||
return nil, fmt.Errorf("agent: bad ED25519 key size: %d", len(k.Priv))
|
||||
}
|
||||
priv := ed25519.PrivateKey(k.Priv)
|
||||
cert, ok := pubKey.(*ssh.Certificate)
|
||||
if !ok {
|
||||
@@ -393,8 +421,8 @@ func parseRSACert(req []byte) (*AddedKey, error) {
|
||||
return nil, fmt.Errorf("agent: Unmarshal failed to parse public key: %v", err)
|
||||
}
|
||||
|
||||
if rsaPub.E.BitLen() > 30 {
|
||||
return nil, errors.New("agent: RSA public exponent too large")
|
||||
if err := checkRSAKeyParams(rsaPub.N, rsaPub.E, k.P, k.Q); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
priv := rsa.PrivateKey{
|
||||
|
||||
+17
-1
@@ -348,6 +348,9 @@ func (c *CertChecker) CheckHostKey(addr string, remote net.Addr, key PublicKey)
|
||||
if cert.CertType != HostCert {
|
||||
return fmt.Errorf("ssh: certificate presented as a host key has type %d", cert.CertType)
|
||||
}
|
||||
if c.IsHostAuthority == nil {
|
||||
return errors.New("ssh: cannot verify certificate, IsHostAuthority not set")
|
||||
}
|
||||
if !c.IsHostAuthority(cert.SignatureKey, addr) {
|
||||
return fmt.Errorf("ssh: no authorities for hostname: %v", addr)
|
||||
}
|
||||
@@ -375,6 +378,9 @@ func (c *CertChecker) Authenticate(conn ConnMetadata, pubKey PublicKey) (*Permis
|
||||
if cert.CertType != UserCert {
|
||||
return nil, fmt.Errorf("ssh: cert has type %d", cert.CertType)
|
||||
}
|
||||
if c.IsUserAuthority == nil {
|
||||
return nil, errors.New("ssh: cannot verify certificate, IsUserAuthority not set")
|
||||
}
|
||||
if !c.IsUserAuthority(cert.SignatureKey) {
|
||||
return nil, fmt.Errorf("ssh: certificate signed by unrecognized authority")
|
||||
}
|
||||
@@ -438,7 +444,17 @@ func (c *CertChecker) CheckCert(principal string, cert *Certificate) error {
|
||||
if before := int64(cert.ValidBefore); cert.ValidBefore != uint64(CertTimeInfinity) && (unixNow >= before || before < 0) {
|
||||
return fmt.Errorf("ssh: cert has expired")
|
||||
}
|
||||
if err := cert.SignatureKey.Verify(cert.bytesForSigning(), cert.Signature); err != nil {
|
||||
// Match OpenSSH: the SK user-presence flag is never enforced on a
|
||||
// certificate's CA signature. OpenSSH calls sshkey_verify with
|
||||
// detailsp==NULL in sshkey.c:cert_parse, so the UP/UV flags are
|
||||
// not even extracted. The UP bit on a CA signature reflects the
|
||||
// CA operator's presence at signing time, which has no bearing on
|
||||
// whether the user being authenticated is present now; enforcing
|
||||
// it here would only break interop with certificates issued by
|
||||
// non-interactive SK CAs. skKeyWithoutUP is a no-op for non-SK
|
||||
// keys (the common case).
|
||||
caKey := skKeyWithoutUP(cert.SignatureKey)
|
||||
if err := caKey.Verify(cert.bytesForSigning(), cert.Signature); err != nil {
|
||||
return fmt.Errorf("ssh: certificate signature does not verify")
|
||||
}
|
||||
|
||||
|
||||
+62
-6
@@ -11,6 +11,7 @@ import (
|
||||
"io"
|
||||
"log"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -131,11 +132,17 @@ func (r RejectionReason) String() string {
|
||||
return fmt.Sprintf("unknown reason %d", int(r))
|
||||
}
|
||||
|
||||
func min(a uint32, b int) uint32 {
|
||||
if a < uint32(b) {
|
||||
return a
|
||||
// minPayloadSize returns min(limit, length) clamped to a uint32. It is used
|
||||
// to compute the size of the next channel data packet from the remaining
|
||||
// payload. The comparison is done in int64 because length is an int — on
|
||||
// 64-bit systems len(data) can exceed 2^32, and a direct uint32(length)
|
||||
// cast would silently truncate to 0 at every multiple of 2^32, causing
|
||||
// WriteExtended's loop to spin without making progress.
|
||||
func minPayloadSize(limit uint32, length int) uint32 {
|
||||
if int64(length) > int64(limit) {
|
||||
return limit
|
||||
}
|
||||
return uint32(b)
|
||||
return uint32(length)
|
||||
}
|
||||
|
||||
type channelDirection uint8
|
||||
@@ -177,6 +184,12 @@ type channel struct {
|
||||
// with WantReply=true outstanding. This lock is held by a
|
||||
// goroutine that has such an outgoing request pending.
|
||||
sentRequestMu sync.Mutex
|
||||
// sentRequestPending is set to true while a SendRequest call with
|
||||
// WantReply=true is in flight. handlePacket uses it as a gate: responses
|
||||
// arriving while no request is pending are dropped to prevent a
|
||||
// misbehaving peer from stalling the mux read loop by filling ch.msg
|
||||
// with unsolicited channelRequestSuccess/Failure messages.
|
||||
sentRequestPending atomic.Bool
|
||||
|
||||
incomingRequests chan *Request
|
||||
|
||||
@@ -251,7 +264,7 @@ func (ch *channel) WriteExtended(data []byte, extendedCode uint32) (n int, err e
|
||||
ch.writeMu.Unlock()
|
||||
|
||||
for len(data) > 0 {
|
||||
space := min(ch.maxRemotePayload, len(data))
|
||||
space := minPayloadSize(ch.maxRemotePayload, len(data))
|
||||
if space, err = ch.remoteWin.reserve(space); err != nil {
|
||||
return n, err
|
||||
}
|
||||
@@ -460,6 +473,18 @@ func (ch *channel) handlePacket(packet []byte) error {
|
||||
}
|
||||
|
||||
ch.incomingRequests <- &req
|
||||
case *channelRequestSuccessMsg, *channelRequestFailureMsg:
|
||||
// Drop responses that arrive when no SendRequest is waiting, to
|
||||
// prevent a malicious peer from filling ch.msg and stalling the
|
||||
// mux read loop. The non-blocking send additionally protects the
|
||||
// loop if a well-behaved caller is slow to read.
|
||||
if !ch.sentRequestPending.Load() {
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case ch.msg <- msg:
|
||||
default:
|
||||
}
|
||||
default:
|
||||
ch.msg <- msg
|
||||
}
|
||||
@@ -530,7 +555,17 @@ func (ch *channel) Reject(reason RejectionReason, message string) error {
|
||||
Language: "en",
|
||||
}
|
||||
ch.decided = true
|
||||
return ch.sendMessage(reject)
|
||||
err := ch.sendMessage(reject)
|
||||
|
||||
// Remove the channel from the mux to prevent memory leaks.
|
||||
// Do not call ch.close() here: no goroutine holds a reference to a
|
||||
// rejected channel's internal channels (msg, incomingRequests), so
|
||||
// removing it from chanList is sufficient for GC. Calling close()
|
||||
// would race with the mux loop goroutine (handlePacket or dropAll),
|
||||
// causing a panic from closing an already-closed channel.
|
||||
ch.mux.chanList.remove(ch.localId)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (ch *channel) Read(data []byte) (int, error) {
|
||||
@@ -586,6 +621,27 @@ func (ch *channel) SendRequest(name string, wantReply bool, payload []byte) (boo
|
||||
if wantReply {
|
||||
ch.sentRequestMu.Lock()
|
||||
defer ch.sentRequestMu.Unlock()
|
||||
|
||||
// Open the gate so that responses arriving while this request is in
|
||||
// flight are allowed to reach ch.msg. Responses arriving while no
|
||||
// request is pending are dropped by handlePacket.
|
||||
ch.sentRequestPending.Store(true)
|
||||
defer ch.sentRequestPending.Store(false)
|
||||
|
||||
// Drain any spurious responses that may have been buffered. This
|
||||
// prevents a previously buffered unexpected response from being
|
||||
// consumed instead of the actual response for this request.
|
||||
drain:
|
||||
for {
|
||||
select {
|
||||
case _, ok := <-ch.msg:
|
||||
if !ok {
|
||||
break drain
|
||||
}
|
||||
default:
|
||||
break drain
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
msg := channelRequestMsg{
|
||||
|
||||
+2
-2
@@ -407,7 +407,7 @@ func (c *gcmCipher) readCipherPacket(seqNum uint32, r io.Reader) ([]byte, error)
|
||||
return nil, fmt.Errorf("ssh: illegal padding %d", padding)
|
||||
}
|
||||
|
||||
if int(padding+1) >= len(plain) {
|
||||
if int(padding)+1 >= len(plain) {
|
||||
return nil, fmt.Errorf("ssh: padding %d too large", padding)
|
||||
}
|
||||
plain = plain[1 : length-uint32(padding)]
|
||||
@@ -586,7 +586,7 @@ func (c *cbcCipher) writeCipherPacket(seqNum uint32, w io.Writer, rand io.Reader
|
||||
|
||||
// Length of encrypted portion of the packet (header, payload, padding).
|
||||
// Enforce minimum padding and packet size.
|
||||
encLength := maxUInt32(prefixLen+len(packet)+cbcMinPaddingSize, cbcMinPaddingSize)
|
||||
encLength := maxUInt32(prefixLen+len(packet)+cbcMinPaddingSize, cbcMinPacketSize)
|
||||
// Enforce block size.
|
||||
encLength = (encLength + effectiveBlockSize - 1) / effectiveBlockSize * effectiveBlockSize
|
||||
|
||||
|
||||
+85
@@ -88,6 +88,32 @@ func NewClientConn(c net.Conn, addr string, config *ClientConfig) (Conn, <-chan
|
||||
return conn, conn.mux.incomingChannels, conn.mux.incomingRequests, nil
|
||||
}
|
||||
|
||||
// NewControlClientConn establishes an SSH connection over an OpenSSH
|
||||
// ControlMaster socket c in proxy mode.
|
||||
//
|
||||
// Note that this package only implements the client side of the multiplexing
|
||||
// protocol. The provided net.Conn must be a local, secure connection (such as a
|
||||
// Unix domain socket) connected to an already-running OpenSSH process acting as
|
||||
// the ControlMaster.
|
||||
//
|
||||
// WARNING: Because proxy mode bypasses the standard cryptographic handshake
|
||||
// passing a standard network connection (e.g., TCP) will result in plaintext
|
||||
// data leakage.
|
||||
//
|
||||
// The Request and NewChannel channels must be serviced or the connection
|
||||
// will hang.
|
||||
func NewControlClientConn(c net.Conn) (Conn, <-chan NewChannel, <-chan *Request, error) {
|
||||
conn := &connection{
|
||||
sshConn: sshConn{conn: c},
|
||||
}
|
||||
var err error
|
||||
if conn.transport, err = handshakeControlProxy(c); err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("ssh: control proxy handshake failed: %w", err)
|
||||
}
|
||||
conn.mux = newMux(conn.transport)
|
||||
return conn, conn.mux.incomingChannels, conn.mux.incomingRequests, nil
|
||||
}
|
||||
|
||||
// clientHandshake performs the client side key exchange. See RFC 4253 Section
|
||||
// 7.
|
||||
func (c *connection) clientHandshake(dialAddress string, config *ClientConfig) error {
|
||||
@@ -197,6 +223,59 @@ type HostKeyCallback func(hostname string, remote net.Addr, key PublicKey) error
|
||||
// the server. A BannerCallback receives the message sent by the remote server.
|
||||
type BannerCallback func(message string) error
|
||||
|
||||
// ClientAuthContext contains information about the current state of the
|
||||
// authentication process, passed to [ClientAuthCallback].
|
||||
type ClientAuthContext struct {
|
||||
// Metadata contains the connection metadata.
|
||||
Metadata ConnMetadata
|
||||
|
||||
// Algorithms contains the negotiated algorithms.
|
||||
Algorithms NegotiatedAlgorithms
|
||||
|
||||
// AllowedMethods lists the authentication methods currently accepted
|
||||
// by the server. These are the protocol-level names defined in RFC 4252
|
||||
// such as "publickey", "password".
|
||||
AllowedMethods []string
|
||||
|
||||
// PartialSuccessMethods lists the authentication methods that have already
|
||||
// succeeded, indicating a multi-step authentication flow. This list
|
||||
// represents the exact sequence of partial successes and may contain
|
||||
// duplicates if the same method succeeded multiple times.
|
||||
PartialSuccessMethods []string
|
||||
|
||||
// TriedMethods lists the methods that have already been attempted and
|
||||
// failed during this session. This list represents the exact sequence of
|
||||
// failures and may contain duplicates. This allows the callback to also
|
||||
// track the number of failed attempts for a specific method.
|
||||
TriedMethods []string
|
||||
}
|
||||
|
||||
// ClientAuthCallback is a hook invoked before each authentication attempt. It
|
||||
// allows the client to dynamically select an authentication method based on the
|
||||
// current context, server capabilities, or previous failures.
|
||||
//
|
||||
// The callback is invoked after the initial "none" authentication method, once
|
||||
// the server's supported authentication methods are known.
|
||||
//
|
||||
// Return values:
|
||||
// - (AuthMethod, nil): The client will attempt this specific method next.
|
||||
// The returned method does NOT need to be present in [ClientConfig.Auth].
|
||||
// This allows for dynamic authentication strategies (e.g., prompting
|
||||
// for a password only if public key auth fails). Callers should inspect
|
||||
// [ClientAuthContext.TriedMethods] to avoid repeatedly returning the
|
||||
// same failing method.
|
||||
// - (nil, nil): The client selects from [ClientConfig.Auth] the first
|
||||
// instance of a method that has not been tried yet, or aborts if none
|
||||
// are left. If authentication is not successful, the callback is invoked
|
||||
// again before the following attempt.
|
||||
// - (nil, error): The authentication process is aborted immediately,
|
||||
// causing the ongoing SSH handshake to fail with the provided error.
|
||||
//
|
||||
// To bound resource use, the client caps the total number of authentication
|
||||
// attempts (failures and partial successes combined) at 64. If the cap is
|
||||
// exceeded the handshake aborts with an error.
|
||||
type ClientAuthCallback func(ctx *ClientAuthContext) (AuthMethod, error)
|
||||
|
||||
// A ClientConfig structure is used to configure a Client. It must not be
|
||||
// modified after having been passed to an SSH function.
|
||||
type ClientConfig struct {
|
||||
@@ -210,6 +289,9 @@ type ClientConfig struct {
|
||||
// Auth contains possible authentication methods to use with the
|
||||
// server. Only the first instance of a particular RFC 4252 method will
|
||||
// be used during authentication.
|
||||
//
|
||||
// If AuthCallback is set, these AuthMethod are only used if the
|
||||
// callback returns nil.
|
||||
Auth []AuthMethod
|
||||
|
||||
// HostKeyCallback is called during the cryptographic
|
||||
@@ -240,6 +322,9 @@ type ClientConfig struct {
|
||||
//
|
||||
// A Timeout of zero means no timeout.
|
||||
Timeout time.Duration
|
||||
|
||||
// AuthCallback, if non-nil, is invoked before each authentication attempt.
|
||||
AuthCallback ClientAuthCallback
|
||||
}
|
||||
|
||||
// InsecureIgnoreHostKey returns a function that can be used for
|
||||
|
||||
+57
-17
@@ -21,6 +21,12 @@ const (
|
||||
authSuccess
|
||||
)
|
||||
|
||||
// maxAuthClientTried bounds the total number of authentication attempts
|
||||
// (failures and partial successes combined) the client makes before
|
||||
// aborting the loop, to prevent unbounded growth when an AuthCallback
|
||||
// keeps supplying methods.
|
||||
const maxAuthClientTried = 64
|
||||
|
||||
// clientAuthenticate authenticates with the remote server. See RFC 4252.
|
||||
func (c *connection) clientAuthenticate(config *ClientConfig) error {
|
||||
// initiate user auth session
|
||||
@@ -67,32 +73,62 @@ func (c *connection) clientAuthenticate(config *ClientConfig) error {
|
||||
// then any untried methods suggested by the server.
|
||||
var tried []string
|
||||
var lastMethods []string
|
||||
var partialSuccess []string
|
||||
|
||||
sessionID := c.transport.getSessionID()
|
||||
for auth := AuthMethod(new(noneAuth)); auth != nil; {
|
||||
ok, methods, err := auth.auth(sessionID, config.User, c.transport, config.Rand, extensions)
|
||||
if err != nil {
|
||||
// On disconnect, return error immediately
|
||||
if _, ok := err.(*disconnectMsg); ok {
|
||||
if _, isDisconnect := err.(*disconnectMsg); isDisconnect {
|
||||
return err
|
||||
}
|
||||
// We return the error later if there is no other method left to
|
||||
// try.
|
||||
// We return the error later if there is no other method
|
||||
// left to try.
|
||||
ok = authFailure
|
||||
}
|
||||
if ok == authSuccess {
|
||||
// success
|
||||
|
||||
switch ok {
|
||||
case authSuccess:
|
||||
return nil
|
||||
} else if ok == authFailure {
|
||||
if m := auth.method(); !slices.Contains(tried, m) {
|
||||
tried = append(tried, m)
|
||||
}
|
||||
case authPartialSuccess:
|
||||
partialSuccess = append(partialSuccess, auth.method())
|
||||
case authFailure:
|
||||
tried = append(tried, auth.method())
|
||||
}
|
||||
if len(partialSuccess)+len(tried) > maxAuthClientTried {
|
||||
return fmt.Errorf("ssh: too many authentication attempts (%d), aborting",
|
||||
len(partialSuccess)+len(tried))
|
||||
}
|
||||
|
||||
if methods == nil {
|
||||
methods = lastMethods
|
||||
}
|
||||
lastMethods = methods
|
||||
|
||||
// If AuthCallback is set it takes precedence: it picks the next
|
||||
// AuthMethod dynamically. The returned method need not be in
|
||||
// config.Auth. If the callback returns (nil, nil) we fall back to
|
||||
// selecting the next untried method from config.Auth below; on
|
||||
// (nil, error) the handshake aborts.
|
||||
if config.AuthCallback != nil {
|
||||
ctx := &ClientAuthContext{
|
||||
Metadata: c,
|
||||
Algorithms: c.Algorithms(),
|
||||
AllowedMethods: slices.Clone(methods),
|
||||
PartialSuccessMethods: slices.Clone(partialSuccess),
|
||||
TriedMethods: slices.Clone(tried),
|
||||
}
|
||||
altAuth, cbErr := config.AuthCallback(ctx)
|
||||
if cbErr != nil {
|
||||
return cbErr
|
||||
}
|
||||
if altAuth != nil {
|
||||
auth = altAuth
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
auth = nil
|
||||
|
||||
findNext:
|
||||
@@ -274,10 +310,14 @@ func pickSignatureAlgorithm(signer Signer, extensions map[string][]byte) (MultiA
|
||||
}
|
||||
|
||||
// Filter algorithms based on those supported by MultiAlgorithmSigner.
|
||||
// Iterate over the signer's algorithms first to preserve its preference order.
|
||||
supportedKeyAlgos := algorithmsForKeyFormat(keyFormat)
|
||||
var keyAlgos []string
|
||||
for _, algo := range algorithmsForKeyFormat(keyFormat) {
|
||||
if slices.Contains(as.Algorithms(), underlyingAlgo(algo)) {
|
||||
keyAlgos = append(keyAlgos, algo)
|
||||
for _, signerAlgo := range as.Algorithms() {
|
||||
if idx := slices.IndexFunc(supportedKeyAlgos, func(algo string) bool {
|
||||
return underlyingAlgo(algo) == signerAlgo
|
||||
}); idx >= 0 {
|
||||
keyAlgos = append(keyAlgos, supportedKeyAlgos[idx])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -373,11 +413,11 @@ func (cb publicKeyCallback) auth(session []byte, user string, c packetConn, rand
|
||||
return authFailure, nil, err
|
||||
}
|
||||
|
||||
// If authentication succeeds or the list of available methods does not
|
||||
// contain the "publickey" method, do not attempt to authenticate with any
|
||||
// other keys. According to RFC 4252 Section 7, the latter can occur when
|
||||
// additional authentication methods are required.
|
||||
if success == authSuccess || !slices.Contains(methods, cb.method()) {
|
||||
// If authentication succeeds or partially succeeds, return immediately
|
||||
// so the caller can select the next auth method. According to RFC 4252
|
||||
// Section 7, if the server no longer lists "publickey" among its
|
||||
// allowed methods, do not attempt to authenticate with any other keys.
|
||||
if success == authSuccess || success == authPartialSuccess || !slices.Contains(methods, cb.method()) {
|
||||
return success, methods, err
|
||||
}
|
||||
}
|
||||
|
||||
+9
-1
@@ -91,9 +91,17 @@ func DiscardRequests(in <-chan *Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// A connTransport represents the transport for a connection.
|
||||
type connTransport interface {
|
||||
packetConn
|
||||
getAlgorithms() NegotiatedAlgorithms
|
||||
getSessionID() []byte
|
||||
waitSession() error
|
||||
}
|
||||
|
||||
// A connection represents an incoming connection.
|
||||
type connection struct {
|
||||
transport *handshakeTransport
|
||||
transport connTransport
|
||||
sshConn
|
||||
|
||||
// The connection protocol.
|
||||
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
// Copyright 2026 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"golang.org/x/crypto/cryptobyte"
|
||||
)
|
||||
|
||||
const (
|
||||
muxProtocolVersion = 4
|
||||
|
||||
muxMsgHello = 0x00000001
|
||||
muxCProxy = 0x1000000f
|
||||
muxSProxy = 0x8000000f
|
||||
)
|
||||
|
||||
const controlProxyRequestID = 0
|
||||
|
||||
// handshakeControlProxy attempts to establish a transport connection with an
|
||||
// OpenSSH ControlMaster socket in proxy mode. For details see:
|
||||
// https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.mux
|
||||
func handshakeControlProxy(rw io.ReadWriteCloser) (connTransport, error) {
|
||||
if err := controlProxyWritePacket(rw, func(b *cryptobyte.Builder) {
|
||||
b.AddUint32(muxMsgHello)
|
||||
b.AddUint32(muxProtocolVersion)
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("mux hello write failed: %w", err)
|
||||
}
|
||||
if err := controlProxyWritePacket(rw, func(b *cryptobyte.Builder) {
|
||||
b.AddUint32(muxCProxy)
|
||||
b.AddUint32(controlProxyRequestID)
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("mux client proxy write failed: %w", err)
|
||||
}
|
||||
|
||||
messageType, body, err := controlProxyReadMessage(rw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mux hello read failed: %w", err)
|
||||
}
|
||||
if messageType != muxMsgHello {
|
||||
return nil, fmt.Errorf("expected hello response, got %v", messageType)
|
||||
}
|
||||
var v uint32
|
||||
if !body.ReadUint32(&v) {
|
||||
return nil, errors.New("EOF reading mux protocol version")
|
||||
}
|
||||
if v != muxProtocolVersion {
|
||||
return nil, fmt.Errorf("mux server has unsupported version %v", v)
|
||||
}
|
||||
messageType, body, err = controlProxyReadMessage(rw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mux server proxy read failed: %w", err)
|
||||
}
|
||||
if messageType != muxSProxy {
|
||||
return nil, fmt.Errorf("expected server proxy response, got %v", messageType)
|
||||
}
|
||||
var reqID uint32
|
||||
if !body.ReadUint32(&reqID) {
|
||||
return nil, errors.New("EOF reading request id")
|
||||
}
|
||||
if reqID != controlProxyRequestID {
|
||||
return nil, fmt.Errorf("expected request id %v, got %v", controlProxyRequestID, reqID)
|
||||
}
|
||||
return &controlProxyTransport{rw}, nil
|
||||
}
|
||||
|
||||
// controlProxyTransport implements the connTransport interface for
|
||||
// ControlMaster connections. Each controlMessage has zero length padding and
|
||||
// no MAC.
|
||||
type controlProxyTransport struct {
|
||||
rw io.ReadWriteCloser
|
||||
}
|
||||
|
||||
func (p *controlProxyTransport) Close() error {
|
||||
return p.rw.Close()
|
||||
}
|
||||
|
||||
func (p *controlProxyTransport) writePacket(controlMessage []byte) error {
|
||||
return controlProxyWritePacket(p.rw, func(b *cryptobyte.Builder) {
|
||||
b.AddUint8(0) // Padding length.
|
||||
b.AddBytes(controlMessage)
|
||||
})
|
||||
}
|
||||
|
||||
func (p *controlProxyTransport) readPacket() ([]byte, error) {
|
||||
buf, err := controlProxyReadPacket(p.rw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ssh: error reading control message: %w", err)
|
||||
}
|
||||
// Discard the padding length.
|
||||
if len(buf) < 1 {
|
||||
return nil, errors.New("ssh: EOF reading padding length")
|
||||
}
|
||||
if buf[0] != 0 {
|
||||
return nil, errors.New("ssh: unexpected non-zero padding in control message")
|
||||
}
|
||||
return buf[1:], nil
|
||||
}
|
||||
|
||||
func (p *controlProxyTransport) getAlgorithms() NegotiatedAlgorithms {
|
||||
return NegotiatedAlgorithms{}
|
||||
}
|
||||
|
||||
func (p *controlProxyTransport) getSessionID() []byte {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *controlProxyTransport) waitSession() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func controlProxyWritePacket(w io.Writer, f cryptobyte.BuilderContinuation) error {
|
||||
var buf []byte
|
||||
b := cryptobyte.NewBuilder(buf)
|
||||
b.AddUint32LengthPrefixed(f)
|
||||
out, err := b.Bytes()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = w.Write(out)
|
||||
return err
|
||||
}
|
||||
|
||||
func controlProxyReadPacket(r io.Reader) (cryptobyte.String, error) {
|
||||
var l uint32
|
||||
if err := binary.Read(r, binary.BigEndian, &l); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if l > maxPacket {
|
||||
return nil, fmt.Errorf("message length %v exceeds maximum %v", l, maxPacket)
|
||||
}
|
||||
buf := make([]byte, l)
|
||||
if _, err := io.ReadFull(r, buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
func controlProxyReadMessage(r io.Reader) (messageType uint32, body cryptobyte.String, err error) {
|
||||
body, err = controlProxyReadPacket(r)
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("error reading message body: %w", err)
|
||||
}
|
||||
if !body.ReadUint32(&messageType) {
|
||||
return 0, nil, errors.New("EOF reading message type")
|
||||
}
|
||||
return messageType, body, nil
|
||||
}
|
||||
+66
-9
@@ -16,6 +16,7 @@ import (
|
||||
"io"
|
||||
"math/big"
|
||||
"slices"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/crypto/curve25519"
|
||||
)
|
||||
@@ -718,15 +719,9 @@ func (gex *dhGEXSHA) Server(c packetConn, randSource io.Reader, magics *handshak
|
||||
kexDHGexRequest.MaxBits, kexDHGexRequest.PreferredBits)
|
||||
}
|
||||
|
||||
var p *big.Int
|
||||
// We hardcode sending Oakley Group 14 (2048 bits), Oakley Group 15 (3072
|
||||
// bits) or Oakley Group 16 (4096 bits), based on the requested max size.
|
||||
if kexDHGexRequest.MaxBits < 3072 {
|
||||
p, _ = new(big.Int).SetString(oakleyGroup14, 16)
|
||||
} else if kexDHGexRequest.MaxBits < 4096 {
|
||||
p, _ = new(big.Int).SetString(oakleyGroup15, 16)
|
||||
} else {
|
||||
p, _ = new(big.Int).SetString(oakleyGroup16, 16)
|
||||
p, err := chooseDH(kexDHGexRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
g := big.NewInt(2)
|
||||
@@ -805,3 +800,65 @@ func (gex *dhGEXSHA) Server(c packetConn, randSource io.Reader, magics *handshak
|
||||
Hash: gex.hashFunc,
|
||||
}, err
|
||||
}
|
||||
|
||||
type dhKEXGroup struct {
|
||||
size int
|
||||
p *big.Int
|
||||
}
|
||||
|
||||
// supportedDHKEXGroups returns the DH groups the server is willing to offer
|
||||
// for diffie-hellman-group-exchange-* key exchanges. The list is built lazily
|
||||
// on first use to keep the hex-to-big.Int parse out of package initialization.
|
||||
var supportedDHKEXGroups = sync.OnceValue(func() []dhKEXGroup {
|
||||
specs := []struct {
|
||||
size int
|
||||
hex string
|
||||
}{
|
||||
{2048, oakleyGroup14},
|
||||
{3072, oakleyGroup15},
|
||||
{4096, oakleyGroup16},
|
||||
}
|
||||
out := make([]dhKEXGroup, 0, len(specs))
|
||||
for _, s := range specs {
|
||||
p, _ := new(big.Int).SetString(s.hex, 16)
|
||||
out = append(out, dhKEXGroup{size: s.size, p: p})
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
// chooseDH picks a DH group for the given client request, mirroring the
|
||||
// algorithm used by OpenSSH's choose_dh in dh.c: prefer the smallest known
|
||||
// group larger than or equal to the client's PreferredBits, and otherwise pick
|
||||
// the largest group within the accepted [MinBits, MaxBits] range.
|
||||
func chooseDH(req kexDHGexRequestMsg) (*big.Int, error) {
|
||||
var best *big.Int
|
||||
bestSize := 0
|
||||
wantBits := int(req.PreferredBits)
|
||||
|
||||
for _, group := range supportedDHKEXGroups() {
|
||||
if uint32(group.size) < req.MinBits || uint32(group.size) > req.MaxBits {
|
||||
continue
|
||||
}
|
||||
|
||||
if bestSize == 0 {
|
||||
best = group.p
|
||||
bestSize = group.size
|
||||
continue
|
||||
}
|
||||
|
||||
closerFromAbove := group.size >= wantBits && group.size < bestSize
|
||||
closerFromBelow := group.size > bestSize && bestSize < wantBits
|
||||
|
||||
if closerFromAbove || closerFromBelow {
|
||||
best = group.p
|
||||
bestSize = group.size
|
||||
}
|
||||
}
|
||||
|
||||
if bestSize == 0 {
|
||||
return nil, fmt.Errorf("ssh: no suitable DH group found for request min: %d, preferred: %d, max: %d",
|
||||
req.MinBits, req.PreferredBits, req.MaxBits)
|
||||
}
|
||||
|
||||
return best, nil
|
||||
}
|
||||
|
||||
+96
-3
@@ -76,7 +76,7 @@ func parsePubKey(in []byte, algo string) (pubKey PublicKey, rest []byte, err err
|
||||
case InsecureKeyAlgoDSA:
|
||||
return parseDSA(in)
|
||||
case KeyAlgoECDSA256, KeyAlgoECDSA384, KeyAlgoECDSA521:
|
||||
return parseECDSA(in)
|
||||
return parseECDSA(in, algo)
|
||||
case KeyAlgoSKECDSA256:
|
||||
return parseSKECDSA(in)
|
||||
case KeyAlgoED25519:
|
||||
@@ -469,6 +469,12 @@ func parseRSA(in []byte) (out PublicKey, rest []byte, err error) {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// 8192 bits is also the maximum RSA key size accepted by crypto/tls for
|
||||
// signature verification:
|
||||
// https://github.com/golang/go/blob/69801b25/src/crypto/tls/handshake_client.go#L1096
|
||||
if w.N.BitLen() > 8192 {
|
||||
return nil, nil, errors.New("ssh: rsa modulus too large")
|
||||
}
|
||||
if w.E.BitLen() > 24 {
|
||||
return nil, nil, errors.New("ssh: exponent too large")
|
||||
}
|
||||
@@ -574,6 +580,24 @@ func checkDSAParams(param *dsa.Parameters) error {
|
||||
return fmt.Errorf("ssh: unsupported DSA key size %d", l)
|
||||
}
|
||||
|
||||
// FIPS 186-2 specifies that Q must be exactly 160 bits. We must enforce
|
||||
// this to prevent DoS attacks where an attacker sends a huge Q which makes
|
||||
// verification slow.
|
||||
if l := param.Q.BitLen(); l != 160 {
|
||||
return fmt.Errorf("ssh: unsupported DSA sub-prime size %d", l)
|
||||
}
|
||||
|
||||
// The generator G is an element of the group, so it must be strictly less
|
||||
// than the modulus P.
|
||||
if param.G.Cmp(param.P) >= 0 {
|
||||
return errors.New("ssh: DSA generator larger than modulus")
|
||||
}
|
||||
|
||||
// G must be positive.
|
||||
if param.G.Sign() <= 0 {
|
||||
return errors.New("ssh: DSA generator must be positive")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -596,6 +620,14 @@ func parseDSA(in []byte) (out PublicKey, rest []byte, err error) {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// The public value Y must be a non-zero element of the group, i.e.
|
||||
// strictly between 0 and P. crypto/dsa.Verify does not range-check Y,
|
||||
// so we reject out-of-range values here to prevent a maliciously
|
||||
// oversized Y from slowing verification.
|
||||
if w.Y.Sign() <= 0 || w.Y.Cmp(w.P) >= 0 {
|
||||
return nil, nil, errors.New("ssh: DSA public value Y out of range")
|
||||
}
|
||||
|
||||
key := &dsaPublicKey{
|
||||
Parameters: param,
|
||||
Y: w.Y,
|
||||
@@ -774,7 +806,7 @@ func supportedEllipticCurve(curve elliptic.Curve) bool {
|
||||
}
|
||||
|
||||
// parseECDSA parses an ECDSA key according to RFC 5656, section 3.1.
|
||||
func parseECDSA(in []byte) (out PublicKey, rest []byte, err error) {
|
||||
func parseECDSA(in []byte, expectedType string) (out PublicKey, rest []byte, err error) {
|
||||
var w struct {
|
||||
Curve string
|
||||
KeyBytes []byte
|
||||
@@ -785,6 +817,12 @@ func parseECDSA(in []byte) (out PublicKey, rest []byte, err error) {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
actualType := "ecdsa-sha2-" + w.Curve
|
||||
if expectedType != actualType {
|
||||
return nil, nil, fmt.Errorf("ssh: algorithm type mismatch: expected %q, found curve %q (type %q)",
|
||||
expectedType, w.Curve, actualType)
|
||||
}
|
||||
|
||||
key := new(ecdsa.PublicKey)
|
||||
|
||||
switch w.Curve {
|
||||
@@ -869,11 +907,25 @@ type skFields struct {
|
||||
Counter uint32
|
||||
}
|
||||
|
||||
// flagUserPresence is the "user present" bit (UP) in the SK signature
|
||||
// flags, matching the FIDO CTAP2 authenticatorData UP flag. See
|
||||
// openssh/PROTOCOL.u2f.
|
||||
const flagUserPresence = 0x01
|
||||
|
||||
// errSKMissingUserPresence is returned by SK key Verify methods when
|
||||
// the signature does not assert user presence and the key was not
|
||||
// marked as no-touch-required.
|
||||
var errSKMissingUserPresence = errors.New("ssh: signature missing required user presence flag")
|
||||
|
||||
type skECDSAPublicKey struct {
|
||||
// application is a URL-like string, typically "ssh:" for SSH.
|
||||
// see openssh/PROTOCOL.u2f for details.
|
||||
application string
|
||||
ecdsa.PublicKey
|
||||
// noTouchRequired, when true, disables the default user-presence
|
||||
// check in Verify. It is set by skKeyWithoutUP on a clone of the
|
||||
// key, never on an instance shared across authentication attempts.
|
||||
noTouchRequired bool
|
||||
}
|
||||
|
||||
func (k *skECDSAPublicKey) Type() string {
|
||||
@@ -959,6 +1011,10 @@ func (k *skECDSAPublicKey) Verify(data []byte, sig *Signature) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if skf.Flags&flagUserPresence == 0 && !k.noTouchRequired {
|
||||
return errSKMissingUserPresence
|
||||
}
|
||||
|
||||
blob := struct {
|
||||
ApplicationDigest []byte `ssh:"rest"`
|
||||
Flags byte
|
||||
@@ -992,6 +1048,10 @@ type skEd25519PublicKey struct {
|
||||
// see openssh/PROTOCOL.u2f for details.
|
||||
application string
|
||||
ed25519.PublicKey
|
||||
// noTouchRequired, when true, disables the default user-presence
|
||||
// check in Verify. It is set by skKeyWithoutUP on a clone of the
|
||||
// key, never on an instance shared across authentication attempts.
|
||||
noTouchRequired bool
|
||||
}
|
||||
|
||||
func (k *skEd25519PublicKey) Type() string {
|
||||
@@ -1066,6 +1126,10 @@ func (k *skEd25519PublicKey) Verify(data []byte, sig *Signature) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if skf.Flags&flagUserPresence == 0 && !k.noTouchRequired {
|
||||
return errSKMissingUserPresence
|
||||
}
|
||||
|
||||
blob := struct {
|
||||
ApplicationDigest []byte `ssh:"rest"`
|
||||
Flags byte
|
||||
@@ -1408,6 +1472,17 @@ func passphraseProtectedOpenSSHKey(passphrase []byte) openSSHDecryptFunc {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// OpenSSH does not impose an upper bound on the bcrypt round count
|
||||
// stored in the key file, but bcrypt_pbkdf cost is linear in rounds:
|
||||
// the default is 16, ssh-keygen lets users pick anything up to
|
||||
// INT_MAX. Cap at 2048 (128x the default, a few seconds of CPU) so
|
||||
// that an oversized value in the file cannot tie up the caller for
|
||||
// months.
|
||||
const maxRounds = 1 << 11
|
||||
if opts.Rounds > maxRounds {
|
||||
return nil, fmt.Errorf("ssh: bcrypt KDF rounds %d exceed maximum %d", opts.Rounds, maxRounds)
|
||||
}
|
||||
|
||||
k, err := bcrypt_pbkdf.Key(passphrase, []byte(opts.Salt), int(opts.Rounds), 32+16)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1577,10 +1652,28 @@ func parseOpenSSHPrivateKey(key []byte, decrypt openSSHDecryptFunc) (crypto.Priv
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Mirror the validation done in parseRSA for public keys: cap the
|
||||
// modulus at the same limit enforced by crypto/tls, reject oversized
|
||||
// or invalid exponents, and additionally bound the prime factors to
|
||||
// avoid the expensive CRT coefficient recomputation in pk.Precompute.
|
||||
if key.N.BitLen() > 8192 {
|
||||
return nil, errors.New("ssh: rsa modulus too large")
|
||||
}
|
||||
if key.P.BitLen() > 4096 || key.Q.BitLen() > 4096 {
|
||||
return nil, errors.New("ssh: rsa prime too large")
|
||||
}
|
||||
if key.E.BitLen() > 24 {
|
||||
return nil, errors.New("ssh: exponent too large")
|
||||
}
|
||||
e := key.E.Int64()
|
||||
if e < 3 || e&1 == 0 {
|
||||
return nil, errors.New("ssh: incorrect exponent")
|
||||
}
|
||||
|
||||
pk := &rsa.PrivateKey{
|
||||
PublicKey: rsa.PublicKey{
|
||||
N: key.N,
|
||||
E: int(key.E.Int64()),
|
||||
E: int(e),
|
||||
},
|
||||
D: key.D,
|
||||
Primes: []*big.Int{key.P, key.Q},
|
||||
|
||||
+27
-6
@@ -160,8 +160,13 @@ func (db *hostKeyDB) IsHostAuthority(remote ssh.PublicKey, address string) bool
|
||||
|
||||
// IsRevoked can be used as a callback in ssh.CertChecker
|
||||
func (db *hostKeyDB) IsRevoked(key *ssh.Certificate) bool {
|
||||
_, ok := db.revoked[string(key.Marshal())]
|
||||
return ok
|
||||
if _, ok := db.revoked[string(key.Marshal())]; ok {
|
||||
return true
|
||||
}
|
||||
if _, ok := db.revoked[string(key.SignatureKey.Marshal())]; ok {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const markerCert = "@cert-authority"
|
||||
@@ -173,7 +178,7 @@ func nextWord(line []byte) (string, []byte) {
|
||||
return string(line), nil
|
||||
}
|
||||
|
||||
return string(line[:i]), bytes.TrimSpace(line[i:])
|
||||
return string(line[:i]), trimSpace(line[i:])
|
||||
}
|
||||
|
||||
func parseLine(line []byte) (marker, host string, key ssh.PublicKey, err error) {
|
||||
@@ -183,12 +188,17 @@ func parseLine(line []byte) (marker, host string, key ssh.PublicKey, err error)
|
||||
}
|
||||
|
||||
host, line = nextWord(line)
|
||||
// If the extracted 'host' starts with '@', it means we either encountered
|
||||
// a second marker (e.g., "@cert-authority @revoked") or an unknown marker
|
||||
// (e.g., "@unknown"). Both are invalid.
|
||||
if len(host) > 0 && host[0] == '@' {
|
||||
return "", "", nil, fmt.Errorf("knownhosts: unexpected marker: %q", host)
|
||||
}
|
||||
if len(line) == 0 {
|
||||
return "", "", nil, errors.New("knownhosts: missing host pattern")
|
||||
}
|
||||
|
||||
// ignore the keytype as it's in the key blob anyway.
|
||||
_, line = nextWord(line)
|
||||
wantType, line := nextWord(line)
|
||||
if len(line) == 0 {
|
||||
return "", "", nil, errors.New("knownhosts: missing key type pattern")
|
||||
}
|
||||
@@ -204,6 +214,10 @@ func parseLine(line []byte) (marker, host string, key ssh.PublicKey, err error)
|
||||
return "", "", nil, err
|
||||
}
|
||||
|
||||
if key.Type() != wantType {
|
||||
return "", "", nil, fmt.Errorf("knownhosts: key type mismatch: found %q, want %q", key.Type(), wantType)
|
||||
}
|
||||
|
||||
return marker, host, key, nil
|
||||
}
|
||||
|
||||
@@ -382,7 +396,7 @@ func (db *hostKeyDB) Read(r io.Reader, filename string) error {
|
||||
for scanner.Scan() {
|
||||
lineNum++
|
||||
line := scanner.Bytes()
|
||||
line = bytes.TrimSpace(line)
|
||||
line = trimSpace(line)
|
||||
if len(line) == 0 || line[0] == '#' {
|
||||
continue
|
||||
}
|
||||
@@ -530,3 +544,10 @@ func newHashedHost(encoded string) (*hashedHost, error) {
|
||||
func (h *hashedHost) match(a addr) bool {
|
||||
return bytes.Equal(hashHost(Normalize(a.String()), h.salt), h.hash)
|
||||
}
|
||||
|
||||
// trimSpace removes leading and trailing ASCII whitespace (space and tab). It
|
||||
// is used instead of bytes.TrimSpace to match OpenSSH behavior, which strictly
|
||||
// parses only ASCII space (0x20) and tab (0x09) as whitespace.
|
||||
func trimSpace(in []byte) []byte {
|
||||
return bytes.Trim(in, " \t")
|
||||
}
|
||||
|
||||
+35
-4
@@ -91,9 +91,10 @@ type mux struct {
|
||||
|
||||
incomingChannels chan NewChannel
|
||||
|
||||
globalSentMu sync.Mutex
|
||||
globalResponses chan interface{}
|
||||
incomingRequests chan *Request
|
||||
globalSentMu sync.Mutex
|
||||
globalSentPending atomic.Bool
|
||||
globalResponses chan interface{}
|
||||
incomingRequests chan *Request
|
||||
|
||||
errCond *sync.Cond
|
||||
err error
|
||||
@@ -141,6 +142,27 @@ func (m *mux) SendRequest(name string, wantReply bool, payload []byte) (bool, []
|
||||
if wantReply {
|
||||
m.globalSentMu.Lock()
|
||||
defer m.globalSentMu.Unlock()
|
||||
|
||||
// Open the gate so that responses arriving while this request is in
|
||||
// flight are allowed to reach globalResponses. Any response arriving
|
||||
// while no request is pending is dropped by handleGlobalPacket.
|
||||
m.globalSentPending.Store(true)
|
||||
defer m.globalSentPending.Store(false)
|
||||
|
||||
// Drain any spurious responses that may have been buffered. This prevents
|
||||
// a previously buffered unexpected response from being consumed instead
|
||||
// of the actual response for this request.
|
||||
drain:
|
||||
for {
|
||||
select {
|
||||
case _, ok := <-m.globalResponses:
|
||||
if !ok {
|
||||
break drain
|
||||
}
|
||||
default:
|
||||
break drain
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := m.sendMessage(globalRequestMsg{
|
||||
@@ -267,7 +289,16 @@ func (m *mux) handleGlobalPacket(packet []byte) error {
|
||||
mux: m,
|
||||
}
|
||||
case *globalRequestSuccessMsg, *globalRequestFailureMsg:
|
||||
m.globalResponses <- msg
|
||||
// Drop responses that arrive when no SendRequest is waiting, to
|
||||
// prevent a malicious peer from staging responses for a future
|
||||
// caller.
|
||||
if !m.globalSentPending.Load() {
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case m.globalResponses <- msg:
|
||||
default:
|
||||
}
|
||||
default:
|
||||
panic(fmt.Sprintf("not a global message %#v", msg))
|
||||
}
|
||||
|
||||
+147
-18
@@ -34,21 +34,29 @@ type Permissions struct {
|
||||
// or not supported.
|
||||
CriticalOptions map[string]string
|
||||
|
||||
// Extensions are extra functionality that the server may
|
||||
// offer on authenticated connections. Lack of support for an
|
||||
// extension does not preclude authenticating a user. Common
|
||||
// extensions are "permit-agent-forwarding",
|
||||
// "permit-X11-forwarding". The Go SSH library currently does
|
||||
// not act on any extension, and it is up to server
|
||||
// implementations to honor them. Extensions can be used to
|
||||
// pass data from the authentication callbacks to the server
|
||||
// application layer.
|
||||
// Extensions are extra functionality that the server may offer on
|
||||
// authenticated connections. Lack of support for an extension does not
|
||||
// preclude authenticating a user. Common extensions are
|
||||
// "permit-agent-forwarding", "permit-X11-forwarding". In general the Go
|
||||
// SSH library does not act on extensions and it is up to server
|
||||
// implementations to honor them; extensions can also be used to pass data
|
||||
// from the authentication callbacks to the server application layer.
|
||||
//
|
||||
// The one extension acted upon by this library is "no-touch-required",
|
||||
// which applies only to security-key public keys
|
||||
// (sk-ecdsa-sha2-nistp256@openssh.com and sk-ssh-ed25519@openssh.com).
|
||||
// When present, it waives the default requirement that SK signatures
|
||||
// assert user presence (i.e. a physical touch of the authenticator)
|
||||
// during signature verification.
|
||||
Extensions map[string]string
|
||||
|
||||
// ExtraData allows to store user defined data.
|
||||
ExtraData map[any]any
|
||||
}
|
||||
|
||||
// GSSAPIWithMICConfig includes the server callbacks for gssapi-with-mic
|
||||
// authentication. If either field is nil, gssapi-with-mic is considered not
|
||||
// configured.
|
||||
type GSSAPIWithMICConfig struct {
|
||||
// AllowLogin, must be set, is called when gssapi-with-mic
|
||||
// authentication is selected (RFC 4462 section 3). The srcName is from the
|
||||
@@ -63,6 +71,10 @@ type GSSAPIWithMICConfig struct {
|
||||
Server GSSAPIServer
|
||||
}
|
||||
|
||||
func gssapiWithMICConfigured(config *GSSAPIWithMICConfig) bool {
|
||||
return config != nil && config.AllowLogin != nil && config.Server != nil
|
||||
}
|
||||
|
||||
// SendAuthBanner implements [ServerPreAuthConn].
|
||||
func (s *connection) SendAuthBanner(msg string) error {
|
||||
return s.transport.writePacket(Marshal(&userAuthBannerMsg{
|
||||
@@ -84,6 +96,79 @@ type ServerPreAuthConn interface {
|
||||
SendAuthBanner(string) error
|
||||
}
|
||||
|
||||
// noTouchRequiredExtension is the extension name used by OpenSSH in
|
||||
// authorized_keys options and certificate extensions to mark keys
|
||||
// whose signatures do not need to assert user presence (touch). See
|
||||
// ssh-keygen(1) and sshd(8).
|
||||
const noTouchRequiredExtension = "no-touch-required"
|
||||
|
||||
// noTouchAllowed reports whether the user presence requirement on
|
||||
// SK signatures should be waived for this authentication attempt. The
|
||||
// requirement is waived when the "no-touch-required" extension is
|
||||
// present either in the Permissions returned by the auth callback
|
||||
// (authorized_keys-level opt-out) or in the certificate's own
|
||||
// Extensions (CA-level opt-out), matching OpenSSH behavior. OpenSSH
|
||||
// reads the per-key opt-out only from cert Extensions and
|
||||
// authorized_keys options (never from CriticalOptions); we follow the
|
||||
// same rule.
|
||||
func noTouchAllowed(pubKey PublicKey, perms *Permissions) bool {
|
||||
if perms != nil {
|
||||
if _, ok := perms.Extensions[noTouchRequiredExtension]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if cert, ok := pubKey.(*Certificate); ok {
|
||||
if _, ok := cert.Extensions[noTouchRequiredExtension]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// skKeyWithoutUP returns a PublicKey equivalent to pubKey but whose
|
||||
// Verify accepts SK signatures with the user-presence flag clear. If
|
||||
// pubKey is not (and does not wrap) an SK key, pubKey is returned
|
||||
// unchanged. The returned value never mutates pubKey: for SK keys a
|
||||
// shallow copy is made so that the noTouchRequired flag is set only on
|
||||
// the clone.
|
||||
//
|
||||
// The implementation is iterative rather than recursive. When pubKey
|
||||
// is a *Certificate we unwrap exactly one level to look at the inner
|
||||
// key. The SSH cert format forbids Certificate.Key from being another
|
||||
// Certificate (parseCert rejects it), but nothing stops callers from
|
||||
// constructing such a value directly in Go; a recursive descent could
|
||||
// otherwise be driven to unbounded depth by a hand-crafted or cyclic
|
||||
// Certificate. A malformed input of that shape simply returns
|
||||
// unchanged here.
|
||||
func skKeyWithoutUP(pubKey PublicKey) PublicKey {
|
||||
cert, isCert := pubKey.(*Certificate)
|
||||
target := pubKey
|
||||
if isCert {
|
||||
target = cert.Key
|
||||
}
|
||||
var cloned PublicKey
|
||||
switch k := target.(type) {
|
||||
case *skECDSAPublicKey:
|
||||
c := *k
|
||||
c.noTouchRequired = true
|
||||
cloned = &c
|
||||
case *skEd25519PublicKey:
|
||||
c := *k
|
||||
c.noTouchRequired = true
|
||||
cloned = &c
|
||||
default:
|
||||
// Not an SK key (or a pathological *Certificate wrapping
|
||||
// another *Certificate): pubKey is already usable for Verify.
|
||||
return pubKey
|
||||
}
|
||||
if !isCert {
|
||||
return cloned
|
||||
}
|
||||
c := *cert
|
||||
c.Key = cloned
|
||||
return &c
|
||||
}
|
||||
|
||||
// ServerConfig holds server specific configuration data.
|
||||
type ServerConfig struct {
|
||||
// Config contains configuration shared between client and server.
|
||||
@@ -242,8 +327,10 @@ func (c *pubKeyCache) add(candidate cachedPubKey) {
|
||||
type ServerConn struct {
|
||||
Conn
|
||||
|
||||
// If the succeeding authentication callback returned a
|
||||
// non-nil Permissions pointer, it is stored here.
|
||||
// If the succeeding authentication callback returned a non-nil Permissions
|
||||
// pointer, it is stored here. These are the permissions from the final,
|
||||
// successful authentication method. Permissions returned by callbacks that
|
||||
// return PartialSuccessError are not preserved and must be nil.
|
||||
Permissions *Permissions
|
||||
}
|
||||
|
||||
@@ -302,8 +389,7 @@ func (s *connection) serverHandshake(config *ServerConfig) (*Permissions, error)
|
||||
}
|
||||
|
||||
if !config.NoClientAuth && config.PasswordCallback == nil && config.PublicKeyCallback == nil &&
|
||||
config.KeyboardInteractiveCallback == nil && (config.GSSAPIWithMICConfig == nil ||
|
||||
config.GSSAPIWithMICConfig.AllowLogin == nil || config.GSSAPIWithMICConfig.Server == nil) {
|
||||
config.KeyboardInteractiveCallback == nil && !gssapiWithMICConfigured(config.GSSAPIWithMICConfig) {
|
||||
return nil, errors.New("ssh: no authentication methods configured but NoClientAuth is also false")
|
||||
}
|
||||
|
||||
@@ -527,6 +613,15 @@ func (b *BannerError) Error() string {
|
||||
return b.Err.Error()
|
||||
}
|
||||
|
||||
// maxAuthServerAttempts caps the total number of SSH_MSG_USERAUTH_REQUEST
|
||||
// messages the server will process on a single connection, regardless of
|
||||
// outcome (failure, partial success, public key query, or none). It is a
|
||||
// backstop against clients that drive the authentication loop indefinitely
|
||||
// without ever incurring a real failure — for example by repeatedly
|
||||
// triggering PartialSuccessError or by spamming public key offer queries —
|
||||
// neither of which increment the MaxAuthTries failure counter.
|
||||
const maxAuthServerAttempts = 128
|
||||
|
||||
func (s *connection) serverAuthenticate(config *ServerConfig) (*Permissions, error) {
|
||||
if config.PreAuthConnCallback != nil {
|
||||
config.PreAuthConnCallback(s)
|
||||
@@ -537,6 +632,7 @@ func (s *connection) serverAuthenticate(config *ServerConfig) (*Permissions, err
|
||||
var perms *Permissions
|
||||
|
||||
authFailures := 0
|
||||
authAttempts := 0
|
||||
noneAuthCount := 0
|
||||
var authErrs []error
|
||||
var calledBannerCallback bool
|
||||
@@ -565,6 +661,19 @@ userAuthLoop:
|
||||
return nil, &ServerAuthError{Errors: authErrs}
|
||||
}
|
||||
|
||||
if authAttempts >= maxAuthServerAttempts {
|
||||
discMsg := &disconnectMsg{
|
||||
Reason: 2,
|
||||
Message: "too many authentication attempts",
|
||||
}
|
||||
if err := s.transport.writePacket(Marshal(discMsg)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
authErrs = append(authErrs, discMsg)
|
||||
return nil, &ServerAuthError{Errors: authErrs}
|
||||
}
|
||||
authAttempts++
|
||||
|
||||
var userAuthReq userAuthRequestMsg
|
||||
if packet, err := s.transport.readPacket(); err != nil {
|
||||
if err == io.EOF {
|
||||
@@ -737,8 +846,15 @@ userAuthLoop:
|
||||
}
|
||||
|
||||
signedData := buildDataSignedForAuth(sessionID, userAuthReq, algo, pubKeyData)
|
||||
|
||||
if err := pubKey.Verify(signedData, sig); err != nil {
|
||||
// pubKey is reused below for VerifiedPublicKeyCallback and
|
||||
// must remain the key as presented by the client; derive a
|
||||
// separate value for Verify that carries any applicable
|
||||
// no-touch-required opt-out.
|
||||
pubKeyForVerify := pubKey
|
||||
if noTouchAllowed(pubKey, candidate.perms) {
|
||||
pubKeyForVerify = skKeyWithoutUP(pubKey)
|
||||
}
|
||||
if err := pubKeyForVerify.Verify(signedData, sig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -750,9 +866,16 @@ userAuthLoop:
|
||||
// considered verified and the callback must not run.
|
||||
perms, authErr = config.VerifiedPublicKeyCallback(s, pubKey, perms, algo)
|
||||
}
|
||||
if authErr == nil && perms != nil && perms.CriticalOptions != nil {
|
||||
if saco := perms.CriticalOptions[sourceAddressCriticalOption]; saco != "" {
|
||||
if err := checkSourceAddress(s.RemoteAddr(), saco); err != nil {
|
||||
authErr = err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
case "gssapi-with-mic":
|
||||
if authConfig.GSSAPIWithMICConfig == nil {
|
||||
if !gssapiWithMICConfigured(authConfig.GSSAPIWithMICConfig) {
|
||||
authErr = errors.New("ssh: gssapi-with-mic auth not configured")
|
||||
break
|
||||
}
|
||||
@@ -824,6 +947,13 @@ userAuthLoop:
|
||||
var failureMsg userAuthFailureMsg
|
||||
|
||||
if partialSuccess, ok := authErr.(*PartialSuccessError); ok {
|
||||
// Permissions are not preserved between authentication steps. To
|
||||
// avoid confusion about the final state of the connection, we
|
||||
// disallow returning non-nil Permissions combined with
|
||||
// PartialSuccessError.
|
||||
if perms != nil {
|
||||
return nil, errors.New("ssh: permissions must be nil when returning PartialSuccessError")
|
||||
}
|
||||
// After a partial success error we don't allow changing the user
|
||||
// name and execute the NoClientAuthCallback.
|
||||
partialSuccessReturned = true
|
||||
@@ -878,8 +1008,7 @@ userAuthLoop:
|
||||
if authConfig.KeyboardInteractiveCallback != nil {
|
||||
failureMsg.Methods = append(failureMsg.Methods, "keyboard-interactive")
|
||||
}
|
||||
if authConfig.GSSAPIWithMICConfig != nil && authConfig.GSSAPIWithMICConfig.Server != nil &&
|
||||
authConfig.GSSAPIWithMICConfig.AllowLogin != nil {
|
||||
if gssapiWithMICConfigured(authConfig.GSSAPIWithMICConfig) {
|
||||
failureMsg.Methods = append(failureMsg.Methods, "gssapi-with-mic")
|
||||
}
|
||||
|
||||
|
||||
+3
@@ -423,6 +423,9 @@ func (s *Session) wait(reqs <-chan *Request) error {
|
||||
for msg := range reqs {
|
||||
switch msg.Type {
|
||||
case "exit-status":
|
||||
if len(msg.Payload) < 4 {
|
||||
return errors.New("ssh: malformed exit-status request")
|
||||
}
|
||||
wm.status = int(binary.BigEndian.Uint32(msg.Payload))
|
||||
case "exit-signal":
|
||||
var sigval struct {
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
This package (golang.org/x/net/http2) is the original source of truth
|
||||
of the Go HTTP/2 implementation.
|
||||
|
||||
As of Go 1.27, the source of truth has moved to the standard library
|
||||
package net/http/internal/http2.
|
||||
All new feature development should happen in that package.
|
||||
Only critical bug fixes and security fixes will be backported to x/net.
|
||||
|
||||
The x/net package contains two implementations of the HTTP/2 transport and server:
|
||||
|
||||
The original implementation (no longer the source of truth).
|
||||
|
||||
A reimplementation of the x/net/http2 APIs in terms of net/http.
|
||||
This is called "the wrapping implementation", since it wraps net/http.
|
||||
|
||||
The original implementation is used when the Go version is less than 1.27.
|
||||
|
||||
The wrapping implementation is used when the Go version is at least 1.27.
|
||||
The build tag "http2legacy" may be set to use the original implementation.
|
||||
+2
-12
@@ -2,6 +2,8 @@
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build !(go1.27 && !http2legacy)
|
||||
|
||||
// Transport code's client connection pooling.
|
||||
|
||||
package http2
|
||||
@@ -14,18 +16,6 @@ import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ClientConnPool manages a pool of HTTP/2 client connections.
|
||||
type ClientConnPool interface {
|
||||
// GetClientConn returns a specific HTTP/2 connection (usually
|
||||
// a TLS-TCP connection) to an HTTP/2 server. On success, the
|
||||
// returned ClientConn accounts for the upcoming RoundTrip
|
||||
// call, so the caller should not omit it. If the caller needs
|
||||
// to, ClientConn.RoundTrip can be called with a bogus
|
||||
// new(http.Request) to release the stream reservation.
|
||||
GetClientConn(req *http.Request, addr string) (*ClientConn, error)
|
||||
MarkDead(*ClientConn)
|
||||
}
|
||||
|
||||
// clientConnPoolIdleCloser is the interface implemented by ClientConnPool
|
||||
// implementations which can close their idle connections.
|
||||
type clientConnPoolIdleCloser interface {
|
||||
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
// Copyright 2026 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package http2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func (cc *ClientConn) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return cc.roundTrip(req)
|
||||
}
|
||||
|
||||
// SetDoNotReuse marks cc as not reusable for future HTTP requests.
|
||||
func (cc *ClientConn) SetDoNotReuse() {
|
||||
cc.setDoNotReuse()
|
||||
}
|
||||
|
||||
// CanTakeNewRequest reports whether the connection can take a new request,
|
||||
// meaning it has not been closed or received or sent a GOAWAY.
|
||||
//
|
||||
// If the caller is going to immediately make a new request on this
|
||||
// connection, use ReserveNewRequest instead.
|
||||
func (cc *ClientConn) CanTakeNewRequest() bool {
|
||||
return cc.canTakeNewRequest()
|
||||
}
|
||||
|
||||
// ReserveNewRequest is like CanTakeNewRequest but also reserves a
|
||||
// concurrent stream in cc. The reservation is decremented on the
|
||||
// next call to RoundTrip.
|
||||
func (cc *ClientConn) ReserveNewRequest() bool {
|
||||
return cc.reserveNewRequest()
|
||||
}
|
||||
|
||||
// State returns a snapshot of cc's state.
|
||||
func (cc *ClientConn) State() ClientConnState {
|
||||
return cc.state()
|
||||
}
|
||||
|
||||
// Shutdown gracefully closes the client connection, waiting for running streams to complete.
|
||||
func (cc *ClientConn) Shutdown(ctx context.Context) error {
|
||||
return cc.shutdown(ctx)
|
||||
}
|
||||
|
||||
// Close closes the client connection immediately.
|
||||
//
|
||||
// In-flight requests are interrupted. For a graceful shutdown, use Shutdown instead.
|
||||
func (cc *ClientConn) Close() error {
|
||||
return cc.close()
|
||||
}
|
||||
|
||||
// Ping sends a PING frame to the server and waits for the ack.
|
||||
func (cc *ClientConn) Ping(ctx context.Context) error {
|
||||
return cc.ping(ctx)
|
||||
}
|
||||
+2
@@ -2,6 +2,8 @@
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build !(go1.27 && !http2legacy)
|
||||
|
||||
package http2
|
||||
|
||||
import (
|
||||
|
||||
+1
-1
@@ -195,7 +195,7 @@ func (s SettingID) String() string {
|
||||
}
|
||||
|
||||
// validWireHeaderFieldName reports whether v is a valid header field
|
||||
// name (key). See httpguts.ValidHeaderName for the base rules.
|
||||
// name (key). See httpguts.ValidHeaderFieldName for the base rules.
|
||||
//
|
||||
// Further, http2 says:
|
||||
//
|
||||
|
||||
+9
-205
@@ -2,6 +2,8 @@
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build !(go1.27 && !http2legacy)
|
||||
|
||||
// TODO: turn off the serve goroutine when idle, so
|
||||
// an idle conn only has the readFrames goroutine active. (which could
|
||||
// also be optimized probably to pin less memory in crypto/tls). This
|
||||
@@ -88,98 +90,6 @@ var (
|
||||
testHookOnPanic func(sc *serverConn, panicVal interface{}) (rePanic bool)
|
||||
)
|
||||
|
||||
// Server is an HTTP/2 server.
|
||||
type Server struct {
|
||||
// MaxHandlers limits the number of http.Handler ServeHTTP goroutines
|
||||
// which may run at a time over all connections.
|
||||
// Negative or zero no limit.
|
||||
// TODO: implement
|
||||
MaxHandlers int
|
||||
|
||||
// MaxConcurrentStreams optionally specifies the number of
|
||||
// concurrent streams that each client may have open at a
|
||||
// time. This is unrelated to the number of http.Handler goroutines
|
||||
// which may be active globally, which is MaxHandlers.
|
||||
// If zero, MaxConcurrentStreams defaults to at least 100, per
|
||||
// the HTTP/2 spec's recommendations.
|
||||
MaxConcurrentStreams uint32
|
||||
|
||||
// MaxDecoderHeaderTableSize optionally specifies the http2
|
||||
// SETTINGS_HEADER_TABLE_SIZE to send in the initial settings frame. It
|
||||
// informs the remote endpoint of the maximum size of the header compression
|
||||
// table used to decode header blocks, in octets. If zero, the default value
|
||||
// of 4096 is used.
|
||||
MaxDecoderHeaderTableSize uint32
|
||||
|
||||
// MaxEncoderHeaderTableSize optionally specifies an upper limit for the
|
||||
// header compression table used for encoding request headers. Received
|
||||
// SETTINGS_HEADER_TABLE_SIZE settings are capped at this limit. If zero,
|
||||
// the default value of 4096 is used.
|
||||
MaxEncoderHeaderTableSize uint32
|
||||
|
||||
// MaxReadFrameSize optionally specifies the largest frame
|
||||
// this server is willing to read. A valid value is between
|
||||
// 16k and 16M, inclusive. If zero or otherwise invalid, a
|
||||
// default value is used.
|
||||
MaxReadFrameSize uint32
|
||||
|
||||
// PermitProhibitedCipherSuites, if true, permits the use of
|
||||
// cipher suites prohibited by the HTTP/2 spec.
|
||||
PermitProhibitedCipherSuites bool
|
||||
|
||||
// IdleTimeout specifies how long until idle clients should be
|
||||
// closed with a GOAWAY frame. PING frames are not considered
|
||||
// activity for the purposes of IdleTimeout.
|
||||
// If zero or negative, there is no timeout.
|
||||
IdleTimeout time.Duration
|
||||
|
||||
// ReadIdleTimeout is the timeout after which a health check using a ping
|
||||
// frame will be carried out if no frame is received on the connection.
|
||||
// If zero, no health check is performed.
|
||||
ReadIdleTimeout time.Duration
|
||||
|
||||
// PingTimeout is the timeout after which the connection will be closed
|
||||
// if a response to a ping is not received.
|
||||
// If zero, a default of 15 seconds is used.
|
||||
PingTimeout time.Duration
|
||||
|
||||
// WriteByteTimeout is the timeout after which a connection will be
|
||||
// closed if no data can be written to it. The timeout begins when data is
|
||||
// available to write, and is extended whenever any bytes are written.
|
||||
// If zero or negative, there is no timeout.
|
||||
WriteByteTimeout time.Duration
|
||||
|
||||
// MaxUploadBufferPerConnection is the size of the initial flow
|
||||
// control window for each connections. The HTTP/2 spec does not
|
||||
// allow this to be smaller than 65535 or larger than 2^32-1.
|
||||
// If the value is outside this range, a default value will be
|
||||
// used instead.
|
||||
MaxUploadBufferPerConnection int32
|
||||
|
||||
// MaxUploadBufferPerStream is the size of the initial flow control
|
||||
// window for each stream. The HTTP/2 spec does not allow this to
|
||||
// be larger than 2^32-1. If the value is zero or larger than the
|
||||
// maximum, a default value will be used instead.
|
||||
MaxUploadBufferPerStream int32
|
||||
|
||||
// NewWriteScheduler constructs a write scheduler for a connection.
|
||||
// If nil, a default scheduler is chosen.
|
||||
//
|
||||
// Deprecated: User-provided write schedulers are deprecated.
|
||||
NewWriteScheduler func() WriteScheduler
|
||||
|
||||
// CountError, if non-nil, is called on HTTP/2 server errors.
|
||||
// It's intended to increment a metric for monitoring, such
|
||||
// as an expvar or Prometheus metric.
|
||||
// The errType consists of only ASCII word characters.
|
||||
CountError func(errType string)
|
||||
|
||||
// Internal state. This is a pointer (rather than embedded directly)
|
||||
// so that we don't embed a Mutex in this struct, which will make the
|
||||
// struct non-copyable, which might break some callers.
|
||||
state *serverInternalState
|
||||
}
|
||||
|
||||
type serverInternalState struct {
|
||||
mu sync.Mutex
|
||||
activeConns map[*serverConn]struct{}
|
||||
@@ -187,6 +97,9 @@ type serverInternalState struct {
|
||||
// Pool of error channels. This is per-Server rather than global
|
||||
// because channels can't be reused across synctest bubbles.
|
||||
errChanPool sync.Pool
|
||||
|
||||
// Used in tests.
|
||||
testNewConn func(*serverConn)
|
||||
}
|
||||
|
||||
func (s *serverInternalState) registerConn(sc *serverConn) {
|
||||
@@ -239,12 +152,7 @@ func (s *serverInternalState) putErrChan(ch chan error) {
|
||||
s.errChanPool.Put(ch)
|
||||
}
|
||||
|
||||
// ConfigureServer adds HTTP/2 support to a net/http Server.
|
||||
//
|
||||
// The configuration conf may be nil.
|
||||
//
|
||||
// ConfigureServer must be called before s begins serving.
|
||||
func ConfigureServer(s *http.Server, conf *Server) error {
|
||||
func configureServer(s *http.Server, conf *Server) error {
|
||||
if s == nil {
|
||||
panic("nil *http.Server")
|
||||
}
|
||||
@@ -349,83 +257,6 @@ func ConfigureServer(s *http.Server, conf *Server) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ServeConnOpts are options for the Server.ServeConn method.
|
||||
type ServeConnOpts struct {
|
||||
// Context is the base context to use.
|
||||
// If nil, context.Background is used.
|
||||
Context context.Context
|
||||
|
||||
// BaseConfig optionally sets the base configuration
|
||||
// for values. If nil, defaults are used.
|
||||
BaseConfig *http.Server
|
||||
|
||||
// Handler specifies which handler to use for processing
|
||||
// requests. If nil, BaseConfig.Handler is used. If BaseConfig
|
||||
// or BaseConfig.Handler is nil, http.DefaultServeMux is used.
|
||||
Handler http.Handler
|
||||
|
||||
// UpgradeRequest is an initial request received on a connection
|
||||
// undergoing an h2c upgrade. The request body must have been
|
||||
// completely read from the connection before calling ServeConn,
|
||||
// and the 101 Switching Protocols response written.
|
||||
UpgradeRequest *http.Request
|
||||
|
||||
// Settings is the decoded contents of the HTTP2-Settings header
|
||||
// in an h2c upgrade request.
|
||||
Settings []byte
|
||||
|
||||
// SawClientPreface is set if the HTTP/2 connection preface
|
||||
// has already been read from the connection.
|
||||
SawClientPreface bool
|
||||
}
|
||||
|
||||
func (o *ServeConnOpts) context() context.Context {
|
||||
if o != nil && o.Context != nil {
|
||||
return o.Context
|
||||
}
|
||||
return context.Background()
|
||||
}
|
||||
|
||||
func (o *ServeConnOpts) baseConfig() *http.Server {
|
||||
if o != nil && o.BaseConfig != nil {
|
||||
return o.BaseConfig
|
||||
}
|
||||
return new(http.Server)
|
||||
}
|
||||
|
||||
func (o *ServeConnOpts) handler() http.Handler {
|
||||
if o != nil {
|
||||
if o.Handler != nil {
|
||||
return o.Handler
|
||||
}
|
||||
if o.BaseConfig != nil && o.BaseConfig.Handler != nil {
|
||||
return o.BaseConfig.Handler
|
||||
}
|
||||
}
|
||||
return http.DefaultServeMux
|
||||
}
|
||||
|
||||
// ServeConn serves HTTP/2 requests on the provided connection and
|
||||
// blocks until the connection is no longer readable.
|
||||
//
|
||||
// ServeConn starts speaking HTTP/2 assuming that c has not had any
|
||||
// reads or writes. It writes its initial settings frame and expects
|
||||
// to be able to read the preface and settings frame from the
|
||||
// client. If c has a ConnectionState method like a *tls.Conn, the
|
||||
// ConnectionState is used to verify the TLS ciphersuite and to set
|
||||
// the Request.TLS field in Handlers.
|
||||
//
|
||||
// ServeConn does not support h2c by itself. Any h2c support must be
|
||||
// implemented in terms of providing a suitably-behaving net.Conn.
|
||||
//
|
||||
// The opts parameter is optional. If nil, default values are used.
|
||||
func (s *Server) ServeConn(c net.Conn, opts *ServeConnOpts) {
|
||||
if opts == nil {
|
||||
opts = &ServeConnOpts{}
|
||||
}
|
||||
s.serveConn(c, opts, nil)
|
||||
}
|
||||
|
||||
func (s *Server) serveConn(c net.Conn, opts *ServeConnOpts, newf func(*serverConn)) {
|
||||
baseCtx, cancel := serverConnBaseContext(c, opts)
|
||||
defer cancel()
|
||||
@@ -461,6 +292,9 @@ func (s *Server) serveConn(c net.Conn, opts *ServeConnOpts, newf func(*serverCon
|
||||
if newf != nil {
|
||||
newf(sc)
|
||||
}
|
||||
if s.state != nil && s.state.testNewConn != nil {
|
||||
s.state.testNewConn(sc)
|
||||
}
|
||||
|
||||
s.state.registerConn(sc)
|
||||
defer s.state.unregisterConn(sc)
|
||||
@@ -570,15 +404,6 @@ func (s *Server) serveConn(c net.Conn, opts *ServeConnOpts, newf func(*serverCon
|
||||
sc.serve(conf)
|
||||
}
|
||||
|
||||
func serverConnBaseContext(c net.Conn, opts *ServeConnOpts) (ctx context.Context, cancel func()) {
|
||||
ctx, cancel = context.WithCancel(opts.context())
|
||||
ctx = context.WithValue(ctx, http.LocalAddrContextKey, c.LocalAddr())
|
||||
if hs := opts.baseConfig(); hs != nil {
|
||||
ctx = context.WithValue(ctx, http.ServerContextKey, hs)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (sc *serverConn) rejectConn(err ErrCode, debug string) {
|
||||
sc.vlogf("http2: server rejecting conn: %v, %s", err, debug)
|
||||
// ignoring errors. hanging up anyway.
|
||||
@@ -2832,21 +2657,6 @@ func (rws *responseWriterState) writeChunk(p []byte) (n int, err error) {
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// TrailerPrefix is a magic prefix for ResponseWriter.Header map keys
|
||||
// that, if present, signals that the map entry is actually for
|
||||
// the response trailers, and not the response headers. The prefix
|
||||
// is stripped after the ServeHTTP call finishes and the values are
|
||||
// sent in the trailers.
|
||||
//
|
||||
// This mechanism is intended only for trailers that are not known
|
||||
// prior to the headers being written. If the set of trailers is fixed
|
||||
// or known before the header is written, the normal Go trailers mechanism
|
||||
// is preferred:
|
||||
//
|
||||
// https://golang.org/pkg/net/http/#ResponseWriter
|
||||
// https://golang.org/pkg/net/http/#example_ResponseWriter_trailers
|
||||
const TrailerPrefix = "Trailer:"
|
||||
|
||||
// promoteUndeclaredTrailers permits http.Handlers to set trailers
|
||||
// after the header has already been flushed. Because the Go
|
||||
// ResponseWriter interface has no way to set Trailers (only the
|
||||
@@ -3123,12 +2933,6 @@ func (w *responseWriter) handlerDone() {
|
||||
responseWriterStatePool.Put(rws)
|
||||
}
|
||||
|
||||
// Push errors.
|
||||
var (
|
||||
ErrRecursivePush = errors.New("http2: recursive push not allowed")
|
||||
ErrPushLimitReached = errors.New("http2: push would exceed peer's SETTINGS_MAX_CONCURRENT_STREAMS")
|
||||
)
|
||||
|
||||
var _ http.Pusher = (*responseWriter)(nil)
|
||||
|
||||
func (w *responseWriter) Push(target string, opts *http.PushOptions) error {
|
||||
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
// Copyright 2026 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package http2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TrailerPrefix is a magic prefix for ResponseWriter.Header map keys
|
||||
// that, if present, signals that the map entry is actually for
|
||||
// the response trailers, and not the response headers. The prefix
|
||||
// is stripped after the ServeHTTP call finishes and the values are
|
||||
// sent in the trailers.
|
||||
//
|
||||
// This mechanism is intended only for trailers that are not known
|
||||
// prior to the headers being written. If the set of trailers is fixed
|
||||
// or known before the header is written, the normal Go trailers mechanism
|
||||
// is preferred:
|
||||
//
|
||||
// https://golang.org/pkg/net/http/#ResponseWriter
|
||||
// https://golang.org/pkg/net/http/#example_ResponseWriter_trailers
|
||||
const TrailerPrefix = "Trailer:"
|
||||
|
||||
// Push errors.
|
||||
var (
|
||||
ErrRecursivePush = errors.New("http2: recursive push not allowed")
|
||||
ErrPushLimitReached = errors.New("http2: push would exceed peer's SETTINGS_MAX_CONCURRENT_STREAMS")
|
||||
)
|
||||
|
||||
// ConfigureServer adds HTTP/2 support to a net/http Server.
|
||||
//
|
||||
// The configuration conf may be nil.
|
||||
//
|
||||
// ConfigureServer must be called before s begins serving.
|
||||
func ConfigureServer(s *http.Server, conf *Server) error {
|
||||
return configureServer(s, conf)
|
||||
}
|
||||
|
||||
// Server is an HTTP/2 server.
|
||||
type Server struct {
|
||||
// MaxHandlers limits the number of http.Handler ServeHTTP goroutines
|
||||
// which may run at a time over all connections.
|
||||
// Negative or zero no limit.
|
||||
// TODO: implement
|
||||
MaxHandlers int
|
||||
|
||||
// MaxConcurrentStreams optionally specifies the number of
|
||||
// concurrent streams that each client may have open at a
|
||||
// time. This is unrelated to the number of http.Handler goroutines
|
||||
// which may be active globally, which is MaxHandlers.
|
||||
// If zero, MaxConcurrentStreams defaults to at least 100, per
|
||||
// the HTTP/2 spec's recommendations.
|
||||
MaxConcurrentStreams uint32
|
||||
|
||||
// MaxDecoderHeaderTableSize optionally specifies the http2
|
||||
// SETTINGS_HEADER_TABLE_SIZE to send in the initial settings frame. It
|
||||
// informs the remote endpoint of the maximum size of the header compression
|
||||
// table used to decode header blocks, in octets. If zero, the default value
|
||||
// of 4096 is used.
|
||||
MaxDecoderHeaderTableSize uint32
|
||||
|
||||
// MaxEncoderHeaderTableSize optionally specifies an upper limit for the
|
||||
// header compression table used for encoding request headers. Received
|
||||
// SETTINGS_HEADER_TABLE_SIZE settings are capped at this limit. If zero,
|
||||
// the default value of 4096 is used.
|
||||
MaxEncoderHeaderTableSize uint32
|
||||
|
||||
// MaxReadFrameSize optionally specifies the largest frame
|
||||
// this server is willing to read. A valid value is between
|
||||
// 16k and 16M, inclusive. If zero or otherwise invalid, a
|
||||
// default value is used.
|
||||
MaxReadFrameSize uint32
|
||||
|
||||
// PermitProhibitedCipherSuites, if true, permits the use of
|
||||
// cipher suites prohibited by the HTTP/2 spec.
|
||||
PermitProhibitedCipherSuites bool
|
||||
|
||||
// IdleTimeout specifies how long until idle clients should be
|
||||
// closed with a GOAWAY frame. PING frames are not considered
|
||||
// activity for the purposes of IdleTimeout.
|
||||
// If zero or negative, there is no timeout.
|
||||
IdleTimeout time.Duration
|
||||
|
||||
// ReadIdleTimeout is the timeout after which a health check using a ping
|
||||
// frame will be carried out if no frame is received on the connection.
|
||||
// If zero, no health check is performed.
|
||||
ReadIdleTimeout time.Duration
|
||||
|
||||
// PingTimeout is the timeout after which the connection will be closed
|
||||
// if a response to a ping is not received.
|
||||
// If zero, a default of 15 seconds is used.
|
||||
PingTimeout time.Duration
|
||||
|
||||
// WriteByteTimeout is the timeout after which a connection will be
|
||||
// closed if no data can be written to it. The timeout begins when data is
|
||||
// available to write, and is extended whenever any bytes are written.
|
||||
// If zero or negative, there is no timeout.
|
||||
WriteByteTimeout time.Duration
|
||||
|
||||
// MaxUploadBufferPerConnection is the size of the initial flow
|
||||
// control window for each connections. The HTTP/2 spec does not
|
||||
// allow this to be smaller than 65535 or larger than 2^32-1.
|
||||
// If the value is outside this range, a default value will be
|
||||
// used instead.
|
||||
MaxUploadBufferPerConnection int32
|
||||
|
||||
// MaxUploadBufferPerStream is the size of the initial flow control
|
||||
// window for each stream. The HTTP/2 spec does not allow this to
|
||||
// be larger than 2^32-1. If the value is zero or larger than the
|
||||
// maximum, a default value will be used instead.
|
||||
MaxUploadBufferPerStream int32
|
||||
|
||||
// NewWriteScheduler constructs a write scheduler for a connection.
|
||||
// If nil, a default scheduler is chosen.
|
||||
//
|
||||
// Deprecated: User-provided write schedulers are deprecated.
|
||||
NewWriteScheduler func() WriteScheduler
|
||||
|
||||
// CountError, if non-nil, is called on HTTP/2 server errors.
|
||||
// It's intended to increment a metric for monitoring, such
|
||||
// as an expvar or Prometheus metric.
|
||||
// The errType consists of only ASCII word characters.
|
||||
CountError func(errType string)
|
||||
|
||||
// Internal state. This is a pointer (rather than embedded directly)
|
||||
// so that we don't embed a Mutex in this struct, which will make the
|
||||
// struct non-copyable, which might break some callers.
|
||||
state *serverInternalState
|
||||
}
|
||||
|
||||
// ServeConnOpts are options for the Server.ServeConn method.
|
||||
type ServeConnOpts struct {
|
||||
// Context is the base context to use.
|
||||
// If nil, context.Background is used.
|
||||
Context context.Context
|
||||
|
||||
// BaseConfig optionally sets the base configuration
|
||||
// for values. If nil, defaults are used.
|
||||
BaseConfig *http.Server
|
||||
|
||||
// Handler specifies which handler to use for processing
|
||||
// requests. If nil, BaseConfig.Handler is used. If BaseConfig
|
||||
// or BaseConfig.Handler is nil, http.DefaultServeMux is used.
|
||||
Handler http.Handler
|
||||
|
||||
// UpgradeRequest is an initial request received on a connection
|
||||
// undergoing an h2c upgrade. The request body must have been
|
||||
// completely read from the connection before calling ServeConn,
|
||||
// and the 101 Switching Protocols response written.
|
||||
UpgradeRequest *http.Request
|
||||
|
||||
// Settings is the decoded contents of the HTTP2-Settings header
|
||||
// in an h2c upgrade request.
|
||||
Settings []byte
|
||||
|
||||
// SawClientPreface is set if the HTTP/2 connection preface
|
||||
// has already been read from the connection.
|
||||
SawClientPreface bool
|
||||
}
|
||||
|
||||
// ServeConn serves HTTP/2 requests on the provided connection and
|
||||
// blocks until the connection is no longer readable.
|
||||
//
|
||||
// ServeConn starts speaking HTTP/2 assuming that c has not had any
|
||||
// reads or writes. It writes its initial settings frame and expects
|
||||
// to be able to read the preface and settings frame from the
|
||||
// client. If c has a ConnectionState method like a *tls.Conn, the
|
||||
// ConnectionState is used to verify the TLS ciphersuite and to set
|
||||
// the Request.TLS field in Handlers.
|
||||
//
|
||||
// ServeConn does not support h2c by itself. Any h2c support must be
|
||||
// implemented in terms of providing a suitably-behaving net.Conn.
|
||||
//
|
||||
// The opts parameter is optional. If nil, default values are used.
|
||||
func (s *Server) ServeConn(c net.Conn, opts *ServeConnOpts) {
|
||||
if opts == nil {
|
||||
opts = &ServeConnOpts{}
|
||||
}
|
||||
s.serveConn(c, opts, nil)
|
||||
}
|
||||
|
||||
func (o *ServeConnOpts) context() context.Context {
|
||||
if o != nil && o.Context != nil {
|
||||
return o.Context
|
||||
}
|
||||
return context.Background()
|
||||
}
|
||||
|
||||
func (o *ServeConnOpts) baseConfig() *http.Server {
|
||||
if o != nil && o.BaseConfig != nil {
|
||||
return o.BaseConfig
|
||||
}
|
||||
return new(http.Server)
|
||||
}
|
||||
|
||||
func (o *ServeConnOpts) handler() http.Handler {
|
||||
if o != nil {
|
||||
if o.Handler != nil {
|
||||
return o.Handler
|
||||
}
|
||||
if o.BaseConfig != nil && o.BaseConfig.Handler != nil {
|
||||
return o.BaseConfig.Handler
|
||||
}
|
||||
}
|
||||
return http.DefaultServeMux
|
||||
}
|
||||
|
||||
func serverConnBaseContext(c net.Conn, opts *ServeConnOpts) (ctx context.Context, cancel func()) {
|
||||
ctx, cancel = context.WithCancel(opts.context())
|
||||
ctx = context.WithValue(ctx, http.LocalAddrContextKey, c.LocalAddr())
|
||||
if hs := opts.baseConfig(); hs != nil {
|
||||
ctx = context.WithValue(ctx, http.ServerContextKey, hs)
|
||||
}
|
||||
return
|
||||
}
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
// Copyright 2026 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build go1.27 && !http2legacy
|
||||
|
||||
// Server wrapping a net/http.Server.
|
||||
|
||||
package http2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type serverInternalState struct {
|
||||
s1 *http.Server
|
||||
initOnce sync.Once
|
||||
serveConnFunc func(context.Context, net.Conn, http.Handler, bool, *http.Request, []byte)
|
||||
}
|
||||
|
||||
func configureServer(s *http.Server, conf *Server) error {
|
||||
if s == nil {
|
||||
panic("nil *http.Server")
|
||||
}
|
||||
if conf == nil {
|
||||
conf = new(Server)
|
||||
}
|
||||
if conf.state != nil {
|
||||
// This isn't a panic in the pre-wrapping implementation,
|
||||
// but calling ConfigureServer twice with the same http2.Server
|
||||
// overwrites internal state on the server.
|
||||
// Make the error explicit and early here.
|
||||
panic("ConfigureServer may be called only once per Server")
|
||||
}
|
||||
if h1, h2 := s, conf; h2.IdleTimeout == 0 {
|
||||
if h1.IdleTimeout != 0 {
|
||||
h2.IdleTimeout = h1.IdleTimeout
|
||||
} else {
|
||||
h2.IdleTimeout = h1.ReadTimeout
|
||||
}
|
||||
}
|
||||
|
||||
// Register h2 and http/1.1 ALPN protocols on s.TLSConfig, matching
|
||||
// the pre-wrapping implementation in server.go, so that TLS listeners
|
||||
// built from s.TLSConfig still negotiate HTTP/2.
|
||||
if s.TLSConfig == nil {
|
||||
s.TLSConfig = new(tls.Config)
|
||||
}
|
||||
if !slices.Contains(s.TLSConfig.NextProtos, NextProtoTLS) {
|
||||
s.TLSConfig.NextProtos = append(s.TLSConfig.NextProtos, NextProtoTLS)
|
||||
}
|
||||
if !slices.Contains(s.TLSConfig.NextProtos, "http/1.1") {
|
||||
s.TLSConfig.NextProtos = append(s.TLSConfig.NextProtos, "http/1.1")
|
||||
}
|
||||
|
||||
conf.state = &serverInternalState{
|
||||
s1: s,
|
||||
}
|
||||
sconfig := &serverConfig{s: conf}
|
||||
if err := s.Serve(sconfig); err != nil || sconfig.serveConnFunc == nil {
|
||||
panic("http2: net/http does not support this version of x/net/http2")
|
||||
}
|
||||
conf.state.serveConnFunc = sconfig.serveConnFunc
|
||||
return nil
|
||||
}
|
||||
|
||||
type serverConfig struct {
|
||||
s *Server
|
||||
serveConnFunc func(context.Context, net.Conn, http.Handler, bool, *http.Request, []byte)
|
||||
}
|
||||
|
||||
func (*serverConfig) Accept() (net.Conn, error) {
|
||||
return nil, errors.New("unexpected call to Accept")
|
||||
}
|
||||
func (*serverConfig) Close() error {
|
||||
return nil
|
||||
}
|
||||
func (*serverConfig) Addr() net.Addr {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *serverConfig) ServeConnFunc(f func(context.Context, net.Conn, http.Handler, bool, *http.Request, []byte)) {
|
||||
s.serveConnFunc = f
|
||||
}
|
||||
|
||||
func (s *serverConfig) HTTP2Config() http.HTTP2Config {
|
||||
return http.HTTP2Config{
|
||||
MaxConcurrentStreams: int(s.s.MaxConcurrentStreams),
|
||||
MaxDecoderHeaderTableSize: int(s.s.MaxDecoderHeaderTableSize),
|
||||
MaxEncoderHeaderTableSize: int(s.s.MaxEncoderHeaderTableSize),
|
||||
MaxReadFrameSize: int(s.s.MaxReadFrameSize),
|
||||
PermitProhibitedCipherSuites: s.s.PermitProhibitedCipherSuites,
|
||||
MaxReceiveBufferPerConnection: int(s.s.MaxUploadBufferPerConnection),
|
||||
MaxReceiveBufferPerStream: int(s.s.MaxUploadBufferPerStream),
|
||||
SendPingTimeout: s.s.ReadIdleTimeout,
|
||||
PingTimeout: s.s.PingTimeout,
|
||||
WriteByteTimeout: s.s.WriteByteTimeout,
|
||||
CountError: s.s.CountError,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *serverConfig) IdleTimeout() time.Duration {
|
||||
return s.s.IdleTimeout
|
||||
}
|
||||
|
||||
type serverConn struct{}
|
||||
|
||||
func (s *Server) serveConn(c net.Conn, opts *ServeConnOpts, _ func(*serverConn)) {
|
||||
var serveConnFunc func(context.Context, net.Conn, http.Handler, bool, *http.Request, []byte)
|
||||
switch {
|
||||
case opts.BaseConfig != nil:
|
||||
// The user has provided us with an http.Server to take configuration from.
|
||||
//
|
||||
// We can't send our request to opts.BaseConfig, because an http.Server can
|
||||
// only be associated with a single http2.Server and the user might
|
||||
// use this one with several http.Servers.
|
||||
//
|
||||
// We can't send our request to s.state.s1, because it doesn't contain
|
||||
// the right configuration.
|
||||
//
|
||||
// So create a one-off copy of opts.BaseConfig and use it.
|
||||
h1 := &http.Server{
|
||||
TLSConfig: opts.BaseConfig.TLSConfig,
|
||||
ReadTimeout: opts.BaseConfig.ReadTimeout,
|
||||
ReadHeaderTimeout: opts.BaseConfig.ReadHeaderTimeout,
|
||||
WriteTimeout: opts.BaseConfig.WriteTimeout,
|
||||
IdleTimeout: opts.BaseConfig.IdleTimeout,
|
||||
MaxHeaderBytes: opts.BaseConfig.MaxHeaderBytes,
|
||||
ConnState: opts.BaseConfig.ConnState,
|
||||
ErrorLog: opts.BaseConfig.ErrorLog,
|
||||
BaseContext: opts.BaseConfig.BaseContext,
|
||||
ConnContext: opts.BaseConfig.ConnContext,
|
||||
HTTP2: opts.BaseConfig.HTTP2,
|
||||
}
|
||||
sconfig := &serverConfig{s: s}
|
||||
if err := h1.Serve(sconfig); err != nil || sconfig.serveConnFunc == nil {
|
||||
panic("http2: net/http does not support this version of x/net/http2")
|
||||
}
|
||||
serveConnFunc = sconfig.serveConnFunc
|
||||
case s.state != nil:
|
||||
serveConnFunc = s.state.serveConnFunc
|
||||
default:
|
||||
// Strange-but-true: Server has no concurrency-safe way to initialize
|
||||
// its internal state, so historically ServeConn just doesn't use any
|
||||
// persistent state if you don't call ConfigureServer first.
|
||||
//
|
||||
// If ConfigureServer hasn't been called, create a one-off http.Server
|
||||
// for the connection, since we don't have any way to keep one around for reuse.
|
||||
h1 := &http.Server{}
|
||||
sconfig := &serverConfig{s: s}
|
||||
if err := h1.Serve(sconfig); err != nil || sconfig.serveConnFunc == nil {
|
||||
panic("http2: net/http does not support this version of x/net/http2")
|
||||
}
|
||||
serveConnFunc = sconfig.serveConnFunc
|
||||
}
|
||||
|
||||
ctx, cancel := serverConnBaseContext(c, opts)
|
||||
defer cancel()
|
||||
serveConnFunc(ctx, c, opts.handler(), opts.SawClientPreface, opts.UpgradeRequest, opts.Settings)
|
||||
|
||||
}
|
||||
|
||||
// FrameWriteRequest is a request to write a frame.
|
||||
//
|
||||
// Deprecated: User-provided write schedulers are deprecated.
|
||||
type FrameWriteRequest struct {
|
||||
// Ideally we'd define this in writesched_common.go,
|
||||
// to avoid duplicating an exported symbol across two files,
|
||||
// but the changes required to make this work are fairly large.
|
||||
}
|
||||
|
||||
func (wr FrameWriteRequest) StreamID() uint32 {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (wr FrameWriteRequest) DataSize() int {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (wr FrameWriteRequest) Consume(n int32) (FrameWriteRequest, FrameWriteRequest, int) {
|
||||
return FrameWriteRequest{}, FrameWriteRequest{}, 0
|
||||
}
|
||||
|
||||
func (wr FrameWriteRequest) String() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// NewPriorityWriteScheduler is deprecated.
|
||||
//
|
||||
// Deprecated: User-provided write schedulers are deprecated.
|
||||
func NewPriorityWriteScheduler(cfg *PriorityWriteSchedulerConfig) WriteScheduler {
|
||||
return unsupportedWriteScheduler{}
|
||||
}
|
||||
|
||||
// NewRandomWriteScheduler is deprecated.
|
||||
//
|
||||
// Deprecated: User-provided write schedulers are deprecated.
|
||||
func NewRandomWriteScheduler() WriteScheduler {
|
||||
return unsupportedWriteScheduler{}
|
||||
}
|
||||
|
||||
type unsupportedWriteScheduler struct{}
|
||||
|
||||
func (unsupportedWriteScheduler) OpenStream(streamID uint32, options OpenStreamOptions) {}
|
||||
func (unsupportedWriteScheduler) CloseStream(streamID uint32) {}
|
||||
func (unsupportedWriteScheduler) AdjustStream(streamID uint32, priority PriorityParam) {}
|
||||
func (unsupportedWriteScheduler) Push(wr FrameWriteRequest) {}
|
||||
func (unsupportedWriteScheduler) Pop() (wr FrameWriteRequest, ok bool) {
|
||||
return FrameWriteRequest{}, false
|
||||
}
|
||||
+31
-431
@@ -2,6 +2,8 @@
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build !(go1.27 && !http2legacy)
|
||||
|
||||
// Transport code.
|
||||
|
||||
package http2
|
||||
@@ -21,20 +23,17 @@ import (
|
||||
"log"
|
||||
"math"
|
||||
"math/bits"
|
||||
mathrand "math/rand"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptrace"
|
||||
"net/textproto"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/http/httpguts"
|
||||
"golang.org/x/net/http2/hpack"
|
||||
"golang.org/x/net/idna"
|
||||
"golang.org/x/net/internal/httpcommon"
|
||||
)
|
||||
|
||||
@@ -60,123 +59,7 @@ const (
|
||||
defaultMaxConcurrentStreams = 1000
|
||||
)
|
||||
|
||||
// Transport is an HTTP/2 Transport.
|
||||
//
|
||||
// A Transport internally caches connections to servers. It is safe
|
||||
// for concurrent use by multiple goroutines.
|
||||
type Transport struct {
|
||||
// DialTLSContext specifies an optional dial function with context for
|
||||
// creating TLS connections for requests.
|
||||
//
|
||||
// If DialTLSContext and DialTLS is nil, tls.Dial is used.
|
||||
//
|
||||
// If the returned net.Conn has a ConnectionState method like tls.Conn,
|
||||
// it will be used to set http.Response.TLS.
|
||||
DialTLSContext func(ctx context.Context, network, addr string, cfg *tls.Config) (net.Conn, error)
|
||||
|
||||
// DialTLS specifies an optional dial function for creating
|
||||
// TLS connections for requests.
|
||||
//
|
||||
// If DialTLSContext and DialTLS is nil, tls.Dial is used.
|
||||
//
|
||||
// Deprecated: Use DialTLSContext instead, which allows the transport
|
||||
// to cancel dials as soon as they are no longer needed.
|
||||
// If both are set, DialTLSContext takes priority.
|
||||
DialTLS func(network, addr string, cfg *tls.Config) (net.Conn, error)
|
||||
|
||||
// TLSClientConfig specifies the TLS configuration to use with
|
||||
// tls.Client. If nil, the default configuration is used.
|
||||
TLSClientConfig *tls.Config
|
||||
|
||||
// ConnPool optionally specifies an alternate connection pool to use.
|
||||
// If nil, the default is used.
|
||||
ConnPool ClientConnPool
|
||||
|
||||
// DisableCompression, if true, prevents the Transport from
|
||||
// requesting compression with an "Accept-Encoding: gzip"
|
||||
// request header when the Request contains no existing
|
||||
// Accept-Encoding value. If the Transport requests gzip on
|
||||
// its own and gets a gzipped response, it's transparently
|
||||
// decoded in the Response.Body. However, if the user
|
||||
// explicitly requested gzip it is not automatically
|
||||
// uncompressed.
|
||||
DisableCompression bool
|
||||
|
||||
// AllowHTTP, if true, permits HTTP/2 requests using the insecure,
|
||||
// plain-text "http" scheme. Note that this does not enable h2c support.
|
||||
AllowHTTP bool
|
||||
|
||||
// MaxHeaderListSize is the http2 SETTINGS_MAX_HEADER_LIST_SIZE to
|
||||
// send in the initial settings frame. It is how many bytes
|
||||
// of response headers are allowed. Unlike the http2 spec, zero here
|
||||
// means to use a default limit (currently 10MB). If you actually
|
||||
// want to advertise an unlimited value to the peer, Transport
|
||||
// interprets the highest possible value here (0xffffffff or 1<<32-1)
|
||||
// to mean no limit.
|
||||
MaxHeaderListSize uint32
|
||||
|
||||
// MaxReadFrameSize is the http2 SETTINGS_MAX_FRAME_SIZE to send in the
|
||||
// initial settings frame. It is the size in bytes of the largest frame
|
||||
// payload that the sender is willing to receive. If 0, no setting is
|
||||
// sent, and the value is provided by the peer, which should be 16384
|
||||
// according to the spec:
|
||||
// https://datatracker.ietf.org/doc/html/rfc7540#section-6.5.2.
|
||||
// Values are bounded in the range 16k to 16M.
|
||||
MaxReadFrameSize uint32
|
||||
|
||||
// MaxDecoderHeaderTableSize optionally specifies the http2
|
||||
// SETTINGS_HEADER_TABLE_SIZE to send in the initial settings frame. It
|
||||
// informs the remote endpoint of the maximum size of the header compression
|
||||
// table used to decode header blocks, in octets. If zero, the default value
|
||||
// of 4096 is used.
|
||||
MaxDecoderHeaderTableSize uint32
|
||||
|
||||
// MaxEncoderHeaderTableSize optionally specifies an upper limit for the
|
||||
// header compression table used for encoding request headers. Received
|
||||
// SETTINGS_HEADER_TABLE_SIZE settings are capped at this limit. If zero,
|
||||
// the default value of 4096 is used.
|
||||
MaxEncoderHeaderTableSize uint32
|
||||
|
||||
// StrictMaxConcurrentStreams controls whether the server's
|
||||
// SETTINGS_MAX_CONCURRENT_STREAMS should be respected
|
||||
// globally. If false, new TCP connections are created to the
|
||||
// server as needed to keep each under the per-connection
|
||||
// SETTINGS_MAX_CONCURRENT_STREAMS limit. If true, the
|
||||
// server's SETTINGS_MAX_CONCURRENT_STREAMS is interpreted as
|
||||
// a global limit and callers of RoundTrip block when needed,
|
||||
// waiting for their turn.
|
||||
StrictMaxConcurrentStreams bool
|
||||
|
||||
// IdleConnTimeout is the maximum amount of time an idle
|
||||
// (keep-alive) connection will remain idle before closing
|
||||
// itself.
|
||||
// Zero means no limit.
|
||||
IdleConnTimeout time.Duration
|
||||
|
||||
// ReadIdleTimeout is the timeout after which a health check using ping
|
||||
// frame will be carried out if no frame is received on the connection.
|
||||
// Note that a ping response will is considered a received frame, so if
|
||||
// there is no other traffic on the connection, the health check will
|
||||
// be performed every ReadIdleTimeout interval.
|
||||
// If zero, no health check is performed.
|
||||
ReadIdleTimeout time.Duration
|
||||
|
||||
// PingTimeout is the timeout after which the connection will be closed
|
||||
// if a response to Ping is not received.
|
||||
// Defaults to 15s.
|
||||
PingTimeout time.Duration
|
||||
|
||||
// WriteByteTimeout is the timeout after which the connection will be
|
||||
// closed no data can be written to it. The timeout begins when data is
|
||||
// available to write, and is extended whenever any bytes are written.
|
||||
WriteByteTimeout time.Duration
|
||||
|
||||
// CountError, if non-nil, is called on HTTP/2 transport errors.
|
||||
// It's intended to increment a metric for monitoring, such
|
||||
// as an expvar or Prometheus metric.
|
||||
// The errType consists of only ASCII word characters.
|
||||
CountError func(errType string)
|
||||
|
||||
type transportInternal struct {
|
||||
// t1, if non-nil, is the standard library Transport using
|
||||
// this transport. Its settings are used (but not its
|
||||
// RoundTrip method, etc).
|
||||
@@ -217,27 +100,18 @@ func (t *Transport) disableCompression() bool {
|
||||
return t.DisableCompression || (t.t1 != nil && t.t1.DisableCompression)
|
||||
}
|
||||
|
||||
// ConfigureTransport configures a net/http HTTP/1 Transport to use HTTP/2.
|
||||
// It returns an error if t1 has already been HTTP/2-enabled.
|
||||
//
|
||||
// Use ConfigureTransports instead to configure the HTTP/2 Transport.
|
||||
func ConfigureTransport(t1 *http.Transport) error {
|
||||
_, err := ConfigureTransports(t1)
|
||||
func configureTransport(t1 *http.Transport) error {
|
||||
_, err := configureTransports(t1)
|
||||
return err
|
||||
}
|
||||
|
||||
// ConfigureTransports configures a net/http HTTP/1 Transport to use HTTP/2.
|
||||
// It returns a new HTTP/2 Transport for further configuration.
|
||||
// It returns an error if t1 has already been HTTP/2-enabled.
|
||||
func ConfigureTransports(t1 *http.Transport) (*Transport, error) {
|
||||
return configureTransports(t1)
|
||||
}
|
||||
|
||||
func configureTransports(t1 *http.Transport) (*Transport, error) {
|
||||
connPool := new(clientConnPool)
|
||||
t2 := &Transport{
|
||||
ConnPool: noDialClientConnPool{connPool},
|
||||
t1: t1,
|
||||
transportInternal: transportInternal{
|
||||
t1: t1,
|
||||
},
|
||||
}
|
||||
connPool.t = t2
|
||||
if err := registerHTTPSProtocol(t1, noDialH2RoundTripper{t2}); err != nil {
|
||||
@@ -525,68 +399,7 @@ func (sew stickyErrWriter) Write(p []byte) (n int, err error) {
|
||||
return n, err
|
||||
}
|
||||
|
||||
// noCachedConnError is the concrete type of ErrNoCachedConn, which
|
||||
// needs to be detected by net/http regardless of whether it's its
|
||||
// bundled version (in h2_bundle.go with a rewritten type name) or
|
||||
// from a user's x/net/http2. As such, as it has a unique method name
|
||||
// (IsHTTP2NoCachedConnError) that net/http sniffs for via func
|
||||
// isNoCachedConnError.
|
||||
type noCachedConnError struct{}
|
||||
|
||||
func (noCachedConnError) IsHTTP2NoCachedConnError() {}
|
||||
func (noCachedConnError) Error() string { return "http2: no cached connection was available" }
|
||||
|
||||
// isNoCachedConnError reports whether err is of type noCachedConnError
|
||||
// or its equivalent renamed type in net/http2's h2_bundle.go. Both types
|
||||
// may coexist in the same running program.
|
||||
func isNoCachedConnError(err error) bool {
|
||||
_, ok := err.(interface{ IsHTTP2NoCachedConnError() })
|
||||
return ok
|
||||
}
|
||||
|
||||
var ErrNoCachedConn error = noCachedConnError{}
|
||||
|
||||
// RoundTripOpt are options for the Transport.RoundTripOpt method.
|
||||
type RoundTripOpt struct {
|
||||
// OnlyCachedConn controls whether RoundTripOpt may
|
||||
// create a new TCP connection. If set true and
|
||||
// no cached connection is available, RoundTripOpt
|
||||
// will return ErrNoCachedConn.
|
||||
OnlyCachedConn bool
|
||||
|
||||
allowHTTP bool // allow http:// URLs
|
||||
}
|
||||
|
||||
func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return t.RoundTripOpt(req, RoundTripOpt{})
|
||||
}
|
||||
|
||||
// authorityAddr returns a given authority (a host/IP, or host:port / ip:port)
|
||||
// and returns a host:port. The port 443 is added if needed.
|
||||
func authorityAddr(scheme string, authority string) (addr string) {
|
||||
host, port, err := net.SplitHostPort(authority)
|
||||
if err != nil { // authority didn't have a port
|
||||
host = authority
|
||||
port = ""
|
||||
}
|
||||
if port == "" { // authority's port was empty
|
||||
port = "443"
|
||||
if scheme == "http" {
|
||||
port = "80"
|
||||
}
|
||||
}
|
||||
if a, err := idna.ToASCII(host); err == nil {
|
||||
host = a
|
||||
}
|
||||
// IPv6 address literal, without a port:
|
||||
if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
|
||||
return host + ":" + port
|
||||
}
|
||||
return net.JoinHostPort(host, port)
|
||||
}
|
||||
|
||||
// RoundTripOpt is like RoundTrip, but takes options.
|
||||
func (t *Transport) RoundTripOpt(req *http.Request, opt RoundTripOpt) (*http.Response, error) {
|
||||
func (t *Transport) roundTripOpt(req *http.Request, opt RoundTripOpt) (*http.Response, error) {
|
||||
switch req.URL.Scheme {
|
||||
case "https":
|
||||
// Always okay.
|
||||
@@ -597,130 +410,16 @@ func (t *Transport) RoundTripOpt(req *http.Request, opt RoundTripOpt) (*http.Res
|
||||
default:
|
||||
return nil, errors.New("http2: unsupported scheme")
|
||||
}
|
||||
|
||||
addr := authorityAddr(req.URL.Scheme, req.URL.Host)
|
||||
for retry := 0; ; retry++ {
|
||||
cc, err := t.connPool().GetClientConn(req, addr)
|
||||
if err != nil {
|
||||
t.vlogf("http2: Transport failed to get client conn for %s: %v", addr, err)
|
||||
return nil, err
|
||||
}
|
||||
reused := !atomic.CompareAndSwapUint32(&cc.atomicReused, 0, 1)
|
||||
traceGotConn(req, cc, reused)
|
||||
res, err := cc.RoundTrip(req)
|
||||
if err != nil && retry <= 6 {
|
||||
roundTripErr := err
|
||||
if req, err = shouldRetryRequest(req, err); err == nil {
|
||||
// After the first retry, do exponential backoff with 10% jitter.
|
||||
if retry == 0 {
|
||||
t.vlogf("RoundTrip retrying after failure: %v", roundTripErr)
|
||||
continue
|
||||
}
|
||||
backoff := float64(uint(1) << (uint(retry) - 1))
|
||||
backoff += backoff * (0.1 * mathrand.Float64())
|
||||
d := time.Second * time.Duration(backoff)
|
||||
tm := time.NewTimer(d)
|
||||
select {
|
||||
case <-tm.C:
|
||||
t.vlogf("RoundTrip retrying after failure: %v", roundTripErr)
|
||||
continue
|
||||
case <-req.Context().Done():
|
||||
tm.Stop()
|
||||
err = req.Context().Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
if err == errClientConnNotEstablished {
|
||||
// This ClientConn was created recently,
|
||||
// this is the first request to use it,
|
||||
// and the connection is closed and not usable.
|
||||
//
|
||||
// In this state, cc.idleTimer will remove the conn from the pool
|
||||
// when it fires. Stop the timer and remove it here so future requests
|
||||
// won't try to use this connection.
|
||||
//
|
||||
// If the timer has already fired and we're racing it, the redundant
|
||||
// call to MarkDead is harmless.
|
||||
if cc.idleTimer != nil {
|
||||
cc.idleTimer.Stop()
|
||||
}
|
||||
t.connPool().MarkDead(cc)
|
||||
}
|
||||
if err != nil {
|
||||
t.vlogf("RoundTrip failure: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
return t.roundTripViaPool(req, opt, t.connPool())
|
||||
}
|
||||
|
||||
// CloseIdleConnections closes any connections which were previously
|
||||
// connected from previous requests but are now sitting idle.
|
||||
// It does not interrupt any connections currently in use.
|
||||
func (t *Transport) CloseIdleConnections() {
|
||||
func (t *Transport) closeIdleConnections() {
|
||||
if cp, ok := t.connPool().(clientConnPoolIdleCloser); ok {
|
||||
cp.closeIdleConnections()
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
errClientConnClosed = errors.New("http2: client conn is closed")
|
||||
errClientConnUnusable = errors.New("http2: client conn not usable")
|
||||
errClientConnNotEstablished = errors.New("http2: client conn could not be established")
|
||||
errClientConnGotGoAway = errors.New("http2: Transport received Server's graceful shutdown GOAWAY")
|
||||
errClientConnForceClosed = errors.New("http2: client connection force closed via ClientConn.Close")
|
||||
)
|
||||
|
||||
// shouldRetryRequest is called by RoundTrip when a request fails to get
|
||||
// response headers. It is always called with a non-nil error.
|
||||
// It returns either a request to retry (either the same request, or a
|
||||
// modified clone), or an error if the request can't be replayed.
|
||||
func shouldRetryRequest(req *http.Request, err error) (*http.Request, error) {
|
||||
if !canRetryError(err) {
|
||||
return nil, err
|
||||
}
|
||||
// If the Body is nil (or http.NoBody), it's safe to reuse
|
||||
// this request and its Body.
|
||||
if req.Body == nil || req.Body == http.NoBody {
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// If the request body can be reset back to its original
|
||||
// state via the optional req.GetBody, do that.
|
||||
if req.GetBody != nil {
|
||||
body, err := req.GetBody()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
newReq := *req
|
||||
newReq.Body = body
|
||||
return &newReq, nil
|
||||
}
|
||||
|
||||
// The Request.Body can't reset back to the beginning, but we
|
||||
// don't seem to have started to read from it yet, so reuse
|
||||
// the request directly.
|
||||
if err == errClientConnUnusable {
|
||||
return req, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("http2: Transport: cannot retry err [%v] after Request.Body was written; define Request.GetBody to avoid this error", err)
|
||||
}
|
||||
|
||||
func canRetryError(err error) bool {
|
||||
if err == errClientConnUnusable || err == errClientConnGotGoAway {
|
||||
return true
|
||||
}
|
||||
if se, ok := err.(StreamError); ok {
|
||||
return se.Code == ErrCodeRefusedStream
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (t *Transport) dialClientConn(ctx context.Context, addr string, singleUse bool) (*ClientConn, error) {
|
||||
if t.transportTestHooks != nil {
|
||||
return t.newClientConn(nil, singleUse, nil)
|
||||
}
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -746,27 +445,6 @@ func (t *Transport) newTLSConfig(host string) *tls.Config {
|
||||
return cfg
|
||||
}
|
||||
|
||||
func (t *Transport) dialTLS(ctx context.Context, network, addr string, tlsCfg *tls.Config) (net.Conn, error) {
|
||||
if t.DialTLSContext != nil {
|
||||
return t.DialTLSContext(ctx, network, addr, tlsCfg)
|
||||
} else if t.DialTLS != nil {
|
||||
return t.DialTLS(network, addr, tlsCfg)
|
||||
}
|
||||
|
||||
tlsCn, err := t.dialTLSWithContext(ctx, network, addr, tlsCfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
state := tlsCn.ConnectionState()
|
||||
if p := state.NegotiatedProtocol; p != NextProtoTLS {
|
||||
return nil, fmt.Errorf("http2: unexpected ALPN protocol %q; want %q", p, NextProtoTLS)
|
||||
}
|
||||
if !state.NegotiatedProtocolIsMutual {
|
||||
return nil, errors.New("http2: could not negotiate protocol mutually")
|
||||
}
|
||||
return tlsCn, nil
|
||||
}
|
||||
|
||||
// disableKeepAlives reports whether connections should be closed as
|
||||
// soon as possible after handling the first request.
|
||||
func (t *Transport) disableKeepAlives() bool {
|
||||
@@ -780,7 +458,7 @@ func (t *Transport) expectContinueTimeout() time.Duration {
|
||||
return t.t1.ExpectContinueTimeout
|
||||
}
|
||||
|
||||
func (t *Transport) NewClientConn(c net.Conn) (*ClientConn, error) {
|
||||
func (t *Transport) newUserClientConn(c net.Conn) (*ClientConn, error) {
|
||||
return t.newClientConn(c, t.disableKeepAlives(), nil)
|
||||
}
|
||||
|
||||
@@ -893,8 +571,7 @@ func (cc *ClientConn) healthCheck() {
|
||||
}
|
||||
}
|
||||
|
||||
// SetDoNotReuse marks cc as not reusable for future HTTP requests.
|
||||
func (cc *ClientConn) SetDoNotReuse() {
|
||||
func (cc *ClientConn) setDoNotReuse() {
|
||||
cc.mu.Lock()
|
||||
defer cc.mu.Unlock()
|
||||
cc.doNotReuse = true
|
||||
@@ -935,21 +612,13 @@ func (cc *ClientConn) setGoAway(f *GoAwayFrame) {
|
||||
}
|
||||
}
|
||||
|
||||
// CanTakeNewRequest reports whether the connection can take a new request,
|
||||
// meaning it has not been closed or received or sent a GOAWAY.
|
||||
//
|
||||
// If the caller is going to immediately make a new request on this
|
||||
// connection, use ReserveNewRequest instead.
|
||||
func (cc *ClientConn) CanTakeNewRequest() bool {
|
||||
func (cc *ClientConn) canTakeNewRequest() bool {
|
||||
cc.mu.Lock()
|
||||
defer cc.mu.Unlock()
|
||||
return cc.canTakeNewRequestLocked()
|
||||
}
|
||||
|
||||
// ReserveNewRequest is like CanTakeNewRequest but also reserves a
|
||||
// concurrent stream in cc. The reservation is decremented on the
|
||||
// next call to RoundTrip.
|
||||
func (cc *ClientConn) ReserveNewRequest() bool {
|
||||
func (cc *ClientConn) reserveNewRequest() bool {
|
||||
cc.mu.Lock()
|
||||
defer cc.mu.Unlock()
|
||||
if st := cc.idleStateLocked(); !st.canTakeNewRequest {
|
||||
@@ -959,41 +628,7 @@ func (cc *ClientConn) ReserveNewRequest() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// ClientConnState describes the state of a ClientConn.
|
||||
type ClientConnState struct {
|
||||
// Closed is whether the connection is closed.
|
||||
Closed bool
|
||||
|
||||
// Closing is whether the connection is in the process of
|
||||
// closing. It may be closing due to shutdown, being a
|
||||
// single-use connection, being marked as DoNotReuse, or
|
||||
// having received a GOAWAY frame.
|
||||
Closing bool
|
||||
|
||||
// StreamsActive is how many streams are active.
|
||||
StreamsActive int
|
||||
|
||||
// StreamsReserved is how many streams have been reserved via
|
||||
// ClientConn.ReserveNewRequest.
|
||||
StreamsReserved int
|
||||
|
||||
// StreamsPending is how many requests have been sent in excess
|
||||
// of the peer's advertised MaxConcurrentStreams setting and
|
||||
// are waiting for other streams to complete.
|
||||
StreamsPending int
|
||||
|
||||
// MaxConcurrentStreams is how many concurrent streams the
|
||||
// peer advertised as acceptable. Zero means no SETTINGS
|
||||
// frame has been received yet.
|
||||
MaxConcurrentStreams uint32
|
||||
|
||||
// LastIdle, if non-zero, is when the connection last
|
||||
// transitioned to idle state.
|
||||
LastIdle time.Time
|
||||
}
|
||||
|
||||
// State returns a snapshot of cc's state.
|
||||
func (cc *ClientConn) State() ClientConnState {
|
||||
func (cc *ClientConn) state() ClientConnState {
|
||||
cc.wmu.Lock()
|
||||
maxConcurrent := cc.maxConcurrentStreams
|
||||
if !cc.seenSettings {
|
||||
@@ -1164,6 +799,12 @@ func (cc *ClientConn) closeIfIdle() {
|
||||
cc.closeConn()
|
||||
}
|
||||
|
||||
func (cc *ClientConn) stopIdleTimer() {
|
||||
if cc.idleTimer != nil {
|
||||
cc.idleTimer.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
func (cc *ClientConn) isDoNotReuseAndIdle() bool {
|
||||
cc.mu.Lock()
|
||||
defer cc.mu.Unlock()
|
||||
@@ -1172,8 +813,7 @@ func (cc *ClientConn) isDoNotReuseAndIdle() bool {
|
||||
|
||||
var shutdownEnterWaitStateHook = func() {}
|
||||
|
||||
// Shutdown gracefully closes the client connection, waiting for running streams to complete.
|
||||
func (cc *ClientConn) Shutdown(ctx context.Context) error {
|
||||
func (cc *ClientConn) shutdown(ctx context.Context) error {
|
||||
if err := cc.sendGoAway(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1247,10 +887,7 @@ func (cc *ClientConn) closeForError(err error) {
|
||||
cc.closeConn()
|
||||
}
|
||||
|
||||
// Close closes the client connection immediately.
|
||||
//
|
||||
// In-flight requests are interrupted. For a graceful shutdown, use Shutdown instead.
|
||||
func (cc *ClientConn) Close() error {
|
||||
func (cc *ClientConn) close() error {
|
||||
cc.closeForError(errClientConnForceClosed)
|
||||
return nil
|
||||
}
|
||||
@@ -1304,11 +941,11 @@ func (cc *ClientConn) decrStreamReservationsLocked() {
|
||||
}
|
||||
}
|
||||
|
||||
func (cc *ClientConn) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return cc.roundTrip(req, nil)
|
||||
func (cc *ClientConn) roundTrip(req *http.Request) (*http.Response, error) {
|
||||
return cc.internalRoundTrip(req, nil)
|
||||
}
|
||||
|
||||
func (cc *ClientConn) roundTrip(req *http.Request, streamf func(*clientStream)) (*http.Response, error) {
|
||||
func (cc *ClientConn) internalRoundTrip(req *http.Request, streamf func(*clientStream)) (*http.Response, error) {
|
||||
ctx := req.Context()
|
||||
cs := &clientStream{
|
||||
cc: cc,
|
||||
@@ -2128,19 +1765,6 @@ func (cc *ClientConn) readLoop() {
|
||||
}
|
||||
}
|
||||
|
||||
// GoAwayError is returned by the Transport when the server closes the
|
||||
// TCP connection after sending a GOAWAY frame.
|
||||
type GoAwayError struct {
|
||||
LastStreamID uint32
|
||||
ErrCode ErrCode
|
||||
DebugData string
|
||||
}
|
||||
|
||||
func (e GoAwayError) Error() string {
|
||||
return fmt.Sprintf("http2: server sent GOAWAY and closed the connection; LastStreamID=%v, ErrCode=%v, debug=%q",
|
||||
e.LastStreamID, e.ErrCode, e.DebugData)
|
||||
}
|
||||
|
||||
func isEOFOrNetReadError(err error) bool {
|
||||
if err == io.EOF {
|
||||
return true
|
||||
@@ -2861,6 +2485,9 @@ func (rl *clientConnReadLoop) processSettingsNoWrite(f *SettingsFrame) error {
|
||||
|
||||
var seenMaxConcurrentStreams bool
|
||||
err := f.ForeachSetting(func(s Setting) error {
|
||||
if err := s.Valid(); err != nil {
|
||||
return err
|
||||
}
|
||||
switch s.ID {
|
||||
case SettingMaxFrameSize:
|
||||
cc.maxFrameSize = s.Val
|
||||
@@ -2892,9 +2519,6 @@ func (rl *clientConnReadLoop) processSettingsNoWrite(f *SettingsFrame) error {
|
||||
cc.henc.SetMaxDynamicTableSize(s.Val)
|
||||
cc.peerMaxHeaderTableSize = s.Val
|
||||
case SettingEnableConnectProtocol:
|
||||
if err := s.Valid(); err != nil {
|
||||
return err
|
||||
}
|
||||
// If the peer wants to send us SETTINGS_ENABLE_CONNECT_PROTOCOL,
|
||||
// we require that it do so in the first SETTINGS frame.
|
||||
//
|
||||
@@ -2981,7 +2605,7 @@ func (rl *clientConnReadLoop) processResetStream(f *RSTStreamFrame) error {
|
||||
}
|
||||
|
||||
// Ping sends a PING frame to the server and waits for the ack.
|
||||
func (cc *ClientConn) Ping(ctx context.Context) error {
|
||||
func (cc *ClientConn) ping(ctx context.Context) error {
|
||||
c := make(chan struct{})
|
||||
// Generate a random payload
|
||||
var p [8]byte
|
||||
@@ -3095,16 +2719,6 @@ func (cc *ClientConn) vlogf(format string, args ...interface{}) {
|
||||
cc.t.vlogf(format, args...)
|
||||
}
|
||||
|
||||
func (t *Transport) vlogf(format string, args ...interface{}) {
|
||||
if VerboseLogs {
|
||||
t.logf(format, args...)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Transport) logf(format string, args ...interface{}) {
|
||||
log.Printf(format, args...)
|
||||
}
|
||||
|
||||
var noBody io.ReadCloser = noBodyReader{}
|
||||
|
||||
type noBodyReader struct{}
|
||||
@@ -3420,17 +3034,3 @@ func traceGot1xxResponseFunc(trace *httptrace.ClientTrace) func(int, textproto.M
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// dialTLSWithContext uses tls.Dialer, added in Go 1.15, to open a TLS
|
||||
// connection.
|
||||
func (t *Transport) dialTLSWithContext(ctx context.Context, network, addr string, cfg *tls.Config) (*tls.Conn, error) {
|
||||
dialer := &tls.Dialer{
|
||||
Config: cfg,
|
||||
}
|
||||
cn, err := dialer.DialContext(ctx, network, addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tlsCn := cn.(*tls.Conn) // DialContext comment promises this will always succeed
|
||||
return tlsCn, nil
|
||||
}
|
||||
|
||||
+447
@@ -0,0 +1,447 @@
|
||||
// Copyright 2026 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package http2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
mathrand "math/rand"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/idna"
|
||||
)
|
||||
|
||||
// ConfigureTransport configures a net/http HTTP/1 Transport to use HTTP/2.
|
||||
// It returns an error if t1 has already been HTTP/2-enabled.
|
||||
//
|
||||
// Use ConfigureTransports instead to configure the HTTP/2 Transport.
|
||||
func ConfigureTransport(t1 *http.Transport) error {
|
||||
return configureTransport(t1)
|
||||
}
|
||||
|
||||
// ConfigureTransports configures a net/http HTTP/1 Transport to use HTTP/2.
|
||||
// It returns a new HTTP/2 Transport for further configuration.
|
||||
// It returns an error if t1 has already been HTTP/2-enabled.
|
||||
func ConfigureTransports(t1 *http.Transport) (*Transport, error) {
|
||||
return configureTransports(t1)
|
||||
}
|
||||
|
||||
// Transport is an HTTP/2 Transport.
|
||||
//
|
||||
// A Transport internally caches connections to servers. It is safe
|
||||
// for concurrent use by multiple goroutines.
|
||||
type Transport struct {
|
||||
// DialTLSContext specifies an optional dial function with context for
|
||||
// creating TLS connections for requests.
|
||||
//
|
||||
// If DialTLSContext and DialTLS is nil, tls.Dial is used.
|
||||
//
|
||||
// If the returned net.Conn has a ConnectionState method like tls.Conn,
|
||||
// it will be used to set http.Response.TLS.
|
||||
DialTLSContext func(ctx context.Context, network, addr string, cfg *tls.Config) (net.Conn, error)
|
||||
|
||||
// DialTLS specifies an optional dial function for creating
|
||||
// TLS connections for requests.
|
||||
//
|
||||
// If DialTLSContext and DialTLS is nil, tls.Dial is used.
|
||||
//
|
||||
// Deprecated: Use DialTLSContext instead, which allows the transport
|
||||
// to cancel dials as soon as they are no longer needed.
|
||||
// If both are set, DialTLSContext takes priority.
|
||||
DialTLS func(network, addr string, cfg *tls.Config) (net.Conn, error)
|
||||
|
||||
// TLSClientConfig specifies the TLS configuration to use with
|
||||
// tls.Client. If nil, the default configuration is used.
|
||||
TLSClientConfig *tls.Config
|
||||
|
||||
// ConnPool optionally specifies an alternate connection pool to use.
|
||||
// If nil, the default is used.
|
||||
ConnPool ClientConnPool
|
||||
|
||||
// DisableCompression, if true, prevents the Transport from
|
||||
// requesting compression with an "Accept-Encoding: gzip"
|
||||
// request header when the Request contains no existing
|
||||
// Accept-Encoding value. If the Transport requests gzip on
|
||||
// its own and gets a gzipped response, it's transparently
|
||||
// decoded in the Response.Body. However, if the user
|
||||
// explicitly requested gzip it is not automatically
|
||||
// uncompressed.
|
||||
DisableCompression bool
|
||||
|
||||
// AllowHTTP, if true, permits HTTP/2 requests using the insecure,
|
||||
// plain-text "http" scheme. Note that this does not enable h2c support.
|
||||
AllowHTTP bool
|
||||
|
||||
// MaxHeaderListSize is the http2 SETTINGS_MAX_HEADER_LIST_SIZE to
|
||||
// send in the initial settings frame. It is how many bytes
|
||||
// of response headers are allowed. Unlike the http2 spec, zero here
|
||||
// means to use a default limit (currently 10MB). If you actually
|
||||
// want to advertise an unlimited value to the peer, Transport
|
||||
// interprets the highest possible value here (0xffffffff or 1<<32-1)
|
||||
// to mean no limit.
|
||||
MaxHeaderListSize uint32
|
||||
|
||||
// MaxReadFrameSize is the http2 SETTINGS_MAX_FRAME_SIZE to send in the
|
||||
// initial settings frame. It is the size in bytes of the largest frame
|
||||
// payload that the sender is willing to receive. If 0, no setting is
|
||||
// sent, and the value is provided by the peer, which should be 16384
|
||||
// according to the spec:
|
||||
// https://datatracker.ietf.org/doc/html/rfc7540#section-6.5.2.
|
||||
// Values are bounded in the range 16k to 16M.
|
||||
MaxReadFrameSize uint32
|
||||
|
||||
// MaxDecoderHeaderTableSize optionally specifies the http2
|
||||
// SETTINGS_HEADER_TABLE_SIZE to send in the initial settings frame. It
|
||||
// informs the remote endpoint of the maximum size of the header compression
|
||||
// table used to decode header blocks, in octets. If zero, the default value
|
||||
// of 4096 is used.
|
||||
MaxDecoderHeaderTableSize uint32
|
||||
|
||||
// MaxEncoderHeaderTableSize optionally specifies an upper limit for the
|
||||
// header compression table used for encoding request headers. Received
|
||||
// SETTINGS_HEADER_TABLE_SIZE settings are capped at this limit. If zero,
|
||||
// the default value of 4096 is used.
|
||||
MaxEncoderHeaderTableSize uint32
|
||||
|
||||
// StrictMaxConcurrentStreams controls whether the server's
|
||||
// SETTINGS_MAX_CONCURRENT_STREAMS should be respected
|
||||
// globally. If false, new TCP connections are created to the
|
||||
// server as needed to keep each under the per-connection
|
||||
// SETTINGS_MAX_CONCURRENT_STREAMS limit. If true, the
|
||||
// server's SETTINGS_MAX_CONCURRENT_STREAMS is interpreted as
|
||||
// a global limit and callers of RoundTrip block when needed,
|
||||
// waiting for their turn.
|
||||
StrictMaxConcurrentStreams bool
|
||||
|
||||
// IdleConnTimeout is the maximum amount of time an idle
|
||||
// (keep-alive) connection will remain idle before closing
|
||||
// itself.
|
||||
// Zero means no limit.
|
||||
IdleConnTimeout time.Duration
|
||||
|
||||
// ReadIdleTimeout is the timeout after which a health check using ping
|
||||
// frame will be carried out if no frame is received on the connection.
|
||||
// Note that a ping response will is considered a received frame, so if
|
||||
// there is no other traffic on the connection, the health check will
|
||||
// be performed every ReadIdleTimeout interval.
|
||||
// If zero, no health check is performed.
|
||||
ReadIdleTimeout time.Duration
|
||||
|
||||
// PingTimeout is the timeout after which the connection will be closed
|
||||
// if a response to Ping is not received.
|
||||
// Defaults to 15s.
|
||||
PingTimeout time.Duration
|
||||
|
||||
// WriteByteTimeout is the timeout after which the connection will be
|
||||
// closed no data can be written to it. The timeout begins when data is
|
||||
// available to write, and is extended whenever any bytes are written.
|
||||
WriteByteTimeout time.Duration
|
||||
|
||||
// CountError, if non-nil, is called on HTTP/2 transport errors.
|
||||
// It's intended to increment a metric for monitoring, such
|
||||
// as an expvar or Prometheus metric.
|
||||
// The errType consists of only ASCII word characters.
|
||||
CountError func(errType string)
|
||||
|
||||
// Internal state, differs between wrapped and non-wrapped implementations.
|
||||
transportInternal
|
||||
}
|
||||
|
||||
var (
|
||||
errClientConnClosed = errors.New("http2: client conn is closed")
|
||||
errClientConnNotEstablished = errors.New("http2: client conn could not be established")
|
||||
errClientConnGotGoAway = errors.New("http2: Transport received Server's graceful shutdown GOAWAY")
|
||||
errClientConnForceClosed = errors.New("http2: client connection force closed via ClientConn.Close")
|
||||
errClientConnUnusable = errors.New("http2: client conn not usable")
|
||||
)
|
||||
|
||||
// ClientConnPool manages a pool of HTTP/2 client connections.
|
||||
type ClientConnPool interface {
|
||||
// GetClientConn returns a specific HTTP/2 connection (usually
|
||||
// a TLS-TCP connection) to an HTTP/2 server. On success, the
|
||||
// returned ClientConn accounts for the upcoming RoundTrip
|
||||
// call, so the caller should not omit it. If the caller needs
|
||||
// to, ClientConn.RoundTrip can be called with a bogus
|
||||
// new(http.Request) to release the stream reservation.
|
||||
GetClientConn(req *http.Request, addr string) (*ClientConn, error)
|
||||
MarkDead(*ClientConn)
|
||||
}
|
||||
|
||||
// ClientConnState describes the state of a ClientConn.
|
||||
type ClientConnState struct {
|
||||
// Closed is whether the connection is closed.
|
||||
Closed bool
|
||||
|
||||
// Closing is whether the connection is in the process of
|
||||
// closing. It may be closing due to shutdown, being a
|
||||
// single-use connection, being marked as DoNotReuse, or
|
||||
// having received a GOAWAY frame.
|
||||
Closing bool
|
||||
|
||||
// StreamsActive is how many streams are active.
|
||||
StreamsActive int
|
||||
|
||||
// StreamsReserved is how many streams have been reserved via
|
||||
// ClientConn.ReserveNewRequest.
|
||||
StreamsReserved int
|
||||
|
||||
// StreamsPending is how many requests have been sent in excess
|
||||
// of the peer's advertised MaxConcurrentStreams setting and
|
||||
// are waiting for other streams to complete.
|
||||
StreamsPending int
|
||||
|
||||
// MaxConcurrentStreams is how many concurrent streams the
|
||||
// peer advertised as acceptable. Zero means no SETTINGS
|
||||
// frame has been received yet.
|
||||
MaxConcurrentStreams uint32
|
||||
|
||||
// LastIdle, if non-zero, is when the connection last
|
||||
// transitioned to idle state.
|
||||
LastIdle time.Time
|
||||
}
|
||||
|
||||
// RoundTripOpt are options for the Transport.RoundTripOpt method.
|
||||
type RoundTripOpt struct {
|
||||
// OnlyCachedConn controls whether RoundTripOpt may
|
||||
// create a new TCP connection. If set true and
|
||||
// no cached connection is available, RoundTripOpt
|
||||
// will return ErrNoCachedConn.
|
||||
|
||||
// OnlyCachedConn was broken in https://go.dev/cl/16699.
|
||||
OnlyCachedConn bool
|
||||
|
||||
allowHTTP bool // allow http:// URLs
|
||||
}
|
||||
|
||||
func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return t.RoundTripOpt(req, RoundTripOpt{})
|
||||
}
|
||||
|
||||
// RoundTripOpt is like RoundTrip, but takes options.
|
||||
func (t *Transport) RoundTripOpt(req *http.Request, opt RoundTripOpt) (*http.Response, error) {
|
||||
return t.roundTripOpt(req, opt)
|
||||
}
|
||||
|
||||
// CloseIdleConnections closes any connections which were previously
|
||||
// connected from previous requests but are now sitting idle.
|
||||
// It does not interrupt any connections currently in use.
|
||||
func (t *Transport) CloseIdleConnections() {
|
||||
t.closeIdleConnections()
|
||||
}
|
||||
|
||||
func (t *Transport) NewClientConn(c net.Conn) (*ClientConn, error) {
|
||||
return t.newUserClientConn(c)
|
||||
}
|
||||
|
||||
// authorityAddr returns a given authority (a host/IP, or host:port / ip:port)
|
||||
// and returns a host:port. The port 443 is added if needed.
|
||||
func authorityAddr(scheme string, authority string) (addr string) {
|
||||
host, port, err := net.SplitHostPort(authority)
|
||||
if err != nil { // authority didn't have a port
|
||||
host = authority
|
||||
port = ""
|
||||
}
|
||||
if port == "" { // authority's port was empty
|
||||
port = "443"
|
||||
if scheme == "http" {
|
||||
port = "80"
|
||||
}
|
||||
}
|
||||
if a, err := idna.ToASCII(host); err == nil {
|
||||
host = a
|
||||
}
|
||||
// IPv6 address literal, without a port:
|
||||
if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
|
||||
return host + ":" + port
|
||||
}
|
||||
return net.JoinHostPort(host, port)
|
||||
}
|
||||
|
||||
func (t *Transport) roundTripViaPool(req *http.Request, opt RoundTripOpt, pool ClientConnPool) (*http.Response, error) {
|
||||
addr := authorityAddr(req.URL.Scheme, req.URL.Host)
|
||||
for retry := 0; ; retry++ {
|
||||
cc, err := pool.GetClientConn(req, addr)
|
||||
if err != nil {
|
||||
t.vlogf("http2: Transport failed to get client conn for %s: %v", addr, err)
|
||||
return nil, err
|
||||
}
|
||||
reused := !atomic.CompareAndSwapUint32(&cc.atomicReused, 0, 1)
|
||||
traceGotConn(req, cc, reused)
|
||||
res, err := cc.RoundTrip(req)
|
||||
if err != nil && retry <= 6 {
|
||||
roundTripErr := err
|
||||
if req, err = shouldRetryRequest(req, err); err == nil {
|
||||
// After the first retry, do exponential backoff with 10% jitter.
|
||||
if retry == 0 {
|
||||
t.vlogf("RoundTrip retrying after failure: %v", roundTripErr)
|
||||
continue
|
||||
}
|
||||
backoff := float64(uint(1) << (uint(retry) - 1))
|
||||
backoff += backoff * (0.1 * mathrand.Float64())
|
||||
d := time.Second * time.Duration(backoff)
|
||||
tm := time.NewTimer(d)
|
||||
select {
|
||||
case <-tm.C:
|
||||
t.vlogf("RoundTrip retrying after failure: %v", roundTripErr)
|
||||
continue
|
||||
case <-req.Context().Done():
|
||||
tm.Stop()
|
||||
err = req.Context().Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
if err == errClientConnNotEstablished {
|
||||
// This ClientConn was created recently,
|
||||
// this is the first request to use it,
|
||||
// and the connection is closed and not usable.
|
||||
//
|
||||
// In this state, cc.idleTimer will remove the conn from the pool
|
||||
// when it fires. Stop the timer and remove it here so future requests
|
||||
// won't try to use this connection.
|
||||
//
|
||||
// If the timer has already fired and we're racing it, the redundant
|
||||
// call to MarkDead is harmless.
|
||||
cc.stopIdleTimer()
|
||||
pool.MarkDead(cc)
|
||||
}
|
||||
if err != nil {
|
||||
t.vlogf("RoundTrip failure: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
}
|
||||
|
||||
// shouldRetryRequest is called by RoundTrip when a request fails to get
|
||||
// response headers. It is always called with a non-nil error.
|
||||
// It returns either a request to retry (either the same request, or a
|
||||
// modified clone), or an error if the request can't be replayed.
|
||||
func shouldRetryRequest(req *http.Request, err error) (*http.Request, error) {
|
||||
if !canRetryError(err) {
|
||||
return nil, err
|
||||
}
|
||||
// If the Body is nil (or http.NoBody), it's safe to reuse
|
||||
// this request and its Body.
|
||||
if req.Body == nil || req.Body == http.NoBody {
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// If the request body can be reset back to its original
|
||||
// state via the optional req.GetBody, do that.
|
||||
if req.GetBody != nil {
|
||||
body, err := req.GetBody()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
newReq := *req
|
||||
newReq.Body = body
|
||||
return &newReq, nil
|
||||
}
|
||||
|
||||
// The Request.Body can't reset back to the beginning, but we
|
||||
// don't seem to have started to read from it yet, so reuse
|
||||
// the request directly.
|
||||
if err == errClientConnUnusable {
|
||||
return req, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("http2: Transport: cannot retry err [%v] after Request.Body was written; define Request.GetBody to avoid this error", err)
|
||||
}
|
||||
|
||||
func canRetryError(err error) bool {
|
||||
if err == errClientConnUnusable || err == errClientConnGotGoAway {
|
||||
return true
|
||||
}
|
||||
if se, ok := err.(StreamError); ok {
|
||||
return se.Code == ErrCodeRefusedStream
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (t *Transport) vlogf(format string, args ...interface{}) {
|
||||
if VerboseLogs {
|
||||
t.logf(format, args...)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Transport) logf(format string, args ...interface{}) {
|
||||
log.Printf(format, args...)
|
||||
}
|
||||
|
||||
func (t *Transport) dialTLS(ctx context.Context, network, addr string, tlsCfg *tls.Config) (net.Conn, error) {
|
||||
if t.DialTLSContext != nil {
|
||||
return t.DialTLSContext(ctx, network, addr, tlsCfg)
|
||||
} else if t.DialTLS != nil {
|
||||
return t.DialTLS(network, addr, tlsCfg)
|
||||
}
|
||||
|
||||
tlsCn, err := t.dialTLSWithContext(ctx, network, addr, tlsCfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
state := tlsCn.ConnectionState()
|
||||
if p := state.NegotiatedProtocol; p != NextProtoTLS {
|
||||
return nil, fmt.Errorf("http2: unexpected ALPN protocol %q; want %q", p, NextProtoTLS)
|
||||
}
|
||||
if !state.NegotiatedProtocolIsMutual {
|
||||
return nil, errors.New("http2: could not negotiate protocol mutually")
|
||||
}
|
||||
return tlsCn, nil
|
||||
}
|
||||
|
||||
// dialTLSWithContext uses tls.Dialer, added in Go 1.15, to open a TLS
|
||||
// connection.
|
||||
func (t *Transport) dialTLSWithContext(ctx context.Context, network, addr string, cfg *tls.Config) (*tls.Conn, error) {
|
||||
dialer := &tls.Dialer{
|
||||
Config: cfg,
|
||||
}
|
||||
cn, err := dialer.DialContext(ctx, network, addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tlsCn := cn.(*tls.Conn) // DialContext comment promises this will always succeed
|
||||
return tlsCn, nil
|
||||
}
|
||||
|
||||
// GoAwayError is returned by the Transport when the server closes the
|
||||
// TCP connection after sending a GOAWAY frame.
|
||||
type GoAwayError struct {
|
||||
LastStreamID uint32
|
||||
ErrCode ErrCode
|
||||
DebugData string
|
||||
}
|
||||
|
||||
func (e GoAwayError) Error() string {
|
||||
return fmt.Sprintf("http2: server sent GOAWAY and closed the connection; LastStreamID=%v, ErrCode=%v, debug=%q",
|
||||
e.LastStreamID, e.ErrCode, e.DebugData)
|
||||
}
|
||||
|
||||
// noCachedConnError is the concrete type of ErrNoCachedConn, which
|
||||
// needs to be detected by net/http regardless of whether it's its
|
||||
// bundled version (in h2_bundle.go with a rewritten type name) or
|
||||
// from a user's x/net/http2. As such, as it has a unique method name
|
||||
// (IsHTTP2NoCachedConnError) that net/http sniffs for via func
|
||||
// isNoCachedConnError.
|
||||
type noCachedConnError struct{}
|
||||
|
||||
func (noCachedConnError) IsHTTP2NoCachedConnError() {}
|
||||
func (noCachedConnError) Error() string { return "http2: no cached connection was available" }
|
||||
|
||||
// isNoCachedConnError reports whether err is of type noCachedConnError
|
||||
// or its equivalent renamed type in net/http2's h2_bundle.go. Both types
|
||||
// may coexist in the same running program.
|
||||
func isNoCachedConnError(err error) bool {
|
||||
_, ok := err.(interface{ IsHTTP2NoCachedConnError() })
|
||||
return ok
|
||||
}
|
||||
|
||||
var ErrNoCachedConn error = noCachedConnError{}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user