--- Begin Message ---
Package: release.debian.org
Severity: normal
Tags: trixie
X-Debbugs-Cc: [email protected]
Control: affects -1 + src:git-lfs
User: [email protected]
Usertags: pu
This fixes CVE-2025-26625, which hardenes symlink handling
in git-lfs. Manual tests and tests as run by debusine were
all fine. debdiff below.
Cheers,
Moritz
diff -Nru git-lfs-3.6.1/debian/changelog git-lfs-3.6.1/debian/changelog
--- git-lfs-3.6.1/debian/changelog 2025-01-21 07:34:17.000000000 +0100
+++ git-lfs-3.6.1/debian/changelog 2026-04-10 17:17:50.000000000 +0200
@@ -1,3 +1,9 @@
+git-lfs (3.6.1-1+deb13u1) trixie; urgency=medium
+
+ * CVE-2025-26625 (Closes: #1118339)
+
+ -- Moritz Mühlenhoff <[email protected]> Fri, 10 Apr 2026 17:17:50 +0200
+
git-lfs (3.6.1-1) unstable; urgency=medium
* New upstream release
diff -Nru git-lfs-3.6.1/debian/patches/CVE-2025-26625.patch
git-lfs-3.6.1/debian/patches/CVE-2025-26625.patch
--- git-lfs-3.6.1/debian/patches/CVE-2025-26625.patch 1970-01-01
01:00:00.000000000 +0100
+++ git-lfs-3.6.1/debian/patches/CVE-2025-26625.patch 2026-04-10
10:14:08.000000000 +0200
@@ -0,0 +1,1190 @@
+Backports of:
+
+From 5c11ffce9a4f095ff356bc781e2a031abb46c1a8 Mon Sep 17 00:00:00 2001
+From: Chris Darroch <[email protected]>
+Date: Thu, 15 May 2025 23:42:40 -0700
+Subject: [PATCH] docs,lfs,t: create new files on checkout and pull
+
+From 0cffe93176b870055c9dadbb3cc9a4a440e98396 Mon Sep 17 00:00:00 2001
+From: Chris Darroch <[email protected]>
+Date: Sun, 24 Aug 2025 21:17:41 -0700
+Subject: [PATCH] check for dir/symlink conflicts on checkout/pull
+
+From d02bd13f02ef76f6807581cd6b34709069cb3615 Mon Sep 17 00:00:00 2001
+From: Chris Darroch <[email protected]>
+Date: Wed, 13 Aug 2025 00:24:02 -0700
+Subject: [PATCH] fix bare repo pull/checkout path handling bug
+
+--- git-lfs-3.6.1.orig/commands/command_checkout.go
++++ git-lfs-3.6.1/commands/command_checkout.go
+@@ -3,12 +3,14 @@ package commands
+ import (
+ "fmt"
+ "os"
++ "path/filepath"
+
+ "github.com/git-lfs/git-lfs/v3/errors"
+ "github.com/git-lfs/git-lfs/v3/filepathfilter"
+ "github.com/git-lfs/git-lfs/v3/git"
+ "github.com/git-lfs/git-lfs/v3/lfs"
+ "github.com/git-lfs/git-lfs/v3/tasklog"
++ "github.com/git-lfs/git-lfs/v3/tools"
+ "github.com/git-lfs/git-lfs/v3/tq"
+ "github.com/git-lfs/git-lfs/v3/tr"
+ "github.com/spf13/cobra"
+@@ -24,6 +26,15 @@ var (
+ func checkoutCommand(cmd *cobra.Command, args []string) {
+ setupRepository()
+
++ // TODO: After suitable advance public notice, replace this block
++ // and the preceding call to setupRepository() with a single call to
++ // setupWorkingCopy(), which will perform the same check for a bare
++ // repository but will exit non-zero, as other commands already do.
++ if cfg.LocalWorkingDir() == "" {
++ Print(tr.Tr.Get("This operation must be run in a work tree."))
++ os.Exit(0)
++ }
++
+ stage, err := whichCheckout()
+ if err != nil {
+ Exit(tr.Tr.Get("Error parsing args: %v", err))
+@@ -92,6 +103,11 @@ func checkoutCommand(cmd *cobra.Command,
+ }
+
+ func checkoutConflict(file string, stage git.IndexStage) {
++ err := tools.MkdirAll(filepath.Dir(checkoutTo), cfg)
++ if err != nil {
++ Exit(tr.Tr.Get("Could not create path %q: %v", checkoutTo, err))
++ }
++
+ singleCheckout := newSingleCheckout(cfg.Git, "")
+ if singleCheckout.Skip() {
+ fmt.Println(tr.Tr.Get("Cannot checkout LFS objects, Git LFS is
not installed."))
+--- git-lfs-3.6.1.orig/commands/pull.go
++++ git-lfs-3.6.1/commands/pull.go
+@@ -12,6 +12,7 @@ import (
+ "github.com/git-lfs/git-lfs/v3/git"
+ "github.com/git-lfs/git-lfs/v3/lfs"
+ "github.com/git-lfs/git-lfs/v3/subprocess"
++ "github.com/git-lfs/git-lfs/v3/tools"
+ "github.com/git-lfs/git-lfs/v3/tq"
+ "github.com/git-lfs/git-lfs/v3/tr"
+ )
+@@ -33,6 +34,7 @@ func newSingleCheckout(gitEnv config.Env
+
+ return &singleCheckout{
+ gitIndexer: &gitIndexer{},
++ hasWorkTree: cfg.LocalWorkingDir() != "",
+ pathConverter: pathConverter,
+ manifest: nil,
+ remote: remote,
+@@ -49,6 +51,7 @@ type abstractCheckout interface {
+
+ type singleCheckout struct {
+ gitIndexer *gitIndexer
++ hasWorkTree bool
+ pathConverter lfs.PathConverter
+ manifest tq.Manifest
+ remote string
+@@ -66,10 +69,26 @@ func (c *singleCheckout) Skip() bool {
+ }
+
+ func (c *singleCheckout) Run(p *lfs.WrappedPointer) {
++ if !c.hasWorkTree {
++ return
++ }
++
+ cwdfilepath := c.pathConverter.Convert(p.Name)
+
+- // Check the content - either missing or still this pointer (not exist
is ok)
+- filepointer, err := lfs.DecodePointerFromFile(cwdfilepath)
++ dirWalker := tools.NewDirWalkerForFile("", p.Name, cfg)
++ err := dirWalker.Walk()
++
++ var filepointer *lfs.Pointer
++ if err != nil {
++ if !os.IsNotExist(err) {
++ LoggedError(err, tr.Tr.Get("Checkout error trying to
check path for %q: %s", p.Name, err))
++ return
++ }
++ } else {
++ // Check the content - either missing or still this pointer
(not exist is ok)
++ filepointer, err = lfs.DecodePointerFromFile(p.Name)
++ }
++
+ if err != nil {
+ if os.IsNotExist(err) {
+ output, err := git.DiffIndexWithPaths("HEAD", true,
[]string{p.Name})
+@@ -99,6 +118,13 @@ func (c *singleCheckout) Run(p *lfs.Wrap
+ return
+ }
+
++ if err != nil && os.IsNotExist(err) {
++ if err := dirWalker.WalkAndCreate(); err != nil {
++ LoggedError(err, tr.Tr.Get("Checkout error trying to
create path for %q: %s", p.Name, err))
++ return
++ }
++ }
++
+ if err := c.RunToPath(p, cwdfilepath); err != nil {
+ if errors.IsDownloadDeclinedError(err) {
+ // acceptable error, data not local (fetch not run or
include/exclude)
+--- git-lfs-3.6.1.orig/docs/man/git-lfs-checkout.adoc
++++ git-lfs-3.6.1/docs/man/git-lfs-checkout.adoc
+@@ -30,7 +30,12 @@ to a merge, this option checks out one o
+ Git LFS object into a separate file (which can be outside of the work
+ tree). This can make using diff tools to inspect and resolve merges
+ easier. A single Git LFS object's file path must be provided in
+-`<conflict-obj-path>`.
++`<conflict-obj-path>`. If `<file>` already exists, whether as a regular
++file, symbolic link, or directory, it will be removed and replaced, unless
++it is a non-empty directory or otherwise cannot be deleted.
++
++In a bare repository, this command has no effect. In a future version,
++this command may exit with an error if it is run in a bare repository.
+
+ == OPTIONS
+
+--- git-lfs-3.6.1.orig/docs/man/git-lfs-pull.adoc
++++ git-lfs-3.6.1/docs/man/git-lfs-pull.adoc
+@@ -17,6 +17,16 @@ This is equivalent to running the follow
+
+ git lfs fetch [options] [] git lfs checkout
+
++In a bare repository, if the installed Git version is at least 2.42.0,
++this command will by default fetch Git LFS objects for files only if
++they are present in the Git index and if they match a Git LFS filter
++attribute from a local `gitattributes` file such as
++`$GIT_DIR/info/attributes`. Any `.gitattributes` files in `HEAD` will
++be ignored, unless the `GIT_ATTR_SOURCE` environment variable is set
++to `HEAD`, and any `.gitattributes` files in the index or current
++working tree will always be ignored. These constraints do not apply
++with prior versions of Git.
++
+ == OPTIONS
+
+ `-I <paths>`::
+--- git-lfs-3.6.1.orig/lfs/gitfilter_smudge.go
++++ git-lfs-3.6.1/lfs/gitfilter_smudge.go
+@@ -16,23 +16,17 @@ import (
+ )
+
+ func (f *GitFilter) SmudgeToFile(filename string, ptr *Pointer, download
bool, manifest tq.Manifest, cb tools.CopyCallback) error {
+- tools.MkdirAll(filepath.Dir(filename), f.cfg)
+-
+- if stat, _ := os.Stat(filename); stat != nil {
++ // When no pointer file exists on disk, we should use the permissions
++ // defined for the file in Git, since the executable mode may be set.
++ // However, to conform with our legacy behaviour, we do not do this
++ // at present.
++ var mode os.FileMode = 0666
++ if stat, _ := os.Lstat(filename); stat != nil &&
stat.Mode().IsRegular() {
+ if ptr.Size == 0 && stat.Size() == 0 {
+ return nil
+ }
+
+- if stat.Mode()&0200 == 0 {
+- if err := os.Chmod(filename, stat.Mode()|0200); err !=
nil {
+- return errors.Wrap(err,
+- tr.Tr.Get("Could not restore write
permission"))
+- }
+-
+- // When we're done, return the file back to its normal
+- // permission bits.
+- defer os.Chmod(filename, stat.Mode())
+- }
++ mode = stat.Mode().Perm()
+ }
+
+ abs, err := filepath.Abs(filename)
+@@ -40,9 +34,13 @@ func (f *GitFilter) SmudgeToFile(filenam
+ return errors.New(tr.Tr.Get("could not produce absolute path
for %q", filename))
+ }
+
+- file, err := os.Create(abs)
++ if err := os.Remove(abs); err != nil && !os.IsNotExist(err) {
++ return errors.Wrap(err, tr.Tr.Get("could not remove working
directory file %q", filename))
++ }
++
++ file, err := os.OpenFile(abs, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
+ if err != nil {
+- return errors.New(tr.Tr.Get("could not create working directory
file: %v", err))
++ return errors.Wrap(err, tr.Tr.Get("could not create working
directory file %q", filename))
+ }
+ defer file.Close()
+ if _, err := f.Smudge(file, ptr, filename, download, manifest, cb); err
!= nil {
+--- git-lfs-3.6.1.orig/t/t-checkout.sh
++++ git-lfs-3.6.1/t/t-checkout.sh
+@@ -114,6 +114,64 @@ begin_test "checkout"
+ )
+ end_test
+
++begin_test "checkout: break hard links to existing files"
++(
++ set -e
++
++ reponame="checkout-break-file-hardlinks"
++ setup_remote_repo "$reponame"
++ clone_repo "$reponame" "$reponame"
++
++ git lfs track "*.dat"
++
++ contents="a"
++ contents_oid="$(calc_oid "$contents")"
++ mkdir -p dir1/dir2/dir3
++ printf "%s" "$contents" >a.dat
++ printf "%s" "$contents" >dir1/dir2/dir3/a.dat
++
++ git add .gitattributes a.dat dir1
++ git commit -m "initial commit"
++
++ git push origin main
++ assert_server_object "$reponame" "$contents_oid"
++
++ cd ..
++ GIT_LFS_SKIP_SMUDGE=1 git clone "$GITSERVER/$reponame" "${reponame}-assert"
++
++ cd "${reponame}-assert"
++ git lfs fetch origin main
++
++ assert_local_object "$contents_oid" 1
++
++ rm -f a.dat dir1/dir2/dir3/a.dat ../link
++ pointer="$(git cat-file -p ":a.dat")"
++ echo "$pointer" >../link
++ ln ../link a.dat
++ ln ../link dir1/dir2/dir3/a.dat
++
++ git lfs checkout
++
++ [ "$contents" = "$(cat a.dat)" ]
++ [ "$contents" = "$(cat dir1/dir2/dir3/a.dat)" ]
++ [ "$pointer" = "$(cat ../link)" ]
++ assert_clean_status
++
++ rm a.dat dir1/dir2/dir3/a.dat
++ ln ../link a.dat
++ ln ../link dir1/dir2/dir3/a.dat
++
++ pushd dir1/dir2
++ git lfs checkout
++ popd
++
++ [ "$contents" = "$(cat a.dat)" ]
++ [ "$contents" = "$(cat dir1/dir2/dir3/a.dat)" ]
++ [ "$pointer" = "$(cat ../link)" ]
++ assert_clean_status
++)
++end_test
++
+ begin_test "checkout: without clean filter"
+ (
+ set -e
+@@ -249,6 +307,36 @@ begin_test "checkout: conflicts"
+ echo "abc123" | cmp - theirs.txt
+ echo "def456" | cmp - ours.txt
+
++ rm -f base.txt link1 ../ours.txt ../link2
++ ln -s link1 base.txt
++ ln -s link2 ../ours.txt
++
++ git lfs checkout --to base.txt --base file1.dat
++ git lfs checkout --to ../ours.txt --ours file1.dat
++
++ [ ! -L "base.txt" ]
++ [ ! -L "../ours.txt" ]
++ [ ! -e "link1" ]
++ [ ! -e "../link2" ]
++ echo "file1.dat" | cmp - base.txt
++ echo "def456" | cmp - ../ours.txt
++
++ rm -f base.txt link1 ../ours.txt ../link2
++ printf "link1" >link1
++ printf "link2" >../link2
++ ln link1 base.txt
++ ln ../link2 ../ours.txt
++
++ git lfs checkout --to base.txt --base file1.dat
++ git lfs checkout --to ../ours.txt --ours file1.dat
++
++ [ -f "link1" ]
++ [ -f "../link2" ]
++ [ "link1" = "$(cat link1)" ]
++ [ "link2" = "$(cat ../link2)" ]
++ echo "file1.dat" | cmp - base.txt
++ echo "def456" | cmp - ../ours.txt
++
+ git lfs checkout --to base.txt --ours other.txt 2>&1 | tee output.txt
+ grep 'Could not find decoder pointer for object' output.txt
+ popd > /dev/null
+@@ -281,6 +369,23 @@ begin_test "checkout: GIT_WORK_TREE"
+ )
+ end_test
+
++begin_test "checkout: bare repository"
++(
++ set -e
++
++ reponame="checkout-bare"
++ git init --bare "$reponame"
++ cd "$reponame"
++
++ git lfs checkout 2>&1 | tee checkout.log
++ if [ "0" -ne "${PIPESTATUS[0]}" ]; then
++ echo >&2 "fatal: expected checkout to succeed ..."
++ exit 1
++ fi
++ [ "This operation must be run in a work tree." = "$(cat checkout.log)" ]
++)
++end_test
++
+ begin_test "checkout: sparse with partial clone and sparse index"
+ (
+ set -e
+--- git-lfs-3.6.1.orig/t/t-pull.sh
++++ git-lfs-3.6.1/t/t-pull.sh
+@@ -157,6 +157,67 @@ begin_test "pull"
+ )
+ end_test
+
++begin_test "pull: break hard links to existing files"
++(
++ set -e
++
++ reponame="pull-break-file-hardlinks"
++ setup_remote_repo "$reponame"
++ clone_repo "$reponame" "$reponame"
++
++ git lfs track "*.dat"
++
++ contents="a"
++ contents_oid="$(calc_oid "$contents")"
++ mkdir -p dir1/dir2/dir3
++ printf "%s" "$contents" >a.dat
++ printf "%s" "$contents" >dir1/dir2/dir3/a.dat
++
++ git add .gitattributes a.dat dir1
++ git commit -m "initial commit"
++
++ git push origin main
++ assert_server_object "$reponame" "$contents_oid"
++
++ cd ..
++ GIT_LFS_SKIP_SMUDGE=1 git clone "$GITSERVER/$reponame" "${reponame}-assert"
++
++ cd "${reponame}-assert"
++ refute_local_object "$contents_oid" 1
++
++ rm -f a.dat dir1/dir2/dir3/a.dat ../link
++ pointer="$(git cat-file -p ":a.dat")"
++ echo "$pointer" >../link
++ ln ../link a.dat
++ ln ../link dir1/dir2/dir3/a.dat
++
++ git lfs pull
++ assert_local_object "$contents_oid" 1
++
++ [ "$contents" = "$(cat a.dat)" ]
++ [ "$contents" = "$(cat dir1/dir2/dir3/a.dat)" ]
++ [ "$pointer" = "$(cat ../link)" ]
++ assert_clean_status
++
++ rm a.dat dir1/dir2/dir3/a.dat
++ ln ../link a.dat
++ ln ../link dir1/dir2/dir3/a.dat
++
++ rm -rf .git/lfs/objects
++
++ pushd dir1/dir2
++ git lfs pull
++ popd
++
++ assert_local_object "$contents_oid" 1
++
++ [ "$contents" = "$(cat a.dat)" ]
++ [ "$contents" = "$(cat dir1/dir2/dir3/a.dat)" ]
++ [ "$pointer" = "$(cat ../link)" ]
++ assert_clean_status
++)
++end_test
++
+ begin_test "pull without clean filter"
+ (
+ set -e
+@@ -393,6 +454,137 @@ begin_test "pull with empty file doesn't
+ )
+ end_test
+
++begin_test "pull: bare repository"
++(
++ set -e
++
++ reponame="pull-bare"
++ setup_remote_repo "$reponame"
++ clone_repo "$reponame" "$reponame"
++
++ git lfs track "*.dat"
++
++ contents="a"
++ contents_oid="$(calc_oid "$contents")"
++ printf "%s" "$contents" >a.dat
++
++ # The "git lfs pull" command should never check out files in a bare
++ # repository, either into a directory within the repository or one
++ # outside it. To verify this, we add a Git LFS pointer file whose path
++ # inside the repository is one which, if it were instead treated as an
++ # absolute filesystem path, corresponds to a writable directory.
++ # The "git lfs pull" command should not check out files into either
++ # this external directory or the bare repository.
++ external_dir="$TRASHDIR/${reponame}-external"
++ internal_dir="$(printf "%s" "$external_dir" | sed 's/^\/*//')"
++ mkdir -p "$internal_dir"
++ printf "%s" "$contents" >"$internal_dir/a.dat"
++
++ git add .gitattributes a.dat "$internal_dir/a.dat"
++ git commit -m "initial commit"
++
++ git push origin main
++ assert_server_object "$reponame" "$contents_oid"
++
++ cd ..
++ git clone --bare "$GITSERVER/$reponame" "${reponame}-assert"
++
++ cd "${reponame}-assert"
++ [ ! -e lfs ]
++ refute_local_object "$contents_oid"
++
++ git lfs pull 2>&1 | tee pull.log
++ if [ "0" -ne "${PIPESTATUS[0]}" ]; then
++ echo >&2 "fatal: expected pull to succeed ..."
++ exit 1
++ fi
++
++ # When Git version 2.42.0 or higher is available, the "git lfs pull"
++ # command will use the "git ls-files" command rather than the
++ # "git ls-tree" command to list files. By default a bare repository
++ # lacks an index, so we expect no Git LFS objects to be fetched when
++ # "git ls-files" is used because Git v2.42.0 or higher is available.
++ gitversion="$(git version | cut -d" " -f3)"
++ set +e
++ compare_version "$gitversion" '2.42.0'
++ result=$?
++ set -e
++ if [ "$result" -eq "$VERSION_LOWER" ]; then
++ grep "Downloading LFS objects" pull.log
++
++ assert_local_object "$contents_oid" 1
++ else
++ grep -q "Downloading LFS objects" pull.log && exit 1
++
++ refute_local_object "$contents_oid"
++ fi
++
++ [ ! -e "a.dat" ]
++ [ ! -e "$internal_dir/a.dat" ]
++ [ ! -e "$external_dir/a.dat" ]
++
++ rm -rf lfs/objects
++ refute_local_object "$contents_oid"
++
++ # When Git version 2.42.0 or higher is available, the "git lfs pull"
++ # command will use the "git ls-files" command rather than the
++ # "git ls-tree" command to list files. By default a bare repository
++ # lacks an index, so we expect no Git LFS objects to be fetched when
++ # "git ls-files" is used because Git v2.42.0 or higher is available.
++ #
++ # Therefore to verify that the "git lfs pull" command never checks out
++ # files in a bare repository, we first populate the index with Git LFS
++ # pointer files and then retry the command.
++ contents_git_oid="$(git ls-tree HEAD a.dat | awk '{ print $3 }')"
++ git update-index --add --cacheinfo 100644 "$contents_git_oid" a.dat
++ git update-index --add --cacheinfo 100644 "$contents_git_oid"
"$internal_dir/a.dat"
++
++ # When Git version 2.42.0 or higher is available, the "git lfs pull"
++ # command will use the "git ls-files" command rather than the
++ # "git ls-tree" command to list files, and does so by passing an
++ # "attr:filter=lfs" pathspec to the "git ls-files" command so it only
++ # lists files which match that filter attribute.
++ #
++ # In a bare repository, however, the "git ls-files" command will not read
++ # attributes from ".gitattributes" files in the index, so by default it
++ # will not list any Git LFS pointer files even if those files and the
++ # corresponding ".gitattributes" files have been added to the index and
++ # the pointer files would otherwise match the "attr:filter=lfs" pathspec.
++ #
++ # Therefore, instead of adding the ".gitattributes" file to the index, we
++ # copy it to "info/attributes" so that the pathspec filter will match our
++ # pointer file index entries and they will be listed by the "git ls-files"
++ # command. This allows us to verify that with Git v2.42.0 or higher, the
++ # "git lfs pull" command will fetch the objects for these pointer files
++ # in the index when the command is run in a bare repository.
++ #
++ # Note that with older versions of Git, the "git lfs pull" command will
++ # use the "git ls-tree" command to list the files in the tree referenced
++ # by HEAD. The Git LFS objects for any well-formed pointer files found in
++ # that list will then be fetched (unless local copies already exist),
++ # regardless of whether the pointer files actually match a "filter=lfs"
++ # attribute in any ".gitattributes" file in the index, the tree
++ # referenced by HEAD, or the current work tree.
++ if [ "$result" -ne "$VERSION_LOWER" ]; then
++ mkdir -p info
++ git show HEAD:.gitattributes >info/attributes
++ fi
++
++ git lfs pull 2>&1 | tee pull.log
++ if [ "0" -ne "${PIPESTATUS[0]}" ]; then
++ echo >&2 "fatal: expected pull to succeed ..."
++ exit 1
++ fi
++ grep "Downloading LFS objects" pull.log
++
++ assert_local_object "$contents_oid" 1
++
++ [ ! -e "a.dat" ]
++ [ ! -e "$internal_dir/a.dat" ]
++ [ ! -e "$external_dir/a.dat" ]
++)
++end_test
++
+ begin_test "pull with partial clone and sparse checkout and index"
+ (
+ set -e
+--- /dev/null
++++ git-lfs-3.6.1/tools/dir_walker.go
+@@ -0,0 +1,142 @@
++package tools
++
++import (
++ "os"
++ "strings"
++
++ "github.com/git-lfs/git-lfs/v3/errors"
++ "github.com/git-lfs/git-lfs/v3/tr"
++)
++
++var (
++ errInvalidDir = errors.New(tr.Tr.Get("invalid directory"))
++ errNotDir = errors.New(tr.Tr.Get("not a directory"))
++)
++
++type DirWalker struct {
++ parentPath string
++ path string
++ config repositoryPermissionFetcher
++}
++
++// The parentPath parameter is assumed to be a valid path to a directory
++// in the filesystem.
++//
++// The filePath parameter must be a relative file path as provided by Git,
++// with only the "/" character as a separator and no empty or "." or ".."
++// path segments. Absolute paths are not supported.
++func NewDirWalkerForFile(parentPath string, filePath string, config
repositoryPermissionFetcher) *DirWalker {
++ var path string
++ i := strings.LastIndexByte(filePath, '/')
++ if i >= 0 {
++ path = filePath[0:i]
++ }
++
++ return &DirWalker{
++ parentPath: parentPath,
++ path: path,
++ config: config,
++ }
++}
++
++// walk() checks each directory in a relative path, starting from the
++// initial parent path, and optionally creates any missing directories
++// in the path.
++//
++// If an existing file or something else other than a directory conflicts
++// with a directory in the path, walk() returns an error.
++//
++// If the create option is false, walk() returns ErrNotExist when a
++// directory is not found.
++//
++// Note that for performance reasons and to be consistent with Git's
++// implementation, walk() does not guard against TOCTOU (time-of-check/
++// time-of-use) races, as the methods of the os.Root type do.
++func (w *DirWalker) walk(create bool) error {
++ currentPath := w.parentPath
++
++ n := len(w.path)
++ for n > 0 {
++ currentDir := w.path
++ nextDirIndex := n
++ i := strings.IndexByte(w.path, '/')
++ if i >= 0 {
++ currentDir = w.path[0:i]
++ nextDirIndex = i + 1
++ }
++
++ // These should never occur in Git paths.
++ if currentDir == "" || currentDir == "." || currentDir == ".." {
++ return joinErrors(errors.New(tr.Tr.Get("invalid
directory %q in path: %q", currentDir, w.path)), errInvalidDir)
++ }
++
++ if currentPath == "" {
++ currentPath = currentDir
++ } else {
++ currentPath += "/" + currentDir
++ }
++
++ stat, err := os.Lstat(currentPath)
++ if err != nil {
++ if !os.IsNotExist(err) || !create {
++ return err
++ }
++
++ err = Mkdir(currentPath, w.config)
++ if err != nil {
++ return err
++ }
++ } else if !stat.Mode().IsDir() {
++ return joinErrors(errors.New(tr.Tr.Get("not a
directory: %q", currentPath)), errNotDir)
++ }
++
++ w.parentPath = currentPath
++ w.path = w.path[nextDirIndex:]
++ n -= nextDirIndex
++ }
++
++ return nil
++}
++
++func (w *DirWalker) Walk() error {
++ return w.walk(false)
++}
++
++func (w *DirWalker) WalkAndCreate() error {
++ return w.walk(true)
++}
++
++type joinError struct {
++ errs []error
++}
++
++func (e *joinError) Error() string {
++ var b []byte
++ for i, err := range e.errs {
++ if i > 0 {
++ b = append(b, '\n')
++ }
++ b = append(b, err.Error()...)
++ }
++ return string(b)
++}
++
++func (e *joinError) Unwrap() []error {
++ return e.errs
++}
++
++func joinErrors(errs ...error) error {
++ var validErrs []error
++ for _, err := range errs {
++ if err != nil {
++ validErrs = append(validErrs, err)
++ }
++ }
++ if len(validErrs) == 0 {
++ return nil
++ }
++ if len(validErrs) == 1 {
++ return validErrs[0]
++ }
++ return &joinError{errs: validErrs}
++}
+--- /dev/null
++++ git-lfs-3.6.1/tools/dir_walker_test.go
+@@ -0,0 +1,473 @@
++package tools
++
++import (
++ "errors"
++ "fmt"
++ "os"
++ "testing"
++
++ "github.com/stretchr/testify/assert"
++ "github.com/stretchr/testify/require"
++)
++
++type newDirWalkerForFileTestCase struct {
++ filePath string
++ expectedDirPath string
++}
++
++func (c *newDirWalkerForFileTestCase) Assert(t *testing.T) {
++ w := NewDirWalkerForFile("", c.filePath, nil)
++ assert.Equal(t, c.expectedDirPath, w.path)
++}
++
++func TestNewDirWalkerForFile(t *testing.T) {
++ for desc, c := range map[string]*newDirWalkerForFileTestCase{
++ "filename only": {"foo.bin", ""},
++ "path with one dir": {"abc/foo.bin", "abc"},
++ "path with two dirs": {"abc/def/foo.bin", "abc/def"},
++ "path with leading slash": {"/foo.bin", ""},
++ "path with trailing slash": {"abc/", "abc"},
++ "bare slash": {"/", ""},
++ "empty path": {"", ""},
++ } {
++ t.Run(desc, c.Assert)
++ }
++}
++
++type dirWalkerTestConfig struct{}
++
++func (c *dirWalkerTestConfig) RepositoryPermissions(executable bool)
os.FileMode {
++ return os.FileMode(0755)
++}
++
++type dirWalkerWalkTestCase struct {
++ parentPath string
++ path string
++ create bool
++
++ existsPath string
++ existsFile string
++ existsLink string
++
++ expectedParentPath string
++ expectedPath string
++ expectedErr error
++
++ walker *DirWalker
++}
++
++func (c *dirWalkerWalkTestCase) prependParentPath(path string) string {
++ if path == "" {
++ return c.parentPath
++ } else if c.parentPath == "" {
++ return path
++ } else if path[0] == '/' {
++ return "/" + c.parentPath + path
++ } else {
++ return c.parentPath + "/" + path
++ }
++}
++
++func (c *dirWalkerWalkTestCase) setupPaths(t *testing.T, parentPath string)
error {
++ c.parentPath = parentPath
++
++ if parentPath != "" {
++ if err := os.MkdirAll(parentPath, 0755); err != nil {
++ return fmt.Errorf("unable to create path: %w", err)
++ }
++ }
++
++ if c.existsPath != "" {
++ c.existsPath = c.prependParentPath(c.existsPath)
++ if err := os.MkdirAll(c.existsPath, 0755); err != nil {
++ return fmt.Errorf("unable to create path: %w", err)
++ }
++ }
++
++ if c.existsFile != "" {
++ c.existsFile = c.prependParentPath(c.existsFile)
++ f, err := os.Create(c.existsFile)
++ if err != nil {
++ return fmt.Errorf("unable to create file: %w", err)
++ }
++ f.Close()
++ }
++
++ if c.existsLink != "" {
++ c.existsLink = c.prependParentPath(c.existsLink)
++ if err := os.Symlink(t.TempDir(), c.existsLink); err != nil {
++ return fmt.Errorf("unable to create symbolic link: %w",
err)
++ }
++ }
++
++ c.expectedParentPath = c.prependParentPath(c.expectedParentPath)
++
++ return nil
++}
++
++func (c *dirWalkerWalkTestCase) Assert(t *testing.T) {
++ c.walker.parentPath = c.parentPath
++ c.walker.path = c.path
++
++ err := c.walker.walk(c.create)
++
++ assert.Equal(t, c.expectedParentPath, c.walker.parentPath, "found path
does not match")
++ assert.Equal(t, c.expectedPath, c.walker.path, "missing path does not
match")
++ if c.expectedErr == nil {
++ assert.NoError(t, err)
++ } else {
++ assert.Error(t, err)
++ assert.True(t, errors.Is(err, c.expectedErr), "wrong error
type")
++ }
++}
++
++func TestDirWalkerWalk(t *testing.T) {
++ wd, err := os.Getwd()
++ require.NoError(t, err)
++
++ defer os.Chdir(wd)
++
++ for desc, c := range map[string]*dirWalkerWalkTestCase{
++ "empty path": {},
++ "one extant dir": {
++ path: "abc",
++ existsPath: "abc",
++ expectedParentPath: "abc",
++ },
++ "one missing dir": {
++ path: "abc",
++ expectedPath: "abc",
++ expectedErr: os.ErrNotExist,
++ },
++ "two extant dirs": {
++ path: "abc/def",
++ existsPath: "abc/def",
++ expectedParentPath: "abc/def",
++ },
++ "two missing dirs": {
++ path: "abc/def",
++ expectedPath: "abc/def",
++ expectedErr: os.ErrNotExist,
++ },
++ "three extant dirs": {
++ path: "abc/def/ghi",
++ existsPath: "abc/def/ghi",
++ expectedParentPath: "abc/def/ghi",
++ },
++ "three missing dirs": {
++ path: "abc/def/ghi",
++ expectedPath: "abc/def/ghi",
++ expectedErr: os.ErrNotExist,
++ },
++ "one extant dir and one missing dir": {
++ path: "abc/def",
++ existsPath: "abc",
++ expectedParentPath: "abc",
++ expectedPath: "def",
++ expectedErr: os.ErrNotExist,
++ },
++ "one extant dir and two missing dirs": {
++ path: "abc/def/ghi",
++ existsPath: "abc",
++ expectedParentPath: "abc",
++ expectedPath: "def/ghi",
++ expectedErr: os.ErrNotExist,
++ },
++ "two extant dirs and one missing dir": {
++ path: "abc/def/ghi",
++ existsPath: "abc/def",
++ expectedParentPath: "abc/def",
++ expectedPath: "ghi",
++ expectedErr: os.ErrNotExist,
++ },
++ "one missing dir with trailing slash": {
++ path: "abc/",
++ expectedPath: "abc/",
++ expectedErr: os.ErrNotExist,
++ },
++ "one extant dir with trailing slash": {
++ path: "abc/",
++ existsPath: "abc",
++ expectedParentPath: "abc",
++ },
++ "two extant dirs with trailing slash": {
++ path: "abc/def/",
++ existsPath: "abc/def",
++ expectedParentPath: "abc/def",
++ },
++ "one extant dir and one missing dir with trailing slash": {
++ path: "abc/def/",
++ existsPath: "abc",
++ expectedParentPath: "abc",
++ expectedPath: "def/",
++ expectedErr: os.ErrNotExist,
++ },
++ "one conflicting file": {
++ path: "abc",
++ existsFile: "abc",
++ expectedPath: "abc",
++ expectedErr: errNotDir,
++ },
++ "one extant dir and one conflicting file": {
++ path: "abc/def",
++ existsPath: "abc",
++ existsFile: "abc/def",
++ expectedParentPath: "abc",
++ expectedPath: "def",
++ expectedErr: errNotDir,
++ },
++ "two extant dirs and one conflicting file": {
++ path: "abc/def/ghi",
++ existsPath: "abc/def",
++ existsFile: "abc/def/ghi",
++ expectedParentPath: "abc/def",
++ expectedPath: "ghi",
++ expectedErr: errNotDir,
++ },
++ "one extant dir, one conflicting file, and one missing dir": {
++ path: "abc/def/ghi",
++ existsPath: "abc",
++ existsFile: "abc/def",
++ expectedParentPath: "abc",
++ expectedPath: "def/ghi",
++ expectedErr: errNotDir,
++ },
++ "one conflicting symlink": {
++ path: "abc",
++ existsLink: "abc",
++ expectedPath: "abc",
++ expectedErr: errNotDir,
++ },
++ "one extant dir and one conflicting symlink": {
++ path: "abc/def",
++ existsPath: "abc",
++ existsLink: "abc/def",
++ expectedParentPath: "abc",
++ expectedPath: "def",
++ expectedErr: errNotDir,
++ },
++ "two extant dirs and one conflicting symlink": {
++ path: "abc/def/ghi",
++ existsPath: "abc/def",
++ existsLink: "abc/def/ghi",
++ expectedParentPath: "abc/def",
++ expectedPath: "ghi",
++ expectedErr: errNotDir,
++ },
++ "one extant dir, one conflicting symlink, and one missing dir":
{
++ path: "abc/def/ghi",
++ existsPath: "abc",
++ existsLink: "abc/def",
++ expectedParentPath: "abc",
++ expectedPath: "def/ghi",
++ expectedErr: errNotDir,
++ },
++ "one extant dir (not modified)": {
++ path: "abc",
++ create: true,
++ existsPath: "abc",
++ expectedParentPath: "abc",
++ },
++ "one created dir": {
++ path: "abc",
++ create: true,
++ expectedParentPath: "abc",
++ },
++ "two extant dirs (not modified)": {
++ path: "abc/def",
++ create: true,
++ existsPath: "abc/def",
++ expectedParentPath: "abc/def",
++ },
++ "two created dirs": {
++ path: "abc/def",
++ create: true,
++ expectedParentPath: "abc/def",
++ },
++ "three extant dirs (not modified)": {
++ path: "abc/def/ghi",
++ create: true,
++ existsPath: "abc/def/ghi",
++ expectedParentPath: "abc/def/ghi",
++ },
++ "three created dirs": {
++ path: "abc/def/ghi",
++ create: true,
++ expectedParentPath: "abc/def/ghi",
++ },
++ "one extant dir and one created dir": {
++ path: "abc/def",
++ create: true,
++ existsPath: "abc",
++ expectedParentPath: "abc/def",
++ },
++ "one extant dir and two created dirs": {
++ path: "abc/def/ghi",
++ create: true,
++ existsPath: "abc",
++ expectedParentPath: "abc/def/ghi",
++ },
++ "two extant dirs and one created dir": {
++ path: "abc/def/ghi",
++ create: true,
++ existsPath: "abc/def",
++ expectedParentPath: "abc/def/ghi",
++ },
++ "one created dir with trailing slash": {
++ path: "abc/",
++ create: true,
++ expectedParentPath: "abc",
++ },
++ "one extant dir with trailing slash (not modified)": {
++ path: "abc/",
++ create: true,
++ existsPath: "abc",
++ expectedParentPath: "abc",
++ },
++ "two extant dirs with trailing slash (not modified)": {
++ path: "abc/def/",
++ create: true,
++ existsPath: "abc/def",
++ expectedParentPath: "abc/def",
++ },
++ "one extant dir and one created dir with trailing slash": {
++ path: "abc/def/",
++ create: true,
++ existsPath: "abc",
++ expectedParentPath: "abc/def",
++ },
++ "one conflicting file (not modified)": {
++ path: "abc",
++ create: true,
++ existsFile: "abc",
++ expectedPath: "abc",
++ expectedErr: errNotDir,
++ },
++ "one extant dir and one conflicting file (not modified)": {
++ path: "abc/def",
++ create: true,
++ existsPath: "abc",
++ existsFile: "abc/def",
++ expectedParentPath: "abc",
++ expectedPath: "def",
++ expectedErr: errNotDir,
++ },
++ "two extant dirs and one conflicting file (not modified)": {
++ path: "abc/def/ghi",
++ create: true,
++ existsPath: "abc/def",
++ existsFile: "abc/def/ghi",
++ expectedParentPath: "abc/def",
++ expectedPath: "ghi",
++ expectedErr: errNotDir,
++ },
++ "one extant dir, one conflicting file, and one missing dir (not
modified)": {
++ path: "abc/def/ghi",
++ create: true,
++ existsPath: "abc",
++ existsFile: "abc/def",
++ expectedParentPath: "abc",
++ expectedPath: "def/ghi",
++ expectedErr: errNotDir,
++ },
++ "one conflicting symlink (not modified)": {
++ path: "abc",
++ create: true,
++ existsLink: "abc",
++ expectedPath: "abc",
++ expectedErr: errNotDir,
++ },
++ "one extant dir and one conflicting symlink (not modified)": {
++ path: "abc/def",
++ create: true,
++ existsPath: "abc",
++ existsLink: "abc/def",
++ expectedParentPath: "abc",
++ expectedPath: "def",
++ expectedErr: errNotDir,
++ },
++ "two extant dirs and one conflicting symlink (not modified)": {
++ path: "abc/def/ghi",
++ create: true,
++ existsPath: "abc/def",
++ existsLink: "abc/def/ghi",
++ expectedParentPath: "abc/def",
++ expectedPath: "ghi",
++ expectedErr: errNotDir,
++ },
++ "one extant dir, one conflicting symlink, and one missing dir
(not modified)": {
++ path: "abc/def/ghi",
++ create: true,
++ existsPath: "abc",
++ existsLink: "abc/def",
++ expectedParentPath: "abc",
++ expectedPath: "def/ghi",
++ expectedErr: errNotDir,
++ },
++ "invalid bare slash": {
++ path: "/",
++ expectedPath: "/",
++ expectedErr: errInvalidDir,
++ },
++ "invalid multiple slashes": {
++ path: "abc//def",
++ existsPath: "abc",
++ expectedParentPath: "abc",
++ expectedPath: "/def",
++ expectedErr: errInvalidDir,
++ },
++ "invalid leading slash": {
++ path: "/abc",
++ existsPath: "abc",
++ expectedPath: "/abc",
++ expectedErr: errInvalidDir,
++ },
++ "invalid bare dot component": {
++ path: ".",
++ expectedPath: ".",
++ expectedErr: errInvalidDir,
++ },
++ "invalid dot component": {
++ path: "abc/./def",
++ existsPath: "abc/def",
++ expectedParentPath: "abc",
++ expectedPath: "./def",
++ expectedErr: errInvalidDir,
++ },
++ "invalid bare double-dot component": {
++ path: "..",
++ expectedPath: "..",
++ expectedErr: errInvalidDir,
++ },
++ "invalid double-dot component": {
++ path: "abc/../def",
++ existsPath: "abc",
++ expectedParentPath: "abc",
++ expectedPath: "../def",
++ expectedErr: errInvalidDir,
++ },
++ } {
++ if err := os.Chdir(t.TempDir()); err != nil {
++ t.Errorf("unable to change directory: %s", err)
++ }
++
++ c.walker = &DirWalker{
++ config: &dirWalkerTestConfig{},
++ }
++
++ if err := c.setupPaths(t, ""); err != nil {
++ t.Error(err)
++ continue
++ }
++
++ t.Run(desc, c.Assert)
++
++ // retest with parent path; note that this alters the test case
++ if err := c.setupPaths(t, "foo/bar"); err != nil {
++ t.Error(err)
++ continue
++ }
++
++ t.Run(desc+" with parent path", c.Assert)
++ }
++}
+--- git-lfs-3.6.1.orig/tools/filetools.go
++++ git-lfs-3.6.1/tools/filetools.go
+@@ -121,6 +121,15 @@ type repositoryPermissionFetcher interfa
+ RepositoryPermissions(executable bool) os.FileMode
+ }
+
++// Mkdir makes a directory with the
++// permissions specified by the core.sharedRepository setting.
++func Mkdir(path string, config repositoryPermissionFetcher) error {
++ umask := 0777 & ^config.RepositoryPermissions(true)
++ return doWithUmask(int(umask), func() error {
++ return os.Mkdir(path, config.RepositoryPermissions(true))
++ })
++}
++
+ // MkdirAll makes a directory and any intervening directories with the
+ // permissions specified by the core.sharedRepository setting.
+ func MkdirAll(path string, config repositoryPermissionFetcher) error {
diff -Nru git-lfs-3.6.1/debian/patches/series
git-lfs-3.6.1/debian/patches/series
--- git-lfs-3.6.1/debian/patches/series 1970-01-01 01:00:00.000000000 +0100
+++ git-lfs-3.6.1/debian/patches/series 2026-04-10 10:13:30.000000000 +0200
@@ -0,0 +1 @@
+CVE-2025-26625.patch
--- End Message ---