Skip to content

AddWatch is broken for relative Symlinks on macOS #661

@KayenM

Description

@KayenM

Describe the bug

The backend_kqueue.go's addWatch function is responsible for returning the real path to the file which was added. If the given file is a symlink, it will resolve the symlink's target destination. This functionality was modified in this pull-request to use the os.Readlink method instead of filepath.EvalSymlinks method to resolve the target file's symlink.

os.Readlink returns a relative path while filepath.EvalSymlinks returns the fully resolved absolute path. This poses an issue when addWatch needs to resolve a relative symlink. If not run in the target directory, and if the returned link is a relative path, the watch will receive an error during the lstat operation on link. It will then silently fail without notifying the user as the error condition for the lstat operation is configured not to return the error.

As a result, the watcher.Add operation will fail to add a watch to the relative symlink and fail silently as the error check for the lstat operation doesn't return the error. This bug emerged in fsnotify v1.7.0 and is still present in the master branch. I am unable to reproduce this bug on linux.

Code to Reproduce

I added logging to the fsnotify vendor code's addWatch method's follow symlink logic. Specifically, log what the symlink resolves to with os.Readlink and with filepath.EvalSymlinks. Additionally, we log to stdout if fi, err = os.Lstat(name) returns an error.

# fsnotify v1.8.0 code
// Follow symlinks.
if fi.Mode()&os.ModeSymlink == os.ModeSymlink {
	link, err := os.Readlink(name)
	log.Printf("OS readlink result: %s", link)
	link2, err := filepath.EvalSymlinks(name)
	log.Printf("Eval symlink result: %s", link2)
	if err != nil {
		log.Printf("Unresolvable symlink: %v", err)
		// Return nil because Linux can add unresolvable symlinks to the
		// watch list without problems, so maintain consistency with
		// that. There will be no file events for broken symlinks.
		// TODO: more specific check; returns os.PathError; ENOENT?
		//return "", nil
	}

	_, alreadyWatching = w.watches.byPath(link)
	if alreadyWatching {
		// Add to watches so we don't get spurious Create events later
		// on when we diff the directories.
		w.watches.addLink(name, 0)
		return link, nil
	}

	info.linkName = name
	name = link
	fi, err = os.Lstat(name)
	if err != nil {
		log.Printf("Failed to lstat file resolved from os.Readlink, returning silently: %v", err)
		return "", nil
	}
}

Created TestFSNotifyAddWatchWithRelativeSymlink which only calls watcher.add on a relative symlink:

package httputil

import (
	"github.com/fsnotify/fsnotify"
	"github.com/stretchr/testify/assert"
	"os"
	"path/filepath"
	"testing"
)

func TestFSNotifyAddWatchWithRelativeSymlink(t *testing.T) {
	// Try to add a watcher on a relative symlink
	watcher, err := fsnotify.NewWatcher()
	if err != nil {
		t.Fatalf("Failed to create new Watcher: %v", err)
	}
	assert.NoError(t, err)
	defer watcher.Close()

	// Create initial file and it's symlink.
	fileSymlink := createFileAndSymlink(t, "")

	// Add the symlink to the file to the watcher.
	if err := watcher.Add(fileSymlink); err != nil {
		t.Fatalf("Error adding file %s to file watcher: %v", fileSymlink, err)
	}
}

func createFileAndSymlink(t *testing.T, content string) string {
	t.Helper()
	tempDir := t.TempDir()

	// Step 1: Create a timestamped directory and the `podName` file
	timestampDir := filepath.Join(tempDir, "..data_2024")
	if err := os.MkdirAll(timestampDir, 0o755); err != nil {
		t.Fatalf("could not create timestamped directory: %v", err)
	}
	fileWithContentsPath := filepath.Join(timestampDir, "file_with_contents")
	if err := os.WriteFile(fileWithContentsPath, []byte(content), 0o666); err != nil {
		t.Fatalf("could not write file_with_contents file: %v", err)
	}

	// Step 2: Create the `..data` symlink pointing to the timestamped directory
	symlink := filepath.Join(tempDir, "..data")
	if err := os.Symlink(filepath.Base(timestampDir), symlink); err != nil {
		t.Fatalf("could not create ..data symlink: %v", err)
	}

	t.Logf("Created symlink: %s -> %s", symlink, filepath.Base(timestampDir))
	return symlink
}

See the output to see how the addWatch operation fails on the link resolved from os.Readlink:

=== RUN   TestFSNotifyAddWatchWithRelativeSymlink
    fnsotify_test.go:21: Created symlink: /var/folders/nw/gf79fm_j291dfhp64th3hndh0000gq/T/TestFSNotifyAddWatchWithRelativeSymlink1791335754/001/..data -> ..data_2024
2024/12/18 10:49:37 OS readlink result: ..data_2024
2024/12/18 10:49:37 Eval symlink result: /private/var/folders/nw/gf79fm_j291dfhp64th3hndh0000gq/T/TestFSNotifyAddWatchWithRelativeSymlink1791335754/001/..data_2024
2024/12/18 10:49:37 Failed to lstat file resolved from os.Readlink, returning silently: lstat ..data_2024: no such file or directory
--- PASS: TestFSNotifyAddWatchWithRelativeSymlink (0.00s)

As seen above, the addWatch method will not be able to lstat the ..data/podName symlink because it is not an absolute path, it then returns. Though this test still passes as the error is not returned by the addWatch method for an lstat operation error.

File operations to reproduce

No file operations are needed, running the above test with logging will demonstrate that a watch is not added to the target destination of the symlink.

To confirm that the the watch is not added to the file_with_contents, performing a write on the file_with_contents file and checking for events would show this.

Which operating system and version are you using?

macOS: Sonoma 14.6.1

Which fsnotify version are you using?

v1.7.0

Did you try the latest main branch?

Yes

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions