Version in base suite: 3.6.1-1 Base version: git-lfs_3.6.1-1 Target version: git-lfs_3.6.1-1+deb13u1 Base file: /srv/ftp-master.debian.org/ftp/pool/main/g/git-lfs/git-lfs_3.6.1-1.dsc Target file: /srv/ftp-master.debian.org/policy/pool/main/g/git-lfs/git-lfs_3.6.1-1+deb13u1.dsc changelog | 6 patches/CVE-2025-26625.patch | 1190 +++++++++++++++++++++++++++++++++++++++++++ patches/series | 1 3 files changed, 1197 insertions(+) dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmplnpmms11/git-lfs_3.6.1-1.dsc: no acceptable signature found dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmplnpmms11/git-lfs_3.6.1-1+deb13u1.dsc: no acceptable signature found 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 06:34:17.000000000 +0000 +++ git-lfs-3.6.1/debian/changelog 2026-04-10 15:17:50.000000000 +0000 @@ -1,3 +1,9 @@ +git-lfs (3.6.1-1+deb13u1) trixie; urgency=medium + + * CVE-2025-26625 (Closes: #1118339) + + -- Moritz Mühlenhoff 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 00:00:00.000000000 +0000 +++ git-lfs-3.6.1/debian/patches/CVE-2025-26625.patch 2026-04-10 08:14:08.000000000 +0000 @@ -0,0 +1,1190 @@ +Backports of: + +From 5c11ffce9a4f095ff356bc781e2a031abb46c1a8 Mon Sep 17 00:00:00 2001 +From: Chris Darroch +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 +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 +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 +-``. ++``. If `` 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 `:: +--- 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 00:00:00.000000000 +0000 +++ git-lfs-3.6.1/debian/patches/series 2026-04-10 08:13:30.000000000 +0000 @@ -0,0 +1 @@ +CVE-2025-26625.patch