commit: b89070a6ad2704f4bc061c22e099f662655c3e6f
parent 0f5ac7e86dd36b80016a90fe8aca581a4275b71e
Author: Lain Soykaf <lain@lain.com>
Date: Thu, 27 Feb 2025 15:30:20 +0400
SafeZip: Add tests.
Diffstat:
1 file changed, 496 insertions(+), 0 deletions(-)
diff --git a/test/pleroma/safe_zip_test.exs b/test/pleroma/safe_zip_test.exs
@@ -0,0 +1,496 @@
+defmodule Pleroma.SafeZipTest do
+ # Not making this async because it creates and deletes files
+ use ExUnit.Case
+
+ alias Pleroma.SafeZip
+
+ @fixtures_dir "test/fixtures"
+ @tmp_dir "test/zip_tmp"
+
+ setup do
+ # Ensure tmp directory exists
+ File.mkdir_p!(@tmp_dir)
+
+ on_exit(fn ->
+ # Clean up any files created during tests
+ File.rm_rf!(@tmp_dir)
+ File.mkdir_p!(@tmp_dir)
+ end)
+
+ :ok
+ end
+
+ describe "list_dir_file/1" do
+ test "lists files in a valid zip" do
+ {:ok, files} = SafeZip.list_dir_file(Path.join(@fixtures_dir, "emojis.zip"))
+ assert is_list(files)
+ assert length(files) > 0
+ end
+
+ test "returns an empty list for empty zip" do
+ {:ok, files} = SafeZip.list_dir_file(Path.join(@fixtures_dir, "empty.zip"))
+ assert files == []
+ end
+
+ test "returns error for non-existent file" do
+ assert {:error, _} = SafeZip.list_dir_file(Path.join(@fixtures_dir, "nonexistent.zip"))
+ end
+
+ test "only lists regular files, not directories" do
+ # Create a zip with both files and directories
+ zip_path = create_zip_with_directory()
+
+ # List files with SafeZip
+ {:ok, files} = SafeZip.list_dir_file(zip_path)
+
+ # Verify only regular files are listed, not directories
+ assert "file_in_dir/test_file.txt" in files
+ assert "root_file.txt" in files
+
+ # Directory entries should not be included in the list
+ refute "file_in_dir/" in files
+ end
+ end
+
+ describe "contains_all_data?/2" do
+ test "returns true when all files are in the archive" do
+ # For this test, we'll create our own zip file with known content
+ # to ensure we can test the contains_all_data? function properly
+ zip_path = create_zip_with_directory()
+ archive_data = File.read!(zip_path)
+
+ # Check if the archive contains the root file
+ # Note: The function expects charlists (Erlang strings) in the MapSet
+ assert SafeZip.contains_all_data?(archive_data, MapSet.new([~c"root_file.txt"]))
+ end
+
+ test "returns false when files are missing" do
+ archive_path = Path.join(@fixtures_dir, "emojis.zip")
+ archive_data = File.read!(archive_path)
+
+ # Create a MapSet with non-existent files
+ fset = MapSet.new([~c"nonexistent.txt"])
+
+ refute SafeZip.contains_all_data?(archive_data, fset)
+ end
+
+ test "returns false for invalid archive data" do
+ refute SafeZip.contains_all_data?("invalid data", MapSet.new([~c"file.txt"]))
+ end
+
+ test "only checks for regular files, not directories" do
+ # Create a zip with both files and directories
+ zip_path = create_zip_with_directory()
+ archive_data = File.read!(zip_path)
+
+ # Check if the archive contains a directory (should return false)
+ refute SafeZip.contains_all_data?(archive_data, MapSet.new([~c"file_in_dir/"]))
+
+ # For this test, we'll manually check if the file exists in the archive
+ # by extracting it and verifying it exists
+ extract_dir = Path.join(@tmp_dir, "extract_check")
+ File.mkdir_p!(extract_dir)
+ {:ok, files} = SafeZip.unzip_file(zip_path, extract_dir)
+
+ # Verify the root file was extracted
+ assert Enum.any?(files, fn file ->
+ Path.basename(file) == "root_file.txt"
+ end)
+
+ # Verify the file exists on disk
+ assert File.exists?(Path.join(extract_dir, "root_file.txt"))
+ end
+ end
+
+ describe "zip/4" do
+ test "creates a zip file on disk" do
+ # Create a test file
+ test_file_path = Path.join(@tmp_dir, "test_file.txt")
+ File.write!(test_file_path, "test content")
+
+ # Create a zip file
+ zip_path = Path.join(@tmp_dir, "test.zip")
+ assert {:ok, ^zip_path} = SafeZip.zip(zip_path, ["test_file.txt"], @tmp_dir, false)
+
+ # Verify the zip file exists
+ assert File.exists?(zip_path)
+ end
+
+ test "creates a zip file in memory" do
+ # Create a test file
+ test_file_path = Path.join(@tmp_dir, "test_file.txt")
+ File.write!(test_file_path, "test content")
+
+ # Create a zip file in memory
+ zip_name = Path.join(@tmp_dir, "test.zip")
+
+ assert {:ok, {^zip_name, zip_data}} =
+ SafeZip.zip(zip_name, ["test_file.txt"], @tmp_dir, true)
+
+ # Verify the zip data is binary
+ assert is_binary(zip_data)
+ end
+
+ test "returns error for unsafe paths" do
+ # Try to zip a file with path traversal
+ assert {:error, _} =
+ SafeZip.zip(
+ Path.join(@tmp_dir, "test.zip"),
+ ["../fixtures/test.txt"],
+ @tmp_dir,
+ false
+ )
+ end
+
+ test "can create zip with directories" do
+ # Create a directory structure
+ dir_path = Path.join(@tmp_dir, "test_dir")
+ File.mkdir_p!(dir_path)
+
+ file_in_dir_path = Path.join(dir_path, "file_in_dir.txt")
+ File.write!(file_in_dir_path, "file in directory")
+
+ # Create a zip file
+ zip_path = Path.join(@tmp_dir, "dir_test.zip")
+
+ assert {:ok, ^zip_path} =
+ SafeZip.zip(
+ zip_path,
+ ["test_dir/file_in_dir.txt"],
+ @tmp_dir,
+ false
+ )
+
+ # Verify the zip file exists
+ assert File.exists?(zip_path)
+
+ # Extract and verify the directory structure is preserved
+ extract_dir = Path.join(@tmp_dir, "extract")
+ {:ok, files} = SafeZip.unzip_file(zip_path, extract_dir)
+
+ # Check if the file path is in the list, accounting for possible full paths
+ assert Enum.any?(files, fn file ->
+ String.ends_with?(file, "file_in_dir.txt")
+ end)
+
+ # Verify the file exists in the expected location
+ assert File.exists?(Path.join([extract_dir, "test_dir", "file_in_dir.txt"]))
+ end
+ end
+
+ describe "unzip_file/3" do
+ test "extracts files from a zip archive" do
+ archive_path = Path.join(@fixtures_dir, "emojis.zip")
+
+ # Extract the archive
+ assert {:ok, files} = SafeZip.unzip_file(archive_path, @tmp_dir)
+
+ # Verify files were extracted
+ assert is_list(files)
+ assert length(files) > 0
+
+ # Verify at least one file exists
+ first_file = List.first(files)
+
+ # Simply check that the file exists in the tmp directory
+ assert File.exists?(Path.join(@tmp_dir, Path.basename(first_file)))
+ end
+
+ test "extracts specific files from a zip archive" do
+ archive_path = Path.join(@fixtures_dir, "emojis.zip")
+
+ # Get list of files in the archive
+ {:ok, all_files} = SafeZip.list_dir_file(archive_path)
+ file_to_extract = List.first(all_files)
+
+ # Extract only one file
+ assert {:ok, [extracted_file]} =
+ SafeZip.unzip_file(archive_path, @tmp_dir, [file_to_extract])
+
+ # Verify only the specified file was extracted
+ assert Path.basename(extracted_file) == Path.basename(file_to_extract)
+
+ # Check that the file exists in the tmp directory
+ assert File.exists?(Path.join(@tmp_dir, Path.basename(file_to_extract)))
+ end
+
+ test "returns error for invalid zip file" do
+ invalid_path = Path.join(@tmp_dir, "invalid.zip")
+ File.write!(invalid_path, "not a zip file")
+
+ assert {:error, _} = SafeZip.unzip_file(invalid_path, @tmp_dir)
+ end
+
+ test "creates directories when extracting files in subdirectories" do
+ # Create a zip with files in subdirectories
+ zip_path = create_zip_with_directory()
+
+ # Extract the archive
+ assert {:ok, files} = SafeZip.unzip_file(zip_path, @tmp_dir)
+
+ # Verify files were extracted - handle both relative and absolute paths
+ assert Enum.any?(files, fn file ->
+ Path.basename(file) == "test_file.txt" &&
+ String.contains?(file, "file_in_dir")
+ end)
+
+ assert Enum.any?(files, fn file ->
+ Path.basename(file) == "root_file.txt"
+ end)
+
+ # Verify directory was created
+ dir_path = Path.join(@tmp_dir, "file_in_dir")
+ assert File.exists?(dir_path)
+ assert File.dir?(dir_path)
+
+ # Verify file in directory was extracted
+ file_path = Path.join(dir_path, "test_file.txt")
+ assert File.exists?(file_path)
+ end
+ end
+
+ describe "unzip_data/3" do
+ test "extracts files from zip data" do
+ archive_path = Path.join(@fixtures_dir, "emojis.zip")
+ archive_data = File.read!(archive_path)
+
+ # Extract the archive from data
+ assert {:ok, files} = SafeZip.unzip_data(archive_data, @tmp_dir)
+
+ # Verify files were extracted
+ assert is_list(files)
+ assert length(files) > 0
+
+ # Verify at least one file exists
+ first_file = List.first(files)
+
+ # Simply check that the file exists in the tmp directory
+ assert File.exists?(Path.join(@tmp_dir, Path.basename(first_file)))
+ end
+
+ test "extracts specific files from zip data" do
+ archive_path = Path.join(@fixtures_dir, "emojis.zip")
+ archive_data = File.read!(archive_path)
+
+ # Get list of files in the archive
+ {:ok, all_files} = SafeZip.list_dir_file(archive_path)
+ file_to_extract = List.first(all_files)
+
+ # Extract only one file
+ assert {:ok, extracted_files} =
+ SafeZip.unzip_data(archive_data, @tmp_dir, [file_to_extract])
+
+ # Verify only the specified file was extracted
+ assert Enum.any?(extracted_files, fn path ->
+ Path.basename(path) == Path.basename(file_to_extract)
+ end)
+
+ # Simply check that the file exists in the tmp directory
+ assert File.exists?(Path.join(@tmp_dir, Path.basename(file_to_extract)))
+ end
+
+ test "returns error for invalid zip data" do
+ assert {:error, _} = SafeZip.unzip_data("not a zip file", @tmp_dir)
+ end
+
+ test "creates directories when extracting files in subdirectories from data" do
+ # Create a zip with files in subdirectories
+ zip_path = create_zip_with_directory()
+ archive_data = File.read!(zip_path)
+
+ # Extract the archive from data
+ assert {:ok, files} = SafeZip.unzip_data(archive_data, @tmp_dir)
+
+ # Verify files were extracted - handle both relative and absolute paths
+ assert Enum.any?(files, fn file ->
+ Path.basename(file) == "test_file.txt" &&
+ String.contains?(file, "file_in_dir")
+ end)
+
+ assert Enum.any?(files, fn file ->
+ Path.basename(file) == "root_file.txt"
+ end)
+
+ # Verify directory was created
+ dir_path = Path.join(@tmp_dir, "file_in_dir")
+ assert File.exists?(dir_path)
+ assert File.dir?(dir_path)
+
+ # Verify file in directory was extracted
+ file_path = Path.join(dir_path, "test_file.txt")
+ assert File.exists?(file_path)
+ end
+ end
+
+ # Security tests
+ describe "security checks" do
+ test "prevents path traversal in zip extraction" do
+ # Create a malicious zip file with path traversal
+ malicious_zip_path = create_malicious_zip_with_path_traversal()
+
+ # Try to extract it with SafeZip
+ assert {:error, _} = SafeZip.unzip_file(malicious_zip_path, @tmp_dir)
+
+ # Verify the file was not extracted outside the target directory
+ refute File.exists?(Path.join(Path.dirname(@tmp_dir), "traversal_attempt.txt"))
+ end
+
+ test "prevents directory traversal in zip listing" do
+ # Create a malicious zip file with path traversal
+ malicious_zip_path = create_malicious_zip_with_path_traversal()
+
+ # Try to list files with SafeZip
+ assert {:error, _} = SafeZip.list_dir_file(malicious_zip_path)
+ end
+
+ test "prevents path traversal in zip data extraction" do
+ # Create a malicious zip file with path traversal
+ malicious_zip_path = create_malicious_zip_with_path_traversal()
+ malicious_data = File.read!(malicious_zip_path)
+
+ # Try to extract it with SafeZip
+ assert {:error, _} = SafeZip.unzip_data(malicious_data, @tmp_dir)
+
+ # Verify the file was not extracted outside the target directory
+ refute File.exists?(Path.join(Path.dirname(@tmp_dir), "traversal_attempt.txt"))
+ end
+
+ test "handles zip bomb attempts" do
+ # Create a zip bomb (a zip with many files or large files)
+ zip_bomb_path = create_zip_bomb()
+
+ # The SafeZip module should handle this gracefully
+ # Either by successfully extracting it (if it's not too large)
+ # or by returning an error (if it detects a potential zip bomb)
+ result = SafeZip.unzip_file(zip_bomb_path, @tmp_dir)
+
+ case result do
+ {:ok, _} ->
+ # If it successfully extracts, make sure it didn't fill up the disk
+ # This is a simple check to ensure the extraction was controlled
+ assert File.exists?(@tmp_dir)
+
+ {:error, _} ->
+ # If it returns an error, that's also acceptable
+ # The important thing is that it doesn't crash or hang
+ assert true
+ end
+ end
+
+ test "handles deeply nested directory structures" do
+ # Create a zip with deeply nested directories
+ deep_nest_path = create_deeply_nested_zip()
+
+ # The SafeZip module should handle this gracefully
+ result = SafeZip.unzip_file(deep_nest_path, @tmp_dir)
+
+ case result do
+ {:ok, files} ->
+ # If it successfully extracts, verify the files were extracted
+ assert is_list(files)
+ assert length(files) > 0
+
+ {:error, _} ->
+ # If it returns an error, that's also acceptable
+ # The important thing is that it doesn't crash or hang
+ assert true
+ end
+ end
+ end
+
+ # Helper functions to create test fixtures
+
+ # Creates a zip file with a path traversal attempt
+ defp create_malicious_zip_with_path_traversal do
+ malicious_zip_path = Path.join(@tmp_dir, "path_traversal.zip")
+
+ # Create a file to include in the zip
+ test_file_path = Path.join(@tmp_dir, "test_file.txt")
+ File.write!(test_file_path, "malicious content")
+
+ # Use Erlang's zip module directly to create a zip with path traversal
+ {:ok, charlist_path} =
+ :zip.create(
+ String.to_charlist(malicious_zip_path),
+ [{String.to_charlist("../traversal_attempt.txt"), File.read!(test_file_path)}]
+ )
+
+ to_string(charlist_path)
+ end
+
+ # Creates a zip file with directory entries
+ defp create_zip_with_directory do
+ zip_path = Path.join(@tmp_dir, "with_directory.zip")
+
+ # Create files to include in the zip
+ root_file_path = Path.join(@tmp_dir, "root_file.txt")
+ File.write!(root_file_path, "root file content")
+
+ # Create a directory and a file in it
+ dir_path = Path.join(@tmp_dir, "file_in_dir")
+ File.mkdir_p!(dir_path)
+
+ file_in_dir_path = Path.join(dir_path, "test_file.txt")
+ File.write!(file_in_dir_path, "file in directory content")
+
+ # Use Erlang's zip module to create a zip with directory structure
+ {:ok, charlist_path} =
+ :zip.create(
+ String.to_charlist(zip_path),
+ [
+ {String.to_charlist("root_file.txt"), File.read!(root_file_path)},
+ {String.to_charlist("file_in_dir/test_file.txt"), File.read!(file_in_dir_path)}
+ ]
+ )
+
+ to_string(charlist_path)
+ end
+
+ # Creates a zip bomb (a zip with many small files)
+ defp create_zip_bomb do
+ zip_path = Path.join(@tmp_dir, "zip_bomb.zip")
+
+ # Create a small file to duplicate many times
+ small_file_path = Path.join(@tmp_dir, "small_file.txt")
+ File.write!(small_file_path, String.duplicate("A", 100))
+
+ # Create a list of many files to include in the zip
+ file_entries =
+ for i <- 1..100 do
+ {String.to_charlist("file_#{i}.txt"), File.read!(small_file_path)}
+ end
+
+ # Use Erlang's zip module to create a zip with many files
+ {:ok, charlist_path} =
+ :zip.create(
+ String.to_charlist(zip_path),
+ file_entries
+ )
+
+ to_string(charlist_path)
+ end
+
+ # Creates a zip with deeply nested directories
+ defp create_deeply_nested_zip do
+ zip_path = Path.join(@tmp_dir, "deep_nest.zip")
+
+ # Create a file to include in the zip
+ file_content = "test content"
+
+ # Create a list of deeply nested files
+ file_entries =
+ for i <- 1..10 do
+ nested_path = Enum.reduce(1..i, "nested", fn j, acc -> "#{acc}/level_#{j}" end)
+ {String.to_charlist("#{nested_path}/file.txt"), file_content}
+ end
+
+ # Use Erlang's zip module to create a zip with deeply nested directories
+ {:ok, charlist_path} =
+ :zip.create(
+ String.to_charlist(zip_path),
+ file_entries
+ )
+
+ to_string(charlist_path)
+ end
+end