File sam of Package suse-sam

#!/usr/bin/perl -w
# vim: set et ts=8 sts=4 sw=4 ai si:
#
#  sam - Supportability Analysis Module
#
#  Copyright (c) 2008 SuSE Linux Products GmbH, Nuernberg, Germany
#
#  Author: Olaf Dabrunz <od@suse.de>
#          (based on 'sammi' by Raymund Will <rw@suse.de>)
#
#  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., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
#
# Notes:
#
# RPM epoch is not used (policy at SUSE/Novell and elsewhere), as it is sticky
# (every version upgrade must contain the right epoch value) and also not
# visible to the user.
#
# Extensions for SELinux, ACLs, capabilities and others need to be added when
# they are supported by both the SUSE Linux kernel and the SUSE version of RPM.
#

use strict;
use POSIX qw(strftime WIFEXITED WEXITSTATUS WIFSIGNALED WTERMSIG);
# handle HUP INT PIPE TERM ABRT QUIT with die, so the END block is executed
# which unlinks temporary files
use sigtrap qw(die untrapped normal-signals ABRT QUIT);
use File::Find;

my $progname            = $0; $progname =~ s{^.*/}{};
my $progspcs            = $progname; $progspcs =~ s{.}{ }g;
my $invocation_cmd_line = $progname ." ". join (" ", @ARGV);

my $prog_version        = 0.6;

my $tmpdir              = "/tmp";
my $pubring             = "$progname.pubring.$$";
my $sigfile             = "$progname.sigfile.$$";
my $signedfile          = "$progname.signedfile.$$";

# unlink temp files in the end (but not if the program ends before temp file
# names are known)
my $unlink_ok           = 0;
END {
    if (defined $unlink_ok and $unlink_ok) {
        unlink($pubring, $pubring . "~", $sigfile, $signedfile);
    }
}


$ENV{"LC_ALL"} = "C";
delete $ENV{"LANG"};

my $Unsupportable       = 0;
my $root_dir            = "/";
my $rpm_command         = "/bin/rpm";
my $gpg_exe             = "/usr/bin/gpg";
my $gpg_command         = "$gpg_exe --no-default-keyring --keyring $pubring " .
                          "--trust-model always";
my $zypp_conf           = defined $ENV{'ZYPP_CONF'} ?
                          $ENV{'ZYPP_CONF'} : "/etc/zypp/zypp.conf"; # libzypp

# caches -- for development and debugging
my $cache_dir           = "sam.d";
my $rpm_qa_cache        = "$cache_dir/rpm-qa";
my $rpm_Vv_cache        = "$cache_dir/rpm-Vv";
my $rpm_e_cache         = "$cache_dir/rpm-e";
my $cache_file_version  = "0.4";

# repositories -- for gpg keys and inst source information
my $default_zypp_cache  = "/var/cache/zypp";
my $repossubdir         = "repos.d";
my $metadatasubdir      = "raw";
my $solvfilessubdir     = "solv";
my $solvfilename        = "solv";
my $reposdir;
my $metadatadir;
my $solvfilesdir;

my %repoinfo            = ();

# installed package information
my %package2inst_time   = ();
my %package2name        = ();
my %package2edition     = ();

my %skipped_packages    = ();

my %alien_packages      = ();
my %alien2name          = ();
my %alien2edition       = ();

my $num_sig_ok_packs    = 0;

# package signing keys
my %good_key_ids        = ();

# --------------------------------------------------------------------------
# SAM configuration
my $ALLOW_MULTIHOMED_OBJECTS    = 0;
my $CHECK_SIGNATURE             = 1;
my $VERIFY_PACKAGES             = 1;
my $FIND_DETACHED               = 1;

# hex encoding of RPM header prefix used when creating/verifying the header
# signature
my $headerprefix        = "8eade80100000000";

# --------------------------------------------------------------------------
# statistics
my $Tstart = time();
my ($cacheMiss, $cacheHit) = (0, 0);
my ($aInodes, $aSize, $pInodes, $pSize) = (0, 0, 0, 0);

# --------------------------------------------------------------------------
# logging
#
my $debug               = 0;
my $verbose             = 1;
my $writeReport         = 0;
my $logCont             = 0;
my $logBuffer           = "";

#
# Log to LOG file and maybe also STDERR, if we are $verbose enough for the
# $level of the message. "%T" in the message are replaced with a timestamp.
#
# 0 - really useful to know in most cases
# 1 - more verbose
#       - show packages
#           - for which updates exist but are not installed
#           - which have the same evr as a package in a repo, but are not
#             identical
#       - output of some external programs
#       - more steps reported
# 2 - show installed packages which are newer than available packages in repos
# 3 - show installed packages which are identical with some package in repos
#     and which have no newer updates
# 4 - show informative output of programs
# 5 - show when heuristics drop candidates
#       - dropping pseudo-packages
#       - dropping files that where considered as gpg-keys but turned out not
#         to be
#
# 8 - show execution and all output of several external programs
# 9 - show fatal error message
#     - FIXME: describe purpose of other Log(9,...)
#
sub Log($$@) {
    my ($level, $format, @args) = @_;

    my $msg = sprintf($format, @args);
    my $timestamp = strftime( "%Y-%m-%d_%H:%M:%S_Z", gmtime(time()));
    $msg =~ s{(\%T)}{$timestamp}g;

    $logCont = ( substr( $msg, -1, 1) eq "\n" ) ? 0 : 1;

    print(LOG $msg);

    if ($level < $verbose) {
        print(STDERR $msg);
        print(STDERR (($logCont) ? "C" : ""))     if $debug;
    }

    return($msg);
}

#
# Report at log level 0
#
sub Report($@) {
    my ($format, @args) = @_;

    my $msg = Log(0, $format, @args);

    print(REPORT $msg);
}

#
# Report at log level 2
#
sub ReportQ($@) {
    my ($format, @args) = @_;

    my $msg = Log(2, $format, @args);

    print(REPORT $msg);
}

# ---------------------------------------------------------------------------
# Die. Exits the program with an error return value and an optional message.
#
# Die();
# Die(1);
# Die(2, "%T %s: could not open file %s\n", $progname, $file);
# Die("%T %s: could not open file %s\n", $progname, $file);
#
# Accepts zero or more parameters.
#
# If the first parameter is a number between -999 and 999, it is masked with
# 0x7f and used as an error code. It this case, the next parameter is used as
# the format string for the error message. Otherwise, the error code is -1 and
# the first parameter is used as the format string. Any following parameters
# are used as arguments to the format string.
#
# The message is logged at level 9, and an extra message is written to STDERR
# (using warn()).
#
sub Die(@) {
    my (@args) = @_;

    my ($error_code, $format) = (-1, undef);
    my $t = shift(@args);

    if (defined($t) && $t =~ m{^(0|-?[1-9][0-9]{0,2})$}) {
        $error_code = $t;
        $format = shift(@args);
    } else {
        $format = $t;
    }

    if (defined($format)) {
        Log(9, "FATAL: $format", @args);
        warn(sprintf("$format", @args));
    }

    exit($error_code & 127);
}

# ---------------------------------------------------------------------------
# Print package information as HTML
#
sub initHTML() {
    print(HTML
	 "    <table id=\"SAM-list\">\n",
	 "      <thead>\n",
	 "        <th>Name</th><th>Version</th><th>Supported</th>",
	 "          <th>Notes</th>\n",
	 "      </thead>\n",
	 "      <tbody>\n");
}

sub finishHTML() {
    print(HTML
	 "      </tbody>\n",
	 "    </table>\n");
}

sub HTMLize($) {
    return @_;
}

my $hOE = "odd ";

sub printHTML($$$) {
    my ($p, $r, $n) = @_;

    my ($N, $E, $c) = (HTMLize($package2name{$p}), HTMLize($package2edition{$p}), "class");
    my ($s, $b) = (($r == 0) ? ("  supported", "Yes") : ("unsupported", "No"));
    print(HTML " "x8 . "<tr $c=\"$hOE $s\"><th>$N</th><td $c=\"edition\">",
          "$E</td><td $c=\"support\">$b</td><td>$n</td></tr>\n");
    $hOE = (($hOE eq "odd ") ? "even" : "odd ");
}


# ---------------------------------------------------------------------------
# Print package information as JSON
#
BEGIN() {
    eval { require JSON::XS; };
    if ( $@ ) {
        die unless ($@ =~ m{Can't locate});
    } else {
        require JSON::XS;
    }
}

my %J           = ();
my $js_true     = 1;
my $js_false    = 0;
my $json;

eval { require JSON::XS; };
if ( $@ ) {
    die unless ($@ =~ m{Can't locate});
    $json = undef;
} else {
    $js_true    = JSON::XS->true;
    $js_false   = JSON::XS->false;
    $json       = JSON::XS->new->ascii->pretty->allow_nonref->canonical;
    $json       = $json->space_before(0)->space_after(1)->indent(1);
}

sub JSONize($) {
    my ($s) = @_;

    $s =~ s{([\"\\\/])}{\\$1}g;
    return ( $s );
}

sub fillJSON($$$) {
    my ($p, $r, $n) = @_;

    #print( JSON $json( [$p2name{$p}, $p2edition{$p}, $r, $n ]), "\n");
    push @{ $J{"packages"} },
    {"0::name" => $package2name{$p},
        "1::edition" => $package2edition{$p},
        "2::support" => (($r == 0) ? $js_true : $js_false),
        "3::note" => $n
    };
}

sub finishJSON() {
    my $j;

    if ( defined( $json) ) {
        $j = $json->encode( \%J);
    } else {
        $j = "Please install JSON::XS and cie.\n";
    }

    $j =~ s{ \{ \s* \" }{\{ \"}gsx;
    $j =~ s{ \s+ \}, }{ \},}gsx;
    $j =~ s{ ([^\}],) \s* }{$1 }gsx;
    $j =~ s{ \s+ (\}) }{ $1}gsx;
    $j =~ s{ (\") [0-9] \:\: (\S+\") }{$1$2}gmx;
    $j =~ s{ ^ \s+ (\{) }{  $1}gmx;
    $j =~ s{ ^ \s+ (\]) }{$1}gmx;
    print( JSON $j, "\n");
}

# ---------------------------------------------------------------------------
# finding package source repository with libsatsolver
#
BEGIN() {
    eval { require satsolver; };
    if ( $@ ) {
        die unless ($@ =~ m{Can't locate});
    } else {
        require satsolver;
    }
}

my $satsolver = 0;
my %needed_methods = (
    'Pool'      =>  ['providers'],
    'Repo'      =>  ['solvables'],
    'Solvable'  =>  ['compare', 'identical'],
);

eval { require satsolver; };
if ( $@ ) {
    die unless ($@ =~ m{Can't locate});
} else {
    $satsolver = 1;
    foreach my $subpack (keys %needed_methods) {
        foreach my $sym (@{$needed_methods{$subpack}}) {
            if (not (defined $satsolver::{"${subpack}::"}->{$sym} or
                     defined $satsolver::{$sym})) {
                $satsolver = -1;
            }
        }
    }
}

# ---------------------------------------------------------------------------
# Reading config files
#
sub simplify_path ($) {
    my ($path) = @_;

    # remove additional "/"
    $path =~ s{//+}{/}og;
    return $path;
}

sub get_repo_conf ($) {
    my ($root_dir) = @_;

    my $cachedir;

    $zypp_conf          =~ m{^(.*)/};
    my $zypp_confdir    = $1;

    if (not open(CONF, "<", "$root_dir/$zypp_conf")) {
        Log(0, "  open(\"$root_dir/$zypp_conf\"): $!\n");
    } else {
        # get metadatadir, reposdir (fallback) and solvfilesdir from zypp.conf
        while (<CONF>) {
            $cachedir     = $1, next    if m{^\s*cachedir\s*=\s*(\S+)\s*$};
            $reposdir     = $1, next    if m{^\s*reposdir\s*=\s*(\S+)\s*$};
            $metadatadir  = $1, next    if m{^\s*metadatadir\s*=\s*(\S+)\s*$};
            $solvfilesdir = $1, next    if m{^\s*solvfilesdir\s*=\s*(\S+)\s*$};
        }
        close(CONF);
    }

    $cachedir       = $default_zypp_cache           if not defined $cachedir;
    $reposdir       = "$zypp_confdir/$repossubdir"  if not defined $reposdir;
    $metadatadir    = "$cachedir/$metadatasubdir"   if not defined $metadatadir;
    $solvfilesdir   = "$cachedir/$solvfilessubdir"  if not defined $solvfilesdir;

    $cachedir       = simplify_path("$root_dir/$cachedir");
    $reposdir       = simplify_path("$root_dir/$reposdir");
    $metadatadir    = simplify_path("$root_dir/$metadatadir");
    $solvfilesdir   = simplify_path("$root_dir/$solvfilesdir");
}

sub get_repo_infos () {
    foreach my $repofile (glob("$reposdir/*.repo")) {
        if (not open(REPO, "<", $repofile)) {
            Log(0, "  open(\"$repofile\"): $!\n");
        } else {
            # get reposubdir, name, and baseurl from this *.repo file
            my $subdir;
            while (<REPO>) {
                $subdir       = $1, last    if m{^\s*\[(.*)\]\s*$};
            }
            if (defined $subdir) {
                while (<REPO>) {
                    $repoinfo{$subdir}->{'name'}    = $1, next  if m{^\s*name\s*=\s*(.+)\s*$};
                    $repoinfo{$subdir}->{'baseurl'} = $1, next  if m{^\s*baseurl\s*=\s*(\S+)\s*$};
                }
            }
            close(REPO);
        }
    }

    foreach my $subdir (keys %repoinfo) {
        my $info_ref    = $repoinfo{$subdir};
        my $solvfile    = "$solvfilesdir/$subdir/$solvfilename";
        if ( -r $solvfile ) {
            $repoinfo{$subdir}->{'solvfile'} = $solvfile;
        } else {
            # delete repositories without solv file
            Log(0, "  skipping repository without solv file %s\n" .
                   "    name:    %s\n    baseurl: %s\n",
                "$subdir:",
                $info_ref->{'name'} . ",",
                $info_ref->{'baseurl'});
            delete $repoinfo{$subdir};
            next;
        }

        my $contentfile = "$metadatadir/$subdir/content";
        if (not open(CF, "<", $contentfile)) {
            Log(0, "  open(\"$contentfile\"): $!\n");
        } else {
            # get label from this content file
            while (<CF>) {
                $info_ref->{'label'}    = $1, last  if m{^\s*LABEL\s*(.+?)\s*$};
            }
            close(CF);
        }
    }

    # make duplicate names and labels unique
    foreach my $subdir (keys %repoinfo) {
        my $info_ref    = $repoinfo{$subdir};

        my $name            = $info_ref->{'name'};
        my $label           = $info_ref->{'label'};
        my $next_name_cnt   = 2;
        my $next_label_cnt  = 2;
        foreach my $subdir2 (keys %repoinfo) {
            next        if ($subdir eq $subdir2);

            if ($name eq $repoinfo{$subdir2}->{'name'}) {
                $repoinfo{$subdir2}->{'name'}   = "$name (" . ($next_name_cnt++) . ")";
            }
            if ($label eq $repoinfo{$subdir2}->{'label'}) {
                $repoinfo{$subdir2}->{'label'}  = "$label (" . ($next_label_cnt++) . ")";
            }
        }
        $info_ref->{'name'}  = "$name (1)"     if $next_name_cnt > 2;
        $info_ref->{'label'} = "$label (1)"    if $next_label_cnt > 2;
    }

    # assign number and print found repos
    my $cnt = 0;
    foreach my $subdir (sort (keys %repoinfo)) {
        my $info_ref = $repoinfo{$subdir};
        $info_ref->{'number'}   = ++$cnt;

        Log(0, "  found repository #%d: %s\n" .
               "    name:    %s\n    label:   %s\n    baseurl: %s\n",
            $info_ref->{'number'},
            "$subdir:",
            $info_ref->{'name'} . ",",
            $info_ref->{'label'} . ",",
            $info_ref->{'baseurl'});
    }
}

# ---------------------------------------------------------------------------
# Setting up a keyring with SUSE/Novell build keys
#

# SUSE/Novell vendors of supported packages
my $Vendors = qr((?:
   SuSE\ GmbH |
   SuSE\ AG |
   SuSE\ Linux\ AG |
   SUSE\ LINUX\ Products\ GmbH |
   UnitedLinux\ LLC |
   Novell
))ixo;

# SUSE/Novell build key of supported packages
my $Buildkeys = qr((?:
   (?:SuSE|$Vendors)\ Package\ Signing\ Key |
   (?:SuSE|$Vendors)\ Security\ Team |
   Novell Provo Build |
   Open Enterprise Server
))ixo;

# SUSE/Novell repository content file labels for supported installation sources
my $Labels = qr((?:
   SUSE |
   Novell
))ixo;

sub setup_key () {
    my $cmd;
    my ($key_id_string, $date, $pub_comment, $pub_line);

    # only consider *.key and *.asc files
    return      if not m{\.(key|asc)$}io;

    # find public keys and check comment string against SUSE/Novell vendor
    # strings 
    $cmd = "$gpg_command $File::Find::name";
    open(FH, "$cmd 2>&1 |")         || Die("$cmd: failed to execute: $!\n");
    while (<FH>) {
        chomp;
        if (m{^pub\s+(\S+)\s+(\S+)\s+(.*)$}) {
            ($key_id_string, $date, $pub_comment) = ($1, $2, $3);
            $pub_line = $_;
        }
        Log(8, "  $_\n");
    }
    close(FH);

    if (not defined $pub_comment) {
        Log(5, "  gpg: not a public key file: %s\n",
            $File::Find::name);
    } elsif ($pub_comment =~ m{^$Buildkeys}) {
        if (not defined $good_key_ids{$key_id_string}) {
            Log(1, "  gpg: using SUSE/Novell public key file: %s: %s\n",
                $File::Find::name, $pub_line);
            $good_key_ids{$key_id_string} ++;

            $cmd = "$gpg_command --import $File::Find::name";
            System($cmd);
        } else {
            Log(4, "  gpg: already imported SUSE/Novell public key file: %s: %s\n",
                $File::Find::name, $pub_line);
        }
    } else {
        Log(4, "  gpg: not using foreign public key file: %s: %s\n",
            $File::Find::name, $pub_line);
    }
}

# ---------------------------------------------------------------------------
# Is the package from us?
#
sub is_our_package ($$$$$) {
    my ($rsaheadersig, $dsaheadersig, $rpmheader, $package_name, $vendor) = @_;
    my $headersig;
    my $cmd;
    my $is_ours = 0;

    # user asked to skip sig check or
    # no SUSE/Novell keys we could use for checking?
    if (not $CHECK_SIGNATURE or scalar (keys %good_key_ids) == 0) {
        # cannot check, so fall back on the vendor string in the RPM DB and
        # continue anyway, hoping for the best
        return $vendor =~ m(^$Vendors)o;
    }

    # does the RPM DB have a signature for the header?
    # use RSAHEADER (more hash bits) if available, or fall back to DSAHEADER
    $headersig = ($rsaheadersig =~ m{(none)}) ? $dsaheadersig : $rsaheadersig;
    if ($headersig =~ m{(none)}) {
        # cannot check, use fallback
        if ($vendor =~ m(^$Vendors)o) {
            $is_ours = 1;
        }
        # log at level 0 if one of "our" packages had no signature (should not happen),
        # log at level 4 if it is a "foreign" package without signature
        Log($is_ours ? 0 : 4,
            "  rpm: no header signature, using vendor: %-45s %s\n",
            "$package_name:", $vendor);
        return $is_ours;
    }

    # convert hex strings to binary
    my $rpmheader_bin = pack("H*H*", $headerprefix, $rpmheader);
    my $headersig_bin = pack("H*", $headersig);

    # save RPM header and signature
    open(FH, ">", "$signedfile")        || Die( "open($signedfile): $!\n");
    print(FH $rpmheader_bin);
    close(FH);
    open(FH, ">", "$sigfile")           || Die( "open($sigfile): $!\n");
    print(FH $headersig_bin);
    close(FH);

    # check the signature of the RPM header with our selected keys
    $cmd = "$gpg_command --verify $sigfile $signedfile";
    open(FH, "$cmd 2>&1 |")             || Die( "command failed: $cmd: $!\n");
    while (<FH>) {
        chomp;
        if (m{^gpg:\s*Good\s+signature}io) {
            Log(4, "  $_: $package_name: $vendor\n");
            $is_ours = 1;
        } elsif (m{^gpg:\s*(?:Can't|Cannot)\s+check\s+signature}io) {
            Log(5, "  $_: $package_name: $vendor\n");
        }
    }
    close(FH);

    $num_sig_ok_packs += $is_ours;

    return $is_ours;
}

my %prettyR =
  (
   "S" => "size",
   "M" => "mode",
   "5" => "checksum",
   "D" => "device-node",
   "L" => "sym-link",
   "U" => "owner",
   "G" => "group",
   "T" => "mod-time",
   "?" => "cannot-read",
  );

#
# return pretty-printed result
#
sub prettyR($) {
    my ($result) = @_;

    my ($pretty) = ("'$result'");
    $_ = $result;

    if ( m{^U\:miss\s*(.)(\s+(.*))?$} ) {
        $pretty = "missing" . (defined $3 ? " $3" : "");
    } elsif ( m{^U\:mod (.):([SM5?DLUGT]+)(?: (.*)|)$} ) {
        my ($kind, $summary_result, $R) = ($1, $2, $3);
        my @L = map( $prettyR{$_}, split(//, $summary_result));
        $pretty = "modified: " . join(", ", @L);    
    }
    return $pretty;
}

#
# Create pretty-printed string for a size in bytes
#
sub prettyK($) {
    my ($n) = @_;

    my ($f, $p);
    my @P = ("M", "G", "T");

    if ( $n < 0 ) {
        return sprintf( "%3dk??", $n);
    } elsif ( $n < 1000 ) {
        return sprintf( "%3d kB", $n);
    }

    while ( $n > 999 ) {
        $p = shift(@P);
        $f = $n % 1024;
        $n = $n >> 10;
    }

    if ( $n > 9 ) {
        return sprintf("%3d %sB", $n, $p);
    }

    $f = int(($f * 10 ) / 1024);
    return sprintf("%d.%d %sB", $n, $f, $p);
}

#
# Execute program and die on errors with appropriate message
# Program output is logged at log level 2
# Also logs the command at log level 8
#
sub System ($) {
    my ($cmd) = @_;
    my @C = split(/ /, $cmd);

    Log(8, "+$cmd\n");

    # open a pipe to catch output as well
    open(FH, "$cmd 2>&1 |")         || Die("$C[0]: failed to execute: $!\n");
    while (<FH>) {
        Log(4, "  " . $_);
    }
    close(FH);

    if (WIFSIGNALED($?)) {
        Die(sprintf( "$C[0]: died with signal %d, %s coredump\n",
                (WTERMSIG($?)),  ($? & 128) ? 'with' : 'without'));
    } elsif ( WEXITSTATUS($?) != 0 ) {
        Die("$C[0]: failed with error code %d\n", WEXITSTATUS($?));
    }
}

# ---------------------------------------------------------------------------
# Find files that do not belong to any RPM package
#
# TODO: handle exclusion of directories
#

my @orphans = ("undef");
my %dirpath2devinode;
my %dircontents;

sub findOrphans($) {
    our ($rootdir) = @_;
    our ($rootlen, $rootdev, $ignoredir);
    # based on 'airbag,v 1.2 2001/10/02 15:04:30'
    # created by Torsten Duwe
    # modified by Raymund Will

    # "find" of additional files; more precisely files and directories that
    # do not come from installed RPMs.

    # We take a fsck-like approach: %dirpath2devinode holds a [dev:inode]
    # pair for given directory path(s) and %dircontents stores the
    # directory content's names, as if they had been received via
    # opendir() and readdir().

    # First we fill the %dirpath2devinode / %dircontents cache with
    # list info from "rpm -qal", then we do a "find /" and report all new
    # files and dirs encountered, pruning dirs, of course. A few
    # well-known candidates are suppressed, for convenience.

    # subroutine pathhash: make sure [dev:inode] pair for this path is
    # known as well as those of all of its parents. Argument is a path
    # string.
    sub pathhash($);
    sub pathhash($){
        my($path) = @_;
        print STDERR "pathhash($path): " if ($debug & 0x1);
        my($dev,$ino,$mode,@rest,$parent,$myname);

        # defensive programming: make sure our path string has exactly
        # one slash at the beginning and for subdir separation, and no
        # slash at the end.
        $path =~ s,/+,/,g;
        #$path =~ s,/$,,g;
        $path =~ s,^/,,g;
        $path = "/$path";
        print STDERR "-> '$path'" if ($debug & 0x1);

        if (defined $dirpath2devinode{$path}) {
            print STDERR "=> known\n" if ($debug & 0x1);
            return;
        } # already known
        print STDERR ": stat\n" if ($debug & 0x1);

        ($dev,$ino,$mode,@rest) = stat($rootdir . $path);
        if (@rest < 10) {
            print STDERR "cannot stat($rootdir,$path): $!\n"; return;
        }

        # if we stat()ed a directory, let's remember it.
        if (($mode & 0xf000) == 0x4000) {
            $dirpath2devinode{$path} = "$dev:$ino";
            $dircontents{"$dev:$ino"} = "" unless defined($dircontents{"$dev:$ino"});
        }

        # so this one was new. how about the parent dir ? recursion will
        # stop at "/" (provided it's the real root!), which is its own
        # parent and will be "already known" above.
        return if ( $path eq "/" );

        $parent = $path;
        $parent =~ s,/([^/]*)/?$,,;
        $myname = "$1";

        $parent =~ s,^/,,;
        $parent = "/$parent";
        #  print(STDERR "parent='$parent' myname='$myname'   %> ");

        pathhash($parent);

        # back from recursion -- ensure this path's name is listed in
        # parent's contents.
        if ($dircontents{$dirpath2devinode{$parent}} =~ m,/\Q$myname/, ){
            #    print " already have $parent##/##$myname\n";
        } else {
            $dircontents{$dirpath2devinode{$parent}} .= "/$myname/";
            #    print " $parent##/##$myname\n";
        }

        #  print "$dirpath2devinode{$dir} <= $dir\n";
        #  $dirpath2devinode{$dir};
    }

    $rootlen = length($rootdir);
    $rootdev = (lstat($rootdir))[0];
    $ignoredir = 1;
    $debug = 0;
    $| = 1;

    if ( $> == 0 && ( -x "./bin/rpm" ) ) {
        open(FLIST, "chroot '$rootdir' ./bin/rpm -qal |") ||
        Die( "open( chroot rpm -qa): $!\n");
    } else {
        open(FLIST, "/bin/rpm -qal --root '$rootdir'|") ||
        Die( "open( rpm -qa): $!\n");
    }

    while(<FLIST>){
        my($dir, $fname, $inode);
        chomp;
        s,/$,,;			# doesn't ever happen, anyway.
        s,/+,/,g;
        m,^(.*/)([^/]+)$, || next;
        $dir = $1; $fname = $2;

        $dir =~ s,^/,,;
        $dir = "/$dir";

        next unless ( -d "$rootdir$dir" );
        pathhash($dir);
        $inode = $dirpath2devinode{$dir};
        $dircontents{$inode} .= "/$fname/";
    }
    close( FLIST);

    # subroutine wanted: called by the file tree walk for every node, with
    # the basename() of the current node as string argument.
    sub wanted() {
        my($dir) = "/" . substr($File::Find::dir, $rootlen) . "/";
        $dir =~ s,/+,/,g;
        print STDERR "wanted: $_, dir='$dir'\n" if ($debug & 0x2);

        # omit dot and dotdot, backup files, and well-known boring paths.
        m/^\.\.?$/ && return;
        m/~$/ && return;
#        m/\.rpmorig$/ && return;
#        m/\.rpmnew$/ && return;
#        m,^/vmlinu, && return;
#        m,^/initrd, && return;

        my ($dev,$ino,$mode,$nlink,$uid,$gid,$rdev,$size,@rest) = lstat($_);
        $aInodes++;
        $aSize += ($size + 512) / 1024;
        if ($dev != $rootdev ) { $File::Find::prune = 1; return; }
        if ($File::Find::name =~ m,^/vmlinu, ) { return; }
        if ($File::Find::name =~ m,^/initrd, ) { return; }
        if ($File::Find::name =~ m,/man/whatis$, ) { return; }
        if ($File::Find::name =~ m,^/proc, ) { $File::Find::prune = 1; return; }
        if ($File::Find::name =~ m,^/etc/rc\.d/rc[0-6]\.d, )
        { $File::Find::prune = 1; return; }

#        if (!defined $dirpath2devinode{$dir}) {
#            print " XXX $dir XXX no match in hash table !\n";
#            return;
#        }

        # see if we know the dir we're in
        pathhash($dir);
        my $inode = $dirpath2devinode{$dir};

        # does it know about the file/dir we're examining at this invocation ?
        if ( $dircontents{$inode} =~ m,/\Q$_/, ) {		# yes, ok.
            print STDERR "known: ($dir) $_\n" if ($debug&0x4);
            return;
        } elsif ( $dir eq "/home/httpd/icons/" ) {
            print STDERR "UNknown: ($dir) $_\n" if ($debug&0x4);
            print STDERR "$inode=>'$dircontents{$inode}'\n" if ($debug&0x4);
        }

        # if not, let's have a closer look.

        # we're not interested in symlinks at all.
        if (($mode & 0xf000) == 0xa000) { return;  }

        my $isdir = "";
        if (($mode & 0xf000) == 0x4000) {
            return if ( $ignoredir );
            $isdir =  "/";
            # maybe we know this directory, but by another name, if
            # the installation has followed symlinks like /opt -> /usr/opt
            if (defined $dircontents{"$dev:$ino"}) { return; }
        }

        $File::Find::prune = 1;
        # the rare case of a l&f directory under a mount point. Checked here
        # because of its low probability and because we want prune=1 for it.
        if ($isdir eq "/" && $_ eq "lost+found" && $inode =~ /:2$/) { return; }
        #print" $File::Find::name$isdir\n";
        push @orphans, "$dir$_$isdir";
    }

    #$debug = 0x4;
    find(\&wanted, $rootdir); # Launch !
}

# ---------------------------------------------------------------------------
# Return filehandle for the list of RPMs with ancillary data.
# May use cached data or pipe directly from the rpm command.
#
sub rpm_qa($) {
    my ($root_dir) = @_;

    my $FH;
    my $rpmQ = "$rpm_command -qa --qf " .
        "'%{NAME}  %{VERSION}-%{RELEASE}  %{ARCH}  %{INSTALLTIME}  " .
        "%{VENDOR:shescape}  %{RSAHEADER}  %{DSAHEADER}  %{HEADERIMMUTABLE}\n'";

    # Either use cache if available...
    if ( -d $cache_dir && -r $rpm_qa_cache ) {
        open( $FH, "< $rpm_qa_cache")       || Die( "open(rpm -qa): $!\n");
        $_ = <$FH>;
        if ( ! m{^# (\S+) -- (.*)$} ) {
            Die("$progname: unknown cache format! Please remove.\n" .
                "(e.g. with 'rm -rf $cache_dir/rpm-{qa,Vv})'\n");
        } else {
            my ($wrong_vers, $wrong_root) =
                ($cache_file_version ne $1, $root_dir ne $2);
            if ( $wrong_vers or $wrong_root ) {
                Die("$progname: invalid cache: %s%s%s. Please remove.\n" .
                    "(e.g. with 'rm -rf $cache_dir/rpm-{qa,Vv})'\n",
                      ( $wrong_vers ? "wrong version" : "" ),
                      ( $wrong_vers and $wrong_root ? " and" : "" ),
                      ( $wrong_root ? "different root dir checked" : "" ));
                # or should we only warn and "refresh" it automagically?
            } else {
                ##$cacheHit ++;
                #return $FH;
                close($FH);
            }
        }
    }

    # rpm -qa is usually so fast, that we only cache to "record" the list
    # of installed packages => no "cache accounting"
    ##$cacheMiss ++;

    Log(8, "+%s\n", $rpmQ);
    # ... or read directly from rpm command (and recreate cache if possible)
    open($FH, "$rpmQ |")                    || Die( "rpm: $!\n");
    if (-d $cache_dir) {
        if (open(OUT, "> $rpm_qa_cache") ) {
            print OUT "# $cache_file_version -- $root_dir\n";
            while (<$FH>) {
                print OUT;
            }
            close(OUT);
            close($FH);

            open($FH, "< $rpm_qa_cache")    || Die( "reopen: $rpm_qa_cache: $!\n");
            # skip version / root_dir string
            $_ = <$FH>;
            # pre-create directory for rpm_V()
            if (! -d $rpm_Vv_cache) {
                mkdir($rpm_Vv_cache)        || warn "mkdir $rpm_Vv_cache: $!\n";
            }
            # pre-create directory for rpm_e()
            if (! -d $rpm_e_cache ) {
                mkdir($rpm_e_cache)         || warn "mkdir $rpm_e_cache: $!\n";
            }
        } else {
            warn("create: $rpm_qa_cache: $!\n");
        }
    }
    return $FH;
}

#
# return filehandle for the output of "rpm -V..." on a package
# may use cached data or pipe directly from the rpm command
#
sub rpm_V($$) {
    my ($root_dir, $package) = @_;

    my $FH;
    my $cache_file  = "$rpm_Vv_cache/$package";
    my $rpmV        = "$rpm_command -Vv '$package' 2> /dev/null";

    if (-r $cache_file) {
        $cacheHit ++;
        open($FH, "< $cache_file")          || Die( "open($cache_file): $!\n");
        return $FH;
    }
    $cacheMiss ++;

    open($FH, "$rpmV |")                    || Die( "rpm -V: $!\n");
    if (-d $rpm_Vv_cache) {
        if (open(OUT, "> $cache_file")) {
            while (<$FH>) {
                print OUT;
            }
            close(OUT);
            close($FH);

            open($FH, "< $cache_file")      || Die( "reopen($cache_file): $!\n");
        } else {
            warn("create: $cache_file: $!\n");
        }
    }
    return $FH;
}

#
# return filehandle for the output of "rpm -e --test ..." on a package
# may use cached data or pipe directly from the rpm command
#
sub rpm_e($$) {
    my ($root_dir, $package) = @_;

    my $FH;
    my $cache_file  = "$rpm_e_cache/$package";
    my $rpme        = "$rpm_command -e --test '$package' 2> /dev/null";

    if ( -r $cache_file ) {
        $cacheHit ++;
        open($FH, "< $cache_file")          || Die( "open($cache_file): $!\n");
        return $FH;
    }
    $cacheMiss ++;

    open($FH, "$rpme |")                    || Die( "rpm -e: $!\n");
    if (-d $rpm_e_cache) {
        if (open(OUT, "> $cache_file") ) {
            while (<$FH>) {
                print OUT;
            }
            close(OUT);
            close($FH);

            open($FH, "< $cache_file")      || Die( "reopen($cache_file): $!\n");
        } else {
            warn("create: $cache_file: $!\n");
        }
    }
    return $FH;
}

# ---------------------------------------------------------------------------
# Assess if this file was changed in an unsupportable way. Return descriptive
# string for the supportability information based on the evaluation of the
# "rpm -V..." output for a single file from some package.
#
# assessment results:
# O:   OK               (miss/mod: no,  supportability problem: no,  report: lvl 4)
# H:   Harmless         (miss/mod: yes, supportability problem: no,  report: lvl 3)
# T:   Tolerable        (miss/mod: yes, supportability problem: no,  report: lvl 2)
# U:   Unsupportable    (miss/mod: yes, supportability problem: yes, report: lvl 1)
#
# change state of files:
#   OK      OK, no changes
#   miss    missing
#   mod     modified
#
# TODO: check report levels are well chosen and documented corrrectly
#
sub assess($$$$$) {
    my ($rpm, $file, $kind, $result, $error) = @_;
    my $summary_result = $result;
    $summary_result =~ s{\.}{}g;
    $error = (defined $error ? " ($error)" : "");

    if ( $result =~ m{^\.{8}$} ) {
        # file is not modified at all: OK
        return "O:OK";
    } elsif ( $result eq "missing " && $kind eq "d" ) {
        # missing documentation: Harmless
        return "H:miss doc" . $error;
    } elsif ( $result eq "missing " ) {
        # missing non-documentation file: Unsupportable
        return "U:miss   $kind" . $error;

    } elsif ( $kind eq "c" ) {
        # existing config file with any kind of changes:
        # Harmless
        return "H:mod c:$summary_result";

    } elsif ( $result =~ m{^[UG.]{8}$} ) {
        # existing (non-config) file with ownership change only:
        # Tolerable
        return "T:mod $kind:$summary_result";
    } elsif ( $result =~ m{^[MUG.]{8}$} ) {
        # existing (non-config) file with exactly some kind of
        # ownership change and file mode change: Unsupportable
        return "U:mod $kind:$summary_result";
    } elsif ( $result =~ m{^[T.]{8}$} ) {
        # existing (non-config) file with some kind of metadata
        # change that does not affect ownership or file mode (and
        # no other changes): Tolerable
        return "T:mod $kind:$summary_result";

    } else {
        # existing (non-config) file
        #   - has a change in file size
        #   - has a content change
        #   - is a device node and major/minor has changed
        #   - is a softlink that has changed
        # -> Unsupportable
        return "U:mod $kind:$summary_result";
    }
}

sub max($$) {
    my ($a, $b) = @_;
    return ($a > $b) ? $a : $b;
}

# Usage message
sub Usage($$) {
    my( $rv, $msg) = @_;

    print( STDERR $msg . "\n")      if ( $msg );

    print STDERR <<"EOF";
$progname $prog_version
Supportability Analysis Module

$progname [-v|--verbose] [-q|--quiet] [-d|--debug] [-t|--tmpdir <tmpdir>]
$progspcs [--no-header-sig-check] [--no-rpm-verify] [--no-orphan-search]
$progspcs [-w|--write] [root_path]
$progname [-h|--help]

If <root_path> is specified, it will be used as the path to the root of the
installation to verify.

Options:
    -w|--write              write reports to sam.* files
    -t|--tmpdir             write temporary files to <tmpdir> (default: $tmpdir)
                            $progname needs about 100KB for temporary files
    --no-header-sig-check   skip checking RPM header signature of installed packs
    --no-rpm-verify         skip verifying installed files against the RPM db
    --no-orphan-search      skip searching for orphaned files
    -h|--help               print this help message
    -v|--verbose            increase verbosity level
    -q|--quiet              decrease verbosity level
    -d|--debug              increase debug level
EOF

    Die( $rv);
}

# ---------------------------------------------------------------------------
# Main program
#

{
    use Getopt::Long;
    $Getopt::Long::debug = 0;
    $Getopt::Long::ignorecase = 0;
    $Getopt::Long::bundling = 1;
    $Getopt::Long::passthrough = 0;
    my %Opt = ();

    Usage( -1, "") unless ( GetOptions( \%Opt,
          'help|h', , 'verbose|v+', 'quiet|q+', 'debug|d+',
          'write|w', 'tmpdir|t:s', 'no-header-sig-check',
          'no-rpm-verify', 'no-orphan-search'));# && ! $Opt{'help'} );

    Usage(0, "")                            if ( $Opt{'help'} );
    $debug             += $Opt{'debug'}     if ( $Opt{'debug'} );
    $verbose           += $Opt{'verbose'}   if ( $Opt{'verbose'} );
    $verbose           -= $Opt{'quiet'}     if ( $Opt{'quiet'} );
    $writeReport        = 1                 if ( $Opt{'write'} );
    $tmpdir             = $Opt{'tmpdir'}    if ( $Opt{'tmpdir'} );
    $CHECK_SIGNATURE    = 0                 if ( $Opt{'no-header-sig-check'} );
    $VERIFY_PACKAGES    = 0                 if ( $Opt{'no-rpm-verify'} );
    $FIND_DETACHED      = 0                 if ( $Opt{'no-orphan-search'} );
}
$verbose    = ($verbose < 0) ? 0 : $verbose;
$debug      = ($debug  <= 0) ? 0 : (1 << $debug) - 1;

if ( exists( $ARGV[0]) && -d $ARGV[0] ) {
    $root_dir = $ARGV[0];
    $rpm_command .= " --root '$root_dir'";
}

#
# set up temp file names and delete stale temp files
#

Die("$tmpdir is not a writeable directory\n")   if ( not (-d $tmpdir and -w $tmpdir) );
$pubring    = "$tmpdir/$pubring";
$sigfile    = "$tmpdir/$sigfile";
$signedfile = "$tmpdir/$signedfile";
$unlink_ok  = 1;
unlink($pubring, $sigfile, $signedfile);

#
# write reports if we have the $cache_dir directory
#
$writeReport++ if ( -d $cache_dir );

if ( $writeReport ) {
    open( LOG,      "> sam.log")    || Die("open(LOG): $!\n");
    open( REPORT,   "> sam.report") || Die("open(REPORT): $!\n");
    open( HTML,     "> sam.html")   || Die("open(HTML!\n");
    open( JSON,     "> sam.json")   || Die("open(JSON!\n");
} else {
    open( LOG,      ">> /dev/null") || Die("open(/dev/null): $!\n");
    open( REPORT,   ">& LOG")       || Die("dup(LOG, REPORT): $!\n");
    Log( 1, "$progname: $cache_dir: no such directory.\n" .
        "  sam.{log,report,html.json} will not be written.\n\n");
}

# ---------------------------------------------------------------------------
Log(0, "%%T: MS00: started %s\n", $invocation_cmd_line);
Log(0, "%%T: MS01: Find metadata and set up GPG\n");
#
# read available configuration and repository infos
#
# get (configured) locations of repository files
get_repo_conf($root_dir);
# get infos about all repositories
get_repo_infos();

if (not $CHECK_SIGNATURE) {
    Log(0, "  rpm header signature check disabled: will not check package authenticity\n");
} elsif (not -x $gpg_exe) {
    Log(0, "  GPG executable \"$gpg_exe\" not found: will not check package authenticity\n");
} else {
    # setup keyring
    find(\&setup_key, $metadatadir);

    if (scalar (keys %good_key_ids) > 0) {
        Log(0, "  found %d SUSE/Novell build keys: will check package authenticity\n",
            scalar (keys %good_key_ids));
    } else {
        Log(0, "  no SUSE/Novell build keys found: cannot check package authenticity\n");
    }
}

# ---------------------------------------------------------------------------
# Enumerate packages
#
my $max_pack_name_length = 0;
my $max_filename_length = 0;

Log(0, "%%T: MS02: enumerate packages (1a)\n");
my $IN = rpm_qa($root_dir);
while ( <$IN> ) {
    chomp();
    if ( ! m{^(\S+)  (\S+)  (\S+)  ([0-9]+)  '(.*?)'  (\S+)  (\S+)  (\S+)$} ) {
        Log(0, "  rpm: unexpected query response: '$_'\n");
        next;
    }

    my ($name, $vers_rel, $arch, $inst_time, $vendor,
        $rsaheadersig, $dsaheadersig, $rpmheader) =
        ($1, $2, $3, $4, $5, $6, $7, $8);
    my $package_name = "$name-$vers_rel.$arch";
    $vendor ||= "undef";

    if ( $arch eq '(none)' and $package_name =~ m{^gpg-pubkey-} ) {
        # "silently" drop verification keys
        Log(5, "  rpm: dropping: $package_name\n");
        next;
    }

    # check for SUSE/Novell package
    my $is_ours = is_our_package($rsaheadersig, $dsaheadersig, $rpmheader,
                                 $package_name, $vendor);

    if ( $arch eq '(none)' and $is_ours ) {
        #if ( $vendor =~ m(^$Vendors)o ) {
        # ignore SUSE/Novell packages without architecture info
        Log(0, "  rpm: ignoring: $package_name\n");
        $skipped_packages{$package_name} = "$inst_time $vendor";
        next;
    }

    #if ( $vendor !~ m(^$Vendors)o ) {
    if ( not $is_ours ) {
        # remember packages from other vendors
        Log(0, "  rpm: foreign vendor package: $package_name: $vendor\n");
        $alien_packages{$package_name}  = "$inst_time $vendor";
        $alien2name{$package_name}      = $name;
        $alien2edition{$package_name}   = "$vers_rel";
        next;
    }

    $package2inst_time{$package_name}   = $inst_time;
    $package2name{$package_name}        = $name;
    $package2edition{$package_name}     = "$vers_rel";
    $max_pack_name_length               = max($max_pack_name_length, length($package_name));
}
close($IN);

# ---------------------------------------------------------------------------
# Verify installed files against package headers:
# first "our" packages, then "foreign" packages
#
my $num_packages = scalar(keys(%package2inst_time));
my $num_foreign  = scalar(keys(%alien_packages));
my %inodes;
my %file2rpm;
my %file2kind;
my %file2type;
my %file2res;
my %unsatisfied;
my %foreign_unsatisfied;
my %depends_on_alien;

my @file_modified;
my @file_tolerated;
my @file_missing;
my @file_dispensable;

my @foreign_file_modified;
my @foreign_file_tolerated;
my @foreign_file_missing;
my @foreign_file_dispensable;

my %unsupportable;
my %tolerable;
my %harmless;
my %ok;

my $is_ours = 0;

# have we seen this filename already?
# -> handle duplicates
#
sub check_and_log_duplicate_file ($$$$$$) {
    my ($logBuffer_ref, $rpm, $file, $kind, $vrfy_result, $error) = @_;

    if ($ALLOW_MULTIHOMED_OBJECTS) {
        # if multihomed files are allowed, all the packages for such a file are
        # remembered
        push @{ $file2rpm{$file} }, $rpm;
        if ( exists( $file2kind{$file}) && $file2kind{$file} ne $kind ) {
            Die( "$file: conflicting attributes: $file2kind{$file} <> $kind\n");
        } else {
            $file2kind{$file} = $kind;
            #Log( 0, "$file\n") if $kind eq "d" && $vrfy_result eq "missing ";
        }
    } else {
        # if multihomed files are not allowed, log entries will be generated
        if ( exists($file2rpm{$file}) ) {
            # file is multihomed
            $_ = $file2res{$file};
            $$logBuffer_ref = " "x4 . "$file: duplicate $file2rpm{$file}\n";
            if ( m{^[UTH]:miss} ) {
                # a duplicate of a missing object hardly makes it worse...
                $$logBuffer_ref .= " "x6 ."dupe: miss\n";
                $file2res{$file} .= " && U:dup :$rpm";
                push @{ $unsupportable{$rpm} }, $file;
            } elsif ( m{^U:} ) {
                if ( $file2type{$file} eq "dir" ) {
                    # packaging directories multiple times is OK...
                    $$logBuffer_ref .= " "x6 ."dup: U:  dir => H\n";
                    $file2res{$file} .= " && H:dupe:$rpm";
                } else {
                    # ...but not other objects
                    $$logBuffer_ref .= " "x6 ."dup: U: !dir => U\n";
                    $file2res{$file} .= " && U:dupe:$rpm";
                    push @{ $unsupportable{$rpm} }, $file;
                }
            } elsif ( $file2type{$file} eq "dir" ) {
                # again, packaging directories multiple times is OK...
                $$logBuffer_ref .= " "x6 ."dup: H:  dir => H\n";
                $file2res{$file} .= "H:dupe: $rpm";
            } else {
                $_ = assess($rpm, $file, $kind, $vrfy_result, $error);
                if ( ! m{^U:} ) {
                    # ...if it verifies OK, only note
                    $$logBuffer_ref .= " "x6 ."dup: H: same => H\n";
                    $file2res{$file} .= "H:dupe: $rpm";
                } else {
                    # ...otherwise promote to "Unsupportable"
                    $$logBuffer_ref .= " "x6 ."dup: H: !dir => U\n";
                    $file2res{$file} = "$_ && U:dupe: $rpm && $file2res{$file}";
                    if ( ! exists $unsupportable{$file2rpm{$file}} ) {
                        push @{ $unsupportable{$file2rpm{$file}} }, $file;
                    }
                    # FIXME: why push $file twice on @{$unsupportable{$file2rpm{$file}}}
                    # if it does not exist? (see above and below): the above
                    # should be unnecessary
                    push @{ $unsupportable{$file2rpm{$file}} }, $file;
                    push @{ $unsupportable{$rpm} }, $file;
                }
            }
            return 1;
        }

        # not multihomed: record package for this file
        $file2rpm{$file} =  $rpm;
        $file2kind{$file} =  $kind;
    }
    return 0;
}

sub evaluate_supportability_and_record_results ($$$$$) {
    my ($rpm, $file, $kind, $vrfy_result, $error) = @_;

    # is the result for the file a supportability problem? classify...
    $_ = assess($rpm, $file, $kind, $vrfy_result, $error);
    #$packinfo{$rpm}->{file}->{$file}->{res} = $_;
    $file2res{$file} = $_;
    if ( m{^U:miss} ) {
        if ($is_ours) {
            push @file_missing, $file;
            push @{ $unsupportable{$rpm} }, $file;
        } else {
            push @foreign_file_missing, $file;
        }
        return;
    } elsif ( m{^T:miss} ) {
        if ($is_ours) {
            push @file_dispensable, $file;
            push @{ $tolerable{$rpm} }, $file;
        } else {
            push @foreign_file_dispensable, $file;
        }
        return;
    } elsif ( m{^H:miss} ) {
        if ($is_ours) {
            push @file_dispensable, $file;
            push @{ $harmless{$rpm} }, $file;
        } else {
            push @foreign_file_dispensable, $file;
        }
        return;

    } elsif ( m{^U} ) {
        if ($is_ours) {
            push @file_modified, $file;
            push @{ $unsupportable{$rpm} }, $file;
        } else {
            push @foreign_file_modified, $file;
        }
    } elsif ( m{^T} ) {
        if ($is_ours) {
            push @file_tolerated, $file;
            push @{ $tolerable{$rpm} }, $file;
        } else {
            push @foreign_file_tolerated, $file;
        }
    } elsif ( m{^H} ) {
        if ($is_ours) {
            push @{ $harmless{$rpm} }, $file;
        }
    } elsif ( m{^O} ) {
        if ($is_ours) {
            push @{ $ok{$rpm} }, $file;
        }
    } else {
        Die("$progname: internal error. $_\n");
    }

    # when we see the file for the first time, add to total size of all files,
    # increase number of different files and remember type of file (file, dir,
    # link, special)
    my ($dev,$ino,$mode,$nlink,$uid,$gid,$rdev,$size,
        $atime,$mtime,$ctime,$blksize,$blocks) =
        lstat( $root_dir . $file);

    if ( -f _ ) {
        $file2type{$file} = "file";
        if (! exists $inodes{"$dev:$ino"}) {
            $pSize += ($size + 512) / 1024;
        }
    } elsif ( -d _ ) {
        $file2type{$file} = "dir";
    } elsif ( -l _ ) {
        $file2type{$file} = "link";
    } else {
        $file2type{$file} = "special";
    }

    if (! exists $inodes{"$dev:$ino"}) {
        $inodes{"$dev:$ino"} = 1;
        $pInodes++;
    }
}

sub verify_packages ($$$) {
    my ($packhash_ref, $unsatisfied_ref, $msg) = (@_);

    foreach my $rpm ( sort(keys(%$packhash_ref)) ) {
        $logBuffer = "";
        Log(1, "  %%T: $msg: $rpm\n");

        $IN = rpm_V($root_dir, $rpm);
        while ( <$IN> ) {
            chomp();
            if (m{^([S.][M.][5?.][D.][L.][U.][G.][T.]|missing )  ([cdglr ]) (\S.+)$}) {
                # note: rpm(8) calls the file kind (%config, %doc, ...) an
                # "attribute" of the file
                my ($vrfy_result, $kind, $file, $error) = ($1, $2, $3, undef);
                if ($vrfy_result =~ /missing/ and $file =~ /^(\S.+) \(([^\(\)]*)\)$/) {
                    $file = $1;
                    $error = $2;
                }
                $max_filename_length = max($max_filename_length, length($file));

                next if check_and_log_duplicate_file(\$logBuffer, $rpm, $file,
                                                $kind, $vrfy_result, $error);

                evaluate_supportability_and_record_results($rpm, $file, $kind,
                                                        $vrfy_result, $error);

            } elsif ( m{^Unsatisfied dependencies for ([^:]+)\: (\S.+)$} ) {
                my ($pkg, $deps) = ($1, $2);
                $$unsatisfied_ref{$pkg} = $deps;
            } else {
                Die( "$progname: rpm: unexpected query response:\n  $_\n");
            }
        }
        close( $IN);

        Log(3, $logBuffer)  if $logBuffer;
    }
}

Log(0, "%%T: MS03: verify");
if (not $VERIFY_PACKAGES) {
    Log(0, "%d packages: skipped\n", $num_packages);
} elsif ( $num_packages == 0 ) {
    Log(0, "%%T: Panic: no packages to verify!?\n");
    Die(-1);
} else {
    Log(0, " %d packages\n", $num_packages);

    # first, verify "our" packages
    $is_ours = 1;
    verify_packages(\%package2inst_time, \%unsatisfied, "verify");

    # then verify "foreign" packages
    $is_ours = 0;
    verify_packages(\%alien_packages, \%foreign_unsatisfied, "verify foreign");
}

# ---------------------------------------------------------------------------
Log(0, "%%T: MS04: find packages that depend on foreign packages\n");
foreach my $rpm ( sort(keys(%alien_packages)) ) {
    Log(1, "  find dependents on: $rpm\n");

    $IN = rpm_e($root_dir, $rpm);
    while ( <$IN> ) {
        chomp();
        if (m{^error: Failed dependencies:$}) {
            Log(4, "  rpm: found dependents on: $rpm\n");
        } elsif (m{^\s+(.*)\s+is needed by(?: \(installed\))? (\S+)$}) {
            my ($provided, $dependant_pack) = ($1, $2);

            # remember if the dependant package is one of ours
            if (exists $package2inst_time{$dependant_pack}) {
                push @{$depends_on_alien{$dependant_pack}->{$rpm}}, $provided;
                Log(4, "  rpm: our package $dependant_pack needs $provided from $rpm\n");
            }
        } else {
            Die("$progname: rpm: unexpected query response:\n  $_\n");
        }
    }
    close( $IN);
}

# ---------------------------------------------------------------------------
# Find SUSE/Novell source repos for installed packages, compare versions
#
my %newer_exists    = ();

Log(0, "%%T: MS05: Identify package sources (2i)");
if ($satsolver == 0) {
    Log(0, ": skipped: no satsolver\n");
} elsif ($satsolver == -1) {
    Log(0, ": skipped: no usable satsolver (missing functions)\n");
} elsif ((scalar keys %repoinfo) == 0) {
    Log(0, ": skipped: no repositories\n");
} else {
    Log(0, "\n");

    # create pool
    my $pool = new satsolver::Pool;

    # set architecture: only compatible packages are considered
    my $sysarch = `uname -m`    || Die("uname -m\n");
    chomp $sysarch;
    $pool->set_arch($sysarch);

    # create repo with RPM database
    my $installed = $pool->create_repo('installed') || Die("create_repo('installed')\n");
    $installed->add_rpmdb("/");

    # create a repo each for SUSE/Novell installation sources
    my @repos = ();
    foreach my $subdir (keys %repoinfo) {
        my $name    = $repoinfo{$subdir}->{'name'};
        my $label   = $repoinfo{$subdir}->{'label'};
        next        if $label !~ m($Labels)o;

        Log(0, "  using repository %s\n", $name);
        push @repos, $pool->create_repo($subdir)    || Die("create_repo($subdir)\n");
        $repos[$#repos]->add_solv($repoinfo{$subdir}->{'solvfile'});
    }

    # create dependencies to provides table
    $pool->prepare();

    # find providers for each installed package
    foreach my $inst_solvable ($installed->solvables()) {
        Die "inst_solvable not defined\n"   if not defined $inst_solvable;

        my $inst_solvname   = $inst_solvable->name();
        my $inst_solvevr    = $inst_solvable->evr();
        my $inst_solvstring = $inst_solvable->string();

        my %found = ();
        foreach my $solvable ($pool->providers($inst_solvable->name())) {
            next            if (not defined $solvable);

            my $subdir      = $solvable->repo()->name();
            # do not use matches on the 'installed' repo
            next            if $subdir eq "installed";

            my $reponame    = $repoinfo{$subdir}->{name};
            my $reponumber  = $repoinfo{$subdir}->{number};

            # identical package? (name, arch, evr, vendor, build time,
            # requires, ...)
            if ($solvable->identical($inst_solvable)) {
                Log(3, "  = %-69s (repo has same package: %s)\n",
                    $inst_solvstring, $reponame);
                $found{identical}++;
            } else {
                # find out if the repository provides an older or newer package
                my $result      = $solvable->compare($inst_solvable);
                my $solv_evr    = $solvable->evr();
                if ($result < 0) {
                    Log(2, "  + %-35s %-15s > %-15s (repo has older evr: %s)\n",
                        $inst_solvname, $inst_solvevr, $solv_evr, $reponame);
                    $found{older}++;
                } elsif ($result > 0) {
                    Log(1, "  - %-35s %-15s < %-15s (repo has newer evr: %s)\n",
                        $inst_solvname, $inst_solvevr, $solv_evr, $reponame);
                    $found{newer}++;
                    push @{$newer_exists{$inst_solvstring}}, "repo #$reponumber: $solv_evr";
                } else {
                    # identical evr, different package: strange
                    # (manual rebuild installed or in repo?)
                    Log(1, "  ! %-35s %-15s ~ %-15s (repo has same evr: %s)\n",
                        $inst_solvname, $inst_solvevr, $solv_evr, $reponame);
                    $found{similar}++;
                }
            }
        }
        if ((scalar(keys %found)) == 0) {
            Log(0, "  m %-69s (no package with this name exists in SUSE/Novell repos)\n",
                $inst_solvname);
        } elsif (not exists $found{identical}) {
            Log(0, "  ? %-69s (not found in SUSE/Novell repos)\n",
                $inst_solvstring);
        }
    }
}

# ---------------------------------------------------------------------------
Log( 0, "%%T: MS06: find 'detached' files (1e)");
if ( not $FIND_DETACHED ) {
    Log( 0, ": skipped\n");
    shift @orphans;
    $aSize = $aInodes = -1;
} else {
    Log( 0, "\n");
    findOrphans($root_dir);
}

# ---------------------------------------------------------------------------
Log( 0, "%%T: MS90: Reporting Results...\n");

# ===========================================================================
Report("*** Report by %s\n", $invocation_cmd_line);
Report("*** Considered Packages (1a): %d (sig ok: %s), foreign: %d (excluded: %d)\n",
        $num_packages, $CHECK_SIGNATURE ? $num_sig_ok_packs : "OFF",
        $num_foreign, scalar( keys( %skipped_packages)));

my $unsupportable = scalar( keys( %unsupportable));
my $tolerable = scalar( keys( %tolerable));
Report("*** Packages with Verification Anomalies (1b): ");
if (not $VERIFY_PACKAGES) {
    Report( "NOT CHECKED.\n");
} elsif ( $unsupportable + $tolerable > 0 ) {
    Report( "%d (+%d minor)\n", $unsupportable, $tolerable);
} else {
    Report( "NONE.\n");
}

my $unsat = scalar( keys( %unsatisfied));
Report("*** Violated Package Dependencies (1c): ");
if (not $VERIFY_PACKAGES) {
    Report( "NOT CHECKED.\n");
} elsif ( $unsat > 0 ) {
    Report("%d\n", $unsat);
    foreach my $package ( sort( keys( %unsatisfied)) ) {
        Report("  %s\n    %s\n", $package, $unsatisfied{$package});
    }
} else {
    Report("NONE.\n");
}

my $aliendeps = scalar( keys( %depends_on_alien));
Report("*** Packages Depending on Foreign Packages (2l): ");
if ( $aliendeps > 0 ) {
    Report("%d\n", $aliendeps);
    foreach my $package ( sort( keys( %depends_on_alien)) ) {
        Report( "  %s\n    %s\n", $package, join (" ", sort( keys( %{$depends_on_alien{$package}}))) );
    }
} else {
    Report("NONE.\n");
}

if ( $unsupportable + $unsat + $aliendeps > 0 ) {
    $Unsupportable = 1;
}

# ---------------------------------------------------------------------------
Report("*** Found Package Repositories (2h): ");
my $num_repos = (keys %repoinfo);
if ( $num_repos == 0 ) {
    Report("NONE.\n");
} else {
    Report("%d\n", $num_repos);

    foreach my $subdir (sort (keys %repoinfo)) {
        my $info_ref    = $repoinfo{$subdir};
        Report("  Repository #%d:\n", $info_ref->{number});

        Report("    %s\n",
            join("\n    ", map(sprintf("%-12s %s", "$_:", $info_ref->{$_}),
                           (sort (keys %$info_ref)))));
    }
}

# ---------------------------------------------------------------------------
Report("*** Package Version Checks (2i): ");
my $num_old_packages = (keys %newer_exists);
if ($satsolver <= 0) {
    Report("NOT CHECKED.\n");
} elsif ( $num_old_packages > 0 ) {
    Report("updates available for %d packages\n", $num_old_packages);
    foreach my $pack (sort(keys(%newer_exists))) {
        Report("  %-55s  %s\n", "$pack:", join(", ", @{$newer_exists{$pack}}));
    }
} else {
    Report("NONE.\n");
}

# ---------------------------------------------------------------------------
Report("*** Modified filesystem objects (1d): ");
if (not $VERIFY_PACKAGES) {
    Report("NOT CHECKED.\n");
} elsif ( ($#file_modified + $#file_tolerated) > 0 ) {
    Report("%d (+%d ignored)\n", $#file_modified + 1, $#file_tolerated + 1);
    foreach my $pack ( sort( keys(%unsupportable)) ) {
        Report("  %s\n", $pack);
        my $num_files = 0;
        foreach my $pfile ( @{ $unsupportable{$pack} } ) {
            my $res = prettyR($file2res{$pfile});
#           if ( length($pfile) + length($res) > 72 ) {
#                ReportQ(" "x4 ."%s\n" ." "x6 ."%s\n", $pfile, $res);
#            } else {
##                ReportQ(" "x4 ."%s  %s\n", $pfile, $res);
#                ReportQ(" "x4 ."%-*s  %s\n", 55 - max((72 - 55), length($res)), $pfile, $res);
#            }
            ReportQ(" "x4 ."%-55s  %s\n", $pfile, $res);
            Log(9, " "x6 ."%s %s\n", $file2kind{$pfile}, $file2res{$pfile});
            if ( $num_files++ >= 10 ) {
                ReportQ(" "x4 ."and %d more\n", $#{ $unsupportable{$pack} } - 10);
                last;
            }
        }
    }
} else {
    Report("NONE.\n");
}

# ---------------------------------------------------------------------------
Report("*** Accrued filesystem objects (1e): ");
if (not $FIND_DETACHED) {
    Report( "NOT CHECKED.\n");
} elsif ( $#orphans == 0 ) {
    Report("* s k i p p e d *.\n");
} elsif ( $#orphans >= 0  ) {
    Report("%d (+%d ignored)\n", $#orphans, 0);
    shift @orphans;
    Log(1, "  %s\n", join( "\n  ", @orphans));
} else {
    Report("NONE.\n");
}

Report("*** Preliminary Conclusion: %ssupportable.\n",
        (($Unsupportable) ? "NOT " : ""));
Report("  %d (%.2f%%) OK, %d (%.2f%%) dubious, %d (%.2f%%) fail\n",
        ($num_packages - $unsupportable - $unsat - $aliendeps),
        ($num_packages - $unsupportable - $unsat - $aliendeps) * 100.0 / $num_packages,
        $unsupportable,
        $unsupportable * 100.0 / $num_packages,
        ($unsat + $aliendeps),
        ($unsat + $aliendeps) * 100.0 / $num_packages);
if ($Unsupportable) {
    Report("  This is a preliminary interpretation.\n");
    Report("  A detailed analysis of the report or the log file\n");
    Report("  may show that this system is supportable.\n");
}

# ===========================================================================
if ( $writeReport ) {
    my ($user, $system, $cuser, $csystem) = times();
    my $Telapsed = time() - $Tstart;

    Log( 1, "%%T: MS92: Generate reports...\n");
    initHTML();
    foreach my $p ( sort( keys( %package2inst_time)) ) {
        my ($r, $n, $t) = ( 0, "", "");
        $t = $unsatisfied{$p};
        if (defined( $t) ) {
            $r = 1;
            $n .= "unsatisfied dependencies: $t";
        }
        if (defined( @{ $unsupportable{$p} }) ) {
            $r = 1;
            $n .= "; " if ( $n );
            $t = $#{ $unsupportable{$p} } + 1;
            $n .=  sprintf( "%d object%s modified", $t,
                            ($t != 1) ? "s were" : " was");
        }
        printHTML($p, $r, $n);
        fillJSON($p, $r, $n);
    }
    finishHTML();
    finishJSON();

    Log(1, "%%T: MS95: Log statistics...\n");
    Log(1, "  Filesystem: %s, %d inodes over all\n",
        prettyK($aSize), $aInodes);
    Log(1, "  rpmDB:      %s, %d inodes from %d packages (hit %d, miss %d)\n",
        prettyK($pSize), $pInodes, scalar( keys( %package2inst_time)),
        $cacheHit, $cacheMiss);
    Log(1, "  Runtime (in secs): %.2f user, %.2f system, %.2f elapsed\n",
        $user, $system, $Telapsed);
    open( IN, "< /proc/self/status")|| Die( "open(status): $!\n");
    Log(1, "  Memory: ");
    while ( <IN> ) {
        next unless ( m{^(Vm(Size|RSS|Data))} );
        chomp; s{\s+}{}g; s{\:}{=}g;
        Log(1, " %s", $_);
    }
    Log(1, "\n");
    close( IN);
}

exit($Unsupportable);

openSUSE Build Service is sponsored by