diff --git a/go.mod b/go.mod index 1118e607b..afd6e1422 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/argoproj-labs/argocd-operator v0.17.0 github.com/bradleyfalzon/ghinstallation/v2 v2.18.0 github.com/go-errors/errors v1.5.1 - github.com/go-git/go-git/v5 v5.19.0 + github.com/go-git/go-git/v5 v5.19.1 github.com/go-logr/logr v1.4.3 github.com/google/uuid v1.6.1-0.20241114170450-2d3c2a9cc518 github.com/onsi/ginkgo/v2 v2.28.3 diff --git a/go.sum b/go.sum index 4076266bd..5c9d76e1b 100644 --- a/go.sum +++ b/go.sum @@ -152,8 +152,8 @@ github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmm github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= -github.com/go-git/go-git/v5 v5.19.0 h1:+WkVUQZSy/F1Gb13udrMKjIM2PrzsNfDKFSfo5tkMtc= -github.com/go-git/go-git/v5 v5.19.0/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ= +github.com/go-git/go-git/v5 v5.19.1 h1:nX27AnaU43/K5bKktKwgBmR9lawoYVe1Ckg0rgzzN00= +github.com/go-git/go-git/v5 v5.19.1/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= diff --git a/vendor/github.com/go-git/go-git/v5/config/config.go b/vendor/github.com/go-git/go-git/v5/config/config.go index 33f6e37d2..3ae6a571e 100644 --- a/vendor/github.com/go-git/go-git/v5/config/config.go +++ b/vendor/github.com/go-git/go-git/v5/config/config.go @@ -61,6 +61,16 @@ type Config struct { CommentChar string // RepositoryFormatVersion identifies the repository format and layout version. RepositoryFormatVersion format.RepositoryFormatVersion + // ProtectNTFS controls whether NTFS-specific path protections are + // applied (e.g. rejecting .git trailing spaces/periods, alternate + // data streams, 8.3 short names). When unset, defaults to true on + // Windows. + ProtectNTFS OptBool + // ProtectHFS controls whether HFS+-specific path protections are + // applied (e.g. rejecting .git with Unicode zero-width or + // directional characters that HFS+ would normalize away). + // When unset, defaults to true on macOS. + ProtectHFS OptBool } User struct { @@ -266,6 +276,8 @@ const ( repositoryFormatVersionKey = "repositoryformatversion" objectFormat = "objectformat" mirrorKey = "mirror" + protectNTFSKey = "protectNTFS" + protectHFSKey = "protectHFS" // DefaultPackWindow holds the number of previous objects used to // generate deltas. The value 10 is the same used by git command. @@ -309,6 +321,14 @@ func (c *Config) unmarshalCore() { c.Core.Worktree = s.Options.Get(worktreeKey) c.Core.CommentChar = s.Options.Get(commentCharKey) + + if parsed := parseConfigBool(s.Options.Get(protectNTFSKey)); parsed.IsSet() { + c.Core.ProtectNTFS = parsed + } + + if parsed := parseConfigBool(s.Options.Get(protectHFSKey)); parsed.IsSet() { + c.Core.ProtectHFS = parsed + } } func (c *Config) unmarshalUser() { @@ -379,7 +399,8 @@ func unmarshalSubmodules(fc *format.Config, submodules map[string]*Submodule) { m := &Submodule{} m.unmarshal(sub) - if m.Validate() == ErrModuleBadPath { + if err := m.Validate(); errors.Is(err, ErrModuleBadPath) || + errors.Is(err, ErrModuleBadName) { continue } @@ -436,6 +457,14 @@ func (c *Config) marshalCore() { if c.Core.Worktree != "" { s.SetOption(worktreeKey, c.Core.Worktree) } + + if c.Core.ProtectNTFS.IsSet() { + s.SetOption(protectNTFSKey, c.Core.ProtectNTFS.FormatBool()) + } + + if c.Core.ProtectHFS.IsSet() { + s.SetOption(protectHFSKey, c.Core.ProtectHFS.FormatBool()) + } } func (c *Config) marshalExtensions() { diff --git a/vendor/github.com/go-git/go-git/v5/config/modules.go b/vendor/github.com/go-git/go-git/v5/config/modules.go index 1c10aa354..5fdd83864 100644 --- a/vendor/github.com/go-git/go-git/v5/config/modules.go +++ b/vendor/github.com/go-git/go-git/v5/config/modules.go @@ -3,8 +3,11 @@ package config import ( "bytes" "errors" + "fmt" "regexp" + "strings" + "github.com/go-git/go-git/v5/internal/pathutil" format "github.com/go-git/go-git/v5/plumbing/format/config" ) @@ -12,6 +15,7 @@ var ( ErrModuleEmptyURL = errors.New("module config: empty URL") ErrModuleEmptyPath = errors.New("module config: empty path") ErrModuleBadPath = errors.New("submodule has an invalid path") + ErrModuleBadName = errors.New("ignoring suspicious submodule name") ) var ( @@ -94,6 +98,10 @@ type Submodule struct { // Validate validates the fields and sets the default values. func (m *Submodule) Validate() error { + if err := validSubmoduleName(m.Name); err != nil { + return fmt.Errorf("%w: %q", ErrModuleBadName, m.Name) + } + if m.Path == "" { return ErrModuleEmptyPath } @@ -109,6 +117,50 @@ func (m *Submodule) Validate() error { return nil } +// validSubmoduleName mirrors canonical Git's check_submodule_name in +// submodule-config.c [1]: reject empty names and any name with a ".." +// path component, using both '/' and '\\' as separators so the rule +// is consistent across platforms. The component check is delegated to +// `pathutil.IsHFSDot` and `pathutil.IsNTFSDot` with `.` as the needle, +// which both cover the bare ".." case and reject components that +// resolve to ".." after HFS+ Unicode normalisation (ignored code +// points, e.g. `..`) or NTFS trailing-space/dot/ADS +// canonicalisation (e.g. `.. `, `..::$INDEX_ALLOCATION`). +// `.gitmodules` is attacker-controlled by definition, so both checks +// run unconditionally regardless of host OS. +// +// The additional checks (bare ".", NUL byte, leading or trailing +// separator, drive-letter prefix) close go-git-specific edge cases +// the canonical loop does not exercise: canonical Git treats names +// as opaque C strings, while Go strings carry NULs through and the +// billy filesystem layer is path-aware in ways Git's working storage +// is not. +// +// [1]: https://github.com/git/git/blob/v2.54.0/submodule-config.c#L214-L237 +func validSubmoduleName(name string) error { + if name == "" || name == "." { + return ErrModuleBadName + } + for _, seg := range strings.FieldsFunc(name, isPathSep) { + if pathutil.IsHFSDot(seg, ".") || pathutil.IsNTFSDot(seg, ".", "") { + return ErrModuleBadName + } + } + // go-git-specific defensive checks beyond canonical Git. + if strings.ContainsRune(name, 0) { + return ErrModuleBadName + } + if isPathSep(rune(name[0])) || isPathSep(rune(name[len(name)-1])) { + return ErrModuleBadName + } + if len(name) >= 2 && name[1] == ':' { + return ErrModuleBadName + } + return nil +} + +func isPathSep(r rune) bool { return r == '/' || r == '\\' } + func (m *Submodule) unmarshal(s *format.Subsection) { m.raw = s diff --git a/vendor/github.com/go-git/go-git/v5/config/optbool.go b/vendor/github.com/go-git/go-git/v5/config/optbool.go new file mode 100644 index 000000000..cb89fbf42 --- /dev/null +++ b/vendor/github.com/go-git/go-git/v5/config/optbool.go @@ -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 +} diff --git a/vendor/github.com/go-git/go-git/v5/internal/pathutil/dotgit.go b/vendor/github.com/go-git/go-git/v5/internal/pathutil/dotgit.go new file mode 100644 index 000000000..e50ee9ce5 --- /dev/null +++ b/vendor/github.com/go-git/go-git/v5/internal/pathutil/dotgit.go @@ -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 +} diff --git a/vendor/github.com/go-git/go-git/v5/internal/pathutil/hfs.go b/vendor/github.com/go-git/go-git/v5/internal/pathutil/hfs.go new file mode 100644 index 000000000..66fc12f89 --- /dev/null +++ b/vendor/github.com/go-git/go-git/v5/internal/pathutil/hfs.go @@ -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 "." 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") } diff --git a/vendor/github.com/go-git/go-git/v5/internal/pathutil/ntfs.go b/vendor/github.com/go-git/go-git/v5/internal/pathutil/ntfs.go new file mode 100644 index 000000000..2ca6c2834 --- /dev/null +++ b/vendor/github.com/go-git/go-git/v5/internal/pathutil/ntfs.go @@ -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 (":"). 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: "." 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 ~[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 +} diff --git a/vendor/github.com/go-git/go-git/v5/internal/pathutil/tree.go b/vendor/github.com/go-git/go-git/v5/internal/pathutil/tree.go new file mode 100644 index 000000000..e610cd4a8 --- /dev/null +++ b/vendor/github.com/go-git/go-git/v5/internal/pathutil/tree.go @@ -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 :. + 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 +} diff --git a/vendor/github.com/go-git/go-git/v5/internal/url/url.go b/vendor/github.com/go-git/go-git/v5/internal/url/url.go index 266244869..e40947c90 100644 --- a/vendor/github.com/go-git/go-git/v5/internal/url/url.go +++ b/vendor/github.com/go-git/go-git/v5/internal/url/url.go @@ -2,12 +2,14 @@ package url import ( "regexp" + "runtime" + "strings" ) var ( isSchemeRegExp = regexp.MustCompile(`^[^:]+://`) - // Ref: https://github.com/git/git/blob/master/Documentation/urls.txt#L37 + // Ref: https://github.com/git/git/blob/v2.54.0/Documentation/urls.adoc#L41-L48 scpLikeUrlRegExp = regexp.MustCompile(`^(?:(?P[^@]+)@)?(?P[^:\s]+):(?:(?P[0-9]{1,5}):)?(?P[^\\].*)$`) ) @@ -20,7 +22,38 @@ func MatchesScheme(url string) bool { // MatchesScpLike returns true if the given string matches an SCP-like // format scheme. func MatchesScpLike(url string) bool { - return scpLikeUrlRegExp.MatchString(url) + if !scpLikeUrlRegExp.MatchString(url) { + return false + } + // Mirror canonical Git's url_is_local_not_ssh in connect.c[1] for + // the cases the regex above cannot disambiguate by itself: a URL + // is treated as a local path (not SCP-style SSH) when a `/` + // precedes the first `:` (e.g. `./relative:path`, + // `/abs/with:colon/file`), or — on Windows only — when it has a + // DOS drive prefix like `C:foo` where the host is a single + // ASCII letter. + // + // [1]: https://github.com/git/git/blob/v2.54.0/connect.c#L710-L716 + if before, _, _ := strings.Cut(url, ":"); strings.Contains(before, "/") { + return false + } + if runtime.GOOS == "windows" && hasDosDrivePrefix(url) { + return false + } + return true +} + +// hasDosDrivePrefix reports whether s begins with `:` (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 diff --git a/vendor/github.com/go-git/go-git/v5/plumbing/format/idxfile/decoder.go b/vendor/github.com/go-git/go-git/v5/plumbing/format/idxfile/decoder.go index 9e006a726..825fad9a5 100644 --- a/vendor/github.com/go-git/go-git/v5/plumbing/format/idxfile/decoder.go +++ b/vendor/github.com/go-git/go-git/v5/plumbing/format/idxfile/decoder.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io" + "io/fs" "github.com/go-git/go-git/v5/plumbing/hash" "github.com/go-git/go-git/v5/utils/binary" @@ -25,35 +26,88 @@ const ( objectIDLength = hash.Size ) +// Byte sizes of the idx v2 layout elements, used by the size formula +// in [validateIdxV2Size]. See [gitformat-pack] for the canonical +// layout. +// +// [gitformat-pack]: https://git-scm.com/docs/gitformat-pack +const ( + headerLen = 8 // magic + version + fanoutLen = fanout * 4 // uint32 per bucket + crc32Len = 4 // CRC32 per object + offset32Len = 4 // 32-bit offset per object + offset64Len = 8 // 64-bit overflow offset + trailerHashes = 2 // pack checksum + idx checksum, each hashsz +) + +// statInput is the optional shape the [Decoder] probes for at the +// start of [Decoder.Decode] to learn the on-disk length of the idx +// blob, which it uses to validate the canonical-Git size formula +// before any allocations driven by the fanout table. Callers that +// pass an [*os.File] or a `billy.File` backed by an `*os.File` +// (the production call sites in `storage/filesystem`) satisfy it +// directly; arbitrary [io.Reader]s do not, and decode for them +// retains the pre-existing behaviour of erroring out at the +// truncated-payload boundary instead. +// +// The interface is intentionally unexported so the public +// [NewDecoder] signature stays compatible with v5. +type statInput interface { + Stat() (fs.FileInfo, error) +} + // Decoder reads and decodes idx files from an input stream. type Decoder struct { io.Reader - h hash.Hash + src io.Reader + h hash.Hash } // NewDecoder builds a new idx stream decoder, that reads from r. func NewDecoder(r io.Reader) *Decoder { h := hash.New(crypto.SHA1) tr := io.TeeReader(r, h) - return &Decoder{tr, h} + return &Decoder{tr, r, h} } // Decode reads from the stream and decode the content into the MemoryIndex struct. func (d *Decoder) Decode(idx *MemoryIndex) error { + idxSize := int64(-1) + if in, ok := d.src.(statInput); ok { + fi, err := in.Stat() + if err != nil { + return fmt.Errorf("%w: stat input: %w", ErrMalformedIdxFile, err) + } + idxSize = fi.Size() + } + if err := validateHeader(d); err != nil { return err } - flow := []func(*MemoryIndex, io.Reader) error{ + headerFlow := []func(*MemoryIndex, io.Reader) error{ readVersion, readFanout, + } + for _, f := range headerFlow { + if err := f(idx, d); err != nil { + return err + } + } + + if idxSize >= 0 { + if err := validateIdxV2Size(idx, idxSize); err != nil { + return err + } + } + + bodyFlow := []func(*MemoryIndex, io.Reader) error{ readObjectNames, readCRC32, readOffsets, readPackChecksum, } - - for _, f := range flow { + for _, f := range bodyFlow { if err := f(idx, d); err != nil { return err } @@ -199,3 +253,103 @@ func readIdxChecksum(idx *MemoryIndex, r io.Reader) error { return nil } + +// validateIdxV2Size enforces the size formula used by canonical Git +// load_idx for idx v2 files: the on-disk length must lie within +// [minSize, maxSize] where +// +// perObject = hashsz + crc32Len + offset32Len +// minSize = headerLen + fanoutLen + trailerHashes*hashsz + nr*perObject +// maxSize = minSize + (nr-1)*offset64Len when nr > 0 +// +// with nr taken from the last fanout entry and hashsz fixed at +// [objectIDLength] (SHA-1 in v5). Multiplications use a self-checking +// overflow guard so inputs whose claimed object count overflows the +// formula are rejected rather than wrapping into a smaller value. +func validateIdxV2Size(idx *MemoryIndex, idxSize int64) error { + nr := int64(idx.Fanout[fanout-1]) + hashsz := int64(objectIDLength) + + minSize := minIdxV2Size(nr, hashsz) + maxSize := maxIdxV2Size(nr, hashsz) + if minSize < 0 || maxSize < 0 { + return fmt.Errorf("%w: object count %d is inconsistent with file size", ErrMalformedIdxFile, nr) + } + + if idxSize < minSize || idxSize > maxSize { + return fmt.Errorf("%w: file size %d is inconsistent with object count %d", ErrMalformedIdxFile, idxSize, nr) + } + return nil +} + +// minIdxV2Size returns the minimum on-disk size of an idx v2 file +// holding nr objects with the given hash size, mirroring the +// computation in canonical Git load_idx. Returns -1 when any +// intermediate multiplication or addition would overflow int64. +func minIdxV2Size(nr, hashsz int64) int64 { + perObject := hashsz + crc32Len + offset32Len + fixed := int64(headerLen+fanoutLen) + trailerHashes*hashsz + + objects, ok := mulInt64(nr, perObject) + if !ok { + return -1 + } + sum, ok := addInt64(fixed, objects) + if !ok { + return -1 + } + return sum +} + +// maxIdxV2Size returns the maximum on-disk size of an idx v2 file +// holding nr objects with the given hash size, mirroring the +// computation in canonical Git load_idx. Returns -1 on overflow. +func maxIdxV2Size(nr, hashsz int64) int64 { + minSize := minIdxV2Size(nr, hashsz) + if minSize < 0 { + return -1 + } + if nr == 0 { + return minSize + } + overflow, ok := mulInt64(nr-1, offset64Len) + if !ok { + return -1 + } + sum, ok := addInt64(minSize, overflow) + if !ok { + return -1 + } + return sum +} + +// mulInt64 returns a*b and whether the result fits in an int64 without +// overflow. Negative operands or overflow yield ok=false. The overflow +// check uses the standard self-inverse identity: a*b/b == a only when +// the multiplication did not wrap. +func mulInt64(a, b int64) (int64, bool) { + if a < 0 || b < 0 { + return 0, false + } + if a == 0 || b == 0 { + return 0, true + } + c := a * b + if c/b != a { + return 0, false + } + return c, true +} + +// addInt64 returns a+b and whether the result fits in an int64 without +// overflow. Negative operands or overflow yield ok=false. +func addInt64(a, b int64) (int64, bool) { + if a < 0 || b < 0 { + return 0, false + } + c := a + b + if c < a { + return 0, false + } + return c, true +} diff --git a/vendor/github.com/go-git/go-git/v5/plumbing/format/idxfile/idxfile.go b/vendor/github.com/go-git/go-git/v5/plumbing/format/idxfile/idxfile.go index 136c3e2ac..f068c25e5 100644 --- a/vendor/github.com/go-git/go-git/v5/plumbing/format/idxfile/idxfile.go +++ b/vendor/github.com/go-git/go-git/v5/plumbing/format/idxfile/idxfile.go @@ -2,6 +2,7 @@ package idxfile import ( "bytes" + "fmt" "io" "sort" "sync" @@ -126,7 +127,10 @@ func (idx *MemoryIndex) FindOffset(h plumbing.Hash) (int64, error) { return 0, plumbing.ErrObjectNotFound } - offset := idx.getOffset(k, i) + offset, err := idx.getOffset(k, i) + if err != nil { + return 0, err + } // Save the offset for reverse lookup idx.mu.Lock() @@ -141,17 +145,19 @@ func (idx *MemoryIndex) FindOffset(h plumbing.Hash) (int64, error) { const isO64Mask = uint64(1) << 31 -func (idx *MemoryIndex) getOffset(firstLevel, secondLevel int) uint64 { +func (idx *MemoryIndex) getOffset(firstLevel, secondLevel int) (uint64, error) { offset := secondLevel << 2 ofs := encbin.BigEndian.Uint32(idx.Offset32[firstLevel][offset : offset+4]) if (uint64(ofs) & isO64Mask) != 0 { offset := 8 * (uint64(ofs) & ^isO64Mask) - n := encbin.BigEndian.Uint64(idx.Offset64[offset : offset+8]) - return n + if l := uint64(len(idx.Offset64)); l < 8 || offset > l-8 { + return 0, fmt.Errorf("%w: offset64 index out of range", ErrMalformedIdxFile) + } + return encbin.BigEndian.Uint64(idx.Offset64[offset : offset+8]), nil } - return uint64(ofs) + return uint64(ofs), nil } // FindCRC32 implements the Index interface. @@ -209,8 +215,11 @@ func (idx *MemoryIndex) genOffsetHash() error { mappedFirstLevel := idx.FanoutMapping[firstLevel] for secondLevel := uint32(0); i < fanoutValue; i++ { copy(hash[:], idx.Names[mappedFirstLevel][secondLevel*objectIDLength:]) - offset := int64(idx.getOffset(mappedFirstLevel, int(secondLevel))) - offsetHash[offset] = hash + off, err := idx.getOffset(mappedFirstLevel, int(secondLevel)) + if err != nil { + return err + } + offsetHash[int64(off)] = hash secondLevel++ } } @@ -291,7 +300,11 @@ func (i *idxfileEntryIter) Next() (*Entry, error) { mappedFirstLevel := i.idx.FanoutMapping[i.firstLevel] entry := new(Entry) copy(entry.Hash[:], i.idx.Names[mappedFirstLevel][i.secondLevel*objectIDLength:]) - entry.Offset = i.idx.getOffset(mappedFirstLevel, i.secondLevel) + var err error + entry.Offset, err = i.idx.getOffset(mappedFirstLevel, i.secondLevel) + if err != nil { + return nil, err + } entry.CRC32 = i.idx.getCRC32(mappedFirstLevel, i.secondLevel) i.secondLevel++ diff --git a/vendor/github.com/go-git/go-git/v5/plumbing/format/objfile/reader.go b/vendor/github.com/go-git/go-git/v5/plumbing/format/objfile/reader.go index 621883a67..f9842ed9a 100644 --- a/vendor/github.com/go-git/go-git/v5/plumbing/format/objfile/reader.go +++ b/vendor/github.com/go-git/go-git/v5/plumbing/format/objfile/reader.go @@ -11,9 +11,10 @@ import ( ) var ( - ErrClosed = errors.New("objfile: already closed") - ErrHeader = errors.New("objfile: invalid header") - ErrNegativeSize = errors.New("objfile: negative object size") + ErrClosed = errors.New("objfile: already closed") + ErrHeader = errors.New("objfile: invalid header") + ErrHeaderNotRead = errors.New("objfile: Header must be called before Read") + ErrNegativeSize = errors.New("objfile: negative object size") ) // Reader reads and decodes compressed objfile data from a provided io.Reader. @@ -100,12 +101,23 @@ func (r *Reader) prepareForRead(t plumbing.ObjectType, size int64) { // // If Read encounters the end of the data stream it will return err == io.EOF, // either in the current call if n > 0 or in a subsequent call. +// +// Read returns ErrHeaderNotRead if Header has not been called successfully. func (r *Reader) Read(p []byte) (n int, err error) { + if r.multi == nil { + return 0, ErrHeaderNotRead + } return r.multi.Read(p) } // Hash returns the hash of the object data stream that has been read so far. +// It returns the zero plumbing.Hash if Header has not been called +// successfully — guarding against the nil hasher that prepareForRead has +// not yet allocated. func (r *Reader) Hash() plumbing.Hash { + if r.multi == nil { + return plumbing.ZeroHash + } return r.hasher.Sum() } diff --git a/vendor/github.com/go-git/go-git/v5/plumbing/format/packfile/diff_delta.go b/vendor/github.com/go-git/go-git/v5/plumbing/format/packfile/diff_delta.go index 8898e5830..a24b63b41 100644 --- a/vendor/github.com/go-git/go-git/v5/plumbing/format/packfile/diff_delta.go +++ b/vendor/github.com/go-git/go-git/v5/plumbing/format/packfile/diff_delta.go @@ -19,9 +19,6 @@ const ( // https://github.com/git/git/blob/f7466e94375b3be27f229c78873f0acf8301c0a5/diff-delta.c#L428 // Max size of a copy operation (64KB). maxCopySize = 64 * 1024 - - // Min size of a copy operation. - minCopySize = 4 ) // GetDelta returns an EncodedObject of type OFSDeltaObject. Base and Target object, diff --git a/vendor/github.com/go-git/go-git/v5/plumbing/format/packfile/fsobject.go b/vendor/github.com/go-git/go-git/v5/plumbing/format/packfile/fsobject.go index 238339daf..93a6fafca 100644 --- a/vendor/github.com/go-git/go-git/v5/plumbing/format/packfile/fsobject.go +++ b/vendor/github.com/go-git/go-git/v5/plumbing/format/packfile/fsobject.go @@ -78,7 +78,13 @@ func (o *FSObject) Reader() (io.ReadCloser, error) { _ = f.Close() return nil, err } - return ioutil.NewReadCloserWithCloser(r, f.Close), nil + // Cap the lazy stream at the resolved object size: well-formed + // content reaches EOF inside the bound, an inflated stream that + // runs past surfaces ErrInflatedSizeMismatch on the byte just + // past the limit. For delta-resolved objects o.size is the + // expanded size, which is what the caller is reading here. + bounded := newBoundedReadCloser(r, o.size) + return ioutil.NewReadCloserWithCloser(bounded, f.Close), nil } r, err := p.getObjectContent(o.offset) if err != nil { diff --git a/vendor/github.com/go-git/go-git/v5/plumbing/format/packfile/packfile.go b/vendor/github.com/go-git/go-git/v5/plumbing/format/packfile/packfile.go index 685270225..f7fb958f9 100644 --- a/vendor/github.com/go-git/go-git/v5/plumbing/format/packfile/packfile.go +++ b/vendor/github.com/go-git/go-git/v5/plumbing/format/packfile/packfile.go @@ -126,11 +126,17 @@ func (p *Packfile) nextObjectHeader() (*ObjectHeader, error) { return h, err } -func (p *Packfile) getDeltaObjectSize(buf *bytes.Buffer) int64 { +func (p *Packfile) getDeltaObjectSize(buf *bytes.Buffer) (int64, error) { delta := buf.Bytes() - _, delta = decodeLEB128(delta) // skip src size - sz, _ := decodeLEB128(delta) - return int64(sz) + _, delta, err := decodeLEB128(delta) // skip src size + if err != nil { + return 0, err + } + sz, _, err := decodeLEB128(delta) + if err != nil { + return 0, err + } + return int64(sz), nil } func (p *Packfile) getObjectSize(h *ObjectHeader) (int64, error) { @@ -145,7 +151,7 @@ func (p *Packfile) getObjectSize(h *ObjectHeader) (int64, error) { return 0, err } - return p.getDeltaObjectSize(buf), nil + return p.getDeltaObjectSize(buf) default: return 0, ErrInvalidObject.AddDetails("type %q", h.Type) } @@ -233,7 +239,10 @@ func (p *Packfile) getNextObject(h *ObjectHeader, hash plumbing.Hash) (plumbing. return nil, err } - size = p.getDeltaObjectSize(buf) + size, err = p.getDeltaObjectSize(buf) + if err != nil { + return nil, err + } if size <= smallObjectThreshold { var obj = new(plumbing.MemoryObject) obj.SetSize(size) diff --git a/vendor/github.com/go-git/go-git/v5/plumbing/format/packfile/parser.go b/vendor/github.com/go-git/go-git/v5/plumbing/format/packfile/parser.go index 2659c27e5..7774d2dc1 100644 --- a/vendor/github.com/go-git/go-git/v5/plumbing/format/packfile/parser.go +++ b/vendor/github.com/go-git/go-git/v5/plumbing/format/packfile/parser.go @@ -26,6 +26,45 @@ var ( ErrDeltaNotCached = errors.New("delta could not be found in cache") ) +// maxObjectPreallocBytes caps the up-front size hint passed to +// bytes.Buffer.Grow when staging an object's contents, so a malformed length +// cannot trigger a huge or out-of-range allocation. The buffer still grows +// dynamically as data is written; this is purely a hint cap. +const maxObjectPreallocBytes = 1 << 30 // 1 GiB + +// maxObjectsPrealloc caps the up-front capacity reserved from the pack's +// declared object count, so a header advertising an absurd quantity cannot +// trigger a multi-gigabyte allocation. The slice and maps still grow +// organically beyond this hint. +const maxObjectsPrealloc = 1 << 16 // 64 Ki entries + +// Match upstream Git's pack depth ceiling: pack-objects.h OE_DEPTH_BITS, +// enforced in builtin/pack-objects.c as (1 << OE_DEPTH_BITS) - 1. +const maxDeltaChainDepth = 4095 + +// growHint returns a non-negative int64 size, clamped to a sane upper bound, +// suitable for passing to bytes.Buffer.Grow. +func growHint(n int64) int { + switch { + case n <= 0: + return 0 + case n > maxObjectPreallocBytes: + return maxObjectPreallocBytes + default: + return int(n) + } +} + +// objectsHint returns a non-negative count, clamped to maxObjectsPrealloc, +// suitable for passing to make() as the capacity hint for slices or maps +// sized from a pack's declared object count. +func objectsHint(n uint32) int { + if n > maxObjectsPrealloc { + return maxObjectsPrealloc + } + return int(n) +} + // Observer interface is implemented by index encoders. type Observer interface { // OnHeader is called when a new packfile is opened. @@ -166,9 +205,10 @@ func (p *Parser) init() error { } p.count = c - p.oiByHash = make(map[plumbing.Hash]*objectInfo, p.count) - p.oiByOffset = make(map[int64]*objectInfo, p.count) - p.oi = make([]*objectInfo, p.count) + hint := objectsHint(p.count) + p.oiByHash = make(map[plumbing.Hash]*objectInfo, hint) + p.oiByOffset = make(map[int64]*objectInfo, hint) + p.oi = make([]*objectInfo, 0, hint) return nil } @@ -261,7 +301,7 @@ func (p *Parser) indexObjects() error { } if delta && !p.scanner.IsSeekable { buf.Reset() - buf.Grow(int(oh.Length)) + buf.Grow(growHint(oh.Length)) writers = append(writers, buf) } @@ -306,7 +346,7 @@ func (p *Parser) indexObjects() error { } p.oiByOffset[oh.Offset] = ota - p.oi[i] = ota + p.oi = append(p.oi, ota) } return nil @@ -317,8 +357,12 @@ func (p *Parser) resolveDeltas() error { defer sync.PutBytesBuffer(buf) for _, obj := range p.oi { + if err := checkDeltaChainDepth(obj); err != nil { + return err + } + buf.Reset() - buf.Grow(int(obj.Length)) + buf.Grow(growHint(obj.Length)) err := p.get(obj, buf) if err != nil { return err @@ -337,6 +381,9 @@ func (p *Parser) resolveDeltas() error { // create it once and reuse across all children. r := bytes.NewReader(buf.Bytes()) for _, child := range obj.Children { + if err := checkDeltaChainDepth(child); err != nil { + return err + } // Even though we are discarding the output, we still need to read it to // so that the scanner can advance to the next object, and the SHA1 can be // calculated. @@ -356,6 +403,17 @@ func (p *Parser) resolveDeltas() error { return nil } +func checkDeltaChainDepth(o *objectInfo) error { + var depth int + for current := o; current != nil && current.DiskType.IsDelta(); current = current.Parent { + depth++ + if depth > maxDeltaChainDepth { + return fmt.Errorf("%w: delta chain depth exceeds %d", ErrMalformedPackFile, maxDeltaChainDepth) + } + } + return nil +} + func (p *Parser) resolveExternalRef(o *objectInfo) { if ref, ok := p.oiByHash[o.SHA1]; ok && ref.ExternalRef { p.oiByHash[o.SHA1] = o @@ -405,7 +463,7 @@ func (p *Parser) get(o *objectInfo, buf *bytes.Buffer) (err error) { if o.DiskType.IsDelta() { b := sync.GetBytesBuffer() defer sync.PutBytesBuffer(b) - buf.Grow(int(o.Length)) + buf.Grow(growHint(o.Length)) err := p.get(o.Parent, b) if err != nil { return err diff --git a/vendor/github.com/go-git/go-git/v5/plumbing/format/packfile/patch_delta.go b/vendor/github.com/go-git/go-git/v5/plumbing/format/packfile/patch_delta.go index a9c6b9b56..4bcb49114 100644 --- a/vendor/github.com/go-git/go-git/v5/plumbing/format/packfile/patch_delta.go +++ b/vendor/github.com/go-git/go-git/v5/plumbing/format/packfile/patch_delta.go @@ -31,10 +31,15 @@ const ( // premptively made available for a patch operation. maxPatchPreemptionSize uint = 65536 - // minDeltaSize defines the smallest size for a delta. - minDeltaSize = 4 + // minDeltaSize is the smallest valid delta: a 1-byte srcSz LEB128 + // header followed by a 1-byte targetSz LEB128 header (the + // shortest case being targetSz=0 with no operations). + minDeltaSize = 2 ) +// uintBits is the bit width of uint on the current platform (32 or 64). +const uintBits = 32 << (^uint(0) >> 63) + type offset struct { mask byte shift uint @@ -142,7 +147,7 @@ func ReaderFromDelta(base plumbing.EncodedObject, deltaRC io.Reader) (io.ReadClo baseBuf := bufio.NewReader(baseRd) basePos := uint(0) - for { + for remainingTargetSz > 0 { cmd, err := deltaBuf.ReadByte() if err == io.EOF { _ = dstWr.CloseWithError(ErrInvalidDelta) @@ -166,9 +171,9 @@ func ReaderFromDelta(base plumbing.EncodedObject, deltaRC io.Reader) (io.ReadClo return } - if invalidSize(sz, targetSz) || + if invalidSize(sz, remainingTargetSz) || invalidOffsetSize(offset, sz, srcSz) { - _ = dstWr.Close() + _ = dstWr.CloseWithError(ErrInvalidDelta) return } @@ -210,7 +215,7 @@ func ReaderFromDelta(base plumbing.EncodedObject, deltaRC io.Reader) (io.ReadClo case isCopyFromDelta(cmd): sz := uint(cmd) // cmd is the size itself - if invalidSize(sz, targetSz) { + if invalidSize(sz, remainingTargetSz) { _ = dstWr.CloseWithError(ErrInvalidDelta) return } @@ -225,40 +230,48 @@ func ReaderFromDelta(base plumbing.EncodedObject, deltaRC io.Reader) (io.ReadClo _ = dstWr.CloseWithError(ErrDeltaCmd) return } + } - if remainingTargetSz <= 0 { - _ = dstWr.Close() - return - } + // Mirror upstream's `data != top` post-loop check: every byte + // of the delta payload must be consumed. + if _, err := deltaBuf.ReadByte(); err == nil { + _ = dstWr.CloseWithError(ErrInvalidDelta) + return + } else if err != io.EOF { + _ = dstWr.CloseWithError(err) + return } + + _ = dstWr.Close() }() return dstRd, nil } func patchDelta(dst *bytes.Buffer, src, delta []byte) error { - if len(delta) < minCopySize { - return ErrInvalidDelta + srcSz, delta, err := decodeLEB128(delta) + if err != nil { + return fmt.Errorf("%w: %w", ErrInvalidDelta, err) } - - srcSz, delta := decodeLEB128(delta) if srcSz != uint(len(src)) { return ErrInvalidDelta } - targetSz, delta := decodeLEB128(delta) + targetSz, delta, err := decodeLEB128(delta) + if err != nil { + return fmt.Errorf("%w: %w", ErrInvalidDelta, err) + } remainingTargetSz := targetSz - var cmd byte - growSz := min(targetSz, maxPatchPreemptionSize) dst.Grow(int(growSz)) - for { + + for remainingTargetSz > 0 { if len(delta) == 0 { return ErrInvalidDelta } - cmd = delta[0] + cmd := delta[0] delta = delta[1:] switch { @@ -275,16 +288,16 @@ func patchDelta(dst *bytes.Buffer, src, delta []byte) error { return err } - if invalidSize(sz, targetSz) || + if invalidSize(sz, remainingTargetSz) || invalidOffsetSize(offset, sz, srcSz) { - break + return ErrInvalidDelta } dst.Write(src[offset : offset+sz]) remainingTargetSz -= sz case isCopyFromDelta(cmd): sz := uint(cmd) // cmd is the size itself - if invalidSize(sz, targetSz) { + if invalidSize(sz, remainingTargetSz) { return ErrInvalidDelta } @@ -299,10 +312,12 @@ func patchDelta(dst *bytes.Buffer, src, delta []byte) error { default: return ErrDeltaCmd } + } - if remainingTargetSz <= 0 { - break - } + // Mirror upstream's `data != top` post-loop check: every byte of + // the delta payload must be consumed. + if len(delta) != 0 { + return ErrInvalidDelta } return nil @@ -354,7 +369,7 @@ func patchDeltaWriter(dst io.Writer, base io.ReaderAt, delta io.Reader, baselr := io.LimitReader(sr, 0).(*io.LimitedReader) deltalr := io.LimitReader(deltaBuf, 0).(*io.LimitedReader) - for { + for remainingTargetSz > 0 { buf := *bufp cmd, err := deltaBuf.ReadByte() if err == io.EOF { @@ -374,9 +389,9 @@ func patchDeltaWriter(dst io.Writer, base io.ReaderAt, delta io.Reader, return 0, plumbing.ZeroHash, err } - if invalidSize(sz, targetSz) || + if invalidSize(sz, remainingTargetSz) || invalidOffsetSize(offset, sz, srcSz) { - return 0, plumbing.ZeroHash, err + return 0, plumbing.ZeroHash, ErrInvalidDelta } if _, err := sr.Seek(int64(offset), io.SeekStart); err != nil { @@ -389,7 +404,7 @@ func patchDeltaWriter(dst io.Writer, base io.ReaderAt, delta io.Reader, remainingTargetSz -= sz } else if isCopyFromDelta(cmd) { sz := uint(cmd) // cmd is the size itself - if invalidSize(sz, targetSz) { + if invalidSize(sz, remainingTargetSz) { return 0, plumbing.ZeroHash, ErrInvalidDelta } deltalr.N = int64(sz) @@ -399,30 +414,41 @@ func patchDeltaWriter(dst io.Writer, base io.ReaderAt, delta io.Reader, remainingTargetSz -= sz } else { - return 0, plumbing.ZeroHash, err - } - if remainingTargetSz <= 0 { - break + return 0, plumbing.ZeroHash, ErrDeltaCmd } } + // Mirror upstream's `data != top` post-loop check: every byte of + // the delta payload must be consumed. + if _, err := deltaBuf.ReadByte(); err == nil { + return 0, plumbing.ZeroHash, ErrInvalidDelta + } else if err != io.EOF { + return 0, plumbing.ZeroHash, err + } + return targetSz, hasher.Sum(), nil } // Decodes a number encoded as an unsigned LEB128 at the start of some -// binary data and returns the decoded number and the rest of the -// stream. +// binary data and returns the decoded number, the rest of the stream, +// and an error if the encoded value does not fit in a uint. // // This must be called twice on the delta data buffer, first to get the // expected source buffer size, and again to get the target buffer size. -func decodeLEB128(input []byte) (uint, []byte) { +func decodeLEB128(input []byte) (uint, []byte, error) { if len(input) == 0 { - return 0, input + return 0, input, nil } var num, sz uint var b byte for { + // A continuation byte at shift > uintBits-7 cannot contribute + // without overflowing the accumulator. + if sz*7 > uintBits-7 { + return 0, input, ErrLengthOverflow + } + b = input[sz] num |= (uint(b) & payload) << (sz * 7) // concats 7 bits chunks sz++ @@ -432,12 +458,16 @@ func decodeLEB128(input []byte) (uint, []byte) { } } - return num, input[sz:] + return num, input[sz:], nil } func decodeLEB128ByteReader(input io.ByteReader) (uint, error) { var num, sz uint for { + if sz*7 > uintBits-7 { + return 0, ErrLengthOverflow + } + b, err := input.ReadByte() if err != nil { return 0, err @@ -529,8 +559,9 @@ func decodeSize(cmd byte, delta []byte) (uint, []byte, error) { return sz, delta, nil } -func invalidSize(sz, targetSz uint) bool { - return sz > targetSz +// invalidSize reports whether sz exceeds the remaining target size. +func invalidSize(sz, remaining uint) bool { + return sz > remaining } func invalidOffsetSize(offset, sz, srcSz uint) bool { diff --git a/vendor/github.com/go-git/go-git/v5/plumbing/format/packfile/scanner.go b/vendor/github.com/go-git/go-git/v5/plumbing/format/packfile/scanner.go index 8318aae40..6d2907ecd 100644 --- a/vendor/github.com/go-git/go-git/v5/plumbing/format/packfile/scanner.go +++ b/vendor/github.com/go-git/go-git/v5/plumbing/format/packfile/scanner.go @@ -29,8 +29,100 @@ var ( ErrSeekNotSupported = NewError("not seek support") // ErrMalformedPackFile is returned by the parser when the pack file is corrupted. ErrMalformedPackFile = errors.New("malformed PACK file") + // ErrLengthOverflow is returned when a variable-length integer would not + // fit into its accumulator because the input declares more continuation + // bytes than the type can hold. + ErrLengthOverflow = errors.New("variable-length integer overflow") + // ErrInflatedSizeMismatch is returned when a packfile object inflates to + // more bytes than the size declared in its object header. A well-formed + // packfile never produces more data than the declared size; exceeding it + // indicates a structurally invalid entry. + ErrInflatedSizeMismatch = errors.New("packfile: inflated object exceeds declared size") ) +// boundedWriter passes writes through to w up to limit bytes total, then +// returns ErrInflatedSizeMismatch. It is used to enforce that a packfile +// object's inflated length does not exceed the size declared in its header. +type boundedWriter struct { + w io.Writer + limit int64 + n int64 +} + +// Write forwards p to the underlying writer while keeping the running total +// at or below limit. On overrun it forwards the legal prefix and reports +// the number of bytes actually consumed alongside ErrInflatedSizeMismatch, +// matching the contract in io.Writer. A write error from the underlying +// writer during overrun-handling is joined with ErrInflatedSizeMismatch so +// it is not silently dropped. +func (b *boundedWriter) Write(p []byte) (int, error) { + if b.n+int64(len(p)) > b.limit { + remain := int(b.limit - b.n) + err := error(ErrInflatedSizeMismatch) + if remain > 0 { + n, werr := b.w.Write(p[:remain]) + b.n += int64(n) + if werr != nil { + err = errors.Join(ErrInflatedSizeMismatch, werr) + } + return n, err + } + return 0, err + } + n, err := b.w.Write(p) + b.n += int64(n) + return n, err +} + +// boundedReadCloser wraps a ReadCloser and reports ErrInflatedSizeMismatch +// once more than limit bytes have been read. It is used by the on-demand +// object reader returned from FSObject.Reader so that a lazy Read of a +// packfile object cannot stream past its declared inflated size. +// +// The implementation builds on io.LimitedReader with the standard +// overrun-detection trick: request limit+1 bytes from the underlying so +// that the moment the sentinel byte materializes (LimitedReader.N drops +// to zero) we know the source produced more than limit bytes. +type boundedReadCloser struct { + lr io.LimitedReader + closer io.Closer + overrun bool +} + +// newBoundedReadCloser wraps rc so that the cumulative bytes returned from +// Read never exceed limit. The first call that would have returned a byte +// past limit instead returns ErrInflatedSizeMismatch; subsequent calls +// keep returning the same error. A negative limit is treated as zero, so +// the first byte produced by rc surfaces ErrInflatedSizeMismatch. +func newBoundedReadCloser(rc io.ReadCloser, limit int64) *boundedReadCloser { + if limit < 0 { + limit = 0 + } + return &boundedReadCloser{ + lr: io.LimitedReader{R: rc, N: limit + 1}, + closer: rc, + } +} + +// Read forwards Read up to the configured byte limit. When the underlying +// stream produces the limit+1 sentinel byte, the legal prefix is returned +// alongside ErrInflatedSizeMismatch; on subsequent calls only the error +// is returned. +func (b *boundedReadCloser) Read(p []byte) (int, error) { + if b.overrun { + return 0, ErrInflatedSizeMismatch + } + n, err := b.lr.Read(p) + if b.lr.N == 0 { + b.overrun = true + return n - 1, ErrInflatedSizeMismatch + } + return n, err +} + +// Close closes the underlying ReadCloser. +func (b *boundedReadCloser) Close() error { return b.closer.Close() } + // ObjectHeader contains the information related to the object, this information // is collected from the previous bytes to the content of the object. type ObjectHeader struct { @@ -220,6 +312,13 @@ func (s *Scanner) nextObjectHeader() (*ObjectHeader, error) { return nil, err } + // An OFS-delta references a base object that appears earlier + // in the pack; the negative offset must be strictly positive + // and not larger than the current object's offset. + if no <= 0 || no > h.Offset { + return nil, fmt.Errorf("%w: invalid OFS delta offset", ErrMalformedPackFile) + } + h.OffsetReference = h.Offset - no case plumbing.REFDeltaObject: var err error @@ -303,6 +402,13 @@ func (s *Scanner) readLength(first byte) (int64, error) { shift := firstLengthBits var err error for c&maskContinue > 0 { + // Mirrors unpack_object_header_buffer in canonical Git's + // packfile.c: a continuation byte at shift > 64-7 cannot + // contribute without overflowing an int64. + if shift > 64-lengthBits { + return 0, fmt.Errorf("%w: %w", ErrMalformedPackFile, ErrLengthOverflow) + } + if c, err = s.r.ReadByte(); err != nil { return 0, err } @@ -315,10 +421,18 @@ func (s *Scanner) readLength(first byte) (int64, error) { } // NextObject writes the content of the next object into the reader, returns -// the number of bytes written, the CRC32 of the content and an error, if any +// the number of bytes written, the CRC32 of the content and an error, if any. +// +// When a prior NextObjectHeader has stashed the object header in +// pendingObject, the inflated stream is bounded by the header's declared +// length and surfaces ErrInflatedSizeMismatch on overrun. func (s *Scanner) NextObject(w io.Writer) (written int64, crc32 uint32, err error) { + declaredSize := int64(-1) + if s.pendingObject != nil { + declaredSize = s.pendingObject.Length + } s.pendingObject = nil - written, err = s.copyObject(w) + written, err = s.copyObject(w, declaredSize) s.r.Flush() crc32 = s.crc.Sum32() @@ -327,23 +441,39 @@ func (s *Scanner) NextObject(w io.Writer) (written int64, crc32 uint32, err erro return } -// ReadObject returns a reader for the object content and an error +// ReadObject returns a reader for the object content and an error. +// +// When a prior NextObjectHeader has stashed the object header in +// pendingObject, the returned reader is bounded by the header's declared +// length so callers cannot stream past the declared inflated size; an +// overrun surfaces ErrInflatedSizeMismatch on the byte just past the +// limit. func (s *Scanner) ReadObject() (io.ReadCloser, error) { + declaredSize := int64(-1) + if s.pendingObject != nil { + declaredSize = s.pendingObject.Length + } s.pendingObject = nil zr, err := sync.GetZlibReader(s.r) if err != nil { return nil, fmt.Errorf("zlib reset error: %s", err) } - return ioutil.NewReadCloserWithCloser(zr.Reader, func() error { + rc := ioutil.NewReadCloserWithCloser(zr.Reader, func() error { sync.PutZlibReader(zr) return nil - }), nil + }) + if declaredSize >= 0 { + return newBoundedReadCloser(rc, declaredSize), nil + } + return rc, nil } -// ReadRegularObject reads and write a non-deltified object -// from it zlib stream in an object entry in the packfile. -func (s *Scanner) copyObject(w io.Writer) (n int64, err error) { +// copyObject inflates a non-deltified object's zlib stream into w. When +// declaredSize is non-negative, the write sink is wrapped in a +// boundedWriter so an overrun surfaces ErrInflatedSizeMismatch instead +// of being silently appended. +func (s *Scanner) copyObject(w io.Writer, declaredSize int64) (n int64, err error) { zr, err := sync.GetZlibReader(s.r) defer sync.PutZlibReader(zr) @@ -352,8 +482,14 @@ func (s *Scanner) copyObject(w io.Writer) (n int64, err error) { } defer ioutil.CheckClose(zr.Reader, &err) + + sink := w + if declaredSize >= 0 { + sink = &boundedWriter{w: w, limit: declaredSize} + } + buf := sync.GetByteSlice() - n, err = io.CopyBuffer(w, zr.Reader, *buf) + n, err = io.CopyBuffer(sink, zr.Reader, *buf) sync.PutByteSlice(buf) return } diff --git a/vendor/github.com/go-git/go-git/v5/plumbing/object/tree.go b/vendor/github.com/go-git/go-git/v5/plumbing/object/tree.go index d0d0036de..3c004f5fd 100644 --- a/vendor/github.com/go-git/go-git/v5/plumbing/object/tree.go +++ b/vendor/github.com/go-git/go-git/v5/plumbing/object/tree.go @@ -10,6 +10,7 @@ import ( "sort" "strings" + "github.com/go-git/go-git/v5/internal/pathutil" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/filemode" "github.com/go-git/go-git/v5/plumbing/storer" @@ -118,7 +119,16 @@ func (t *Tree) Tree(path string) (*Tree, error) { } // TreeEntryFile returns the *File for a given *TreeEntry. +// +// The entry's name is validated against pathutil.ValidTreePath for +// the same reason FindEntry validates: TreeEntryFile is a boundary +// where attacker-controlled tree data leaves the trusted store as a +// *File whose Name a caller can hand to filesystem ops. func (t *Tree) TreeEntryFile(e *TreeEntry) (*File, error) { + if err := pathutil.ValidTreePath(e.Name); err != nil { + return nil, err + } + blob, err := GetBlob(t.s, e.Hash) if err != nil { return nil, err @@ -128,7 +138,16 @@ func (t *Tree) TreeEntryFile(e *TreeEntry) (*File, error) { } // FindEntry search a TreeEntry in this tree or any subtree. +// +// The lookup path is validated against pathutil.ValidTreePath to +// prevent attacker-controlled tree contents from leaking past this +// boundary as `.git`-shaped or path-traversal-shaped names. Callers +// that legitimately need to look up unsafe paths should walk the +// tree manually. func (t *Tree) FindEntry(path string) (*TreeEntry, error) { + if err := pathutil.ValidTreePath(path); err != nil { + return nil, err + } if t.t == nil { t.t = make(map[string]*Tree) } @@ -517,6 +536,10 @@ func (w *TreeWalker) Next() (name string, entry TreeEntry, err error) { continue } + if err := pathutil.ValidTreePath(entry.Name); err != nil { + return name, entry, err + } + if entry.Mode == filemode.Dir { obj, err = GetTree(w.s, entry.Hash) } diff --git a/vendor/github.com/go-git/go-git/v5/plumbing/transport/ssh/common.go b/vendor/github.com/go-git/go-git/v5/plumbing/transport/ssh/common.go index ae6f2174a..647955b2d 100644 --- a/vendor/github.com/go-git/go-git/v5/plumbing/transport/ssh/common.go +++ b/vendor/github.com/go-git/go-git/v5/plumbing/transport/ssh/common.go @@ -252,7 +252,39 @@ func (c *command) setAuthFromEndpoint() error { } func endpointToCommand(cmd string, ep *transport.Endpoint) string { - return fmt.Sprintf("%s '%s'", cmd, ep.Path) + var b strings.Builder + b.WriteString(cmd) + b.WriteByte(' ') + writeShellQuote(&b, ep.Path) + return b.String() +} + +// writeShellQuote writes s to b, wrapped in single quotes with +// embedded single quotes and exclamation marks escaped using the +// POSIX close-escape-reopen idiom: +// +// ' becomes '\'' +// ! becomes '\!' +// +// It is a direct port of canonical Git's sq_quote_buf (quote.c). +// The bang escape keeps the result safe when re-evaluated under +// csh-derived shells that perform history expansion. The output is +// safe to pass as a single argument through any POSIX shell and +// round-trips through git-shell's sq_dequote_to_argv. +func writeShellQuote(b *strings.Builder, s string) { + b.Grow(len(s) + 2) + b.WriteByte('\'') + for i := 0; i < len(s); i++ { + c := s[i] + if c == '\'' || c == '!' { + b.WriteString(`'\`) + b.WriteByte(c) + b.WriteByte('\'') + continue + } + b.WriteByte(c) + } + b.WriteByte('\'') } func overrideConfig(overrides *ssh.ClientConfig, c *ssh.ClientConfig) { diff --git a/vendor/github.com/go-git/go-git/v5/repository.go b/vendor/github.com/go-git/go-git/v5/repository.go index e0cefc491..12af16239 100644 --- a/vendor/github.com/go-git/go-git/v5/repository.go +++ b/vendor/github.com/go-git/go-git/v5/repository.go @@ -1530,7 +1530,18 @@ func (r *Repository) Worktree() (*Worktree, error) { return nil, ErrIsBareRepository } - return &Worktree{r: r, Filesystem: r.wt}, nil + protectNTFS := defaultProtectNTFS() + protectHFS := defaultProtectHFS() + if cfg, err := r.Config(); err == nil { + if cfg.Core.ProtectNTFS.IsSet() { + protectNTFS = cfg.Core.ProtectNTFS.IsTrue() + } + if cfg.Core.ProtectHFS.IsSet() { + protectHFS = cfg.Core.ProtectHFS.IsTrue() + } + } + + return &Worktree{r: r, Filesystem: newWorktreeFilesystem(r.wt, protectNTFS, protectHFS)}, nil } func expand_ref(s storer.ReferenceStorer, ref plumbing.ReferenceName) (*plumbing.Reference, error) { diff --git a/vendor/github.com/go-git/go-git/v5/storage/filesystem/dotgit/dotgit.go b/vendor/github.com/go-git/go-git/v5/storage/filesystem/dotgit/dotgit.go index 72c9ccfc1..eb85a1145 100644 --- a/vendor/github.com/go-git/go-git/v5/storage/filesystem/dotgit/dotgit.go +++ b/vendor/github.com/go-git/go-git/v5/storage/filesystem/dotgit/dotgit.go @@ -75,6 +75,10 @@ var ( // ErrEmptyRefFile is returned when a reference file is attempted to be read, // but the file is empty ErrEmptyRefFile = errors.New("ref file is empty") + // ErrModuleNameEscape is returned when a submodule name would + // resolve outside the modules/ subtree, mirroring canonical Git's + // "ignoring suspicious submodule name" defence. + ErrModuleNameEscape = errors.New("submodule name escapes modules/ directory") ) // Options holds configuration for the storage. @@ -1127,9 +1131,20 @@ func (d *DotGit) PackRefs() (err error) { return nil } -// Module return a billy.Filesystem pointing to the module folder +// Module returns a billy.Filesystem pointing to the module folder. +// +// As a defence in depth against submodule name path traversal, +// refuse names whose joined path leaves the modules/ subtree once +// cleaned. The config-layer parser also validates submodule names, +// but Module may be reached from any caller that constructs a +// Submodule struct programmatically and so bypasses the parser. func (d *DotGit) Module(name string) (billy.Filesystem, error) { - return d.fs.Chroot(d.fs.Join(modulePath, name)) + p := d.fs.Join(modulePath, name) + cleaned := path.Clean(filepath.ToSlash(p)) + if cleaned != modulePath && !strings.HasPrefix(cleaned, modulePath+"/") { + return nil, ErrModuleNameEscape + } + return d.fs.Chroot(p) } func (d *DotGit) AddAlternate(remote string) error { diff --git a/vendor/github.com/go-git/go-git/v5/submodule.go b/vendor/github.com/go-git/go-git/v5/submodule.go index afabb6aca..2fe4ca2d2 100644 --- a/vendor/github.com/go-git/go-git/v5/submodule.go +++ b/vendor/github.com/go-git/go-git/v5/submodule.go @@ -6,9 +6,12 @@ import ( "errors" "fmt" "path" + "path/filepath" "github.com/go-git/go-billy/v5" "github.com/go-git/go-git/v5/config" + "github.com/go-git/go-git/v5/internal/pathutil" + giturl "github.com/go-git/go-git/v5/internal/url" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/format/index" "github.com/go-git/go-git/v5/plumbing/transport" @@ -119,6 +122,16 @@ func (s *Submodule) Repository() (*Repository, error) { exists = true } + // s.c.Path is sourced from the worktree's .gitmodules and is + // therefore tree-controlled. Apply the strict tree-path validator + // before chroot — the wrapper's tolerant validPath would let a + // final-position .git component through (e.g. "submodule/.git"), + // which a malicious .gitmodules could use to chroot the submodule + // worktree into the repository's actual .git directory. + if err := pathutil.ValidTreePath(s.c.Path); err != nil { + return nil, err + } + var worktree billy.Filesystem if worktree, err = s.w.Filesystem.Chroot(s.c.Path); err != nil { return nil, err @@ -138,18 +151,25 @@ func (s *Submodule) Repository() (*Repository, error) { return nil, err } - if !path.IsAbs(moduleEndpoint.Path) && moduleEndpoint.Protocol == "file" { - remotes, err := s.w.r.Remotes() + // A relative submodule URL such as "../X.git" must resolve against + // the parent repository's remote URL, not against the process CWD. + // Detect relativity from the raw configured URL because + // transport.NewEndpoint normalizes local paths to absolute form via + // filepath.Abs, which would otherwise mask the relative form here. + if giturl.IsLocalEndpoint(s.c.URL) && + !path.IsAbs(s.c.URL) && !filepath.IsAbs(s.c.URL) { + + base, err := defaultRemote(s.w.r) if err != nil { - return nil, err + return nil, fmt.Errorf("resolving relative submodule URL: %w", err) } - rootEndpoint, err := transport.NewEndpoint(remotes[0].c.URLs[0]) + rootEndpoint, err := transport.NewEndpoint(base.URLs[0]) if err != nil { return nil, err } - rootEndpoint.Path = path.Join(rootEndpoint.Path, moduleEndpoint.Path) + rootEndpoint.Path = path.Join(rootEndpoint.Path, s.c.URL) *moduleEndpoint = *rootEndpoint } @@ -161,6 +181,52 @@ func (s *Submodule) Repository() (*Repository, error) { return r, err } +// defaultRemote returns the remote that relative submodule URLs are +// resolved against, mirroring canonical Git's repo_default_remote +// (remote.c) and resolve_relative_url (builtin/submodule--helper.c): +// +// 1. if HEAD is on a branch with branch..remote configured, +// use that remote; +// 2. else if exactly one remote is configured, use it; +// 3. otherwise fall back to DefaultRemoteName ("origin"). +// +// Each rule falls through unconditionally: a branch lookup that +// finds the branch but with an empty Remote does not short-circuit +// rule (2). Returns an error when the chosen remote is not configured. +func defaultRemote(r *Repository) (*config.RemoteConfig, error) { + cfg, err := r.Config() + if err != nil { + return nil, err + } + + if ref, err := r.Reference(plumbing.HEAD, false); err == nil && + ref.Type() == plumbing.SymbolicReference && + ref.Target().IsBranch() { + if b, ok := cfg.Branches[ref.Target().Short()]; ok && b.Remote != "" { + return lookupRemote(cfg, b.Remote) + } + } + + if len(cfg.Remotes) == 1 { + for name := range cfg.Remotes { + return lookupRemote(cfg, name) + } + } + + return lookupRemote(cfg, DefaultRemoteName) +} + +func lookupRemote(cfg *config.Config, name string) (*config.RemoteConfig, error) { + rc, ok := cfg.Remotes[name] + if !ok { + return nil, fmt.Errorf("remote %q not found", name) + } + if len(rc.URLs) == 0 { + return nil, fmt.Errorf("remote %q has no configured URL", name) + } + return rc, nil +} + // Update the registered submodule to match what the superproject expects, the // submodule should be initialized first calling the Init method or setting in // the options SubmoduleUpdateOptions.Init equals true diff --git a/vendor/github.com/go-git/go-git/v5/utils/binary/read.go b/vendor/github.com/go-git/go-git/v5/utils/binary/read.go index b8f9df1a2..71d9ad607 100644 --- a/vendor/github.com/go-git/go-git/v5/utils/binary/read.go +++ b/vendor/github.com/go-git/go-git/v5/utils/binary/read.go @@ -5,11 +5,18 @@ package binary import ( "bufio" "encoding/binary" + "errors" "io" + "math" "github.com/go-git/go-git/v5/plumbing" ) +// ErrIntegerOverflow is returned when a Git-format variable-width integer +// would not fit into an int64 because the input declares more continuation +// bytes than the type can hold. +var ErrIntegerOverflow = errors.New("variable-width integer overflow") + // Read reads structured binary data from r into data. Bytes are read and // decoded in BigEndian order // https://golang.org/pkg/encoding/binary/#Read @@ -92,6 +99,14 @@ func ReadVariableWidthInt(r io.Reader) (int64, error) { var v = int64(c & maskLength) for c&maskContinue > 0 { + // Reject input that, after the v++ and shift below, would + // not fit in an int64. With v < (MaxInt64-127)>>7, the + // post-increment v is at most (MaxInt64-127)>>7 and the + // final (v << 7) + (c & 0x7F) stays within int64. + if v >= (math.MaxInt64-int64(maskLength))>>lengthBits { + return 0, ErrIntegerOverflow + } + v++ if err := Read(r, &c); err != nil { return 0, err diff --git a/vendor/github.com/go-git/go-git/v5/worktree.go b/vendor/github.com/go-git/go-git/v5/worktree.go index 55d7ebb1b..d8ee9fdd1 100644 --- a/vendor/github.com/go-git/go-git/v5/worktree.go +++ b/vendor/github.com/go-git/go-git/v5/worktree.go @@ -7,7 +7,6 @@ import ( "io" "os" "path/filepath" - "runtime" "strings" "github.com/go-git/go-billy/v5" @@ -458,10 +457,6 @@ func (w *Worktree) resetWorktree(t *object.Tree, files []string) error { filesMap := buildFilePathMap(files) for _, ch := range changes { - if err := w.validChange(ch); err != nil { - return err - } - if len(files) > 0 { file := "" if ch.From != nil { @@ -489,108 +484,6 @@ func (w *Worktree) resetWorktree(t *object.Tree, files []string) error { return w.r.Storer.SetIndex(idx) } -// worktreeDeny is a list of paths that are not allowed -// to be used when resetting the worktree. -var worktreeDeny = map[string]struct{}{ - // .git - GitDirName: {}, - - // For other historical reasons, file names that do not conform to the 8.3 - // format (up to eight characters for the basename, three for the file - // extension, certain characters not allowed such as `+`, etc) are associated - // with a so-called "short name", at least on the `C:` drive by default. - // Which means that `git~1/` is a valid way to refer to `.git/`. - "git~1": {}, -} - -// validPath checks whether paths are valid. -// The rules around invalid paths could differ from upstream based on how -// filesystems are managed within go-git, but they are largely the same. -// -// For upstream rules: -// https://github.com/git/git/blob/564d0252ca632e0264ed670534a51d18a689ef5d/read-cache.c#L946 -// https://github.com/git/git/blob/564d0252ca632e0264ed670534a51d18a689ef5d/path.c#L1383 -func validPath(paths ...string) error { - for _, p := range paths { - parts := strings.FieldsFunc(p, func(r rune) bool { return (r == '\\' || r == '/') }) - if len(parts) == 0 { - return fmt.Errorf("invalid path: %q", p) - } - - if _, denied := worktreeDeny[strings.ToLower(parts[0])]; denied { - return fmt.Errorf("invalid path prefix: %q", p) - } - - if runtime.GOOS == "windows" { - // Volume names are not supported, in both formats: \\ and :. - 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 - // `::`. There exists a default stream - // type for directories, allowing `.git/` to be accessed via - // `.git::$INDEX_ALLOCATION/`. - // - // For performance reasons, _all_ Alternate Data Streams of `.git/` are - // forbidden, not just `::$INDEX_ALLOCATION`. - if len(part) > 4 && part[4:5] == ":" { - return false - } - } - return true -} - -func (w *Worktree) validChange(ch merkletrie.Change) error { - action, err := ch.Action() - if err != nil { - return nil - } - - switch action { - case merkletrie.Delete: - return validPath(ch.From.String()) - case merkletrie.Insert: - return validPath(ch.To.String()) - case merkletrie.Modify: - return validPath(ch.From.String(), ch.To.String()) - } - - return nil -} - func (w *Worktree) checkoutChange(ch merkletrie.Change, t *object.Tree, idx *indexBuilder) error { a, err := ch.Action() if err != nil { @@ -763,10 +656,10 @@ func (w *Worktree) checkoutFile(f *object.File) (err error) { } func (w *Worktree) checkoutFileSymlink(f *object.File) (err error) { - // https://github.com/git/git/commit/10ecfa76491e4923988337b2e2243b05376b40de - if strings.EqualFold(f.Name, gitmodulesFile) { - return ErrGitModulesSymlink - } + // .gitmodules symlink rejection (and its NTFS / HFS variants) is + // enforced by the worktreeFilesystem wrapper's Symlink method via + // validSymlinkName. See https://github.com/git/git/commit/10ecfa7 + // for the upstream rationale. from, err := f.Reader() if err != nil { diff --git a/vendor/github.com/go-git/go-git/v5/worktree_fs.go b/vendor/github.com/go-git/go-git/v5/worktree_fs.go new file mode 100644 index 000000000..9bc2fd97d --- /dev/null +++ b/vendor/github.com/go-git/go-git/v5/worktree_fs.go @@ -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 :. + 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 +} diff --git a/vendor/github.com/go-git/go-git/v5/worktree_status.go b/vendor/github.com/go-git/go-git/v5/worktree_status.go index e7a60747b..ecc3d7ab8 100644 --- a/vendor/github.com/go-git/go-git/v5/worktree_status.go +++ b/vendor/github.com/go-git/go-git/v5/worktree_status.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/go-git/go-billy/v5/util" + "github.com/go-git/go-git/v5/internal/pathutil" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/filemode" "github.com/go-git/go-git/v5/plumbing/format/gitignore" @@ -545,6 +546,14 @@ func (w *Worktree) addOrUpdateFileToIndex(idx *index.Index, filename string, h p } func (w *Worktree) doAddFileToIndex(idx *index.Index, filename string, h plumbing.Hash) error { + // Mirror upstream's Index.Add gate at the v5 caller boundary: the + // index feeds future trees, so a name that the tree-side + // pathutil.ValidTreePath gate would reject must not enter the + // index in the first place. v5 keeps Index.Add's existing signature + // for API compatibility, so the validation happens here. + if err := pathutil.ValidTreePath(filename); err != nil { + return err + } return w.doUpdateFileToIndex(idx.Add(filename), filename, h) } diff --git a/vendor/modules.txt b/vendor/modules.txt index 3eed3900a..e8f7922e5 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -334,11 +334,12 @@ github.com/go-git/go-billy/v5/helper/polyfill github.com/go-git/go-billy/v5/memfs github.com/go-git/go-billy/v5/osfs github.com/go-git/go-billy/v5/util -# github.com/go-git/go-git/v5 v5.19.0 +# github.com/go-git/go-git/v5 v5.19.1 ## explicit; go 1.25.0 github.com/go-git/go-git/v5 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/pathutil 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/plumbing