Archive-Lha
view release on metacpan or search on metacpan
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)
(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
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/) {
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`;
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
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);
}
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}
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
=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 )