# Vend::File - Interchange file functions
#
# Copyright (C) 2002-2021 Interchange Development Group
# Copyright (C) 1996-2002 Red Hat, Inc.
#
# This program was originally based on Vend 0.2 and 0.3
# Copyright 1995 by Andrew M. Wilcox <amw@wilcoxsolutions.com>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public
# License along with this program; if not, write to the Free
# Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston,
# MA  02110-1301  USA.

package Vend::File;
require Exporter;

@ISA = qw(Exporter);

@EXPORT = qw(
	absolute_or_relative
	allowed_file
	catfile
	exists_filename
	file_allow
	file_modification_time
	file_name_is_absolute
	get_filename
	lockfile
	log_file_violation
	readfile
	readfile_db
	set_lock_type
	unlockfile
	writefile
);

use strict;
use Config;
use Fcntl;
use Errno;

my $PERLQQ = 0x0100; # from Encode(3perl)

unless( $ENV{MINIVEND_DISABLE_UTF8} ) {
	require Encode;
	import Encode qw( is_utf8 );
}

use Vend::Util;
use File::Path;
use File::Copy;
use File::Temp;

use subs qw(logError logGlobal);
use vars qw($VERSION @EXPORT @EXPORT_OK $errstr);

$VERSION = '2.35';

sub writefile {
    my($file, $data, $opt) = @_;
	ref($opt) or $opt = {};
	my ($encoding, $fallback, $save_umask);

	if ($::Variable->{MV_UTF8} || $Global::Variable->{MV_UTF8}) {
		$encoding = $opt->{encoding} ||= 'utf8';
		undef $encoding if $encoding eq 'raw';
		$fallback = $opt->{fallback};
		$fallback = $PERLQQ unless defined $fallback;
	}

	$file = ">>$file" unless $file =~ /^[|>]/;

	if ($opt->{umask}) {
		$save_umask = umask oct($opt->{umask});
	}

    eval {
		unless($file =~ s/^[|]\s*//) {
			if ($opt->{auto_create_dir}) {
				my $dir = $file;
				$dir =~ s/>+//;
				$dir =~ s:[\r\n]::g;   # Just in case
				$dir =~ s:(.*)/.*:$1: or $dir = '';
				if($dir and ! -d $dir) {
					eval{
						File::Path::mkpath($dir);
					};
					die "mkpath\n" unless -d $dir;
				}
			}
			# We have checked for beginning > or | previously
			open(MVLOGDATA, $file) or die "open\n";
			local $PerlIO::encoding::fallback = $fallback if $encoding;
			binmode(MVLOGDATA, ":encoding($encoding)") if $encoding;

			lockfile(\*MVLOGDATA, 1, 1) or die "lock\n";
			seek(MVLOGDATA, 0, 2) or die "seek\n";
			if(ref $data) {
				print(MVLOGDATA $$data) or die "write to\n";
			}
			else {
				print(MVLOGDATA $data) or die "write to\n";
			}
			unlockfile(\*MVLOGDATA) or die "unlock\n";
		}
		else {
            my (@args) = grep /\S/, Text::ParseWords::shellwords($file);
			open(MVLOGDATA, "|-") || exec @args;
            if ($encoding) {
                local $PerlIO::encoding::fallback = $fallback;
                binmode(MVLOGDATA, ":encoding($encoding)");
            }
			if(ref $data) {
				print(MVLOGDATA $$data) or die "pipe to\n";
			}
			else {
				print(MVLOGDATA $data) or die "pipe to\n";
			}
		}
		close(MVLOGDATA) or die "close\n";
    };

	my $status = 1;
    if ($@) {
		::logError ("Could not %s file '%s': %s\nto write this data:\n%s",
				$@,
				$file,
				$!,
				substr(ref($data) ? $$data : $data,0,120),
				);
		$status = 0;
    }

    umask $save_umask if defined $save_umask;

	return $status;
}


# writefile_atomic() is similar to writefile(), but:
# Only writes entire files (no appending or writing to a pipe).
# Writes first to a temporary file and then renames that into place at the expected file name, which is an atomic operation in POSIX filesystems.
#
# This removes the race conditions in writefile() where:
# * a reader can see an empty file right after truncation
# * a reader can see a a partially-written file
# * concurrent writers can double output in the file due to the lock race and seek to end (which is only needed for appending, not overwriting)
#
# It also removes the need for locking.

sub writefile_atomic {
    my ($path, $data, $opt) = @_;

    if ($path =~ /^\s*(?:>>|<|\+|\|)/) {
        ::logError(__PACKAGE__ . "::writefile_atomic can only write an entire file; invalid prefix: $path");
        return;
    }

    # Tolerate unneeded leading > for compatibility
    $path =~ s/^\s*>\s*//;

    my ($encoding, $fallback, $save_umask, $tmpfile, $status);
    ref($opt) or $opt = {};

    if ($::Variable->{MV_UTF8} || $Global::Variable->{MV_UTF8}) {
        $encoding = $opt->{encoding} || 'utf8';
        undef $encoding if $encoding eq 'raw';
        $fallback = $opt->{fallback} // $PERLQQ;
    }

    if ($opt->{umask}) {
        $save_umask = umask oct($opt->{umask});
    }

    eval {
        my $dir = $path;
        $dir =~ s:[\r\n]::g;   # Just in case
        my $file;
        if ($dir =~ s:(.*)/(.*):$1:) {
            $file = $2;
        }
        else {
            $dir = '';
            $file = 'temp';
        }

        if ($dir and $opt->{auto_create_dir} and ! -d $dir) {
            File::Path::mkpath($dir);
            die "mkpath\n" unless -d $dir;
        }

        my $fh;
        ($fh, $tmpfile) = File::Temp::tempfile($file . '.XXXXXXXX', DIR => $dir);
        $fh or die "open\n";
        # File::Temp doesn't respect umask, so correct the file mode
        chmod 0666 &~ umask(), $tmpfile or die "chmod\n";
        if ($encoding) {
            local $PerlIO::encoding::fallback = $fallback;
            binmode $fh, ":encoding($encoding)";
        }
        print $fh ref($data) ? $$data : $data or die "write\n";
        close $fh or die "close\n";
        rename $tmpfile, $path or die "rename\n";
    };
    my $err = $@;
    if ($err) {
        chomp $err;
        my $msg = ($err !~ /\W/)
            ? sprintf("Could not %s file '%s': %s",
                $err,
                $tmpfile,
                $!,
                )
            : sprintf("Error saving file '%s': %s", $path, $err);
        ::logError("%s\nto write this data (excerpt):\n%s",
            $msg,
            substr(ref($data) ? $$data : $data, 0, 120),
        );
    }
    else {
        $status = 1;
    }

    umask $save_umask if defined $save_umask;

    return $status;
}

sub file_modification_time {
    my ($fn, $tolerate) = @_;
    my @s = stat($fn) or ($tolerate and return 0) or die "Can't stat '$fn': $!\n";
    return $s[9];
}

sub readfile_db {
	my ($name) = @_;
	return unless $Vend::Cfg->{FileDatabase};
	my ($tab, $col) = split /:+/, $Vend::Cfg->{FileDatabase};
	my $db = $Vend::Interpolate::Db{$tab} || ::database_exists_ref($tab)
		or return undef;
#::logDebug("tab=$tab exists, db=$db");

	# I guess this is the best test
	if($col) {
		return undef unless $db->column_exists($col);
	}
	elsif ( $col = $Global::Variable->{LANG} and $db->column_exists($col) ) {
		#do nothing
	}
	else {
		$col = 'default';
		return undef unless $db->column_exists($col);
	}

#::logDebug("col=$col exists, db=$db");
	return undef unless $db->record_exists($name);
#::logDebug("ifile=$name exists, db=$db");
	return $db->field($name, $col);
}

# Reads in an arbitrary file.  Returns the entire contents,
# or undef if the file could not be read.
# Careful, needs the full path, or will be read relative to
# VendRoot..and will return binary. Should be tested by
# the user.
#
# To ensure security in multiple catalog setups, leading /
# is not allowed if $Global::NoAbsolute) is true and the file
# is not part of the TemplateDir, VendRoot, or is owned by the
# defined CatalogUser.

# If catalog FileDatabase is enabled and there are no contents, we can retrieve
# the file from the database.

sub readfile {
    my($ifile, $no, $loc, $opt) = @_;
    my($contents,$encoding,$fallback);
    local($/);

	$opt ||= {};
	
	if ($::Variable->{MV_UTF8} || $Global::Variable->{MV_UTF8}) {
		$encoding = $opt->{encoding} ||= 'utf8';
		$fallback = $opt->{fallback};
		$fallback = $PERLQQ unless defined $fallback;
		undef $encoding if $encoding eq 'raw';
	}
	
	unless(allowed_file($ifile)) {
		log_file_violation($ifile);
		return undef;
	}

	my $file;

	if (file_name_is_absolute($ifile) and -f $ifile) {
		$file = $ifile;
	}
	else {
		for (".", @{$Vend::Cfg->{TemplateDir} || []}, @{$Global::TemplateDir || []}) {
			my $candidate = "$_/$ifile";
			log_file_violation($candidate), next if ! allowed_file($candidate);
			next if ! -f $candidate;
			$file = $candidate;
			last;
		}
	}

	if(! $file) {

		$contents = readfile_db($ifile);
		return undef unless defined $contents;
	}
	else {
		return undef unless open(READIN, "< $file");
		$Global::Variable->{MV_FILE} = $file;

        if ($encoding) {
            local $PerlIO::encoding::fallback = $PERLQQ;
            binmode(READIN, ":encoding($encoding)");
        }

		undef $/;
		$contents = <READIN>;
		close(READIN);
#::logDebug("done reading contents");

        # at this point, $contents should be either raw if encoding is
        # not specified or PerlUnicode.
	}

	if (
		$Vend::Cfg->{Locale}
			and
		(defined $loc ? $loc : $Vend::Cfg->{Locale}->{readfile} )
		)
	{
		Vend::Util::parse_locale(\$contents);
	}
    return $contents;
}

### flock locking

# sys/file.h:
my $flock_LOCK_SH = 1;          # Shared lock
my $flock_LOCK_EX = 2;          # Exclusive lock
my $flock_LOCK_NB = 4;          # Don't block when locking
my $flock_LOCK_UN = 8;          # Unlock

sub flock_lock {
    my ($fh, $excl, $wait) = @_;
    my $flag = $excl ? $flock_LOCK_EX : $flock_LOCK_SH;

    if ($wait) {
	my $trylimit = $::Limit->{file_lock_retries} || 5;
	my $failedcount = 0;
        while (
                ! flock($fh, $flag)
                    and
                $failedcount < $trylimit
               )
        {
           $failedcount++;
           select(undef,undef,undef,0.05 * $failedcount);
        }
        die "Could not lock file after $trylimit tries: $!\n" if ($failedcount == $trylimit);
        return 1;
    }
    else {
        if (! flock($fh, $flag | $flock_LOCK_NB)) {
            if ($!{EAGAIN} or $!{EWOULDBLOCK}) {
				return 0;
            }
            else {
                die "Could not lock file: $!\n";
            }
        }
        return 1;
    }
}

sub flock_unlock {
    my ($fh) = @_;
    flock($fh, $flock_LOCK_UN) or die "Could not unlock file: $!\n";
}

sub fcntl_lock {
    my ($fh, $excl, $wait) = @_;
    my $flag = $excl ? F_WRLCK : F_RDLCK;
    my $op = $wait ? F_SETLKW : F_SETLK;

	my $struct = pack('sslli', $flag, 0, 0, 0, $$);

    if ($wait) {
        fcntl($fh, $op, $struct) or die "Could not fcntl_lock file: $!\n";
        return 1;
    }
    else {
        if (fcntl($fh, $op, $struct) < 0) {
            if ($!{EAGAIN} or $!{EWOULDBLOCK}) {
                return 0;
            }
            else {
                die "Could not fcntl_lock file: $!\n";
            }
        }
        return 1;
    }
}

sub fcntl_unlock {
    my ($fh) = @_;
	my $struct = pack('sslli', F_UNLCK, 0, 0, 0, $$);
	if (fcntl($fh, F_SETLK, $struct) < 0) {
		if ($!{EAGAIN} or $!{EWOULDBLOCK}) {
			return 0;
		}
		else {
			die "Could not un-fcntl_lock file: $!\n";
		}
	}
	return 1;
}

my $lock_function = \&flock_lock;
my $unlock_function = \&flock_unlock;

sub set_lock_type {
	if ($Global::LockType eq 'none') {
		::logDebug("using NO locking");
		$lock_function = sub {1};
		$unlock_function = sub {1};
	}
	elsif ($Global::LockType =~ /fcntl/i) {
		::logDebug("using fcntl(2) locking");
		$lock_function = \&fcntl_lock;
		$unlock_function = \&fcntl_unlock;
	}
	else {
		$lock_function = \&flock_lock;
		$unlock_function = \&flock_unlock;
	}
	return; # VOID
}
 
sub lockfile {
    &$lock_function(@_);
}

sub unlockfile {
    &$unlock_function(@_);
}

if($^O =~ /hpux/) {
   set_lock_type('fcntl');
}

# Return a quasi-hashed directory/file combo, creating if necessary
sub exists_filename {
    my ($file,$levels,$chars, $dir) = @_;
	my $i;
	$levels = 1 unless defined $levels;
	$chars = 1 unless defined $chars;
	$dir = $Vend::Cfg->{ScratchDir} unless $dir;
    for($i = 0; $i < $levels; $i++) {
		$dir .= "/";
		$dir .= substr($file, $i * $chars, $chars);
		return 0 unless -d $dir;
	}
	return -f "$dir/$file" ? 1 : 0;
}

# Return a quasi-hashed directory/file combo, creating if necessary
sub get_filename {
    my ($file,$levels,$chars, $dir) = @_;
	my $i;
	$levels = 1 unless defined $levels;
	$chars = 1 unless defined $chars;

	# Accomodate PermanentDir not existing in pre-5.3.1 catalogs
	# Block is better than always doing -d test
	if(! $dir) {
		$dir = $Vend::Cfg->{ScratchDir};
	}
	else {
		mkdir $dir, 0777 unless -d $dir;
	}

    for($i = 0; $i < $levels; $i++) {
		$dir .= "/";
		$dir .= substr($file, $i * $chars, $chars);
		mkdir $dir, 0777 unless -d $dir;
	}
    die "Couldn't make directory $dir (or parents): $!\n"
		unless -d $dir;
    return "$dir/$file";
}

# These were stolen from File::Spec
# Can't use that because it INSISTS on object
# calls without returning a blessed object

my $abspat = qr{^/};
my $relpat = qr{\.\./};

sub file_name_is_absolute {
    my($file) = @_;
    $file =~ $abspat;
}

sub absolute_or_relative {
    my($file) = @_;
    $file =~ $abspat or $file =~ $relpat;
}

sub make_absolute_file {
	my ($path, $global) = @_;
	# empty string stays empty
	return unless length($path);
	# is file already an absolute path?
	return $path if $path =~ $abspat;
	# use global or catalog root?
	my $prefix = ($global ? $Global::VendRoot : $Vend::Cfg->{VendRoot});
	return catfile($prefix, $path);
}

sub unix_catfile {
    my $file = pop @_;
    return $file unless @_;
    my $dir = catdir(@_);
    for ($dir) {
        $_ .= "/" unless substr($_, -1) eq "/";
    }
    return $dir.$file;
}

*catfile = \&unix_catfile;

sub unix_path {
    my $path_sep = ":";
    my $path = $ENV{PATH};
    my @path = split $path_sep, $path;
    foreach(@path) { $_ = '.' if $_ eq '' }
    @path;
}

*path = \&unix_path;

sub unix_canonpath {
    my($path) = @_;
    $path =~ s|/+|/|g;                             # xx////xx  -> xx/xx
    $path =~ s|(?:/\.)+/|/|g;                      # xx/././xx -> xx/xx
    $path =~ s|^(?:\./)+|| unless $path eq "./";   # ./xx      -> xx
    $path =~ s|/$|| unless $path eq "/";           # xx/       -> xx
    $path;
}

*canonpath = \&unix_canonpath;

sub unix_catdir {
    my @args = @_;
    for (@args) {
	# append a slash to each argument unless it has one there
	$_ .= "/" if $_ eq '' or substr($_,-1) ne "/";
    }
    my $result = join('', @args);
    # remove a trailing slash unless we are root
    substr($result,-1) = ""
	if length($result) > 1 && substr($result,-1) eq "/";
    $result;
}

*catdir = \&unix_catdir;

my %intrinsic = (
	ic_super => sub { return 1 if $Vend::superuser; },
	ic_admin => sub { return 1 if $Vend::admin; },
	ic_logged => sub {
					my ($fn, $checkpath, $write, $sub) = @_;
					return 0 unless $Vend::username;
					return 0 unless $Vend::Session->{logged_in};
					return 0 if $sub and $Vend::login_table ne $sub;
					return 1;
					},
	ic_session => sub {
					my ($fn, $checkpath, $write, $sub, $compare) = @_;
					my $false = $sub =~ s/^!\s*//;
					my $status	= length($compare)
								? ($Vend::Session->{$sub} eq $compare)
								: ($Vend::Session->{$sub});
					return ! $false if $status;
					return $false;
					},
	ic_scratch => sub {
					my ($fn, $checkpath, $write, $sub, $compare) = @_;
					my $false = $sub =~ s/^!\s*//;
					my $status	= defined $compare && length($compare)
								? ($::Scratch->{$sub} eq $compare)
								: ($::Scratch->{$sub});
					return ! $false if $status;
					return $false;
					},
	ic_userdb => sub {
		my ($fn, $checkpath, $write, $profile, $sub, $mode) = @_;
		return 0 unless $Vend::username;
		return 0 unless $Vend::Session->{logged_in};
		$profile ||= 'default';
		$sub     ||= 'file_acl';
		my $u = new Vend::UserDB profile => $profile;
		$mode ||= $write ? 'w' : 'r';
		my $func = "check_$sub";
		my %o = ( 
			location => $fn,
			mode => $mode,
		);
		return undef unless $u->can($func);
		my $status = $u->$func( %o );
		unless(defined $status) {
			$o{location} = $checkpath;
			$status = $u->$func( %o );
		}
#::logDebug("status=$status back from userdb: " . ::uneval(\%o));
		return $status;
	},
);

sub _intrinsic {
	my ($thing, $fn, $checkpath, $write) = @_;
	$thing =~ s/^\s+//;
	$thing =~ s/\s+$//;
	my @checks = split /\s*;\s*/, $thing;
	my $status = 1;
	for(@checks) {
		my ($check, @args) = split /:/, $_;
		my $sub = $intrinsic{$check}
			or do {
				## $errstr is package global
				$errstr = ::errmsg("Bad intrinsic check '%s', denying.", $_);
				return undef;
			};
		unless( $sub->($fn, $checkpath, $write, @args) ) {
			## $errstr is package global
			$errstr = ::errmsg(
						"Failed intrinsic check '%s'%s for %s, denying.",
						$_,
						$write ? " (write)" : '',
						$fn,
						);
			$status = 0;
			last;
		}
	}
	return $status;
}

sub check_user_write {
	my $fn = shift;
	my $un = $Global::CatalogUser->{$Vend::Cat}
		or return undef;
	my ($mode,$own, $grown) = (stat($fn))[2,4,5];
	return 0 unless defined $own;
	my $uid = getpwnam($un);
	return 1 if $uid eq $own and $mode & 0200;
	return 0 unless $mode & 020;
	my @members = split /\s+/, (getgrgid($grown))[3];
	for(@members) {
		return 1 if $un eq $_;
	}
	return 0;
}

sub check_user_read {
	my $fn = shift;
	my $un = $Global::CatalogUser->{$Vend::Cat}
		or return undef;
	my ($mode,$own, $grown) = (stat($fn))[2,4,5];
	return 0 unless defined $own;
	my $uid = getpwnam($un);
	return 1 if $uid eq $own and $mode & 0400;
	return 0 unless $mode & 040;
	my @members = split /\s+/, (getgrgid($grown))[3];
	for(@members) {
		return 1 if $un eq $_;
	}
	return 0;
}

sub file_control {
	my ($fn, $write, $global, @caller) = @_;
	return 1 if $Vend::superuser and ! $global;
	my $subref = $global ? $Global::FileControl : $Vend::Cfg->{FileControl};
	my $f = $fn;
	CHECKPATH: {
		do {
			if(ref($subref->{$f}) eq 'CODE') {
				return $subref->{$f}->($fn, $f, $write, @caller);
			}
			elsif ($subref->{$f}) {
				return _intrinsic($subref->{$f}, $fn, $f, $write);
			}
		} while $f =~ s{/[^/]*$}{};
	}
	return 1;
}

sub allowed_file {
	my $fn = shift;
	my $write = shift;
	my $status = 1;
	my $pat;
	$Vend::File::errstr = '';
	if(	$Global::NoAbsolute
			and
		$pat = $Global::AllowedFileRegex->{$Vend::Cat || ''}
			and
		$fn !~ $pat
			and
		absolute_or_relative($fn)
		)
	{
		if($Vend::admin and ! $write and $fn =~ /^$Global::RunDir/ and $fn !~ $relpat) {
			$status = 1;
		}
		else {
			$status = $write ? check_user_write($fn) : check_user_read($fn);
		}
	}
	if($status and $Global::FileControl) {
		$status &= file_control($fn, $write, 1, caller(0))
			or $Vend::File::errstr ||=
							::errmsg(
								 "Denied %s access to %s by global FileControl.",
								 $write ? 'write' : 'read',
								 $fn,
							 );
	}
	if($status and $Vend::Cfg->{FileControl}) {
		$status &= file_control($fn, $write, 0, caller(0))
		  or $Vend::File::errstr ||=
		  					::errmsg(
								 "Denied %s access to %s by catalog FileControl.",
								 $write ? 'write' : 'read',
								 $fn,
							 );
	}
	
#::logDebug("allowed_file check for $fn: $status");
	return $status;
}

sub log_file_violation {
	my ($file, $action) = @_;
	my $msg;

	unless ($msg = $Vend::File::errstr) {
		if ($action) {
			$msg = ::errmsg ("%s: Can't use file '%s' with NoAbsolute set",
							 $action, $file);
		} else {
			$msg = ::errmsg ("Can't use file '%s' with NoAbsolute set",
							 $file);
		}
	}

	::logError($msg);
	::logGlobal({ level => 'warning' }, $msg);
}

1;
__END__
