# ClamTk, copyright (C) 2004-2021 Dave M
#
# This file is part of ClamTk
# (https://gitlab.com/dave_m/clamtk/).
#
# ClamTk is free software; you can redistribute it and/or modify it
# under the terms of either:
#
# a) the GNU General Public License as published by the Free Software
# Foundation; either version 1, or (at your option) any later version, or
#
# b) the "Artistic License".
package ClamTk::Scan;

use Glib 'TRUE', 'FALSE';
use File::Find;
use Cwd 'chdir';

# use strict;
# use warnings;
$| = 1;

use constant HATE_GNOME_SHELL    => -6;
use constant DESTROY_GNOME_SHELL => -11;

use POSIX 'locale_h', 'strftime';
use File::Basename 'basename', 'dirname', 'fileparse';
use Locale::gettext;
use Encode 'decode';

binmode( STDIN,  ':utf8' );
binmode( STDOUT, ':utf8' );

my $SCAN;     # File handle for scanning
my $found;    # Holds information of bad stuff found

my $found_count = 0;    # Scalar number of bad stuff found
my $num_scanned = 0;    # Overall number of files scanned
my %dirs_scanned;       # Directories scanned

my $hb;                     # HeaderBar
my $pb;                     # ProgressBar
my $pb_file_counter = 0;    # For ProgressBar
my $pb_step;                # For ProgressBar
my $root_scan = FALSE;      # Scanning /
my $scan_pid;               # PID of scanner, for killing/cancelling scan

my $stopped = 1;            # Whether scanner is stopped (1) or running (0)
my $directive;              # Options sent to scanner
my $topbar;                 # InfoBar on top
my $bottombar;              # InfoBar on bottom
my $files_scanned_label;    # Label
my $threats_label;          # Label
my $show;                   # Whether or not to show the preferences button
my $window;                 # Main window/dialog
my $from_cli;               # from the commandline?

sub filter {
    # $pkg_name = drop this
    # $scanthis = file or directory to be scanned
    # $show = whether or not to show the preferences button;
    #   we do if it's a commandline or right-click scan; otherwise we don't
    # $from = from the commandline?
    my ( $pkg_name, $scanthis, $show, $from ) = @_;
    $from_cli = $from;

    # Currently just to test permissions:
    # If given a file/directory from the commandline
    # AND we can't scan it, just die.
    # However, if interface is running
    # AND we can't scan it, just return to interface.
    if ( !sanity_check( $scanthis ) ) {
        if ( $from && $from eq 'startup' ) {
            Gtk3->main_quit;
        } else {
            return;
        }
    }

    # We're gonna need these:
    my $paths = ClamTk::App->get_path( 'all' );
    my %prefs = ClamTk::Prefs->get_all_prefs();

    # Don't bother doing anything if clamscan can't be found
    if ( !-e $paths->{ clampath } ) {
        warn "Cannot scan without clamscan!\n";
        return;
    }

    # Begin popup scanning
    $window
        = Gtk3::Dialog->new( undef, undef,
        [ qw| modal destroy-with-parent use-header-bar | ],
        );
    $window->set_deletable( FALSE );
    $window->set_default_size( 450, 80 );

    $hb = Gtk3::HeaderBar->new;
    $window->set_titlebar( $hb );
    $hb->set_title( _( 'Please wait...' ) );
    $hb->set_show_close_button( TRUE );
    $hb->set_decoration_layout( 'menu:close' );

    $window->signal_connect(
        'destroy' => sub {
            if ( !$stopped ) {
                return TRUE;
            } else {
                $window->destroy;
                1;
            }
        }
    );
    $window->set_border_width( 5 );
    $window->set_position( 'center-on-parent' );

    my $images_dir = ClamTk::App->get_path( 'images' );
    if ( -e "$images_dir/clamtk.png" ) {
        my $pixbuf
            = Gtk3::Gdk::Pixbuf->new_from_file( "$images_dir/clamtk.png" );
        my $transparent = $pixbuf->add_alpha( TRUE, 0xff, 0xff, 0xff );
        $window->set_icon( $transparent );
    }

    my $eb = Gtk3::EventBox->new;
    $window->get_content_area->add( $eb );

    my $box = Gtk3::VBox->new( FALSE, 5 );
    $eb->add( $box );

    my $hbox = Gtk3::HBox->new( FALSE, 0 );
    $box->add( $hbox );

    $topbar = Gtk3::InfoBar->new;
    $hbox->pack_start( $topbar, TRUE, TRUE, 5 );

    Gtk3::main_iteration while Gtk3::events_pending;
    $topbar->set_message_type( 'other' );
    set_infobar_text( $topbar, _( 'Preparing...' ) );
    Gtk3::main_iteration while Gtk3::events_pending;

    $pb = Gtk3::ProgressBar->new;
    $box->pack_start( $pb, FALSE, FALSE, 5 );
    $window->{ pb } = $pb;

    # reset numbers
    reset_stats();

    $files_scanned_label
        = Gtk3::Label->new( sprintf _( "Files scanned: %d" ), $num_scanned );
    $files_scanned_label->set_alignment( 0.0, 0.5 );

    $threats_label
        = Gtk3::Label->new( sprintf _( "Possible threats: %d" ),
        $found_count );
    $threats_label->set_alignment( 0.0, 0.5 );

    my $text_box = Gtk3::VBox->new( FALSE, 5 );
    $text_box->add( $files_scanned_label );
    $text_box->add( $threats_label );

    $bottombar = Gtk3::InfoBar->new;
    $box->pack_start( $bottombar, FALSE, FALSE, 5 );

    $bottombar->set_message_type( 'other' );
    # Stupid infobars
    # $use_image = ClamTk::Icons->get_image( 'gtk-cancel' );
    $use_image = 'gtk-cancel';
    $bottombar->add_button( $use_image, HATE_GNOME_SHELL );
    if ( $show ) {
        # $use_image = ClamTk::Icons->get_image( 'preferences-system' );
        $use_image = ClamTk::Icons->get_image( 'gtk-preferences' );
        $bottombar->add_button( $use_image, DESTROY_GNOME_SHELL );
    }
    $bottombar->signal_connect(
        response => sub {
            my ( $bar, $button ) = @_;
            if ( $button eq 'cancel' ) {
                cancel_scan();
            } elsif ( $button eq 'help' ) {
                system( 'clamtk &' );
                return FALSE;
            }
        }
    );
    $bottombar->get_content_area->add( $text_box );

    $window->show_all;
    $window->set_gravity( 'south-east' );
    $window->queue_draw;
    $window->set_position( 'mouse' );
    Gtk3::main_iteration while Gtk3::events_pending;

    # Try to avoid MS Windows file systems...
    # This fubars Live ISOs: see
    # https://github.com/dave-theunsub/clamtk/issues/67
    # $directive .= ' --cross-fs=no';

    # Try to avoid scanning emails...
    $directive .= ' --scan-mail=no';

    # I didn't know we had to explicitly state this.
    # https://github.com/dave-theunsub/clamtk/issues/59
    $directive .= ' --scan-archive=yes';

    # Increase maximum amount of data to scan for each container file;
    # goes hand in hand with the $directive above
    # https://github.com/dave-theunsub/clamtk/issues/59
    $directive .= ' --max-scansize=500M';

    # By default, we ignore .gvfs directories.
    # Once we figure out KDE's process, we'll exclude that too.
    #<<<
    for my $m (
            'smb4k',
            "/run/user/$ENV{USER}/gvfs",
            "$ENV{HOME}/.gvfs" ) {
                $directive .= " --exclude-dir=$m";
    }
    #>>>

    # Now strip whitelisted directories
    for my $ignore (
        split(
            /;/,
            ClamTk::Prefs->get_preference( 'Whitelist' )
                . $paths->{ whitelist_dir }
        )
        )
    {
        # warn "excluding $ignore\n";
        # --exclude-dir=REGEX  Don't scan directories matching REGEX
        # Using REGEX is important because users could have some
        # of the whitelisted domains as part of a directory that
        # should be scanned.
        # Github #61 - https://github.com/dave-theunsub/clamtk/issues/61
        $directive .= " --exclude-dir=^" . quotemeta( $ignore );
    }

    # Remove mail directories and some backup directories for now.
    # Not all of these can be appended to $HOME for a more
    # specific path - kmail (e.g.) is somewhere
    # under $HOME/.kde/blah/foo/...
    my @maildirs = qw(
        .thunderbird	.mozilla-thunderbird
        Mail	kmail   evolution   timeshift
    );
    for my $mailbox ( @maildirs ) {
        # warn "excluding mailbox directory $mailbox\n";
        $directive .= " --exclude-dir=$mailbox";
    }

    # remove the hidden files if chosen:
    if ( !$prefs{ ScanHidden } ) {
        # But only if Trash directory is not being scanned
        if ( $scanthis !~ m#/.local/share/Trash# ) {
            $directive .= ' --exclude="\/\."';
        }
    }

    # symlinks:
    # The symlink stuff from clamscan requires >= 0.97.
    my ( $version ) = ClamTk::App->get_AV_version();
    # Ensure it's just digits and dots:
    $version =~ s/[^0-9\.]//g;
    $version =~ s/^[0\.]+//;
    $version =~ s/\.0$//;
    warn "clamscan version = >$version<\n";
    if (   ( $version <=> '97' ) == 0
        || ( $version <=> '97' ) == 1 )
    {
        $directive .= ' --follow-dir-symlinks=1';
        $directive .= ' --follow-file-symlinks=1';
    }
    # This feature mitigates the risk of malformed media files intended
    # to exploit vulnerabilities in other software. At present, media
    # validation exists for JPEG, TIFF, PNG and GIF files.
    if ( $version >= '103.1' ) {
        $directive .= ' --alert-broken-media=yes';
    }

    # we'll count this as ! $stopped
    #$stopped = 0;

    # reset %$found
    $found = {};

    # These lines are for 'thorough'. :)
    # If it's selected, we add detection for both
    # potentially unwanted applications and broken executables.
    # Version 0.101.0 changed and added some options.
    if ( $prefs{ Thorough } ) {
        if (   ( $version <=> '101' ) == 0
            || ( $version <=> '101' ) == 1 )
        {
            $directive .= ' --detect-pua --alert-broken' . ' --alert-macros';
        } else {
            $directive .= ' --detect-pua --algorithmic-detection';
        }
    } else {
        if (   ( $version cmp '101' ) == 0
            || ( $version cmp '101' ) == 1 )
        {
            $directive =~ s/\s--detect-pua --alert-broken --alert-macros'//;
        } else {
            $directive =~ s/\s--detect-pua --algorithmic-detection//;
        }
    }

    # Heuristic scanning
    if ( $prefs{ Heuristic } ) {
        # By default, if included, == yes
        $directive .= ' --heuristic-alerts=yes';
    } else {
        $directive .= ' --heuristic-alerts=no';
    }

    # only a single file

    # By default, 20Mb is the largest we go -
    # unless the preference is to ignore size.
    if ( !$prefs{ SizeLimit } ) {
        $directive .= ' --max-filesize=20M';
    }

    if ( !$prefs{ Recursive } ) {
        $directive .= ' --max-dir-recursion=1';
    } else {
        $directive .= ' --recursive=yes';
    }

    scan( $scanthis, $directive );

    clean_up();
}

sub scan {
    my ( $path_to_scan, $directive ) = @_;
    chomp( $path_to_scan );
    chomp( $directive );

    $pb_step = get_step( $path_to_scan );
    if ( $pb_step ) {
        $pb->set_pulse_step( $pb_step );
    } else {
        $pb->{ timer } = Glib::Timeout->add( 200, \&progress_timeout, $pb );
        $root_scan = TRUE;
    }

    $pb->show;

    my $quoted = quotemeta( $path_to_scan );
    chomp( $quoted );

    # Leave if we have no real path
    if ( !$path_to_scan ) {
        warn "No path to scan!\n";
        return;
    }

    my $paths   = ClamTk::App->get_path( 'all' );
    my $command = $paths->{ clamscan };

    # Use the user's sig db if it's selected
    if ( ClamTk::Prefs->get_preference( 'Update' ) eq 'single' ) {
        $command .= " --database=$paths->{db}";
    }

    # Implicit fork; gives us the PID of clamscan so we can
    # kill it if the user hits the Stop button
    #<<<
    # Using the verbose (-v) switch gives us the next file
    # for the display "Scanning $1..."
    $scan_pid
        = open( $SCAN, '-|', "$command $directive -v $quoted 2>&1" );
    defined( $scan_pid ) or die "couldn't fork: $!\n";
    $window->queue_draw;
    Gtk3::main_iteration while Gtk3::events_pending;
    #>>>
    # binmode( $SCAN, ':utf8:bytes' );

    Gtk3::main_iteration while Gtk3::events_pending;
    while ( <$SCAN> ) {
        chomp;
        $window->queue_draw;
        Gtk3::main_iteration while Gtk3::events_pending;

        # Warning stuff we don't need
        next if ( /^LibClamAV/ );
        next if ( /^\s*$/ );

        if ( /^Scanning (.*?)$/ ) {
            my $base = decode( 'UTF-8', basename( $1 ) );
            # Display stuff in popup infobar
            set_infobar_text(
                $topbar,
                # sprintf( _( 'Scanning %s...' ), $dirname )
                # sprintf( _( 'Scanning %s...' ), $dirname )
                sprintf( _( 'Scanning %s...' ), $base )
            );
            $num_scanned++;
            if ( !$root_scan ) {
                my $pb_current = $pb->get_fraction;

                if (   $pb_current + $pb_step <= 0
                    || $pb_current + $pb_step >= 1.0 )
                {
                    $pb_current = .99;
                } else {
                    $pb_current += $pb_step;
                }
                $pb->set_fraction( $pb_current );
            }

            Gtk3::main_iteration while Gtk3::events_pending;
            $files_scanned_label->set_text( sprintf _( "Files scanned: %d" ),
                $num_scanned );
            $topbar->show_all;
            Gtk3::main_iteration while Gtk3::events_pending;
            $window->queue_draw;
            next;
        }

        my ( $file, $status );
        if ( /(.*?): ([^:]+) FOUND/ ) {
            $file   = $1;
            $status = $2;
        }

        # Ensure the file is still there (things get moved)
        # and that it got scanned
        next unless ( $file && -e $file && $status );
        next if ( $status =~ /module failure/ );

        chomp( $file )   if ( defined $file );
        chomp( $status ) if ( defined $status );

        my $dirname   = decode( 'UTF-8', dirname( $file ) );
        my $fileparse = decode( 'UTF-8', fileparse( $file ) );

        my $hidden = ClamTk::Prefs->get_preference( 'ScanHidden' );
        next if ( !$hidden && basename( $fileparse ) =~ /^\./ );

        my $dirparse;
        if ( length( $dirname ) > 35 ) {
            $dirparse = substr $dirname, 0, 35;
        } else {
            $dirparse = $dirname;
        }

        # Lots of temporary things under /tmp/clamav;
        # we'll just ignore them.
        $dirs_scanned{ $dirname } = 1
            unless ( dirname( $file ) =~ /\/tmp\/clamav/
            || dirname( $file ) eq '.' );

        # Do not show files in archives - we just want the end-result.
        # It still scans and we still show the result.
        next if ( $file =~ /\/tmp\/clamav/ );

        # $status is the "virus" name.
        $status =~ s/\s+FOUND$//;

        # These aren't necessarily clean (despite the variable's name)
        # - we just don't want them counted as viruses
        my $clean_words = join( '|',
            'OK',                 'Zip module failure',
            "RAR module failure", 'Encrypted.PDF',
            'Encrypted.RAR',      'Encrypted.Zip',
            'Empty file',         'Excluded',
            'Input/Output error', 'Files number limit exceeded',
            'handler error',      'Broken.Executable',
            'Oversized.Zip',      'Symbolic link' );

        if ( $status !~ /$clean_words/ ) {    # a virus
            $found->{ $found_count }->{ name }   = $file;
            $found->{ $found_count }->{ status } = $status;
            $found->{ $found_count }->{ action } = _( 'None' );
            $found_count++;
            $threats_label->set_text( sprintf _( "Possible threats: %d" ),
                $found_count );
        }

    }

    Gtk3::main_iteration while Gtk3::events_pending;

    # Done scanning - close filehandle and return to
    # filter() and then to clean-up
    close( $SCAN );    # or warn "Unable to close scanner! $!\n";
}

sub cancel_scan {
    kill 15, $scan_pid + 1;
    waitpid( $scan_pid + 1, 0 ) if ( $scan_pid + 1 );
    kill 15, $scan_pid if ( $scan_pid );
    waitpid( $scan_pid, 0 ) if ( $scan_pid );

    close( $SCAN );
    $stopped = 1;
}

sub clean_up {
    set_infobar_text( $topbar, _( 'Cleaning up...' ) );
    $pb->set_fraction( 1.00 );
    destroy_progress();

    destroy_buttons();
    add_closing_buttons();

    $hb->set_title( _( 'Complete' ) );
    my $message = '';
    if ( !$found_count ) {
        $message = _( 'Scanning complete' );
    } else {
        $message = _( 'Possible threats found' );
    }
    set_infobar_text( $topbar, $message );

    # Save scan information
    logit();

    if ( $found_count ) {
        ClamTk::Results->show_window( $found, $window );
        if ( $from_cli ) {
            Gtk3->main_quit;
        }
    } else {
        bad_popup();
    }

    # reset numbers
    reset_stats();
}

sub destroy_progress {
    Glib::Source->remove( $window->{ pb }->{ timer } )
        if ( $root_scan );

    return FALSE;
}

sub reset_stats {
    # reset things
    $num_scanned     = 0;
    $found_count     = 0;
    $pb_file_counter = 0;
    $pb_step         = 0;
    %dirs_scanned    = ();
    $stopped         = 1;
    $directive       = '';
    $root_scan       = FALSE;
}

sub bad_popup {
    my $dialog = Gtk3::MessageDialog->new(
        $window, [ qw| modal destroy-with-parent | ],
        'info',  'close', _( 'No threats found' ),
    );
    $dialog->run;
    $dialog->destroy;
}

sub logit {
    my $db_total = ClamTk::App->get_sigtool_info( 'count' );
    # warn "in Scan: db_total = >$db_total<\n";
    my $REPORT;    # filehandle for histories log

    #<<<
    my ( $mon, $day, $year )
        = split / /, strftime( '%b %d %Y', localtime );

    # Save date of scan
    if ( $found_count > 0 ) {
        ClamTk::Prefs->set_preference(
                'LastInfection', "$day $mon $year"
        );
    }
    #>>>

    my %prefs = ClamTk::Prefs->get_all_prefs();
    my $paths = ClamTk::App->get_path( 'history' );

    my $virus_log
        = $paths . "/" . decode( 'UTF-8', "$mon-$day-$year" ) . '.log';

    #<<<
    # sort the directories scanned for display
    my @sorted = sort { $a cmp $b } keys %dirs_scanned;
    if ( open $REPORT, '>>:encoding(UTF-8)', $virus_log ) {
        print $REPORT "\nClamTk, v",
            ClamTk::App->get_TK_version(), "\n",
            scalar localtime,
            "\n";
        print $REPORT sprintf _(
                "ClamAV Signatures: %d\n" ),
                $db_total;
        print $REPORT _( "Directories Scanned:\n" );
        for my $list ( @sorted ) {
            print $REPORT "$list\n";
        }
        printf $REPORT _(
            "\nFound %d possible %s (%d %s scanned).\n\n" ),
            $found_count,
            $found_count == 1 ? _( 'threat' ) : _( 'threats' ),
            $num_scanned,
            $num_scanned == 1 ? _( 'file' ) : _( 'files' );
    } else {
            warn "Could not write to logfile. Check permissions.\n";
    }
    #>>>

    # Set the minimum sizes for the two columns,
    # the filename and its status - if we're saving a log (which we
    # do, by default)
    my $lsize = 20;
    my $rsize = 20;
    if ( $found_count == 0 ) {
        print $REPORT _( "No threats found.\n" );
    } else {
        # Now get the longest lengths of the column contents.
        for my $length ( sort keys %$found ) {
            $lsize
                = ( length( $found->{ $length }->{ name } ) > $lsize )
                ? length( $found->{ $length }->{ name } )
                : $lsize;
            $rsize
                = ( length( $found->{ $length }->{ status } ) > $rsize )
                ? length( $found->{ $length }->{ status } )
                : $rsize;
        }
        # Set a buffer which is probably unnecessary.
        $lsize += 5;
        $rsize += 5;
        # Print to the log:
        for my $num ( sort keys %$found ) {
            printf $REPORT "%-${lsize}s %-${rsize}s\n",
                decode( 'utf8', $found->{ $num }->{ name } ),
                $found->{ $num }->{ status };
        }
    }

    print $REPORT '-' x ( $lsize + $rsize + 5 ), "\n";
    close( $REPORT );

    return;
}

sub set_infobar_text {
    my ( $bar, $text ) = @_;

    Gtk3::main_iteration while Gtk3::events_pending;
    for my $c ( $bar->get_content_area->get_children ) {
        if ( $c->isa( 'Gtk3::Label' ) ) {
            $c->set_text( $text );
            Gtk3::main_iteration while Gtk3::events_pending;
            return;
        }
    }

    #<<<
    my $label = Gtk3::Label->new;
    $label->set_text( _( $text ) );
    $label->set_alignment( 0.0, 0.5 );
    $label->set_ellipsize( 'middle' );
    $bar->get_content_area->add(
            #Gtk3::Label->new( _( $text ) )
            $label
    );
    #>>>
    $window->queue_draw;
    Gtk3::main_iteration while Gtk3::events_pending;
}

sub add_default_buttons {
    # We're going to show the following:
    # cancel-button: obviously cancels the scan
    # prefs-button: allows popup 'clamtk' with no args, for settings.
    # We don't even need translations for this.
    $bottombar->add_button( 'gtk-cancel',      HATE_GNOME_SHELL, );
    $bottombar->add_button( 'gtk-preferences', DESTROY_GNOME_SHELL, );

    $bottombar->signal_connect(
        response => sub {
            my ( $bar, $response ) = @_;
            if ( $response eq 'cancel' ) {
                cancel_scan();
                return TRUE;
            } elsif ( $response eq 'help' ) {
                system( 'clamtk &' );
                return FALSE;
            }
        }
    );
}

sub add_closing_buttons {
    $bottombar->add_button( 'gtk-close', -7 );
    if ( $show ) {
        $bottombar->add_button( 'gtk-preferences', DESTROY_GNOME_SHELL, );
    }

    $bottombar->signal_connect(
        response => sub {
            my ( $bar, $response ) = @_;
            if ( $response eq 'close' ) {
                $window->destroy;
            } elsif ( $response eq 'help' ) {
                system( 'clamtk &' );
                return FALSE;
            }
        }
    );
    $bottombar->show_all;
}

sub destroy_buttons {
    for my $c ( $bottombar->get_action_area->get_children ) {
        if ( $c->isa( 'Gtk3::Button' ) ) {
            $c->destroy;
        }
    }
    return TRUE;
}

sub get_step {
    my $dir = shift;

    my $recur = ClamTk::Prefs->get_preference( 'Recursive' );

    return if ( $dir eq '/' );

    if ( $recur ) {
        find( \&wanted, $dir );
    } else {
        find( { wanted => \&wanted, preprocess => \&nodirs }, $dir );
    }

    #find( { \&wanted, no_chdir => ( $recur ) ? 0 : 1 }, $dir );

    if ( !$pb_file_counter ) {
        return;
    }

    return 1 / $pb_file_counter;
}

sub nodirs {
    grep !-d, @_;
}

sub progress_timeout {
    $pb->pulse;

    return TRUE;
}

sub wanted {
    my $file = $_;
    return unless ( -f $file );
    my $hidden = ClamTk::Prefs->get_preference( 'ScanHidden' );
    next if ( !$hidden && $file =~ /^\./ );
    $pb_file_counter++;
}

sub sanity_check {
    my $check = shift;

    if ( -d $check ) {
        if ( !chdir( $check ) || $check =~ m#^(/proc|/sys|/dev)# ) {
            popup(
                _(  'You do not have permissions to scan that file or directory'
                )
            );
            reset_stats();
            return 0;
        } else {
            return 1;
        }
    } elsif ( -f $check ) {
        if ( !-r $check || $check =~ m#^(/proc|/sys|/dev)# ) {
            popup(
                _(  'You do not have permissions to scan that file or directory'
                )
            );
            reset_stats();
            return 0;
        } else {
            return 1;
        }
    }
}

sub popup {
    my ( $message, $option ) = @_;

    my $dialog = Gtk3::MessageDialog->new(
        undef,    # no parent
        [ qw| modal destroy-with-parent | ],
        'info',
        $option ? 'ok-cancel' : 'close',
        $message,
    );

    if ( 'ok' eq $dialog->run ) {
        $dialog->destroy;
        return TRUE;
    }
    $dialog->destroy;

    return FALSE;
}

1;
