fortplot_test_helpers.f90 Source File


Source Code

module fortplot_test_helpers
    !! Unified secure test utilities module
    !! Provides comprehensive test file management with proper cleanup
    !! Replaces all previous test helper modules with secure implementation
    
    use iso_fortran_env, only: int32, int64
    use fortplot_system_runtime, only: is_windows, create_directory_runtime, &
                                       delete_file_runtime, normalize_path_separators
    ! No longer need secure module - validation is built-in
    use fortplot, only: figure_t
    implicit none
    private
    
    public :: test_savefig
    public :: test_initialize_figure
    public :: test_cleanup_all
    public :: test_get_temp_path
    public :: test_cleanup_file
    public :: test_initialize_environment
    public :: test_register_file
    public :: test_get_unique_suffix
    
    character(len=*), parameter :: TEMP_DIR_PREFIX = "fortplot_test_"
    character(len=512), save :: current_test_dir = ""
    logical, save :: test_dir_created = .false.
    character(len=512), save :: registered_files(100) = ""
    integer, save :: file_count = 0
    
contains

    function is_safe_path(path) result(safe)
        !! Validate path for basic security (simplified version)
        character(len=*), intent(in) :: path
        logical :: safe
        integer :: i, len_path
        character :: c
        
        safe = .false.
        len_path = len_trim(path)
        
        ! Check basic constraints
        if (len_path == 0 .or. len_path > 512) return
        
        ! Check for directory traversal
        if (index(path, '..') > 0) return
        
        ! Check for null bytes
        if (index(path, char(0)) > 0) return
        
        ! Check for reasonable characters
        do i = 1, len_path
            c = path(i:i)
            if (.not. ((c >= 'a' .and. c <= 'z') .or. &
                       (c >= 'A' .and. c <= 'Z') .or. &
                       (c >= '0' .and. c <= '9') .or. &
                       c == '-' .or. c == '_' .or. c == '.' .or. &
                       c == '/' .or. c == '\' .or. c == ':' .or. &
                       c == ' ')) then
                return
            end if
        end do
        
        safe = .true.
    end function is_safe_path

    function test_get_unique_suffix() result(suffix)
        !! Generate reliable unique suffix combining multiple entropy sources
        character(len=:), allocatable :: suffix
        character(len=64) :: timestamp_str, pid_str, random_str
        integer(int64) :: timestamp
        integer :: pid, random_val, status
        real :: random_real
        
        ! Get high-resolution timestamp
        call system_clock(timestamp)
        write(timestamp_str, '(I0)') timestamp
        
        ! Try to get real process ID from environment
        call get_environment_variable("PPID", pid_str, status=status)
        if (status == 0 .and. len_trim(pid_str) > 0) then
            read(pid_str, *, iostat=status) pid
            if (status /= 0) pid = int(mod(timestamp, 99991_int64)) + 1000
        else
            ! Use timestamp-based fallback with better distribution
            pid = int(mod(timestamp, 99991_int64)) + 1000
        end if
        write(pid_str, '(I0)') pid
        
        ! Add random component
        call random_number(random_real)
        random_val = int(random_real * 8999) + 1000
        write(random_str, '(I0)') random_val
        
        ! Combine all entropy sources
        suffix = trim(timestamp_str) // "_" // trim(pid_str) // "_" // trim(random_str)
    end function test_get_unique_suffix
    
    subroutine ensure_test_directory(unique_suffix)
        !! Ensure test directory exists with proper error handling
        character(len=*), intent(in), optional :: unique_suffix
        character(len=512) :: base_temp
        character(len=:), allocatable :: suffix
        integer :: status
        logical :: success
        
        if (test_dir_created .and. len_trim(current_test_dir) > 0) return
        
        ! Generate or use provided unique suffix
        if (present(unique_suffix)) then
            suffix = trim(unique_suffix)
        else
            suffix = test_get_unique_suffix()
        end if
        
        ! Build platform-appropriate temp directory path
        if (is_windows()) then
            call get_environment_variable("TEMP", base_temp, status=status)
            if (status /= 0 .or. len_trim(base_temp) == 0) then
                call get_environment_variable("TMP", base_temp, status=status)
                if (status /= 0 .or. len_trim(base_temp) == 0) then
                    ! Use current directory as fallback on Windows
                    base_temp = "."
                end if
            end if
            current_test_dir = trim(base_temp) // "\" // TEMP_DIR_PREFIX // trim(suffix)
            current_test_dir = normalize_path_separators(current_test_dir, .true.)
        else
            ! Always use /tmp on Unix systems - it exists and is writable
            current_test_dir = "/tmp/" // TEMP_DIR_PREFIX // trim(suffix)
        end if
        
        ! Validate the generated path
        if (.not. is_safe_path(current_test_dir)) then
            print *, "ERROR: Generated invalid temp directory path"
            current_test_dir = TEMP_DIR_PREFIX // trim(suffix)
        end if
        
        ! Try to create or use the directory
        call create_directory_runtime(current_test_dir, success)
        if (success) then
            test_dir_created = .true.
        else
            ! Check if directory already exists
            inquire(file=trim(current_test_dir)//"/." , exist=test_dir_created)
            if (.not. test_dir_created) then
                ! Try build/test directory which should exist
                current_test_dir = "build/test/" // TEMP_DIR_PREFIX // trim(suffix)
                if (is_windows()) then
                    current_test_dir = normalize_path_separators(current_test_dir, .true.)
                end if
                call create_directory_runtime(current_test_dir, success)
                test_dir_created = success
                
                if (.not. success) then
                    ! Check if build/test exists
                    inquire(file="build/test/." , exist=test_dir_created)
                    if (test_dir_created) then
                        current_test_dir = "build/test"
                        if (is_windows()) then
                            current_test_dir = normalize_path_separators(current_test_dir, .true.)
                        end if
                    else
                        ! Ultimate fallback: use current directory
                        current_test_dir = "."
                        test_dir_created = .true.
                    end if
                end if
            end if
        end if
    end subroutine ensure_test_directory
    
    function test_get_temp_path(filename) result(full_path)
        !! Get secure temporary file path with validation
        character(len=*), intent(in) :: filename
        character(len=:), allocatable :: full_path
        
        ! Validate input filename
        if (.not. is_safe_path(filename)) then
            print *, "ERROR: Invalid filename provided: ", trim(filename)
            full_path = "invalid_filename"
            return
        end if
        
        call ensure_test_directory()
        
        ! If temp directory creation failed, don't create files
        if (.not. test_dir_created .or. len_trim(current_test_dir) == 0) then
            print *, "ERROR: No temp directory available, cannot create test file: ", trim(filename)
            if (is_windows()) then
                full_path = "NUL"  ! Windows null device
            else
                full_path = "/dev/null"  ! Unix null device
            end if
            return
        end if
        
        ! Build path with proper separators
        if (is_windows()) then
            full_path = trim(current_test_dir) // "\" // trim(filename)
            full_path = normalize_path_separators(full_path, .true.)
        else
            full_path = trim(current_test_dir) // "/" // trim(filename)
        end if
        
        ! Final path validation
        if (.not. is_safe_path(full_path)) then
            print *, "ERROR: Generated invalid temp path: ", trim(full_path)
            full_path = trim(filename)  ! Fallback to local file
        end if
    end function test_get_temp_path
    
    subroutine test_initialize_environment(test_name)
        !! Initialize test environment with specific name
        character(len=*), intent(in) :: test_name
        
        ! Reset file registry
        file_count = 0
        registered_files = ""
        
        ! Initialize directory with test name as suffix
        call ensure_test_directory(test_name)
    end subroutine test_initialize_environment
    
    subroutine test_register_file(filename)
        !! Register a file for automatic cleanup
        character(len=*), intent(in) :: filename
        
        if (file_count < size(registered_files)) then
            file_count = file_count + 1
            registered_files(file_count) = filename
        else
            print *, "WARNING: Test file registry full, cannot register: ", trim(filename)
        end if
    end subroutine test_register_file
    
    subroutine test_cleanup_file(filename)
        !! Clean up a specific test file
        character(len=*), intent(in) :: filename
        character(len=:), allocatable :: full_path
        logical :: success
        
        full_path = test_get_temp_path(filename)
        call delete_file_runtime(full_path, success)
        
        if (.not. success) then
            print *, "WARNING: Could not delete test file: ", trim(full_path)
        end if
    end subroutine test_cleanup_file
    
    subroutine test_initialize_figure(fig, width, height, backend)
        !! Initialize figure for testing (ensures temp directory exists)
        type(figure_t), intent(out) :: fig
        integer, intent(in) :: width, height
        character(len=*), intent(in) :: backend
        
        call ensure_test_directory()
        call fig%initialize(width, height, backend)
    end subroutine test_initialize_figure
    
    subroutine test_savefig(fig, filename)
        !! Save figure to temporary directory with automatic registration
        type(figure_t), intent(inout) :: fig
        character(len=*), intent(in) :: filename
        character(len=:), allocatable :: temp_path
        
        temp_path = test_get_temp_path(filename)
        call test_register_file(filename)
        call fig%savefig(temp_path)
    end subroutine test_savefig
    
    subroutine test_cleanup_all()
        !! Clean up all registered files and test directory securely
        logical :: success
        integer :: i
        character(len=:), allocatable :: file_path
        
        if (.not. test_dir_created .or. len_trim(current_test_dir) == 0) return
        
        ! First, clean up all registered files individually
        do i = 1, file_count
            if (len_trim(registered_files(i)) > 0) then
                file_path = test_get_temp_path(registered_files(i))
                call delete_file_runtime(file_path, success)
                ! Don't warn on individual file cleanup failures
            end if
        end do
        
        ! Try to remove the test directory itself
        ! This is safe since we validate paths and use only our created directories
        call delete_directory_secure(current_test_dir, success)
        
        ! Reset state
        test_dir_created = .false.
        current_test_dir = ""
        file_count = 0
        registered_files = ""
    end subroutine test_cleanup_all
    
    subroutine delete_directory_secure(dir_path, success)
        !! Securely delete a directory using runtime system calls
        character(len=*), intent(in) :: dir_path
        logical, intent(out) :: success
        character(len=:), allocatable :: command
        integer :: exitstat, cmdstat
        character(len=256) :: cmdmsg
        
        success = .false.
        
        ! Validate the directory path before deletion
        if (.not. is_safe_path(dir_path)) then
            return
        end if
        
        ! Only delete directories we created (must contain our prefix)
        if (index(dir_path, TEMP_DIR_PREFIX) == 0) then
            return
        end if
        
        ! SECURITY: Directory deletion requires external command execution
        ! This functionality is disabled for security compliance
        success = .false.
    end subroutine delete_directory_secure

end module fortplot_test_helpers