| File: | blib/lib/App/Access2CSV/Exporter.pm |
| Coverage: | 98.5% |
| line | stmt | bran | cond | sub | time | code |
|---|---|---|---|---|---|---|
| 1 | package App::Access2CSV::Exporter; | |||||
| 2 | ||||||
| 3 | 36 36 36 | 145521 27 481 | use strict; | |||
| 4 | 36 36 36 | 61 22 697 | use warnings; | |||
| 5 | 36 36 36 | 443 12095 89 | use autodie qw(:all); | |||
| 6 | ||||||
| 7 | 36 36 36 | 87169 30 949 | use Config; | |||
| 8 | ||||||
| 9 | # Sub::Private must be in enforce mode before it is loaded, so that | |||||
| 10 | # private methods still work through $self->method dispatch | |||||
| 11 | 36 | 359 | BEGIN { $Sub::Private::config{mode} = 'enforce' } | |||
| 12 | ||||||
| 13 | # Inherit i18n() and the protected _croak_i18n/_carp_i18n helpers | |||||
| 14 | 36 36 36 | 66 30 93 | use parent 'App::Access2CSV::I18N'; | |||
| 15 | ||||||
| 16 | 36 36 36 | 1104 22 805 | use Encode qw(FB_CROAK find_encoding); | |||
| 17 | 36 36 36 | 61 26 812 | use File::Path qw(make_path); | |||
| 18 | 36 36 36 | 64 21 367 | use File::Spec; | |||
| 19 | 36 36 36 | 7462 137455 1166 | use File::Temp; | |||
| 20 | 36 35 35 | 6810 17685 963 | use File::Which qw(which); | |||
| 21 | 35 34 34 | 6722 46857 1001 | use IPC::Run3 qw(run3); | |||
| 22 | 34 34 34 | 83 24 622 | use Params::Get qw(get_params); | |||
| 23 | 34 34 34 | 58 24 439 | use Params::Validate::Strict qw(validate_strict); | |||
| 24 | 34 34 34 | 49 24 459 | use Readonly; | |||
| 25 | 34 34 34 | 53 30 432 | use Return::Set qw(set_return); | |||
| 26 | 34 34 34 | 51 26 437 | use Scalar::Util qw(blessed); | |||
| 27 | 34 34 34 | 44 24 78 | use Sub::Private; | |||
| 28 | 34 34 34 | 2650 23 73 | use Sub::Protected; | |||
| 29 | ||||||
| 30 | our $VERSION = '0.001.0'; | |||||
| 31 | ||||||
| 32 | # Stop Carp from reporting errors against the access-control wrappers | |||||
| 33 | our @CARP_NOT = qw(Sub::Private Sub::Protected App::Access2CSV::I18N); | |||||
| 34 | ||||||
| 35 | # Exit statuses returned by run() | |||||
| 36 | Readonly::Scalar my $EXIT_OK => 0; | |||||
| 37 | Readonly::Scalar my $EXIT_FAILURE => 1; | |||||
| 38 | ||||||
| 39 | # The mdbtools programs; mdb-count is only needed for --show-counts | |||||
| 40 | Readonly::Scalar my $MDB_TABLES => 'mdb-tables'; | |||||
| 41 | Readonly::Scalar my $MDB_EXPORT => 'mdb-export'; | |||||
| 42 | Readonly::Scalar my $MDB_COUNT => 'mdb-count'; | |||||
| 43 | Readonly::Array my @REQUIRED_PROGRAMS => ($MDB_TABLES, $MDB_EXPORT); | |||||
| 44 | ||||||
| 45 | # Output encodings accepted by --encoding | |||||
| 46 | Readonly::Scalar my $ENC_UTF8 => 'utf8'; | |||||
| 47 | Readonly::Scalar my $ENC_UTF8_BOM => 'utf8-bom'; | |||||
| 48 | Readonly::Scalar my $ENC_CP1252 => 'cp1252'; | |||||
| 49 | Readonly::Array my @ENCODINGS => ($ENC_UTF8, $ENC_UTF8_BOM, $ENC_CP1252); | |||||
| 50 | ||||||
| 51 | # Taint mode: a value is untainted only after it has been validated, by | |||||
| 52 | # capturing it with this pattern (anything non-empty without a NUL byte: | |||||
| 53 | # a NUL cannot be passed to the operating system at all) | |||||
| 54 | Readonly::Scalar my $UNTAINT_RE => qr/\A([^\x00]+)\z/s; | |||||
| 55 | ||||||
| 56 | # Environment variables that can change how a program is started (see | |||||
| 57 | # perlsec); removed for the mdbtools processes | |||||
| 58 | Readonly::Array my @UNSAFE_ENV => qw(IFS CDPATH ENV BASH_ENV); | |||||
| 59 | ||||||
| 60 | # Encoding objects, looked up once: calling Encode::decode/encode by name | |||||
| 61 | # repeats the lookup for every line, which made conversion about 4 times | |||||
| 62 | # slower on large tables. (Plain lexicals, not Readonly: Readonly's deep | |||||
| 63 | # copy could interfere with the objects' internals.) | |||||
| 64 | my $UTF8_CODEC = find_encoding('UTF-8'); | |||||
| 65 | my $CP1252_CODEC = find_encoding('cp1252'); | |||||
| 66 | ||||||
| 67 | # Byte order mark written at the start of utf8-bom files (for Excel) | |||||
| 68 | Readonly::Scalar my $UTF8_BOM => "\xEF\xBB\xBF"; | |||||
| 69 | ||||||
| 70 | # Tables Access creates for itself: MSys*, USys* and ~temporary objects | |||||
| 71 | Readonly::Scalar my $SYSTEM_TABLE_RE => qr/\A(?:MSys|USys|~)/i; | |||||
| 72 | ||||||
| 73 | # Characters that are illegal in a file name on at least one common OS | |||||
| 74 | Readonly::Scalar my $UNSAFE_CHARS_RE => qr/[<>:"\/\\|?*\x00-\x1F\x7F]/; | |||||
| 75 | ||||||
| 76 | # Invisible text-direction controls (LRM, RLM, LRE, RLE, PDF, LRO, RLO, | |||||
| 77 | # LRI, RLI, FSI, PDI). They can make a file name display as something | |||||
| 78 | # else ("report<RLO>vsc.exe.csv"), so they are unsafe like other control | |||||
| 79 | # characters. Matched both as Perl characters and as UTF-8 bytes, since | |||||
| 80 | # table names from mdbtools arrive as bytes. | |||||
| 81 | Readonly::Scalar my $BIDI_CONTROLS_RE => qr/ | |||||
| 82 | [\x{200E}\x{200F}\x{202A}-\x{202E}\x{2066}-\x{2069}] # as characters | |||||
| 83 | | \xE2 \x80 [\x8E\x8F\xAA-\xAE] # as UTF-8: marks, embeddings, overrides | |||||
| 84 | | \xE2 \x81 [\xA6-\xA9] # as UTF-8: isolates | |||||
| 85 | /x; | |||||
| 86 | ||||||
| 87 | # Device names Windows reserves whatever the extension (CON.csv is illegal) | |||||
| 88 | Readonly::Scalar my $RESERVED_NAME_RE => qr/\A(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])\z/i; | |||||
| 89 | ||||||
| 90 | # Name used when sanitising leaves nothing, and the extension we write | |||||
| 91 | Readonly::Scalar my $UNNAMED => 'unnamed'; | |||||
| 92 | Readonly::Scalar my $CSV_SUFFIX => '.csv'; | |||||
| 93 | ||||||
| 94 | # Ends option parsing in mdbtools, so a table or database name that | |||||
| 95 | # starts with "-" is never taken for an option (glib parses options | |||||
| 96 | # anywhere on the command line, not only before the file name) | |||||
| 97 | Readonly::Scalar my $END_OF_OPTIONS => '--'; | |||||
| 98 | ||||||
| 99 | # Temporary files are hidden and live next to the target for atomic rename | |||||
| 100 | Readonly::Scalar my $TEMP_TEMPLATE => '.access2csv-XXXXXX'; | |||||
| 101 | ||||||
| 102 | # How much of an unreadable mdb-count answer to quote in the warning | |||||
| 103 | Readonly::Scalar my $COUNT_SHOWN => 40; | |||||
| 104 | ||||||
| 105 | # Shown in the dry run's ROWS column when a table could not be counted | |||||
| 106 | Readonly::Scalar my $UNKNOWN_COUNT => '?'; | |||||
| 107 | ||||||
| 108 | # Dry-run table layout | |||||
| 109 | Readonly::Scalar my $TABLE_COLUMN_WIDTH => 40; | |||||
| 110 | Readonly::Scalar my $ROWS_COLUMN_WIDTH => 10; | |||||
| 111 | Readonly::Scalar my $RULE_WIDTH => 70; | |||||
| 112 | ||||||
| 113 | # Return schema of run(), shared by its two exits | |||||
| 114 | Readonly::Hash my %RUN_STATUS_SCHEMA => (type => 'integer', min => $EXIT_OK, max => $EXIT_FAILURE); | |||||
| 115 | ||||||
| 116 | # Mode bits for new files before the umask is applied | |||||
| 117 | Readonly::Scalar my $FILE_MODE => oct('666'); | |||||
| 118 | ||||||
| 119 | # Default settings; the flat scalar layout is compatible with Object::Configure | |||||
| 120 | Readonly::Hash my %DEFAULTS => ( | |||||
| 121 | output_dir => File::Spec->curdir(), | |||||
| 122 | overwrite => 0, | |||||
| 123 | verbose => 0, | |||||
| 124 | dry_run => 0, | |||||
| 125 | show_counts => 0, | |||||
| 126 | progress => 1, | |||||
| 127 | encoding => $ENC_UTF8, | |||||
| 128 | ); | |||||
| 129 | ||||||
| 130 | # Constructor argument schema, shared by new() and the POD | |||||
| 131 | Readonly::Hash my %NEW_SCHEMA => ( | |||||
| 132 | output_dir => { type => 'string', min => 1, optional => 1 }, | |||||
| 133 | tables => { type => 'arrayref', element_type => 'string', optional => 1 }, | |||||
| 134 | overwrite => { type => 'boolean', optional => 1 }, | |||||
| 135 | verbose => { type => 'boolean', optional => 1 }, | |||||
| 136 | dry_run => { type => 'boolean', optional => 1 }, | |||||
| 137 | show_counts => { type => 'boolean', optional => 1 }, | |||||
| 138 | progress => { type => 'boolean', optional => 1 }, | |||||
| 139 | encoding => { type => 'string', memberof => [@ENCODINGS], optional => 1 }, | |||||
| 140 | logger => { type => 'object', can => ['debug', 'info', 'warn'], optional => 1 }, | |||||
| 141 | language => { type => 'string', optional => 1 }, | |||||
| 142 | ); | |||||
| 143 | ||||||
| 144 | =encoding utf8 | |||||
| 145 | ||||||
| 146 - 432 | =head1 NAME
App::Access2CSV::Exporter - Export the tables of a Microsoft Access database to CSV files
=head1 VERSION
Version 0.001.0
=head1 SYNOPSIS
use App::Access2CSV::Exporter;
# 1. The simplest case: every table, into the current folder
my $exporter = App::Access2CSV::Exporter->new();
my $status = $exporter->run('shop.accdb'); # 0 = all OK, 1 = some failed
# 2. Some tables, into a folder, for Excel, replacing old files
my $exporter = App::Access2CSV::Exporter->new(
output_dir => 'exports',
tables => ['Customers', 'Orders'],
encoding => 'utf8-bom',
overwrite => 1,
);
$exporter->run('shop.accdb');
# 3. Only look: print the table list and row counts, write nothing
App::Access2CSV::Exporter->new(dry_run => 1, show_counts => 1)->run('shop.accdb');
# 4. Inside a larger program: no progress lines, a log, and full
# error handling
use Log::Abstraction;
my $exporter = App::Access2CSV::Exporter->new({
output_dir => '/srv/exports',
progress => 0,
logger => Log::Abstraction->new(logger => '/var/log/export.log'),
});
my $status = eval { $exporter->run('/data/shop.accdb') };
if(!defined $status) {
die "Nothing was exported: $@"; # for example, the file is missing
} elsif($status == 1) {
warn "Some tables were not exported; see the log\n";
}
=head1 DESCRIPTION
This module does the real work of the C<access2csv> program. It writes
one CSV file for each table of a Microsoft Access database.
It runs three programs from the B<mdbtools> package: C<mdb-tables> (to
list the tables), C<mdb-export> (to get each table as CSV) and, only when
row counts are wanted, C<mdb-count>. They must be in your C<PATH>.
Access's own internal tables (names starting with C<MSys>, C<USys> or
C<~>) are skipped.
Each file is first written to a hidden temporary file in the output
folder, and renamed to its real name only when it is complete. So a
failed export never leaves a half-written CSV file, and an old file is
only replaced by a complete new one. New files get the usual
permissions (0666 minus your umask).
An exporter can be used for more than one C<run>. Each C<run> starts
again with the same file names, so running twice gives the same files.
The mdbtools programs are looked up only in absolute C<PATH> folders, so
a program planted in the current folder is never run, and they are
started with a cleaned environment (see L<App::Access2CSV/SECURITY>).
The module works under taint mode (C<perl -T>). Table names are
printed and logged with control characters escaped, so a hostile name
cannot send escape sequences to your terminal.
Table names and the database path are handed to mdbtools as separate
arguments, never through a shell, and after a C<--> marker. So names
containing shell characters (C<; | E<gt> $( )>), spaces or newlines, or
starting with C<->, are always treated as names, never as commands or
options. A table name can never place a file outside the output
folder: C</> and C<\> are replaced, and names cannot start with a dot.
An existing entry at the target name - including a symbolic link, even a
broken one - counts as "already exists". With C<overwrite>, the link
itself is replaced; the file it pointed to is never written.
The rules for file names are described in
L<App::Access2CSV/How the CSV files are named>.
=head1 ENCODING
=over 4
=item * B<CSV data.> mdbtools gives UTF-8. With C<encoding> set to
C<utf8> or C<utf8-bom> the bytes are copied exactly, so every character,
including emoji and non-Latin scripts, is kept. C<utf8-bom> also writes
the three-byte UTF-8 "byte order mark" first, which Microsoft Excel
needs. With C<cp1252>, each line is converted to Windows-1252; if a line
has a character that Windows-1252 does not have (for example Greek,
Chinese or an emoji), that table fails and nothing is written for it.
=item * B<Database path and output_dir.> These are passed to the operating
system unchanged. Give them as byte strings (the form you get from
C<@ARGV> or C<readdir>). Non-ASCII names work on systems whose file names
are UTF-8, such as Linux and macOS.
=item * B<Table names> (in C<tables>). They are compared with the names
that C<mdb-tables> prints, which are UTF-8 bytes. So give UTF-8 byte
strings, not decoded Perl character strings. If you have a decoded
string, use C<Encode::encode('UTF-8', $name)> first. The CSV file name is
made from the same bytes, so non-ASCII names and emoji are kept.
=item * B<Messages.> All messages are plain ASCII English.
=back
=head1 COMMON PITFALLS
=over 4
=item * B<undef means "use the default", not "false".> In C<new>,
C<< overwrite => undef >> is the same as not giving C<overwrite> at all.
To switch something off, give C<0>.
=item * B<An empty table list exports nothing.> C<< tables => undef >>
(or no C<tables>) means "all tables". C<< tables => [] >> means "no
tables": nothing is exported, and C<run> returns 0.
=item * B<Table names are case-sensitive.> C<'orders'> does not match the
table C<Orders>. Names that do not match any table give a warning.
=item * B<A failing logger does not stop the export.> If the logger dies
(for example, its disk is full), C<run> warns once with "Cannot write to
the log", stops logging for this exporter, and carries on exporting.
=item * B<run can croak.> C<run> returns 1 when some tables fail, but it
croaks (throws an exception) when nothing can be exported at all: the
database is missing or unreadable, mdbtools is not installed, or the
output folder cannot be created. Wrap C<run> in C<eval> if your program
must keep going.
=item * B<run may change the show_counts setting.> If C<show_counts> is
on but C<mdb-count> cannot be found, C<run> warns and switches
C<show_counts> off for this exporter.
=item * B<Settings are copied, not shared.> C<new> makes its own copy of
the C<tables> list; changing your array later has no effect. Settings are
not merged in depth: a new C<tables> list replaces the default completely.
=item * B<Warnings go through carp.> Failed tables and unknown table names
are reported with C<carp>, so they appear on standard error (or in your
C<$SIG{__WARN__}> handler) even when a logger is given.
=item * B<Load the module with use, not require.> Protection of the
private methods is set up at compile time. After a run-time
C<require> Perl prints "Too late to run CHECK block" and the protection is
missing.
=back
=head1 METHODS
=head2 new
=head3 Purpose
Make a new exporter with your settings. Nothing is checked on disk yet.
=head3 Arguments
Named arguments, either as a list or as one hash reference. All of them
are optional. An argument whose value is C<undef> is ignored, so its
default is used.
=over 4
=item C<output_dir> - the folder for the CSV files. Default: the current folder.
=item C<tables> - an array reference of table names to export. Default:
all tables. An empty array means no tables.
=item C<overwrite> - true to replace CSV files that already exist. Default: false.
=item C<verbose> - true to log extra detail. Default: false.
=item C<dry_run> - true to only print what would be written. Default: false.
=item C<show_counts> - true to report row counts (needs C<mdb-count>). Default: false.
=item C<progress> - true to print C<[n/total] table> lines to standard
error. Default: true.
=item C<encoding> - C<utf8>, C<utf8-bom> or C<cp1252>. Default: C<utf8>.
=item C<logger> - an object with C<debug>, C<info> and C<warn> methods,
such as a L<Log::Abstraction> object. Default: no logging.
=item C<language> - a language code such as C<en> for messages. Default:
taken from the locale (see L<App::Access2CSV::I18N>).
=back
=head3 Returns
A new C<App::Access2CSV::Exporter> object.
=head3 Side Effects
None. Your C<$@>, C<$!> and C<$_> are left as they were.
=head3 Usage
my $exporter = App::Access2CSV::Exporter->new({ dry_run => 1 });
=head3 EXAMPLE
# Export two tables as Windows-1252, replacing older files, with a log
my $exporter = App::Access2CSV::Exporter->new(
output_dir => 'out',
tables => ['Customers', 'Orders'],
encoding => 'cp1252',
overwrite => 1,
logger => Log::Abstraction->new(logger => 'export.log'),
);
=head3 API SPECIFICATION
=head4 Input
{
output_dir => { type => 'string', min => 1, optional => 1 },
tables => { type => 'arrayref', element_type => 'string', optional => 1 },
overwrite => { type => 'boolean', optional => 1 },
verbose => { type => 'boolean', optional => 1 },
dry_run => { type => 'boolean', optional => 1 },
show_counts => { type => 'boolean', optional => 1 },
progress => { type => 'boolean', optional => 1 },
encoding => { type => 'string', memberof => ['utf8', 'utf8-bom', 'cp1252'], optional => 1 },
logger => { type => 'object', can => ['debug', 'info', 'warn'], optional => 1 },
language => { type => 'string', optional => 1 },
}
Valid and invalid values (tested in F<t/domain.t>):
output_dir valid: any path of 1 character or more ("0" is valid),
including non-ASCII names given as UTF-8 bytes
invalid: "" (below the minimum), references
tables valid: undef (all tables), [] (no tables), one name,
many names; names are matched byte for byte
invalid: anything but an array reference; elements
that are references
booleans valid: exactly 1 true TRUE yes on / 0 false FALSE no off
(overwrite, invalid: everything else, e.g. "", 2, -1, "True", "Yes",
verbose, "0.0", " 1"
dry_run, show_counts, progress)
encoding valid: exactly utf8, utf8-bom, cp1252
invalid: other spellings (UTF8, utf-8, CP1252, " utf8")
logger valid: an object with debug, info and warn methods
invalid: an object missing any of them, a plain hash,
a string
language valid: any string; "" means "use the environment"
invalid: references
=head4 Output
{
type => 'object',
isa => 'App::Access2CSV::Exporter',
}
=head3 MESSAGES
All are fatal, and read "Invalid setting: REASON". The REASON part
comes from L<Params::Validate::Strict> and is not translated; for example:
+--------------------------------------+------------------------------+-----------------------------+
| Message | Meaning | What to do |
+--------------------------------------+------------------------------+-----------------------------+
| Invalid setting: Unknown parameter | X is not a known setting | Remove X, or fix its |
| 'X' | | spelling |
| Invalid setting: Parameter | This encoding is not | Use utf8, utf8-bom or |
| 'encoding' (X) must be one of utf8, | supported | cp1252 |
| utf8-bom, cp1252 | | |
| Invalid setting: Parameter 'logger' | logger is not an object | Give a logger object |
| must be an object | | |
| Invalid setting: Parameter 'tables' | tables is not an array | Give an array reference |
| must be ... | reference | |
+--------------------------------------+------------------------------+-----------------------------+
=cut | |||||
| 433 | ||||||
| 434 | sub new { | |||||
| 435 | 1785 | 9494299 | my $class = shift; | |||
| 436 | ||||||
| 437 | # Validation uses eval internally; the caller's $@ must survive | |||||
| 438 | 1785 | 1392 | local $@; | |||
| 439 | ||||||
| 440 | # Drop undefined values so that "not given" means "use the default" | |||||
| 441 | 1785 | 3598 | my $args = get_params(undef, \@_) || {}; | |||
| 442 | 1784 2733 2975 1784 | 22264 2768 2557 1960 | my %given = map { $_ => $args->{$_} } grep { defined $args->{$_} } keys %{$args}; | |||
| 443 | ||||||
| 444 | # A bad setting is reported in plain words ("Invalid setting: ..."), | |||||
| 445 | # not with Params::Validate::Strict's internal prefix and location | |||||
| 446 | 1784 1784 | 1655 3032 | my $params = eval { validate_strict(schema => { %NEW_SCHEMA }, input => \%given) } | |||
| 447 | or $class->_croak_i18n('invalid_setting', { params => [_validation_reason($@)] }); | |||||
| 448 | ||||||
| 449 | # Copy the table list so later changes by the caller cannot affect us | |||||
| 450 | 1692 46 | 705974 208 | $params->{tables} = [ @{ $params->{tables} } ] if $params->{tables}; | |||
| 451 | ||||||
| 452 | # The output folder is the invoking user's own choice; it is only used | |||||
| 453 | # as a folder name, so it is untainted here (see _untaint) | |||||
| 454 | 1692 | 2304 | $params->{output_dir} = _untaint($params->{output_dir}) if defined $params->{output_dir}; | |||
| 455 | ||||||
| 456 | 1692 1692 | 2279 20126 | my $self = bless { %DEFAULTS, %{$params}, used_names => {}, next_suffix => {}, programs => {} }, $class; | |||
| 457 | 1692 | 25948 | return set_return($self, { type => 'object' }); | |||
| 458 | } | |||||
| 459 | ||||||
| 460 - 671 | =head2 run
=head3 Purpose
Export the selected tables of one database to CSV files. In dry-run mode,
only print what would be exported.
=head3 Arguments
=over 4
=item C<database> (string, required) - the path of the C<.mdb> or C<.accdb> file.
The name is taken literally: C<-> is a file called C<->. (Reading from
standard input is a feature of the command-line program; see
L<App::Access2CSV/Reading the database from standard input>.)
=back
You can give it on its own, C<< $exporter->run('shop.accdb') >>, or as a
hash reference, C<< $exporter->run({ database => 'shop.accdb' }) >>.
=head3 Returns
C<0> if every selected table was exported, or in dry-run mode.
C<1> if at least one table was not exported (the others were).
=head3 Side Effects
=over 4
=item * Creates the output folder if needed (not in dry-run mode, and
not when no table is selected).
=item * Writes one CSV file per table (not in dry-run mode).
=item * Prints progress lines to standard error, if C<progress> is on.
=item * Prints the dry-run list to standard output, in dry-run mode.
=item * Sends messages to the logger, if there is one.
=item * Warns (with C<carp>) about each table that failed, about unknown
names in C<tables>, and about a missing C<mdb-count>.
=item * Croaks, before writing anything, if the database cannot be read, a
needed mdbtools program is missing, C<mdb-tables> fails, or the output
folder cannot be created.
=item * Switches C<show_counts> off for this exporter if C<mdb-count> is
missing.
=item * While it runs, handles the signals INT, QUIT, TERM and HUP (only
those you have not set a handler for yourself; they are restored when
C<run> returns). Any of them stops the run: the table being exported is
discarded - its temporary file deleted, any old CSV file left as it was -
no further table is started, and C<run> croaks. Pressing Ctrl-C, which
also stops the mdbtools program, has the same effect.
=item * Leaves your C<$@>, C<$!>, C<$?>, C<$_>, C<$.> and any pending C<alarm>
as they were (except that a croak sets C<$@> in your C<eval>, as usual).
=back
=head3 Usage
exit $exporter->run('shop.accdb');
=head3 EXAMPLE
my $exporter = App::Access2CSV::Exporter->new(output_dir => 'out');
# eval catches the fatal errors; the return value covers the rest
my $status = eval { $exporter->run('shop.accdb') };
if(!defined $status) {
print STDERR "Nothing was exported: $@";
} elsif($status) {
print STDERR "Some tables failed; see the warnings above\n";
} else {
print "Done\n";
}
=head3 API SPECIFICATION
=head4 Input
{
database => {
type => 'string',
min => 1,
optional => 0,
},
}
Valid and invalid values (tested in F<t/domain.t>):
database valid: a readable regular file
invalid: "" (below the 1-character minimum), undef,
a missing file, a folder, a device or FIFO,
an unreadable file
edges: each part of the path may be up to 255 bytes;
256 gives "File name too long"
The table names that mdbtools reports are data, not arguments, but they
have limits of their own:
length a CSV file name is the table name plus ".csv", and the
file system limits file names, so table names up to 251
units work and longer ones fail (that table only). The
unit depends on the file system: Linux counts bytes (125
u-umlauts, 2 bytes each, fit; 126 do not), macOS counts
characters (up to 251 of any letter fit). Access allows
at most 64 characters, well within either limit.
characters non-ASCII letters, emoji, joined emoji, combining marks
and right-to-left text are kept byte for byte.
Characters that are unsafe in file names - including
invisible text-direction controls such as U+202E - are
replaced by "_".
collisions the first name has no suffix, then _2, _3, ... _10 ...
cp1252 U+00FF and the Euro sign convert; U+0100 and above
(except the few Windows-1252 symbols), the C1 controls
U+0080-U+009F and emoji make the table fail.
=head4 Output
{
type => 'integer',
min => 0,
max => 1,
}
=head3 MESSAGES
"fatal" means C<run> croaks and nothing is exported. "per table" means
only that table fails; C<run> warns, logs, and carries on.
+-----------------------------------------+------------------------------+-------------------------------+
| Message | Meaning | What to do |
+-----------------------------------------+------------------------------+-------------------------------+
| Interrupted by SIGx: stopped, and the | Ctrl-C, Ctrl-\\, kill or a | Run again; tables finished |
| table being exported was discarded | closed terminal stopped the | before the interruption are |
| (fatal) | run | complete |
| run() must be called on an object | run was called on the class | Call new() first, then run() |
| created by new() (fatal) | or on something that is not | on the object it returns |
| | an exporter | |
| Cannot read database F: E (fatal) | F does not exist, or cannot | Check the path |
| | be reached; E is the reason | |
| | from the operating system | |
| Database F is not a regular file (fatal)| F is a folder or a device | Give the database file |
| Database F is not readable (fatal) | No permission to read F | Fix the permissions |
| | (never happens for root) | |
| Required program not found in PATH: P | mdbtools is not installed, | Install mdbtools, or fix PATH |
| (fatal) | or not in PATH | |
| mdb-tables failed with exit status N: E | mdbtools cannot read the | Check that F is a real Access |
| (fatal) | file | database |
| Cannot create output directory D: E | The folder cannot be made; | Check permissions and path |
| (fatal) | E is the reason for D itself | |
| | (e.g. "Not a directory" when | |
| | a file is in the way) | |
| Cannot count the rows of T: E (warning) | mdb-count failed for table T | The table is still exported |
| | (only with show_counts) | (dry run: count shown as "?") |
| ... mdb-count printed no number: "X" | mdb-count's answer was not | As above; X shows what it |
| | just a number | printed |
| Tables not found in database: T | Names in tables are not in | Check spelling and case |
| (warning) | the database | |
| mdb-count not found in PATH; row counts | show_counts is on, but | Install mdb-count, or turn |
| are unavailable (warning) | mdb-count is missing | show_counts off |
| FAILED: T: E (warning, logged) | Table T was not exported, | See E, one of the messages |
| | because of E | below |
| Output file already exists: F (use | F exists (a symbolic link, | Set overwrite, or use another |
| --overwrite to replace it) (per table) | even a broken one, counts) | output_dir |
| | and overwrite is off | |
| mdb-export failed with exit status N: E | mdbtools could not read this | Check the table in Access |
| (per table) | table | |
| P was killed by signal N (per table, | The program was stopped from | Check memory and system |
| or fatal for mdb-tables) | outside | limits |
| P could not be run: E (per table, or | The program was found but | Check its permissions and |
| fatal for mdb-tables) | could not be started | that it is a real program |
| Table T, line N: cannot be represented | A character is not in | Use utf8 or utf8-bom |
| in cp1252 (per table) | Windows-1252 | |
| Table T, line N: output of mdb-export | mdbtools gave bytes that are | Check the MDB_ICONV setting |
| is not valid UTF-8 (per table) | not UTF-8 | |
| Cannot write F: E (per table) | The file could not be | Check permissions and free |
| | written or renamed into place| disk space |
| Cannot write to the log: E (warning, | The logger failed. Exports | Check the log's disk or |
| once) | go on; logging stops | destination |
+-----------------------------------------+------------------------------+-------------------------------+
=head3 PSEUDOCODE
check the argument
stop (croak) unless the database is a readable file
find mdb-tables and mdb-export (croak if missing),
and mdb-count if row counts are wanted (warn if missing)
forget the file names given out by any earlier run
tables := the sorted user tables, filtered by "tables"
(warn about names that are not found)
if dry run:
print the table -> file list (row count "?" with a warning
if a count fails)
return 0
if there are tables to export:
create the output folder (croak if that fails)
for each table:
print "[n/total] table" if progress is on
try to export the table
if that failed: warn, log, and count the failure
(a failed row count after the file is in place is only a
warning; the table still counts as exported)
log the summary
return 1 if any table failed, else 0
=cut | |||||
| 672 | ||||||
| 673 | sub run { | |||||
| 674 | 388 | 109553 | my $self = shift; | |||
| 675 | ||||||
| 676 | # State machine guard: run() is only a transition out of READY, which | |||||
| 677 | # only new() can create. Refuse anything else before doing any work. | |||||
| 678 | # (Reported through the class: $self may not even be an object.) | |||||
| 679 | 388 | 2284 | blessed($self) && $self->isa(__PACKAGE__) or __PACKAGE__->_croak_i18n('needs_object'); | |||
| 680 | ||||||
| 681 | # File tests, evals and child processes below would otherwise leave | |||||
| 682 | # their marks in the caller's $@ and $! | |||||
| 683 | 384 | 2957 | local ($@, $!); | |||
| 684 | ||||||
| 685 | # An undef database is a missing one, not a file called "". Work on a | |||||
| 686 | # copy: get_params hands back the caller's own hash when given one. | |||||
| 687 | # (Params::Get either dies or returns a hash reference - proved in | |||||
| 688 | # t/path.t - so no test of what it returned is needed.) | |||||
| 689 | 384 384 | 370 718 | my $input = { %{ get_params('database', \@_) } }; | |||
| 690 | 378 | 6258 | delete $input->{database} unless defined $input->{database}; | |||
| 691 | 378 | 1012 | my $params = validate_strict( | |||
| 692 | schema => { database => { type => 'string', min => 1 } }, | |||||
| 693 | input => $input, | |||||
| 694 | ); | |||||
| 695 | 369 | 22549 | my $database = $params->{database}; | |||
| 696 | ||||||
| 697 | # Fail fast, before any output, on problems that affect every table | |||||
| 698 | 369 | 972 | $self->_check_database($database) | |||
| 699 | ->_verify_dependencies() | |||||
| 700 | ->_reset_names(); | |||||
| 701 | ||||||
| 702 | # Stopping part-way must behave like a failed transaction: the table | |||||
| 703 | # being exported is discarded (its temporary file deleted) and no | |||||
| 704 | # further table is started. Perl's default action for these signals | |||||
| 705 | # is to exit at once, skipping the clean-up, so while run() is active | |||||
| 706 | # they raise an exception instead. A handler the caller has set is | |||||
| 707 | # left alone; everything is restored when run() returns. | |||||
| 708 | 299 | 486 | local $self->{interrupted}; | |||
| 709 | 299 299 | 258 768 | my @ours = @{ $self->_interrupt_signals() }; | |||
| 710 | local @SIG{@ours} = (sub { | |||||
| 711 | 1 | 19978 | $self->{interrupted} = $_[0]; | |||
| 712 | 1 | 18 | die $self->_printable($self->i18n('interrupted', { params => [$_[0]] })), "\n"; | |||
| 713 | 299 | 5804 | }) x @ours; | |||
| 714 | ||||||
| 715 | # Premise: the database is now known to be a readable regular file, and | |||||
| 716 | # it is only ever passed to mdbtools as one list argument after "--". | |||||
| 717 | # Conclusion: it is safe to untaint. | |||||
| 718 | 299 | 472 | $database = _untaint($database); | |||
| 719 | ||||||
| 720 | 299 | 744 | my $tables = $self->_select_tables($self->_get_tables($database)); | |||
| 721 | ||||||
| 722 | # Guard clause: a dry run must not touch the file system, so it leaves | |||||
| 723 | # before mkdir. Premise: _dry_run writes nothing that can fail a table. | |||||
| 724 | # Conclusion: a dry run always succeeds. | |||||
| 725 | 283 | 860 | if($self->{dry_run}) { | |||
| 726 | 63 | 371 | $self->_dry_run($database, $tables); | |||
| 727 | 63 | 438 | return set_return($EXIT_OK, { %RUN_STATUS_SCHEMA }); | |||
| 728 | } | |||||
| 729 | ||||||
| 730 | # The output folder is only made when there is something to put in it; | |||||
| 731 | # with no tables selected the run goes straight to the summary | |||||
| 732 | 220 220 | 339 1021 | my $failed = (@{$tables} ? $self->_make_output_dir() : $self)->_export_all($database, $tables); | |||
| 733 | 211 | 1910 | return set_return($failed ? $EXIT_FAILURE : $EXIT_OK, { %RUN_STATUS_SCHEMA }); | |||
| 734 | } | |||||
| 735 | ||||||
| 736 | # _check_database | |||||
| 737 | # Purpose: Make sure the database is a readable regular file. | |||||
| 738 | # Entry Criteria: $database is a defined, non-empty path. | |||||
| 739 | # Exit Status: Returns $self for chaining; croaks otherwise. | |||||
| 740 | # Side Effects: stat()s the file; sets $!. | |||||
| 741 | sub _check_database :Private { | |||||
| 742 | 373 | 6820 | my ($self, $database) = @_; | |||
| 743 | ||||||
| 744 | # The stat result is reused via "_" so the file is only examined once; | |||||
| 745 | # $! is captured straight away because later calls may overwrite it | |||||
| 746 | 373 | 2269 | if(!-e $database) { | |||
| 747 | 36 | 396 | $self->_croak_i18n('database_not_found', { params => [$database, "$!"] }); | |||
| 748 | } | |||||
| 749 | 337 | 518 | $self->_croak_i18n('database_not_file', { params => [$database] }) unless -f _; | |||
| 750 | 324 | 731 | $self->_croak_i18n('database_unreadable', { params => [$database] }) unless -r _; | |||
| 751 | ||||||
| 752 | 318 | 781 | return $self; | |||
| 753 | 34 34 34 | 23394 28 541 | } | |||
| 754 | ||||||
| 755 | # _verify_dependencies | |||||
| 756 | # Purpose: Locate the mdbtools programs in PATH. | |||||
| 757 | # Entry Criteria: None. | |||||
| 758 | # Exit Status: Returns $self; croaks if a required program is missing. | |||||
| 759 | # Side Effects: Sets $self->{programs}; may switch off show_counts (with a | |||||
| 760 | # warning) when mdb-count is unavailable; logs at debug level. | |||||
| 761 | sub _verify_dependencies :Private { | |||||
| 762 | 341 | 5399 | my $self = shift; | |||
| 763 | ||||||
| 764 | 341 | 348 | my %programs; | |||
| 765 | 341 | 1102 | foreach my $program (@REQUIRED_PROGRAMS) { | |||
| 766 | 658 | 2382 | $programs{$program} = $self->_find_program($program) | |||
| 767 | or $self->_croak_i18n('program_missing', { params => [$program] }); | |||||
| 768 | } | |||||
| 769 | ||||||
| 770 | # mdb-count is only needed for row counts, so its absence is not fatal. | |||||
| 771 | # _find_program returns a path or false, so one branch decides both | |||||
| 772 | # "store it" and "switch counts off" (nothing is stored and removed). | |||||
| 773 | 302 | 1021 | if($self->{show_counts}) { | |||
| 774 | 47 | 126 | if(my $path = $self->_find_program($MDB_COUNT)) { | |||
| 775 | 35 | 87 | $programs{$MDB_COUNT} = $path; | |||
| 776 | } else { | |||||
| 777 | 12 | 31 | $self->{show_counts} = 0; | |||
| 778 | 12 | 45 | $self->_warn('no_row_counter'); | |||
| 779 | } | |||||
| 780 | } | |||||
| 781 | ||||||
| 782 | 302 | 487 | $self->{programs} = \%programs; | |||
| 783 | 302 | 785 | return $self; | |||
| 784 | 34 34 34 | 6453 28 273 | } | |||
| 785 | ||||||
| 786 | # _find_program | |||||
| 787 | # Purpose: Look up one program in PATH and note where it was found. | |||||
| 788 | # Entry Criteria: $program is a bare program name. | |||||
| 789 | # Exit Status: Returns the full path, or undef if not found. | |||||
| 790 | # Side Effects: Logs the location at debug level when --verbose is on. | |||||
| 791 | sub _find_program :Private { | |||||
| 792 | 704 | 4282 | my ($self, $program) = @_; | |||
| 793 | ||||||
| 794 | # Only absolute paths are trusted. A relative entry in PATH (".", or | |||||
| 795 | # an empty one) would run whatever file of that name is in the current | |||||
| 796 | # folder - a classic way to plant a program. | |||||
| 797 | 704 689 | 3235 59029 | my ($path) = grep { defined && File::Spec->file_name_is_absolute($_) } which($program); | |||
| 798 | ||||||
| 799 | # An absolute path to an existing program: safe to untaint | |||||
| 800 | 704 | 1700 | $path = _untaint($path) if defined $path; | |||
| 801 | 704 | 2129 | if($path && $self->{verbose}) { | |||
| 802 | 8 | 24 | $self->_log(debug => 'program_found', { params => [$program, $path] }); | |||
| 803 | } | |||||
| 804 | 704 | 1847 | return $path; | |||
| 805 | 34 34 34 | 5219 32 305 | } | |||
| 806 | ||||||
| 807 | # _reset_names | |||||
| 808 | # Purpose: Forget file names allocated by a previous run() so that | |||||
| 809 | # running the same exporter twice gives the same names. | |||||
| 810 | # Entry Criteria: None. | |||||
| 811 | # Exit Status: Returns $self. | |||||
| 812 | # Side Effects: Empties $self->{used_names}. | |||||
| 813 | sub _reset_names :Private { | |||||
| 814 | 299 | 1880 | my $self = shift; | |||
| 815 | ||||||
| 816 | 299 | 365 | $self->{used_names} = {}; | |||
| 817 | 299 | 387 | $self->{next_suffix} = {}; | |||
| 818 | 299 | 282 | return $self; | |||
| 819 | 34 34 34 | 3666 41 264 | } | |||
| 820 | ||||||
| 821 | # _get_tables | |||||
| 822 | # Purpose: List the user tables in the database. | |||||
| 823 | # Entry Criteria: _verify_dependencies() has run. | |||||
| 824 | # Exit Status: Returns an arrayref of table names, sorted; croaks if | |||||
| 825 | # mdb-tables fails. | |||||
| 826 | # Side Effects: Runs mdb-tables. | |||||
| 827 | sub _get_tables :Protected { | |||||
| 828 | 295 | 1950 | my ($self, $database) = @_; | |||
| 829 | ||||||
| 830 | # -1 puts one table per line, so names containing spaces survive; | |||||
| 831 | # "--" stops a database path starting with "-" being read as an option | |||||
| 832 | 295 | 343 | my $stdout = ''; | |||
| 833 | 295 | 1074 | $self->_run_program($MDB_TABLES, ['-1', $END_OF_OPTIONS, $database], \$stdout); | |||
| 834 | ||||||
| 835 | # \r? copes with mdbtools builds that emit CRLF line endings; a program | |||||
| 836 | # that printed nothing may leave $stdout undefined | |||||
| 837 | # Table names are untainted: they are only used as one list argument | |||||
| 838 | # after "--", and in file names only after _csv_filename has made them | |||||
| 839 | # safe | |||||
| 840 | 279 1502 401519 | 128650 2307 341892 | my @tables = sort map { _untaint($_) } grep { length($_) && !$self->_is_system_table($_) } split /\r?\n/, $stdout // ''; | |||
| 841 | 279 | 13294 | return \@tables; | |||
| 842 | 34 34 34 | 5181 62 309 | } | |||
| 843 | ||||||
| 844 | # _is_system_table | |||||
| 845 | # Purpose: Decide whether a table is Access's own rather than the user's. | |||||
| 846 | # Entry Criteria: $table is a table name. | |||||
| 847 | # Exit Status: Returns 1 for system tables, 0 otherwise. | |||||
| 848 | # Side Effects: None. Protected so that subclasses can widen the filter. | |||||
| 849 | sub _is_system_table :Protected { | |||||
| 850 | 401528 | 1266064 | my ($self, $table) = @_; | |||
| 851 | ||||||
| 852 | 401528 | 575533 | return ($table =~ $SYSTEM_TABLE_RE) ? 1 : 0; | |||
| 853 | 34 34 34 | 3697 46 294 | } | |||
| 854 | ||||||
| 855 | # _select_tables | |||||
| 856 | # Purpose: Apply the --table filter to the list of tables. | |||||
| 857 | # Entry Criteria: $tables is the arrayref from _get_tables(). | |||||
| 858 | # Exit Status: Returns an arrayref, in database (sorted) order. | |||||
| 859 | # Side Effects: Warns and logs about requested tables that do not exist. | |||||
| 860 | sub _select_tables :Private { | |||||
| 861 | 289 | 3248 | my ($self, $tables) = @_; | |||
| 862 | ||||||
| 863 | 289 | 1164 | return $tables unless $self->{tables}; | |||
| 864 | ||||||
| 865 | # Matching is exact, as mdb-export itself is case-sensitive | |||||
| 866 | 35 87 35 | 104 287 77 | my %available = map { $_ => 1 } @{$tables}; | |||
| 867 | 35 1050 35 | 67 995 93 | my %wanted = map { $_ => 1 } @{ $self->{tables} }; | |||
| 868 | ||||||
| 869 | 35 51 | 132 106 | my @missing = sort grep { !$available{$_} } keys %wanted; | |||
| 870 | 35 | 173 | if(@missing) { | |||
| 871 | 12 | 184 | $self->_warn('unknown_tables', { params => [join(', ', @missing)], count => scalar(@missing) }); | |||
| 872 | } | |||||
| 873 | ||||||
| 874 | 35 87 35 | 68 177 74 | return [ grep { $wanted{$_} } @{$tables} ]; | |||
| 875 | 34 34 34 | 5804 34 266 | } | |||
| 876 | ||||||
| 877 | # _make_output_dir | |||||
| 878 | # Purpose: Create the output directory (and parents) if necessary. | |||||
| 879 | # Entry Criteria: Not in dry-run mode. | |||||
| 880 | # Exit Status: Returns $self; croaks if the directory cannot be created. | |||||
| 881 | # Side Effects: Creates directories. | |||||
| 882 | sub _make_output_dir :Private { | |||||
| 883 | 218 | 2900 | my $self = shift; | |||
| 884 | ||||||
| 885 | 218 | 515 | my $dir = $self->{output_dir}; | |||
| 886 | 218 | 2566 | return $self if -d $dir; | |||
| 887 | ||||||
| 888 | # Ask File::Path to report errors rather than carp/croak on its own, | |||||
| 889 | # so the message can be translated and names the directory we wanted | |||||
| 890 | 164 | 24418 | make_path($dir, { error => \my $errors }); | |||
| 891 | 164 164 | 462 1351 | if(@{$errors} || !-d $dir) { | |||
| 892 | # File::Path may also report a parent (e.g. "File exists" for a | |||||
| 893 | # plain file in the way); the reason for $dir itself, or failing | |||||
| 894 | # that the last one, is what explains the failure | |||||
| 895 | 18 24 18 | 32 40 27 | my ($mine) = grep { exists $_->{$dir} } @{$errors}; | |||
| 896 | 18 6 2 | 46 9 2 | my $detail = $mine ? $mine->{$dir} : (@{$errors} ? (values %{ $errors->[-1] })[0] : undef); | |||
| 897 | 18 | 135 | $self->_croak_i18n('mkdir_failed', { params => [$dir, $detail || "$!"] }); | |||
| 898 | } | |||||
| 899 | 146 | 606 | return $self; | |||
| 900 | 34 34 34 | 5745 26 317 | } | |||
| 901 | ||||||
| 902 | # _export_all | |||||
| 903 | # Purpose: Export each table, carrying on past individual failures. | |||||
| 904 | # Entry Criteria: The output directory exists. | |||||
| 905 | # Exit Status: Returns the number of tables that failed. | |||||
| 906 | # Side Effects: Writes CSV files; prints progress; warns; logs. | |||||
| 907 | sub _export_all :Private { | |||||
| 908 | 216 | 3002 | my ($self, $database, $tables) = @_; | |||
| 909 | ||||||
| 910 | 216 216 | 238 300 | my $total = scalar @{$tables}; | |||
| 911 | 216 | 317 | my $failed = 0; | |||
| 912 | ||||||
| 913 | # eval below would otherwise overwrite the caller's $@ | |||||
| 914 | 216 | 261 | local $@; | |||
| 915 | ||||||
| 916 | # An index loop, not each(), which shares the array's iterator with the | |||||
| 917 | # caller and would silently skip tables if it was already part-way | |||||
| 918 | 216 216 | 294 445 | foreach my $index (0 .. $#{$tables}) { | |||
| 919 | 1369 | 1823 | my $table = $tables->[$index]; | |||
| 920 | # Progress goes to STDERR so that STDOUT can be redirected cleanly | |||||
| 921 | 1369 | 1974 | if($self->{progress}) { | |||
| 922 | 53 | 471 | print STDERR $self->_printable($self->i18n('progress', { params => [$index + 1, $total, $table] })), "\n"; | |||
| 923 | } | |||||
| 924 | ||||||
| 925 | # One bad table should not stop the rest from being exported | |||||
| 926 | 1369 1369 1290 | 1165 2632 35327 | next if eval { $self->_export_table($database, $table); 1 }; | |||
| 927 | ||||||
| 928 | # ... but an interruption stops them all: the failed table has been | |||||
| 929 | # discarded (its temporary file went with the exception), and no | |||||
| 930 | # further table is started | |||||
| 931 | 79 | 30152 | $self->_croak_i18n('interrupted', { params => [$self->{interrupted}] }) if $self->{interrupted}; | |||
| 932 | ||||||
| 933 | 77 | 251 | my $error = $@ || 'Unknown error'; | |||
| 934 | 77 | 119 | chomp $error; | |||
| 935 | 77 | 96 | ++$failed; | |||
| 936 | 77 | 329 | $self->_warn('export_failed', { params => [$table, $error] }); | |||
| 937 | } | |||||
| 938 | ||||||
| 939 | 214 | 1047 | $self->_log(info => 'summary', { params => [$total, $failed], count => $total }); | |||
| 940 | 214 | 811 | return $failed; | |||
| 941 | 34 34 34 | 6735 43 270 | } | |||
| 942 | ||||||
| 943 | # _export_table | |||||
| 944 | # Purpose: Export one table to its CSV file. | |||||
| 945 | # Entry Criteria: _verify_dependencies() and _make_output_dir() have run. | |||||
| 946 | # Exit Status: Returns $self; croaks on any failure, leaving no partial file. | |||||
| 947 | # Side Effects: Runs mdb-export (and mdb-count); creates or replaces a file. | |||||
| 948 | sub _export_table :Protected { | |||||
| 949 | 1369 | 9465 | my ($self, $database, $table) = @_; | |||
| 950 | ||||||
| 951 | 1369 | 2565 | my $outfile = File::Spec->catfile($self->{output_dir}, $self->_csv_filename($table)); | |||
| 952 | ||||||
| 953 | # Check before exporting so that we do not waste time on a big table | |||||
| 954 | # -l as well as -e: a dangling symlink is an existing entry too, and | |||||
| 955 | # must not be silently replaced. The overwrite flag is tested first: | |||||
| 956 | # when it is set the answer is already known, so no file test is needed. | |||||
| 957 | 1369 | 18198 | if(!$self->{overwrite} && (-e $outfile || -l $outfile)) { | |||
| 958 | 24 | 178 | $self->_croak_i18n('output_exists', { params => [$outfile] }); | |||
| 959 | } | |||||
| 960 | ||||||
| 961 | # Write into a temporary file next to the target; it is deleted | |||||
| 962 | # automatically if anything below croaks | |||||
| 963 | 1345 | 4656 | my $tmp = File::Temp->new(DIR => $self->{output_dir}, TEMPLATE => $TEMP_TEMPLATE, UNLINK => 1); | |||
| 964 | 1343 | 230292 | binmode $tmp, ':raw'; | |||
| 965 | ||||||
| 966 | 1343 | 53806 | if($self->{encoding} eq $ENC_CP1252) { | |||
| 967 | 45 | 162 | $self->_export_transcoded($database, $table, $tmp); | |||
| 968 | } else { | |||||
| 969 | # The BOM must reach the file before mdb-export starts writing to | |||||
| 970 | # the same descriptor, hence the explicit flush | |||||
| 971 | 1298 19 | 1820 129 | print {$tmp} $UTF8_BOM if $self->{encoding} eq $ENC_UTF8_BOM; | |||
| 972 | 1298 | 2954 | $tmp->flush() or $self->_croak_i18n('write_failed', { params => [$outfile, "$!"] }); | |||
| 973 | 1295 | 2814 | $self->_run_program($MDB_EXPORT, [$END_OF_OPTIONS, $database, $table], $tmp); | |||
| 974 | } | |||||
| 975 | ||||||
| 976 | 1298 | 7753 | $self->_install_file($tmp, $outfile); | |||
| 977 | ||||||
| 978 | # The file is now in place, so the table has been exported. Row counts | |||||
| 979 | # are optional extras: if counting fails it is only a warning (see | |||||
| 980 | # _try_count_rows), and the export is logged without a count. | |||||
| 981 | 1291 | 2771 | my $rows = $self->{show_counts} ? $self->_try_count_rows($database, $table) : undef; | |||
| 982 | 1291 | 1471 | if(defined $rows) { | |||
| 983 | 31 | 434 | $self->_log(info => 'exported_rows', { params => [$table, $outfile, $rows], count => $rows }); | |||
| 984 | } else { | |||||
| 985 | 1260 | 3316 | $self->_log(info => 'exported', { params => [$table, $outfile] }); | |||
| 986 | } | |||||
| 987 | 1291 | 3322 | return $self; | |||
| 988 | 34 34 34 | 7620 34 269 | } | |||
| 989 | ||||||
| 990 | # _export_transcoded | |||||
| 991 | # Purpose: Export a table and convert it from UTF-8 to Windows-1252. | |||||
| 992 | # Entry Criteria: $out is an open, raw, writable filehandle. | |||||
| 993 | # Exit Status: Returns $self; croaks on invalid UTF-8 or on a character | |||||
| 994 | # that has no cp1252 equivalent (rather than silently | |||||
| 995 | # replacing it with '?'). | |||||
| 996 | # Side Effects: Runs mdb-export into a second temporary file. | |||||
| 997 | sub _export_transcoded :Private { | |||||
| 998 | 51 | 2128 | my ($self, $database, $table, $out) = @_; | |||
| 999 | ||||||
| 1000 | # Reading the spool changes $. and the evals change $@; keep the | |||||
| 1001 | # caller's values | |||||
| 1002 | 51 | 121 | local $.; | |||
| 1003 | 51 | 66 | local $@; | |||
| 1004 | ||||||
| 1005 | # Spool to disk rather than memory, so huge tables do not exhaust RAM | |||||
| 1006 | 51 | 155 | my $spool = File::Temp->new(DIR => $self->{output_dir}, TEMPLATE => $TEMP_TEMPLATE, UNLINK => 1); | |||
| 1007 | 51 | 7323 | binmode $spool, ':raw'; | |||
| 1008 | 51 | 1580 | $self->_run_program($MDB_EXPORT, [$END_OF_OPTIONS, $database, $table], $spool); | |||
| 1009 | 43 | 515 | seek $spool, 0, 0; | |||
| 1010 | ||||||
| 1011 | # Convert line by line; $. gives the user a line number to look at | |||||
| 1012 | 43 | 7376 | while(my $line = <$spool>) { | |||
| 1013 | 81 81 | 131 424 | my $chars = eval { $UTF8_CODEC->decode($line, FB_CROAK) }; | |||
| 1014 | 81 | 467 | $self->_croak_i18n('invalid_utf8', { params => [$table, $.] }) unless defined $chars; | |||
| 1015 | ||||||
| 1016 | 74 74 | 84 350 | my $bytes = eval { $CP1252_CODEC->encode($chars, FB_CROAK) }; | |||
| 1017 | 74 | 180 | $self->_croak_i18n('unmappable', { params => [$table, $., $ENC_CP1252] }) unless defined $bytes; | |||
| 1018 | ||||||
| 1019 | 63 63 | 68 290 | print {$out} $bytes; | |||
| 1020 | } | |||||
| 1021 | ||||||
| 1022 | # Close the spool explicitly once it has been read. (On a croak above, | |||||
| 1023 | # File::Temp's destructor closes and deletes it.) | |||||
| 1024 | 25 | 110 | close $spool; | |||
| 1025 | 25 | 1781 | return $self; | |||
| 1026 | 34 34 34 | 6391 28 278 | } | |||
| 1027 | ||||||
| 1028 | # _install_file | |||||
| 1029 | # Purpose: Move a finished temporary file to its final name. | |||||
| 1030 | # Entry Criteria: $tmp is a File::Temp holding the complete CSV. | |||||
| 1031 | # Exit Status: Returns $self; croaks if the rename or chmod fails. | |||||
| 1032 | # Side Effects: Replaces $outfile; the temporary file is no longer | |||||
| 1033 | # auto-deleted. | |||||
| 1034 | sub _install_file :Private { | |||||
| 1035 | 1293 | 11741 | my ($self, $tmp, $outfile) = @_; | |||
| 1036 | ||||||
| 1037 | # The eval must not overwrite the caller's $@ | |||||
| 1038 | 1293 | 1501 | local $@; | |||
| 1039 | ||||||
| 1040 | # File::Temp creates files as 0600; give the CSV the permissions a | |||||
| 1041 | # normal open() would have, i.e. 0666 less the umask | |||||
| 1042 | 1293 | 1332 | my $ok = eval { | |||
| 1043 | 1293 | 3028 | close $tmp; | |||
| 1044 | 1293 | 63644 | chmod $FILE_MODE & ~umask(), $tmp->filename(); | |||
| 1045 | 1293 | 80225 | rename $tmp->filename(), $outfile; | |||
| 1046 | 1284 | 69405 | 1; | |||
| 1047 | }; | |||||
| 1048 | 1293 | 10044 | $self->_croak_i18n('write_failed', { params => [$outfile, _os_error($@)] }) unless $ok; | |||
| 1049 | ||||||
| 1050 | 1284 | 2328 | $tmp->unlink_on_destroy(0); | |||
| 1051 | 1284 | 6923 | return $self; | |||
| 1052 | 34 34 34 | 4845 24 243 | } | |||
| 1053 | ||||||
| 1054 | # _count_rows | |||||
| 1055 | # Purpose: Ask mdb-count how many rows a table has. | |||||
| 1056 | # Entry Criteria: $self->{programs}{'mdb-count'} is set. | |||||
| 1057 | # Exit Status: Returns a non-negative integer; croaks if mdb-count fails. | |||||
| 1058 | # Side Effects: Runs mdb-count. | |||||
| 1059 | sub _count_rows :Private { | |||||
| 1060 | 57 | 788 | my ($self, $database, $table) = @_; | |||
| 1061 | ||||||
| 1062 | 57 | 140 | my $stdout = ''; | |||
| 1063 | 57 | 275 | $self->_run_program($MDB_COUNT, [$END_OF_OPTIONS, $database, $table], \$stdout); | |||
| 1064 | ||||||
| 1065 | # mdb-count prints just the number (perhaps with spaces around it). | |||||
| 1066 | # Anything else - nothing, "-5", an error text - is not a count, and | |||||
| 1067 | # must not quietly become 0: it is reported (as a warning, by | |||||
| 1068 | # _try_count_rows) | |||||
| 1069 | 50 | 709 | my ($rows) = ($stdout // '') =~ /\A\s*(\d+)\s*\z/; | |||
| 1070 | 50 | 266 | defined($rows) or $self->_croak_i18n('count_unreadable', { params => [$self->_printable(substr($stdout // '', 0, $COUNT_SHOWN))] }); | |||
| 1071 | 42 | 234 | return $rows; | |||
| 1072 | 34 34 34 | 5365 46 277 | } | |||
| 1073 | ||||||
| 1074 | # _try_count_rows | |||||
| 1075 | # Purpose: Count a table's rows, where a failure is only a warning. | |||||
| 1076 | # Row counts are optional extras: they must never make an | |||||
| 1077 | # exported table count as failed, nor end a dry run. | |||||
| 1078 | # Entry Criteria: $self->{programs}{'mdb-count'} is set. | |||||
| 1079 | # Exit Status: Returns the count, or undef if it could not be had. | |||||
| 1080 | # Side Effects: Runs mdb-count; on failure warns and logs "Cannot count | |||||
| 1081 | # the rows of T: E". An interruption is not a failure to | |||||
| 1082 | # count: it is passed on, to stop the run. | |||||
| 1083 | sub _try_count_rows :Private { | |||||
| 1084 | 61 | 793 | my ($self, $database, $table) = @_; | |||
| 1085 | ||||||
| 1086 | 61 | 115 | local $@; | |||
| 1087 | 61 61 | 136 249 | my $rows = eval { $self->_count_rows($database, $table) }; | |||
| 1088 | 61 | 3376 | return $rows if defined $rows; | |||
| 1089 | ||||||
| 1090 | 15 | 104 | die $@ if $self->{interrupted}; | |||
| 1091 | 14 | 15 | my $error = $@; | |||
| 1092 | 14 | 21 | chomp $error; | |||
| 1093 | 14 | 52 | $self->_warn('count_failed', { params => [$table, $error] }); | |||
| 1094 | 14 | 44 | return; | |||
| 1095 | 34 34 34 | 4612 25 237 | } | |||
| 1096 | ||||||
| 1097 | # _run_program | |||||
| 1098 | # Purpose: Run an mdbtools program and check that it succeeded. | |||||
| 1099 | # Shared by every call to mdbtools, so that failures are | |||||
| 1100 | # reported consistently. | |||||
| 1101 | # Entry Criteria: $name is a key of $self->{programs}; $args is an arrayref; | |||||
| 1102 | # $stdout is a scalar ref or a filehandle for run3(). | |||||
| 1103 | # Exit Status: Returns $self; croaks if the program exits non-zero or | |||||
| 1104 | # dies from a signal. | |||||
| 1105 | # Side Effects: Runs a child process; writes to $stdout; sets $?. | |||||
| 1106 | sub _run_program :Private { | |||||
| 1107 | 1686 | 13860 | my ($self, $name, $args, $stdout) = @_; | |||
| 1108 | ||||||
| 1109 | # run3 sets $?, and reads captured output back from a temporary file, | |||||
| 1110 | # which changes the handle $. refers to; keep the caller's values | |||||
| 1111 | 1686 | 3179 | local ($?, $.); | |||
| 1112 | ||||||
| 1113 | # Start the program in a clean environment (as perlsec asks, and as | |||||
| 1114 | # taint mode requires): PATH keeps only absolute folders, and variables | |||||
| 1115 | # that can change how a program is started are removed | |||||
| 1116 | # (File::Spec->path and path_sep, not ":": Windows separates PATH with | |||||
| 1117 | # ";" and its paths contain ":", as in C:\\) | |||||
| 1118 | 1686 31322 32891 | 21585 22433 50075 | local $ENV{PATH} = join($Config{path_sep}, map { _untaint($_) } grep { length && File::Spec->file_name_is_absolute($_) } File::Spec->path()); | |||
| 1119 | 1686 | 7029 | local @ENV{@UNSAFE_ENV}; | |||
| 1120 | 1686 | 21693 | delete @ENV{@UNSAFE_ENV}; | |||
| 1121 | ||||||
| 1122 | # A list (not a string) is passed, so no shell ever sees the file or | |||||
| 1123 | # table name and quoting cannot be abused | |||||
| 1124 | 1686 | 22459 | my $stderr = ''; | |||
| 1125 | 1686 1686 | 2213 3980 | run3([$self->{programs}{$name}, @{$args}], \undef, $stdout, \$stderr); | |||
| 1126 | ||||||
| 1127 | # Distinguish "never started" (-1) and a signal from an ordinary | |||||
| 1128 | # non-zero exit status; $! only means something in the first case | |||||
| 1129 | 1680 | 95242573 | my ($status, $reason) = ($?, "$!"); | |||
| 1130 | 1680 | 2366 | chomp $stderr; | |||
| 1131 | 1680 | 3047 | if($status == -1) { | |||
| 1132 | 6 | 22 | $self->_croak_i18n('program_not_run', { params => [$name, $reason] }); | |||
| 1133 | } | |||||
| 1134 | 1674 | 3174 | if($status & 127) { | |||
| 1135 | # While system() waits, Perl ignores INT and QUIT in this process, | |||||
| 1136 | # so Ctrl-C shows up only as the child dying of SIGINT. Treat | |||||
| 1137 | # that as the user stopping the whole run, not as a bad table. | |||||
| 1138 | 14 | 235 | my $signal = (split ' ', $Config{sig_name})[$status & 127] // ''; | |||
| 1139 | 14 | 113 | if($signal eq 'INT' || $signal eq 'QUIT') { | |||
| 1140 | 3 | 4 | $self->{interrupted} = $signal; | |||
| 1141 | 3 | 26 | $self->_croak_i18n('interrupted', { params => [$signal] }); | |||
| 1142 | } | |||||
| 1143 | 11 | 57 | $self->_croak_i18n('program_signalled', { params => [$name, $status & 127] }); | |||
| 1144 | } | |||||
| 1145 | 1660 | 2615 | if($status) { | |||
| 1146 | 33 | 324 | $self->_croak_i18n('program_failed', { params => [$name, $status >> 8, $stderr] }); | |||
| 1147 | } | |||||
| 1148 | 1627 | 32276 | return $self; | |||
| 1149 | 34 34 34 | 8190 29 265 | } | |||
| 1150 | ||||||
| 1151 | # _csv_filename | |||||
| 1152 | # Purpose: Turn a table name into a safe, unique CSV file name. | |||||
| 1153 | # Entry Criteria: $table is a table name (possibly empty). | |||||
| 1154 | # Exit Status: Returns a file name (no directory) ending in ".csv". | |||||
| 1155 | # Side Effects: Records the name in $self->{used_names}. | |||||
| 1156 | # | |||||
| 1157 | # Names are compared case-insensitively, because Windows and macOS file | |||||
| 1158 | # systems are, so "Orders" and "ORDERS" do not overwrite each other. | |||||
| 1159 | # The loop (rather than a single suffix) guarantees uniqueness even when | |||||
| 1160 | # another table is literally called "Orders_2". | |||||
| 1161 | sub _csv_filename :Protected { | |||||
| 1162 | 17082 | 120594 | my ($self, $table) = @_; | |||
| 1163 | ||||||
| 1164 | 17082 | 13101 | my $name = $table // ''; | |||
| 1165 | ||||||
| 1166 | # Replace characters that are illegal somewhere, then strip leading | |||||
| 1167 | # and trailing whitespace; Windows also silently drops trailing dots | |||||
| 1168 | 17082 | 18751 | $name =~ s/$UNSAFE_CHARS_RE/_/g; | |||
| 1169 | 17082 | 21490 | $name =~ s/$BIDI_CONTROLS_RE/_/g; | |||
| 1170 | ||||||
| 1171 | # C1 controls (U+0080-U+009F; U+009B acts like ESC [ on terminals). | |||||
| 1172 | # In a byte string they must be matched as their UTF-8 form, because a | |||||
| 1173 | # bare [\x80-\x9F] would also hit the continuation bytes of ordinary | |||||
| 1174 | # characters such as the Euro sign (E2 82 AC) | |||||
| 1175 | 17082 | 13567 | if(utf8::is_utf8($name)) { | |||
| 1176 | 0 | 0 | $name =~ s/[\x{80}-\x{9F}]/_/g; | |||
| 1177 | } else { | |||||
| 1178 | 17082 | 11068 | $name =~ s/\xC2[\x80-\x9F]/_/g; | |||
| 1179 | } | |||||
| 1180 | 17082 | 11418 | $name =~ s/\A\s+//; | |||
| 1181 | 17082 | 11414 | $name =~ s/[\s.]+\z//; | |||
| 1182 | ||||||
| 1183 | # Avoid hidden files, Windows device names and empty names | |||||
| 1184 | 17082 | 10166 | $name =~ s/\A\./_/; | |||
| 1185 | 17082 | 21476 | $name = "_$name" if $name =~ $RESERVED_NAME_RE; | |||
| 1186 | 17082 | 12448 | $name = $UNNAMED unless length $name; | |||
| 1187 | ||||||
| 1188 | # Find the smallest free suffix (_2, _3, ...). Start from where this | |||||
| 1189 | # name's last search ended rather than from 2: every suffix below that | |||||
| 1190 | # point was taken then and is still taken (names are never released | |||||
| 1191 | # during a run), so the answer is the same, but N tables with the same | |||||
| 1192 | # name cost O(N) in total instead of O(N squared). | |||||
| 1193 | 17082 | 10634 | my $used = $self->{used_names}; | |||
| 1194 | 17082 | 10618 | my $base = lc $name; | |||
| 1195 | 17082 | 10677 | my $file = $name . $CSV_SUFFIX; | |||
| 1196 | 17082 | 13926 | if(exists $used->{lc $file}) { | |||
| 1197 | 11697 | 10671 | my $n = $self->{next_suffix}{$base} // 2; | |||
| 1198 | 11697 | 11335 | $n++ while exists $used->{lc "${name}_$n$CSV_SUFFIX"}; | |||
| 1199 | 11697 | 7142 | $file = "${name}_$n$CSV_SUFFIX"; | |||
| 1200 | 11697 | 8675 | $self->{next_suffix}{$base} = $n + 1; | |||
| 1201 | } | |||||
| 1202 | 17082 | 14944 | $used->{lc $file} = 1; | |||
| 1203 | ||||||
| 1204 | 17082 | 26804 | return $file; | |||
| 1205 | 34 34 34 | 9057 29 289 | } | |||
| 1206 | ||||||
| 1207 | # _dry_run | |||||
| 1208 | # Purpose: Print the table -> file mapping without writing anything. | |||||
| 1209 | # Entry Criteria: $tables is the arrayref of selected tables. | |||||
| 1210 | # Exit Status: Returns $self. | |||||
| 1211 | # Side Effects: Prints to STDOUT; runs mdb-count when show_counts is on. | |||||
| 1212 | sub _dry_run :Private { | |||||
| 1213 | 66 | 1437 | my ($self, $database, $tables) = @_; | |||
| 1214 | ||||||
| 1215 | # Only show the ROWS column when counts are actually available | |||||
| 1216 | 66 | 197 | my $counts = $self->{show_counts}; | |||
| 1217 | 66 | 451 | my $format = $counts | |||
| 1218 | ? "%-${TABLE_COLUMN_WIDTH}s %${ROWS_COLUMN_WIDTH}s %s\n" | |||||
| 1219 | : "%-${TABLE_COLUMN_WIDTH}s %s\n"; | |||||
| 1220 | 66 146 | 247 5694 | my @header = map { $self->i18n($_) } ($counts ? qw(column_table column_rows column_output) : qw(column_table column_output)); | |||
| 1221 | ||||||
| 1222 | # Underline the title to the length of whatever the translation is | |||||
| 1223 | 66 | 3787 | my $title = $self->i18n('dry_run_title'); | |||
| 1224 | 66 | 5319 | print "\n$title\n", '=' x length($title), "\n\n"; | |||
| 1225 | 66 | 343 | printf $format, @header; | |||
| 1226 | 66 | 251 | print '-' x $RULE_WIDTH, "\n"; | |||
| 1227 | ||||||
| 1228 | 66 66 | 96 137 | foreach my $table (@{$tables}) { | |||
| 1229 | 89 | 154 | my @row = ($table); | |||
| 1230 | # A count that cannot be had is shown as "?" (with a warning), so a | |||||
| 1231 | # dry run still lists every table and succeeds | |||||
| 1232 | 89 | 224 | push @row, $self->_try_count_rows($database, $table) // $UNKNOWN_COUNT if $counts; | |||
| 1233 | # The table name comes from the database, so show it escaped (the | |||||
| 1234 | # file name is already safe; escaping it too costs nothing) | |||||
| 1235 | 89 | 426 | $row[0] = $self->_printable($row[0]); | |||
| 1236 | 89 | 279 | printf $format, @row, $self->_printable($self->_csv_filename($table)); | |||
| 1237 | } | |||||
| 1238 | 66 | 235 | print "\n"; | |||
| 1239 | ||||||
| 1240 | 66 | 140 | return $self; | |||
| 1241 | 34 34 34 | 7111 32 304 | } | |||
| 1242 | ||||||
| 1243 | # _warn | |||||
| 1244 | # Purpose: Warn the user and record the same text in the log. | |||||
| 1245 | # Entry Criteria: $key is a catalog key; $args an optional i18n() hashref. | |||||
| 1246 | # Exit Status: Returns $self. | |||||
| 1247 | # Side Effects: carp()s; logs at warn level. | |||||
| 1248 | sub _warn :Private { | |||||
| 1249 | 113 | 1318 | my ($self, $key, $args) = @_; | |||
| 1250 | ||||||
| 1251 | 113 | 483 | $self->_carp_i18n($key, $args); | |||
| 1252 | 113 | 344 | return $self->_log(warn => $key, $args); | |||
| 1253 | 34 34 34 | 3670 30 224 | } | |||
| 1254 | ||||||
| 1255 | # _log | |||||
| 1256 | # Purpose: Send a localised message to the logger, if there is one. | |||||
| 1257 | # Entry Criteria: $level is a logger method name (debug, info or warn). | |||||
| 1258 | # Exit Status: Returns $self. | |||||
| 1259 | # Side Effects: Calls $self->{logger}->$level(). | |||||
| 1260 | sub _log :Private { | |||||
| 1261 | 1613 | 12740 | my ($self, $level, $key, $args) = @_; | |||
| 1262 | ||||||
| 1263 | 1613 | 2891 | my $logger = $self->{logger} or return $self; | |||
| 1264 | ||||||
| 1265 | # Logging is secondary: a logger that dies (full disk, closed socket) | |||||
| 1266 | # must not make a finished export look failed. Say so once and stop | |||||
| 1267 | # using it. | |||||
| 1268 | 154 | 173 | local $@; | |||
| 1269 | # Escaped, so a hostile table name cannot forge log lines (CR, LF) | |||||
| 1270 | # or send escape sequences to whoever views the log | |||||
| 1271 | 154 154 149 | 192 709 20787 | if(!eval { $logger->$level($self->_printable($self->i18n($key, $args))); 1 }) { | |||
| 1272 | 5 | 24 | my $error = $@; | |||
| 1273 | 5 | 11 | chomp $error; | |||
| 1274 | 5 | 13 | delete $self->{logger}; | |||
| 1275 | 5 | 16 | $self->_carp_i18n('log_failed', { params => [$error] }); | |||
| 1276 | } | |||||
| 1277 | 154 | 261 | return $self; | |||
| 1278 | 34 34 34 | 4740 24 235 | } | |||
| 1279 | ||||||
| 1280 | # _validation_reason | |||||
| 1281 | # Purpose: Reduce a Params::Validate::Strict error to its meaning, | |||||
| 1282 | # e.g. "Parameter 'encoding' (latin1) must be one of utf8, | |||||
| 1283 | # utf8-bom, cp1252", dropping the module's own prefix | |||||
| 1284 | # ("Params::Validate::Strict line N: validate_strict: ") | |||||
| 1285 | # and the Perl location it appends. | |||||
| 1286 | # Entry Criteria: $error is the exception from validate_strict. | |||||
| 1287 | # Exit Status: Returns a one-line string. | |||||
| 1288 | # Side Effects: None. A plain function, not a method. | |||||
| 1289 | sub _validation_reason :Private { | |||||
| 1290 | 92 | 46189 | my $error = shift; | |||
| 1291 | ||||||
| 1292 | 92 | 108 | my $reason = "$error"; | |||
| 1293 | 92 | 247 | $reason =~ s/\AParams::Validate::Strict line \d+: //; | |||
| 1294 | 92 | 145 | $reason =~ s/\Avalidate_strict: //; | |||
| 1295 | 92 | 267 | $reason =~ s/ at \S+ line \d+\.?\n?\z//; | |||
| 1296 | 92 | 96 | chomp $reason; | |||
| 1297 | 92 | 288 | return $reason; | |||
| 1298 | 34 34 34 | 4788 57 248 | } | |||
| 1299 | ||||||
| 1300 | # _untaint | |||||
| 1301 | # Purpose: Mark a value that has already been validated as safe for | |||||
| 1302 | # taint mode. | |||||
| 1303 | # Entry Criteria: $value has been checked by the caller (see each call). | |||||
| 1304 | # Exit Status: Returns the untainted value; returns $value unchanged if | |||||
| 1305 | # it is empty or contains a NUL (it will then fail safely | |||||
| 1306 | # at the operating system, still tainted). | |||||
| 1307 | # Side Effects: None. A plain function, not a method. | |||||
| 1308 | sub _untaint :Private { | |||||
| 1309 | 34244 | 117132 | my $value = shift; | |||
| 1310 | ||||||
| 1311 | 34244 | 73155 | return ($value // '') =~ $UNTAINT_RE ? $1 : $value; | |||
| 1312 | 34 34 34 | 3479 25 240 | } | |||
| 1313 | ||||||
| 1314 | # _os_error | |||||
| 1315 | # Purpose: Get a readable OS error from an autodie exception or string. | |||||
| 1316 | # Entry Criteria: $error is whatever eval left in $@. | |||||
| 1317 | # Exit Status: Returns a string. | |||||
| 1318 | # Side Effects: None. A plain function, not a method. | |||||
| 1319 | sub _os_error :Private { | |||||
| 1320 | 17 | 6281 | my $error = shift; | |||
| 1321 | ||||||
| 1322 | # autodie::exception keeps the original $! for us. blessed(), not | |||||
| 1323 | # ref(): asking an unblessed reference ->can() would die and hide | |||||
| 1324 | # the real error. | |||||
| 1325 | 17 | 191 | my $text = (blessed($error) && $error->can('errno')) ? $error->errno() : "$error"; | |||
| 1326 | 17 | 50 | chomp $text; | |||
| 1327 | 17 | 78 | return $text; | |||
| 1328 | 34 34 34 | 3709 29 242 | } | |||
| 1329 | ||||||
| 1330 | 1; | |||||
| 1331 | ||||||