chore(deps): bump github.com/go-git/go-git/v5 from 5.16.5 to 5.19.1

Bumps [github.com/go-git/go-git/v5](https://github.com/go-git/go-git) from 5.16.5 to 5.19.1.
- [Release notes](https://github.com/go-git/go-git/releases)
- [Changelog](https://github.com/go-git/go-git/blob/main/HISTORY.md)
- [Commits](https://github.com/go-git/go-git/compare/v5.16.5...v5.19.1)

---
updated-dependencies:
- dependency-name: github.com/go-git/go-git/v5
  dependency-version: 5.19.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
This commit is contained in:
dependabot[bot]
2026-05-19 16:11:22 +00:00
committed by GitHub
parent b8098dc1b9
commit 4799c2cb17
76 changed files with 3932 additions and 806 deletions
+6 -6
View File
@@ -5,7 +5,7 @@ go 1.26
require ( require (
github.com/alecthomas/chroma/v2 v2.23.1 github.com/alecthomas/chroma/v2 v2.23.1
github.com/davecgh/go-spew v1.1.1 github.com/davecgh/go-spew v1.1.1
github.com/go-git/go-git/v5 v5.16.5 github.com/go-git/go-git/v5 v5.19.1
github.com/mattn/go-isatty v0.0.20 github.com/mattn/go-isatty v0.0.20
github.com/mitchellh/go-homedir v1.1.0 github.com/mitchellh/go-homedir v1.1.0
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
@@ -21,19 +21,19 @@ require (
github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/emirpasic/gods v1.18.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.7.0 // indirect github.com/go-git/go-billy/v5 v5.9.0 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/kevinburke/ssh_config v1.5.0 // indirect github.com/kevinburke/ssh_config v1.5.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/pjbgf/sha1cd v0.5.0 // indirect github.com/pjbgf/sha1cd v0.6.0 // indirect
github.com/sergi/go-diff v1.4.0 // indirect github.com/sergi/go-diff v1.4.0 // indirect
github.com/skeema/knownhosts v1.3.2 // indirect github.com/skeema/knownhosts v1.3.2 // indirect
github.com/spf13/pflag v1.0.9 // indirect github.com/spf13/pflag v1.0.9 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect
golang.org/x/crypto v0.48.0 // indirect golang.org/x/crypto v0.50.0 // indirect
golang.org/x/net v0.50.0 // indirect golang.org/x/net v0.53.0 // indirect
golang.org/x/sys v0.41.0 // indirect golang.org/x/sys v0.43.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect
) )
+18 -18
View File
@@ -33,12 +33,12 @@ github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= 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 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.7.0 h1:83lBUJhGWhYp0ngzCMSgllhUSuoHP1iEWYjsPl9nwqM= github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA=
github.com/go-git/go-billy/v5 v5.7.0/go.mod h1:/1IUejTKH8xipsAcdfcSAlUlo2J7lkYV8GTKxAT/L3E= 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 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-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.16.5 h1:mdkuqblwr57kVfXri5TTH+nMFLNUxIj9Z7F5ykFbw5s= github.com/go-git/go-git/v5 v5.19.1 h1:nX27AnaU43/K5bKktKwgBmR9lawoYVe1Ckg0rgzzN00=
github.com/go-git/go-git/v5 v5.16.5/go.mod h1:QOMLpNf1qxuSY4StA/ArOdfFR2TrKEjJiye2kel2m+M= github.com/go-git/go-git/v5 v5.19.1/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -66,8 +66,8 @@ github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0= github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU=
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -93,13 +93,13 @@ github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -107,14 +107,14 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/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-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+4
View File
@@ -5,6 +5,10 @@ Billy implements an interface based on the `os` standard library, allowing to de
Billy was born as part of [go-git/go-git](https://github.com/go-git/go-git) project. 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 ## Installation
```go ```go
+198 -10
View File
@@ -3,19 +3,25 @@ package chroot
import ( import (
"errors" "errors"
"os" "os"
"path"
"path/filepath" "path/filepath"
"strings" "strings"
"syscall"
"github.com/go-git/go-billy/v5" "github.com/go-git/go-billy/v5"
"github.com/go-git/go-billy/v5/helper/polyfill" "github.com/go-git/go-billy/v5/helper/polyfill"
) )
// ChrootHelper is a helper to implement billy.Chroot. // 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 { type ChrootHelper struct {
underlying billy.Filesystem underlying billy.Filesystem
base string base string
} }
const maxFollowedSymlinks = 8 // Aligns with POSIX_SYMLOOP_MAX
// New creates a new filesystem wrapping up the given 'fs'. // New creates a new filesystem wrapping up the given 'fs'.
// The created filesystem has its base in the given ChrootHelperectory of the // The created filesystem has its base in the given ChrootHelperectory of the
// underlying filesystem. // underlying filesystem.
@@ -34,15 +40,184 @@ func (fs *ChrootHelper) underlyingPath(filename string) (string, error) {
return fs.Join(fs.Root(), filename), nil return fs.Join(fs.Root(), filename), nil
} }
func isCrossBoundaries(path string) bool { func (fs *ChrootHelper) followedPath(filename string, followFinal bool, op string) (string, error) {
path = filepath.ToSlash(path) fullpath, err := fs.underlyingPath(filename)
path = filepath.Clean(path) 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) { func (fs *ChrootHelper) Create(filename string) (billy.File, error) {
fullpath, err := fs.underlyingPath(filename) fullpath, err := fs.followedPath(filename, true, "create")
if err != nil { if err != nil {
return nil, err 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) { func (fs *ChrootHelper) Open(filename string) (billy.File, error) {
fullpath, err := fs.underlyingPath(filename) fullpath, err := fs.followedPath(filename, true, "open")
if err != nil { if err != nil {
return nil, err 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) { 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 { if err != nil {
return nil, err 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) { func (fs *ChrootHelper) Stat(filename string) (os.FileInfo, error) {
fullpath, err := fs.underlyingPath(filename) fullpath, err := fs.followedPath(filename, true, "stat")
if err != nil { if err != nil {
return nil, err 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 { 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) { func (fs *ChrootHelper) ReadDir(path string) ([]os.FileInfo, error) {
fullpath, err := fs.underlyingPath(path) fullpath, err := fs.followedPath(path, true, "readdir")
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -241,6 +420,11 @@ type file struct {
name string name string
} }
type fileInfo struct {
os.FileInfo
name string
}
func newFile(fs billy.Filesystem, f billy.File, filename string) billy.File { func newFile(fs billy.Filesystem, f billy.File, filename string) billy.File {
filename = fs.Join(fs.Root(), filename) filename = fs.Join(fs.Root(), filename)
filename, _ = filepath.Rel(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 { func (f *file) Name() string {
return f.name return f.name
} }
func (fi fileInfo) Name() string {
return fi.name
}
+10 -1
View File
@@ -13,7 +13,7 @@ type Polyfill struct {
c capabilities c capabilities
} }
type capabilities struct{ tempfile, dir, symlink, chroot bool } type capabilities struct{ tempfile, dir, symlink, chroot, chmod bool }
// New creates a new filesystem wrapping up 'fs' the intercepts all the calls // New creates a new filesystem wrapping up 'fs' the intercepts all the calls
// made and errors if fs doesn't implement any of the billy interfaces. // made and errors if fs doesn't implement any of the billy interfaces.
@@ -28,6 +28,7 @@ func New(fs billy.Basic) billy.Filesystem {
_, h.c.dir = h.Basic.(billy.Dir) _, h.c.dir = h.Basic.(billy.Dir)
_, h.c.symlink = h.Basic.(billy.Symlink) _, h.c.symlink = h.Basic.(billy.Symlink)
_, h.c.chroot = h.Basic.(billy.Chroot) _, h.c.chroot = h.Basic.(billy.Chroot)
_, h.c.chmod = h.Basic.(billy.Chmod)
return h return h
} }
@@ -87,6 +88,14 @@ func (h *Polyfill) Chroot(path string) (billy.Filesystem, error) {
return h.Basic.(billy.Chroot).Chroot(path) return h.Basic.(billy.Chroot).Chroot(path)
} }
func (h *Polyfill) Chmod(path string, mode os.FileMode) error {
if !h.c.chmod {
return billy.ErrNotSupported
}
return h.Basic.(billy.Chmod).Chmod(path, mode)
}
func (h *Polyfill) Root() string { func (h *Polyfill) Root() string {
if !h.c.chroot { if !h.c.chroot {
return string(filepath.Separator) return string(filepath.Separator)
+5
View File
@@ -24,6 +24,9 @@ var Default = &ChrootOS{}
// New returns a new OS filesystem. // New returns a new OS filesystem.
// By default paths are deduplicated, but still enforced // By default paths are deduplicated, but still enforced
// under baseDir. For more info refer to WithDeduplicatePath. // 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 { func New(baseDir string, opts ...Option) billy.Filesystem {
o := &options{ o := &options{
deduplicatePath: true, deduplicatePath: true,
@@ -47,6 +50,8 @@ func WithBoundOS() Option {
} }
// WithChrootOS returns the option of using a Chroot filesystem OS. // WithChrootOS returns the option of using a Chroot filesystem OS.
//
// Deprecated: use WithBoundOS instead.
func WithChrootOS() Option { func WithChrootOS() Option {
return func(o *options) { return func(o *options) {
o.Type = ChrootOSFS o.Type = ChrootOSFS
+93 -15
View File
@@ -20,6 +20,7 @@
package osfs package osfs
import ( import (
"errors"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
@@ -29,6 +30,31 @@ import (
"github.com/go-git/go-billy/v5" "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 // BoundOS is a fs implementation based on the OS filesystem which is bound to
// a base dir. // a base dir.
// Prefer this fs implementation over ChrootOS. // 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) { func (fs *BoundOS) OpenFile(filename string, flag int, perm os.FileMode) (billy.File, error) {
filename = fs.expandDot(filename)
fn, err := fs.abs(filename) fn, err := fs.abs(filename)
if err != nil { if err != nil {
return nil, err 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) { func (fs *BoundOS) ReadDir(path string) ([]os.FileInfo, error) {
path = fs.expandDot(path)
dir, err := fs.abs(path) dir, err := fs.abs(path)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -71,6 +99,12 @@ func (fs *BoundOS) ReadDir(path string) ([]os.FileInfo, error) {
} }
func (fs *BoundOS) Rename(from, to string) 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) f, err := fs.abs(from)
if err != nil { if err != nil {
return err return err
@@ -89,6 +123,7 @@ func (fs *BoundOS) Rename(from, to string) error {
} }
func (fs *BoundOS) MkdirAll(path string, perm os.FileMode) error { func (fs *BoundOS) MkdirAll(path string, perm os.FileMode) error {
path = fs.expandDot(path)
dir, err := fs.abs(path) dir, err := fs.abs(path)
if err != nil { if err != nil {
return err return err
@@ -101,6 +136,7 @@ func (fs *BoundOS) Open(filename string) (billy.File, error) {
} }
func (fs *BoundOS) Stat(filename string) (os.FileInfo, error) { func (fs *BoundOS) Stat(filename string) (os.FileInfo, error) {
filename = fs.expandDot(filename)
filename, err := fs.abs(filename) filename, err := fs.abs(filename)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -109,6 +145,11 @@ func (fs *BoundOS) Stat(filename string) (os.FileInfo, error) {
} }
func (fs *BoundOS) Remove(filename string) error { func (fs *BoundOS) Remove(filename string) error {
if fs.isBaseDir(filename) {
return ErrBaseDirCannotBeRemoved
}
filename = fs.expandDot(filename)
fn, err := fs.abs(filename) fn, err := fs.abs(filename)
if err != nil { if err != nil {
return err return err
@@ -122,10 +163,19 @@ func (fs *BoundOS) Remove(filename string) error {
func (fs *BoundOS) TempFile(dir, prefix string) (billy.File, error) { func (fs *BoundOS) TempFile(dir, prefix string) (billy.File, error) {
if dir != "" { if dir != "" {
var err error var err error
dir = fs.expandDot(dir)
dir, err = fs.abs(dir) dir, err = fs.abs(dir)
if err != nil { if err != nil {
return nil, err return nil, err
} }
_, err = os.Stat(dir)
if err != nil && os.IsNotExist(err) {
err = os.MkdirAll(dir, defaultDirectoryMode)
if err != nil {
return nil, err
}
}
} }
return tempFile(dir, prefix) return tempFile(dir, prefix)
@@ -136,6 +186,11 @@ func (fs *BoundOS) Join(elem ...string) string {
} }
func (fs *BoundOS) RemoveAll(path string) error { func (fs *BoundOS) RemoveAll(path string) error {
if fs.isBaseDir(path) {
return ErrBaseDirCannotBeRemoved
}
path = fs.expandDot(path)
dir, err := fs.abs(path) dir, err := fs.abs(path)
if err != nil { if err != nil {
return err return err
@@ -144,6 +199,7 @@ func (fs *BoundOS) RemoveAll(path string) error {
} }
func (fs *BoundOS) Symlink(target, link string) error { func (fs *BoundOS) Symlink(target, link string) error {
link = fs.expandDot(link)
ln, err := fs.abs(link) ln, err := fs.abs(link)
if err != nil { if err != nil {
return err return err
@@ -156,6 +212,7 @@ func (fs *BoundOS) Symlink(target, link string) error {
} }
func (fs *BoundOS) Lstat(filename string) (os.FileInfo, error) { func (fs *BoundOS) Lstat(filename string) (os.FileInfo, error) {
filename = fs.expandDot(filename)
filename = filepath.Clean(filename) filename = filepath.Clean(filename)
if !filepath.IsAbs(filename) { if !filepath.IsAbs(filename) {
filename = filepath.Join(fs.baseDir, filename) filename = filepath.Join(fs.baseDir, filename)
@@ -167,6 +224,7 @@ func (fs *BoundOS) Lstat(filename string) (os.FileInfo, error) {
} }
func (fs *BoundOS) Readlink(link string) (string, error) { func (fs *BoundOS) Readlink(link string) (string, error) {
link = fs.expandDot(link)
if !filepath.IsAbs(link) { if !filepath.IsAbs(link) {
link = filepath.Clean(filepath.Join(fs.baseDir, link)) link = filepath.Clean(filepath.Join(fs.baseDir, link))
} }
@@ -177,6 +235,7 @@ func (fs *BoundOS) Readlink(link string) (string, error) {
} }
func (fs *BoundOS) Chmod(path string, mode os.FileMode) error { func (fs *BoundOS) Chmod(path string, mode os.FileMode) error {
path = fs.expandDot(path)
abspath, err := fs.abs(path) abspath, err := fs.abs(path)
if err != nil { if err != nil {
return err return err
@@ -191,7 +250,7 @@ func (fs *BoundOS) Chroot(path string) (billy.Filesystem, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
return New(joined), nil return New(joined, WithBoundOS()), nil
} }
// Root returns the current base dir of the billy.Filesystem. // Root returns the current base dir of the billy.Filesystem.
@@ -212,6 +271,37 @@ func (fs *BoundOS) createDir(fullpath string) error {
return nil 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. // 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 // Relative paths won't be allowed to ascend the base dir, so `../file` will become
// `/working-dir/file`. // `/working-dir/file`.
@@ -225,7 +315,7 @@ func (fs *BoundOS) abs(filename string) (string, error) {
path, err := securejoin.SecureJoin(fs.baseDir, filename) path, err := securejoin.SecureJoin(fs.baseDir, filename)
if err != nil { if err != nil {
return "", nil return "", err
} }
if fs.deduplicatePath { if fs.deduplicatePath {
@@ -238,24 +328,12 @@ func (fs *BoundOS) abs(filename string) (string, error) {
return path, nil 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 // insideBaseDirEval checks whether filename is contained within
// a dir that is within the fs.baseDir, by first evaluating any symlinks // a dir that is within the fs.baseDir, by first evaluating any symlinks
// that either filename or fs.baseDir may contain. // that either filename or fs.baseDir may contain.
func (fs *BoundOS) insideBaseDirEval(filename string) (bool, error) { func (fs *BoundOS) insideBaseDirEval(filename string) (bool, error) {
// "/" contains all others. // "/" contains all others.
if fs.baseDir == "/" { if fs.baseDir == "/" || fs.baseDir == filename {
return true, nil return true, nil
} }
dir, err := filepath.EvalSymlinks(filepath.Dir(filename)) dir, err := filepath.EvalSymlinks(filepath.Dir(filename))
+10
View File
@@ -14,6 +14,8 @@ import (
// ChrootOS is a legacy filesystem based on a "soft chroot" of the os filesystem. // 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. // Although this is still the default os filesystem, consider using BoundOS instead.
// //
// Deprecated: use New with WithBoundOS instead.
//
// Behaviours of note: // Behaviours of note:
// 1. A "soft chroot" translates the base dir to "/" for the purposes of the // 1. A "soft chroot" translates the base dir to "/" for the purposes of the
// fs abstraction. // fs abstraction.
@@ -24,6 +26,14 @@ import (
type ChrootOS struct{} type ChrootOS struct{}
func newChrootOS(baseDir string) billy.Filesystem { 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) return chroot.New(&ChrootOS{}, baseDir)
} }
+19 -24
View File
@@ -16,8 +16,6 @@ import (
// can but returns the first error it encounters. If the path does not exist, // can but returns the first error it encounters. If the path does not exist,
// RemoveAll returns nil (no error). // RemoveAll returns nil (no error).
func RemoveAll(fs billy.Basic, path string) error { func RemoveAll(fs billy.Basic, path string) error {
fs, path = getUnderlyingAndPath(fs, path)
if r, ok := fs.(removerAll); ok { if r, ok := fs.(removerAll); ok {
return r.RemoveAll(path) 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? // Otherwise, is this a directory we need to recurse into?
dir, serr := fs.Stat(path) dir, serr := lstat(fs, path)
if serr != nil { if serr != nil {
if errors.Is(serr, os.ErrNotExist) { if errors.Is(serr, os.ErrNotExist) {
return nil return nil
@@ -48,8 +46,8 @@ func removeAll(fs billy.Basic, path string) error {
return serr return serr
} }
if !dir.IsDir() { if dir.Mode()&os.ModeSymlink != 0 || !dir.IsDir() {
// Not a directory; return the error from Remove. // Not a directory we should recurse into; return the error from Remove.
return err return err
} }
@@ -62,7 +60,7 @@ func removeAll(fs billy.Basic, path string) error {
fis, err := dirfs.ReadDir(path) fis, err := dirfs.ReadDir(path)
if err != nil { if err != nil {
if errors.Is(err, os.ErrNotExist) { 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 per RemoveAll's docs.
return nil return nil
} }
@@ -91,7 +89,18 @@ func removeAll(fs billy.Basic, path string) error {
} }
return err 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. // 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 // 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 // chance the file doesn't exist yet - keeps the number of tries in
// TempFile to a minimum. // TempFile to a minimum.
var rand uint32 var (
var randmu sync.Mutex rand uint32
randmu sync.Mutex
)
func reseed() uint32 { func reseed() uint32 {
return uint32(time.Now().UnixNano() + int64(os.Getpid())) return uint32(time.Now().UnixNano() + int64(os.Getpid()))
@@ -220,22 +231,6 @@ func getTempDir(fs billy.Basic) string {
return ".tmp" 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. // ReadFile reads the named file and returns the contents from the given filesystem.
// A successful call returns err == nil, not err == EOF. // A successful call returns err == nil, not err == EOF.
// Because ReadFile reads the whole file, it does not treat an EOF from Read // Because ReadFile reads the whole file, it does not treat an EOF from Read
+30 -1
View File
@@ -61,6 +61,16 @@ type Config struct {
CommentChar string CommentChar string
// RepositoryFormatVersion identifies the repository format and layout version. // RepositoryFormatVersion identifies the repository format and layout version.
RepositoryFormatVersion format.RepositoryFormatVersion 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 { User struct {
@@ -266,6 +276,8 @@ const (
repositoryFormatVersionKey = "repositoryformatversion" repositoryFormatVersionKey = "repositoryformatversion"
objectFormat = "objectformat" objectFormat = "objectformat"
mirrorKey = "mirror" mirrorKey = "mirror"
protectNTFSKey = "protectNTFS"
protectHFSKey = "protectHFS"
// DefaultPackWindow holds the number of previous objects used to // DefaultPackWindow holds the number of previous objects used to
// generate deltas. The value 10 is the same used by git command. // 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.Worktree = s.Options.Get(worktreeKey)
c.Core.CommentChar = s.Options.Get(commentCharKey) 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() { func (c *Config) unmarshalUser() {
@@ -379,7 +399,8 @@ func unmarshalSubmodules(fc *format.Config, submodules map[string]*Submodule) {
m := &Submodule{} m := &Submodule{}
m.unmarshal(sub) m.unmarshal(sub)
if m.Validate() == ErrModuleBadPath { if err := m.Validate(); errors.Is(err, ErrModuleBadPath) ||
errors.Is(err, ErrModuleBadName) {
continue continue
} }
@@ -436,6 +457,14 @@ func (c *Config) marshalCore() {
if c.Core.Worktree != "" { if c.Core.Worktree != "" {
s.SetOption(worktreeKey, 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() { func (c *Config) marshalExtensions() {
+52
View File
@@ -3,8 +3,11 @@ package config
import ( import (
"bytes" "bytes"
"errors" "errors"
"fmt"
"regexp" "regexp"
"strings"
"github.com/go-git/go-git/v5/internal/pathutil"
format "github.com/go-git/go-git/v5/plumbing/format/config" format "github.com/go-git/go-git/v5/plumbing/format/config"
) )
@@ -12,6 +15,7 @@ var (
ErrModuleEmptyURL = errors.New("module config: empty URL") ErrModuleEmptyURL = errors.New("module config: empty URL")
ErrModuleEmptyPath = errors.New("module config: empty path") ErrModuleEmptyPath = errors.New("module config: empty path")
ErrModuleBadPath = errors.New("submodule has an invalid path") ErrModuleBadPath = errors.New("submodule has an invalid path")
ErrModuleBadName = errors.New("ignoring suspicious submodule name")
) )
var ( var (
@@ -94,6 +98,10 @@ type Submodule struct {
// Validate validates the fields and sets the default values. // Validate validates the fields and sets the default values.
func (m *Submodule) Validate() error { func (m *Submodule) Validate() error {
if err := validSubmoduleName(m.Name); err != nil {
return fmt.Errorf("%w: %q", ErrModuleBadName, m.Name)
}
if m.Path == "" { if m.Path == "" {
return ErrModuleEmptyPath return ErrModuleEmptyPath
} }
@@ -109,6 +117,50 @@ func (m *Submodule) Validate() error {
return nil 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) { func (m *Submodule) unmarshal(s *format.Subsection) {
m.raw = s m.raw = s
+82
View File
@@ -0,0 +1,82 @@
package config
import (
"strconv"
"strings"
)
// OptBool is a tri-state boolean: unset, explicitly false, or explicitly true.
// Its zero value (OptBoolUnset) means the setting was not specified, which
// allows merge logic based on reflect.Value.IsZero to skip unset fields while
// still letting an explicit "false" override a previously set "true".
type OptBool byte
const (
// OptBoolUnset indicates the setting was not specified.
OptBoolUnset OptBool = iota
// OptBoolFalse indicates the setting was explicitly set to false.
OptBoolFalse
// OptBoolTrue indicates the setting was explicitly set to true.
OptBoolTrue
)
// NewOptBool converts a plain bool into an OptBool.
func NewOptBool(v bool) OptBool {
if v {
return OptBoolTrue
}
return OptBoolFalse
}
// IsTrue returns whether the value is explicitly true.
func (o OptBool) IsTrue() bool { return o == OptBoolTrue }
// IsSet returns whether the value was explicitly specified (true or false).
func (o OptBool) IsSet() bool { return o != OptBoolUnset }
func (o OptBool) String() string {
switch o {
case OptBoolTrue:
return "true"
case OptBoolFalse:
return "false"
default:
return "unset"
}
}
// FormatBool returns the strconv-formatted value. Only meaningful when IsSet.
func (o OptBool) FormatBool() string {
return strconv.FormatBool(o.IsTrue())
}
// parseConfigBool mirrors upstream Git's git_parse_maybe_bool: it
// accepts true/yes/on (→ OptBoolTrue) and false/no/off (→
// OptBoolFalse) case-insensitively, plus any decimal integer (zero
// → OptBoolFalse, non-zero → OptBoolTrue). Empty or otherwise
// unrecognised values return OptBoolUnset, leaving the caller's
// platform default in place. The empty-string handling is the only
// intentional divergence from upstream, which returns false for
// empty: in our unmarshalCore caller, an empty value means the key
// is unset and the platform default should apply.
//
// Reference: upstream Git git_parse_maybe_bool_text at parse.c
// L157-L173 and git_parse_maybe_bool at parse.c L174-L182 in tag
// v2.54.0[1].
//
// [1]: https://github.com/git/git/blob/v2.54.0/parse.c#L157-L182
func parseConfigBool(v string) OptBool {
switch strings.ToLower(v) {
case "true", "yes", "on":
return OptBoolTrue
case "false", "no", "off":
return OptBoolFalse
}
if i, err := strconv.Atoi(v); err == nil {
if i != 0 {
return OptBoolTrue
}
return OptBoolFalse
}
return OptBoolUnset
}
+21
View File
@@ -0,0 +1,21 @@
package pathutil
import "strings"
// IsDotGitName reports whether name is `.git` or its 8.3 NTFS short
// alias `git~1`, case-insensitively. Both are forbidden as path
// components (and as submodule names) because they refer to the
// repository's own metadata directory.
//
// File names that do not conform to the 8.3 format (up to eight
// characters for the basename, three for the file extension) are
// associated with a so-called "short name" on NTFS — at least on
// the `C:` drive by default — which means that `git~1/` is a valid
// way to refer to `.git/`.
func IsDotGitName(name string) bool {
switch strings.ToLower(name) {
case ".git", "git~1":
return true
}
return false
}
+99
View File
@@ -0,0 +1,99 @@
package pathutil
import "unicode"
// hfsIgnoredCodepoints contains Unicode code points that HFS+ ignores
// during path normalization. A path component containing these
// characters between the bytes of ".git" (or ".gitmodules", etc.)
// will be treated as that name by HFS+, so they have to be filtered
// out before comparison.
//
// See upstream Git utf8.c next_hfs_char in tag v2.54.0[1].
//
// [1]: https://github.com/git/git/blob/v2.54.0/utf8.c#L703-L740
var hfsIgnoredCodepoints = map[rune]struct{}{
0x200c: {}, // ZERO WIDTH NON-JOINER
0x200d: {}, // ZERO WIDTH JOINER
0x200e: {}, // LEFT-TO-RIGHT MARK
0x200f: {}, // RIGHT-TO-LEFT MARK
0x202a: {}, // LEFT-TO-RIGHT EMBEDDING
0x202b: {}, // RIGHT-TO-LEFT EMBEDDING
0x202c: {}, // POP DIRECTIONAL FORMATTING
0x202d: {}, // LEFT-TO-RIGHT OVERRIDE
0x202e: {}, // RIGHT-TO-LEFT OVERRIDE
0x206a: {}, // INHIBIT SYMMETRIC SWAPPING
0x206b: {}, // ACTIVATE SYMMETRIC SWAPPING
0x206c: {}, // INHIBIT ARABIC FORM SHAPING
0x206d: {}, // ACTIVATE ARABIC FORM SHAPING
0x206e: {}, // NATIONAL DIGIT SHAPES
0x206f: {}, // NOMINAL DIGIT SHAPES
0xfeff: {}, // ZERO WIDTH NO-BREAK SPACE
}
// IsHFSDot reports whether part would be treated as ".<needle>" on an
// HFS+ filesystem after stripping ignored Unicode code points and
// folding ASCII to lower case. The needle is the lowercase ASCII
// suffix without the leading dot (e.g. "git", "gitmodules"). It
// mirrors upstream Git's is_hfs_dot_generic and is the building
// block of IsHFSDotGit / IsHFSDotGitmodules.
//
// Reference: upstream Git utf8.c is_hfs_dot_generic at L741-L774 and
// the dotgit family at L784-L809 in tag v2.54.0[1].
//
// [1]: https://github.com/git/git/blob/v2.54.0/utf8.c#L741-L809
func IsHFSDot(part, needle string) bool {
runes := []rune(part)
i := 0
// skip ignored code points, then expect '.'
for i < len(runes) {
if _, ok := hfsIgnoredCodepoints[runes[i]]; !ok {
break
}
i++
}
if i >= len(runes) || runes[i] != '.' {
return false
}
i++
// match needle case-insensitively, skipping ignored code points
for _, expected := range needle {
for i < len(runes) {
if _, ok := hfsIgnoredCodepoints[runes[i]]; !ok {
break
}
i++
}
if i >= len(runes) {
return false
}
r := runes[i]
if r > 127 {
return false
}
if unicode.ToLower(r) != expected {
return false
}
i++
}
// skip trailing ignored code points
for i < len(runes) {
if _, ok := hfsIgnoredCodepoints[runes[i]]; !ok {
break
}
i++
}
// must be at end of component
return i == len(runes)
}
// IsHFSDotGit reports whether part is an HFS+ equivalent of ".git".
func IsHFSDotGit(part string) bool { return IsHFSDot(part, "git") }
// IsHFSDotGitmodules reports whether part is an HFS+ equivalent of
// ".gitmodules", catching attempts to plant the file via Unicode
// code points that HFS+ would strip during normalisation.
func IsHFSDotGitmodules(part string) bool { return IsHFSDot(part, "gitmodules") }
+187
View File
@@ -0,0 +1,187 @@
package pathutil
import "strings"
// IsNTFSDotGit ports upstream Git's is_ntfs_dotgit. It detects path
// components that NTFS would resolve to ".git": the canonical name
// itself and its 8.3 short-name alias "git~1", each followed by any
// number of trailing spaces or periods (which NTFS silently trims)
// and an optional Alternate Data Stream suffix (":<stream>"). The
// bare strings ".git" and "git~1" also match, mirroring upstream.
//
// Reference: upstream Git path.c is_ntfs_dotgit at L1415-L1449
// in tag v2.54.0[1].
//
// [1]: https://github.com/git/git/blob/v2.54.0/path.c#L1415-L1449
func IsNTFSDotGit(part string) bool {
var i int
switch {
case len(part) >= 4 && part[0] == '.' &&
asciiToLower(part[1]) == 'g' &&
asciiToLower(part[2]) == 'i' &&
asciiToLower(part[3]) == 't':
i = 4
case len(part) >= 5 &&
asciiToLower(part[0]) == 'g' &&
asciiToLower(part[1]) == 'i' &&
asciiToLower(part[2]) == 't' &&
part[3] == '~' && part[4] == '1':
i = 5
default:
return false
}
for ; i < len(part); i++ {
c := part[i]
if c == ':' {
return true
}
if c != '.' && c != ' ' {
return false
}
}
return true
}
// WindowsValidPath reports whether part is a valid Windows / NTFS
// path component for the worktree filesystem abstraction. It rejects
// NTFS-disguised variants of `.git` and `git~1` (trailing spaces,
// periods, Alternate Data Streams) and Windows reserved device
// names. Bare `.git` and `git~1` are allowed at this layer; the
// caller decides whether they are permissible at the current path
// position.
func WindowsValidPath(part string) bool {
if IsNTFSDotGit(part) && !IsDotGitName(part) {
return false
}
return !isWindowsReservedName(part)
}
// windowsReservedNames lists the Windows reserved device names.
// A path component is reserved if its base name (ignoring trailing
// spaces, extensions, and NTFS Alternate Data Streams) matches one of
// these case-insensitively.
//
// See upstream Git compat/mingw.c is_valid_win32_path().
var windowsReservedNames = []string{
"CON", "PRN", "AUX", "NUL",
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
"CONIN$", "CONOUT$",
}
func isWindowsReservedName(part string) bool {
for _, name := range windowsReservedNames {
if len(part) < len(name) {
continue
}
if !strings.EqualFold(part[:len(name)], name) {
continue
}
// Exact match or followed by space, dot, colon (ADS), or separator.
if len(part) == len(name) {
return true
}
switch part[len(name)] {
case ' ', '.', ':':
return true
}
}
return false
}
// IsNTFSDot ports upstream Git's is_ntfs_dot_generic. It detects NTFS
// path-component variants of a dotfile name that attackers can use to
// bypass case-insensitive comparisons against the canonical name on
// Windows. The dotgit parameter is the lowercase name without the
// leading dot (e.g. "gitmodules"); shortnamePrefix is the canonical
// 6-character NTFS short-name prefix used as a fall-back match
// (e.g. "gi7eba" for ".gitmodules").
//
// Reference: upstream Git path.c is_ntfs_dot_generic at L1451-L1507
// in tag v2.54.0[1].
//
// [1]: https://github.com/git/git/blob/v2.54.0/path.c#L1451-L1507
func IsNTFSDot(name, dotgit, shortnamePrefix string) bool {
// onlySpacesAndPeriods returns true when the suffix from start
// onwards consists only of trailing spaces and periods, possibly
// terminated by a NTFS Alternate Data Stream colon. Mirrors the
// only_spaces_and_periods label in upstream's is_ntfs_dot_generic.
onlySpacesAndPeriods := func(start int) bool {
for i := start; i < len(name); i++ {
c := name[i]
if c == ':' {
return true
}
if c != ' ' && c != '.' {
return false
}
}
return true
}
// Pattern 1: ".<dotgit>" prefix + trailing spaces / periods / ADS.
if len(name) >= len(dotgit)+1 && name[0] == '.' &&
strings.EqualFold(name[1:1+len(dotgit)], dotgit) {
if onlySpacesAndPeriods(len(dotgit) + 1) {
return true
}
}
// Pattern 2: standard NTFS short name <dotgit[:6]>~[1-4].
if len(dotgit) >= 6 && len(name) >= 8 &&
strings.EqualFold(name[:6], dotgit[:6]) &&
name[6] == '~' && name[7] >= '1' && name[7] <= '4' {
if onlySpacesAndPeriods(8) {
return true
}
}
// Pattern 3: fall-back NTFS short name keyed by shortnamePrefix.
if len(shortnamePrefix) < 6 || len(name) < 8 {
return false
}
sawTilde := false
i := 0
for i < 8 {
c := name[i]
switch {
case sawTilde:
if c < '0' || c > '9' {
return false
}
case c == '~':
i++
if i >= len(name) || name[i] < '1' || name[i] > '9' {
return false
}
sawTilde = true
case i >= 6:
return false
case c&0x80 != 0:
return false
default:
if asciiToLower(c) != shortnamePrefix[i] {
return false
}
}
i++
}
return onlySpacesAndPeriods(8)
}
// IsNTFSDotGitmodules reports whether part is an NTFS-equivalent of
// ".gitmodules" — the file name (or any of its variants that NTFS
// would resolve to it) that attackers can use to plant submodule
// configuration disguised as a symlink. The 6-character canonical
// short-name prefix "gi7eba" mirrors upstream Git's is_ntfs_dotgitmodules.
func IsNTFSDotGitmodules(part string) bool {
return IsNTFSDot(part, "gitmodules", "gi7eba")
}
func asciiToLower(c byte) byte {
if c >= 'A' && c <= 'Z' {
return c + ('a' - 'A')
}
return c
}
+66
View File
@@ -0,0 +1,66 @@
package pathutil
import (
"fmt"
"path/filepath"
"strings"
)
// ErrInvalidPath is returned by ValidTreePath when its argument is
// not a safe path to materialise into the worktree.
var ErrInvalidPath = fmt.Errorf("invalid path")
// ValidTreePath rejects path strings that, if materialised into a
// worktree, would let an attacker-controlled tree entry escape the
// worktree or rewrite repository metadata. It rejects:
//
// - control characters (< 0x20, 0x7f);
// - empty paths and "." / ".." components;
// - Windows volume name prefixes (e.g. C:);
// - .git, its 8.3 NTFS short-name git~1, plus their HFS+ and NTFS
// variants — at every position, not just the root.
//
// HFS+/NTFS variants of `.git` are always rejected at this layer
// regardless of runtime config: tree paths are canonical UTF-8 with
// no zero-width characters or NTFS short-name forms, so an entry
// that looks like a disguised `.git` is suspicious anywhere. Windows
// reserved device names (CON, NUL, etc.) are not policed here — they
// are legitimate filenames on non-Windows filesystems and upstream
// Git accepts them. The wrapper layer (validPath in package git)
// rejects them at materialisation time when core.protectNTFS is on.
//
// Mirrors upstream Git's verify_path_internal at read-cache.c#L987
// in tag v2.54.0[1] with protect_hfs / protect_ntfs treated as
// always-on for `.git`-disguise detection (tree paths are not
// application-supplied) and is_valid_win32_path left to the wrapper.
//
// [1]: https://github.com/git/git/blob/v2.54.0/read-cache.c#L987
func ValidTreePath(p string) error {
for i := 0; i < len(p); i++ {
if p[i] < 0x20 || p[i] == 0x7f {
return fmt.Errorf("%w %q: contains control character", ErrInvalidPath, p)
}
}
parts := strings.FieldsFunc(p, func(r rune) bool { return r == '\\' || r == '/' })
if len(parts) == 0 {
return fmt.Errorf("%w: %q", ErrInvalidPath, p)
}
// Volume names are not supported, in both formats: \\ and <DRIVE_LETTER>:.
if vol := filepath.VolumeName(p); vol != "" {
return fmt.Errorf("%w: %q", ErrInvalidPath, p)
}
for _, part := range parts {
if part == "." || part == ".." {
return fmt.Errorf("%w %q: cannot use %q", ErrInvalidPath, p, part)
}
if IsDotGitName(part) || IsHFSDotGit(part) || IsNTFSDotGit(part) {
return fmt.Errorf("%w component: %q", ErrInvalidPath, p)
}
}
return nil
}
+35 -2
View File
@@ -2,12 +2,14 @@ package url
import ( import (
"regexp" "regexp"
"runtime"
"strings"
) )
var ( var (
isSchemeRegExp = regexp.MustCompile(`^[^:]+://`) 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>[^\\].*)$`) 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 // MatchesScpLike returns true if the given string matches an SCP-like
// format scheme. // format scheme.
func MatchesScpLike(url string) bool { 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 // FindScpLikeComponents returns the user, host, port and path of the
+165 -7
View File
@@ -7,6 +7,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"io/fs"
"github.com/go-git/go-git/v5/plumbing/hash" "github.com/go-git/go-git/v5/plumbing/hash"
"github.com/go-git/go-git/v5/utils/binary" "github.com/go-git/go-git/v5/utils/binary"
@@ -25,9 +26,40 @@ const (
objectIDLength = hash.Size 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. // Decoder reads and decodes idx files from an input stream.
type Decoder struct { type Decoder struct {
io.Reader io.Reader
src io.Reader
h hash.Hash h hash.Hash
} }
@@ -35,25 +67,47 @@ type Decoder struct {
func NewDecoder(r io.Reader) *Decoder { func NewDecoder(r io.Reader) *Decoder {
h := hash.New(crypto.SHA1) h := hash.New(crypto.SHA1)
tr := io.TeeReader(r, h) 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. // Decode reads from the stream and decode the content into the MemoryIndex struct.
func (d *Decoder) Decode(idx *MemoryIndex) error { 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 { if err := validateHeader(d); err != nil {
return err return err
} }
flow := []func(*MemoryIndex, io.Reader) error{ headerFlow := []func(*MemoryIndex, io.Reader) error{
readVersion, readVersion,
readFanout, 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, readObjectNames,
readCRC32, readCRC32,
readOffsets, readOffsets,
readPackChecksum, readPackChecksum,
} }
for _, f := range bodyFlow {
for _, f := range flow {
if err := f(idx, d); err != nil { if err := f(idx, d); err != nil {
return err return err
} }
@@ -91,8 +145,8 @@ func readVersion(idx *MemoryIndex, r io.Reader) error {
return err return err
} }
if v > VersionSupported { if v != VersionSupported {
return ErrUnsupportedVersion return fmt.Errorf("%w: v%d", ErrUnsupportedVersion, v)
} }
idx.Version = v idx.Version = v
@@ -106,6 +160,10 @@ func readFanout(idx *MemoryIndex, r io.Reader) error {
return err return err
} }
if k > 0 && n < idx.Fanout[k-1] {
return fmt.Errorf("%w: fanout table is not monotonically non-decreasing at entry %d", ErrMalformedIdxFile, k)
}
idx.Fanout[k] = n idx.Fanout[k] = n
idx.FanoutMapping[k] = noMapping idx.FanoutMapping[k] = noMapping
} }
@@ -155,7 +213,7 @@ func readCRC32(idx *MemoryIndex, r io.Reader) error {
} }
func readOffsets(idx *MemoryIndex, r io.Reader) error { func readOffsets(idx *MemoryIndex, r io.Reader) error {
var o64cnt int var o64cnt int64
for k := 0; k < fanout; k++ { for k := 0; k < fanout; k++ {
if pos := idx.FanoutMapping[k]; pos != noMapping { if pos := idx.FanoutMapping[k]; pos != noMapping {
if _, err := io.ReadFull(r, idx.Offset32[pos]); err != nil { if _, err := io.ReadFull(r, idx.Offset32[pos]); err != nil {
@@ -195,3 +253,103 @@ func readIdxChecksum(idx *MemoryIndex, r io.Reader) error {
return nil return nil
} }
// validateIdxV2Size enforces the size formula used by canonical Git
// load_idx for idx v2 files: the on-disk length must lie within
// [minSize, maxSize] where
//
// perObject = hashsz + crc32Len + offset32Len
// minSize = headerLen + fanoutLen + trailerHashes*hashsz + nr*perObject
// maxSize = minSize + (nr-1)*offset64Len when nr > 0
//
// with nr taken from the last fanout entry and hashsz fixed at
// [objectIDLength] (SHA-1 in v5). Multiplications use a self-checking
// overflow guard so inputs whose claimed object count overflows the
// formula are rejected rather than wrapping into a smaller value.
func validateIdxV2Size(idx *MemoryIndex, idxSize int64) error {
nr := int64(idx.Fanout[fanout-1])
hashsz := int64(objectIDLength)
minSize := minIdxV2Size(nr, hashsz)
maxSize := maxIdxV2Size(nr, hashsz)
if minSize < 0 || maxSize < 0 {
return fmt.Errorf("%w: object count %d is inconsistent with file size", ErrMalformedIdxFile, nr)
}
if idxSize < minSize || idxSize > maxSize {
return fmt.Errorf("%w: file size %d is inconsistent with object count %d", ErrMalformedIdxFile, idxSize, nr)
}
return nil
}
// minIdxV2Size returns the minimum on-disk size of an idx v2 file
// holding nr objects with the given hash size, mirroring the
// computation in canonical Git load_idx. Returns -1 when any
// intermediate multiplication or addition would overflow int64.
func minIdxV2Size(nr, hashsz int64) int64 {
perObject := hashsz + crc32Len + offset32Len
fixed := int64(headerLen+fanoutLen) + trailerHashes*hashsz
objects, ok := mulInt64(nr, perObject)
if !ok {
return -1
}
sum, ok := addInt64(fixed, objects)
if !ok {
return -1
}
return sum
}
// maxIdxV2Size returns the maximum on-disk size of an idx v2 file
// holding nr objects with the given hash size, mirroring the
// computation in canonical Git load_idx. Returns -1 on overflow.
func maxIdxV2Size(nr, hashsz int64) int64 {
minSize := minIdxV2Size(nr, hashsz)
if minSize < 0 {
return -1
}
if nr == 0 {
return minSize
}
overflow, ok := mulInt64(nr-1, offset64Len)
if !ok {
return -1
}
sum, ok := addInt64(minSize, overflow)
if !ok {
return -1
}
return sum
}
// mulInt64 returns a*b and whether the result fits in an int64 without
// overflow. Negative operands or overflow yield ok=false. The overflow
// check uses the standard self-inverse identity: a*b/b == a only when
// the multiplication did not wrap.
func mulInt64(a, b int64) (int64, bool) {
if a < 0 || b < 0 {
return 0, false
}
if a == 0 || b == 0 {
return 0, true
}
c := a * b
if c/b != a {
return 0, false
}
return c, true
}
// addInt64 returns a+b and whether the result fits in an int64 without
// overflow. Negative operands or overflow yield ok=false.
func addInt64(a, b int64) (int64, bool) {
if a < 0 || b < 0 {
return 0, false
}
c := a + b
if c < a {
return 0, false
}
return c, true
}
+21 -8
View File
@@ -2,6 +2,7 @@ package idxfile
import ( import (
"bytes" "bytes"
"fmt"
"io" "io"
"sort" "sort"
"sync" "sync"
@@ -126,7 +127,10 @@ func (idx *MemoryIndex) FindOffset(h plumbing.Hash) (int64, error) {
return 0, plumbing.ErrObjectNotFound 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 // Save the offset for reverse lookup
idx.mu.Lock() idx.mu.Lock()
@@ -141,17 +145,19 @@ func (idx *MemoryIndex) FindOffset(h plumbing.Hash) (int64, error) {
const isO64Mask = uint64(1) << 31 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 offset := secondLevel << 2
ofs := encbin.BigEndian.Uint32(idx.Offset32[firstLevel][offset : offset+4]) ofs := encbin.BigEndian.Uint32(idx.Offset32[firstLevel][offset : offset+4])
if (uint64(ofs) & isO64Mask) != 0 { if (uint64(ofs) & isO64Mask) != 0 {
offset := 8 * (uint64(ofs) & ^isO64Mask) offset := 8 * (uint64(ofs) & ^isO64Mask)
n := encbin.BigEndian.Uint64(idx.Offset64[offset : offset+8]) if l := uint64(len(idx.Offset64)); l < 8 || offset > l-8 {
return n 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. // FindCRC32 implements the Index interface.
@@ -209,8 +215,11 @@ func (idx *MemoryIndex) genOffsetHash() error {
mappedFirstLevel := idx.FanoutMapping[firstLevel] mappedFirstLevel := idx.FanoutMapping[firstLevel]
for secondLevel := uint32(0); i < fanoutValue; i++ { for secondLevel := uint32(0); i < fanoutValue; i++ {
copy(hash[:], idx.Names[mappedFirstLevel][secondLevel*objectIDLength:]) copy(hash[:], idx.Names[mappedFirstLevel][secondLevel*objectIDLength:])
offset := int64(idx.getOffset(mappedFirstLevel, int(secondLevel))) off, err := idx.getOffset(mappedFirstLevel, int(secondLevel))
offsetHash[offset] = hash if err != nil {
return err
}
offsetHash[int64(off)] = hash
secondLevel++ secondLevel++
} }
} }
@@ -291,7 +300,11 @@ func (i *idxfileEntryIter) Next() (*Entry, error) {
mappedFirstLevel := i.idx.FanoutMapping[i.firstLevel] mappedFirstLevel := i.idx.FanoutMapping[i.firstLevel]
entry := new(Entry) entry := new(Entry)
copy(entry.Hash[:], i.idx.Names[mappedFirstLevel][i.secondLevel*objectIDLength:]) 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) entry.CRC32 = i.idx.getCRC32(mappedFirstLevel, i.secondLevel)
i.secondLevel++ i.secondLevel++
+70 -38
View File
@@ -4,8 +4,8 @@ import (
"bufio" "bufio"
"bytes" "bytes"
"errors" "errors"
"fmt"
"io" "io"
"strconv" "strconv"
"time" "time"
@@ -26,12 +26,14 @@ var (
ErrInvalidChecksum = errors.New("invalid checksum") ErrInvalidChecksum = errors.New("invalid checksum")
// ErrUnknownExtension is returned when an index extension is encountered that is considered mandatory // ErrUnknownExtension is returned when an index extension is encountered that is considered mandatory
ErrUnknownExtension = errors.New("unknown extension") ErrUnknownExtension = errors.New("unknown extension")
// ErrMalformedIndexFile is returned when the index file contents are
// structurally invalid.
ErrMalformedIndexFile = errors.New("index decoder: malformed index file")
) )
const ( const (
entryHeaderLength = 62 entryHeaderLength = 62
entryExtended = 0x4000 entryExtended = 0x4000
entryValid = 0x8000
nameMask = 0xfff nameMask = 0xfff
intentToAddMask = 1 << 13 intentToAddMask = 1 << 13
skipWorkTreeMask = 1 << 14 skipWorkTreeMask = 1 << 14
@@ -140,33 +142,55 @@ func (d *Decoder) readEntry(idx *Index) (*Entry, error) {
e.SkipWorktree = extended&skipWorkTreeMask != 0 e.SkipWorktree = extended&skipWorkTreeMask != 0
} }
if err := d.readEntryName(idx, e, flags); err != nil { nameConsumed, err := d.readEntryName(idx, e, flags)
if err != nil {
return nil, err return nil, err
} }
return e, d.padEntry(idx, e, read) return e, d.padEntry(idx, e, read, nameConsumed)
} }
func (d *Decoder) readEntryName(idx *Index, e *Entry, flags uint16) error { // readEntryName reads the entry path and sets e.Name. It returns the
var name string // number of bytes consumed from the stream for the name portion.
var err error func (d *Decoder) readEntryName(idx *Index, e *Entry, flags uint16) (int, error) {
switch idx.Version { switch idx.Version {
case 2, 3: case 2, 3:
len := flags & nameMask nameLen := flags & nameMask
name, err = d.doReadEntryName(len) name, consumed, err := d.doReadEntryName(nameLen)
case 4:
name, err = d.doReadEntryNameV4()
default:
return ErrUnsupportedVersion
}
if err != nil { if err != nil {
return err return 0, err
}
e.Name = name
return consumed, nil
case 4:
name, err := d.doReadEntryNameV4()
if err != nil {
return 0, err
}
e.Name = name
return 0, nil // V4 has no padding; consumed count unused
default:
return 0, ErrUnsupportedVersion
}
}
// doReadEntryName reads the entry path for V2/V3 indexes. It returns the
// name, the number of bytes consumed from the stream, and any error.
// When nameLen equals nameMask (0xFFF), the name was too long to fit in
// the 12-bit field and the real length is found by scanning for the NUL
// terminator — matching C Git's strlen(name) fallback in create_from_disk.
func (d *Decoder) doReadEntryName(nameLen uint16) (string, int, error) {
if nameLen == nameMask {
name, err := binary.ReadUntil(d.r, '\x00')
if err != nil {
return "", 0, err
}
return string(name), len(name) + 1, nil // +1 for the consumed NUL delimiter
} }
e.Name = name name := make([]byte, nameLen)
return nil _, err := io.ReadFull(d.r, name)
return string(name), int(nameLen), err
} }
func (d *Decoder) doReadEntryNameV4() (string, error) { func (d *Decoder) doReadEntryNameV4() (string, error) {
@@ -177,7 +201,14 @@ func (d *Decoder) doReadEntryNameV4() (string, error) {
var base string var base string
if d.lastEntry != nil { if d.lastEntry != nil {
if l < 0 || int(l) > len(d.lastEntry.Name) {
return "", fmt.Errorf("%w: invalid V4 entry name strip length %d (previous name length: %d)",
ErrMalformedIndexFile, l, len(d.lastEntry.Name))
}
base = d.lastEntry.Name[:len(d.lastEntry.Name)-int(l)] base = d.lastEntry.Name[:len(d.lastEntry.Name)-int(l)]
} else if l > 0 {
return "", fmt.Errorf("%w: non-zero strip length %d on first V4 entry",
ErrMalformedIndexFile, l)
} }
name, err := binary.ReadUntil(d.r, '\x00') name, err := binary.ReadUntil(d.r, '\x00')
@@ -188,24 +219,23 @@ func (d *Decoder) doReadEntryNameV4() (string, error) {
return base + string(name), nil return base + string(name), nil
} }
func (d *Decoder) doReadEntryName(len uint16) (string, error) { // padEntry discards NUL padding bytes that follow each V2/V3 entry on
name := make([]byte, len) // disk. nameConsumed is the number of stream bytes consumed while reading
_, err := io.ReadFull(d.r, name) // the entry name (which may exceed len(e.Name) when a NUL terminator was
// consumed for long names where the 12-bit length field overflowed).
return string(name), err func (d *Decoder) padEntry(idx *Index, e *Entry, read, nameConsumed int) error {
}
// Index entries are padded out to the next 8 byte alignment
// for historical reasons related to how C Git read the files.
func (d *Decoder) padEntry(idx *Index, e *Entry, read int) error {
if idx.Version == 4 { if idx.Version == 4 {
return nil return nil
} }
entrySize := read + len(e.Name) entrySize := read + len(e.Name)
padLen := 8 - entrySize%8 padLen := 8 - entrySize%8
padLen -= nameConsumed - len(e.Name)
if padLen > 0 {
_, err := io.CopyN(io.Discard, d.r, int64(padLen)) _, err := io.CopyN(io.Discard, d.r, int64(padLen))
return err return err
}
return nil
} }
func (d *Decoder) readExtensions(idx *Index) error { func (d *Decoder) readExtensions(idx *Index) error {
@@ -312,7 +342,7 @@ func (d *Decoder) readChecksum(expected []byte) error {
} }
func validateHeader(r io.Reader) (version uint32, err error) { func validateHeader(r io.Reader) (version uint32, err error) {
var s = make([]byte, 4) s := make([]byte, 4)
if _, err := io.ReadFull(r, s); err != nil { if _, err := io.ReadFull(r, s); err != nil {
return 0, err return 0, err
} }
@@ -376,24 +406,26 @@ func (d *treeExtensionDecoder) readEntry() (*TreeEntry, error) {
return nil, err return nil, err
} }
// An entry can be in an invalidated state and is represented by having a
// negative number in the entry_count field.
if i == -1 {
return nil, nil
}
e.Entries = i e.Entries = i
trees, err := binary.ReadUntil(d.r, '\n') trees, err := binary.ReadUntil(d.r, '\n')
if err != nil { if err != nil {
return nil, err return nil, err
} }
i, err = strconv.Atoi(string(trees)) subtrees, err := strconv.Atoi(string(trees))
if err != nil { if err != nil {
return nil, err return nil, err
} }
e.Trees = i e.Trees = subtrees
// An entry can be in an invalidated state and is represented by having a
// negative number in the entry_count field. In this case, there is no
// object name and the next entry starts immediately after the newline.
if i < 0 {
return nil, nil
}
_, err = io.ReadFull(d.r, e.Hash[:]) _, err = io.ReadFull(d.r, e.Hash[:])
if err != nil { if err != nil {
return nil, err return nil, err
+24 -13
View File
@@ -5,9 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"path"
"sort" "sort"
"strings"
"time" "time"
"github.com/go-git/go-git/v5/plumbing/hash" "github.com/go-git/go-git/v5/plumbing/hash"
@@ -160,26 +158,39 @@ func (e *Encoder) encodeEntryName(entry *Entry) error {
} }
func (e *Encoder) encodeEntryNameV4(entry *Entry) error { func (e *Encoder) encodeEntryNameV4(entry *Entry) error {
name := entry.Name // V4 prefix compression: find the longest common prefix between the
l := 0 // previous entry's name and the current one. The strip length tells
// the decoder how many bytes to remove from the end of the previous
// name, and the suffix is the remainder of the current name.
prefix := 0
if e.lastEntry != nil { if e.lastEntry != nil {
dir := path.Dir(e.lastEntry.Name) + "/" prefix = commonPrefixLen(e.lastEntry.Name, entry.Name)
if strings.HasPrefix(entry.Name, dir) {
l = len(e.lastEntry.Name) - len(dir)
name = strings.TrimPrefix(entry.Name, dir)
} else {
l = len(e.lastEntry.Name)
} }
stripLen := 0
if e.lastEntry != nil {
stripLen = len(e.lastEntry.Name) - prefix
} }
e.lastEntry = entry e.lastEntry = entry
err := binary.WriteVariableWidthInt(e.w, int64(l)) if err := binary.WriteVariableWidthInt(e.w, int64(stripLen)); err != nil {
if err != nil {
return err return err
} }
return binary.Write(e.w, []byte(name+string('\x00'))) suffix := entry.Name[prefix:]
return binary.Write(e.w, append([]byte(suffix), '\x00'))
}
// commonPrefixLen returns the length of the longest common byte prefix
// between a and b.
func commonPrefixLen(a, b string) int {
n := min(len(b), len(a))
for i := range n {
if a[i] != b[i] {
return i
}
}
return n
} }
func (e *Encoder) encodeRawExtension(signature string, data []byte) error { func (e *Encoder) encodeRawExtension(signature string, data []byte) error {
+2
View File
@@ -54,6 +54,8 @@ type Index struct {
ResolveUndo *ResolveUndo ResolveUndo *ResolveUndo
// EndOfIndexEntry represents the 'End of Index Entry' extension // EndOfIndexEntry represents the 'End of Index Entry' extension
EndOfIndexEntry *EndOfIndexEntry EndOfIndexEntry *EndOfIndexEntry
// ModTime is the modification time of the index file
ModTime time.Time
} }
// Add creates a new Entry and returns it. The caller should first check that // Add creates a new Entry and returns it. The caller should first check that
+12
View File
@@ -13,6 +13,7 @@ import (
var ( var (
ErrClosed = errors.New("objfile: already closed") ErrClosed = errors.New("objfile: already closed")
ErrHeader = errors.New("objfile: invalid header") ErrHeader = errors.New("objfile: invalid header")
ErrHeaderNotRead = errors.New("objfile: Header must be called before Read")
ErrNegativeSize = errors.New("objfile: negative object size") ErrNegativeSize = errors.New("objfile: negative object size")
) )
@@ -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, // 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. // 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) { func (r *Reader) Read(p []byte) (n int, err error) {
if r.multi == nil {
return 0, ErrHeaderNotRead
}
return r.multi.Read(p) return r.multi.Read(p)
} }
// Hash returns the hash of the object data stream that has been read so far. // 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 { func (r *Reader) Hash() plumbing.Hash {
if r.multi == nil {
return plumbing.ZeroHash
}
return r.hasher.Sum() return r.hasher.Sum()
} }
@@ -19,9 +19,6 @@ const (
// https://github.com/git/git/blob/f7466e94375b3be27f229c78873f0acf8301c0a5/diff-delta.c#L428 // https://github.com/git/git/blob/f7466e94375b3be27f229c78873f0acf8301c0a5/diff-delta.c#L428
// Max size of a copy operation (64KB). // Max size of a copy operation (64KB).
maxCopySize = 64 * 1024 maxCopySize = 64 * 1024
// Min size of a copy operation.
minCopySize = 4
) )
// GetDelta returns an EncodedObject of type OFSDeltaObject. Base and Target object, // GetDelta returns an EncodedObject of type OFSDeltaObject. Base and Target object,
+7 -1
View File
@@ -78,7 +78,13 @@ func (o *FSObject) Reader() (io.ReadCloser, error) {
_ = f.Close() _ = f.Close()
return nil, err 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) r, err := p.getObjectContent(o.offset)
if err != nil { if err != nil {
+15 -6
View File
@@ -126,11 +126,17 @@ func (p *Packfile) nextObjectHeader() (*ObjectHeader, error) {
return h, err return h, err
} }
func (p *Packfile) getDeltaObjectSize(buf *bytes.Buffer) int64 { func (p *Packfile) getDeltaObjectSize(buf *bytes.Buffer) (int64, error) {
delta := buf.Bytes() delta := buf.Bytes()
_, delta = decodeLEB128(delta) // skip src size _, delta, err := decodeLEB128(delta) // skip src size
sz, _ := decodeLEB128(delta) if err != nil {
return int64(sz) 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) { func (p *Packfile) getObjectSize(h *ObjectHeader) (int64, error) {
@@ -145,7 +151,7 @@ func (p *Packfile) getObjectSize(h *ObjectHeader) (int64, error) {
return 0, err return 0, err
} }
return p.getDeltaObjectSize(buf), nil return p.getDeltaObjectSize(buf)
default: default:
return 0, ErrInvalidObject.AddDetails("type %q", h.Type) 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 return nil, err
} }
size = p.getDeltaObjectSize(buf) size, err = p.getDeltaObjectSize(buf)
if err != nil {
return nil, err
}
if size <= smallObjectThreshold { if size <= smallObjectThreshold {
var obj = new(plumbing.MemoryObject) var obj = new(plumbing.MemoryObject)
obj.SetSize(size) obj.SetSize(size)
+65 -7
View File
@@ -26,6 +26,45 @@ var (
ErrDeltaNotCached = errors.New("delta could not be found in cache") 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. // Observer interface is implemented by index encoders.
type Observer interface { type Observer interface {
// OnHeader is called when a new packfile is opened. // OnHeader is called when a new packfile is opened.
@@ -166,9 +205,10 @@ func (p *Parser) init() error {
} }
p.count = c p.count = c
p.oiByHash = make(map[plumbing.Hash]*objectInfo, p.count) hint := objectsHint(p.count)
p.oiByOffset = make(map[int64]*objectInfo, p.count) p.oiByHash = make(map[plumbing.Hash]*objectInfo, hint)
p.oi = make([]*objectInfo, p.count) p.oiByOffset = make(map[int64]*objectInfo, hint)
p.oi = make([]*objectInfo, 0, hint)
return nil return nil
} }
@@ -261,7 +301,7 @@ func (p *Parser) indexObjects() error {
} }
if delta && !p.scanner.IsSeekable { if delta && !p.scanner.IsSeekable {
buf.Reset() buf.Reset()
buf.Grow(int(oh.Length)) buf.Grow(growHint(oh.Length))
writers = append(writers, buf) writers = append(writers, buf)
} }
@@ -306,7 +346,7 @@ func (p *Parser) indexObjects() error {
} }
p.oiByOffset[oh.Offset] = ota p.oiByOffset[oh.Offset] = ota
p.oi[i] = ota p.oi = append(p.oi, ota)
} }
return nil return nil
@@ -317,8 +357,12 @@ func (p *Parser) resolveDeltas() error {
defer sync.PutBytesBuffer(buf) defer sync.PutBytesBuffer(buf)
for _, obj := range p.oi { for _, obj := range p.oi {
if err := checkDeltaChainDepth(obj); err != nil {
return err
}
buf.Reset() buf.Reset()
buf.Grow(int(obj.Length)) buf.Grow(growHint(obj.Length))
err := p.get(obj, buf) err := p.get(obj, buf)
if err != nil { if err != nil {
return err return err
@@ -337,6 +381,9 @@ func (p *Parser) resolveDeltas() error {
// create it once and reuse across all children. // create it once and reuse across all children.
r := bytes.NewReader(buf.Bytes()) r := bytes.NewReader(buf.Bytes())
for _, child := range obj.Children { 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 // 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 // so that the scanner can advance to the next object, and the SHA1 can be
// calculated. // calculated.
@@ -356,6 +403,17 @@ func (p *Parser) resolveDeltas() error {
return nil 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) { func (p *Parser) resolveExternalRef(o *objectInfo) {
if ref, ok := p.oiByHash[o.SHA1]; ok && ref.ExternalRef { if ref, ok := p.oiByHash[o.SHA1]; ok && ref.ExternalRef {
p.oiByHash[o.SHA1] = o p.oiByHash[o.SHA1] = o
@@ -405,7 +463,7 @@ func (p *Parser) get(o *objectInfo, buf *bytes.Buffer) (err error) {
if o.DiskType.IsDelta() { if o.DiskType.IsDelta() {
b := sync.GetBytesBuffer() b := sync.GetBytesBuffer()
defer sync.PutBytesBuffer(b) defer sync.PutBytesBuffer(b)
buf.Grow(int(o.Length)) buf.Grow(growHint(o.Length))
err := p.get(o.Parent, b) err := p.get(o.Parent, b)
if err != nil { if err != nil {
return err return err
+70 -39
View File
@@ -31,10 +31,15 @@ const (
// premptively made available for a patch operation. // premptively made available for a patch operation.
maxPatchPreemptionSize uint = 65536 maxPatchPreemptionSize uint = 65536
// minDeltaSize defines the smallest size for a delta. // minDeltaSize is the smallest valid delta: a 1-byte srcSz LEB128
minDeltaSize = 4 // 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 { type offset struct {
mask byte mask byte
shift uint shift uint
@@ -142,7 +147,7 @@ func ReaderFromDelta(base plumbing.EncodedObject, deltaRC io.Reader) (io.ReadClo
baseBuf := bufio.NewReader(baseRd) baseBuf := bufio.NewReader(baseRd)
basePos := uint(0) basePos := uint(0)
for { for remainingTargetSz > 0 {
cmd, err := deltaBuf.ReadByte() cmd, err := deltaBuf.ReadByte()
if err == io.EOF { if err == io.EOF {
_ = dstWr.CloseWithError(ErrInvalidDelta) _ = dstWr.CloseWithError(ErrInvalidDelta)
@@ -166,9 +171,9 @@ func ReaderFromDelta(base plumbing.EncodedObject, deltaRC io.Reader) (io.ReadClo
return return
} }
if invalidSize(sz, targetSz) || if invalidSize(sz, remainingTargetSz) ||
invalidOffsetSize(offset, sz, srcSz) { invalidOffsetSize(offset, sz, srcSz) {
_ = dstWr.Close() _ = dstWr.CloseWithError(ErrInvalidDelta)
return return
} }
@@ -210,7 +215,7 @@ func ReaderFromDelta(base plumbing.EncodedObject, deltaRC io.Reader) (io.ReadClo
case isCopyFromDelta(cmd): case isCopyFromDelta(cmd):
sz := uint(cmd) // cmd is the size itself sz := uint(cmd) // cmd is the size itself
if invalidSize(sz, targetSz) { if invalidSize(sz, remainingTargetSz) {
_ = dstWr.CloseWithError(ErrInvalidDelta) _ = dstWr.CloseWithError(ErrInvalidDelta)
return return
} }
@@ -225,40 +230,48 @@ func ReaderFromDelta(base plumbing.EncodedObject, deltaRC io.Reader) (io.ReadClo
_ = dstWr.CloseWithError(ErrDeltaCmd) _ = dstWr.CloseWithError(ErrDeltaCmd)
return return
} }
}
if remainingTargetSz <= 0 { // Mirror upstream's `data != top` post-loop check: every byte
_ = dstWr.Close() // 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 return
} }
}
_ = dstWr.Close()
}() }()
return dstRd, nil return dstRd, nil
} }
func patchDelta(dst *bytes.Buffer, src, delta []byte) error { func patchDelta(dst *bytes.Buffer, src, delta []byte) error {
if len(delta) < minCopySize { srcSz, delta, err := decodeLEB128(delta)
return ErrInvalidDelta if err != nil {
return fmt.Errorf("%w: %w", ErrInvalidDelta, err)
} }
srcSz, delta := decodeLEB128(delta)
if srcSz != uint(len(src)) { if srcSz != uint(len(src)) {
return ErrInvalidDelta return ErrInvalidDelta
} }
targetSz, delta := decodeLEB128(delta) targetSz, delta, err := decodeLEB128(delta)
if err != nil {
return fmt.Errorf("%w: %w", ErrInvalidDelta, err)
}
remainingTargetSz := targetSz remainingTargetSz := targetSz
var cmd byte
growSz := min(targetSz, maxPatchPreemptionSize) growSz := min(targetSz, maxPatchPreemptionSize)
dst.Grow(int(growSz)) dst.Grow(int(growSz))
for {
for remainingTargetSz > 0 {
if len(delta) == 0 { if len(delta) == 0 {
return ErrInvalidDelta return ErrInvalidDelta
} }
cmd = delta[0] cmd := delta[0]
delta = delta[1:] delta = delta[1:]
switch { switch {
@@ -275,16 +288,16 @@ func patchDelta(dst *bytes.Buffer, src, delta []byte) error {
return err return err
} }
if invalidSize(sz, targetSz) || if invalidSize(sz, remainingTargetSz) ||
invalidOffsetSize(offset, sz, srcSz) { invalidOffsetSize(offset, sz, srcSz) {
break return ErrInvalidDelta
} }
dst.Write(src[offset : offset+sz]) dst.Write(src[offset : offset+sz])
remainingTargetSz -= sz remainingTargetSz -= sz
case isCopyFromDelta(cmd): case isCopyFromDelta(cmd):
sz := uint(cmd) // cmd is the size itself sz := uint(cmd) // cmd is the size itself
if invalidSize(sz, targetSz) { if invalidSize(sz, remainingTargetSz) {
return ErrInvalidDelta return ErrInvalidDelta
} }
@@ -299,10 +312,12 @@ func patchDelta(dst *bytes.Buffer, src, delta []byte) error {
default: default:
return ErrDeltaCmd 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 return nil
@@ -354,7 +369,7 @@ func patchDeltaWriter(dst io.Writer, base io.ReaderAt, delta io.Reader,
baselr := io.LimitReader(sr, 0).(*io.LimitedReader) baselr := io.LimitReader(sr, 0).(*io.LimitedReader)
deltalr := io.LimitReader(deltaBuf, 0).(*io.LimitedReader) deltalr := io.LimitReader(deltaBuf, 0).(*io.LimitedReader)
for { for remainingTargetSz > 0 {
buf := *bufp buf := *bufp
cmd, err := deltaBuf.ReadByte() cmd, err := deltaBuf.ReadByte()
if err == io.EOF { if err == io.EOF {
@@ -374,9 +389,9 @@ func patchDeltaWriter(dst io.Writer, base io.ReaderAt, delta io.Reader,
return 0, plumbing.ZeroHash, err return 0, plumbing.ZeroHash, err
} }
if invalidSize(sz, targetSz) || if invalidSize(sz, remainingTargetSz) ||
invalidOffsetSize(offset, sz, srcSz) { invalidOffsetSize(offset, sz, srcSz) {
return 0, plumbing.ZeroHash, err return 0, plumbing.ZeroHash, ErrInvalidDelta
} }
if _, err := sr.Seek(int64(offset), io.SeekStart); err != nil { 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 remainingTargetSz -= sz
} else if isCopyFromDelta(cmd) { } else if isCopyFromDelta(cmd) {
sz := uint(cmd) // cmd is the size itself sz := uint(cmd) // cmd is the size itself
if invalidSize(sz, targetSz) { if invalidSize(sz, remainingTargetSz) {
return 0, plumbing.ZeroHash, ErrInvalidDelta return 0, plumbing.ZeroHash, ErrInvalidDelta
} }
deltalr.N = int64(sz) deltalr.N = int64(sz)
@@ -399,30 +414,41 @@ func patchDeltaWriter(dst io.Writer, base io.ReaderAt, delta io.Reader,
remainingTargetSz -= sz remainingTargetSz -= sz
} else { } else {
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 0, plumbing.ZeroHash, err
} }
if remainingTargetSz <= 0 {
break
}
}
return targetSz, hasher.Sum(), nil return targetSz, hasher.Sum(), nil
} }
// Decodes a number encoded as an unsigned LEB128 at the start of some // 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 // binary data and returns the decoded number, the rest of the stream,
// 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 // 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. // 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 { if len(input) == 0 {
return 0, input return 0, input, nil
} }
var num, sz uint var num, sz uint
var b byte var b byte
for { 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] b = input[sz]
num |= (uint(b) & payload) << (sz * 7) // concats 7 bits chunks num |= (uint(b) & payload) << (sz * 7) // concats 7 bits chunks
sz++ 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) { func decodeLEB128ByteReader(input io.ByteReader) (uint, error) {
var num, sz uint var num, sz uint
for { for {
if sz*7 > uintBits-7 {
return 0, ErrLengthOverflow
}
b, err := input.ReadByte() b, err := input.ReadByte()
if err != nil { if err != nil {
return 0, err return 0, err
@@ -529,8 +559,9 @@ func decodeSize(cmd byte, delta []byte) (uint, []byte, error) {
return sz, delta, nil return sz, delta, nil
} }
func invalidSize(sz, targetSz uint) bool { // invalidSize reports whether sz exceeds the remaining target size.
return sz > targetSz func invalidSize(sz, remaining uint) bool {
return sz > remaining
} }
func invalidOffsetSize(offset, sz, srcSz uint) bool { func invalidOffsetSize(offset, sz, srcSz uint) bool {
+145 -9
View File
@@ -29,8 +29,100 @@ var (
ErrSeekNotSupported = NewError("not seek support") ErrSeekNotSupported = NewError("not seek support")
// ErrMalformedPackFile is returned by the parser when the pack file is corrupted. // ErrMalformedPackFile is returned by the parser when the pack file is corrupted.
ErrMalformedPackFile = errors.New("malformed PACK file") 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 // ObjectHeader contains the information related to the object, this information
// is collected from the previous bytes to the content of the object. // is collected from the previous bytes to the content of the object.
type ObjectHeader struct { type ObjectHeader struct {
@@ -220,6 +312,13 @@ func (s *Scanner) nextObjectHeader() (*ObjectHeader, error) {
return nil, err 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 h.OffsetReference = h.Offset - no
case plumbing.REFDeltaObject: case plumbing.REFDeltaObject:
var err error var err error
@@ -303,6 +402,13 @@ func (s *Scanner) readLength(first byte) (int64, error) {
shift := firstLengthBits shift := firstLengthBits
var err error var err error
for c&maskContinue > 0 { 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 { if c, err = s.r.ReadByte(); err != nil {
return 0, err 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 // 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) { 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 s.pendingObject = nil
written, err = s.copyObject(w) written, err = s.copyObject(w, declaredSize)
s.r.Flush() s.r.Flush()
crc32 = s.crc.Sum32() crc32 = s.crc.Sum32()
@@ -327,23 +441,39 @@ func (s *Scanner) NextObject(w io.Writer) (written int64, crc32 uint32, err erro
return 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) { func (s *Scanner) ReadObject() (io.ReadCloser, error) {
declaredSize := int64(-1)
if s.pendingObject != nil {
declaredSize = s.pendingObject.Length
}
s.pendingObject = nil s.pendingObject = nil
zr, err := sync.GetZlibReader(s.r) zr, err := sync.GetZlibReader(s.r)
if err != nil { if err != nil {
return nil, fmt.Errorf("zlib reset error: %s", err) 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) sync.PutZlibReader(zr)
return nil return nil
}), nil })
if declaredSize >= 0 {
return newBoundedReadCloser(rc, declaredSize), nil
}
return rc, nil
} }
// ReadRegularObject reads and write a non-deltified object // copyObject inflates a non-deltified object's zlib stream into w. When
// from it zlib stream in an object entry in the packfile. // declaredSize is non-negative, the write sink is wrapped in a
func (s *Scanner) copyObject(w io.Writer) (n int64, err error) { // 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) zr, err := sync.GetZlibReader(s.r)
defer sync.PutZlibReader(zr) 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) defer ioutil.CheckClose(zr.Reader, &err)
sink := w
if declaredSize >= 0 {
sink = &boundedWriter{w: w, limit: declaredSize}
}
buf := sync.GetByteSlice() buf := sync.GetByteSlice()
n, err = io.CopyBuffer(w, zr.Reader, *buf) n, err = io.CopyBuffer(sink, zr.Reader, *buf)
sync.PutByteSlice(buf) sync.PutByteSlice(buf)
return return
} }
+111 -92
View File
@@ -5,7 +5,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"io" "slices"
"strings" "strings"
"github.com/ProtonMail/go-crypto/openpgp" "github.com/ProtonMail/go-crypto/openpgp"
@@ -20,6 +20,7 @@ const (
beginpgp string = "-----BEGIN PGP SIGNATURE-----" beginpgp string = "-----BEGIN PGP SIGNATURE-----"
endpgp string = "-----END PGP SIGNATURE-----" endpgp string = "-----END PGP SIGNATURE-----"
headerpgp string = "gpgsig" headerpgp string = "gpgsig"
headerpgp256 string = "gpgsig-sha256"
headerencoding string = "encoding" headerencoding string = "encoding"
// https://github.com/git/git/blob/bcb6cae2966cc407ca1afc77413b3ef11103c175/Documentation/gitformat-signature.txt#L153 // 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 // in time, such as a timestamp, the author of the changes since the last
// commit, a pointer to the previous commit(s), etc. // commit, a pointer to the previous commit(s), etc.
// http://shafiulazam.com/gitbook/1_the_git_object_model.html // 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 { type Commit struct {
// Hash of the commit object. // Hash of the commit object.
Hash plumbing.Hash Hash plumbing.Hash
@@ -66,6 +72,9 @@ type Commit struct {
ExtraHeaders []ExtraHeader ExtraHeaders []ExtraHeader
s storer.EncodedObjectStorer 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 // ExtraHeader holds any non-standard header
@@ -98,7 +107,7 @@ func (h ExtraHeader) Format(f fmt.State, verb rune) {
func parseExtraHeader(line []byte) (ExtraHeader, bool) { func parseExtraHeader(line []byte) (ExtraHeader, bool) {
split := bytes.SplitN(line, []byte{' '}, 2) split := bytes.SplitN(line, []byte{' '}, 2)
out := ExtraHeader { out := ExtraHeader{
Key: string(bytes.TrimRight(split[0], "\n")), Key: string(bytes.TrimRight(split[0], "\n")),
Value: "", Value: "",
} }
@@ -181,6 +190,11 @@ func (c *Commit) NumParents() int {
var ErrParentNotFound = errors.New("commit parent not found") 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. // Parent returns the ith parent of a commit.
func (c *Commit) Parent(i int) (*Commit, error) { func (c *Commit) Parent(i int) (*Commit, error) {
if len(c.ParentHashes) == 0 || i > len(c.ParentHashes)-1 { if len(c.ParentHashes) == 0 || i > len(c.ParentHashes)-1 {
@@ -227,14 +241,23 @@ func (c *Commit) Type() plumbing.ObjectType {
return plumbing.CommitObject 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. // Decode transforms a plumbing.EncodedObject into a Commit struct.
func (c *Commit) Decode(o plumbing.EncodedObject) (err error) { func (c *Commit) Decode(o plumbing.EncodedObject) (err error) {
if o.Type() != plumbing.CommitObject { if o.Type() != plumbing.CommitObject {
return ErrUnsupportedObject return ErrUnsupportedObject
} }
c.reset()
c.Hash = o.Hash() c.Hash = o.Hash()
c.Encoding = defaultUtf8CommitMessageEncoding c.src = o
reader, err := o.Reader() reader, err := o.Reader()
if err != nil { if err != nil {
@@ -245,97 +268,17 @@ func (c *Commit) Decode(o plumbing.EncodedObject) (err error) {
r := sync.GetBufioReader(reader) r := sync.GetBufioReader(reader)
defer sync.PutBufioReader(r) defer sync.PutBufioReader(r)
var message bool s := &commitScanner{r: r, c: c}
var mergetag bool for state := scanTree; state != nil; {
var pgpsig bool state, err = state(s)
var msgbuf bytes.Buffer if err != nil {
var extraheader *ExtraHeader = nil
for {
line, err := r.ReadBytes('\n')
if err != nil && err != io.EOF {
return err return err
} }
if mergetag {
if len(line) > 0 && line[0] == ' ' {
line = bytes.TrimLeft(line, " ")
c.MergeTag += string(line)
continue
} else {
mergetag = false
} }
if !s.sawTree {
return fmt.Errorf("%w: missing tree header", ErrMalformedCommit)
} }
c.Message = s.msgbuf.String()
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()
return nil return nil
} }
@@ -344,11 +287,73 @@ func (c *Commit) Encode(o plumbing.EncodedObject) error {
return c.encode(o, true) 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 { func (c *Commit) EncodeWithoutSignature(o plumbing.EncodedObject) error {
if c.matchesSource() {
return stripObjectSignatures(o, c.src, plumbing.CommitObject)
}
return c.encode(o, false) 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) { func (c *Commit) encode(o plumbing.EncodedObject, includeSig bool) (err error) {
o.SetType(plumbing.CommitObject) o.SetType(plumbing.CommitObject)
w, err := o.Writer() w, err := o.Writer()
@@ -407,7 +412,9 @@ func (c *Commit) encode(o plumbing.EncodedObject, includeSig bool) (err error) {
} }
for _, header := range c.ExtraHeaders { for _, header := range c.ExtraHeaders {
if isStandardHeader(header.Key) {
continue
}
if _, err = fmt.Fprintf(w, "\n%s", header); err != nil { if _, err = fmt.Fprintf(w, "\n%s", header); err != nil {
return err 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 // Verify performs PGP verification of the commit 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 (c *Commit) Verify(armoredKeyRing string) (*openpgp.Entity, error) { func (c *Commit) Verify(armoredKeyRing string) (*openpgp.Entity, error) {
if countSignatureBlocks([]byte(c.PGPSignature)) > 1 {
return nil, ErrMultipleSignatures
}
keyRingReader := strings.NewReader(armoredKeyRing) keyRingReader := strings.NewReader(armoredKeyRing)
keyring, err := openpgp.ReadArmoredKeyRing(keyRingReader) keyring, err := openpgp.ReadArmoredKeyRing(keyRingReader)
if err != nil { if err != nil {
+377
View File
@@ -0,0 +1,377 @@
package object
import (
"bufio"
"bytes"
"fmt"
"io"
"strings"
"github.com/go-git/go-git/v5/plumbing"
)
// commitScanner holds the working state of the commit decoder driven by the
// stateFn loop in (*Commit).Decode. Each commitState reads one or more lines
// from r, updates the in-progress *Commit and the scanner's bookkeeping, and
// returns the state that should run next (or nil to stop).
type commitScanner struct {
r *bufio.Reader
c *Commit
msgbuf bytes.Buffer
// pending holds a line that was read but the current state decided to
// hand back to the next state, paired with the io.EOF flag that was
// returned when the line was originally read.
pending []byte
pendingErr error
// First-occurrence tracking: once the corresponding field has been
// decoded, subsequent occurrences are silently dropped (matches
// upstream's find_commit_header / first-wins semantics).
//
// gpgsig is not tracked here: upstream's parse_buffer_signed_by_header
// (commit.c:1186) accumulates every occurrence into one signature buffer,
// so we do the same on the scanner side to keep verification payloads
// byte-aligned. gpgsig-sha256 is recognized and skipped without exposing a
// new field in v5.
sawTree, sawAuthor, sawCommitter bool
sawEncoding, sawMergetag bool
// extra is the multi-line ExtraHeader currently being assembled.
extra *ExtraHeader
}
// commitState is one step of the decoder state machine. Each function reads
// the lines it needs, mutates *Commit via s.c, and returns the next state to
// run (or nil to terminate the loop).
type commitState func(*commitScanner) (commitState, error)
// readLine returns the next line from the buffer, transparently consuming any
// line that was previously pushed back by a state that decided not to handle
// it.
func (s *commitScanner) readLine() ([]byte, error) {
if s.pending != nil {
line, err := s.pending, s.pendingErr
s.pending, s.pendingErr = nil, nil
return line, err
}
line, err := s.r.ReadBytes('\n')
if err != nil && err != io.EOF {
return line, err
}
return line, err
}
// pushBack stashes an unconsumed line so the next state's readLine call sees
// it. Only one line can be pushed back at a time.
func (s *commitScanner) pushBack(line []byte, err error) {
s.pending = line
s.pendingErr = err
}
// scanTree expects the first non-empty header to be `tree HASH`. Anything
// else (or an empty buffer) is rejected with ErrMalformedCommit, matching
// upstream's `bogus commit object` check.
func scanTree(s *commitScanner) (commitState, error) {
line, err := s.readLine()
if err != nil && err != io.EOF {
return nil, err
}
if len(line) == 0 || isBlankLine(line) {
return nil, fmt.Errorf("%w: missing tree header", ErrMalformedCommit)
}
key, data := splitHeader(line)
if key != "tree" {
return nil, fmt.Errorf("%w: tree header must be first", ErrMalformedCommit)
}
h, herr := parseObjectIDHex(data, ErrMalformedCommit, "tree")
if herr != nil {
return nil, herr
}
s.c.TreeHash = h
s.sawTree = true
if err == io.EOF {
return nil, nil
}
return scanParents, nil
}
// scanParents consumes contiguous `parent HASH` lines. The first non-parent
// line ends the parent block and is handed off to scanAuthor; any later
// `parent` line is silently dropped (matches upstream's parse_commit_buffer
// exiting its parent loop at the first non-parent line and
// read_commit_extra_header_lines filtering `parent` out of extras).
func scanParents(s *commitScanner) (commitState, error) {
line, err := s.readLine()
if err != nil && err != io.EOF {
return nil, err
}
if len(line) == 0 {
return nil, nil
}
if isBlankLine(line) {
return scanMessage, nil
}
key, data := splitHeader(line)
if key == "parent" {
h, herr := parseObjectIDHex(data, ErrMalformedCommit, "parent")
if herr != nil {
return nil, herr
}
s.c.ParentHashes = append(s.c.ParentHashes, h)
if err == io.EOF {
return nil, nil
}
return scanParents, nil
}
s.pushBack(line, err)
return scanAuthor, nil
}
// scanAuthor accepts an `author` line at its canonical position immediately
// after the parent block. Any other header here is pushed back for
// scanCommitter; an out-of-place author is therefore silently dropped.
// Mirrors upstream's parse_commit_date func.
func scanAuthor(s *commitScanner) (commitState, error) {
line, err := s.readLine()
if err != nil && err != io.EOF {
return nil, err
}
if len(line) == 0 {
return nil, nil
}
if isBlankLine(line) {
return scanMessage, nil
}
key, data := splitHeader(line)
if key == "author" {
s.c.Author.Decode(data)
s.sawAuthor = true
if err == io.EOF {
return nil, nil
}
return scanCommitter, nil
}
s.pushBack(line, err)
return scanCommitter, nil
}
// scanCommitter accepts a `committer` line at its canonical position
// immediately after the author. Any other header is pushed back for
// scanHeaders. Same upstream rationale as scanAuthor.
func scanCommitter(s *commitScanner) (commitState, error) {
line, err := s.readLine()
if err != nil && err != io.EOF {
return nil, err
}
if len(line) == 0 {
return nil, nil
}
if isBlankLine(line) {
return scanMessage, nil
}
key, data := splitHeader(line)
if key == "committer" {
s.c.Committer.Decode(data)
s.sawCommitter = true
if err == io.EOF {
return nil, nil
}
return scanHeaders, nil
}
s.pushBack(line, err)
return scanHeaders, nil
}
// scanHeaders dispatches one header line. Continuation-bearing headers
// (mergetag, gpgsig, gpgsig-sha256, and unknown extras whose value is
// continued on subsequent lines) hand off to a dedicated continuation state
// that handles the `<space>...` lines and then returns here.
func scanHeaders(s *commitScanner) (commitState, error) {
line, err := s.readLine()
if err != nil && err != io.EOF {
return nil, err
}
if len(line) == 0 {
return nil, nil
}
if isBlankLine(line) {
return scanMessage, nil
}
originalLine := line
key, data := splitHeader(line)
var next commitState = scanHeaders
switch key {
case "tree", "parent", "author", "committer":
// Anything reaching scanHeaders with one of these keys is out of
// canonical position: duplicate tree, parent past the contiguous
// block, or author/committer not at their expected slot. Drop them
// the same way upstream's standard_header_field filter excludes
// them from the extras list (read_commit_extra_header_lines,
// commit.c:1520-1522).
case headerencoding:
if !s.sawEncoding {
s.c.Encoding = MessageEncoding(data)
s.sawEncoding = true
}
case headermergetag:
if s.sawMergetag {
next = scanSkipCont
} else {
s.c.MergeTag += string(data) + "\n"
s.sawMergetag = true
next = scanMergetagCont
}
case headerpgp:
s.c.PGPSignature += string(data) + "\n"
next = scanPgpCont
case headerpgp256:
next = scanSkipCont
default:
h, multiline := parseExtraHeader(originalLine)
if multiline {
s.extra = &h
next = scanExtraCont
} else {
s.c.ExtraHeaders = append(s.c.ExtraHeaders, h)
}
}
if err == io.EOF {
return nil, nil
}
return next, nil
}
// scanMergetagCont accumulates continuation lines for the first mergetag
// header. Continuations strip exactly one leading space, mirroring upstream's
// `line + 1` (commit.c:1509). The first non-continuation line is pushed back
// so scanHeaders can dispatch it.
func scanMergetagCont(s *commitScanner) (commitState, error) {
return continuationCont(s, &s.c.MergeTag, scanMergetagCont)
}
// scanPgpCont accumulates continuation lines for a signature header.
// Continuations strip exactly one leading space, mirroring upstream's
// `line + 1` (commit.c:1509). The first non-continuation line is pushed back
// so scanHeaders can dispatch it. Repeat occurrences of the same signature
// header land back here and concatenate, matching upstream's
// parse_buffer_signed_by_header (commit.c:1186).
func scanPgpCont(s *commitScanner) (commitState, error) {
return continuationCont(s, &s.c.PGPSignature, scanPgpCont)
}
func continuationCont(s *commitScanner, dst *string, self commitState) (commitState, error) {
line, err := s.readLine()
if err != nil && err != io.EOF {
return nil, err
}
if len(line) > 0 && line[0] == ' ' {
*dst += string(line[1:])
if err == io.EOF {
return nil, nil
}
return self, nil
}
if len(line) > 0 {
s.pushBack(line, err)
}
return scanHeaders, nil
}
// scanSkipCont discards continuation lines that belong to a header scanHeaders
// chose to drop.
func scanSkipCont(s *commitScanner) (commitState, error) {
line, err := s.readLine()
if err != nil && err != io.EOF {
return nil, err
}
if len(line) > 0 && line[0] == ' ' {
if err == io.EOF {
return nil, nil
}
return scanSkipCont, nil
}
if len(line) > 0 {
s.pushBack(line, err)
}
return scanHeaders, nil
}
// scanExtraCont accumulates continuation lines for an unknown ExtraHeader
// whose value spans multiple lines, then finalises the entry once the
// continuation block ends.
func scanExtraCont(s *commitScanner) (commitState, error) {
line, err := s.readLine()
if err != nil && err != io.EOF {
return nil, err
}
if len(line) > 0 && line[0] == ' ' {
s.extra.Value += string(line[1:])
if err == io.EOF {
s.finaliseExtra()
return nil, nil
}
return scanExtraCont, nil
}
s.finaliseExtra()
if len(line) > 0 {
s.pushBack(line, err)
}
return scanHeaders, nil
}
func (s *commitScanner) finaliseExtra() {
s.extra.Value = strings.TrimRight(s.extra.Value, "\n")
s.c.ExtraHeaders = append(s.c.ExtraHeaders, *s.extra)
s.extra = nil
}
// scanMessage drains the remaining bytes into the message buffer.
func scanMessage(s *commitScanner) (commitState, error) {
for {
line, err := s.readLine()
if err != nil && err != io.EOF {
return nil, err
}
if len(line) > 0 {
s.msgbuf.Write(line)
}
if err == io.EOF {
return nil, nil
}
}
}
// isBlankLine reports whether line is the canonical header/body separator:
// a single newline. Mirrors upstream's `*line == '\n'` test in
// read_commit_extra_header_lines (commit.c:1502).
func isBlankLine(line []byte) bool {
return len(line) == 1 && line[0] == '\n'
}
// splitHeader returns the header keyword (everything before the first space)
// and the value (everything after, with the trailing newline stripped). If
// the header has no value the returned data is nil.
func splitHeader(line []byte) (string, []byte) {
trimmed := bytes.TrimRight(line, "\n")
key, value, ok := bytes.Cut(trimmed, []byte{' '})
if !ok {
return string(trimmed), nil
}
return string(key), value
}
func parseObjectIDHex(data []byte, malformedErr error, header string) (plumbing.Hash, error) {
id := string(data)
if !plumbing.IsHash(id) {
return plumbing.ZeroHash, fmt.Errorf("%w: bad %s hash", malformedErr, header)
}
return plumbing.NewHash(id), nil
}
+121 -1
View File
@@ -1,6 +1,13 @@
package object 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 ( const (
signatureTypeUnknown signatureType = iota signatureTypeUnknown signatureType = iota
@@ -100,3 +107,116 @@ func parseSignedBytes(b []byte) (int, signatureType) {
} }
return match, t 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
}
}
}
+87 -42
View File
@@ -1,9 +1,8 @@
package object package object
import ( import (
"bytes" "errors"
"fmt" "fmt"
"io"
"strings" "strings"
"github.com/ProtonMail/go-crypto/openpgp" "github.com/ProtonMail/go-crypto/openpgp"
@@ -13,6 +12,10 @@ import (
"github.com/go-git/go-git/v5/utils/sync" "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 // 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 // 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 // provides a reference that associates the target with a tag name. It also
@@ -39,6 +42,9 @@ type Tag struct {
Target plumbing.Hash Target plumbing.Hash
s storer.EncodedObjectStorer 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. // GetTag gets a tag from an object storer and decodes it.
@@ -77,13 +83,20 @@ func (t *Tag) Type() plumbing.ObjectType {
return plumbing.TagObject return plumbing.TagObject
} }
func (t *Tag) reset() {
storer := t.s
*t = Tag{s: storer}
}
// Decode transforms a plumbing.EncodedObject into a Tag struct. // Decode transforms a plumbing.EncodedObject into a Tag struct.
func (t *Tag) Decode(o plumbing.EncodedObject) (err error) { func (t *Tag) Decode(o plumbing.EncodedObject) (err error) {
if o.Type() != plumbing.TagObject { if o.Type() != plumbing.TagObject {
return ErrUnsupportedObject return ErrUnsupportedObject
} }
t.reset()
t.Hash = o.Hash() t.Hash = o.Hash()
t.src = o
reader, err := o.Reader() reader, err := o.Reader()
if err != nil { if err != nil {
@@ -94,42 +107,15 @@ func (t *Tag) Decode(o plumbing.EncodedObject) (err error) {
r := sync.GetBufioReader(reader) r := sync.GetBufioReader(reader)
defer sync.PutBufioReader(r) defer sync.PutBufioReader(r)
for { scanner := &tagScanner{r: r, t: t}
var line []byte for state := scanTagObject; state != nil; {
line, err = r.ReadBytes('\n') state, err = state(scanner)
if err != nil && err != io.EOF {
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 { if err != nil {
return err return err
} }
case "tag":
t.Name = string(split[1])
case "tagger":
t.Tagger.Decode(split[1])
} }
if err == io.EOF { data := scanner.msgbuf.Bytes()
return nil
}
}
data, err := io.ReadAll(r)
if err != nil {
return err
}
if sm, _ := parseSignedBytes(data); sm >= 0 { if sm, _ := parseSignedBytes(data); sm >= 0 {
t.PGPSignature = string(data[sm:]) t.PGPSignature = string(data[sm:])
data = data[:sm] data = data[:sm]
@@ -144,11 +130,54 @@ func (t *Tag) Encode(o plumbing.EncodedObject) error {
return t.encode(o, true) 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 { func (t *Tag) EncodeWithoutSignature(o plumbing.EncodedObject) error {
if t.matchesSource() {
return stripObjectSignatures(o, t.src, plumbing.TagObject)
}
return t.encode(o, false) 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) { func (t *Tag) encode(o plumbing.EncodedObject, includeSig bool) (err error) {
o.SetType(plumbing.TagObject) o.SetType(plumbing.TagObject)
w, err := o.Writer() w, err := o.Writer()
@@ -158,16 +187,26 @@ func (t *Tag) encode(o plumbing.EncodedObject, includeSig bool) (err error) {
defer ioutil.CheckClose(w, &err) defer ioutil.CheckClose(w, &err)
if _, err = fmt.Fprintf(w, 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 { t.Target.String(), t.TargetType.Bytes(), t.Name); err != nil {
return err return err
} }
if !isZeroSignature(t.Tagger) {
if _, err = fmt.Fprint(w, "tagger "); err != nil {
return err
}
if err = t.Tagger.Encode(w); err != nil { if err = t.Tagger.Encode(w); err != nil {
return err return err
} }
if _, err = fmt.Fprint(w, "\n\n"); err != nil { if _, err = fmt.Fprint(w, "\n"); err != nil {
return err
}
}
if _, err = fmt.Fprint(w, "\n"); err != nil {
return err return err
} }
@@ -175,11 +214,12 @@ func (t *Tag) encode(o plumbing.EncodedObject, includeSig bool) (err error) {
return err return err
} }
// Note that this is highly sensitive to what it sent along in the message. // Note that this is highly sensitive to what is sent along in the
// Message *always* needs to end with a newline, or else the message and the // message. Message *always* needs to end with a newline, or else the
// signature will be concatenated into a corrupt object. Since this is a // message and the trailing signature will be concatenated into a
// lower-level method, we assume you know what you are doing and have already // corrupt object. Since this is a lower-level method, we assume you
// done the needful on the message in the caller. // know what you are doing and have already done the needful on the
// message in the caller.
if includeSig { if includeSig {
if _, err = fmt.Fprint(w, t.PGPSignature); err != nil { if _, err = fmt.Fprint(w, t.PGPSignature); err != nil {
return err return err
@@ -189,6 +229,10 @@ func (t *Tag) encode(o plumbing.EncodedObject, includeSig bool) (err error) {
return err 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 // Commit returns the commit pointed to by the tag. If the tag points to a
// different type of object ErrUnsupportedObject will be returned. // different type of object ErrUnsupportedObject will be returned.
func (t *Tag) Commit() (*Commit, error) { 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 // 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) { func (t *Tag) Verify(armoredKeyRing string) (*openpgp.Entity, error) {
keyRingReader := strings.NewReader(armoredKeyRing) keyRingReader := strings.NewReader(armoredKeyRing)
keyring, err := openpgp.ReadArmoredKeyRing(keyRingReader) keyring, err := openpgp.ReadArmoredKeyRing(keyRingReader)
+237
View File
@@ -0,0 +1,237 @@
package object
import (
"bufio"
"bytes"
"fmt"
"io"
"github.com/go-git/go-git/v5/plumbing"
)
// tagScanner holds the working state of the tag decoder driven by the
// stateFn loop in (*Tag).Decode. Each tagState reads one or more lines
// from r, updates the in-progress *Tag and the scanner's bookkeeping,
// and returns the state that should run next (or nil to stop).
type tagScanner struct {
r *bufio.Reader
t *Tag
msgbuf bytes.Buffer
// pending holds a line that was read but the current state decided to
// hand back to the next state, paired with the io.EOF flag returned
// when the line was originally read.
pending []byte
pendingErr error
// First-occurrence tracking: once the corresponding canonical
// header has been decoded at its expected position, subsequent
// occurrences (or out-of-position lines) are silently dropped,
// matching the strict layout enforced by upstream's
// parse_tag_buffer (tag.c:130).
//
// gpgsig-sha256 is recognized and skipped without exposing a new field
// in v5.
sawObject, sawType, sawName, sawTagger bool
}
// tagState is one step of the decoder state machine. Each function reads
// the lines it needs, mutates *Tag via s.t, and returns the next state
// to run (or nil to terminate the loop).
type tagState func(*tagScanner) (tagState, error)
// readLine returns the next line from the buffer, transparently
// consuming any line that was previously pushed back by a state that
// decided not to handle it.
func (s *tagScanner) readLine() ([]byte, error) {
if s.pending != nil {
line, err := s.pending, s.pendingErr
s.pending, s.pendingErr = nil, nil
return line, err
}
return s.r.ReadBytes('\n')
}
// pushBack stashes an unconsumed line so the next state's readLine call
// sees it. Only one line can be pushed back at a time.
func (s *tagScanner) pushBack(line []byte, err error) {
s.pending = line
s.pendingErr = err
}
// scanTagObject requires the first line to be `object HASH`, mirroring
// upstream's strict parse_tag_buffer (tag.c:151-156). Anything else
// returns ErrMalformedTag.
func scanTagObject(s *tagScanner) (tagState, error) {
line, err := s.readLine()
if err != nil && err != io.EOF {
return nil, err
}
if len(line) == 0 || isBlankLine(line) {
return nil, fmt.Errorf("%w: missing object header", ErrMalformedTag)
}
key, data := splitHeader(line)
if key != "object" {
return nil, fmt.Errorf("%w: object header must be first", ErrMalformedTag)
}
h, herr := parseObjectIDHex(data, ErrMalformedTag, "object")
if herr != nil {
return nil, herr
}
s.t.Target = h
s.sawObject = true
if err == io.EOF {
return nil, nil
}
return scanTagType, nil
}
// scanTagType requires a `type` line immediately after the object header,
// mirroring upstream's parse_tag_buffer (tag.c:158-166).
func scanTagType(s *tagScanner) (tagState, error) {
line, err := s.readLine()
if err != nil && err != io.EOF {
return nil, err
}
if len(line) == 0 || isBlankLine(line) {
return nil, fmt.Errorf("%w: missing type header", ErrMalformedTag)
}
key, data := splitHeader(line)
if key != "type" {
return nil, fmt.Errorf("%w: type header must follow object", ErrMalformedTag)
}
ot, perr := plumbing.ParseObjectType(string(data))
if perr != nil {
return nil, perr
}
s.t.TargetType = ot
s.sawType = true
if err == io.EOF {
return nil, nil
}
return scanTagName, nil
}
// scanTagName requires a `tag` line immediately after the type header,
// mirroring upstream's parse_tag_buffer (tag.c:186-194).
func scanTagName(s *tagScanner) (tagState, error) {
line, err := s.readLine()
if err != nil && err != io.EOF {
return nil, err
}
if len(line) == 0 || isBlankLine(line) {
return nil, fmt.Errorf("%w: missing tag header", ErrMalformedTag)
}
key, data := splitHeader(line)
if key != "tag" {
return nil, fmt.Errorf("%w: tag header must follow type", ErrMalformedTag)
}
s.t.Name = string(data)
s.sawName = true
if err == io.EOF {
return nil, nil
}
return scanTagTagger, nil
}
// scanTagTagger accepts a `tagger` line at its canonical position. Any
// other header is pushed back for scanTagHeaders.
func scanTagTagger(s *tagScanner) (tagState, error) {
line, err := s.readLine()
if err != nil && err != io.EOF {
return nil, err
}
if len(line) == 0 {
return nil, nil
}
if isBlankLine(line) {
return scanTagMessage, nil
}
key, data := splitHeader(line)
if key == "tagger" {
s.t.Tagger.Decode(data)
s.sawTagger = true
if err == io.EOF {
return nil, nil
}
return scanTagHeaders, nil
}
s.pushBack(line, err)
return scanTagHeaders, nil
}
// scanTagHeaders dispatches one header line. gpgsig-sha256 hands off to
// scanTagSkipCont so the continuation block can be consumed; out-of-position
// canonical fields and unknown headers are silently dropped.
func scanTagHeaders(s *tagScanner) (tagState, error) {
line, err := s.readLine()
if err != nil && err != io.EOF {
return nil, err
}
if len(line) == 0 {
return nil, nil
}
if isBlankLine(line) {
return scanTagMessage, nil
}
key, _ := splitHeader(line)
next := scanTagHeaders
switch key {
case "object", "type", "tag", "tagger":
// Out-of-canonical-position duplicates are dropped, mirroring the
// strict ordering of upstream's parse_tag_buffer.
case headerpgp256:
next = scanTagSkipCont
default:
// Unknown header: silently dropped (the Tag struct does not
// expose ExtraHeaders).
}
if err == io.EOF {
return nil, nil
}
return next, nil
}
// scanTagSkipCont discards continuation lines for a header scanTagHeaders chose
// to drop. The first non-continuation line is pushed back so scanTagHeaders can
// dispatch it.
func scanTagSkipCont(s *tagScanner) (tagState, error) {
line, err := s.readLine()
if err != nil && err != io.EOF {
return nil, err
}
if len(line) > 0 && line[0] == ' ' {
if err == io.EOF {
return nil, nil
}
return scanTagSkipCont, nil
}
if len(line) > 0 {
s.pushBack(line, err)
}
return scanTagHeaders, nil
}
// scanTagMessage drains the remaining bytes into the message buffer.
// (*Tag).Decode then runs parseSignedBytes over those bytes to peel off
// the optional inline trailing PGP signature.
func scanTagMessage(s *tagScanner) (tagState, error) {
for {
line, err := s.readLine()
if err != nil && err != io.EOF {
return nil, err
}
if len(line) > 0 {
s.msgbuf.Write(line)
}
if err == io.EOF {
return nil, nil
}
}
}
+114 -29
View File
@@ -10,6 +10,7 @@ import (
"sort" "sort"
"strings" "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"
"github.com/go-git/go-git/v5/plumbing/filemode" "github.com/go-git/go-git/v5/plumbing/filemode"
"github.com/go-git/go-git/v5/plumbing/storer" "github.com/go-git/go-git/v5/plumbing/storer"
@@ -29,6 +30,7 @@ var (
ErrDirectoryNotFound = errors.New("directory not found") ErrDirectoryNotFound = errors.New("directory not found")
ErrEntryNotFound = errors.New("entry not found") ErrEntryNotFound = errors.New("entry not found")
ErrEntriesNotSorted = errors.New("entries in tree are not sorted") 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 // Tree is basically like a directory - it references a bunch of other trees
@@ -38,8 +40,8 @@ type Tree struct {
Hash plumbing.Hash Hash plumbing.Hash
s storer.EncodedObjectStorer s storer.EncodedObjectStorer
m map[string]*TreeEntry
t map[string]*Tree // tree path cache t map[string]*Tree // tree path cache
entriesSorted bool
} }
// GetTree gets a tree from an object storer and decodes it. // 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. // 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) { 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) blob, err := GetBlob(t.s, e.Hash)
if err != nil { if err != nil {
return nil, err 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. // 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) { func (t *Tree) FindEntry(path string) (*TreeEntry, error) {
if err := pathutil.ValidTreePath(path); err != nil {
return nil, err
}
if t.t == nil { if t.t == nil {
t.t = make(map[string]*Tree) 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) { func (t *Tree) entry(baseName string) (*TreeEntry, error) {
if t.m == nil { if t.entriesSorted {
t.buildMap() if entry := t.searchEntry(baseName); entry != nil {
return entry, nil
} }
entry, ok := t.m[baseName]
if !ok {
return nil, ErrEntryNotFound return nil, ErrEntryNotFound
} }
pastName := baseName + "/"
for i := range t.Entries {
entry := &t.Entries[i]
if entry.Name == baseName {
return entry, nil 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 // Files returns a FileIter allowing to iterate over the Tree
@@ -212,20 +259,25 @@ func (t *Tree) Type() plumbing.ObjectType {
return plumbing.TreeObject return plumbing.TreeObject
} }
func (t *Tree) reset() {
storer := t.s
*t = Tree{s: storer}
}
// Decode transform an plumbing.EncodedObject into a Tree struct // Decode transform an plumbing.EncodedObject into a Tree struct
func (t *Tree) Decode(o plumbing.EncodedObject) (err error) { func (t *Tree) Decode(o plumbing.EncodedObject) (err error) {
if o.Type() != plumbing.TreeObject { if o.Type() != plumbing.TreeObject {
return ErrUnsupportedObject return ErrUnsupportedObject
} }
t.reset()
t.Hash = o.Hash() t.Hash = o.Hash()
// assume tree is sorted as a valid tree should always be sorted.
t.entriesSorted = true
if o.Size() == 0 { if o.Size() == 0 {
return nil return nil
} }
t.Entries = nil
t.m = nil
reader, err := o.Reader() reader, err := o.Reader()
if err != nil { if err != nil {
return err return err
@@ -235,10 +287,14 @@ func (t *Tree) Decode(o plumbing.EncodedObject) (err error) {
r := sync.GetBufioReader(reader) r := sync.GetBufioReader(reader)
defer sync.PutBufioReader(r) defer sync.PutBufioReader(r)
var prevSortName string
for { for {
str, err := r.ReadString(' ') str, err := r.ReadString(' ')
if err != nil { if err != nil {
if err == io.EOF { if err == io.EOF {
if len(str) != 0 {
return fmt.Errorf("%w: missing mode terminator", ErrMalformedTree)
}
break break
} }
@@ -248,25 +304,41 @@ func (t *Tree) Decode(o plumbing.EncodedObject) (err error) {
mode, err := filemode.New(str) mode, err := filemode.New(str)
if err != nil { if err != nil {
return err return fmt.Errorf("%w: malformed mode", ErrMalformedTree)
} }
mode = canonicalTreeMode(mode)
name, err := r.ReadString(0) 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 return err
} }
if len(name) == 1 {
return fmt.Errorf("%w: empty filename", ErrMalformedTree)
}
var hash plumbing.Hash var hash plumbing.Hash
if _, err = io.ReadFull(r, hash[:]); err != nil { 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 return err
} }
baseName := name[:len(name)-1] baseName := name[:len(name)-1]
t.Entries = append(t.Entries, TreeEntry{ entry := TreeEntry{
Hash: hash, Hash: hash,
Mode: mode, Mode: mode,
Name: baseName, 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 return nil
@@ -279,21 +351,37 @@ func (s TreeEntrySorter) Len() int {
} }
func (s TreeEntrySorter) Less(i, j int) bool { func (s TreeEntrySorter) Less(i, j int) bool {
name1 := s[i].Name return treeEntrySortName(&s[i]) < treeEntrySortName(&s[j])
name2 := s[j].Name
if s[i].Mode == filemode.Dir {
name1 += "/"
}
if s[j].Mode == filemode.Dir {
name2 += "/"
}
return name1 < name2
} }
func (s TreeEntrySorter) Swap(i, j int) { func (s TreeEntrySorter) Swap(i, j int) {
s[i], s[j] = s[j], s[i] 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. // Encode transforms a Tree into a plumbing.EncodedObject.
// The tree entries must be sorted by name. // The tree entries must be sorted by name.
func (t *Tree) Encode(o plumbing.EncodedObject) (err error) { func (t *Tree) Encode(o plumbing.EncodedObject) (err error) {
@@ -329,13 +417,6 @@ func (t *Tree) Encode(o plumbing.EncodedObject) (err error) {
return err 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 // Diff returns a list of changes between this tree and the provided one
func (t *Tree) Diff(to *Tree) (Changes, error) { func (t *Tree) Diff(to *Tree) (Changes, error) {
return t.DiffContext(context.Background(), to) return t.DiffContext(context.Background(), to)
@@ -455,6 +536,10 @@ func (w *TreeWalker) Next() (name string, entry TreeEntry, err error) {
continue continue
} }
if err := pathutil.ValidTreePath(entry.Name); err != nil {
return name, entry, err
}
if entry.Mode == filemode.Dir { if entry.Mode == filemode.Dir {
obj, err = GetTree(w.s, entry.Hash) obj, err = GetTree(w.s, entry.Hash)
} }
+147 -21
View File
@@ -7,7 +7,6 @@ import (
"crypto/tls" "crypto/tls"
"crypto/x509" "crypto/x509"
"fmt" "fmt"
"net"
"net/http" "net/http"
"net/url" "net/url"
"reflect" "reflect"
@@ -24,6 +23,33 @@ import (
"github.com/go-git/go-git/v5/utils/ioutil" "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 // it requires a bytes.Buffer, because we need to know the length
func applyHeadersToRequest(req *http.Request, content *bytes.Buffer, host string, requestType string) { func applyHeadersToRequest(req *http.Request, content *bytes.Buffer, host string, requestType string) {
req.Header.Add("User-Agent", capability.DefaultAgent()) req.Header.Add("User-Agent", capability.DefaultAgent())
@@ -54,12 +80,15 @@ func advertisedReferences(ctx context.Context, s *session, serviceName string) (
s.ApplyAuthToRequest(req) s.ApplyAuthToRequest(req)
applyHeadersToRequest(req, nil, s.endpoint.Host, serviceName) 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 { if err != nil {
return nil, err 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) defer ioutil.CheckClose(res.Body, &err)
if err = NewErr(res); err != nil { if err = NewErr(res); err != nil {
@@ -96,6 +125,7 @@ type client struct {
client *http.Client client *http.Client
transports *lru.Cache transports *lru.Cache
mutex sync.RWMutex mutex sync.RWMutex
follow RedirectPolicy
} }
// ClientOptions holds user configurable options for the client. // 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 // size, will result in the least recently used transport getting deleted
// before the provided transport is added to the cache. // before the provided transport is added to the cache.
CacheMaxEntries int 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 ( var (
@@ -150,12 +185,16 @@ func NewClientWithOptions(c *http.Client, opts *ClientOptions) transport.Transpo
} }
cl := &client{ cl := &client{
client: c, client: c,
follow: FollowInitialRedirects,
} }
if opts != nil { if opts != nil {
if opts.CacheMaxEntries > 0 { if opts.CacheMaxEntries > 0 {
cl.transports = lru.New(opts.CacheMaxEntries) cl.transports = lru.New(opts.CacheMaxEntries)
} }
if opts.RedirectPolicy != "" {
cl.follow = opts.RedirectPolicy
}
} }
return cl return cl
} }
@@ -289,14 +328,9 @@ func newSession(c *client, ep *transport.Endpoint, auth transport.AuthMethod) (*
} }
} }
httpClient = &http.Client{ httpClient = c.cloneHTTPClient(transport)
Transport: transport,
CheckRedirect: c.client.CheckRedirect,
Jar: c.client.Jar,
Timeout: c.client.Timeout,
}
} else { } else {
httpClient = c.client httpClient = c.cloneHTTPClient(c.client.Transport)
} }
s := &session{ s := &session{
@@ -324,30 +358,122 @@ func (s *session) ApplyAuthToRequest(req *http.Request) {
s.auth.SetAuth(req) s.auth.SetAuth(req)
} }
func (s *session) ModifyEndpointIfRedirect(res *http.Response) { func (s *session) ModifyEndpointIfRedirect(res *http.Response) error {
if res.Request == nil { if res.Request == nil {
return return nil
}
if s.endpoint == nil {
return fmt.Errorf("http redirect: nil endpoint")
} }
r := res.Request r := res.Request
if !strings.HasSuffix(r.URL.Path, infoRefsPath) { 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 { if err != nil {
h = r.URL.Host return err
} }
if p != "" {
port, err := strconv.Atoi(p) if host != s.endpoint.Host || effectivePort(r.URL.Scheme, port) != effectivePort(s.endpoint.Protocol, s.endpoint.Port) {
if err == nil { s.endpoint.User = ""
s.endpoint.Password = ""
s.auth = nil
}
s.endpoint.Host = host
s.endpoint.Port = port s.endpoint.Port = port
}
}
s.endpoint.Host = h
s.endpoint.Protocol = r.URL.Scheme s.endpoint.Protocol = r.URL.Scheme
s.endpoint.Path = r.URL.Path[:len(r.URL.Path)-len(infoRefsPath)] 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 { func (*session) Close() error {
+33 -1
View File
@@ -252,7 +252,39 @@ func (c *command) setAuthFromEndpoint() error {
} }
func endpointToCommand(cmd string, ep *transport.Endpoint) string { 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) { func overrideConfig(overrides *ssh.ClientConfig, c *ssh.ClientConfig) {
+18 -1
View File
@@ -208,6 +208,12 @@ func Open(s storage.Storer, worktree billy.Filesystem) (*Repository, error) {
return nil, ErrRepositoryNotExists return nil, ErrRepositoryNotExists
} }
cfg, err := s.Config()
if err != nil {
return nil, err
}
err = verifyExtensions(s, cfg)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -1524,7 +1530,18 @@ func (r *Repository) Worktree() (*Worktree, error) {
return nil, ErrIsBareRepository 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) { func expand_ref(s storer.ReferenceStorer, ref plumbing.ReferenceName) (*plumbing.Reference, error) {
+121
View File
@@ -0,0 +1,121 @@
package git
import (
"errors"
"fmt"
"strings"
"github.com/go-git/go-git/v5/config"
cfgformat "github.com/go-git/go-git/v5/plumbing/format/config"
"github.com/go-git/go-git/v5/storage"
)
var (
// ErrUnsupportedExtensionRepositoryFormatVersion represents when an
// extension being used is not compatible with the repository's
// core.repositoryFormatVersion.
ErrUnsupportedExtensionRepositoryFormatVersion = errors.New("core.repositoryformatversion does not support extension")
// ErrUnsupportedRepositoryFormatVersion represents when an repository
// is using a format version that is not supported.
ErrUnsupportedRepositoryFormatVersion = errors.New("core.repositoryformatversion not supported")
// ErrUnknownExtension represents when a repository has an extension
// which is unknown or unsupported by go-git.
ErrUnknownExtension = errors.New("unknown extension")
// builtinExtensions defines the Git extensions that are supported by
// the core go-git implementation.
//
// Some extensions are storage-specific, those are defined by the Storers
// themselves by implementing the ExtensionChecker interface.
builtinExtensions = map[string]struct{}{
// noop does not change gits behavior at all.
// It is useful only for testing format-1 compatibility.
//
// This extension is respected regardless of the
// core.repositoryFormatVersion setting.
"noop": {},
// noop-v1 does not change gits behavior at all.
// It is useful only for testing format-1 compatibility.
"noop-v1": {},
}
// Some Git extensions were supported upstream before the introduction
// of repositoryformatversion. These are the only extensions that can be
// enabled while core.repositoryformatversion is unset or set to 0.
extensionsValidForV0 = map[string]struct{}{
"noop": {},
"partialClone": {},
"preciousObjects": {},
"worktreeConfig": {},
}
)
type extension struct {
name string
value string
}
func extensions(cfg *config.Config) []extension {
if cfg == nil || cfg.Raw == nil {
return nil
}
if !cfg.Raw.HasSection("extensions") {
return nil
}
section := cfg.Raw.Section("extensions")
out := make([]extension, 0, len(section.Options))
for _, opt := range section.Options {
out = append(out, extension{name: strings.ToLower(opt.Key), value: strings.ToLower(opt.Value)})
}
return out
}
func verifyExtensions(st storage.Storer, cfg *config.Config) error {
needed := extensions(cfg)
switch cfg.Core.RepositoryFormatVersion {
case "", cfgformat.Version_0, cfgformat.Version_1:
default:
return fmt.Errorf("%w: %q",
ErrUnsupportedRepositoryFormatVersion,
cfg.Core.RepositoryFormatVersion)
}
if len(needed) > 0 {
if cfg.Core.RepositoryFormatVersion == cfgformat.Version_0 ||
cfg.Core.RepositoryFormatVersion == "" {
var unsupported []string
for _, ext := range needed {
if _, ok := extensionsValidForV0[ext.name]; !ok {
unsupported = append(unsupported, ext.name)
}
}
if len(unsupported) > 0 {
return fmt.Errorf("%w: %s",
ErrUnsupportedExtensionRepositoryFormatVersion,
strings.Join(unsupported, ", "))
}
}
var missing []string
for _, ext := range needed {
if _, ok := builtinExtensions[ext.name]; ok {
continue
}
missing = append(missing, ext.name)
}
if len(missing) > 0 {
return fmt.Errorf("%w: %s", ErrUnknownExtension, strings.Join(missing, ", "))
}
}
return nil
}
+17 -2
View File
@@ -75,6 +75,10 @@ var (
// ErrEmptyRefFile is returned when a reference file is attempted to be read, // ErrEmptyRefFile is returned when a reference file is attempted to be read,
// but the file is empty // but the file is empty
ErrEmptyRefFile = errors.New("ref file is empty") ErrEmptyRefFile = errors.New("ref file is empty")
// ErrModuleNameEscape is returned when a submodule name would
// resolve outside the modules/ subtree, mirroring canonical Git's
// "ignoring suspicious submodule name" defence.
ErrModuleNameEscape = errors.New("submodule name escapes modules/ directory")
) )
// Options holds configuration for the storage. // Options holds configuration for the storage.
@@ -1127,9 +1131,20 @@ func (d *DotGit) PackRefs() (err error) {
return nil 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) { 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 { func (d *DotGit) AddAlternate(remote string) error {
+58 -4
View File
@@ -3,6 +3,7 @@ package dotgit
import ( import (
"fmt" "fmt"
"io" "io"
"os"
"sync/atomic" "sync/atomic"
"github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing"
@@ -131,20 +132,62 @@ func (w *PackWriter) clean() error {
func (w *PackWriter) save() error { func (w *PackWriter) save() error {
base := w.fs.Join(objectsPath, packPath, fmt.Sprintf("pack-%s", w.checksum)) base := w.fs.Join(objectsPath, packPath, fmt.Sprintf("pack-%s", w.checksum))
idx, err := w.fs.Create(fmt.Sprintf("%s.idx", base))
// Pack files are content addressable. Each file is checked
// individually — if it already exists on disk, skip creating it.
idxPath := fmt.Sprintf("%s.idx", base)
exists, err := fileExists(w.fs, idxPath)
if err != nil {
return err
}
if !exists {
idx, err := w.fs.Create(idxPath)
if err != nil { if err != nil {
return err return err
} }
if err := w.encodeIdx(idx); err != nil { if err := w.encodeIdx(idx); err != nil {
_ = idx.Close()
return err return err
} }
if err := idx.Close(); err != nil { if err := idx.Close(); err != nil {
return err return err
} }
fixPermissions(w.fs, idxPath)
}
return w.fs.Rename(w.fw.Name(), fmt.Sprintf("%s.pack", base)) packPath := fmt.Sprintf("%s.pack", base)
exists, err = fileExists(w.fs, packPath)
if err != nil {
return err
}
if !exists {
if err := w.fs.Rename(w.fw.Name(), packPath); err != nil {
return err
}
fixPermissions(w.fs, packPath)
} else {
// Pack already exists, clean up the temp file.
return w.clean()
}
return nil
}
// fileExists checks whether path already exists as a regular file.
// It returns (true, nil) for an existing regular file, (false, nil) when the
// path does not exist, and (false, err) if the path exists but is not a
// regular file (e.g. a directory or symlink).
func fileExists(fs billy.Filesystem, path string) (bool, error) {
fi, err := fs.Lstat(path)
if err != nil {
return false, nil
}
if !fi.Mode().IsRegular() {
return false, fmt.Errorf("unexpected file type for %q: %s", path, fi.Mode().Type())
}
return true, nil
} }
func (w *PackWriter) encodeIdx(writer io.Writer) error { func (w *PackWriter) encodeIdx(writer io.Writer) error {
@@ -226,7 +269,6 @@ func (s *syncedReader) sleep() {
atomic.StoreUint32(&s.blocked, 1) atomic.StoreUint32(&s.blocked, 1)
<-s.news <-s.news
} }
} }
func (s *syncedReader) Seek(offset int64, whence int) (int64, error) { func (s *syncedReader) Seek(offset int64, whence int) (int64, error) {
@@ -281,5 +323,17 @@ func (w *ObjectWriter) save() error {
hex := w.Hash().String() hex := w.Hash().String()
file := w.fs.Join(objectsPath, hex[0:2], hex[2:hash.HexSize]) file := w.fs.Join(objectsPath, hex[0:2], hex[2:hash.HexSize])
return w.fs.Rename(w.f.Name(), file) // Loose objects are content addressable, if they already exist
// we can safely delete the temporary file and short-circuit the
// operation.
if _, err := w.fs.Lstat(file); err == nil || os.IsExist(err) {
return w.fs.Remove(w.f.Name())
}
if err := w.fs.Rename(w.f.Name(), file); err != nil {
return err
}
fixPermissions(w.fs, file)
return nil
} }
@@ -0,0 +1,29 @@
//go:build !windows
package dotgit
import (
"github.com/go-git/go-billy/v5"
"github.com/go-git/go-git/v5/utils/trace"
)
func fixPermissions(fs billy.Filesystem, path string) {
if chmodFS, ok := fs.(billy.Chmod); ok {
if err := chmodFS.Chmod(path, 0o444); err != nil {
trace.General.Printf("failed to chmod %s: %v", path, err)
}
}
}
func isReadOnly(fs billy.Filesystem, path string) (bool, error) {
fi, err := fs.Stat(path)
if err != nil {
return false, err
}
if fi.Mode().Perm() == 0o444 {
return true, nil
}
return false, nil
}
@@ -0,0 +1,58 @@
//go:build windows
package dotgit
import (
"fmt"
"path/filepath"
"github.com/go-git/go-billy/v5"
"github.com/go-git/go-git/v5/utils/trace"
"golang.org/x/sys/windows"
)
func fixPermissions(fs billy.Filesystem, path string) {
fullpath := filepath.Join(fs.Root(), path)
p, err := windows.UTF16PtrFromString(fullpath)
if err != nil {
trace.General.Printf("failed to chmod %s: %v", fullpath, err)
return
}
attrs, err := windows.GetFileAttributes(p)
if err != nil {
trace.General.Printf("failed to chmod %s: %v", fullpath, err)
return
}
if attrs&windows.FILE_ATTRIBUTE_READONLY != 0 {
return
}
err = windows.SetFileAttributes(p,
attrs|windows.FILE_ATTRIBUTE_READONLY,
)
if err != nil {
trace.General.Printf("failed to chmod %s: %v", fullpath, err)
}
}
func isReadOnly(fs billy.Filesystem, path string) (bool, error) {
fullpath := filepath.Join(fs.Root(), path)
p, err := windows.UTF16PtrFromString(fullpath)
if err != nil {
return false, fmt.Errorf("%w: %q", err, fullpath)
}
attrs, err := windows.GetFileAttributes(p)
if err != nil {
return false, fmt.Errorf("%w: %q", err, fullpath)
}
if attrs&windows.FILE_ATTRIBUTE_READONLY != 0 {
return true, nil
}
return false, nil
}
+5
View File
@@ -48,6 +48,11 @@ func (s *IndexStorage) Index() (i *index.Index, err error) {
defer ioutil.CheckClose(f, &err) defer ioutil.CheckClose(f, &err)
fi, statErr := s.dir.Fs().Stat(f.Name())
if statErr == nil {
idx.ModTime = fi.ModTime()
}
d := index.NewDecoder(f) d := index.NewDecoder(f)
err = d.Decode(idx) err = d.Decode(idx)
return idx, err return idx, err
+4
View File
@@ -69,7 +69,11 @@ type IndexStorage struct {
index *index.Index index *index.Index
} }
// SetIndex stores the given index.
// Note: this method sets idx.ModTime to simulate filesystem storage behavior.
func (c *IndexStorage) SetIndex(idx *index.Index) error { func (c *IndexStorage) SetIndex(idx *index.Index) error {
// Set ModTime to enable racy git detection in the metadata optimization.
idx.ModTime = time.Now()
c.index = idx c.index = idx
return nil return nil
} }
+74 -8
View File
@@ -6,9 +6,12 @@ import (
"errors" "errors"
"fmt" "fmt"
"path" "path"
"path/filepath"
"github.com/go-git/go-billy/v5" "github.com/go-git/go-billy/v5"
"github.com/go-git/go-git/v5/config" "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"
"github.com/go-git/go-git/v5/plumbing/format/index" "github.com/go-git/go-git/v5/plumbing/format/index"
"github.com/go-git/go-git/v5/plumbing/transport" "github.com/go-git/go-git/v5/plumbing/transport"
@@ -119,6 +122,16 @@ func (s *Submodule) Repository() (*Repository, error) {
exists = true 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 var worktree billy.Filesystem
if worktree, err = s.w.Filesystem.Chroot(s.c.Path); err != nil { if worktree, err = s.w.Filesystem.Chroot(s.c.Path); err != nil {
return nil, err return nil, err
@@ -138,18 +151,25 @@ func (s *Submodule) Repository() (*Repository, error) {
return nil, err return nil, err
} }
if !path.IsAbs(moduleEndpoint.Path) && moduleEndpoint.Protocol == "file" { // A relative submodule URL such as "../X.git" must resolve against
remotes, err := s.w.r.Remotes() // 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 { if err != nil {
return nil, err return nil, err
} }
rootEndpoint, err := transport.NewEndpoint(remotes[0].c.URLs[0]) rootEndpoint.Path = path.Join(rootEndpoint.Path, s.c.URL)
if err != nil {
return nil, err
}
rootEndpoint.Path = path.Join(rootEndpoint.Path, moduleEndpoint.Path)
*moduleEndpoint = *rootEndpoint *moduleEndpoint = *rootEndpoint
} }
@@ -161,6 +181,52 @@ func (s *Submodule) Repository() (*Repository, error) {
return r, err 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 // Update the registered submodule to match what the superproject expects, the
// submodule should be initialized first calling the Init method or setting in // submodule should be initialized first calling the Init method or setting in
// the options SubmoduleUpdateOptions.Init equals true // the options SubmoduleUpdateOptions.Init equals true
+15
View File
@@ -5,11 +5,18 @@ package binary
import ( import (
"bufio" "bufio"
"encoding/binary" "encoding/binary"
"errors"
"io" "io"
"math"
"github.com/go-git/go-git/v5/plumbing" "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 // Read reads structured binary data from r into data. Bytes are read and
// decoded in BigEndian order // decoded in BigEndian order
// https://golang.org/pkg/encoding/binary/#Read // https://golang.org/pkg/encoding/binary/#Read
@@ -92,6 +99,14 @@ func ReadVariableWidthInt(r io.Reader) (int64, error) {
var v = int64(c & maskLength) var v = int64(c & maskLength)
for c&maskContinue > 0 { 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++ v++
if err := Read(r, &c); err != nil { if err := Read(r, &c); err != nil {
return 0, err return 0, err
+99 -1
View File
@@ -4,9 +4,11 @@ import (
"io" "io"
"os" "os"
"path" "path"
"time"
"github.com/go-git/go-git/v5/plumbing" "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/filemode"
"github.com/go-git/go-git/v5/plumbing/format/index"
"github.com/go-git/go-git/v5/utils/merkletrie/noder" "github.com/go-git/go-git/v5/utils/merkletrie/noder"
"github.com/go-git/go-billy/v5" "github.com/go-git/go-billy/v5"
@@ -16,6 +18,14 @@ var ignore = map[string]bool{
".git": true, ".git": true,
} }
// Options contains configuration for the filesystem node.
type Options struct {
// Index is used to enable the metadata-first comparison optimization while
// correctly handling the "racy git" condition. If no index is provided,
// the function works without the optimization.
Index *index.Index
}
// The node represents a file or a directory in a billy.Filesystem. It // The node represents a file or a directory in a billy.Filesystem. It
// implements the interface noder.Noder of merkletrie package. // implements the interface noder.Noder of merkletrie package.
// //
@@ -24,6 +34,8 @@ var ignore = map[string]bool{
type node struct { type node struct {
fs billy.Filesystem fs billy.Filesystem
submodules map[string]plumbing.Hash submodules map[string]plumbing.Hash
idx *index.Index
idxMap map[string]*index.Entry
path string path string
hash []byte hash []byte
@@ -31,6 +43,7 @@ type node struct {
isDir bool isDir bool
mode os.FileMode mode os.FileMode
size int64 size int64
modTime time.Time
} }
// NewRootNode returns the root node based on a given billy.Filesystem. // NewRootNode returns the root node based on a given billy.Filesystem.
@@ -42,7 +55,41 @@ func NewRootNode(
fs billy.Filesystem, fs billy.Filesystem,
submodules map[string]plumbing.Hash, submodules map[string]plumbing.Hash,
) noder.Noder { ) noder.Noder {
return &node{fs: fs, submodules: submodules, isDir: true} return NewRootNodeWithOptions(fs, submodules, Options{})
}
// NewRootNodeWithOptions returns the root node based on a given billy.Filesystem
// with options to set an index. Providing an index enables the metadata-first
// comparison optimization while correctly handling the "racy git" condition. If
// no index is provided, the function works without the optimization.
//
// The index's ModTime field is used to detect the racy git condition. When a file's
// mtime equals or is newer than the index ModTime, we must hash the file content
// even if other metadata matches, because the file may have been modified in the
// same second that the index was written.
//
// Reference: https://git-scm.com/docs/racy-git
func NewRootNodeWithOptions(
fs billy.Filesystem,
submodules map[string]plumbing.Hash,
options Options,
) noder.Noder {
var idxMap map[string]*index.Entry
if options.Index != nil {
idxMap = make(map[string]*index.Entry, len(options.Index.Entries))
for _, entry := range options.Index.Entries {
idxMap[entry.Name] = entry
}
}
return &node{
fs: fs,
submodules: submodules,
idx: options.Index,
idxMap: idxMap,
isDir: true,
}
} }
// Hash the hash of a filesystem is the result of concatenating the computed // Hash the hash of a filesystem is the result of concatenating the computed
@@ -133,11 +180,14 @@ func (n *node) newChildNode(file os.FileInfo) (*node, error) {
node := &node{ node := &node{
fs: n.fs, fs: n.fs,
submodules: n.submodules, submodules: n.submodules,
idx: n.idx,
idxMap: n.idxMap,
path: path, path: path,
isDir: file.IsDir(), isDir: file.IsDir(),
size: file.Size(), size: file.Size(),
mode: file.Mode(), mode: file.Mode(),
modTime: file.ModTime(),
} }
if _, isSubmodule := n.submodules[path]; isSubmodule { if _, isSubmodule := n.submodules[path]; isSubmodule {
@@ -161,6 +211,16 @@ func (n *node) calculateHash() {
n.hash = append(submoduleHash[:], filemode.Submodule.Bytes()...) n.hash = append(submoduleHash[:], filemode.Submodule.Bytes()...)
return return
} }
if n.idxMap != nil {
if entry, ok := n.idxMap[n.path]; ok {
if n.metadataMatches(entry) {
n.hash = append(entry.Hash[:], mode.Bytes()...)
return
}
}
}
var hash plumbing.Hash var hash plumbing.Hash
if n.mode&os.ModeSymlink != 0 { if n.mode&os.ModeSymlink != 0 {
hash = n.doCalculateHashForSymlink() hash = n.doCalculateHashForSymlink()
@@ -170,6 +230,44 @@ func (n *node) calculateHash() {
n.hash = append(hash[:], mode.Bytes()...) n.hash = append(hash[:], mode.Bytes()...)
} }
func (n *node) metadataMatches(entry *index.Entry) bool {
if entry == nil {
return false
}
if uint32(n.size) != entry.Size {
return false
}
if !n.modTime.IsZero() && !n.modTime.Equal(entry.ModifiedAt) {
return false
}
mode, err := filemode.NewFromOSFileMode(n.mode)
if err != nil {
return false
}
if mode != entry.Mode {
return false
}
if n.idx != nil && !n.idx.ModTime.IsZero() && !n.modTime.IsZero() {
if !n.modTime.Before(n.idx.ModTime) {
return false
}
}
// If we couldn't perform the racy git check (idx is nil or idx.ModTime is zero),
// we cannot safely rely on metadata alone — force content hashing.
// This can occur with in-memory storage where the index file timestamp is unavailable.
if n.idx == nil || n.idx.ModTime.IsZero() {
return false
}
return true
}
func (n *node) doCalculateHashForRegular() plumbing.Hash { func (n *node) doCalculateHashForRegular() plumbing.Hash {
f, err := n.fs.Open(n.path) f, err := n.fs.Open(n.path)
if err != nil { if err != nil {
+26 -122
View File
@@ -7,7 +7,6 @@ import (
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
"runtime"
"strings" "strings"
"github.com/go-git/go-billy/v5" "github.com/go-git/go-billy/v5"
@@ -385,7 +384,8 @@ func (w *Worktree) resetIndex(t *object.Tree, dirs []string, files []string) ([]
return nil, err return nil, err
} }
var removedFiles []string removedFiles := make([]string, 0, len(changes))
filesMap := buildFilePathMap(files)
for _, ch := range changes { for _, ch := range changes {
a, err := ch.Action() a, err := ch.Action()
if err != nil { if err != nil {
@@ -407,7 +407,7 @@ func (w *Worktree) resetIndex(t *object.Tree, dirs []string, files []string) ([]
} }
if len(files) > 0 { if len(files) > 0 {
contains := inFiles(files, name) contains := inFiles(filesMap, name)
if !contains { if !contains {
continue continue
} }
@@ -436,15 +436,11 @@ func (w *Worktree) resetIndex(t *object.Tree, dirs []string, files []string) ([]
return removedFiles, w.r.Storer.SetIndex(idx) return removedFiles, w.r.Storer.SetIndex(idx)
} }
func inFiles(files []string, v string) bool { // inFiles checks if the given file is in the list of files. The incoming filepaths in files should be cleaned before calling this function.
func inFiles(files map[string]struct{}, v string) bool {
v = filepath.Clean(v) v = filepath.Clean(v)
for _, s := range files { _, exists := files[v]
if filepath.Clean(s) == v { return exists
return true
}
}
return false
} }
func (w *Worktree) resetWorktree(t *object.Tree, files []string) error { func (w *Worktree) resetWorktree(t *object.Tree, files []string) error {
@@ -459,11 +455,8 @@ func (w *Worktree) resetWorktree(t *object.Tree, files []string) error {
} }
b := newIndexBuilder(idx) b := newIndexBuilder(idx)
filesMap := buildFilePathMap(files)
for _, ch := range changes { for _, ch := range changes {
if err := w.validChange(ch); err != nil {
return err
}
if len(files) > 0 { if len(files) > 0 {
file := "" file := ""
if ch.From != nil { if ch.From != nil {
@@ -476,7 +469,7 @@ func (w *Worktree) resetWorktree(t *object.Tree, files []string) error {
continue continue
} }
contains := inFiles(files, file) contains := inFiles(filesMap, file)
if !contains { if !contains {
continue continue
} }
@@ -491,108 +484,6 @@ func (w *Worktree) resetWorktree(t *object.Tree, files []string) error {
return w.r.Storer.SetIndex(idx) 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 { func (w *Worktree) checkoutChange(ch merkletrie.Change, t *object.Tree, idx *indexBuilder) error {
a, err := ch.Action() a, err := ch.Action()
if err != nil { if err != nil {
@@ -765,10 +656,10 @@ func (w *Worktree) checkoutFile(f *object.File) (err error) {
} }
func (w *Worktree) checkoutFileSymlink(f *object.File) (err error) { func (w *Worktree) checkoutFileSymlink(f *object.File) (err error) {
// https://github.com/git/git/commit/10ecfa76491e4923988337b2e2243b05376b40de // .gitmodules symlink rejection (and its NTFS / HFS variants) is
if strings.EqualFold(f.Name, gitmodulesFile) { // enforced by the worktreeFilesystem wrapper's Symlink method via
return ErrGitModulesSymlink // validSymlinkName. See https://github.com/git/git/commit/10ecfa7
} // for the upstream rationale.
from, err := f.Reader() from, err := f.Reader()
if err != nil { if err != nil {
@@ -1206,3 +1097,16 @@ func (b *indexBuilder) Add(e *index.Entry) {
func (b *indexBuilder) Remove(name string) { func (b *indexBuilder) Remove(name string) {
delete(b.entries, filepath.ToSlash(name)) delete(b.entries, filepath.ToSlash(name))
} }
// buildFilePathMap creates a map of cleaned file paths for efficient lookup.
// Returns nil if the input slice is empty.
func buildFilePathMap(files []string) map[string]struct{} {
if len(files) == 0 {
return nil
}
filesMap := make(map[string]struct{}, len(files))
for _, f := range files {
filesMap[filepath.Clean(f)] = struct{}{}
}
return filesMap
}
+264
View File
@@ -0,0 +1,264 @@
package git
import (
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"github.com/go-git/go-billy/v5"
"github.com/go-git/go-git/v5/internal/pathutil"
)
// defaultProtectHFS returns the default value for core.protectHFS
// when not explicitly configured. Matches upstream Git's
// PROTECT_HFS_DEFAULT[1], which the Makefile sets to 1 on Darwin
// and leaves at 0 on every other platform.
//
// [1]: https://github.com/git/git/blob/v2.54.0/config.mak.uname#L146
func defaultProtectHFS() bool {
return runtime.GOOS == "darwin"
}
// defaultProtectNTFS returns the default value for core.protectNTFS
// when not explicitly configured. Matches upstream Git's
// PROTECT_NTFS_DEFAULT, which has been 1 on every platform since
// 9102f958ee5 (CVE-2019-1353)[1]: WSL allows Linux processes to
// reach NTFS-mounted worktrees on Windows hosts, so the
// is_ntfs_dotgit guard cannot safely be gated on the runtime OS.
//
// [1]: https://github.com/git/git/commit/9102f958ee5
func defaultProtectNTFS() bool {
return true
}
// worktreeFilesystem wraps a billy.Filesystem and validates every path passed
// to a mutating operation. This prevents writing to, or deleting from,
// dangerous locations (e.g. .git/*, ../) regardless of which worktree
// code path triggers the operation.
type worktreeFilesystem struct {
billy.Filesystem
protectNTFS bool
protectHFS bool
}
func newWorktreeFilesystem(fs billy.Filesystem, protectNTFS, protectHFS bool) *worktreeFilesystem {
return &worktreeFilesystem{Filesystem: fs, protectNTFS: protectNTFS, protectHFS: protectHFS}
}
func (sfs *worktreeFilesystem) Create(filename string) (billy.File, error) {
if err := sfs.validPath(filename); err != nil {
return nil, fmt.Errorf("create: %w", err)
}
return sfs.Filesystem.Create(filename)
}
func (sfs *worktreeFilesystem) Open(filename string) (billy.File, error) {
if err := sfs.validReadPath(filename); err != nil {
return nil, fmt.Errorf("open: %w", err)
}
return sfs.Filesystem.Open(filename)
}
func (sfs *worktreeFilesystem) OpenFile(filename string, flag int, perm os.FileMode) (billy.File, error) {
if err := sfs.validPath(filename); err != nil {
return nil, fmt.Errorf("openfile: %w", err)
}
return sfs.Filesystem.OpenFile(filename, flag, perm)
}
func (sfs *worktreeFilesystem) Stat(filename string) (os.FileInfo, error) {
if err := sfs.validReadPath(filename); err != nil {
return nil, fmt.Errorf("stat: %w", err)
}
return sfs.Filesystem.Stat(filename)
}
func (sfs *worktreeFilesystem) Remove(filename string) error {
if err := sfs.validPath(filename); err != nil {
return fmt.Errorf("remove: %w", err)
}
return sfs.Filesystem.Remove(filename)
}
func (sfs *worktreeFilesystem) Rename(from, to string) error {
if err := sfs.validPath(from, to); err != nil {
return fmt.Errorf("rename: %w", err)
}
return sfs.Filesystem.Rename(from, to)
}
func (sfs *worktreeFilesystem) ReadDir(path string) ([]os.FileInfo, error) {
if err := sfs.validReadPath(path); err != nil {
return nil, fmt.Errorf("readdir: %w", err)
}
return sfs.Filesystem.ReadDir(path)
}
func (sfs *worktreeFilesystem) Lstat(filename string) (os.FileInfo, error) {
if err := sfs.validReadPath(filename); err != nil {
return nil, fmt.Errorf("lstat: %w", err)
}
return sfs.Filesystem.Lstat(filename)
}
func (sfs *worktreeFilesystem) Symlink(target, link string) error {
if err := sfs.validPath(link); err != nil {
return fmt.Errorf("symlink: %w", err)
}
if err := sfs.validSymlinkName(link); err != nil {
return fmt.Errorf("symlink: %w", err)
}
return sfs.Filesystem.Symlink(target, link)
}
func (sfs *worktreeFilesystem) Readlink(link string) (string, error) {
if err := sfs.validReadPath(link); err != nil {
return "", fmt.Errorf("readlink: %w", err)
}
return sfs.Filesystem.Readlink(link)
}
func (sfs *worktreeFilesystem) MkdirAll(path string, perm os.FileMode) error {
// MkdirAll on the worktree root is a no-op: the root always exists,
// so there is nothing to materialise. Mirroring the tolerance that
// validReadPath gives to read-side operations avoids breaking callers
// that walk a directory tree and pass the relative-to-root prefix
// ("") through to the worktree FS.
if path == "" || path == "." || path == "/" {
return nil
}
if err := sfs.validPath(path); err != nil {
return fmt.Errorf("mkdirall: %w", err)
}
return sfs.Filesystem.MkdirAll(path, perm)
}
func (sfs *worktreeFilesystem) TempFile(_, _ string) (billy.File, error) {
return nil, fmt.Errorf("tempfile: %w", errUnsupportedOperation)
}
func (sfs *worktreeFilesystem) Chroot(path string) (billy.Filesystem, error) {
if err := sfs.validReadPath(path); err != nil {
return nil, fmt.Errorf("chroot: %w", err)
}
return sfs.Filesystem.Chroot(path)
}
// validReadPath is like validPath but treats the empty string and "." as
// valid references to the worktree root. Read-side operations on the root
// (e.g. ReadDir(""), Lstat(".")) are legitimate; mutating the root itself
// is not, so write-side operations continue to use validPath directly.
func (sfs *worktreeFilesystem) validReadPath(p string) error {
if p == "" || p == "." || p == "/" {
return nil
}
return sfs.validPath(p)
}
var errUnsupportedOperation = errors.New("unsupported operation")
// isDotGitVariant reports whether part is .git, git~1, or an HFS+
// equivalent of .git (when protectHFS is true). NTFS variants of .git
// (e.g. ".git " with trailing space, ".git::$INDEX_ALLOCATION") are
// detected separately by pathutil.WindowsValidPath, which applies
// regardless of position in the path. Both validators reuse this
// helper.
func isDotGitVariant(part string, protectHFS bool) bool {
if pathutil.IsDotGitName(part) {
return true
}
if protectHFS && pathutil.IsHFSDotGit(part) {
return true
}
return false
}
// validPath checks whether paths are valid for the worktree
// filesystem abstraction. It is intentionally tolerant of .git as
// the final path component of a multi-component path
// (e.g. "submodule/.git"), so that legitimate gitlink pointer files
// can still be Stat'd, Read, and Removed via the wrapper during
// submodule cleanup. Attacker-controlled tree-entry paths are
// validated separately by pathutil.ValidTreePath at the boundaries
// where data leaves the trusted store (Tree.FindEntry, the explicit
// callers in CherryPick and Submodule.Repository).
//
// For upstream rules:
// https://github.com/git/git/blob/v2.54.0/read-cache.c#L987
// https://github.com/git/git/blob/v2.54.0/path.c#L1419
func (sfs *worktreeFilesystem) validPath(paths ...string) error {
for _, p := range paths {
for i := 0; i < len(p); i++ {
if p[i] < 0x20 || p[i] == 0x7f {
return fmt.Errorf("invalid path %q: contains control character", p)
}
}
parts := strings.FieldsFunc(p, func(r rune) bool { return (r == '\\' || r == '/') })
if len(parts) == 0 {
return fmt.Errorf("invalid path: %q", p)
}
if sfs.protectNTFS {
// Volume names are not supported, in both formats: \\ and <DRIVE_LETTER>:.
if vol := filepath.VolumeName(p); vol != "" {
return fmt.Errorf("invalid path: %q", p)
}
}
for i, part := range parts {
if part == "." || part == ".." {
return fmt.Errorf("invalid path %q: cannot use %q", p, part)
}
// Reject .git (and equivalents) as a path component when it is
// either the first component (root-level .git) or a non-final
// component (traversal into a .git directory, e.g. "a/.git/config").
// A final non-first .git component (e.g. "submodule/.git") is
// allowed because submodule worktrees contain a .git pointer file.
if isDotGitVariant(part, sfs.protectHFS) && (i == 0 || i < len(parts)-1) {
return fmt.Errorf("invalid path component: %q", p)
}
if sfs.protectNTFS && !pathutil.WindowsValidPath(part) {
return fmt.Errorf("invalid path: %q", p)
}
}
}
return nil
}
// validSymlinkName checks the per-component name of a symlink for
// dotfile names that attackers can use to trick a checkout into
// writing a dangerous symlink. Each path component is compared
// against .gitmodules case-insensitively, against its NTFS variants
// (e.g. ".gitmodules .", ".gitmodules::$INDEX_ALLOCATION", or 8.3
// short-name forms) when protectNTFS is on, and against its HFS+
// variants (Unicode ignored code points folded into ".gitmodules")
// when protectHFS is on.
//
// Reference: upstream Git verify_path_internal at read-cache.c#L1004-L1024
// in tag v2.54.0[1].
//
// [1]: https://github.com/git/git/blob/v2.54.0/read-cache.c#L1004-L1024
func (sfs *worktreeFilesystem) validSymlinkName(name string) error {
parts := strings.FieldsFunc(name, func(r rune) bool {
return r == '/' || r == '\\'
})
for _, part := range parts {
if strings.EqualFold(part, gitmodulesFile) {
return ErrGitModulesSymlink
}
if sfs.protectNTFS && pathutil.IsNTFSDotGitmodules(part) {
return ErrGitModulesSymlink
}
if sfs.protectHFS && pathutil.IsHFSDotGitmodules(part) {
return ErrGitModulesSymlink
}
}
return nil
}
+10 -1
View File
@@ -10,6 +10,7 @@ import (
"strings" "strings"
"github.com/go-git/go-billy/v5/util" "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"
"github.com/go-git/go-git/v5/plumbing/filemode" "github.com/go-git/go-git/v5/plumbing/filemode"
"github.com/go-git/go-git/v5/plumbing/format/gitignore" "github.com/go-git/go-git/v5/plumbing/format/gitignore"
@@ -141,7 +142,7 @@ func (w *Worktree) diffStagingWithWorktree(reverse, excludeIgnoredChanges bool)
return nil, err return nil, err
} }
to := filesystem.NewRootNode(w.Filesystem, submodules) to := filesystem.NewRootNodeWithOptions(w.Filesystem, submodules, filesystem.Options{Index: idx})
var c merkletrie.Changes var c merkletrie.Changes
if reverse { if reverse {
@@ -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 { 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) return w.doUpdateFileToIndex(idx.Add(filename), filename, h)
} }
+1 -1
View File
@@ -1,4 +1,4 @@
FROM golang:1.24@sha256:14fd8a55e59a560704e5fc44970b301d00d344e45d6b914dda228e09f359a088 FROM golang:1.25@sha256:31c1e53dfc1cc2d269deec9c83f58729fa3c53dc9a576f6426109d1e319e9e9a
ENV GOOS=linux ENV GOOS=linux
ENV GOARCH=arm ENV GOARCH=arm
+1 -1
View File
@@ -1,4 +1,4 @@
FROM golang:1.24@sha256:14fd8a55e59a560704e5fc44970b301d00d344e45d6b914dda228e09f359a088 FROM golang:1.25@sha256:31c1e53dfc1cc2d269deec9c83f58729fa3c53dc9a576f6426109d1e319e9e9a
ENV GOOS=linux ENV GOOS=linux
ENV GOARCH=arm64 ENV GOARCH=arm64
-5
View File
@@ -12,7 +12,6 @@ package sha1cd
// Original: https://github.com/golang/go/blob/master/src/crypto/sha1/sha1.go // Original: https://github.com/golang/go/blob/master/src/crypto/sha1/sha1.go
import ( import (
"crypto"
"encoding/binary" "encoding/binary"
"errors" "errors"
"hash" "hash"
@@ -20,10 +19,6 @@ import (
shared "github.com/pjbgf/sha1cd/internal" shared "github.com/pjbgf/sha1cd/internal"
) )
func init() {
crypto.RegisterHash(crypto.SHA1, New)
}
// The size of a SHA-1 checksum in bytes. // The size of a SHA-1 checksum in bytes.
const Size = shared.Size const Size = shared.Size
+2 -2
View File
@@ -37,9 +37,9 @@ func block(dig *digest, p []byte) {
chunk := p[:shared.Chunk] chunk := p[:shared.Chunk]
blockAMD64(dig.h[:], chunk, m1[:], cs[:]) 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 { if col {
dig.col = true dig.col = true
+4 -4
View File
@@ -11,11 +11,11 @@
// Reference implementations: // Reference implementations:
// - https://github.com/golang/go/blob/master/src/crypto/sha1/sha1block_amd64.s // - 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) \ #define LOADCS(abcd, e, index, target) \
VPEXTRD $3, abcd, ((index*20)+0)(target); \ VPSHUFD $0x1B, abcd, X8; \
VPEXTRD $2, abcd, ((index*20)+4)(target); \ VMOVDQU X8, ((index*20)+0)(target); \
VPEXTRD $1, abcd, ((index*20)+8)(target); \
VPEXTRD $0, abcd, ((index*20)+12)(target); \
MOVL e, ((index*20)+16)(target); MOVL e, ((index*20)+16)(target);
#define LOADM1(m1, index, target) \ #define LOADM1(m1, index, target) \
+2 -2
View File
@@ -34,8 +34,8 @@ func block(dig *digest, p []byte) {
blockARM64(dig.h[:], chunk, m1[:], cs[:]) blockARM64(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 { if col {
dig.col = true dig.col = true
+13 -12
View File
@@ -127,7 +127,8 @@ func blockGeneric(dig *digest, p []byte) {
} }
if hi == 1 { 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 { if col {
dig.col = true dig.col = true
hi++ hi++
@@ -143,23 +144,23 @@ func blockGeneric(dig *digest, p []byte) {
//go:noinline //go:noinline
func checkCollision( func checkCollision(
m1 [shared.Rounds]uint32, m1 *[shared.Rounds]uint32,
cs [shared.PreStepState][shared.WordBuffers]uint32, cs *[shared.PreStepState][shared.WordBuffers]uint32,
h [shared.WordBuffers]uint32, h *[shared.WordBuffers]uint32,
) bool { ) bool {
if mask := ubc.CalculateDvMask(m1); mask != 0 { if mask := ubc.CalculateDvMask(m1); mask != 0 {
dvs := ubc.SHA1_dvs() dvs := ubc.SHA1_dvs()
for i := 0; dvs[i].DvType != 0; i++ { for i := 0; dvs[i].DvType != 0; i++ {
if (mask & ((uint32)(1) << uint32(dvs[i].MaskB))) != 0 { if (mask & ((uint32)(1) << uint32(dvs[i].MaskB))) != 0 {
var csState [shared.WordBuffers]uint32 var csState *[shared.WordBuffers]uint32
switch dvs[i].TestT { switch dvs[i].TestT {
case 58: case 58:
csState = cs[1] csState = &cs[1]
case 65: case 65:
csState = cs[2] csState = &cs[2]
case 0: case 0:
csState = cs[0] csState = &cs[0]
default: default:
panic(fmt.Sprintf("dvs data is trying to use a testT that isn't available: %d", dvs[i].TestT)) 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 dvs[i].TestT, // testT is the step number
// m2 is a secondary message created XORing with // m2 is a secondary message created XORing with
// ubc's DM prior to the SHA recompression step. // ubc's DM prior to the SHA recompression step.
m1, dvs[i].Dm, m1, &dvs[i].Dm,
csState, csState,
h) h)
@@ -182,8 +183,8 @@ func checkCollision(
} }
//go:nosplit //go:nosplit
func hasCollided(step uint32, m1, dm [shared.Rounds]uint32, func hasCollided(step uint32, m1, dm *[shared.Rounds]uint32,
state [shared.WordBuffers]uint32, h [shared.WordBuffers]uint32) bool { state *[shared.WordBuffers]uint32, h *[shared.WordBuffers]uint32) bool {
// Intermediary Hash Value. // Intermediary Hash Value.
ihv := [shared.WordBuffers]uint32{} ihv := [shared.WordBuffers]uint32{}
@@ -282,7 +283,7 @@ func hasCollided(step uint32, m1, dm [shared.Rounds]uint32,
// //
//go:nosplit //go:nosplit
func rectifyCompressionState( func rectifyCompressionState(
m1 [shared.Rounds]uint32, m1 *[shared.Rounds]uint32,
cs *[shared.PreStepState][shared.WordBuffers]uint32, cs *[shared.PreStepState][shared.WordBuffers]uint32,
) { ) {
if cs == nil { if cs == nil {
+4 -1
View File
@@ -29,7 +29,10 @@ type DvInfo struct {
// bitconditions for that DV have been met. // bitconditions for that DV have been met.
// //
//go:nosplit //go:nosplit
func CalculateDvMask(W [80]uint32) uint32 { func CalculateDvMask(W *[80]uint32) uint32 {
if W == nil {
return 0
}
mask := uint32(0xFFFFFFFF) 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[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)) 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))
+1 -1
View File
@@ -36,7 +36,7 @@ func (s *server) processRequestBytes(reqData []byte) []byte {
return []byte{agentFailure} return []byte{agentFailure}
} }
if err == nil && rep == nil { if rep == nil {
return []byte{agentSuccess} return []byte{agentSuccess}
} }
+1 -1
View File
@@ -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). // Length of encrypted portion of the packet (header, payload, padding).
// Enforce minimum padding and packet size. // Enforce minimum padding and packet size.
encLength := maxUInt32(prefixLen+len(packet)+cbcMinPaddingSize, cbcMinPaddingSize) encLength := maxUInt32(prefixLen+len(packet)+cbcMinPaddingSize, cbcMinPacketSize)
// Enforce block size. // Enforce block size.
encLength = (encLength + effectiveBlockSize - 1) / effectiveBlockSize * effectiveBlockSize encLength = (encLength + effectiveBlockSize - 1) / effectiveBlockSize * effectiveBlockSize
+7 -3
View File
@@ -274,10 +274,14 @@ func pickSignatureAlgorithm(signer Signer, extensions map[string][]byte) (MultiA
} }
// Filter algorithms based on those supported by MultiAlgorithmSigner. // 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 var keyAlgos []string
for _, algo := range algorithmsForKeyFormat(keyFormat) { for _, signerAlgo := range as.Algorithms() {
if slices.Contains(as.Algorithms(), underlyingAlgo(algo)) { if idx := slices.IndexFunc(supportedKeyAlgos, func(algo string) bool {
keyAlgos = append(keyAlgos, algo) return underlyingAlgo(algo) == signerAlgo
}); idx >= 0 {
keyAlgos = append(keyAlgos, supportedKeyAlgos[idx])
} }
} }
+12
View File
@@ -0,0 +1,12 @@
// Copyright 2024 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 darwin && arm64 && gc
#include "textflag.h"
TEXT libc_sysctlbyname_trampoline<>(SB),NOSPLIT,$0-0
JMP libc_sysctlbyname(SB)
GLOBL ·libc_sysctlbyname_trampoline_addr(SB), RODATA, $8
DATA ·libc_sysctlbyname_trampoline_addr(SB)/8, $libc_sysctlbyname_trampoline<>(SB)
+3 -6
View File
@@ -44,14 +44,11 @@ func initOptions() {
} }
func archInit() { func archInit() {
switch runtime.GOOS { if runtime.GOOS == "freebsd" {
case "freebsd":
readARM64Registers() readARM64Registers()
case "linux", "netbsd", "openbsd", "windows": } else {
// Most platforms don't seem to allow directly reading these registers.
doinit() doinit()
default:
// Many platforms don't seem to allow reading these registers.
setMinimalFeatures()
} }
} }
+67
View File
@@ -0,0 +1,67 @@
// 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 darwin && arm64 && gc
package cpu
func doinit() {
setMinimalFeatures()
// The feature flags are explained in [Instruction Set Detection].
// There are some differences between MacOS versions:
//
// MacOS 11 and 12 do not have "hw.optional" sysctl values for some of the features.
//
// MacOS 13 changed some of the naming conventions to align with ARM Architecture Reference Manual.
// For example "hw.optional.armv8_2_sha512" became "hw.optional.arm.FEAT_SHA512".
// It currently checks both to stay compatible with MacOS 11 and 12.
// The old names also work with MacOS 13, however it's not clear whether
// they will continue working with future OS releases.
//
// Once MacOS 12 is no longer supported the old names can be removed.
//
// [Instruction Set Detection]: https://developer.apple.com/documentation/kernel/1387446-sysctlbyname/determining_instruction_set_characteristics
// Encryption, hashing and checksum capabilities
// For the following flags there are no MacOS 11 sysctl flags.
ARM64.HasAES = true || darwinSysctlEnabled([]byte("hw.optional.arm.FEAT_AES\x00"))
ARM64.HasPMULL = true || darwinSysctlEnabled([]byte("hw.optional.arm.FEAT_PMULL\x00"))
ARM64.HasSHA1 = true || darwinSysctlEnabled([]byte("hw.optional.arm.FEAT_SHA1\x00"))
ARM64.HasSHA2 = true || darwinSysctlEnabled([]byte("hw.optional.arm.FEAT_SHA256\x00"))
ARM64.HasSHA3 = darwinSysctlEnabled([]byte("hw.optional.armv8_2_sha3\x00")) || darwinSysctlEnabled([]byte("hw.optional.arm.FEAT_SHA3\x00"))
ARM64.HasSHA512 = darwinSysctlEnabled([]byte("hw.optional.armv8_2_sha512\x00")) || darwinSysctlEnabled([]byte("hw.optional.arm.FEAT_SHA512\x00"))
ARM64.HasCRC32 = darwinSysctlEnabled([]byte("hw.optional.armv8_crc32\x00"))
// Atomic and memory ordering
ARM64.HasATOMICS = darwinSysctlEnabled([]byte("hw.optional.armv8_1_atomics\x00")) || darwinSysctlEnabled([]byte("hw.optional.arm.FEAT_LSE\x00"))
ARM64.HasLRCPC = darwinSysctlEnabled([]byte("hw.optional.arm.FEAT_LRCPC\x00"))
// SIMD and floating point capabilities
ARM64.HasFPHP = darwinSysctlEnabled([]byte("hw.optional.neon_fp16\x00")) || darwinSysctlEnabled([]byte("hw.optional.arm.FEAT_FP16\x00"))
ARM64.HasASIMDHP = darwinSysctlEnabled([]byte("hw.optional.neon_hpfp\x00")) || darwinSysctlEnabled([]byte("hw.optional.AdvSIMD_HPFPCvt\x00"))
ARM64.HasASIMDRDM = darwinSysctlEnabled([]byte("hw.optional.arm.FEAT_RDM\x00"))
ARM64.HasASIMDDP = darwinSysctlEnabled([]byte("hw.optional.arm.FEAT_DotProd\x00"))
ARM64.HasASIMDFHM = darwinSysctlEnabled([]byte("hw.optional.armv8_2_fhm\x00")) || darwinSysctlEnabled([]byte("hw.optional.arm.FEAT_FHM\x00"))
ARM64.HasI8MM = darwinSysctlEnabled([]byte("hw.optional.arm.FEAT_I8MM\x00"))
ARM64.HasJSCVT = darwinSysctlEnabled([]byte("hw.optional.arm.FEAT_JSCVT\x00"))
ARM64.HasFCMA = darwinSysctlEnabled([]byte("hw.optional.armv8_3_compnum\x00")) || darwinSysctlEnabled([]byte("hw.optional.arm.FEAT_FCMA\x00"))
// Miscellaneous
ARM64.HasDCPOP = darwinSysctlEnabled([]byte("hw.optional.arm.FEAT_DPB\x00"))
ARM64.HasEVTSTRM = darwinSysctlEnabled([]byte("hw.optional.arm.FEAT_ECV\x00"))
ARM64.HasDIT = darwinSysctlEnabled([]byte("hw.optional.arm.FEAT_DIT\x00"))
// Not supported, but added for completeness
ARM64.HasCPUID = false
ARM64.HasSM3 = false // darwinSysctlEnabled([]byte("hw.optional.arm.FEAT_SM3\x00"))
ARM64.HasSM4 = false // darwinSysctlEnabled([]byte("hw.optional.arm.FEAT_SM4\x00"))
ARM64.HasSVE = false // darwinSysctlEnabled([]byte("hw.optional.arm.FEAT_SVE\x00"))
ARM64.HasSVE2 = false // darwinSysctlEnabled([]byte("hw.optional.arm.FEAT_SVE2\x00"))
}
+31
View File
@@ -0,0 +1,31 @@
// 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 darwin && arm64 && !gc
package cpu
import "runtime"
func doinit() {
setMinimalFeatures()
ARM64.HasASIMD = true
ARM64.HasFP = true
// Go already assumes these to be available because they were on the M1
// and these are supported on all Apple arm64 chips.
ARM64.HasAES = true
ARM64.HasPMULL = true
ARM64.HasSHA1 = true
ARM64.HasSHA2 = true
if runtime.GOOS != "ios" {
// Apple A7 processors do not support these, however
// M-series SoCs are at least armv8.4-a
ARM64.HasCRC32 = true // armv8.1
ARM64.HasATOMICS = true // armv8.2
ARM64.HasJSCVT = true // armv8.3, if HasFP
}
}
+1
View File
@@ -9,3 +9,4 @@ package cpu
func getisar0() uint64 { return 0 } func getisar0() uint64 { return 0 }
func getisar1() uint64 { return 0 } func getisar1() uint64 { return 0 }
func getpfr0() uint64 { return 0 } func getpfr0() uint64 { return 0 }
func getzfr0() uint64 { return 0 }
+4 -2
View File
@@ -2,8 +2,10 @@
// Use of this source code is governed by a BSD-style // Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file. // license that can be found in the LICENSE file.
//go:build !linux && !netbsd && !openbsd && !windows && arm64 //go:build !darwin && !linux && !netbsd && !openbsd && arm64
package cpu package cpu
func doinit() {} func doinit() {
setMinimalFeatures()
}
-42
View File
@@ -1,42 +0,0 @@
// 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 cpu
import (
"golang.org/x/sys/windows"
)
func doinit() {
// set HasASIMD and HasFP to true as per
// https://learn.microsoft.com/en-us/cpp/build/arm64-windows-abi-conventions?view=msvc-170#base-requirements
//
// The ARM64 version of Windows always presupposes that it's running on an ARMv8 or later architecture.
// Both floating-point and NEON support are presumed to be present in hardware.
//
ARM64.HasASIMD = true
ARM64.HasFP = true
if windows.IsProcessorFeaturePresent(windows.PF_ARM_V8_CRYPTO_INSTRUCTIONS_AVAILABLE) {
ARM64.HasAES = true
ARM64.HasPMULL = true
ARM64.HasSHA1 = true
ARM64.HasSHA2 = true
}
ARM64.HasSHA3 = windows.IsProcessorFeaturePresent(windows.PF_ARM_SHA3_INSTRUCTIONS_AVAILABLE)
ARM64.HasCRC32 = windows.IsProcessorFeaturePresent(windows.PF_ARM_V8_CRC32_INSTRUCTIONS_AVAILABLE)
ARM64.HasSHA512 = windows.IsProcessorFeaturePresent(windows.PF_ARM_SHA512_INSTRUCTIONS_AVAILABLE)
ARM64.HasATOMICS = windows.IsProcessorFeaturePresent(windows.PF_ARM_V81_ATOMIC_INSTRUCTIONS_AVAILABLE)
if windows.IsProcessorFeaturePresent(windows.PF_ARM_V82_DP_INSTRUCTIONS_AVAILABLE) {
ARM64.HasASIMDDP = true
ARM64.HasASIMDRDM = true
}
if windows.IsProcessorFeaturePresent(windows.PF_ARM_V83_LRCPC_INSTRUCTIONS_AVAILABLE) {
ARM64.HasLRCPC = true
ARM64.HasSM3 = true
}
ARM64.HasSVE = windows.IsProcessorFeaturePresent(windows.PF_ARM_SVE_INSTRUCTIONS_AVAILABLE)
ARM64.HasSVE2 = windows.IsProcessorFeaturePresent(windows.PF_ARM_SVE2_INSTRUCTIONS_AVAILABLE)
ARM64.HasJSCVT = windows.IsProcessorFeaturePresent(windows.PF_ARM_V83_JSCVT_INSTRUCTIONS_AVAILABLE)
}
+54
View File
@@ -0,0 +1,54 @@
// Copyright 2024 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.
// Minimal copy from internal/cpu and runtime to make sysctl calls.
//go:build darwin && arm64 && gc
package cpu
import (
"syscall"
"unsafe"
)
type Errno = syscall.Errno
// adapted from internal/cpu/cpu_arm64_darwin.go
func darwinSysctlEnabled(name []byte) bool {
out := int32(0)
nout := unsafe.Sizeof(out)
if ret := sysctlbyname(&name[0], (*byte)(unsafe.Pointer(&out)), &nout, nil, 0); ret != nil {
return false
}
return out > 0
}
//go:cgo_import_dynamic libc_sysctl sysctl "/usr/lib/libSystem.B.dylib"
var libc_sysctlbyname_trampoline_addr uintptr
// adapted from runtime/sys_darwin.go in the pattern of sysctl() above, as defined in x/sys/unix
func sysctlbyname(name *byte, old *byte, oldlen *uintptr, new *byte, newlen uintptr) error {
if _, _, err := syscall_syscall6(
libc_sysctlbyname_trampoline_addr,
uintptr(unsafe.Pointer(name)),
uintptr(unsafe.Pointer(old)),
uintptr(unsafe.Pointer(oldlen)),
uintptr(unsafe.Pointer(new)),
uintptr(newlen),
0,
); err != 0 {
return err
}
return nil
}
//go:cgo_import_dynamic libc_sysctlbyname sysctlbyname "/usr/lib/libSystem.B.dylib"
// Implemented in the runtime package (runtime/sys_darwin.go)
func syscall_syscall6(fn, a1, a2, a3, a4, a5, a6 uintptr) (r1, r2 uintptr, err Errno)
//go:linkname syscall_syscall6 syscall.syscall6
+21
View File
@@ -684,12 +684,17 @@ const (
RTN_THROW = 0x9 RTN_THROW = 0x9
RTN_NAT = 0xa RTN_NAT = 0xa
RTN_XRESOLVE = 0xb RTN_XRESOLVE = 0xb
PREFIX_UNSPEC = 0x0
PREFIX_ADDRESS = 0x1
PREFIX_CACHEINFO = 0x2
SizeofNlMsghdr = 0x10 SizeofNlMsghdr = 0x10
SizeofNlMsgerr = 0x14 SizeofNlMsgerr = 0x14
SizeofRtGenmsg = 0x1 SizeofRtGenmsg = 0x1
SizeofNlAttr = 0x4 SizeofNlAttr = 0x4
SizeofRtAttr = 0x4 SizeofRtAttr = 0x4
SizeofIfInfomsg = 0x10 SizeofIfInfomsg = 0x10
SizeofPrefixmsg = 0xc
SizeofPrefixCacheinfo = 0x8
SizeofIfAddrmsg = 0x8 SizeofIfAddrmsg = 0x8
SizeofIfAddrlblmsg = 0xc SizeofIfAddrlblmsg = 0xc
SizeofIfaCacheinfo = 0x10 SizeofIfaCacheinfo = 0x10
@@ -735,6 +740,22 @@ type IfInfomsg struct {
Change uint32 Change uint32
} }
type Prefixmsg struct {
Family uint8
Pad1 uint8
Pad2 uint16
Ifindex int32
Type uint8
Len uint8
Flags uint8
Pad3 uint8
}
type PrefixCacheinfo struct {
Preferred_time uint32
Valid_time uint32
}
type IfAddrmsg struct { type IfAddrmsg struct {
Family uint8 Family uint8
Prefixlen uint8 Prefixlen uint8
+1
View File
@@ -8,5 +8,6 @@ package windows
import "syscall" import "syscall"
type Signal = syscall.Signal
type Errno = syscall.Errno type Errno = syscall.Errno
type SysProcAttr = syscall.SysProcAttr type SysProcAttr = syscall.SysProcAttr
+1 -36
View File
@@ -163,42 +163,7 @@ func (p *Proc) Addr() uintptr {
// (according to the semantics of the specific function being called) before consulting // (according to the semantics of the specific function being called) before consulting
// the error. The error will be guaranteed to contain windows.Errno. // the error. The error will be guaranteed to contain windows.Errno.
func (p *Proc) Call(a ...uintptr) (r1, r2 uintptr, lastErr error) { func (p *Proc) Call(a ...uintptr) (r1, r2 uintptr, lastErr error) {
switch len(a) { return syscall.SyscallN(p.Addr(), a...)
case 0:
return syscall.Syscall(p.Addr(), uintptr(len(a)), 0, 0, 0)
case 1:
return syscall.Syscall(p.Addr(), uintptr(len(a)), a[0], 0, 0)
case 2:
return syscall.Syscall(p.Addr(), uintptr(len(a)), a[0], a[1], 0)
case 3:
return syscall.Syscall(p.Addr(), uintptr(len(a)), a[0], a[1], a[2])
case 4:
return syscall.Syscall6(p.Addr(), uintptr(len(a)), a[0], a[1], a[2], a[3], 0, 0)
case 5:
return syscall.Syscall6(p.Addr(), uintptr(len(a)), a[0], a[1], a[2], a[3], a[4], 0)
case 6:
return syscall.Syscall6(p.Addr(), uintptr(len(a)), a[0], a[1], a[2], a[3], a[4], a[5])
case 7:
return syscall.Syscall9(p.Addr(), uintptr(len(a)), a[0], a[1], a[2], a[3], a[4], a[5], a[6], 0, 0)
case 8:
return syscall.Syscall9(p.Addr(), uintptr(len(a)), a[0], a[1], a[2], a[3], a[4], a[5], a[6], a[7], 0)
case 9:
return syscall.Syscall9(p.Addr(), uintptr(len(a)), a[0], a[1], a[2], a[3], a[4], a[5], a[6], a[7], a[8])
case 10:
return syscall.Syscall12(p.Addr(), uintptr(len(a)), a[0], a[1], a[2], a[3], a[4], a[5], a[6], a[7], a[8], a[9], 0, 0)
case 11:
return syscall.Syscall12(p.Addr(), uintptr(len(a)), a[0], a[1], a[2], a[3], a[4], a[5], a[6], a[7], a[8], a[9], a[10], 0)
case 12:
return syscall.Syscall12(p.Addr(), uintptr(len(a)), a[0], a[1], a[2], a[3], a[4], a[5], a[6], a[7], a[8], a[9], a[10], a[11])
case 13:
return syscall.Syscall15(p.Addr(), uintptr(len(a)), a[0], a[1], a[2], a[3], a[4], a[5], a[6], a[7], a[8], a[9], a[10], a[11], a[12], 0, 0)
case 14:
return syscall.Syscall15(p.Addr(), uintptr(len(a)), a[0], a[1], a[2], a[3], a[4], a[5], a[6], a[7], a[8], a[9], a[10], a[11], a[12], a[13], 0)
case 15:
return syscall.Syscall15(p.Addr(), uintptr(len(a)), a[0], a[1], a[2], a[3], a[4], a[5], a[6], a[7], a[8], a[9], a[10], a[11], a[12], a[13], a[14])
default:
panic("Call " + p.Name + " with too many arguments " + itoa(len(a)) + ".")
}
} }
// A LazyDLL implements access to a single DLL. // A LazyDLL implements access to a single DLL.
+5 -1
View File
@@ -1438,13 +1438,17 @@ func GetSecurityInfo(handle Handle, objectType SE_OBJECT_TYPE, securityInformati
} }
// GetNamedSecurityInfo queries the security information for a given named object and returns the self-relative security // GetNamedSecurityInfo queries the security information for a given named object and returns the self-relative security
// descriptor result on the Go heap. // descriptor result on the Go heap. The security descriptor might be nil, even when err is nil, if the object exists
// but has no security descriptor.
func GetNamedSecurityInfo(objectName string, objectType SE_OBJECT_TYPE, securityInformation SECURITY_INFORMATION) (sd *SECURITY_DESCRIPTOR, err error) { func GetNamedSecurityInfo(objectName string, objectType SE_OBJECT_TYPE, securityInformation SECURITY_INFORMATION) (sd *SECURITY_DESCRIPTOR, err error) {
var winHeapSD *SECURITY_DESCRIPTOR var winHeapSD *SECURITY_DESCRIPTOR
err = getNamedSecurityInfo(objectName, objectType, securityInformation, nil, nil, nil, nil, &winHeapSD) err = getNamedSecurityInfo(objectName, objectType, securityInformation, nil, nil, nil, nil, &winHeapSD)
if err != nil { if err != nil {
return return
} }
if winHeapSD == nil {
return nil, nil
}
defer LocalFree(Handle(unsafe.Pointer(winHeapSD))) defer LocalFree(Handle(unsafe.Pointer(winHeapSD)))
return winHeapSD.copySelfRelativeSecurityDescriptor(), nil return winHeapSD.copySelfRelativeSecurityDescriptor(), nil
} }
-14
View File
@@ -1490,20 +1490,6 @@ func Getgid() (gid int) { return -1 }
func Getegid() (egid int) { return -1 } func Getegid() (egid int) { return -1 }
func Getgroups() (gids []int, err error) { return nil, syscall.EWINDOWS } func Getgroups() (gids []int, err error) { return nil, syscall.EWINDOWS }
type Signal int
func (s Signal) Signal() {}
func (s Signal) String() string {
if 0 <= s && int(s) < len(signals) {
str := signals[s]
if str != "" {
return str
}
}
return "signal " + itoa(int(s))
}
func LoadCreateSymbolicLink() error { func LoadCreateSymbolicLink() error {
return procCreateSymbolicLinkW.Find() return procCreateSymbolicLinkW.Find()
} }
+12 -11
View File
@@ -80,19 +80,20 @@ github.com/go-git/gcfg
github.com/go-git/gcfg/scanner github.com/go-git/gcfg/scanner
github.com/go-git/gcfg/token github.com/go-git/gcfg/token
github.com/go-git/gcfg/types github.com/go-git/gcfg/types
# github.com/go-git/go-billy/v5 v5.7.0 # github.com/go-git/go-billy/v5 v5.9.0
## explicit; go 1.23.0 ## explicit; go 1.25.0
github.com/go-git/go-billy/v5 github.com/go-git/go-billy/v5
github.com/go-git/go-billy/v5/helper/chroot github.com/go-git/go-billy/v5/helper/chroot
github.com/go-git/go-billy/v5/helper/polyfill github.com/go-git/go-billy/v5/helper/polyfill
github.com/go-git/go-billy/v5/memfs github.com/go-git/go-billy/v5/memfs
github.com/go-git/go-billy/v5/osfs github.com/go-git/go-billy/v5/osfs
github.com/go-git/go-billy/v5/util github.com/go-git/go-billy/v5/util
# github.com/go-git/go-git/v5 v5.16.5 # github.com/go-git/go-git/v5 v5.19.1
## explicit; go 1.24.0 ## explicit; go 1.25.0
github.com/go-git/go-git/v5 github.com/go-git/go-git/v5
github.com/go-git/go-git/v5/config github.com/go-git/go-git/v5/config
github.com/go-git/go-git/v5/internal/path_util github.com/go-git/go-git/v5/internal/path_util
github.com/go-git/go-git/v5/internal/pathutil
github.com/go-git/go-git/v5/internal/revision github.com/go-git/go-git/v5/internal/revision
github.com/go-git/go-git/v5/internal/url 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
@@ -157,7 +158,7 @@ github.com/mattn/go-isatty
# github.com/mitchellh/go-homedir v1.1.0 # github.com/mitchellh/go-homedir v1.1.0
## explicit ## explicit
github.com/mitchellh/go-homedir github.com/mitchellh/go-homedir
# github.com/pjbgf/sha1cd v0.5.0 # github.com/pjbgf/sha1cd v0.6.0
## explicit; go 1.22 ## explicit; go 1.22
github.com/pjbgf/sha1cd github.com/pjbgf/sha1cd
github.com/pjbgf/sha1cd/internal github.com/pjbgf/sha1cd/internal
@@ -177,8 +178,8 @@ github.com/spf13/pflag
# github.com/xanzy/ssh-agent v0.3.3 # github.com/xanzy/ssh-agent v0.3.3
## explicit; go 1.16 ## explicit; go 1.16
github.com/xanzy/ssh-agent github.com/xanzy/ssh-agent
# golang.org/x/crypto v0.48.0 # golang.org/x/crypto v0.50.0
## explicit; go 1.24.0 ## explicit; go 1.25.0
golang.org/x/crypto/argon2 golang.org/x/crypto/argon2
golang.org/x/crypto/blake2b golang.org/x/crypto/blake2b
golang.org/x/crypto/blowfish golang.org/x/crypto/blowfish
@@ -195,13 +196,13 @@ golang.org/x/crypto/ssh
golang.org/x/crypto/ssh/agent golang.org/x/crypto/ssh/agent
golang.org/x/crypto/ssh/internal/bcrypt_pbkdf golang.org/x/crypto/ssh/internal/bcrypt_pbkdf
golang.org/x/crypto/ssh/knownhosts golang.org/x/crypto/ssh/knownhosts
# golang.org/x/net v0.50.0 # golang.org/x/net v0.53.0
## explicit; go 1.24.0 ## explicit; go 1.25.0
golang.org/x/net/context golang.org/x/net/context
golang.org/x/net/internal/socks golang.org/x/net/internal/socks
golang.org/x/net/proxy golang.org/x/net/proxy
# golang.org/x/sys v0.41.0 # golang.org/x/sys v0.43.0
## explicit; go 1.24.0 ## explicit; go 1.25.0
golang.org/x/sys/cpu golang.org/x/sys/cpu
golang.org/x/sys/execabs golang.org/x/sys/execabs
golang.org/x/sys/unix golang.org/x/sys/unix