Skip to content

Commit dd1f529

Browse files
authored
Fix File.cp_r/3 infinite loop when copying into subdirectory of source (#15148)
When destination is a subdirectory of source, `cp_r` would enter an infinite loop because the newly created destination directory would be included in the file listing during recursion. This can **cause disk exhaustion** if large files are present under source. This adds a validation check that returns `{:error, :einval, destination}` when copying on a subdir of the source path. Also document typical error reasons including the new `:einval` case.
1 parent 07e6e1a commit dd1f529

2 files changed

Lines changed: 57 additions & 4 deletions

File tree

lib/elixir/lib/file.ex

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1116,6 +1116,12 @@ defmodule File do
11161116
11171117
Special files such as device files, sockets, and named pipes are not copied.
11181118
1119+
Typical error reasons are:
1120+
1121+
* `:enoent` - `source` does not exist
1122+
* `:eisdir` - `source` is a file and `destination` is a directory
1123+
* `:einval` - `destination` is the same as or a subdirectory of `source`
1124+
11191125
## Options
11201126
11211127
* `:on_conflict` - (since v1.14.0) Invoked when a file already exists in the destination.
@@ -1146,7 +1152,11 @@ defmodule File do
11461152
#=> {:ok, ["z.txt", "y.txt", "x.txt]}
11471153
11481154
File.cp_r("non_existing.txt", "copy.txt")
1149-
#=> {:error, :enoent}
1155+
#=> {:error, :enoent, "non_existing.txt"}
1156+
1157+
# Copying into a subdirectory of source is not allowed
1158+
File.cp_r("src", "src/dest")
1159+
#=> {:error, :einval, "src/dest"}
11501160
"""
11511161
@spec cp_r(Path.t(), Path.t(),
11521162
on_conflict: on_conflict_callback,
@@ -1183,9 +1193,16 @@ defmodule File do
11831193
|> IO.chardata_to_string()
11841194
|> assert_no_null_byte!("File.cp_r/3")
11851195

1186-
case do_cp_r(source, destination, on_conflict, dereference?, []) do
1187-
{:error, _, _} = error -> error
1188-
res -> {:ok, res}
1196+
source_parts = source |> Path.expand() |> Path.split()
1197+
dest_parts = destination |> Path.expand() |> Path.split()
1198+
1199+
if source_parts != dest_parts and List.starts_with?(dest_parts, source_parts) do
1200+
{:error, :einval, destination}
1201+
else
1202+
case do_cp_r(source, destination, on_conflict, dereference?, []) do
1203+
{:error, _, _} = error -> error
1204+
res -> {:ok, res}
1205+
end
11891206
end
11901207
end
11911208

lib/elixir/test/elixir/file_test.exs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -799,6 +799,42 @@ defmodule FileTest do
799799
end
800800
end
801801

802+
test "cp_r with destination inside source returns error" do
803+
src = tmp_path("tmp/src")
804+
dest = tmp_path("tmp/src/subdir/dest")
805+
806+
File.mkdir_p!(src)
807+
File.write!(Path.join(src, "file.txt"), "hello")
808+
809+
try do
810+
assert File.cp_r(src, dest) == {:error, :einval, dest}
811+
refute File.exists?(dest)
812+
after
813+
File.rm_rf(src)
814+
end
815+
end
816+
817+
test "cp_r! with destination inside source raises" do
818+
src = tmp_path("tmp/src")
819+
dest = tmp_path("tmp/src/subdir/dest")
820+
821+
File.mkdir_p!(src)
822+
File.write!(Path.join(src, "file.txt"), "hello")
823+
824+
try do
825+
message =
826+
"could not copy recursively from #{inspect(src)} to #{inspect(dest)}. #{dest}: invalid argument"
827+
828+
assert_raise File.CopyError, message, fn ->
829+
File.cp_r!(src, dest)
830+
end
831+
832+
refute File.exists?(dest)
833+
after
834+
File.rm_rf(src)
835+
end
836+
end
837+
802838
test "cp preserves mode" do
803839
File.mkdir_p!(tmp_path("tmp"))
804840
src = fixture_path("cp_mode")

0 commit comments

Comments
 (0)