Archive-Lha

 view release on metacpan or  search on metacpan

Changes  view on Meta::CPAN

      convert symlinks on Windows); detect plhasa mode via $ENV{PLHASA}
    - Fix timestamp display on Windows: avoid strftime %e (unsupported on
      older MSVC CRT); use sprintf "%2d" for space-padded day instead
    - Fix _ls_stamp_header to use _dostime2utime instead of POSIX::mktime
    - Add Windows CI jobs (Strawberry Perl 5.38 and 5.40)
    - Remove Data::Dump noise from t/20_decode.t test names

0.19  2026-05-11 (Nicolas Mendoza)
    - Fix ratio precision in plhasa output: use pack/unpack 'f' to match
      lhasa's C float rounding (e.g. 25340/40000 gives 63.3% not 63.4%)
    - Update MANIFEST: add t/charset.t, tools/plhasa, MANIFEST.SKIP,
      .github/workflows/ci.yml, synthetic test archives; remove bin/act
      and lib/Archive/Lha.c (generated, now git-ignored)

0.18  2026-05-11 (Nicolas Mendoza)
    - Fix DOS timestamp test to not assume positive epoch on 32-bit Perl:
      0xFB9FF926 decodes to year 2105 which overflows 32-bit time_t on
      ivsize=4 builds (e.g. armv6l without -Duse64bitint); only assert
      no crash — proper fix is a Perl built with -Duse64bitint or -Duse64bitall

0.17  2026-05-11 (Nicolas Mendoza)

Changes  view on Meta::CPAN

      (GitHub issue #2)
    - Replace real-world test archives with minimal synthetic fixtures
      (amiga_prefix.lha, amiga_allcaps_preserve.lha)

0.13  2026-05-09 (Nicolas Mendoza)
    - XS implementations of checksum and dostime2utime in
      Archive::Lha::Header::Utils; pure-Perl fallbacks retained
    - Store raw DOS integer in header{timestamp} for Level0/1; new
      timestamp_is_unix flag distinguishes DOS from Unix epoch at display time
    - Preload Level0/1/2 header parsers; dispatch via array instead of eval
    - Fix filename charset for UNIX-tagged archives: use Encode::Guess
      instead of assuming UTF-8 (fixes latin-1 filenames e.g. "Français")
    - plha l: show filename node only (no path), matching Amiga LhA guide
    - plha v/vv: fix timestamps for Level0/1 archives; display CRC uppercase
    - plha/plhasa: English month names by default (LC_TIME=C), matching
      lhasa and LhA for UNIX which both hardcode a static months[] array
    - Add --use-locale flag to opt in to system locale month names
    - Add --from-charset/--to-charset as long forms of -fc/-tc
    - CI: run on all branches, use cpanm --installdeps, cache deps,
      use prove -wlvmb t; hardcode expected plhasa output in tests

0.12  2026-05-08 (Nicolas Mendoza)
    - Install plhasa via EXE_FILES instead of postamble symlink so it
      is correctly installed by cpanm (postamble install targets are
      skipped by cpanm)
    - Add GitHub repository URL to CPAN metadata

0.11  2026-05-07 (Nicolas Mendoza)
    - OS-based filename charset auto-detection in pathname()
      (Amiga->iso-8859-15, MS-DOS/Win->cp1252, Unix->UTF-8, X68K->cp932)
    - plha l command: LhA terse listing format (filename only, + prefix for paths)
    - plha -fc/-tc options: explicit from/to charset for filenames
    - plhasa symlink: activates lhasa-compatible interface (l/v listing formats)
    - Truncation detection in check_magic(): warn if last byte is not 0x00
    - Fix Perl 5.38+ incompatibility in Decode::Base (constant subs closing over
      lexicals): replaced with %_params hash keyed by subclass name
    - Fix Decode::Base accessor redefinition warnings (_accessors_installed guard)
    - Full POD rewrite for Archive::Lha and plha: encoding, charset table,
      limitations, Amiga LhA command docs, Wayback Machine links for dead URLs
    - Makefile.PL: added missing PREREQ_PM deps (Encode, File::Basename,
      File::Path, Getopt::Long, Log::Dispatch, Data::Dump, Time::Moment)
    - Test suite: Amiga and latin-1 test archives, truncation test,
      unknown command error test, charset option tests

0.10  2026-05-06 (Nicolas Mendoza)
    - Handle Level 0 file comments (null-byte separator in pathname)
    - Truncate pathnames and filenames at embedded null bytes
    - Parse Unix permissions (0x50), UID/GID (0x51), group name (0x52)
      from extended headers
    - Handle all LHA methods (lhd, lzs, lz4, lz5, pm0, pm1, pm2)
    - Fix Windows directory path handling (Amiga compatibility)
    - Use POSIX::mktime for DOS timestamps (normalizes out-of-range values)
    - Lower minimum Level 0/1 header size to match lhasa behavior

MANIFEST  view on Meta::CPAN

t/99_podcoverage.t
t/archive/amiga_allcaps_preserve.lha
t/archive/amiga_prefix.lha
t/archive/Amoric_src.lha
t/archive/dir_trailing_slash.lha
t/archive/lh0.lzh
t/archive/lh5.lzh
t/archive/lh5_lvl1.lzh
t/archive/lh5_truncated.lzh
t/archive/lh7.lzh
t/charset.t
t/plha.t
tools/plha
tools/plhasa
META.yml                                 Module YAML meta-data (added by MakeMaker)
META.json                                Module JSON meta-data (added by MakeMaker)

lib/Archive/Lha.pm  view on Meta::CPAN

  use Archive::Lha::Stream::File;
  use Archive::Lha::Decode;

  my $stream = Archive::Lha::Stream::File->new(file => 'archive.lzh');
  while (defined(my $level = $stream->search_header)) {
    my $header = Archive::Lha::Header->new(
      level  => $level,
      stream => $stream,
    );

    # filename decoded from archive charset (auto-detected from OS field)
    print $header->pathname, "\n";

    # or override charset explicitly
    print $header->pathname('cp932', 'utf-8'), "\n";

    $stream->seek($header->data_top);
    my $decoded = '';
    my $decoder = Archive::Lha::Decode->new(
      header => $header,
      read   => sub { $stream->read(@_) },
      write  => sub { $decoded .= join '', @_ },
    );
    my $crc = $decoder->decode;

lib/Archive/Lha.pm  view on Meta::CPAN

=head1 COMMAND LINE TOOLS

This distribution includes two command line tools:

=over 4

=item L<plha>

Lists and extracts archives using Amiga LhA-compatible output formats
(C<l>, C<v>, C<vv>, C<x>, C<t> commands). Supports C<-fc>/C<-tc> for
charset override.

=item L<plhasa>

A symlink to C<plha> that activates a lhasa-compatible interface when
invoked as C<plhasa>.

=back

=head1 ACKNOWLEDGMENTS

lib/Archive/Lha.pm  view on Meta::CPAN

=item L<http://oku.edu.mie-u.ac.jp/~okumura/compression/oldstory.html>
(older history of LHa/LHarc, may be offline)
— archived at L<https://web.archive.org/web/*/http://oku.edu.mie-u.ac.jp/~okumura/compression/oldstory.html>

=back

=head1 AUTHORS

Kenichi Ishigaki E<lt>ishigaki@cpan.orgE<gt> (original author).

Nicolas Mendoza E<lt>mendoza@pvv.ntnu.noE<gt> (Amiga support, charset
handling, CLI tools, bug fixes).

=head1 COPYRIGHT AND LICENSE

Copyright (C) 2007 by Kenichi Ishigaki, unless otherwise noted.

Copyright (C) 2025-2026 by Nicolas Mendoza, for additional modifications.

This program is free software; you can redistribute it and/or modify it
under the same terms as Perl itself.

lib/Archive/Lha/Header/Base.pm  view on Meta::CPAN

  {
    no strict 'refs'; no warnings 'redefine';
    foreach my $name ( @accessors ) {
      *{"$class\::$name"} = sub { shift->{$name} };
    }
    push @{"$caller\::ISA"}, $class;
  }
}

# Map OS identifier to the most likely filename encoding used by that platform.
my %_os_charset = (
  'a' => 'iso-8859-15',  # Amiga
  'M' => 'cp1252',       # MS-DOS / Windows
  'w' => 'cp1252',       # WinNT / Win95
  'U' => 'guess',        # Unix (encoding varies: UTF-8 on modern, latin-1 on older)
  'H' => 'cp932',        # Human68K (Sharp X68000)
  'J' => 'cp932',        # Java VM (often used with Japanese archives)
  'm' => 'UTF-8',        # Macintosh (modern)
);

sub charset_for_os {
  my ($self) = @_;
  my $os_id = $self->{os} ? $self->{os}[0] : undef;
  return $os_id && $os_id ne '?' ? ( $_os_charset{$os_id} // 'guess' ) : 'guess';
}

sub pathname {
  my ($self, $from, $to) = @_;
  my $path;
  if ( $self->{pathname} ) {
    $path = _conv_sep( $self->{pathname} );
  }
  elsif ( $self->{directory} && $self->{filename} ) {
    $path = File::Spec::Unix->catfile(

lib/Archive/Lha/Header/Base.pm  view on Meta::CPAN

    $path = _conv_sep( $self->{directory} . '/' );
  }

  # avoid traversal
  if ( File::Spec::Unix->file_name_is_absolute( $path ) ) {
    my ($vol, $dir, $file) = File::Spec::Unix->splitpath( $path );
    $path = File::Spec::Unix->catfile( '.', $dir, $file );
  }

  # default from-encoding: auto-detect from OS field
  $from //= $self->charset_for_os;
  $to   //= 'UTF-8';

  require Encode;
  if ( lc $from eq 'guess' ) {
    require Encode::Guess;
    my $enc = Encode::Guess::guess_encoding(
      $path => qw( latin1 latin2 cp932 euc-jp )
    );
    Encode::from_to( $path, ref($enc) ? $enc->name : 'latin1', $to );
  }

lib/Archive/Lha/Header/Base.pm  view on Meta::CPAN

You also can pass encoding options:

  # the pathname should have been encoded as 'euc-jp'
  $header->pathname('euc-jp' => 'shiftjis');

If you are not sure, you can let it guess:

  # original encoding of the path would be guessed
  $header->pathname('guess' => 'shiftjis');

=head2 charset_for_os

Returns the most likely filename encoding for this archive entry based on
the OS identifier byte in the header: C<iso-8859-15> for Amiga (C<a>),
C<cp1252> for MS-DOS/Windows (C<M>/C<w>), C<UTF-8> for Unix (C<U>) and
modern Mac (C<m>), C<cp932> for Human68K/Java (C<H>/C<J>). Returns
C<'guess'> when the OS field is absent or unrecognised, which causes
C<pathname()> to invoke L<Encode::Guess>.

=head2 dirname

t/charset.t  view on Meta::CPAN

#!/usr/bin/env perl
use strict;
use warnings;
use Test::More;
use FindBin qw/$Bin/;

use Archive::Lha::Header;
use Archive::Lha::Header::Base;
use Archive::Lha::Stream::File;

# charset_for_os maps OS byte to encoding name
my %os_map = (
    'a' => 'iso-8859-15',   # Amiga
    'M' => 'cp1252',        # MS-DOS / Windows
    'w' => 'cp1252',        # WinNT / Win95
    'U' => 'guess',         # Unix (encoding varies)
    'H' => 'cp932',         # Human68K
    'J' => 'cp932',         # Java VM
    'm' => 'UTF-8',         # Macintosh
    '?' => 'guess',         # unknown -> Encode::Guess
);

subtest 'charset_for_os OS mapping' => sub {
    for my $os (sort keys %os_map) {
        my $obj = bless { os => [$os] }, 'Archive::Lha::Header::Level2';
        is Archive::Lha::Header::Base::charset_for_os($obj),
            $os_map{$os},
            "OS '$os' -> $os_map{$os}";
    }
};

subtest 'charset_for_os with no OS field' => sub {
    my $obj = bless {}, 'Archive::Lha::Header::Level0';
    is Archive::Lha::Header::Base::charset_for_os($obj), 'guess',
        'missing OS field falls back to guess';
};

subtest 'Amiga archive: latin-1 filename decoded to UTF-8' => sub {
    my $archive = "$Bin/archive/Amoric_src.lha";
    plan skip_all => "Amoric_src.lha not found" unless -f $archive;

    my $stream = Archive::Lha::Stream::File->new(file => $archive);
    my $found;
    while (defined(my $level = $stream->search_header)) {

t/charset.t  view on Meta::CPAN

    }
    ok $found, 'Found entry with non-ASCII byte (0xe7 = ç in iso-8859-15)';
    my $name = $found->pathname;
    like $name, qr/fran/, 'Decoded name starts with "fran"';
    like $name, qr/ais/, 'Decoded name ends with "ais"';
    # \xc3\xa7 is UTF-8 for ç
    ok utf8::is_utf8($name) || $name =~ /\xc3\xa7/, 'Name contains UTF-8 ç';
    like $name, qr/fran.*ais/, 'Full name matches français pattern';
};

subtest 'Amiga archive: charset auto-detected as iso-8859-15' => sub {
    my $archive = "$Bin/archive/Amoric_src.lha";
    plan skip_all => "Amoric_src.lha not found" unless -f $archive;

    my $stream = Archive::Lha::Stream::File->new(file => $archive);
    my $header;
    while (defined(my $level = $stream->search_header)) {
        $header = Archive::Lha::Header->new(level => $level, stream => $stream);
        $stream->seek($header->{next_header});
        last if ($header->{os}[0] // '') eq 'a';
    }
    ok $header, 'Found Amiga entry';
    is $header->charset_for_os, 'iso-8859-15', 'Amiga OS auto-detects iso-8859-15';
};

subtest 'pathname explicit charset override' => sub {
    my $archive = "$Bin/archive/Amoric_src.lha";
    plan skip_all => "Amoric_src.lha not found" unless -f $archive;

    my $stream = Archive::Lha::Stream::File->new(file => $archive);
    my $found;
    while (defined(my $level = $stream->search_header)) {
        my $header = Archive::Lha::Header->new(level => $level, stream => $stream);
        $stream->seek($header->{next_header});
        my $raw = $header->{filename} // $header->{pathname} // '';
        if ($raw =~ /\xe7/) {

t/plha.t  view on Meta::CPAN

    unlike $output, qr/Can't load|Can't locate/, 'No module loading errors';
    # Amoric_src.lha is an Amiga archive with latin-1 filenames
    # Auto-detection should pick up iso-8859-15 for Amiga archives
    like $output, qr/\w/, 'Produces output without error';
};

subtest 'latin-1 filename with explicit -fc' => sub {
    plan skip_all => "Amoric_src.lha not found" unless -f $latin1;
    my $output = `$^X $blib $plha -fc iso-8859-15 -tc UTF-8 v $latin1 2>&1`;
    unlike $output, qr/Can't load|Can't locate/, 'No module loading errors';
    like $output, qr/\w/, 'Produces output with explicit charset options';
};

subtest 'truncated archive warning' => sub {
    plan skip_all => "lh5_truncated.lzh not found" unless -f $trunc;
    my $output = `$^X $blib $plha v $trunc 2>&1`;
    like $output, qr/WARNING.*truncated|truncated.*WARNING/i, 'Warns about truncated archive';
};

subtest 'unknown command error' => sub {
    my $output = `$^X $blib $plha zzz $lha 2>&1`;

tools/plha  view on Meta::CPAN

use lib File::Spec->catfile($FindBin::Bin, '..', 'lib');

use Archive::Lha::Decode;
use Archive::Lha::Header;
use Archive::Lha::Header::Utils ();
use Archive::Lha::Stream::File;
use Carp;
use POSIX qw( strftime setlocale LC_TIME );
use Time::Moment;

# Charset options: -fc (from charset) and -tc (to charset)
my $opt_from_charset;
my $opt_to_charset;
my $opt_use_locale;

# Parse --use-locale before anything else so setlocale runs at startup
Getopt::Long::GetOptionsFromArray(\@ARGV,
    'use-locale'              => \$opt_use_locale,
    'from-charset|fc=s'      => \$opt_from_charset,
    'to-charset|tc=s'        => \$opt_to_charset,
);
setlocale(LC_TIME, 'C') unless $opt_use_locale;

# Return display name for a header, respecting -fc/-tc options.
# Without options, pathname() auto-detects from the OS field.
sub _display_name {
  my ($header) = @_;
  return $header->pathname( $opt_from_charset, $opt_to_charset // 'UTF-8' );
}

my $controller = +{
    d => sub {
        my $fname = shift or usage();
        my $stream = open_archive($fname);
        while ( defined( my $level = $stream->search_header ) ) {
            my $header = Archive::Lha::Header->new(
                level  => $level,
                stream => $stream

tools/plha  view on Meta::CPAN

sub main {
    if ($PROGNAME eq 'plhasa') {
        _main_lhasa();
    } else {
        _main_plha();
    }
}

sub _main_plha {
    GetOptionsFromArray(\@ARGV,
        'from-charset|fc=s' => \$opt_from_charset,
        'to-charset|tc=s'   => \$opt_to_charset,
        'use-locale'        => \$opt_use_locale,
    );
    my $cmd = shift @ARGV or usage();
    my $file = shift @ARGV or usage();
    check_magic($file);
    if ( !exists $controller->{$cmd} ) {
        usage("Unknown command: $cmd");
    }
    $controller->{$cmd}->($file, @ARGV);
}

tools/plha  view on Meta::CPAN


sub usage {
    my ($msg) = @_;
    my $text = "Usage: $0 [options] (l|v|vv|x|t|d) archive (files)\n" .
        " l  - list contents (LhA terse format, filename only)\n" .
        " v  - list archive verbose (LhA v format)\n" .
        " vv - list archive full (LhA vv format)\n" .
        " x  - extract archive\n" .
        " t  - test file\n" .
        " d  - dump each header\n" .
        " -fc, --from-charset <charset>  source encoding for filenames (default: auto-detect)\n" .
        " -tc, --to-charset <charset>    output encoding for filenames (default: UTF-8)\n" .
        " --use-locale                   use system locale for month names (default: English)\n";
    if ($msg) {
        die "$msg\n$text";
    }
    die $text;
}

sub _header_date {
    my ($h) = @_;
    return $h->{timestamp_is_unix}

tools/plha  view on Meta::CPAN

  plha x archive.lzh          # extract all files
  plha x archive.lzh foo.txt  # extract a specific file
  plha t archive.lzh          # test archive integrity
  plha d archive.lzh          # dump raw header data

  # lhasa-compatible interface (via plhasa wrapper)
  plhasa l archive.lzh        # terse list (lhasa l format, with PERMSSN column)
  plhasa v archive.lzh        # verbose list (lhasa v format, with METHOD and CRC)
  plhasa x archive.lzh        # extract

  # Override filename charset
  plha --from-charset cp932 --to-charset UTF-8 v archive.lzh
  plha -fc iso-8859-15 v archive.lzh

=head1 DESCRIPTION

B<plha> reads and extracts LZH/LHA archives using Amiga LhA-compatible output
formats. It supports header levels 0, 1 and 2, and decompression methods
lh0 (stored), lh5, lh6 and lh7.

B<plhasa> is a wrapper script that invokes B<plha> with C<$ENV{PLHASA}> set.
When the script detects it is invoked as C<plhasa>, it switches to a

tools/plha  view on Meta::CPAN

=item d

Dump the raw parsed header data for each entry (for debugging).

=back

=head1 OPTIONS

=over 4

=item -fc I<charset>, --from-charset I<charset>

Specify the character encoding of filenames stored in the archive.
Defaults to auto-detection based on the OS field in the archive header:

  Amiga (a)        -> iso-8859-15
  MS-DOS/Win (M/w) -> cp1252
  Unix (U)         -> guess (Encode::Guess)
  Human68K (H/J)   -> cp932

If the OS field is absent or unrecognised, L<Encode::Guess> is used to
probe for latin1, latin2, cp932 and euc-jp.

Supported charset names are those accepted by L<Encode>. Run
C<perl -MEncode -e 'print join "\n", Encode->encodings(":all")'>
for a full list.

=item -tc I<charset>, --to-charset I<charset>

Specify the output character encoding for displayed filenames.
Defaults to UTF-8.

=item --use-locale

Use the system locale for month name abbreviations in date output.
By default, month names are always displayed in English (equivalent to
LC_TIME=C), matching the behaviour of lhasa and LhA for UNIX, which both
hardcode English month abbreviations.



( run in 1.230 second using v1.01-cache-2.11-cpan-6aa56a78535 )