File Coverage

File:blib/lib/App/Access2CSV.pm
Coverage:97.4%

linestmtbrancondsubtimecode
1package App::Access2CSV;
2
3
40
40
40
2347728
33
545
use strict;
4
40
40
40
61
27
754
use warnings;
5
40
40
40
2278
88905
96
use autodie qw(:all);
6
7# Sub::Private must be in enforce mode before it is loaded, so that
8# private class methods still work through $class->method dispatch
9
40
169343
BEGIN { $Sub::Private::config{mode} = 'enforce' }
10
11# Inherit i18n() and the protected _croak_i18n/_carp_i18n helpers
12
40
40
40
97
49
82
use parent 'App::Access2CSV::I18N';
13
14
34
32
32
10198
45
584
use App::Access2CSV::Exporter;
15
32
32
32
72
26
733
use Fcntl qw(O_APPEND O_CREAT O_WRONLY);
16
32
32
32
55
22
807
use File::Temp;
17
32
32
32
10293
146397
59
use Getopt::Long qw(GetOptionsFromArray);
18
32
31
31
11043
727915
601
use Log::Abstraction;
19
31
31
31
7216
576007
1101
use Pod::Usage qw(pod2usage);
20
31
31
31
91
30
628
use Readonly;
21
31
31
31
59
28
465
use Return::Set qw(set_return);
22
31
31
31
57
19
406
use Scalar::Util qw(blessed);
23
31
31
31
51
28
101
use Sub::Private;
24
25our $VERSION = '0.001.0';
26
27# ---------------------------------------------------------------------
28# Roadmap (from the pre-release gap analysis)
29#
30# Features
31# TODO: Pass mdbtools' CSV options through: --delimiter, --quote,
32#       --date-format, --no-header, binary column handling (mdb-export's
33#       -d, -q, -D, -H and -b).
34# TODO: --schema: write each table's definition next to its data, using
35#       mdb-schema.
36# TODO: Export one table to standard output (--table X --output -), for
37#       use in pipes.
38# TODO: --gzip, or a single ZIP file holding all the CSV files.
39# TODO: More output formats: JSON Lines, and SQL INSERT statements
40#       (mdb-export -I).
41# TODO: --jobs N: export several tables at once.  Starting one mdbtools
42#       process per table is the main cost of a run.
43# TODO: A second back end using ODBC (DBD::ODBC with the Microsoft Access
44#       driver), so Windows does not need mdbtools.
45# TODO: Ship at least one real translation (e.g. German) to prove the
46#       message catalog end to end.
47# TODO: Read settings from a configuration file via Object::Configure;
48#       %DEFAULTS is already laid out for it.
49# TODO: Optionally exit 130 on Ctrl-C (the shell convention), not 3.
50#
51# Technical debt
52# TODO: Sub::Private/Sub::Protected hide the real subs from Devel::Cover
53#       (worked around by the CHECK block in I18N.pm) and refuse
54#       Test::Mockingbird hooks unless $Sub::*::BYPASS is set.  Fix both in
55#       Sub::Private/Sub::Protected, then drop the workaround.
56# TODO: Remove the IO::Handle::flush workaround in t/edge_cases.t once the
57#       Test::Mockingbird fix for mocking inherited methods is released.
58# TODO: IPC::Run3 does not reveal the child's process ID, so after SIGTERM
59#       the running mdb-export is not stopped.  Moving to IPC::Run or
60#       IPC::Open3 would allow that, and streaming for --jobs.
61# TODO: The log symlink check and Log::Abstraction's own open are two
62#       steps; closing that gap needs Log::Abstraction to accept an open
63#       filehandle.
64# TODO: I18N.pm also holds helpers unrelated to messages (_printable,
65#       _interrupt_signals, the coverage CHECK block); move them to a small
66#       shared module, and remove the duplicated control-character patterns
67#       (I18N.pm and Exporter.pm) at the same time.
68# TODO: The test files repeat the same helpers (slurp, new_database, cli,
69#       stand-in set-up); a shared t/lib helper module would shrink them and
70#       remove most Windows skips in one place.
71# TODO: Tests read exporter internals ($e->{show_counts}, {used_names});
72#       small read-only accessors would make refactoring safer.
73# ---------------------------------------------------------------------
74
75# Stop Carp from reporting errors against the access-control wrappers
76our @CARP_NOT = qw(Sub::Private Sub::Protected App::Access2CSV::I18N);
77
78# Exit statuses, documented in the POD below
79Readonly::Scalar my $EXIT_OK      => 0;
80Readonly::Scalar my $EXIT_FAILURE => 1;
81Readonly::Scalar my $EXIT_USAGE   => 2;
82Readonly::Scalar my $EXIT_FATAL   => 3;
83
84# Pod::Usage verbosity levels for --help and --man, and for usage errors
85Readonly::Scalar my $POD_SYNOPSIS => 0;
86Readonly::Scalar my $POD_OPTIONS  => 1;
87Readonly::Scalar my $POD_FULL     => 2;
88
89# The database name that means "read it from standard input"
90Readonly::Scalar my $STDIN_NAME => '-';
91
92# Standard input is copied, in chunks of this many bytes, to a private
93# temporary file named from this template (mdbtools need a real file)
94Readonly::Scalar my $READ_CHUNK     => 65_536;
95Readonly::Scalar my $STDIN_TEMPLATE => 'access2csv-stdin-XXXXXX';
96
97# Log levels: --verbose adds the debug messages
98Readonly::Scalar my $LOG_LEVEL         => 'info';
99Readonly::Scalar my $LOG_LEVEL_VERBOSE => 'debug';
100
101# Command-line defaults; the flat layout is compatible with Object::Configure
102Readonly::Hash my %DEFAULTS => (
103        output_dir  => '.',
104        overwrite   => 0,
105        verbose     => 0,
106        dry_run     => 0,
107        show_counts => 0,
108        progress    => 1,
109        encoding    => 'utf8',
110        log         => 'access2csv.log',
111);
112
113=encoding utf8
114
115 - 613
=head1 NAME

App::Access2CSV - Export the tables of a Microsoft Access database to CSV files

=head1 VERSION

Version 0.001.0

=head1 SYNOPSIS

        # Export every table to the current directory
        access2csv shop.accdb

        # See what would be written, with row counts, without writing anything
        access2csv --dry-run --show-counts shop.accdb

        # Export only two tables, into a folder called "exports"
        access2csv --output-dir exports --table Customers --table Orders shop.mdb

        # Make files that Excel opens correctly, replace old files, no log file
        access2csv --encoding utf8-bom --overwrite --no-log shop.accdb

        # Read the database from standard input ("-"), e.g. from a download
        curl -s https://example.com/shop.accdb | access2csv --output-dir exports -

        # Nightly job: quiet, with a log in a fixed place, and stop on failure
        access2csv --no-progress --log /var/log/access2csv.log \
                --output-dir /srv/exports --overwrite shop.accdb || exit 1

=head1 DESCRIPTION

Microsoft Access keeps its data in C<.mdb> or C<.accdb> files.
C<access2csv> reads one of these files and writes one CSV file
(comma-separated values, a plain-text table) for each table in it.

It does not read the Access file itself.  It runs three small programs
from the free B<mdbtools> package:

=over 4

=item * C<mdb-tables> - to get the list of tables

=item * C<mdb-export> - to get the data of each table as CSV

=item * C<mdb-count> - to count rows (only when you use B<--show-counts>)

=back

These programs must be installed and must be in your C<PATH>.

Access also keeps its own internal tables in the file.  Their names start
with C<MSys>, C<USys> or C<~>.  They are skipped.

=head2 How the CSV files are named

Each file has the name of its table plus C<.csv>, for example
C<Orders.csv>.  Some characters are not allowed in file names on some
computers (C<< < > : " / \ | ? * >> and control characters).  They are
changed to C<_>, and so are invisible text-direction controls (such as
U+202E, "right-to-left override"), which could make a file name look
like something else.  Spaces and dots at the end, and spaces at the start,
are removed.  A name such as C<CON> or C<NUL> (reserved on Windows) gets a
C<_> in front.  An empty name becomes C<unnamed>.

If two tables would get the same file name, the second one gets C<_2>,
the third C<_3>, and so on.  Upper and lower case count as the same here,
because Windows and macOS treat C<Orders.csv> and C<ORDERS.csv> as one file.

=head2 Reading the database from standard input

If the database name is C<->, the database is read from standard input
instead of a file, so it can be piped in.  mdbtools can only read a real
file, so the data is first copied to a private temporary file (readable
by you only) in the temporary folder (C<TMPDIR>, or F</tmp>), and that
copy is deleted when the program ends - whether it succeeds, fails or is
interrupted.

C<-> is refused if standard input is a terminal (there is nothing to
read but the keyboard), and empty input is an error.  To use a file that
is really called C<->, write F<./->.

=head2 How files are written

Each file is first written to a hidden temporary file (its name starts
with C<.access2csv->) in the output directory.  Only when it is complete
is it renamed to its real name.  So if something goes wrong, you never
get a half-written CSV file, and an old file is only replaced by a
complete new one.

=head1 REQUIREMENTS

The mdbtools programs C<mdb-tables> and C<mdb-export> must be installed
and in your C<PATH>; C<mdb-count> is needed only for B<--show-counts>.
They are not Perl modules, so the CPAN installer cannot install them for
you.  Install them with your system's package manager, for example:

        sudo apt install mdbtools       # Debian, Ubuntu
        sudo dnf install mdbtools       # Fedora
        brew install mdbtools           # macOS (Homebrew)
        pacman -S mingw-w64-x86_64-mdbtools   # Windows (MSYS2)

Without them the program stops with "Required program not found in
PATH".  Project home: L<https://github.com/mdbtools/mdbtools>.

=head1 USING FROM PERL

The program is a very thin wrapper.  You can call the same code from Perl:

        use App::Access2CSV;

        my $status = App::Access2CSV->run('--no-log', '--output-dir', 'out', 'shop.accdb');

For more control, use L<App::Access2CSV::Exporter> directly.

=head1 OPTIONS

=over 4

=item B<--output-dir> I<DIR>

The folder to write the CSV files to.  It is created if it does not exist.
Default: the current folder.

=item B<--table> I<NAME>

Export only this table.  You can use this option more than once.
Names must match exactly, including upper and lower case.  A name that is
not in the database gives a warning.

=item B<--overwrite>

Replace CSV files that already exist.  Without this option, a table whose
CSV file already exists is not exported, and it counts as a failure.

=item B<--verbose>

Write more detail to the log (where each mdbtools program was found).
Also show the Perl file and line number in fatal error messages.

=item B<--dry-run>

Only print a list of the tables and the file names they would get.
Nothing is written.  The output folder is not created.

=item B<--show-counts>

Show the number of rows of each table: in the dry-run list, and in the
log.  This needs C<mdb-count>.  Without it you get a warning, and the
export goes on without counts.

=item B<--no-progress>

Do not print the C<[1/5] Customers> progress lines.  (These lines go to
standard error, not standard output.)

=item B<--encoding> I<utf8|utf8-bom|cp1252>

The character encoding of the CSV files.  See L</ENCODING>.
Default: C<utf8>.

=item B<--log> I<FILE>

Add log messages to the end of I<FILE>.  Default: F<access2csv.log> in
the current folder.  An empty name (C<--log ''>) means no log.  I<FILE>
must not be a symbolic link (see L</SECURITY>).

=item B<--no-log>

Do not write a log file.

=item B<--help>, B<-h>

Print the synopsis and the options, then stop.

=item B<--man>

Print this whole manual, then stop.

=item B<--version>

Print the version ("access2csv version 0.001.0"), then stop.

=back

=head1 EXIT STATUS

The program ends with one of these numbers.  Scripts can test it.

        0  Every selected table was exported.  Also used for --dry-run,
           --help, --man and --version.
        1  At least one table was not exported.  The other tables were.
        2  The command line was wrong, for example an unknown option, an
           invalid value (--encoding latin1), no database name, or "-" while
           standard input is a terminal.  Nothing has been done.
        3  A fatal error happened before any table was exported, for example
           the database does not exist or mdbtools is not installed.

=head1 ENCODING

=head2 The data in the CSV files

mdbtools gives the table data as UTF-8, the encoding that can hold every
character, including accented letters, Chinese and Japanese text, and
emoji.

=over 4

=item * C<utf8> (the default) - the data is copied exactly as mdbtools
gives it.  Every character, including emoji, is kept.

=item * C<utf8-bom> - the same, plus three bytes at the very start of each
file (a "byte order mark").  These bytes tell Microsoft Excel that the file
is UTF-8.  Without them, Excel may show accented letters wrongly.  Some
other programs show the mark as strange characters in the first column
name.

=item * C<cp1252> - Windows-1252, an old Western European encoding.  It has
only 256 characters: English letters, most Western European accented
letters, and a few symbols such as the Euro sign.  It has no Greek,
Cyrillic, Chinese, Japanese or emoji.  If a table contains a character
that Windows-1252 cannot hold, that table is B<not> exported, and the
error message gives the line number.  Nothing is silently replaced.

=back

=head2 Names on the command line

Database paths, folder names, log file names and table names are used
exactly as the operating system gives them to the program (as bytes).
On Linux and macOS, where the terminal uses UTF-8, names with accented
letters, non-Latin scripts and emoji work.  On Windows, the command line
uses the system code page, so names outside that code page may not work.

=head2 Messages

All messages that the program prints and logs are in plain ASCII English.

=head1 ENVIRONMENT

=over 4

=item C<PATH>

Used to find C<mdb-tables>, C<mdb-export> and C<mdb-count>.  Only
absolute folders in C<PATH> are used: relative entries such as C<.> are
ignored, so a program planted in the current folder is never run.

=item C<LANGUAGE>, C<LC_ALL>, C<LC_MESSAGES>, C<LANG>

Choose the language of messages (see L<App::Access2CSV::I18N>).  Only the
language code at the start is used; any other value means English.

=item C<TMPDIR>

Where a database read from standard input (C<->) is copied while it is
exported.  Default: F</tmp>.

=item C<MDB_ICONV>

Not read by this program, but by mdbtools: it sets the character set
mdbtools converts to.  Leave it unset, so that the output is UTF-8.

=back

=head1 SECURITY

The program treats the database as untrusted: an Access file received
from someone else may contain table names and data designed to cause
harm.

=over 4

=item * B<No shell, no option injection.>  Programs are run directly
(never through a shell), and table and file names are passed after a
C<--> marker, so names containing C<; | $( ) `> or starting with C<->
are only ever names.

=item * B<No planted programs.>  Relative C<PATH> entries are ignored
(see L</ENVIRONMENT>).  The mdbtools programs are started with a cleaned
environment: C<PATH> holds only absolute folders, and C<IFS>, C<CDPATH>,
C<ENV> and C<BASH_ENV> are removed.

=item * B<Taint mode.>  The program runs under Perl's taint mode
(C<perl -T>).  Every outside value - the database path, table names,
C<--output-dir>, C<--log> and the program paths found in C<PATH> - is
checked first and only then marked as safe.  Under C<-T>, Perl also
refuses to start mdbtools while C<PATH> contains a folder other users can
write to; the program then stops with "Insecure directory in
$ENV{PATH}".

=item * B<Private copies of piped input.>  A database read from standard
input is copied with L<File::Temp> (a new, unpredictable name, readable
by you only) and deleted when the program ends, also after an error or
an interruption.

=item * B<Safe file names.>  Table names cannot place a file outside the
output folder, and control characters - including invisible
text-direction controls and C1 controls - are replaced by C<_>.

=item * B<Safe terminal and log output.>  Table names and mdbtools error
text are printed with control characters shown as escapes such as
C<\x1B>.  So a table name cannot retitle or clear your terminal, hide
text, or forge lines in the log.

=item * B<No writing through symbolic links.>  If the log file is a
symbolic link (for example one planted in a shared folder such as
F</tmp>), the program stops instead of writing to the file it points at.
Existing CSV files that are links are replaced, never written through.

=item * B<Spreadsheet formulas are NOT neutralised.>  A value such as
C<=cmd|' /C calc'!A0> is copied into the CSV exactly as it is in the
database, because changing data would corrupt genuine values.  Some
spreadsheet programs run such formulas when a CSV is opened.  Do not open
CSV files exported from an untrusted database in a spreadsheet without
checking them, or import them as text.

=back

=head1 COMMON PITFALLS

=over 4

=item * B<A log file appears in the current folder.>  By default the log is
F<access2csv.log> in the folder you run the program from.  Use B<--log> to
choose another place, or B<--no-log>.

=item * B<The second run fails.>  If the CSV files already exist, each table
fails (exit status 1) unless you give B<--overwrite>.

=item * B<--table does not find my table.>  Table names are case-sensitive:
C<--table orders> does not match C<Orders>.  Use B<--dry-run> to see the
exact names.

=item * B<A file is called Orders_2.csv.>  Two tables had names that give
the same file name (for example C<Orders> and C<ORDERS>, or C<A/B> and
C<A:B>).

=item * B<Progress lines appear even though I redirected the output.>
Progress lines go to standard error.  Use B<--no-progress>, or redirect
standard error too (C<2E<gt>/dev/null>).

=item * B<run() does not end the program.>  When calling from Perl,
C<< App::Access2CSV->run(...) >> returns the exit status.  It does not
call C<exit>.  Write C<< exit App::Access2CSV->run(@ARGV) >> if you want
the program to end.

=item * B<Pass a list, not an array reference.>  Write
C<< App::Access2CSV->run(@args) >>, not C<< App::Access2CSV->run(\@args) >>.

=item * B<A file called "-".>  C<-> means standard input, even after
C<-->.  Write F<./-> for a file with that name.

=item * B<Piped databases need temporary space.>  The whole database is
copied to the temporary folder first; if that folder is small, set
C<TMPDIR> to one with room.

=back

=head1 METHODS

=head2 run

=head3 Purpose

This is the whole C<access2csv> program.  It reads the command-line
options, opens the log, and runs an L<App::Access2CSV::Exporter>.

=head3 Arguments

The command-line arguments, as a list of strings (normally C<@ARGV>).
Your array is copied first, so it is not changed.

=head3 Returns

A number from 0 to 3, as described in L</EXIT STATUS>.
C<run> never calls C<exit> itself.

=head3 Side Effects

=over 4

=item * Everything that L<App::Access2CSV::Exporter/run> does: it creates
the output folder, writes CSV files, and prints progress to standard error.

=item * It prints help or usage text (help to standard output, usage errors
to standard error).

=item * It prints a fatal error, if there is one, to standard error as one
line that starts with C<access2csv:>.

=item * It creates or adds to the log file, unless logging is off.

=item * It leaves your C<$@>, C<$!>, C<$_> and any pending C<alarm> as
they were.

=back

=head3 Usage

        exit App::Access2CSV->run(@ARGV);

=head3 EXAMPLE

        use App::Access2CSV;

        # Export to ./out without a log file, then check what happened
        my $status = App::Access2CSV->run('--output-dir', 'out', '--no-log', 'shop.accdb');

        if($status == 0) {
                print "All tables were exported\n";
        } elsif($status == 1) {
                print "Some tables could not be exported\n";
        } elsif($status == 2) {
                print "The arguments were wrong\n";
        } else {
                print "Nothing was exported\n";
        }

=head3 API SPECIFICATION

=head4 Input

        {
                argv => {
                        type         => 'arrayref',
                        optional     => 1,
                        element_type => 'string',
                        description  => 'Command-line arguments, passed as a list',
                },
        }

Valid and invalid values (tested in F<t/domain.t>):

        database names  exactly 1; 0 or 2 or more give exit status 2.
                        "-" means standard input (exit 2 if it is a
                        terminal, 3 if it is empty or unreadable)
        --encoding      utf8, utf8-bom or cp1252; anything else gives exit 2
        --table         0 times (all tables), once, or many times; names
                        may be non-ASCII
        --log           a file name; '' means no log, like --no-log

=head4 Output

        {
                type => 'integer',
                min  => 0,
                max  => 3,
        }

=head3 MESSAGES

        +-------------------------------------+-------------------------------+------------------------------+
        | Message                             | Meaning                       | What to do                   |
        +-------------------------------------+-------------------------------+------------------------------+
        | Unknown option: X (exit 2)          | X is not an option of this    | See --help                   |
        |                                     | program                       |                              |
        | Option X requires an argument       | An option such as --log was   | Give a value after it        |
        |  (exit 2)                           | the last word                 |                              |
        | Invalid setting: REASON (exit 2)    | An option value is not        | Use a documented value (see  |
        |                                     | allowed, e.g. --encoding      | OPTIONS)                     |
        |                                     | latin1; REASON says which     |                              |
        | Missing database filename (exit 2)  | No database name was given,   | Give exactly one database    |
        |                                     | it was empty, or more than    |                              |
        |                                     | one was given                 |                              |
        | Standard input is a terminal: pipe  | "-" was given, but nothing is | Pipe the database in, or     |
        |  the database in, or give its file  | piped in                      | give its file name           |
        |  name (exit 2)                      |                               |                              |
        | access2csv: Standard input is empty:| "-" was given, but the pipe   | Check the command that       |
        |  no database was piped in (exit 3)  | delivered nothing             | produces the database        |
        | access2csv: Cannot read standard    | Reading the pipe failed; E is | See E                        |
        |  input: E (exit 3)                  | the reason                    |                              |
        | access2csv: Interrupted by SIGx     | Stopped (Ctrl-C, kill) while  | Run again                    |
        |  while reading the database from    | waiting for piped input; the  |                              |
        |  standard input (exit 3)            | partial copy was deleted      |                              |
        | access2csv: Cannot open log file F: | The log file cannot be        | Use --log with another file, |
        |  E (exit 3)                         | written; E is the reason from | or --no-log                  |
        |                                     | the operating system, "no     |                              |
        |                                     | logger was created", or "it   |                              |
        |                                     | is a symbolic link"           |                              |
        | access2csv: MESSAGE (exit 3)        | Any fatal error from the      | See MESSAGES in              |
        |                                     | exporter                      | App::Access2CSV::Exporter    |
        +-------------------------------------+-------------------------------+------------------------------+

=head3 PSEUDOCODE

        options := default settings
        read the command line into options
        if the command line is wrong: print usage, return 2
        if --help or --man: print the documentation, return 0
        if there is not exactly one database name: print usage, return 2
        try:
                open the log, unless logging is off
                status := new Exporter(options).run(database)
        if that failed:
                print "access2csv: <reason>" to standard error
                status := 3
        return status

=cut
614
615sub run {
616
190
12305528
        my ($class, @argv) = @_;
617
618        # Option parsing, file tests and the eval below would otherwise leave
619        # their marks in the caller's $@ and $!
620
190
2269
        local ($@, $!);
621
622        # Parsing may already decide the outcome (--help, bad options, ...)
623
190
616
        my %opt = %DEFAULTS;
624
190
6874
        my $status = $class->_parse_options(\@argv, \%opt);
625
626        # CHECKING SETTINGS: a bad option value (e.g. --encoding latin1) is a
627        # command-line mistake like any other: a usage error (exit 2), found
628        # before anything with a side effect happens - before standard input
629        # is copied and before the log file is created
630
190
1512
1702
517
1538
1249
        my %settings = map { $_ => $opt{$_} } grep { $_ ne 'log' } keys %opt;
631
190
142
136
538
724
9129
        if(!defined($status) && !eval { App::Access2CSV::Exporter->new(%settings); 1 }) {
632
6
945
                $status = $class->_usage($EXIT_USAGE, $POD_SYNOPSIS, _strip_location($@));
633        }
634
635
190
320
        if(!defined $status) {
636                # Any croak from here on is a fatal error: report it, don't die
637
136
126
                $status = eval {
638                        # READING STDIN: "-" means standard input.  mdbtools can only
639                        # read a real file, so the data is copied to a private temporary
640                        # file first; the copy is deleted when $piped goes out of scope,
641                        # whatever happens
642
136
217
                        my $piped = ($argv[0] eq $STDIN_NAME) ? $class->_read_stdin() : undef;
643
125
188
                        my $database = $piped ? $piped->filename() : $argv[0];
644
645                        # OPENING LOG, then EXPORTING
646
125
384
                        my $logger = $class->_make_logger(\%opt);
647
111
340
                        App::Access2CSV::Exporter->new(%settings, ($logger ? (logger => $logger) : ()))->run($database);
648                };
649
136
30902
                $status = $class->_report_fatal($@, $opt{verbose}) unless defined $status;
650        }
651
652
190
1074
        return set_return($status, { type => 'integer', min => $EXIT_OK, max => $EXIT_FATAL });
653}
654
655# _parse_options
656# Purpose:        Turn the command line into settings.
657# Entry Criteria: $argv is an arrayref (modified in place: options are
658#                 removed, leaving the positional arguments); $opt is a
659#                 hashref of defaults.
660# Exit Status:    Returns undef if the export should go ahead, otherwise
661#                 the exit status to return straight away.
662# Side Effects:   Fills in $opt; prints help, the manual or usage text.
663sub _parse_options :Private {
664
200
15650
        my ($class, $argv, $opt) = @_;
665
666
200
238
        my ($help, $show_version) = (0, 0);
667        my $parsed = GetOptionsFromArray(
668                $argv,
669                'output-dir=s' => \$opt->{output_dir},
670                'table=s@'     => \$opt->{tables},
671                'overwrite!'   => \$opt->{overwrite},
672                'verbose!'     => \$opt->{verbose},
673                'dry-run!'     => \$opt->{dry_run},
674                'show-counts!' => \$opt->{show_counts},
675                'progress!'    => \$opt->{progress},
676                'encoding=s'   => \$opt->{encoding},
677                'log=s'        => \$opt->{log},
678
111
56300
                'no-log'       => sub { $opt->{log} = undef },
679
17
8049
                'help|h'       => sub { $help = $POD_OPTIONS },
680
5
2146
                'man'          => sub { $help = $POD_FULL },
681
200
2228
                'version'      => \$show_version,
682        );
683
684        # Only one of these applies; the first match decides the exit status.
685        # Getopt::Long has already warned about any unknown option.
686
200
84532
        return $class->_usage($EXIT_USAGE, $POD_SYNOPSIS) unless $parsed;
687
182
349
        return $class->_usage($EXIT_OK, $help) if $help;
688
163
306
        if($show_version) {
689
1
8
                print $class->i18n('version', { params => [$VERSION] }), "\n";
690
1
81
                return $EXIT_OK;
691        }
692        # An empty or undefined name is as good as no name at all.  length()
693        # of an empty string is 0, so one length test covers "", and "// ''"
694        # turns undef into "" first.
695
162
162
149
744
        if(@{$argv} != 1 || !length($argv->[0] // '')) {
696
23
134
                return $class->_usage($EXIT_USAGE, $POD_SYNOPSIS, $class->i18n('missing_database'));
697        }
698
699        # Reading a database from a terminal would only wait for keystrokes
700
139
348
        if($argv->[0] eq $STDIN_NAME && -t STDIN) {
701
0
0
                return $class->_usage($EXIT_USAGE, $POD_SYNOPSIS, $class->i18n('stdin_is_terminal'));
702        }
703
139
329
        return;
704
31
31
31
15371
25
493
}
705
706# _usage
707# Purpose:        Print documentation from this module's POD.
708# Entry Criteria: $status is the exit status to return; $verbose is a
709#                 Pod::Usage verbosity; $message is an optional error.
710# Exit Status:    Returns $status.
711# Side Effects:   Prints to STDOUT (help) or STDERR (errors).
712sub _usage :Private {
713
57
6693
        my ($class, $status, $verbose, $message) = @_;
714
715        # The POD lives here, not in bin/access2csv, so point Pod::Usage at
716        # this file; NOEXIT keeps run() testable
717
57
346
        pod2usage(
718                -input   => __FILE__,
719                -verbose => $verbose,
720                -exitval => 'NOEXIT',
721                -output  => $status == $EXIT_OK ? \*STDOUT : \*STDERR,
722                (defined($message) ? (-message => $message) : ()),
723        );
724
57
3461608
        return $status;
725
31
31
31
5005
48
244
}
726
727# _make_logger
728# Purpose:        Create the log, unless logging is switched off.
729# Entry Criteria: $opt->{log} is a file name, or undef/'' for no log.
730# Exit Status:    Returns a Log::Abstraction object or undef; croaks if the
731#                 file cannot be appended to.
732# Side Effects:   Creates the log file if it does not exist.
733sub _make_logger :Private {
734
133
8573
        my ($class, $opt) = @_;
735
736
133
472
        return unless length($opt->{log} // '');
737
738
38
51
        my $file = $opt->{log};
739
740        # Never write through a symbolic link: in a shared folder such as /tmp
741        # anyone could plant "access2csv.log" pointing at a file of yours
742
38
356
        if(-l $file) {
743
4
12
                $class->_croak_i18n('log_open_failed', { params => [$file, $class->i18n('log_is_symlink')] });
744        }
745
746        # The log file is the invoking user's own choice and is not a link, so
747        # it is untainted (for taint mode); a NUL byte cannot name a file at all
748
34
159
        ($file) = $file =~ /\A([^\x00]+)\z/s or $class->_croak_i18n('log_open_failed', { params => [$opt->{log}, $class->i18n('invalid_name')] });
749
750        # Log::Abstraction silently ignores an unwritable file, which would lose
751        # the log without telling anyone, so prove that we can append first.
752        # O_NOFOLLOW (where the OS has it) closes the gap between the -l test
753        # above and the open.  The eval must not overwrite the caller's $@.
754
34
32
        local $@;
755
34
80
        eval {
756
34
88
                sysopen my $fh, $file, O_WRONLY | O_APPEND | O_CREAT | _no_follow();
757
24
7431
                close $fh;
758
24
5348
                1;
759        } or $class->_croak_i18n('log_open_failed', { params => [$file, _failure_reason($@)] });
760
761        my $logger = Log::Abstraction->new(
762                logger => $file,
763
24
191
                level  => $opt->{verbose} ? $LOG_LEVEL_VERBOSE : $LOG_LEVEL,
764        );
765
766        # The user asked for a log; carrying on without one would silently
767        # break that promise
768
24
759
        $logger or $class->_croak_i18n('log_open_failed', { params => [$file, $class->i18n('logger_unavailable')] });
769
20
151
        return $logger;
770
31
31
31
6941
38
272
}
771
772# _read_stdin
773# Purpose:        Copy the database piped in on standard input to a
774#                 private temporary file, because mdbtools can only read
775#                 a real, seekable file.
776# Entry Criteria: STDIN is not a terminal (checked by _parse_options).
777# Exit Status:    Returns the File::Temp object; the file is deleted when
778#                 the object is destroyed.  Croaks if the input is empty,
779#                 cannot be read, cannot be stored, or the copy is
780#                 interrupted.
781# Side Effects:   Reads all of STDIN; writes a file (mode 0600) in the
782#                 temporary folder (TMPDIR).
783sub _read_stdin :Private {
784
16
117
        my $class = shift;
785
786        # Perl's default action for INT/TERM/... exits at once, which would
787        # leave the copy behind in the temporary folder.  Raise an exception
788        # instead, so the File::Temp object is destroyed and deletes the file.
789
16
12
        my $interrupted;
790
16
16
17
63
        my @ours = @{ $class->_interrupt_signals() };
791
16
3
3
398
600946
48
        local @SIG{@ours} = (sub { $interrupted = $_[0]; die "\n" }) x @ours;
792
793
16
59
        my $copy = File::Temp->new(TEMPLATE => $STDIN_TEMPLATE, TMPDIR => 1, UNLINK => 1);
794
16
3637
        binmode $copy, ':raw';
795
16
5554
        binmode STDIN, ':raw';
796
797        # Copy in chunks: a large database is never held in memory at once
798
16
318
        my $total = 0;
799
16
16
        local $@;
800        eval {
801
16
47
                while(my $got = read(STDIN, my $buffer, $READ_CHUNK)) {
802
38
38
3040
1345
                        print {$copy} $buffer;
803
38
50
                        $total += $got;
804                }
805
10
1821
                1;
806
16
16
        } or do {
807
6
1640
                $class->_croak_i18n('interrupted_reading', { params => [$interrupted] }) if $interrupted;
808
3
7
                $class->_croak_i18n('stdin_read_failed', { params => [_failure_reason($@)] });
809        };
810
811
10
34
        $class->_croak_i18n('stdin_empty') unless $total;
812
813        # Make sure every byte reached the file (e.g. the disk may be full)
814
5
90
        $copy->flush() or $class->_croak_i18n('write_failed', { params => [$copy->filename(), "$!"] });
815
5
54
        return $copy;
816
31
31
31
6972
27
782
}
817
818# _strip_location
819# Purpose:        Remove the " at FILE line N." that Carp appends, which is
820#                 noise for a command-line user.
821# Entry Criteria: $text is an error message.
822# Exit Status:    Returns the message without the location or newline.
823# Side Effects:   None.  A plain function, not a method.
824#
825# The file name may contain spaces ("My Documents"), so it cannot be
826# matched as \S+.  Instead: " at ", then the shortest run of characters
827# that does not contain another " at ", then " line N." at the very end.
828# The (?! at ) guard keeps this linear: each attempt stops at the next
829# " at ", so no character is scanned by more than one attempt.
830sub _strip_location :Private {
831
85
438
        my $text = shift;
832
833
85
442
        $text =~ s/
834                [ ] at [ ]                  # Carp's separator
835                (?: (?! [ ] at [ ] ) . )*?  # the file name: anything but another " at "
836                [ ] line [ ] \d+ \.?        # " line 42."
837                \n? \z                      # at the very end
838        //x;
839
85
101
        chomp $text;
840
85
236
        return $text;
841
31
31
31
4169
25
241
}
842
843# _failure_reason
844# Purpose:        Explain why an eval failed.  An autodie exception carries
845#                 the operating system's reason; anything else (such as a
846#                 taint-mode "Insecure dependency") is reported as it is,
847#                 not replaced by a stale $! from some earlier call.
848# Entry Criteria: $error is the eval's $@.
849# Exit Status:    Returns a one-line string.
850# Side Effects:   None.  A plain function, not a method.
851sub _failure_reason :Private {
852
16
14616
        my $error = shift;
853
854
16
141
        my $reason = (blessed($error) && $error->can('errno') && length($error->errno())) ? $error->errno() : "$error";
855
16
87
        return _strip_location($reason);
856
31
31
31
3879
24
220
}
857
858# _no_follow
859# Purpose:        The O_NOFOLLOW open flag, or 0 where the OS lacks it.
860# Entry Criteria: None.
861# Exit Status:    Returns an integer flag.
862# Side Effects:   None.
863sub _no_follow :Private {
864
34
188
        return eval { Fcntl::O_NOFOLLOW() } || 0;
865
31
31
31
3024
32
800
}
866
867# _report_fatal
868# Purpose:        Tell the user why the program stopped.
869# Entry Criteria: $error is the exception from eval; $verbose is the
870#                 --verbose flag.
871# Exit Status:    Returns the fatal exit status.
872# Side Effects:   Prints to STDERR.
873sub _report_fatal :Private {
874
70
12614
        my ($class, $error, $verbose) = @_;
875
876        # Carp appends " at FILE line N."; that is noise for a command-line
877        # user, but useful when debugging, so keep it with --verbose
878
70
190
        my $text = length($error // '') ? "$error" : 'Unknown error';
879
70
157
        $text = $verbose ? $text : _strip_location($text);
880
70
111
        chomp $text;
881
882        # The reason may quote a hostile table name or file name: escape it
883
70
218
        print STDERR $class->_printable($class->i18n('fatal', { params => [$text] })), "\n";
884
70
173
        return $EXIT_FATAL;
885
31
31
31
4124
39
1312
}
886
8871;
888