Skip to content

Commit 8dbf13b

Browse files
delvhwxiaoguang
andauthored
Follow file symlinks in the UI to their target (#28835)
Symlinks are followed when you click on a link next to an entry, either until a file has been found or until we know that the link is dead. When the link cannot be accessed, we fall back to the current behavior of showing the document containing the target. --------- Co-authored-by: wxiaoguang <[email protected]>
1 parent a94e472 commit 8dbf13b

File tree

22 files changed

+230
-193
lines changed

22 files changed

+230
-193
lines changed

modules/fileicon/entry.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,17 @@ package fileicon
66
import "code.gitea.io/gitea/modules/git"
77

88
type EntryInfo struct {
9-
FullName string
9+
BaseName string
1010
EntryMode git.EntryMode
1111
SymlinkToMode git.EntryMode
1212
IsOpen bool
1313
}
1414

15-
func EntryInfoFromGitTreeEntry(gitEntry *git.TreeEntry) *EntryInfo {
16-
ret := &EntryInfo{FullName: gitEntry.Name(), EntryMode: gitEntry.Mode()}
15+
func EntryInfoFromGitTreeEntry(commit *git.Commit, fullPath string, gitEntry *git.TreeEntry) *EntryInfo {
16+
ret := &EntryInfo{BaseName: gitEntry.Name(), EntryMode: gitEntry.Mode()}
1717
if gitEntry.IsLink() {
18-
if te, err := gitEntry.FollowLink(); err == nil && te.IsDir() {
19-
ret.SymlinkToMode = te.Mode()
18+
if res, err := git.EntryFollowLink(commit, fullPath, gitEntry); err == nil && res.TargetEntry.IsDir() {
19+
ret.SymlinkToMode = res.TargetEntry.Mode()
2020
}
2121
}
2222
return ret

modules/fileicon/material.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ package fileicon
55

66
import (
77
"html/template"
8-
"path"
98
"strings"
109
"sync"
1110

@@ -134,7 +133,7 @@ func (m *MaterialIconProvider) FindIconName(entry *EntryInfo) string {
134133
return "folder-git"
135134
}
136135

137-
fileNameLower := strings.ToLower(path.Base(entry.FullName))
136+
fileNameLower := strings.ToLower(entry.BaseName)
138137
if entry.EntryMode.IsDir() {
139138
if s, ok := m.rules.FolderNames[fileNameLower]; ok {
140139
return s

modules/fileicon/material_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ func TestMain(m *testing.M) {
2020
func TestFindIconName(t *testing.T) {
2121
unittest.PrepareTestEnv(t)
2222
p := fileicon.DefaultMaterialIconProvider()
23-
assert.Equal(t, "php", p.FindIconName(&fileicon.EntryInfo{FullName: "foo.php", EntryMode: git.EntryModeBlob}))
24-
assert.Equal(t, "php", p.FindIconName(&fileicon.EntryInfo{FullName: "foo.PHP", EntryMode: git.EntryModeBlob}))
25-
assert.Equal(t, "javascript", p.FindIconName(&fileicon.EntryInfo{FullName: "foo.js", EntryMode: git.EntryModeBlob}))
26-
assert.Equal(t, "visualstudio", p.FindIconName(&fileicon.EntryInfo{FullName: "foo.vba", EntryMode: git.EntryModeBlob}))
23+
assert.Equal(t, "php", p.FindIconName(&fileicon.EntryInfo{BaseName: "foo.php", EntryMode: git.EntryModeBlob}))
24+
assert.Equal(t, "php", p.FindIconName(&fileicon.EntryInfo{BaseName: "foo.PHP", EntryMode: git.EntryModeBlob}))
25+
assert.Equal(t, "javascript", p.FindIconName(&fileicon.EntryInfo{BaseName: "foo.js", EntryMode: git.EntryModeBlob}))
26+
assert.Equal(t, "visualstudio", p.FindIconName(&fileicon.EntryInfo{BaseName: "foo.vba", EntryMode: git.EntryModeBlob}))
2727
}

modules/git/commit.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ import (
2020

2121
// Commit represents a git commit.
2222
type Commit struct {
23-
Tree
23+
Tree // FIXME: bad design, this field can be nil if the commit is from "last commit cache"
24+
2425
ID ObjectID // The ID of this commit object
2526
Author *Signature
2627
Committer *Signature

modules/git/error.go

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -32,22 +32,6 @@ func (err ErrNotExist) Unwrap() error {
3232
return util.ErrNotExist
3333
}
3434

35-
// ErrSymlinkUnresolved entry.FollowLink error
36-
type ErrSymlinkUnresolved struct {
37-
Name string
38-
Message string
39-
}
40-
41-
func (err ErrSymlinkUnresolved) Error() string {
42-
return fmt.Sprintf("%s: %s", err.Name, err.Message)
43-
}
44-
45-
// IsErrSymlinkUnresolved if some error is ErrSymlinkUnresolved
46-
func IsErrSymlinkUnresolved(err error) bool {
47-
_, ok := err.(ErrSymlinkUnresolved)
48-
return ok
49-
}
50-
5135
// ErrBranchNotExist represents a "BranchNotExist" kind of error.
5236
type ErrBranchNotExist struct {
5337
Name string

modules/git/tree_blob_nogogit.go

Lines changed: 17 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import (
1111
)
1212

1313
// GetTreeEntryByPath get the tree entries according the sub dir
14-
func (t *Tree) GetTreeEntryByPath(relpath string) (*TreeEntry, error) {
14+
func (t *Tree) GetTreeEntryByPath(relpath string) (_ *TreeEntry, err error) {
1515
if len(relpath) == 0 {
1616
return &TreeEntry{
1717
ptree: t,
@@ -21,27 +21,25 @@ func (t *Tree) GetTreeEntryByPath(relpath string) (*TreeEntry, error) {
2121
}, nil
2222
}
2323

24-
// FIXME: This should probably use git cat-file --batch to be a bit more efficient
2524
relpath = path.Clean(relpath)
2625
parts := strings.Split(relpath, "/")
27-
var err error
26+
2827
tree := t
29-
for i, name := range parts {
30-
if i == len(parts)-1 {
31-
entries, err := tree.ListEntries()
32-
if err != nil {
33-
return nil, err
34-
}
35-
for _, v := range entries {
36-
if v.Name() == name {
37-
return v, nil
38-
}
39-
}
40-
} else {
41-
tree, err = tree.SubTree(name)
42-
if err != nil {
43-
return nil, err
44-
}
28+
for _, name := range parts[:len(parts)-1] {
29+
tree, err = tree.SubTree(name)
30+
if err != nil {
31+
return nil, err
32+
}
33+
}
34+
35+
name := parts[len(parts)-1]
36+
entries, err := tree.ListEntries()
37+
if err != nil {
38+
return nil, err
39+
}
40+
for _, v := range entries {
41+
if v.Name() == name {
42+
return v, nil
4543
}
4644
}
4745
return nil, ErrNotExist{"", relpath}

modules/git/tree_entry.go

Lines changed: 32 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
package git
66

77
import (
8-
"io"
8+
"path"
99
"sort"
1010
"strings"
1111

@@ -24,77 +24,57 @@ func (te *TreeEntry) Type() string {
2424
}
2525
}
2626

27-
// FollowLink returns the entry pointed to by a symlink
28-
func (te *TreeEntry) FollowLink() (*TreeEntry, error) {
27+
type EntryFollowResult struct {
28+
SymlinkContent string
29+
TargetFullPath string
30+
TargetEntry *TreeEntry
31+
}
32+
33+
func EntryFollowLink(commit *Commit, fullPath string, te *TreeEntry) (*EntryFollowResult, error) {
2934
if !te.IsLink() {
30-
return nil, ErrSymlinkUnresolved{te.Name(), "not a symlink"}
35+
return nil, util.ErrorWrap(util.ErrUnprocessableContent, "%q is not a symlink", fullPath)
3136
}
3237

33-
// read the link
34-
r, err := te.Blob().DataAsync()
35-
if err != nil {
36-
return nil, err
38+
// git's filename max length is 4096, hopefully a link won't be longer than multiple of that
39+
const maxSymlinkSize = 20 * 4096
40+
if te.Blob().Size() > maxSymlinkSize {
41+
return nil, util.ErrorWrap(util.ErrUnprocessableContent, "%q content exceeds symlink limit", fullPath)
3742
}
38-
closed := false
39-
defer func() {
40-
if !closed {
41-
_ = r.Close()
42-
}
43-
}()
44-
buf := make([]byte, te.Size())
45-
_, err = io.ReadFull(r, buf)
43+
44+
link, err := te.Blob().GetBlobContent(maxSymlinkSize)
4645
if err != nil {
4746
return nil, err
4847
}
49-
_ = r.Close()
50-
closed = true
51-
52-
lnk := string(buf)
53-
t := te.ptree
54-
55-
// traverse up directories
56-
for ; t != nil && strings.HasPrefix(lnk, "../"); lnk = lnk[3:] {
57-
t = t.ptree
48+
if strings.HasPrefix(link, "/") {
49+
// It's said that absolute path will be stored as is in Git
50+
return &EntryFollowResult{SymlinkContent: link}, util.ErrorWrap(util.ErrUnprocessableContent, "%q is an absolute symlink", fullPath)
5851
}
5952

60-
if t == nil {
61-
return nil, ErrSymlinkUnresolved{te.Name(), "points outside of repo"}
62-
}
63-
64-
target, err := t.GetTreeEntryByPath(lnk)
53+
targetFullPath := path.Join(path.Dir(fullPath), link)
54+
targetEntry, err := commit.GetTreeEntryByPath(targetFullPath)
6555
if err != nil {
66-
if IsErrNotExist(err) {
67-
return nil, ErrSymlinkUnresolved{te.Name(), "broken link"}
68-
}
69-
return nil, err
56+
return &EntryFollowResult{SymlinkContent: link}, err
7057
}
71-
return target, nil
58+
return &EntryFollowResult{SymlinkContent: link, TargetFullPath: targetFullPath, TargetEntry: targetEntry}, nil
7259
}
7360

74-
// FollowLinks returns the entry ultimately pointed to by a symlink
75-
func (te *TreeEntry) FollowLinks(optLimit ...int) (*TreeEntry, error) {
76-
if !te.IsLink() {
77-
return nil, ErrSymlinkUnresolved{te.Name(), "not a symlink"}
78-
}
61+
func EntryFollowLinks(commit *Commit, firstFullPath string, firstTreeEntry *TreeEntry, optLimit ...int) (res *EntryFollowResult, err error) {
7962
limit := util.OptionalArg(optLimit, 10)
80-
entry := te
63+
treeEntry, fullPath := firstTreeEntry, firstFullPath
8164
for range limit {
82-
if !entry.IsLink() {
83-
break
84-
}
85-
next, err := entry.FollowLink()
65+
res, err = EntryFollowLink(commit, fullPath, treeEntry)
8666
if err != nil {
87-
return nil, err
67+
return res, err
8868
}
89-
if next.ID == entry.ID {
90-
return nil, ErrSymlinkUnresolved{entry.Name(), "recursive link"}
69+
treeEntry, fullPath = res.TargetEntry, res.TargetFullPath
70+
if !treeEntry.IsLink() {
71+
break
9172
}
92-
entry = next
9373
}
94-
if entry.IsLink() {
95-
return nil, ErrSymlinkUnresolved{te.Name(), "too many levels of symbolic links"}
74+
if treeEntry.IsLink() {
75+
return res, util.ErrorWrap(util.ErrUnprocessableContent, "%q has too many links", firstFullPath)
9676
}
97-
return entry, nil
77+
return res, nil
9878
}
9979

10080
// returns the Tree pointed to by this TreeEntry, or nil if this is not a tree

modules/git/tree_entry_common_test.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
// Copyright 2024 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package git
5+
6+
import (
7+
"testing"
8+
9+
"code.gitea.io/gitea/modules/util"
10+
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
func TestFollowLink(t *testing.T) {
16+
r, err := openRepositoryWithDefaultContext("tests/repos/repo1_bare")
17+
require.NoError(t, err)
18+
defer r.Close()
19+
20+
commit, err := r.GetCommit("37991dec2c8e592043f47155ce4808d4580f9123")
21+
require.NoError(t, err)
22+
23+
// get the symlink
24+
{
25+
lnkFullPath := "foo/bar/link_to_hello"
26+
lnk, err := commit.Tree.GetTreeEntryByPath("foo/bar/link_to_hello")
27+
require.NoError(t, err)
28+
assert.True(t, lnk.IsLink())
29+
30+
// should be able to dereference to target
31+
res, err := EntryFollowLink(commit, lnkFullPath, lnk)
32+
require.NoError(t, err)
33+
assert.Equal(t, "hello", res.TargetEntry.Name())
34+
assert.Equal(t, "foo/nar/hello", res.TargetFullPath)
35+
assert.False(t, res.TargetEntry.IsLink())
36+
assert.Equal(t, "b14df6442ea5a1b382985a6549b85d435376c351", res.TargetEntry.ID.String())
37+
}
38+
39+
{
40+
// should error when called on a normal file
41+
entry, err := commit.Tree.GetTreeEntryByPath("file1.txt")
42+
require.NoError(t, err)
43+
res, err := EntryFollowLink(commit, "file1.txt", entry)
44+
assert.ErrorIs(t, err, util.ErrUnprocessableContent)
45+
assert.Nil(t, res)
46+
}
47+
48+
{
49+
// should error for broken links
50+
entry, err := commit.Tree.GetTreeEntryByPath("foo/broken_link")
51+
require.NoError(t, err)
52+
assert.True(t, entry.IsLink())
53+
res, err := EntryFollowLink(commit, "foo/broken_link", entry)
54+
assert.ErrorIs(t, err, util.ErrNotExist)
55+
assert.Equal(t, "nar/broken_link", res.SymlinkContent)
56+
}
57+
58+
{
59+
// should error for external links
60+
entry, err := commit.Tree.GetTreeEntryByPath("foo/outside_repo")
61+
require.NoError(t, err)
62+
assert.True(t, entry.IsLink())
63+
res, err := EntryFollowLink(commit, "foo/outside_repo", entry)
64+
assert.ErrorIs(t, err, util.ErrNotExist)
65+
assert.Equal(t, "../../outside_repo", res.SymlinkContent)
66+
}
67+
68+
{
69+
// testing fix for short link bug
70+
entry, err := commit.Tree.GetTreeEntryByPath("foo/link_short")
71+
require.NoError(t, err)
72+
res, err := EntryFollowLink(commit, "foo/link_short", entry)
73+
assert.ErrorIs(t, err, util.ErrNotExist)
74+
assert.Equal(t, "a", res.SymlinkContent)
75+
}
76+
}

modules/git/tree_entry_gogit.go

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,12 @@ type TreeEntry struct {
1919
gogitTreeEntry *object.TreeEntry
2020
ptree *Tree
2121

22-
size int64
23-
sized bool
24-
fullName string
22+
size int64
23+
sized bool
2524
}
2625

2726
// Name returns the name of the entry
2827
func (te *TreeEntry) Name() string {
29-
if te.fullName != "" {
30-
return te.fullName
31-
}
3228
return te.gogitTreeEntry.Name
3329
}
3430

@@ -55,7 +51,7 @@ func (te *TreeEntry) Size() int64 {
5551
return te.size
5652
}
5753

58-
// IsSubModule if the entry is a sub module
54+
// IsSubModule if the entry is a submodule
5955
func (te *TreeEntry) IsSubModule() bool {
6056
return te.gogitTreeEntry.Mode == filemode.Submodule
6157
}

modules/git/tree_entry_mode.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ type EntryMode int
1515
// one of these.
1616
const (
1717
// EntryModeNoEntry is possible if the file was added or removed in a commit. In the case of
18-
// added the base commit will not have the file in its tree so a mode of 0o000000 is used.
18+
// when adding the base commit doesn't have the file in its tree, a mode of 0o000000 is used.
1919
EntryModeNoEntry EntryMode = 0o000000
2020

2121
EntryModeBlob EntryMode = 0o100644
@@ -30,7 +30,7 @@ func (e EntryMode) String() string {
3030
return strconv.FormatInt(int64(e), 8)
3131
}
3232

33-
// IsSubModule if the entry is a sub module
33+
// IsSubModule if the entry is a submodule
3434
func (e EntryMode) IsSubModule() bool {
3535
return e == EntryModeCommit
3636
}

0 commit comments

Comments
 (0)