#!/usr/bin/perl -w
#
# Copyright (c) 2018 SUSE Inc.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 2 as
# published by the Free Software Foundation.
#
# 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 (see the file COPYING); if not, write to the
# Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
#
################################################################
#
# Registry interfacing
#

BEGIN {
  my ($wd) = $0 =~ m-(.*)/- ;
  $wd ||= '.';
  unshift @INC,  "$wd/build";
  unshift @INC,  "$wd";
}

use JSON::XS ();
use Digest::SHA ();
use Data::Dumper;
use Compress::Zlib;

use BSRPC ':https';
use BSHTTP;
use BSTar;
use BSContar;
use BSUtil;
use BSBearer;

use strict;

my $registry_timeout = 300;

my $dest_creds;
my $use_image_tags;
my $multiarch;
my $digestfile;
my $listfile;
my $writeinfofile;
my $delete_mode;
my $delete_except_mode;
my $list_mode;
my $no_cosign_info;
my $no_info;
my $listidx;
my $listidx_no_info;
my @tags;
my $blobdir;
my $oci;
my $old_listfile;
my $with_platforms;
my $missingok;

my $cosign;
my $cosigncookie;
my $slsaprovenance;
my $sbom;
my $gun;
my @signcmd;
my $pubkeyfile;

my $rekorserver;

my $registryserver;
my $repository;
my @tarfiles;
my $list_manifestinfo;
my $manifestinfoserver;

my $registry_authenticator;
my $registry_blob_authenticator;
my $keepalive;

my $cosign_cookie_name = 'org.open-build-service.cosign.cookie';

sub registry_errorcode {
  my ($answer) = @_;
  my $code;
  eval {
    my $r = JSON::XS::decode_json($answer);
    $code = $r->{'errors'}->[0]->{'code'} if $r->{'errors'};
  };
  return $code;
}

sub send_layer {
  my ($param) = @_;
  my $sl = $param->{'send_layer_data'}; # [ $layer_ent, $offset ],
  my $chunk = BSTar::extract($sl->[0]->{'file'}, $sl->[0], $sl->[1], 65536);
  $sl->[1] += length($chunk);
  return $chunk;
}

sub contenttype2manitype {
  my ($ct) = @_;
  return 'v1image' unless $ct;
  return 'list' if $ct eq $BSContar::mt_docker_manifestlist;
  return 'image' if $ct eq $BSContar::mt_docker_manifest;
  return 'ociimage' if $ct eq $BSContar::mt_oci_manifest;
  return 'ocilist' if $ct eq $BSContar::mt_oci_index;
  return 'unknown';
}
  
sub blob_exists {
  my ($blobid, $size) = @_;
  my $replyheaders;
  my $param = {
    'uri' => "$registryserver/v2/$repository/blobs/$blobid",
    'request' => 'HEAD',
    'authenticator' => $registry_authenticator,
    'timeout' => $registry_timeout,
    'replyheaders' => \$replyheaders,
    'maxredirects' => 1,
  };
  eval { BSRPC::rpc($param) };
  if ($replyheaders) {
    die("size mismatch?\n") if $replyheaders->{'content-length'} && $size && $size != $replyheaders->{'content-length'};
    return 0 if $replyheaders->{'docker-content-digest'} && $blobid ne $replyheaders->{'docker-content-digest'};
    return 1;
  }
  return 0;
}

sub calc_authrealm {
  my ($url) = @_;
  return '' unless $url =~ /^(https?):\/\/(?:([^\/\@]*)\@)?([^\/:]+)(:\d+)?(\/.*)$/;
  return $3 . ($4 || '');
}

sub blob_upload {
  my ($blobid, $upload_ent) = @_;

  my $size = $upload_ent->{'size'};
  return $blobid if blob_exists($blobid, $size);
  print "uploading layer $blobid... ";
  my $replyheaders;
  my $param = {
    'headers' => [ 'Content-Length: 0', 'Content-Type: application/octet-stream' ],
    'uri' => "$registryserver/v2/$repository/blobs/uploads/",
    'request' => 'POST',
    'authenticator' => $registry_authenticator,
    'timeout' => $registry_timeout,
    'replyheaders' => \$replyheaders,
  };
  BSRPC::rpc($param);
  my $loc = $replyheaders->{'location'};
  my @locextra;
  if ($loc =~ s/\?(.*)$//) {
    push @locextra, split('&', $1);
    s/%([a-fA-F0-9]{2})/chr(hex($1))/ge for @locextra;
  }
  die("no location in upload reply\n") unless $loc;
  $loc = "$registryserver$loc" if $loc =~ /^\//;
  my $authenticator = $registry_authenticator;
  # use the blob authenticator if the upload goes to a different server
  $authenticator = $registry_blob_authenticator if calc_authrealm($loc) ne calc_authrealm("$registryserver/");
  $param = {
    'headers' => [ "Content-Length: $size", "Content-Type: application/octet-stream" ],
    'uri' => $loc,
    'request' => 'PUT',
    'authenticator' => $authenticator,
    'replyheaders' => \$replyheaders,
    'data' => \&send_layer,
    'send_layer_data' => [ $upload_ent, 0 ],
  };
  $replyheaders = undef;
  BSRPC::rpc($param, undef, @locextra, "digest=$blobid");
  my $id = $replyheaders->{'docker-content-digest'};
  die("server did not return a content id\n") unless $id;
  die("server created a different blobid: $blobid $id\n") if $blobid ne $id;
  print "done.\n";
  return $blobid;
}

sub blob_download {
  my ($blobid, $filename) = @_;
  my $stdout_receiver = sub {
    while(1) {
      my $s = BSHTTP::read_data($_[0], 8192);
      return {} if $s eq '';
      print($s) || die("write: $!\n");
    }
  };
  my $param = {
    'uri' => "$registryserver/v2/$repository/blobs/$blobid",
    'authenticator' => $registry_authenticator,
    'timeout' => $registry_timeout,
    'receiver' => $filename eq '-' ? $stdout_receiver : \&BSHTTP::file_receiver,
    'filename' => $filename,
    'maxredirects' => 1,
  };
  BSRPC::rpc($param);
}

sub blob_fetch {
  my ($blobid) = @_;
  my $param = {
    'uri' => "$registryserver/v2/$repository/blobs/$blobid",
    'authenticator' => $registry_authenticator,
    'timeout' => $registry_timeout,
    'maxredirects' => 1,
  };
  return BSRPC::rpc($param);
}

sub manifest_exists {
  my ($manifest, $tag, $content_type) = @_;

  $content_type ||= $BSContar::mt_docker_manifest;
  my $maniid = BSContar::blobid($manifest);
  $tag = $maniid unless defined $tag;
  my $replyheaders;
  my $param = {
    'headers' => [ "Accept: $content_type" ],
    'uri' => "$registryserver/v2/$repository/manifests/$tag",
    'authenticator' => $registry_authenticator,
    'replyheaders' => \$replyheaders,
    'timeout' => $registry_timeout,
    'keepalive' => $keepalive,
  };
  my $maniret;
  eval { $maniret = BSRPC::rpc($param) };
  if ($maniret) {
    my $maniretid = $replyheaders->{'docker-content-digest'};
    $maniretid ||= BSContar::blobid($maniret);
    return 1 if $maniretid eq $maniid;
  }
  return 0;
}

sub manifest_upload {
  my ($manifest, $tag, $content_type, $quiet) = @_;

  my $maniid = BSContar::blobid($manifest);
  return $maniid if manifest_exists($manifest, $tag, $content_type);
  $content_type ||= $BSContar::mt_docker_manifest;
  my $mtype = contenttype2manitype($content_type);
  if (!$quiet) {
    if (defined($tag)) {
      print "uploading $mtype manifest $maniid for tag '$tag'... ";
    } else {
      print "uploading $mtype manifest $maniid... ";
    }
  }
  $tag = $maniid unless defined $tag;
  my $replyheaders;
  my $param = {
    'headers' => [ "Content-Type: $content_type" ],
    'uri' => "$registryserver/v2/$repository/manifests/$tag",
    'request' => 'PUT',
    'authenticator' => $registry_authenticator,
    'replyheaders' => \$replyheaders,
    'data' => $manifest,
  };
  BSRPC::rpc($param, undef);
  my $id = $replyheaders->{'docker-content-digest'};
  die("server did not return a content id\n") unless $id;
  die("server created a different manifest id: $maniid $id\n") if $maniid ne $id;
  print "done.\n" unless $quiet;
  return $maniid;
}

sub manifest_append_digestfile  {
  my ($manifest, $tag, $content_type) = @_;
  my $maniid = BSContar::blobid($manifest);
  my $str = "$maniid ".length($manifest).(defined($tag) ? " $tag" : '')."\n";
  if ($digestfile ne '-') {
    BSUtil::appendstr($digestfile, $str);
  } else {
    print $str;
  }
}

sub manifest_append_listfile {
  my ($manifest, $tag, $content_type, $extra) = @_;
  my $mtype = contenttype2manitype($content_type || $BSContar::mt_docker_manifest);
  my $maniid = BSContar::blobid($manifest);
  $tag = '-' unless defined $tag;
  my $str = sprintf "%-20s %s %s%s\n", $tag, $maniid, $mtype, $extra ne '' ? " $extra" : '';
  if ($listfile ne '-') {
    BSUtil::appendstr($listfile, $str);
  } else {
    print $str;
  }
}

sub manifest_upload_tags {
  my ($manifest, $tags, $content_type) = @_;
  if (!@{$tags || []}) {
    manifest_upload($manifest, undef, $content_type);
    manifest_append_digestfile($manifest, undef, $content_type) if defined $digestfile;
    manifest_append_listfile($manifest, undef, $content_type) if defined $listfile;
    return;
  }
  for my $tag (BSUtil::unify(@$tags)) {
    manifest_upload($manifest, $tag, $content_type);
    manifest_append_digestfile($manifest, $tag, $content_type) if defined $digestfile;
    manifest_append_listfile($manifest, $tag, $content_type) if defined $listfile;
  }
}

sub cosign_upload {
  my ($tag, @layer_ents) = @_;
  my $oci = 1;
  my $config_ent = BSConSign::create_cosign_config_ent(\@layer_ents);
  my $config_data = BSContar::create_config_data($config_ent, $oci);
  blob_upload($config_data->{'digest'}, $config_ent);
  my @layer_data;
  for my $layer_ent (@layer_ents) {
    my $layer_data = BSContar::create_layer_data($layer_ent, $oci);
    push @layer_data, $layer_data;
    blob_upload($layer_data->{'digest'}, $layer_ent);
  }
  my $mani = BSContar::create_dist_manifest_data($config_data, \@layer_data, $oci);
  my $mani_json = BSContar::create_dist_manifest($mani);
  manifest_upload($mani_json, $tag, $mani->{'mediaType'});
  my $extra = '';
  $extra = manifest2extrainfo($mani, $tag) if $tag =~ /^[a-z0-9]+-[a-f0-9]+\.(?:sig|att)$/;
  manifest_append_listfile($mani_json, $tag, $mani->{'mediaType'}, $extra) if defined $listfile;
}

sub get_all_tags {
  my ($missing_ok) = @_;
  my @regtags;
  my $replyheaders;
  my $param = {
    'uri' => "$registryserver/v2/$repository/tags/list",
    'authenticator' => $registry_authenticator,
    'timeout' => $registry_timeout,
    'replyheaders' => \$replyheaders,
    'keepalive' => $keepalive,
    'ignorestatus' => ($missing_ok ? 1 : 0),
  };
  while (1) {
    undef $replyheaders;
    my $r = BSRPC::rpc($param);
    if ($replyheaders->{'status'} !~ /^2\d\d[^\d]/) {
      my $code = registry_errorcode($r);
      return () if !@regtags && $code && $code eq 'NAME_UNKNOWN' && $missing_ok;
      die("remote error: $replyheaders->{'status'} [$code]\n") if $code;
      die("remote error: $replyheaders->{'status'}\n");
    }
    $r = JSON::XS::decode_json($r);
    push @regtags, @{$r->{'tags'} || []};
    last unless $replyheaders->{'link'};
    die unless $replyheaders->{'link'} =~ /^<(\/v2\/.*)>/;
    $param->{'uri'} = "$registryserver$1";
    $param->{'verbatim_uri'} = 1;
  }
  return BSUtil::unify(@regtags);
}

sub get_all_repositories {
  my @repos;
  my $replyheaders;
  my $param = {
    'uri' => "$registryserver/v2/_catalog",
    'authenticator' => $registry_authenticator,
    'timeout' => $registry_timeout,
    'replyheaders' => \$replyheaders,
  };
  while (1) {
    undef $replyheaders;
    my $r = BSRPC::rpc($param, \&JSON::XS::decode_json);
    push @repos, @{$r->{'repositories'}};
    last unless $replyheaders->{'link'};
    die("Bad link: $replyheaders->{'link'}\n") unless $replyheaders->{'link'} =~ /^<(\/v2\/\S+)>/;
    $param->{'uri'} = "$registryserver$1";
    $param->{'verbatim_uri'} = 1;
  }
  return BSUtil::unify(@repos);
}

sub get_manifest_for_tag {
  my ($tag, $ifnonematch) = @_;
  my $replyheaders;
  my $param = {
    'uri' => "$registryserver/v2/$repository/manifests/$tag",
    'headers' => [ "Accept: $BSContar::mt_docker_manifest, $BSContar::mt_docker_manifestlist, $BSContar::mt_oci_manifest, $BSContar::mt_oci_index" ],
    'authenticator' => $registry_authenticator,
    'replyheaders' => \$replyheaders,
    'timeout' => $registry_timeout,
    'keepalive' => $keepalive,
  };
  push @{$param->{'headers'}}, "If-None-Match: $ifnonematch" if $ifnonematch;
  my $mani_json;
  eval { $mani_json = BSRPC::rpc($param); };
  if ($@) {
    return () if $@ =~ /^404/;	# tag does not exist
    die($@);
  }
  my $mani = JSON::XS::decode_json($mani_json);
  my $maniid = $replyheaders->{'docker-content-digest'};
  die("$tag: no docker-content-digest\n") unless $maniid;
  return ($mani, $maniid, $mani_json);
}

sub delete_manifest {
  my ($maniid) = @_;
  die("not a manifest digest: $maniid\n") unless $maniid =~ /^sha256:[0-9a-f]{64}$/;
  my ($mani, $maniid2, $mani_json) = get_manifest_for_tag($maniid);
  return unless $maniid2;
  die("manifest digest mismatch\n") if $maniid2 ne $maniid;
  print "deleting manifest $maniid\n";
  my $param = {
    'uri' => "$registryserver/v2/$repository/manifests/$maniid",
    'request' => 'DELETE',
    'authenticator' => $registry_authenticator,
    'timeout' => $registry_timeout,
  };
  BSRPC::rpc($param, undef);
}

my $cannot_directly_delete_tags;

sub delete_tag {
  my ($tag) = @_;

  my ($mani, $maniid, $mani_json) = get_manifest_for_tag($tag);
  return unless $maniid;
  print "deleting tag $tag [$maniid]\n";

  if (!$cannot_directly_delete_tags) {
    my $param = {
      'uri' => "$registryserver/v2/$repository/manifests/$tag",
      'request' => 'DELETE',
      'authenticator' => $registry_authenticator,
      'timeout' => $registry_timeout,
    };
    eval { BSRPC::rpc($param, undef); };
    return unless $@;
    die($@) unless $@ =~ /^40[05]/;
    $cannot_directly_delete_tags = 1;
  }

  # now mangle and upload so that we get a new unique image id for that tag
  my $mani_json_mangled = "\n\n".JSON::XS->new->utf8->canonical->encode($mani)."\n\n";
  my $newmaniid = manifest_upload($mani_json_mangled, $tag, $mani->{'mediaType'}, 1);

  # then delete the new unique image id, thus deleting the tag as well
  my $param = {
    'uri' => "$registryserver/v2/$repository/manifests/$newmaniid",
    'request' => 'DELETE',
    'authenticator' => $registry_authenticator,
    'timeout' => $registry_timeout,
  };
  eval { BSRPC::rpc($param, undef); };
  my $err = $@;

  if ($err) {
    # delete failed, switch tag back to the old image
    eval { manifest_upload($mani_json, $tag, $mani->{'mediaType'}, 1) if $newmaniid ne $maniid; };
    die($err);
  }
}

sub manifest2extrainfo {
  my ($mani, $tag) = @_;
  my $extra = '';
  if ($tag =~ /^[a-z0-9]+-[a-f0-9]+\.sig$/ && $mani && $mani->{'mediaType'} && $mani->{'mediaType'} eq $BSContar::mt_oci_manifest) {
    if (@{$mani->{'layers'} || []} == 1 && $mani->{'layers'}->[0]->{'mediaType'} eq 'application/vnd.dev.cosign.simplesigning.v1+json') {
      my $annotations = $mani->{'layers'}->[0]->{'annotations'} || {};
      my $cookie = $annotations->{$cosign_cookie_name};
      $extra = "cosigncookie=$cookie" if $cookie;
    }
  }
  if ($tag =~ /^[a-z0-9]+-[a-f0-9]+\.att$/ && $mani && $mani->{'mediaType'} && $mani->{'mediaType'} eq $BSContar::mt_oci_manifest) {
    if (@{$mani->{'layers'} || []} >= 1 && $mani->{'layers'}->[0]->{'mediaType'} eq 'application/vnd.dsse.envelope.v1+json') {
      my $annotations = $mani->{'layers'}->[0]->{'annotations'} || {};
      my $cookie = $annotations->{$cosign_cookie_name};
      $extra = "cosigncookie=$cookie" if $cookie;
    }
  }
  return $extra;
}

sub platform2str {
  my ($platform) = @_;
  return $platform ? BSContar::make_platformstr($platform->{'architecture'}, $platform->{'variant'}, $platform->{'os'}) : 'any';
}

sub manifest2platformsinfo {
  my ($mani, $tag) = @_;
  return '' unless $mani;
  if ($mani->{'mediaType'} eq $BSContar::mt_docker_manifestlist || $mani->{'mediaType'} eq $BSContar::mt_oci_index) {
    my %plats;
    $plats{platform2str($_->{'platform'})} = 1 for @{$mani->{'manifests'} || []};
    return "platforms=".join(',', sort keys %plats) if %plats;
  }
  return '';
}

sub list_manifestinfo {
  my ($maniid, $indent) = @_;
  my $g = $gun ? "$gun/" : '';
  my $param = {
    'uri' => ($manifestinfoserver || $registryserver)."/v2/$g$repository/manifestinfos/$maniid",
    'timeout' => $registry_timeout,
  };
  $param->{'authenticator'} = $registry_authenticator unless $manifestinfoserver;
  my $manifestinfo_json = eval { BSRPC::rpc($param, undef) };
  return unless $manifestinfo_json;
  $indent ||= '';
  my $manifestinfo = JSON::XS::decode_json($manifestinfo_json);
  print "${indent}[$manifestinfo->{'project'}/$manifestinfo->{'repository'}/$manifestinfo->{'arch'}/$manifestinfo->{'package'}]\n";
}

sub list_tag {
  my ($tag, $maniids, $old_datas, $disptag) = @_;

  my $indent = $disptag ? '        ' : '    ';

  if ($no_info || ($no_cosign_info && $tag =~ /^[a-z0-9]+-[a-f0-9]+\.(?:sig|att)$/)) {
    $disptag = $tag unless defined $disptag;
    printf "%-20s -\n", $disptag;
    return;
  }

  # extract information from the old data
  my $old_data = ($old_datas || {})->{$tag};
  undef $old_data if @{$old_data || []} <= 1;	# at least the digest and the type
  my $old_data_mtype = @{$old_data || []} > 1 ? $old_data->[1] : '';
  my $old_data_extra = @{$old_data || []} > 2 ? $old_data->[2] : '';

  # check if we can use the If-None-Match header
  my $ifnonematch;
  $ifnonematch = "\"$old_data->[0]\"" if $old_data;
  $ifnonematch = undef if $maniids;
  $ifnonematch = undef if $list_manifestinfo;
  if ($with_platforms && $tag !~ /^[a-z0-9]+-[a-f0-9]+\.(?:sig|att)$/) {
    $ifnonematch = undef if ($old_data_mtype eq 'list' || $old_data_mtype eq 'ocilist') && $old_data_extra !~ /^platforms=/;
  }
  if ($listidx && ($old_data_mtype eq 'list' || $old_data_mtype eq 'ocilist')) {
    # we will need to list submanifests
    $ifnonematch = undef unless $old_datas->{"$old_data->[0]/"} && $listidx_no_info;
  }

  my ($mani, $maniid) = eval { get_manifest_for_tag($tag, $ifnonematch) };
  if ($@) {
    if ($ifnonematch && $@ =~ /^304/) {
      $tag = '-' if $tag eq $old_data->[0];
      $maniid = $old_data->[0];
      my ($mtype, $extra) = ($old_data_mtype, $old_data_extra);
      if ($tag !~ /^[a-z0-9]+-[a-f0-9]+\.(?:sig|att)$/) {
	$extra = '' unless $with_platforms;
      }
      $disptag = $tag unless defined $disptag;
      printf "%-20s %s %s%s\n", $disptag, $maniid, $mtype, $extra ? " $extra" : '';
      if ($listidx && ($mtype eq 'list' || $mtype eq 'ocilist')) {
	for (@{$old_datas->{"$maniid/"}}) {
	  my $dtag = $indent.$_->[0];
	  if ($listidx_no_info) {
            printf "%-20s %s -\n", $dtag, $_->[1];
	  } else {
	    die;	# not implemented
	  }
	}
      }
      return;
    }
    die($@);
  }

  if (!$mani) {
    $disptag = $tag unless defined $disptag;
    printf "%-20s -\n", $disptag;
    return;
  }

  die unless $mani && $maniid =~ /^[a-z0-9]+:[a-f0-9]+$/;

  # collect digests if requested
  if ($maniids && $tag !~ /^[a-z0-9]+-[a-f0-9]+\.(?:sig|att)$/) {
    $maniids->{$maniid} = 1;
    if ($mani->{'mediaType'} eq $BSContar::mt_docker_manifestlist || $mani->{'mediaType'} eq $BSContar::mt_oci_index) {
      $maniids->{$_->{'digest'}} = 1 for @{$mani->{'manifests'} || []};
    }
  }

  my $mtype = contenttype2manitype($mani->{'mediaType'});
  my $extra = '';
  $extra = manifest2extrainfo($mani, $tag) if $tag =~ /^[a-z0-9]+-[a-f0-9]+\.(?:sig|att)$/;
  $extra = manifest2platformsinfo($mani, $tag) if $with_platforms && $tag !~ /^[a-z0-9]+-[a-f0-9]+\.(?:sig|att)$/;

  $disptag = ($tag eq $maniid ? '-' : $tag) unless defined $disptag;
  printf "%-20s %s %s%s\n", $disptag, $maniid, $mtype, $extra ne '' ? " $extra" : '';
  list_manifestinfo($maniid, $indent) if $list_manifestinfo && $mtype eq 'image' || $mtype eq 'ociimage';

  if ($listidx && ($mtype eq 'list' || $mtype eq 'ocilist')) {
    for (@{$mani->{'manifests'} || []}) {
      my $dtag = $indent.'/'.platform2str($_->{'platform'});
      if ($listidx_no_info) {
	printf "%-20s %s -\n", $dtag, $_->{'digest'};
      } else {
	list_tag($_->{'digest'}, undef, undef, $dtag);
      }
    }
  }
}

sub read_old_listfile {
  local *F;
  open(F, '<', $old_listfile) || die("$old_listfile: $!\n");
  my $old = {} ;
  my $lasttag;
  while (<F>) {
    chomp;
    next if /^#/ || /^\s*$/;
    my @s = split(' ', $_, 4);
    my $tag = shift @s;
    next if $tag && $tag =~ /^\[/;	# ignore manifestinfo
    $lasttag = undef unless $tag && $tag =~ /^\//;
    next if !@s || $tag eq '-';
    if ($tag =~ /^\//) {
      push @{$old->{"$lasttag/"}}, [ $tag, @s] if defined($lasttag);
    } else {
      $old->{$tag} = \@s;
      $lasttag = $s[0];
      delete $old->{"$lasttag/"} if defined($lasttag);
    }
  }
  close F;
  return $old;
}

sub tags_from_digestfile {
  my ($add_cosign_tags) = @_;
  return () unless $digestfile;
  my @ret;
  local *DIG;
  open(DIG, '<', $digestfile) || die("$digestfile: $!\n");
  while (<DIG>) {
    chomp;
    next if /^#/ || /^\s*$/;
    push @ret, "$1-$2.sig", "$1-$2.att" if $add_cosign_tags && /^([a-z0-9]+):([a-f0-9]+) (\d+)/;
    next if /^([a-z0-9]+):([a-f0-9]+) (\d+)\s*$/;	# ignore anonymous images
    die("bad line in digest file\n") unless /^([a-z0-9]+):([a-f0-9]+) (\d+) (.+?)\s*$/;
    push @ret, $4;
  }
  close(DIG);
  return @ret;
}

sub construct_container_tar {
  my ($containerinfo) = @_;

  die("Must specify a blobdir for containerinfos\n") unless $blobdir;
  my $manifest = $containerinfo->{'tar_manifest'};
  my $mtime = $containerinfo->{'tar_mtime'};
  my $blobids = $containerinfo->{'tar_blobids'};
  die("containerinfo is incomplete\n") unless $mtime && $manifest && $blobids;
  my @comp = @{$containerinfo->{'tar_blobcompression'} || []};
  my @tar;
  for my $blobid (@$blobids) {
    my $fd;
    open($fd, '<', "$blobdir/_blob.$blobid") || die("$blobdir/_blob.$blobid: $!\n");
    push @tar, {'name' => $blobid, 'file' => $fd, 'mtime' => $mtime, 'offset' => 0, 'size' => (-s $fd)};
    my $comp = shift @comp;
    $tar[-1]->{'layer_compression'} = $comp if defined $comp;
  }
  push @tar, {'name' => 'manifest.json', 'data' => $manifest, 'mtime' => $mtime, 'size' => length($manifest)};
  BSContar::set_layer_compression(\@tar, $containerinfo->{'layer_compression'}) if !$containerinfo->{'tar_blobcompression'} && $containerinfo->{'layer_compression'};
  return (\@tar, $mtime);
}

sub get_containerinfo {
  my ($tarfile) = @_;
  my $containerinfo;
  if ($tarfile =~ /^artifacthub:(.+)/) {
    $containerinfo = { 'type' => 'artifacthub', 'artifacthubdata' => $1 };
  } elsif ($tarfile =~ /\.helminfo$/) {
    my $chart = $tarfile;
    $chart =~ s/\.helminfo$/.tgz/;
    die("$chart: $!\n") unless -e $chart;
    my $helminfo_json = readstr($tarfile);
    my $helminfo = JSON::XS::decode_json($helminfo_json);
    $containerinfo = { 'type' => 'helm', 'filename' => $chart, 'config_json' => $helminfo->{'config_json'}, 'tags' => $helminfo->{'tags'} };
  } elsif ($tarfile =~ /\.containerinfo$/) {
    my $containerinfo_json = readstr($tarfile);
    $containerinfo = JSON::XS::decode_json($containerinfo_json);
    delete $containerinfo->{'filename'};	# just in case
  } else {
    $containerinfo = { 'filename' => $tarfile };
  }
  return $containerinfo;
}

sub open_container_tar {
  my ($containerinfo, $file) = @_;
  my ($tar, $mtime);
  if (($containerinfo->{'type'} || '') eq 'artifacthub') {
    ($tar, $mtime) = BSContar::container_from_artifacthub($containerinfo->{'artifacthubdata'});
  } elsif (!defined($file)) {
    ($tar, $mtime) = construct_container_tar($containerinfo);
  } elsif (($containerinfo->{'type'} || '') eq 'helm') {
    ($tar, $mtime) = BSContar::container_from_helm($file, $containerinfo->{'config_json'}, $containerinfo->{'tags'});
  } else {
    ($tar, $mtime) = BSContar::open_container_tar($file);
  }
  die("incomplete containerinfo\n") unless $tar;
  return ($tar, $mtime);
}

sub die_with_usage {
  die <<'END';
usage: bs_regpush [options] <registryserver> repository tar [tar...]
       bs_regpush [options] -l <registryserver> [repository [tag]]
       bs_regpush [options] -D <registryserver> repository
       bs_regpush [options] -X <registryserver> repository

MODES:
                - upload mode
  -l            - list mode
  -D            - delete mode
  -X            - delete except mode

OPTIONS:
  --dest-creds  - credentials in form <user>:<password> or "-" to read from STDIN
  -T            - use image tags
  -m            - push multiarch image
  -t            - tag (can be given multiple times)
  -F            - digestfile, output in upload mode, otherwise input

END
}

$| = 1;

while (@ARGV) {
  if ($ARGV[0] eq '--dest-creds') {
    $dest_creds = BSBearer::get_credentials($ARGV[1]);
    splice(@ARGV, 0, 2);
  } elsif ($ARGV[0] eq '-T') {
    $use_image_tags = 1;
    shift @ARGV;
  } elsif ($ARGV[0] eq '-m') {
    $multiarch = 1;
    shift @ARGV;
  } elsif ($ARGV[0] eq '-t') {
    push @tags, $ARGV[1];
    splice(@ARGV, 0, 2);
  } elsif ($ARGV[0] eq '-F' || $ARGV[0] eq '--digestfile') {
    (undef, $digestfile) = splice(@ARGV, 0, 2);
  } elsif ($ARGV[0] eq '--listfile') {
    (undef, $listfile) = splice(@ARGV, 0, 2);
  } elsif ($ARGV[0] eq '--write-info') {
    (undef, $writeinfofile) = splice(@ARGV, 0, 2);
  } elsif ($ARGV[0] eq '-D') {
    $delete_mode = 1;
    shift @ARGV;
  } elsif ($ARGV[0] eq '-l') {
    $list_mode = 1;
    shift @ARGV;
  } elsif ($ARGV[0] eq '--no-info') {
    $no_info = 1;
    shift @ARGV;
  } elsif ($ARGV[0] eq '--no-cosign-info') {
    $no_cosign_info = 1;
    shift @ARGV;
  } elsif ($ARGV[0] eq '--with-platforms') {
    $with_platforms = 1;
    shift @ARGV;
  } elsif ($ARGV[0] eq '--missingok') {
    $missingok = 1;
    shift @ARGV;
  } elsif ($ARGV[0] eq '--old-listfile') {
    (undef, $old_listfile) = splice(@ARGV, 0, 2);
  } elsif ($ARGV[0] eq '-X') {
    $delete_except_mode = 1;
    shift @ARGV;
  } elsif ($ARGV[0] eq '-B') {
    $blobdir = $ARGV[1];
    splice(@ARGV, 0, 2);
  } elsif ($ARGV[0] eq '--oci') {
    $oci = 1;
    shift @ARGV;
  } elsif ($ARGV[0] eq '--cosign') {
    $cosign = 1;
    shift @ARGV;
  } elsif ($ARGV[0] eq '--cosigncookie') {
    (undef, $cosigncookie) = splice(@ARGV, 0, 2);
  } elsif ($ARGV[0] eq '--slsaprovenance') {
    $slsaprovenance = 1;
    shift @ARGV;
  } elsif ($ARGV[0] eq '--sbom') {
    $sbom = 1;
    shift @ARGV;
  } elsif ($ARGV[0] eq '--rekor') {
    (undef, $rekorserver) = splice(@ARGV, 0, 2);
  } elsif ($ARGV[0] eq '-G') {
    (undef, $gun) = splice(@ARGV, 0, 2);
  } elsif ($ARGV[0] eq '-p') {
    (undef, $pubkeyfile) = splice(@ARGV, 0, 2);
  } elsif ($ARGV[0] eq '--dest-creds') {
    $dest_creds = BSBearer::get_credentials($ARGV[1]);
    splice(@ARGV, 0, 2);
  } elsif ($ARGV[0] eq '-P' || $ARGV[0] eq '--project' || $ARGV[0] eq '-u' || $ARGV[0] eq '--signtype' || $ARGV[0] eq '-h') {
    my @signopts = splice(@ARGV, 0, 2);
    push @signcmd, @signopts unless $signopts[0] eq '-h';
  } elsif ($ARGV[0] eq '--manifestinfo') {
    $list_manifestinfo = 1;
    shift @ARGV;
  } elsif ($ARGV[0] eq '--manifestinfoserver') {
    (undef, $manifestinfoserver) = splice(@ARGV, 0, 2);
    $list_manifestinfo = 1;
  } elsif ($ARGV[0] eq '--listidx') {
    $listidx = 1;
    shift @ARGV;
  } elsif ($ARGV[0] eq '--listidx-no-info') {
    $listidx = 1;
    $listidx_no_info = 1;
    shift @ARGV;
  } else {
    last;
  }
}

$registry_authenticator = BSBearer::generate_authenticator($dest_creds, 'verbose' => (-c STDOUT ? 1 : 0));
$registry_blob_authenticator = BSBearer::generate_authenticator($dest_creds, 'verbose' => (-c STDOUT ? 1 : 0));

if ($list_mode) {
  ($registryserver, $repository) = @ARGV;
  if (@ARGV == 1) {
    for my $repo (sort(get_all_repositories())) {
      print "$repo\n";
    }
  } elsif (@ARGV == 2) {
    my $old_datas = {};
    $old_datas = read_old_listfile() if $old_listfile;
    $keepalive = {};
    my %tags = map {$_ => 1} @tags;
    $tags{$_} = 1 for tags_from_digestfile();
    if (%tags && $cosign) {
      my %maniids;
      list_tag($_, \%maniids, $old_datas) for sort keys %tags;
      %tags = ();
      if (%maniids) {
        for (get_all_tags()) {
          $tags{$_} = 1 if /^([a-z0-9]+)-([a-f0-9]+)\.(?:sig|att)$/ && $maniids{"$1:$2"}
        }
      }
      list_tag($_, undef, $old_datas) for sort keys %tags;
    } else {
      %tags = map {$_ => 1} get_all_tags($missingok) unless %tags;
      list_tag($_, undef, $old_datas) for sort keys %tags;
    }
  } elsif (@ARGV == 3) {
    my ($mani, $maniid, $mani_json) = get_manifest_for_tag($ARGV[2]);
    print "$mani_json\n" if $mani_json;
  } elsif (@ARGV == 4) {
    if ($ARGV[3] eq 'config' || $ARGV[3] eq 'config.json') {
      my ($mani) = get_manifest_for_tag($ARGV[2]);
      my $config = blob_fetch($mani->{'config'}->{'digest'});
      $config = JSON::XS->new->utf8->canonical->pretty->encode(JSON::XS::decode_json($config)) if $ARGV[3] eq 'config';
      print $config;
    } elsif ($ARGV[3] =~ /^layer(\d+)$/) {
      my ($mani) = get_manifest_for_tag($ARGV[2]);
      die("no such layer\n") unless $mani->{'layers'}->[$1];
      my $data = blob_fetch($mani->{'layers'}->[$1]->{'digest'});
      print $data;
    } else {
      blob_download($ARGV[2], $ARGV[3]);
    }
  } else {
    die_with_usage();
  }
  exit(0);
}

die_with_usage() unless @ARGV >= 2;
($registryserver, $repository, @tarfiles) = @ARGV;

if ($delete_mode || $delete_except_mode) {
  die("cannot do both delete and delete-except\n") if $delete_mode && $delete_except_mode;
  my %tags;
  $tags{$_} = 1 for @tags;
  $tags{$_} = 1 for tags_from_digestfile($delete_except_mode ? 1 : 0);

  if ($delete_mode) {
    for my $tag (sort keys %tags) {
      if ($tag =~ /^sha256:[0-9a-f]{64}$/) {
        delete_manifest($tag);
      } else {
        delete_tag($tag);
      }
    }
  } elsif ($delete_except_mode) {
    for my $tag (grep {!$tags{$_}} get_all_tags()) {
      delete_tag($tag);
    }
  }
  exit;
}

if ($cosign) {
  require BSConfiguration;
  require BSConSign;
  require BSPGP;
  require BSX509 if $rekorserver;
  require BSRekor if $rekorserver;
  die("need a pubkey for cosign signature creation\n") unless $pubkeyfile;
  die("need a gun for cosign signature creation\n") unless $gun;
  die("sign program is not configured!\n") unless $BSConfig::sign;
  unshift @signcmd, $BSConfig::sign;
}


die("No tar file to upload?\n") if !@tarfiles;
die("more than one tar file specified\n") if @tarfiles > 1 && !$multiarch;

if ($use_image_tags && @tarfiles > 1) {
  # make sure all tar files contain the same tags
  my $imagetags;
  for my $tarfile (@tarfiles) {
    my $containerinfo = get_containerinfo($tarfile);
    my ($tar) = open_container_tar($containerinfo, $containerinfo->{'filename'});
    my %tar = map {$_->{'name'} => $_} @$tar;
    my ($manifest_ent, $manifest) = BSContar::get_manifest(\%tar);
    my @imagetags = @{$manifest->{'RepoTags'} || []};
    s/.*:// for @imagetags;
    my $it = join(', ', sort(BSUtil::unify(@imagetags)));
    die("multiarch images contain different tags: $imagetags -- $it\n") if defined($imagetags) && $imagetags ne $it;
    if (!defined($imagetags)) {
      $imagetags = $it;
      push @tags, @imagetags;
    }
  }
  $use_image_tags = 0;
}

# use oci types if we have a helm chart or artifacthub data
$oci = 1 if grep {/\.helminfo$/ || /^artifacthub:/} @tarfiles;

my %digests_to_sign;
my @multimanifests;
my %multiplatforms;
my @imginfos;
for my $tarfile (@tarfiles) {
  my $containerinfo = get_containerinfo($tarfile);
  my ($tar) = open_container_tar($containerinfo, $containerinfo->{'filename'});
  my %tar = map {$_->{'name'} => $_} @$tar;

  my $provenance;
  my $spdx_json;
  my $cyclonedx_json;
  my @intoto_json;
  if ($slsaprovenance) {
    my $provenancefile = $tarfile;
    if ($provenancefile =~ s/\.[^\.]*$/.slsa_provenance.json/) {
      $provenance = readstr($provenancefile) if -s $provenancefile;
    }
  }
  if ($sbom) {
    my $spdx_file = $tarfile;
    if ($spdx_file =~ s/\.[^\.]*$/.spdx.json/) {
      $spdx_json = readstr($spdx_file) if -s $spdx_file;
    }
    my $cyclonedx_file = $tarfile;
    if ($cyclonedx_file =~ s/\.[^\.]*$/.cdx.json/) {
      $cyclonedx_json = readstr($cyclonedx_file) if -s $cyclonedx_file;
    }
    my $tar_base = $tarfile;
    $tar_base =~ s/.*\///;
    $tar_base =~ s/\.[^\.]*$//;
    my $tar_dir = $tarfile;
    $tar_dir = '' unless $tar_dir =~ s/\/[^\/]*$/\//;
    push @intoto_json, map {readstr("$tar_dir$_")} grep {/^\Q$tar_base\E\..+\.intoto.json$/} sort(ls($tar_dir eq '' ? '.' : $tar_dir));
  }

  my ($manifest_ent, $manifest) = BSContar::get_manifest(\%tar);
  #print Dumper($manifest);

  if ($use_image_tags) {
    my @imagetags = @{$manifest->{'RepoTags'} || []};
    s/.*:// for @imagetags;
    push @tags, @imagetags if $use_image_tags;
  }

  my ($config_ent, $config) = BSContar::get_config(\%tar, $manifest);
  #print Dumper($config);

  my @layers = @{$manifest->{'Layers'} || []};
  die("container has no layers\n") unless @layers;
  my $config_layers;
  if ($config->{'rootfs'} && $config->{'rootfs'}->{'diff_ids'}) {
    $config_layers = $config->{'rootfs'}->{'diff_ids'};
    die("layer number mismatch\n") if @layers != @{$config_layers || []};
  }

  my $goarch = $config->{'architecture'} || 'any';
  my $goos = $config->{'os'} || 'any';
  my $govariant = $config->{'variant'} || $containerinfo->{'govariant'} || undef;
  if ($multiarch) {
    # see if a already have this arch/os combination
    my $platformstr = BSContar::make_platformstr($goarch, $govariant, $goos);
    if ($multiplatforms{$platformstr}) {
      print "ignoring $tarfile, already have $platformstr\n";
      next;
    }
    $multiplatforms{$platformstr} = 1;
  }


  # process config
  my $config_data = BSContar::create_config_data($config_ent, $oci);
  my $config_blobid = $config_data->{'digest'};

  # upload to server
  blob_upload($config_blobid, $config_ent);

  # process layers (compress if necessary)
  my %layer_datas;
  my @layer_data;
  for my $layer_file (@layers) {
    if ($layer_datas{$layer_file}) {
      # already did that file, just reuse old layer data
      push @layer_data, $layer_datas{$layer_file};
      next;
    }
    my $layer_ent = $tar{$layer_file};
    die("File $layer_file not included in tar\n") unless $layer_ent;
    # normalize layer
    $layer_ent = BSContar::normalize_layer($layer_ent, $oci);
    # create layer data
    my $layer_data = BSContar::create_layer_data($layer_ent, $oci);
    push @layer_data, $layer_data;
    $layer_datas{$layer_file} = $layer_data;
    # upload to server
    blob_upload($layer_data->{'digest'}, $layer_ent);
  }

  my $mani = BSContar::create_dist_manifest_data($config_data, \@layer_data, $oci);
  my $mediaType = $mani->{'mediaType'};
  my $mani_json = BSContar::create_dist_manifest($mani);
  my $mani_id = BSContar::blobid($mani_json);
  $digests_to_sign{$mani_id} = [ $provenance, $spdx_json, $cyclonedx_json, \@intoto_json ];

  if ($multiarch) {
    manifest_upload_tags($mani_json, undef, $mediaType);	# upload anonymous image
    my $multimani = {
      'mediaType' => $mediaType,
      'size' => length($mani_json),
      'digest' => $mani_id,
      'platform' => {'architecture' => $goarch, 'os' => $goos},
    };
    $multimani->{'platform'}->{'variant'} = $govariant if $govariant;
    push @multimanifests, $multimani;
  } else {
    manifest_upload_tags($mani_json, \@tags, $mediaType);
  }
  my $imginfo = {
    'file' => $tarfile,
    'imageid' => $config_blobid,
    'goarch' => $goarch,
    'goos' => $goos,
    'distmanifest' => $mani_id,
  };
  $imginfo->{'govariant'} = $govariant if $govariant;
  my @diff_ids = @{$config_layers || []};
  for (@layer_data) {
    my $l = { 'blobid' => $_->{'digest'}, 'blobsize' => $_->{'size'} };
    $l->{'diffid'} = shift @diff_ids if @diff_ids;
    push @{$imginfo->{'layers'}}, $l;
  }
  push @imginfos, $imginfo;
}

my $info = {
  'images' => \@imginfos,
  'tags' => \@tags,
  'distmanifesttype' => 'image',
  'distmanifest' => $imginfos[0]->{'distmanifest'},
};

if ($multiarch) {
  my $mani = BSContar::create_dist_manifest_list_data(\@multimanifests, $oci);
  my $mediaType = $mani->{'mediaType'};
  my $mani_json = BSContar::create_dist_manifest_list($mani);
  my $mani_id = BSContar::blobid($mani_json);
  $digests_to_sign{$mani_id} = [];
  manifest_upload_tags($mani_json, \@tags, $mediaType);
  $info->{'distmanifesttype'} = 'list';
  $info->{'distmanifest'} = $mani_id;
}

if ($writeinfofile) {
  my $info_json = JSON::XS->new->utf8->canonical->encode($info);
  writestr($writeinfofile, undef, $info_json);
}

if ($cosign && %digests_to_sign) {
  my $creator = 'OBS';
  my $gpgpubkey = readstr($pubkeyfile);
  $cosigncookie ||= BSConSign::create_cosign_cookie($gpgpubkey, $gun, $creator);
  # upload signatures
  for my $digest (sort keys %digests_to_sign) {
    my $sig_tag = "$digest.sig";
    $sig_tag =~ s/:/-/;
    my ($sig_mani, $sig_maniid, $sig_mani_json) = get_manifest_for_tag($sig_tag);
    if ($sig_mani && $sig_mani->{'mediaType'} && $sig_mani->{'mediaType'} eq $BSContar::mt_oci_manifest && @{$sig_mani->{'layers'} || []} == 1 && $BSConSign::mt_cosign && $sig_mani->{'layers'}->[0]->{'mediaType'} eq $BSConSign::mt_cosign) {
      my $annotations = $sig_mani->{'layers'}->[0]->{'annotations'} || {};
      next if ($annotations->{$cosign_cookie_name} || '') eq $cosigncookie;
    }
    print "creating cosign signature for $gun $digest\n";
    my $signfunc =  sub { BSUtil::xsystem($_[0], @signcmd, '-O', '-h', 'sha256') };
    my $annotations = { $cosign_cookie_name => $cosigncookie };
    my ($cosign_ent, $sig) = BSConSign::create_cosign_signature_ent($signfunc, $digest, $gun, $creator, undef, $annotations);
    cosign_upload($sig_tag, $cosign_ent);
    if ($rekorserver) {
      print "uploading cosign signature to $rekorserver\n";
      my $sslpubkey = BSX509::keydata2pubkey(BSPGP::pk2keydata(BSPGP::unarmor($gpgpubkey)));
      $sslpubkey = BSASN1::der2pem($sslpubkey, 'PUBLIC KEY');
      my $hash = 'sha256:'.Digest::SHA::sha256_hex($cosign_ent->{'data'});	# must match signfunc
      BSRekor::upload_hashedrekord($rekorserver, $hash, $sslpubkey, $sig);
    }
  }
  # upload attestations
  for my $digest (sort keys %digests_to_sign) {
    my $provenance = $digests_to_sign{$digest}->[0];
    my $spdx_json = $digests_to_sign{$digest}->[1];
    my $cyclonedx_json = $digests_to_sign{$digest}->[2];
    my @intoto_json = @{$digests_to_sign{$digest}->[3] || []};
    next unless $provenance || $spdx_json || $cyclonedx_json || @intoto_json;
    my $att_tag = "$digest.att";
    $att_tag =~ s/:/-/;
    my ($att_mani, $att_maniid, $att_mani_json) = get_manifest_for_tag($att_tag);
    my $numlayers = ($provenance ? 1 : 0) + ($spdx_json ? 1 : 0) + ($cyclonedx_json ? 1 : 0) + scalar(@intoto_json);
    if ($att_mani && $att_mani->{'mediaType'} && $att_mani->{'mediaType'} eq $BSContar::mt_oci_manifest && @{$att_mani->{'layers'} || []} == $numlayers && $BSConSign::mt_dsse) {
      next unless grep {$_->{'mediaType'} ne $BSConSign::mt_dsse || (($_->{'annotations'} || {})->{$cosign_cookie_name} || '') ne $cosigncookie} @{$att_mani->{'layers'}};
    }
    print "creating $numlayers cosign attestations for $gun $digest\n";
    my $signfunc =  sub { BSUtil::xsystem($_[0], @signcmd, '-O', '-h', 'sha256') };
    my $annotations = { $cosign_cookie_name => $cosigncookie };
    my %predicatetypes;
    my @attestations;
    push @attestations, BSConSign::fixup_intoto_attestation($provenance, $signfunc, $digest, $gun, \%predicatetypes) if $provenance;
    push @attestations, BSConSign::fixup_intoto_attestation($spdx_json, $signfunc, $digest, $gun, \%predicatetypes) if $spdx_json;
    push @attestations, BSConSign::fixup_intoto_attestation($cyclonedx_json, $signfunc, $digest, $gun, \%predicatetypes) if $cyclonedx_json;
    push @attestations, BSConSign::fixup_intoto_attestation($_, $signfunc, $digest, $gun, \%predicatetypes) for @intoto_json;
    my @attestation_ents = BSConSign::create_cosign_attestation_ents(\@attestations, $annotations, \%predicatetypes);
    cosign_upload($att_tag, @attestation_ents);
    if ($rekorserver) {
      print "uploading cosign attestations to $rekorserver\n";
      my $sslpubkey = BSX509::keydata2pubkey(BSPGP::pk2keydata(BSPGP::unarmor($gpgpubkey)));
      $sslpubkey = BSASN1::der2pem($sslpubkey, 'PUBLIC KEY');
      for my $attestation (@attestations) {
        BSRekor::upload_intoto($rekorserver, $attestation, $sslpubkey);
      }
    }
  }
}

