diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml
new file mode 100644
index 0000000..6a7a66b
--- /dev/null
+++ b/.github/dependabot.yaml
@@ -0,0 +1,15 @@
+version: 2
+updates:
+  - package-ecosystem: "github-actions"
+    directory: "/"
+    schedule:
+      interval: "weekly"
+    commit-message:
+      prefix: "build"
+
+  - package-ecosystem: "gomod"
+    directory: "/"
+    schedule:
+      interval: "daily"
+    commit-message:
+      prefix: "build"
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 4bdf33e..7665d26 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -5,15 +5,15 @@ jobs:
   test:
     strategy:
       matrix:
-        go-version: [1.20.x,1.21.x]
+        go-version: [1.20.x,1.21.x,1.22.x]
         platform: [ubuntu-latest, macos-latest, windows-latest]
     runs-on: ${{ matrix.platform }}
     steps:
+    - name: Checkout code
+      uses: actions/checkout@v3
     - name: Install Go
       uses: actions/setup-go@v3
       with:
         go-version: ${{ matrix.go-version }}
-    - name: Checkout code
-      uses: actions/checkout@v3
     - name: Test
       run: make test
diff --git a/.github/workflows/test_js.yml b/.github/workflows/test_js.yml
index ae9fef3..539a5e9 100644
--- a/.github/workflows/test_js.yml
+++ b/.github/workflows/test_js.yml
@@ -5,9 +5,12 @@ jobs:
   test:
     strategy:
       matrix:
-        go-version: [1.20.x,1.21.x]
+        go-version: [1.21.x,1.22.x]
     runs-on: ubuntu-latest
     steps:
+    - name: Checkout code
+      uses: actions/checkout@v3
+
     - name: Install Go
       uses: actions/setup-go@v3
       with:
@@ -18,9 +21,6 @@ jobs:
         go install github.com/agnivade/wasmbrowsertest@latest
         mv $HOME/go/bin/wasmbrowsertest $HOME/go/bin/go_js_wasm_exec
 
-    - name: Checkout code
-      uses: actions/checkout@v3
-
     - name: Test
       run: go test -exec="$HOME/go/bin/go_js_wasm_exec" ./...
       env:
diff --git a/.github/workflows/test_wasip1.yml b/.github/workflows/test_wasip1.yml
index e97334e..312a637 100644
--- a/.github/workflows/test_wasip1.yml
+++ b/.github/workflows/test_wasip1.yml
@@ -5,9 +5,12 @@ jobs:
   test:
     strategy:
       matrix:
-        go-version: [1.21.x]
+        go-version: [1.21.x,1.22.x]
     runs-on: ubuntu-latest
     steps:
+    - name: Checkout code
+      uses: actions/checkout@v3
+
     - name: Install Go
       uses: actions/setup-go@v3
       with:
@@ -17,8 +20,5 @@ jobs:
       run: |
         go install github.com/stealthrocket/wasi-go/cmd/wasirun@latest
 
-    - name: Checkout code
-      uses: actions/checkout@v3
-
     - name: Test
-      run: make wasitest
\ No newline at end of file
+      run: make wasitest
diff --git a/go.mod b/go.mod
index a6ebae7..8f7b461 100644
--- a/go.mod
+++ b/go.mod
@@ -1,19 +1,22 @@
 module github.com/go-git/go-billy/v5
 
 // go-git supports the last 3 stable Go versions.
-go 1.19
+go 1.20
 
 require (
 	github.com/cyphar/filepath-securejoin v0.2.4
 	github.com/onsi/gomega v1.27.10
+	github.com/stretchr/testify v1.9.0
 	golang.org/x/sys v0.18.0
 	gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c
 )
 
 require (
+	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/google/go-cmp v0.5.9 // indirect
 	github.com/kr/pretty v0.3.1 // indirect
 	github.com/kr/text v0.2.0 // indirect
+	github.com/pmezard/go-difflib v1.0.0 // indirect
 	github.com/rogpeppe/go-internal v1.11.0 // indirect
 	golang.org/x/net v0.23.0 // indirect
 	golang.org/x/text v0.14.0 // indirect
diff --git a/go.sum b/go.sum
index 076c6c2..6c638fc 100644
--- a/go.sum
+++ b/go.sum
@@ -1,6 +1,8 @@
 github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
 github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg=
 github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ=
 github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
 github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
@@ -17,9 +19,13 @@ github.com/onsi/ginkgo/v2 v2.11.0 h1:WgqUCUt/lT6yXoQ8Wef0fsNn5cAuMK7+KT9UFRz2tcU
 github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI=
 github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M=
 github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
 github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
 github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
+github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
+github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
 golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs=
 golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
 golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
diff --git a/memfs/memory.go b/memfs/memory.go
index c008702..6cbd7d0 100644
--- a/memfs/memory.go
+++ b/memfs/memory.go
@@ -19,7 +19,9 @@ import (
 
 const separator = filepath.Separator
 
-// Memory a very convenient filesystem based on memory files
+var errNotLink = errors.New("not a link")
+
+// Memory a very convenient filesystem based on memory files.
 type Memory struct {
 	s *storage
 
@@ -59,10 +61,9 @@ func (fs *Memory) OpenFile(filename string, flag int, perm os.FileMode) (billy.F
 		}
 
 		if target, isLink := fs.resolveLink(filename, f); isLink {
-			if target == filename {
-				return nil, os.ErrNotExist
+			if target != filename {
+				return fs.OpenFile(target, flag, perm)
 			}
-			return fs.OpenFile(target, flag, perm)
 		}
 	}
 
@@ -73,8 +74,6 @@ func (fs *Memory) OpenFile(filename string, flag int, perm os.FileMode) (billy.F
 	return f.Duplicate(filename, perm, flag), nil
 }
 
-var errNotLink = errors.New("not a link")
-
 func (fs *Memory) resolveLink(fullpath string, f *file) (target string, isLink bool) {
 	if !isSymlink(f.mode) {
 		return fullpath, false
@@ -136,7 +135,9 @@ func (a ByName) Swap(i, j int)      { a[i], a[j] = a[j], a[i] }
 func (fs *Memory) ReadDir(path string) ([]os.FileInfo, error) {
 	if f, has := fs.s.Get(path); has {
 		if target, isLink := fs.resolveLink(path, f); isLink {
-			return fs.ReadDir(target)
+			if target != path {
+				return fs.ReadDir(target)
+			}
 		}
 	} else {
 		return nil, &os.PathError{Op: "open", Path: path, Err: syscall.ENOENT}
@@ -176,17 +177,19 @@ func (fs *Memory) Remove(filename string) error {
 	return fs.s.Remove(filename)
 }
 
+// Falls back to Go's filepath.Join, which works differently depending on the
+// OS where the code is being executed.
 func (fs *Memory) Join(elem ...string) string {
 	return filepath.Join(elem...)
 }
 
 func (fs *Memory) Symlink(target, link string) error {
-	_, err := fs.Stat(link)
+	_, err := fs.Lstat(link)
 	if err == nil {
 		return os.ErrExist
 	}
 
-	if !os.IsNotExist(err) {
+	if !errors.Is(err, os.ErrNotExist) {
 		return err
 	}
 
@@ -237,7 +240,7 @@ func (f *file) Read(b []byte) (int, error) {
 	n, err := f.ReadAt(b, f.position)
 	f.position += int64(n)
 
-	if err == io.EOF && n != 0 {
+	if errors.Is(err, io.EOF) && n != 0 {
 		err = nil
 	}
 
diff --git a/memfs/memory_test.go b/memfs/memory_test.go
index 043a9fb..cd7fa68 100644
--- a/memfs/memory_test.go
+++ b/memfs/memory_test.go
@@ -10,6 +10,7 @@ import (
 	"github.com/go-git/go-billy/v5"
 	"github.com/go-git/go-billy/v5/test"
 	"github.com/go-git/go-billy/v5/util"
+	"github.com/stretchr/testify/assert"
 
 	. "gopkg.in/check.v1"
 )
@@ -122,8 +123,212 @@ func (s *MemorySuite) TestTruncateAppend(c *C) {
 	c.Assert(string(data), Equals, "replace")
 }
 
+func TestReadlink(t *testing.T) {
+	tests := []struct {
+		name    string
+		link    string
+		want    string
+		wantErr *error
+	}{
+		{
+			name:    "symlink not found",
+			link:    "/404",
+			wantErr: &os.ErrNotExist,
+		},
+		{
+			name: "self-targeting symlink",
+			link: "/self",
+			want: "/self",
+		},
+		{
+			name: "symlink",
+			link: "/bar",
+			want: "/foo",
+		},
+		{
+			name: "symlink to windows path",
+			link: "/win",
+			want: "c:\\test\\123",
+		},
+		{
+			name: "symlink to network path",
+			link: "/net",
+			want: "\\test\\123",
+		},
+	}
+
+	// Cater for memfs not being os-agnostic.
+	if runtime.GOOS == "windows" {
+		tests[1].want = "\\self"
+		tests[2].want = "\\foo"
+		tests[3].want = "\\c:\\test\\123"
+	}
+
+	fs := New()
+
+	// arrange fs for tests.
+	assert.NoError(t, fs.Symlink("/self", "/self"))
+	assert.NoError(t, fs.Symlink("/foo", "/bar"))
+	assert.NoError(t, fs.Symlink("c:\\test\\123", "/win"))
+	assert.NoError(t, fs.Symlink("\\test\\123", "/net"))
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			got, err := fs.Readlink(tc.link)
+
+			if tc.wantErr == nil {
+				assert.NoError(t, err)
+				assert.Equal(t, tc.want, got)
+			} else {
+				assert.ErrorIs(t, err, *tc.wantErr)
+			}
+		})
+	}
+}
+
+func TestSymlink(t *testing.T) {
+	tests := []struct {
+		name    string
+		target  string
+		link    string
+		want    string
+		wantErr string
+	}{
+		{
+			name:   "new symlink unexistent target",
+			target: "/bar",
+			link:   "/foo",
+			want:   "/bar",
+		},
+		{
+			name:   "self-targeting symlink",
+			target: "/self",
+			link:   "/self",
+			want:   "/self",
+		},
+		{
+			name:   "new symlink to file",
+			target: "/file",
+			link:   "/file-link",
+			want:   "/file",
+		},
+		{
+			name:   "new symlink to dir",
+			target: "/dir",
+			link:   "/dir-link",
+			want:   "/dir",
+		},
+		{
+			name:   "new symlink to win",
+			target: "c:\\foor\\bar",
+			link:   "/win",
+			want:   "c:\\foor\\bar",
+		},
+		{
+			name:   "new symlink to net",
+			target: "\\net\\bar",
+			link:   "/net",
+			want:   "\\net\\bar",
+		},
+		{
+			name:   "new symlink to net",
+			target: "\\net\\bar",
+			link:   "/net",
+			want:   "\\net\\bar",
+		},
+		{
+			name:    "duplicate symlink",
+			target:  "/bar",
+			link:    "/foo",
+			wantErr: os.ErrExist.Error(),
+		},
+		{
+			name:    "symlink over existing file",
+			target:  "/foo/bar",
+			link:    "/file",
+			want:    "/file",
+			wantErr: os.ErrExist.Error(),
+		},
+	}
+
+	// Cater for memfs not being os-agnostic.
+	if runtime.GOOS == "windows" {
+		tests[0].want = "\\bar"
+		tests[1].want = "\\self"
+		tests[2].want = "\\file"
+		tests[3].want = "\\dir"
+		tests[4].want = "\\c:\\foor\\bar"
+	}
+
+	fs := New()
+
+	// arrange fs for tests.
+	err := fs.MkdirAll("/dir", 0o600)
+	assert.NoError(t, err)
+	_, err = fs.Create("/file")
+	assert.NoError(t, err)
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			err := fs.Symlink(tc.target, tc.link)
+
+			if tc.wantErr == "" {
+				got, err := fs.Readlink(tc.link)
+				assert.NoError(t, err)
+				assert.Equal(t, tc.want, got)
+			} else {
+				assert.ErrorContains(t, err, tc.wantErr)
+			}
+		})
+	}
+}
+
+func TestJoin(t *testing.T) {
+	tests := []struct {
+		name string
+		elem []string
+		want string
+	}{
+		{name: "empty", elem: []string{""}, want: ""},
+		{name: "c:", elem: []string{"C:"}, want: "C:"},
+		{name: "simple rel", elem: []string{"a", "b", "c"}, want: "a/b/c"},
+		{name: "simple rel backslash", elem: []string{"\\", "a", "b", "c"}, want: "\\/a/b/c"},
+		{name: "simple abs slash", elem: []string{"/", "a", "b", "c"}, want: "/a/b/c"},
+		{name: "c: rel", elem: []string{"C:\\", "a", "b", "c"}, want: "C:\\/a/b/c"},
+		{name: "c: abs", elem: []string{"/C:\\", "a", "b", "c"}, want: "/C:\\/a/b/c"},
+		{name: "\\ rel", elem: []string{"\\\\", "a", "b", "c"}, want: "\\\\/a/b/c"},
+		{name: "\\ abs", elem: []string{"/\\\\", "a", "b", "c"}, want: "/\\\\/a/b/c"},
+	}
+
+	// Cater for memfs not being os-agnostic.
+	if runtime.GOOS == "windows" {
+		tests[1].want = "C:."
+		tests[2].want = "a\\b\\c"
+		tests[3].want = "\\a\\b\\c"
+		tests[4].want = "\\a\\b\\c"
+		tests[5].want = "C:\\a\\b\\c"
+		tests[6].want = "\\C:\\a\\b\\c"
+		tests[7].want = "\\\\a\\b\\c"
+		tests[8].want = "\\\\\\a\\b\\c"
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			got := New().Join(tc.elem...)
+			assert.Equal(t, tc.want, got)
+		})
+	}
+}
+
 func (s *MemorySuite) TestSymlink(c *C) {
-	s.FS.Symlink("test", "test")
-	_, err := s.FS.Open("test")
-	c.Assert(err, NotNil)
+	err := s.FS.Symlink("test", "test")
+	c.Assert(err, IsNil)
+
+	f, err := s.FS.Open("test")
+	c.Assert(err, IsNil)
+	c.Assert(f, NotNil)
+
+	fi, err := s.FS.ReadDir("test")
+	c.Assert(err, IsNil)
+	c.Assert(fi, IsNil)
 }
diff --git a/memfs/storage.go b/memfs/storage.go
index e3c4e38..16b48ce 100644
--- a/memfs/storage.go
+++ b/memfs/storage.go
@@ -6,6 +6,7 @@ import (
 	"io"
 	"os"
 	"path/filepath"
+	"strings"
 	"sync"
 )
 
@@ -112,7 +113,7 @@ func (s *storage) Rename(from, to string) error {
 	move := [][2]string{{from, to}}
 
 	for pathFrom := range s.files {
-		if pathFrom == from || !filepath.HasPrefix(pathFrom, from) {
+		if pathFrom == from || !strings.HasPrefix(pathFrom, from) {
 			continue
 		}
 
diff --git a/util/util.go b/util/util.go
index 9fae2ae..2cdd832 100644
--- a/util/util.go
+++ b/util/util.go
@@ -1,6 +1,7 @@
 package util
 
 import (
+	"errors"
 	"io"
 	"os"
 	"path/filepath"
@@ -33,14 +34,14 @@ func removeAll(fs billy.Basic, path string) error {
 
 	// Simple case: if Remove works, we're done.
 	err := fs.Remove(path)
-	if err == nil || os.IsNotExist(err) {
+	if err == nil || errors.Is(err, os.ErrNotExist) {
 		return nil
 	}
 
 	// Otherwise, is this a directory we need to recurse into?
 	dir, serr := fs.Stat(path)
 	if serr != nil {
-		if os.IsNotExist(serr) {
+		if errors.Is(serr, os.ErrNotExist) {
 			return nil
 		}
 
@@ -60,7 +61,7 @@ func removeAll(fs billy.Basic, path string) error {
 	// Directory.
 	fis, err := dirfs.ReadDir(path)
 	if err != nil {
-		if os.IsNotExist(err) {
+		if errors.Is(err, os.ErrNotExist) {
 			// Race. It was deleted between the Lstat and Open.
 			// Return nil per RemoveAll's docs.
 			return nil
@@ -81,7 +82,7 @@ func removeAll(fs billy.Basic, path string) error {
 
 	// Remove directory.
 	err1 := fs.Remove(path)
-	if err1 == nil || os.IsNotExist(err1) {
+	if err1 == nil || errors.Is(err1, os.ErrNotExist) {
 		return nil
 	}
 
@@ -158,7 +159,7 @@ func TempFile(fs billy.Basic, dir, prefix string) (f billy.File, err error) {
 	for i := 0; i < 10000; i++ {
 		name := filepath.Join(dir, prefix+nextSuffix())
 		f, err = fs.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0600)
-		if os.IsExist(err) {
+		if errors.Is(err, os.ErrExist) {
 			if nconflict++; nconflict > 10 {
 				randmu.Lock()
 				rand = reseed()
@@ -189,7 +190,7 @@ func TempDir(fs billy.Dir, dir, prefix string) (name string, err error) {
 	for i := 0; i < 10000; i++ {
 		try := filepath.Join(dir, prefix+nextSuffix())
 		err = fs.MkdirAll(try, 0700)
-		if os.IsExist(err) {
+		if errors.Is(err, os.ErrExist) {
 			if nconflict++; nconflict > 10 {
 				randmu.Lock()
 				rand = reseed()
@@ -197,8 +198,8 @@ func TempDir(fs billy.Dir, dir, prefix string) (name string, err error) {
 			}
 			continue
 		}
-		if os.IsNotExist(err) {
-			if _, err := os.Stat(dir); os.IsNotExist(err) {
+		if errors.Is(err, os.ErrNotExist) {
+			if _, err := os.Stat(dir); errors.Is(err, os.ErrNotExist) {
 				return "", err
 			}
 		}
@@ -276,7 +277,7 @@ func ReadFile(fs billy.Basic, name string) ([]byte, error) {
 		data = data[:len(data)+n]
 
 		if err != nil {
-			if err == io.EOF {
+			if errors.Is(err, io.EOF) {
 				err = nil
 			}