fix: eliminate TOCTOU race in template file system by reading directly instead of checking existence (CSM-026)
This commit is contained in:
106
test/bds/csm026_toctou_file_system_test.exs
Normal file
106
test/bds/csm026_toctou_file_system_test.exs
Normal file
@@ -0,0 +1,106 @@
|
||||
defmodule BDS.TOCTOU.FileSystemTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias BDS.Rendering.FileSystem, as: TemplateFileSystem
|
||||
|
||||
describe "try_read/2 eliminates TOCTOU race" do
|
||||
@tag :tmp_dir
|
||||
test "reads file atomically without separate existence check", %{tmp_dir: tmp_dir} do
|
||||
File.write!(Path.join(tmp_dir, "header.liquid"), "HEADER CONTENT")
|
||||
fs = TemplateFileSystem.new(tmp_dir)
|
||||
|
||||
assert {:ok, "HEADER CONTENT"} = TemplateFileSystem.try_read(fs, "header")
|
||||
end
|
||||
|
||||
@tag :tmp_dir
|
||||
test "returns {:error, :enoent} for missing templates", %{tmp_dir: tmp_dir} do
|
||||
fs = TemplateFileSystem.new(tmp_dir)
|
||||
|
||||
assert {:error, :enoent} = TemplateFileSystem.try_read(fs, "nonexistent")
|
||||
end
|
||||
|
||||
@tag :tmp_dir
|
||||
test "falls through to next root path when first is missing", %{tmp_dir: tmp_dir} do
|
||||
root_a = Path.join(tmp_dir, "a")
|
||||
root_b = Path.join(tmp_dir, "b")
|
||||
File.mkdir_p!(root_a)
|
||||
File.mkdir_p!(root_b)
|
||||
File.write!(Path.join(root_b, "partial.liquid"), "FROM B")
|
||||
|
||||
fs = TemplateFileSystem.new([root_a, root_b])
|
||||
|
||||
assert {:ok, "FROM B"} = TemplateFileSystem.try_read(fs, "partial")
|
||||
end
|
||||
|
||||
@tag :tmp_dir
|
||||
test "first root path wins when both have the template", %{tmp_dir: tmp_dir} do
|
||||
root_a = Path.join(tmp_dir, "a")
|
||||
root_b = Path.join(tmp_dir, "b")
|
||||
File.mkdir_p!(root_a)
|
||||
File.mkdir_p!(root_b)
|
||||
File.write!(Path.join(root_a, "shared.liquid"), "FROM A")
|
||||
File.write!(Path.join(root_b, "shared.liquid"), "FROM B")
|
||||
|
||||
fs = TemplateFileSystem.new([root_a, root_b])
|
||||
|
||||
assert {:ok, "FROM A"} = TemplateFileSystem.try_read(fs, "shared")
|
||||
end
|
||||
|
||||
@tag :tmp_dir
|
||||
test "file deleted between candidate_paths and try_read does not crash", %{tmp_dir: tmp_dir} do
|
||||
path = Path.join(tmp_dir, "ephemeral.liquid")
|
||||
File.write!(path, "TEMPORARY")
|
||||
fs = TemplateFileSystem.new(tmp_dir)
|
||||
|
||||
_candidates = TemplateFileSystem.candidate_paths(fs, "ephemeral")
|
||||
File.rm!(path)
|
||||
|
||||
assert {:error, :enoent} = TemplateFileSystem.try_read(fs, "ephemeral")
|
||||
end
|
||||
end
|
||||
|
||||
describe "read_template_file/2 protocol uses atomic read" do
|
||||
@tag :tmp_dir
|
||||
test "reads existing template", %{tmp_dir: tmp_dir} do
|
||||
File.write!(Path.join(tmp_dir, "footer.liquid"), "FOOTER")
|
||||
fs = TemplateFileSystem.new(tmp_dir)
|
||||
|
||||
assert "FOOTER" = Liquex.FileSystem.read_template_file(fs, "footer")
|
||||
end
|
||||
|
||||
@tag :tmp_dir
|
||||
test "raises on missing template", %{tmp_dir: tmp_dir} do
|
||||
fs = TemplateFileSystem.new(tmp_dir)
|
||||
|
||||
assert_raise Liquex.Error, ~r/No such template/, fn ->
|
||||
Liquex.FileSystem.read_template_file(fs, "missing")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "candidate_paths/2 validation" do
|
||||
test "raises on empty path" do
|
||||
fs = TemplateFileSystem.new("/tmp")
|
||||
|
||||
assert_raise Liquex.Error, ~r/Illegal template path/, fn ->
|
||||
TemplateFileSystem.candidate_paths(fs, "")
|
||||
end
|
||||
end
|
||||
|
||||
test "raises on absolute path" do
|
||||
fs = TemplateFileSystem.new("/tmp")
|
||||
|
||||
assert_raise Liquex.Error, ~r/Illegal template path/, fn ->
|
||||
TemplateFileSystem.candidate_paths(fs, "/etc/passwd")
|
||||
end
|
||||
end
|
||||
|
||||
test "raises on path traversal" do
|
||||
fs = TemplateFileSystem.new("/tmp")
|
||||
|
||||
assert_raise Liquex.Error, ~r/Illegal template path/, fn ->
|
||||
TemplateFileSystem.candidate_paths(fs, "../secret")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user