# Copyright © 2023-2024 Guillem Jover <guillem@debian.org>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <https://www.gnu.org/licenses/>.

=encoding utf8

=head1 NAME

Dpkg::Archive::Ar - Unix ar archive support

=head1 DESCRIPTION

This module provides a class to handle Unix ar archives.
It support the common format, with no GNU or BSD extensions.

B<Note>: This is a private module, its API can change at any time.

=cut

package Dpkg::Archive::Ar 0.01;

use v5.36;

use Carp;
use Fcntl qw(:seek);
use IO::File;

use Dpkg::ErrorHandling;
use Dpkg::Gettext;

my $AR_MAGIC = "!<arch>\n";
my $AR_MAGIC_LEN = 8;
my $AR_FMAG = "\`\n";
my $AR_HDR_LEN = 60;

=head1 METHODS

=over 8

=item $ar = Dpkg::Archive::Ar->new(%opts)

Create a new object to handle Unix ar archives.

Options:

=over 8

=item B<filename>

The filename for the archive to open or create.

=item B<create>

A boolean denoting whether the archive should be created,
otherwise if it does not exist the constructor will not open, create or
scan it.

=back

=cut

sub new {
    my ($this, %opts) = @_;
    my $class = ref($this) || $this;
    my $self = {
        filename => undef,
        fh => undef,
        # XXX: If we promote this out from internal use, we should make this
        # default to the archive mtime, or be overridable like in libdpkg,
        # so that it can be initialized from SOURCE_DATE_EPOCH for example.
        time => 0,
        size => 0,
        members => [],
    };
    bless $self, $class;

    if ($opts{filename}) {
        if ($opts{create}) {
            $self->create_archive($opts{filename});
        } elsif (-e $opts{filename}) {
            $self->open_archive($opts{filename});
        }
        if (-e $opts{filename}) {
            $self->scan_archive();
        }
    }

    return $self;
}

sub init_archive {
    my $self = shift;

    $self->{fh}->binmode();
    $self->{fh}->stat
        or syserr(g_('cannot get archive %s size'), $self->{filename});
    $self->{size} = -s _;
    if ($self->{size} % 2) {
        error(g_('archive %s is truncated or corrupt, ' .
                 'it has a non-even byte size'), $self->{filename});
    }

    return;
}

=item $ar->create_archive($filename)

Create the archive.

=cut

sub create_archive {
    my ($self, $filename) = @_;

    if (defined $self->{fh}) {
        croak 'the object has already been initialized with another file';
    }

    $self->{filename} = $filename;
    $self->{fh} = IO::File->new($filename, '+>')
        or syserr(g_('cannot open or create archive %s'), $filename);
    $self->init_archive();
    $self->{fh}->write($AR_MAGIC, $AR_MAGIC_LEN)
        or syserr(g_('cannot write magic into archive %s'), $filename);

    return;
}

=item $ar->open_archive($filename)

Open the archive.

=cut

sub open_archive {
    my ($self, $filename) = @_;

    if (defined $self->{fh}) {
        croak 'the object has already been initialized with another file';
    }

    $self->{filename} = $filename;
    $self->{fh} = IO::File->new($filename, '+<')
        or syserr(g_('cannot open or create archive %s'), $filename);
    $self->init_archive();

    return;
}

sub _read_buf {
    my ($self, $subject, $size) = @_;

    my $buf;
    my $offs = $self->{fh}->tell();
    my $n = $self->{fh}->read($buf, $size);
    if (not defined $n) {
        # TRANSLATORS: The first %s string is either "the archive magic" or
        # "a file header".
        syserr(g_('cannot read %s; archive %s at offset %d'),
               $subject, $offs, $self->{filename});
    } elsif ($n == 0) {
        return;
    } elsif ($n != $size) {
        # TRANSLATORS: The first %s string is either "the archive magic" or
        # "a file header".
        error(g_('cannot read %s; archive %s is truncated at offset %d'),
              $subject, $offs, $self->{filename});
    }

    return $buf;
}

=item $ar->parse_magic()

Reads and parses the archive magic string, and validates it.

=cut

sub parse_magic {
    my $self = shift;

    my $magic = $self->_read_buf(g_('the archive magic'), $AR_MAGIC_LEN)
        or error(g_('archive %s contains no magic'), $self->{filename});

    if ($magic ne $AR_MAGIC) {
        error(g_('archive %s contains bad magic'), $self->{filename});
    }

    return;
}

=item $ar->parse_member()

Reads and parses the archive member and tracks it for later handling.

=cut

sub parse_member {
    my $self = shift;

    my $offs = $self->{fh}->tell();

    my $hdr = $self->_read_buf(g_('a file header'), $AR_HDR_LEN)
        or return;

    my %hdr;
    my $hdr_fmt = 'A16A12A6A6A8A10a2';
    @hdr{qw(name time uid gid mode size fmag)} = unpack $hdr_fmt, $hdr;

    if ($hdr{fmag} ne $AR_FMAG) {
        error(g_('file header at offset %d in archive %s contains bad magic'),
              $offs, $self->{filename});
    }

    # Remove trailing spaces from the member name.
    $hdr{name} =~ s{ *$}{};

    # Remove optional slash terminator (on GNU-style archives).
    $hdr{name} =~ s{/$}{};

    foreach my $f (qw(name time uid gid mode size fmag)) {
        if (not length $hdr{$f}) {
            error(g_('file member %s in archive %s contains empty %s field'),
                  $hdr{name}, $self->{filename}, $f);
        }
    }

    my $member = {
        name => $hdr{name},
        time => int $hdr{time},
        uid => int $hdr{uid},
        gid => int $hdr{gid},
        mode => oct $hdr{mode},
        size => int $hdr{size},
        offs => $offs,
    };
    push @{$self->{members}}, $member;

    return $member;
}

=item $ar->skip_member($member)

Skip this member to the next one.
Get the value of a given substitution.

=cut

sub skip_member {
    my ($self, $member) = @_;

    my $size = $member->{size};
    my $offs = $member->{offs} + $AR_HDR_LEN + $size + ($size & 1);

    $self->{fh}->seek($offs, SEEK_SET)
        or syserr(g_('cannot seek into next file header at offset %d from archive %s'),
                  $offs, $self->{filename});

    return;
}

=item $ar->scan_archive()

Scan the archive for all its member files and metadata.

=cut

sub scan_archive {
    my $self = shift;

    $self->{fh}->seek(0, SEEK_SET)
        or syserr(g_('cannot seek into beginning of archive %s'),
                  $self->{filename});

    $self->parse_magic();

    while (my $member = $self->parse_member()) {
        $self->skip_member($member);
    }

    return;
}

=item $ar->get_members()

Get the list of members in the archive.

=cut

sub get_members {
    my $self = shift;

    return $self->{members};
}

sub _copy_fh_fh {
    my ($self, $if, $of, $size) = @_;

    while ($size > 0) {
        my $buf;
        my $buflen = $size > 4096 ? 4096 : $size;

        my $n = $if->{fh}->read($buf, $buflen)
            or syserr(g_('cannot read file %s'), $if->{name});

        $of->{fh}->write($buf, $n)
            or syserr(g_('cannot write file %s'), $of->{name});

        $size -= $n;
    }

    return;
}

=item $ar->extract_member($member)

Extract the specified member to the current directory.

=cut

sub extract_member {
    my ($self, $member) = @_;

    $self->{fh}->seek($member->{offs} + $AR_HDR_LEN, SEEK_SET);

    my $ofh = IO::File->new($member->{name}, '+>')
        or syserr(g_('cannot create file %s to extract from archive %s'),
                  $member->{name}, $self->{filename});

    $self->_copy_fh_fh({ fh => $self->{fh}, name => $self->{filename} },
                       { fh => $ofh, name => $member->{name} },
                       $member->{size});

    $ofh->close()
        or syserr(g_('cannot write file %s to the filesystem'),
                  $member->{name});

    return;
}

=item $ar->write_member($member)

Write the provided $member into the archive.

=cut

sub write_member {
    my ($self, $member) = @_;

    my $size = $member->{size};
    my $mode = sprintf '%o', $member->{mode};

    my $hdr_fmt = 'A16A12A6A6A8A10A2';
    my $data = pack $hdr_fmt, @{$member}{qw(name time uid gid)}, $mode, $size, $AR_FMAG;

    $self->{fh}->write($data, $AR_HDR_LEN, $member->{offs})
        or syserr(g_('cannot write file header into archive %s'),
                  $self->{filename});

    return;
}

=item $ar->add_file($filename)

Append the specified $filename into the archive.

=cut

sub add_file {
    my ($self, $filename) = @_;

    if (length $filename > 15) {
        error(g_('filename %s is too long'), $filename);
    }

    my $fh = IO::File->new($filename, '<')
        or syserr(g_('cannot open file %s to append to archive %s'),
                  $filename, $self->{filename});
    $fh->stat()
        or syserr(g_('cannot get file %s size'), $filename);
    my $size = -s _;

    my %member = (
        name => $filename,
        size => $size,
        time => $self->{time},
        mode => 0o100644,
        uid => 0,
        gid => 0,
    );

    $self->write_member(\%member);
    $self->_copy_fh_fh({ fh => $fh, name => $filename },
                       { fh => $self->{fh}, name => $self->{filename} },
                       $size);
    if ($size & 1) {
        $self->{fh}->write("\n", 1)
            or syserr(g_('cannot write file %s padding to archive %s'),
                      $filename, $self->{filename});
    }

    return;
}

=item $ar->close_archive()

Close the archive and release any allocated resource.

=cut

sub close_archive {
    my $self = shift;

    $self->{fh}->close() if defined $self->{fh};
    $self->{fh} = undef;
    $self->{size} = 0;
    $self->{members} = [];

    return;
}

sub DESTROY {
    my $self = shift;

    $self->close_archive();

    return;
}

=back

=head1 CHANGES

=head2 Version 0.xx

This is a private module.

=cut

1;
