Skip to content

Commit 92c3b69

Browse files
author
Libor
committed
feat: allow System.FilePath.walkDir to not follow symlinks
This PR introduces a new argument to `System.FilePath.walkDir` - `followLinks` - which allows the callee to not follow symbolic links and is set to `false` as default. Rationale: `System.FilePath.metadata` action follows symlinks, therefore the branch `| ok { type := .symlink, .. }` was in fact unreachable. We can leverage the `symlinkMetadata` action introduced in add3e1a which uses the `lstat(2)` system call and does not follow symlinks. This gives us the option to not follow symlinks and expose ourselves to an infinite recursion when traversing a directory structure containing a cycle - a situation that is currently unavoidable. --- Rationale for setting the `followLinks := false` default: I realize that setting the default as `false` is a breaking change, and I don't know the internal usage of `walkDir` well enough to see if it breaks anything - from grepping and glancing at the code, not following symlinks seems OK. I consulted implementations of `walkDir` in other languages and * Python stdlib `os.walk` - https://docs.python.org/3/library/os.html#os.walk does not follow links by default * Rust crate `walkdir` - https://docs.rs/walkdir/latest/walkdir/struct.WalkDir.html#method.follow_links does not follow links by default * Go stdlib `filepath/WalkDir` - https://pkg.go.dev/path/filepath#WalkDir does not follow links at all.
1 parent 77ddfd4 commit 92c3b69

File tree

3 files changed

+69
-9
lines changed

3 files changed

+69
-9
lines changed

src/Init/System/IO.lean

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1143,25 +1143,29 @@ def pathExists (p : FilePath) : BaseIO Bool :=
11431143

11441144
/--
11451145
Traverses a filesystem starting at the path `p` and exploring directories that satisfy `enter`,
1146-
returning the paths visited.
1146+
returning the paths visited. Symbolic links are not followed by default. This behaviour can be
1147+
changed by setting `followLinks` to `true`.
11471148
11481149
The traversal is a preorder traversal, in which parent directories occur prior to any of their
1149-
children. Symbolic links are followed.
1150+
children.
1151+
1152+
Note: `walkDir` does not keep track of already visited directories. It is therefore possible to
1153+
get stuck in an infinite recursion when `followLinks` is set to `true` on a directory structure
1154+
that contains a cycle, for example by containing a symbolic link pointing to its parent directory.
11501155
-/
1151-
partial def walkDir (p : FilePath) (enter : FilePath → IO Bool := fun _ => pure true) : IO (Array FilePath) :=
1156+
partial def walkDir (p : FilePath) (enter : FilePath → IO Bool := fun _ => pure true) (followLinks : Bool := false) : IO (Array FilePath) :=
11521157
Prod.snd <$> StateT.run (go p) #[]
11531158
where
11541159
go p := do
11551160
if !(← enter p) then
11561161
return ()
11571162
for d in (← p.readDir) do
11581163
modify (·.push d.path)
1159-
match (← d.path.metadata.toBaseIO) with
1164+
match (← d.path.symlinkMetadata.toBaseIO) with
11601165
| .ok { type := .symlink, .. } =>
1161-
let p' ← FS.realPath d.path
1162-
if (← p'.isDir) then
1163-
-- do not call `enter` on a non-directory symlink
1164-
if (← enter p) then
1166+
if followLinks then
1167+
let p' ← FS.realPath d.path
1168+
if (← p'.isDir) then
11651169
go p'
11661170
| .ok { type := .dir, .. } => go d.path
11671171
| .ok _ => pure ()

src/runtime/io.cpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1130,7 +1130,7 @@ extern "C" LEAN_EXPORT obj_res lean_io_symlink_metadata(b_obj_arg filename) {
11301130
return mk_embedded_nul_error(filename);
11311131
}
11321132
struct stat st;
1133-
if (lstat(string_cstr(filename), &st) != 0) {
1133+
if (lstat(fname, &st) != 0) {
11341134
return io_result_mk_error(decode_io_error(errno, filename));
11351135
}
11361136
return metadata_core(st);

tests/lean/run/IO_test.lean

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,14 @@ unless (expected == actual) do
1313
throw $ IO.userError $
1414
s!"assertion failure \"{tag}\":\n expected: {repr expected}\n actual: {repr actual}"
1515

16+
def mkEmptyFile (file : System.FilePath) : IO Unit :=
17+
writeFile file ""
18+
19+
def removeFileIfExists (file : System.FilePath) : IO Unit :=
20+
try removeFile file catch
21+
| .noFileOrDirectory .. => pure ()
22+
| e => throw e
23+
1624
def test : IO Unit := do
1725
let xs : ByteArray := ⟨#[1,2,3,4]⟩;
1826
let fn := "io_test/foo.txt";
@@ -171,3 +179,51 @@ def testHardLink : IO Unit := do
171179

172180
#guard_msgs in
173181
#eval testHardLink
182+
183+
/-
184+
`walkDir` with `followLinks := false` yields a subset of entries obtained by `walkDir` which
185+
follows symlinks.
186+
-/
187+
188+
def testWalkDir : IO Unit := do
189+
let fn := "g.txt"
190+
let symlink := "io_test/walkdir/dir/symlink"
191+
createDirAll "io_test/walkdir/dir"
192+
createDirAll "io_test/walkdir/symlink_target"
193+
mkEmptyFile "io_test/walkdir/dir/f.txt"
194+
mkEmptyFile s!"io_test/walkdir/symlink_target/{fn}"
195+
removeFileIfExists symlink
196+
197+
unless System.Platform.isWindows do
198+
let _ ← IO.Process.run { cmd := "ln", args := #["-s", "../../symlink_target", symlink] }
199+
let noSymlinks <- System.FilePath.walkDir "io_test/walkdir/dir" (followLinks := false)
200+
let withSymlinks <- System.FilePath.walkDir "io_test/walkdir/dir" (followLinks := true)
201+
check_eq "1" (withSymlinks.filter (fun s => !(s.toString.endsWith fn))) noSymlinks
202+
203+
#guard_msgs in
204+
#eval testWalkDir
205+
206+
/-
207+
`walkDir` with `followLinks := false` does not throw an exception when encountering a dangling
208+
symlink. When the dangling symlink is followed, `IO.Error.noFileOrDirectory` exception is thrown.
209+
-/
210+
211+
def testWalkDirDanglingSymlink : IO Unit := do
212+
let symlink := "io_test/walkdir_dangling_symlink/symlink"
213+
let nonexistentTarget := "nonexistent-target"
214+
createDirAll "io_test/walkdir_dangling_symlink"
215+
removeFileIfExists symlink
216+
217+
unless System.Platform.isWindows do
218+
assert! !(← System.FilePath.pathExists nonexistentTarget)
219+
let _ ← IO.Process.run { cmd := "ln", args := #["-s", nonexistentTarget, symlink] }
220+
let noSymlinks <- System.FilePath.walkDir "io_test/walkdir_dangling_symlink" (followLinks := false)
221+
/- symbolic link was recorded in the array, but not followed -/
222+
check_eq "1" noSymlinks.size 1
223+
224+
match <- (System.FilePath.walkDir "io_test/walkdir_dangling_symlink" (followLinks := true)).toBaseIO with
225+
| .error (.noFileOrDirectory ..) => pure ()
226+
| _ => assert! false
227+
228+
#guard_msgs in
229+
#eval testWalkDirDanglingSymlink

0 commit comments

Comments
 (0)