# Copyright © 2007-2011 Raphaël Hertzog <hertzog@debian.org>
# Copyright © 2011 Linaro Limited
#
# 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, see <https://www.gnu.org/licenses/>.

=encoding utf8

=head1 NAME

Dpkg::Path - some common path handling functions

=head1 DESCRIPTION

It provides some functions to handle various path.

=cut

package Dpkg::Path 1.05;

use v5.36;

our @EXPORT_OK = qw(
    canonpath
    resolve_symlink
    check_files_are_the_same
    check_directory_traversal
    find_command
    find_build_file
    get_control_path
    get_pkg_root_dir
    guess_pkg_root_dir
    relative_to_pkg_root
);

use Exporter qw(import);
use Errno qw(ENOENT);
use File::Spec;
use File::Find;
use Cwd qw(realpath);

use Dpkg::ErrorHandling;
use Dpkg::Gettext;
use Dpkg::Arch qw(get_host_arch debarch_to_debtuple);
use Dpkg::IPC;

=head1 FUNCTIONS

=over 8

=item get_pkg_root_dir($file)

This function will scan upwards the hierarchy of directory to find out
the directory which contains the "DEBIAN" sub-directory and it will return
its path. This directory is the root directory of a package being built.

If no DEBIAN subdirectory is found, it will return undef.

=cut

sub get_pkg_root_dir {
    my $file = shift;
    $file =~ s{/+$}{};
    $file =~ s{/+[^/]+$}{} if not -d $file;
    while ($file) {
        return $file if -d "$file/DEBIAN";
        last if $file !~ m{/};
        $file =~ s{/+[^/]+$}{};
    }
    return;
}

=item relative_to_pkg_root($file)

Returns the filename relative to get_pkg_root_dir($file).

=cut

sub relative_to_pkg_root {
    my $file = shift;
    my $pkg_root = get_pkg_root_dir($file);
    if (defined $pkg_root) {
        $pkg_root .= '/';
        return $file if ($file =~ s/^\Q$pkg_root\E//);
    }
    return;
}

=item guess_pkg_root_dir($file)

This function tries to guess the root directory of the package build tree.
It will first use get_pkg_root_dir(), but it will fallback to a more
imprecise check: namely it will use the parent directory that is a
sub-directory of the debian directory.

It can still return undef if a file outside of the debian sub-directory is
provided.

=cut

sub guess_pkg_root_dir {
    my $file = shift;
    my $root = get_pkg_root_dir($file);
    return $root if defined $root;

    $file =~ s{/+$}{};
    $file =~ s{/+[^/]+$}{} if not -d $file;
    my $parent = $file;
    while ($file) {
        $parent =~ s{/+[^/]+$}{};
        last if not -d $parent;
        return $file if check_files_are_the_same('debian', $parent);
        $file = $parent;
        last if $file !~ m{/};
    }
    return;
}

=item check_files_are_the_same($file1, $file2, $resolve_symlink)

This function verifies that both files are the same by checking that the device
numbers and the inode numbers returned by stat()/lstat() are the same. If
$resolve_symlink is true then stat() is used, otherwise lstat() is used.

=cut

sub check_files_are_the_same {
    my ($file1, $file2, $resolve_symlink) = @_;

    return 1 if $file1 eq $file2;
    return 0 if ((! -e $file1) || (! -e $file2));
    my (@stat1, @stat2);
    if ($resolve_symlink) {
        @stat1 = stat($file1);
        @stat2 = stat($file2);
    } else {
        @stat1 = lstat($file1);
        @stat2 = lstat($file2);
    }
    my $result = ($stat1[0] == $stat2[0]) && ($stat1[1] == $stat2[1]);
    return $result;
}


=item canonpath($file)

This function returns a cleaned path. It simplifies double //, and remove
/./ and /../ intelligently. For /../ it simplifies the path only if the
previous element is not a symlink. Thus it should only be used on real
filenames.

=cut

sub canonpath {
    my $path = shift;
    $path = File::Spec->canonpath($path);
    my ($v, $dirs, $file) = File::Spec->splitpath($path);
    my @dirs = File::Spec->splitdir($dirs);
    my @new;
    foreach my $d (@dirs) {
        if ($d eq '..') {
            if (scalar(@new) > 0 and $new[-1] ne '..') {
                # Root directory has no parent.
                next if $new[-1] eq '';
                my $parent = File::Spec->catpath($v,
                        File::Spec->catdir(@new), '');
                if (not -l $parent) {
                    pop @new;
                } else {
                    push @new, $d;
                }
            } else {
                push @new, $d;
            }
        } else {
            push @new, $d;
        }
    }
    return File::Spec->catpath($v, File::Spec->catdir(@new), $file);
}

=item $newpath = resolve_symlink($symlink)

Return the filename of the file pointed by the symlink. The new name is
canonicalized by canonpath().

=cut

sub resolve_symlink {
    my $symlink = shift;
    my $content = readlink($symlink);
    return unless defined $content;
    if (File::Spec->file_name_is_absolute($content)) {
        return canonpath($content);
    } else {
        my ($link_v, $link_d, $link_f) = File::Spec->splitpath($symlink);
        my ($cont_v, $cont_d, $cont_f) = File::Spec->splitpath($content);
        my $new = File::Spec->catpath($link_v, $link_d . '/' . $cont_d, $cont_f);
        return canonpath($new);
    }
}

=item check_directory_traversal($basedir, $dir)

This function verifies that the directory $dir does not contain any symlink
that goes beyond $basedir (which should be either equal or a parent of $dir).

=cut

sub check_directory_traversal {
    my ($basedir, $dir) = @_;

    my $canon_basedir = realpath($basedir);
    # On Solaris /dev/null points to /devices/pseudo/mm@0:null.
    my $canon_devnull = realpath('/dev/null');
    my $check_symlinks = sub {
        my $canon_pathname = realpath($_);
        if (not defined $canon_pathname) {
            return if $! == ENOENT;

            syserr(g_("pathname '%s' cannot be canonicalized"), $_);
        }
        return if $canon_pathname eq $canon_devnull;
        return if $canon_pathname eq $canon_basedir;
        return if $canon_pathname =~ m{^\Q$canon_basedir/\E};

        error(g_("pathname '%s' points outside source root (to '%s')"),
              $_, $canon_pathname);
    };

    my $scan_symlinks = {
        wanted => $check_symlinks,
        no_chdir => 1,
        follow => 1,
        follow_skip => 2,
    };
    find($scan_symlinks, $dir);

    return;
}

=item $cmdpath = find_command($command)

Return the path of the command if defined and available on an absolute or
relative path or on the $PATH, undef otherwise.

=cut

sub find_command {
    my $cmd = shift;

    return if not $cmd;
    if ($cmd =~ m{/}) {
        return "$cmd" if -x "$cmd";
    } else {
        foreach my $dir (split(/:/, $ENV{PATH})) {
            return "$dir/$cmd" if -x "$dir/$cmd";
        }
    }
    return;
}

=item $control_file = get_control_path($pkg, $filetype)

Return the path of the control file of type $filetype for the given
package.

=item @control_files = get_control_path($pkg)

Return the path of all available control files for the given package.

=cut

sub get_control_path {
    my ($pkg, $filetype) = @_;
    my $control_file;
    my @exec = ('dpkg-query', '--control-path', $pkg);
    push @exec, $filetype if defined $filetype;
    spawn(
        exec => \@exec,
        wait_child => 1,
        to_string => \$control_file,
    );
    chomp($control_file);
    if (defined $filetype) {
        return if $control_file eq '';
        return $control_file;
    }
    return () if $control_file eq '';
    return split(/\n/, $control_file);
}

=item $file = find_build_file($basename)

Selects the right variant of the given file: the arch-specific variant
("$basename.$arch") has priority over the OS-specific variant
("$basename.$os") which has priority over the default variant
("$basename"). If none of the files exists, then it returns undef.

=item @files = find_build_file($basename)

Return the available variants of the given file. Returns an empty
list if none of the files exists.

=cut

sub find_build_file {
    my $base = shift;
    my $host_arch = get_host_arch();
    my ($abi, $libc, $host_os, $cpu) = debarch_to_debtuple($host_arch);
    my @files;
    foreach my $fn ("$base.$host_arch", "$base.$host_os", "$base") {
        push @files, $fn if -f $fn;
    }
    return @files if wantarray;
    return $files[0] if scalar @files;
    return;
}

=back

=head1 CHANGES

=head2 Version 1.05 (dpkg 1.20.4)

New function: check_directory_traversal().

=head2 Version 1.04 (dpkg 1.17.11)

Update semantics: find_command() now handles an empty or undef argument.

=head2 Version 1.03 (dpkg 1.16.1)

New function: find_build_file()

=head2 Version 1.02 (dpkg 1.16.0)

New function: get_control_path()

=head2 Version 1.01 (dpkg 1.15.8)

New function: find_command()

=head2 Version 1.00 (dpkg 1.15.6)

Mark the module as public.

=cut

1;
