File Coverage

File:blib/lib/Object/Configure.pm
Coverage:93.4%

linestmtbrancondsubtimecode
1package Object::Configure;
2
3
39
39
39
3868064
22
550
use strict;
4
39
39
39
60
30
840
use warnings;
5
6
39
39
39
70
32
951
use Carp;
7
39
39
39
11487
983196
777
use Config::Abstraction 0.38;
8
39
39
39
121
33
500
use File::Spec;
9
39
39
39
10751
965229
665
use Log::Abstraction 0.26;
10
39
39
39
170
38
151
use mro;
11
39
39
39
463
246
633
use Params::Get 0.13;
12
39
39
39
64
29
591
use Readonly;
13
39
39
39
61
36
491
use Return::Set;
14
39
39
39
58
23
860
use List::Util   qw(any);
15
39
39
39
68
18
782
use Scalar::Util qw(blessed weaken);
16
39
39
39
85
26
169
use Time::HiRes  qw(time);
17
39
39
39
9016
102638
1006
use File::stat;
18
39
39
39
113
44
111
use POSIX qw(WNOHANG);
19
20# Avoid magic literals scattered across hot paths and signal handlers.
21# Centralising here makes global search-replace safe and self-documents intent.
22Readonly my $OS_WINDOWS      => 'MSWin32';
23Readonly my $LOGGER_NULL     => 'NULL';
24Readonly my $SIG_DEFAULT     => 'DEFAULT';
25Readonly my $SIG_IGNORE      => 'IGNORE';
26Readonly my $POLL_SLEEP      => 0.1;   # seconds between waitpid polls in disable_hot_reload
27Readonly my $KILL_TIMEOUT    => 5;     # seconds before SIGKILL escalation after SIGTERM
28Readonly my $DEFAULT_INTERVAL => 10;   # default hot-reload poll interval in seconds
29
30# Matches any path segment that is exactly ".." (anchored start, slash, or end).
31# Catches: ../foo  foo/../bar  foo/..  and leading ../
32# Used in two places; kept as a constant so both guards are always in sync.
33Readonly my $RE_PATH_TRAVERSAL => qr{(?:\A|/)\.\.(?:/|\z)};
34
35# Global registry — intentionally package-level so that the END block and
36# signal handlers installed in one call site share state with all others.
37# This is a deliberate singleton design; see LIMITATIONS for the trade-offs.
38our %_object_registry   = ();
39our %_config_watchers   = ();
40our %_config_file_stats = ();
41
42# Memoization caches — keyed by inputs; valid for the process lifetime because
43# Perl @ISA hierarchies and filesystem layouts are stable after module load.
44# Both caches include undef sentinels (use exists, not defined, to check hits).
45our %_chain_cache = ();  # class         => [UNIVERSAL, ..., Class] (base-first)
46our %_find_cache  = ();  # "\0"-joined key => file path or undef
47
48# Saved before we install our SIGUSR1 handler so we can chain and restore it.
49our $_original_usr1_handler;
50
51 - 59
=head1 NAME

Object::Configure - Runtime Configuration for an Object

=head1 VERSION

0.24

=cut
60
61our $VERSION = 0.24;
62
63 - 545
=head1 DESCRIPTION

C<Object::Configure> injects runtime configuration and logging into Perl class
constructors.  It is a thin layer on top of L<Config::Abstraction> (reads config
files and environment variables) and L<Log::Abstraction> (logging).

Call C<configure($class, \%params)> at the start of your C<new()> method.  It:

=over 4

=item 1. Walks C<@ISA> and finds config files for every class in the inheritance chain.

=item 2. Merges those files, then overlays environment variables named C<ClassName__key>.

=item 3. Creates a L<Log::Abstraction> logger and stores it in C<$params-E<gt>{logger}>.

=item 4. Returns a hashref ready to pass to C<bless>.

=back

The module also provides optional hot-reload support: a background process watches
config files and sends C<SIGUSR1> to trigger an in-place update of registered objects
without restarting the application.

B<Hot reload is not supported on Windows> (C<SIGUSR1> does not exist there).

=head1 SYNOPSIS

=head2 Example 1: Add configurable logging to your own class

    package My::Module;
    use Object::Configure;

    sub new {
        my ($class, %args) = @_;

        # configure() reads config files + env vars, sets up a logger,
        # and returns a hashref ready to bless.
        my $params = Object::Configure::configure($class, \%args);

        return bless $params, $class;
    }

    sub do_work {
        my $self = shift;
        $self->{logger}->info('Starting do_work');
        # ...
    }

    # Usage -- reads ~/.conf/my-module.yml if it exists:
    my $obj = My::Module->new(config_file => '/etc/myapp/my-module.yml');
    $obj->do_work;

=head2 Example 2: Configure a third-party class you cannot modify

    use Object::Configure;

    # Wrap LWP::UserAgent so it reads its settings from a YAML file.
    my $ua = Object::Configure::instantiate(
        class       => 'LWP::UserAgent',
        config_file => '/etc/myapp/lwp.yml',
        timeout     => 30,      # fallback if the file has no timeout key
    );

    $ua->get('https://example.com');

=head2 Example 3: Multi-level inheritance -- config files merge automatically

    # ~/.conf/my-base-class.yml
    # My__Base__Class:
    #   timeout: 30
    #   retries: 3

    # ~/.conf/my-child-class.yml
    # My__Child__Class:
    #   timeout: 60   # overrides base; retries:3 is inherited

    package My::Child::Class;
    our @ISA = ('My::Base::Class');
    use Object::Configure;

    sub new {
        my ($class, %args) = @_;
        # Walks @ISA, merges base config then child config.
        # Result: timeout=60, retries=3
        my $params = Object::Configure::configure($class, \%args);
        return bless $params, $class;
    }

=head2 Example 4: Hot reload -- objects update when the config file changes

    package My::Service;
    use Object::Configure;

    sub new {
        my ($class, %args) = @_;
        my $params = Object::Configure::configure($class, \%args);
        my $self   = bless $params, $class;

        # Register so reload_config() updates $self in-place on file change.
        Object::Configure::register_object($class, $self)
            if $params->{_config_file};

        return $self;
    }

    package main;

    my $svc = My::Service->new(config_file => '/etc/myapp/service.yml');

    # Fork a background watcher; it sends SIGUSR1 when the file changes.
    Object::Configure::enable_hot_reload(
        interval => 5,
        callback => sub { print "Config reloaded at ", scalar localtime, "\n" },
    );

    while (1) { sleep 1 }          # event loop

    Object::Configure::disable_hot_reload();    # clean shutdown

=head2 Example 5: Override settings with environment variables (no file needed)

    # Shell:
    #   export My__Module__log_level=debug
    #   export My__Module__timeout=120

    package My::Module;
    use Object::Configure;

    sub new {
        my ($class, %args) = @_;
        # configure() picks up My__Module__* env vars automatically.
        my $params = Object::Configure::configure($class, \%args);
        return bless $params, $class;
    }

    my $obj = My::Module->new;
    # $obj->{log_level} eq 'debug'   $obj->{timeout} == 120

=head1 CONFIGURATION

=head2 Config file naming

The config file name is derived from the class name by lowercasing it and replacing
C<::> with hyphens (C<->):

    My::Parent::Class  =>  my-parent-class.yml

The file is searched for in the directory of the C<config_file> argument, then in
any directories listed in C<config_dirs>.

=head2 Section key naming inside the config file

Inside the YAML (or JSON or conf) file, the section key uses double underscores in
place of C<::>:

    # my-parent-class.yml
    ---
    My__Parent__Class:
      timeout: 30
      retries: 3

=head2 Configuration resolution order

The following sources are merged from I<lowest> to I<highest> priority.  A value
from a higher-priority source always wins over a lower-priority one.

=over 4

=item 1. B<Caller-supplied params> -- the hashref you pass to C<configure()>.  Despite
the name "defaults", these are the I<lowest> priority and are overridden by everything
else.

=item 2. B<UNIVERSAL section> -- the C<UNIVERSAL:> block in C<universal.yml> (or
C<universal.conf>, C<universal.json>) in your config directories, if the file exists.

=item 3. B<Ancestor class config files> -- walked base-first through C<@ISA> using
the class's Method Resolution Order (MRO).

=item 4. B<Primary config file> -- the file named by the C<config_file> argument.

=item 5. B<Environment variables> -- named C<ClassName__key=value>, where C<::> in
the class name is replaced by C<__>.  These are the I<highest> priority.

=back

=head2 UNIVERSAL configuration

If you create C<universal.yml> in your config directory with a C<UNIVERSAL:> section,
those settings apply to every class that uses C<Object::Configure> unless a
more-specific source overrides them:

    # ~/.conf/universal.yml
    ---
    UNIVERSAL:
      timeout: 30
      logger:
        level: warning

=head2 Logging

C<configure()> always sets C<$params-E<gt>{logger}> to a L<Log::Abstraction>
instance.  You can control it by passing a C<logger> key:

    # Arrayref: messages are captured into @log (protected from merge override)
    configure($class, { logger => \@log });

    # Hashref: options forwarded to Log::Abstraction::new()
    configure($class, { logger => { level => 'debug', file => '/var/log/app.log' } });

    # String 'NULL': disables all logging
    configure($class, { logger => 'NULL' });

    # Existing Log::Abstraction object: used as-is
    configure($class, { logger => $my_logger });

B<Note:> only arrayref loggers are stashed before the config merge and are guaranteed
to survive it.  C<'NULL'> and blessed logger objects can be overridden by a
C<UNIVERSAL:> section in C<universal.yml>.  See L</COMMON PITFALLS>.

=head2 Environment variable format

Environment variable names are constructed as:

    ClassName__key

where C<::> in the class name is replaced by two underscores.

    export My__Module__timeout=60
    export My__Module__logger__level=debug

=head1 HOT RELOAD

Hot reload lets you edit a config file and have all live objects update themselves
without restarting the program.

=head2 How it works

=over 4

=item 1. Your constructor calls C<register_object($class, $self)> to opt in.

=item 2. Your main program calls C<enable_hot_reload()> to fork a background watcher.

=item 3. The watcher polls config files every C<interval> seconds and sends
C<SIGUSR1> to the parent when it detects a change.

=item 4. The C<SIGUSR1> handler calls C<reload_config()>, which re-reads files and
updates every registered object in-place.

=item 5. Your main program calls C<disable_hot_reload()> on shutdown.

=back

Private keys (those whose names start with C<_>) are never overwritten during
reload, so internal bookkeeping is safe.

B<Hot reload is not supported on Windows.>

=head1 COMMON PITFALLS

=head2 Only arrayref loggers survive the config merge

If you pass C<logger =E<gt> 'NULL'> or C<logger =E<gt> $existing_logger>, the
C<UNIVERSAL:> section in C<universal.yml> (or a site-local config file) can
silently override your logger during the config merge.

Only arrayref loggers are stashed before the merge and are guaranteed to survive:

    # Protected -- will NOT be overridden by universal.yml
    configure($class, { logger => \@captured });

    # NOT protected -- universal.yml can override these
    configure($class, { logger => 'NULL' });
    configure($class, { logger => $existing_obj });

=head2 Caller-supplied keys are the LOWEST priority

Despite being called "defaults", keys you pass directly to C<configure()> are
overridden by config files and environment variables:

    # My__Module__timeout=60 is set in the shell.
    # $params->{timeout} will be 60, not 30.
    configure('My::Module', { timeout => 30 });

Use caller-supplied keys only as a last-resort fallback.

=head2 register_object() pushes; it does not replace

Calling C<register_object()> twice for the same class registers B<two> entries.
Both objects receive updates on every reload.  The second call does not remove the
first.

    Object::Configure::register_object('My::Class', $obj_a);
    Object::Configure::register_object('My::Class', $obj_b);
    # reload_config() now updates BOTH $obj_a and $obj_b

=head2 Private keys are never updated on hot reload

Any key whose name begins with C<_> is skipped during C<reload_config()>.
A config key named C<_my_setting> in your YAML file will be ignored at reload time.

=head2 The 'class' key appears on objects created by instantiate()

C<instantiate()> intentionally leaves the C<class> key in the hashref passed to
C<$class-E<gt>new()> as a debugging aid.  Your object will have a C<class>
attribute set to the class name:

    my $obj = Object::Configure::instantiate(class => 'My::Thing', ...);
    print $obj->{class};    # prints 'My::Thing'

=head2 Memoization caches are not invalidated during a run

C<_get_inheritance_chain()> and C<_find_class_config_file()> cache their results
for the lifetime of the process.  If you alter C<@ISA> or add config files after
the first C<configure()> call for a given class, the cache returns stale results.

=head2 disable_hot_reload() blocks for up to five seconds

It waits for the watcher process to exit (SIGTERM first, then SIGKILL).  Do not
call it from inside a signal handler or a timing-sensitive loop.

=head2 config_file must be developer-controlled

The C<config_file> path is validated against directory traversal (C<../>) but is
not otherwise restricted.  It must always be a developer-supplied path, never raw
user input.

=head1 SUBROUTINES/METHODS

=head2 configure($class, \%params)

Merge configuration for C<$class> from all available sources and return a hashref
ready to pass to C<bless>.  This is the core function; call it at the start of your
C<new()> method.

=head3 Arguments

=over 4

=item * C<$class> (Required, string)

The fully-qualified Perl class name to configure (e.g., C<'My::Module'>).  Must
start with a letter or underscore; each C<::>-separated component must also start
with a letter or underscore.  Digits, newlines, and shell metacharacters are rejected.

=item * C<\%params> (Optional, hashref; defaults to C<{}>)

Caller-supplied values.  These have the I<lowest> priority and are overridden by
config files and environment variables.  Recognized special keys:

=over 4

=item * C<config_file> (string, optional) -- path to the primary YAML/JSON/conf file.

=item * C<config_dirs> (arrayref of strings, optional) -- additional directories to
search when C<config_file> is a bare filename with no directory component.

=item * C<logger> (various, optional) -- see L</Logging>.

=item * C<carp_on_warn> (boolean, optional, default 0) -- if true, the logger uses
C<Carp::carp> instead of C<warn>.

=item * C<croak_on_error> (boolean, optional, default 1) -- if true, the logger uses
C<Carp::croak> instead of C<die>.

=back

=back

=head3 Returns

A hashref containing all merged configuration keys plus:

=over 4

=item * C<logger> -- a L<Log::Abstraction> instance, or the string C<'NULL'>.

=item * C<_config_file> -- path of the primary config file (only if one was loaded).

=item * C<_config_files> -- arrayref of every config file that was loaded (only if
at least one was loaded).

=back

=head3 Error messages

=over 4

=item * C<Object::Configure: configure: what class do you want to configure?> --
C<$class> was C<undef> or an empty string.  Pass C<ref($self)> or C<__PACKAGE__>.

=item * C<Object::Configure: configure: invalid class name (must be a valid Perl package name): CLASS> --
C<$class> contains characters not allowed in a Perl package name (for example, a
digit as the first character of a C<::>-component, a newline, or a semicolon).

=item * C<CLASS: config_file contains path traversal sequences: FILE> --
C<config_file> contains a C<..> segment (e.g., C<../../etc/passwd>).  The
C<config_file> argument must always be a developer-controlled path.

=item * C<CLASS: FILE: OS-ERROR> --
C<config_file> is not readable and no C<config_dirs> were supplied.  Check file
permissions or add C<config_dirs>.

=item * C<Warning: Can't load configuration from FILE: DETAIL> --
L<Config::Abstraction> rejected the file (typically a YAML/JSON syntax error).
This is a warning, not fatal; C<configure()> continues with an empty config.

=item * C<Object::Configure: config_path contains path traversal sequences: PATH> --
An environment variable set a C<config_path> value containing C<..>.

=back

=head3 API Specification

=head4 Input

    schema => {
        class => {
            type        => 'string',
            required    => 1,
            description => 'Fully-qualified Perl class name',
            pattern     => qr/\A[A-Za-z_]\w*(?:::[A-Za-z_]\w*)*\z/,
        },
        params => {
            type        => 'hashref',
            optional    => 1,
            default     => {},
            description => 'Caller-supplied defaults (lowest priority)',
            schema => {
                config_file => {
                    type        => 'string',
                    optional    => 1,
                    description => 'Primary config file path',
                },
                config_dirs => {
                    type        => 'arrayref',
                    optional    => 1,
                    description => 'Extra directories to search for config files',
                },
                logger => {
                    type        => [qw(undef string arrayref hashref object)],
                    optional    => 1,
                    description => 'Logger spec -- see CONFIGURATION/Logging',
                },
                carp_on_warn => {
                    type        => 'boolean',
                    optional    => 1,
                    default     => 0,
                    description => 'Use Carp::carp for logger warnings',
                },
                croak_on_error => {
                    type        => 'boolean',
                    optional    => 1,
                    default     => 1,
                    description => 'Use Carp::croak for logger errors',
                },
            },
        },
    }

=head4 Output

    type        => 'hashref',
    description => 'Merged configuration hashref, ready to bless',
    schema => {
        logger => {
            type        => [qw(object string)],
            description => 'Log::Abstraction instance or the string "NULL"',
        },
        _config_file => {
            type        => 'string',
            optional    => 1,
            description => 'Path of the primary config file that was loaded',
        },
        _config_files => {
            type        => 'arrayref',
            optional    => 1,
            description => 'All config file paths that were loaded, in load order',
        },
    }

=cut
546
547sub configure {
548
872
5429555
        my $class  = $_[0];
549
872
1665
        my $params = $_[1] // {};       # caller's defaults; config file values override them
550
872
716
        my $array_logger;               # stash for an arrayref logger spec (Config::Abstraction rejects refs)
551
552
872
2371
        croak(__PACKAGE__, ': configure: what class do you want to configure?')
553                if !defined($class) || $class eq '';
554
555        # SECURITY: validate $class is a syntactically valid Perl package name before
556        # it propagates into env_prefix, croak messages, and %_chain_cache keys.
557        # Exploit mechanism: a class name containing \n, ;, or shell metacharacters
558        # can poison log lines, carp output, and env-variable lookups if not rejected here.
559        # Under Perl taint mode (-T) an unvalidated external value would also fail the
560        # taint check inside Config::Abstraction when used as an env_prefix substring.
561
836
3297
        croak(__PACKAGE__, ': configure: invalid class name (must be a valid Perl package name): ', $class)
562                unless $class =~ /\A[A-Za-z_]\w*(?:::[A-Za-z_]\w*)*\z/;
563
564        # Config::Abstraction, Log::Abstraction, and Return::Set all use eval internally
565        # Protect the caller's $@ from being clobbered by our internal eval blocks.
566
687
515
        local $@;
567
568        # Config::Abstraction treats unknown scalar values as config file paths and will
569        # attempt to read them, corrupting coderefs and object references.
570        # Stash them here and restore after merging so callers never need this pattern.
571
687
537
        my %stashed_values;
572
687
1056
        foreach my $key (keys %$params) {
573
1247
1111
                next if $key eq 'logger';       # logger has its own path through _build_logger
574
1178
809
                my $value = $params->{$key};
575
1178
1932
                if(ref($value) eq 'CODE' || blessed($value)) {
576
57
88
                        $stashed_values{$key} = delete $params->{$key};
577                }
578        }
579
580
675
1068
        if(exists($params->{'logger'}) && ref($params->{'logger'}) eq 'ARRAY') {
581
20
22
                $array_logger = delete $params->{'logger'};
582        }
583
584
675
553
        my $original_class = $class;
585
675
1157
        $class =~ s/::/__/g;
586
587
675
673
        my $config_file = $params->{'config_file'};
588
675
727
        my $config_dirs = $params->{'config_dirs'};
589
590        # SECURITY (S1 — path traversal): reject config_file paths containing ".." segments
591        # before they reach Config::Abstraction.  The pen-test suite confirmed C::A parses
592        # /etc/passwd as a colon-delimited conf file, injecting every user account as a
593        # config key.  The ".." guard blocks the traversal vector; direct absolute paths to
594        # system files remain the caller's responsibility (document: config_file must be
595        # developer-controlled, never raw user input).
596
675
1324
        if(defined($config_file) && $config_file =~ $RE_PATH_TRAVERSAL) {
597
44
309
                croak("$class: config_file contains path traversal sequences: $config_file");
598        }
599
600        # _get_inheritance_chain returns [UNIVERSAL, ..., Base, Child] (base-first).
601        # Reversing it below gives child-first for the discovery loop; the sort
602        # that follows re-establishes base-first order for actual loading.
603
631
2017
        my @inheritance_chain = _get_inheritance_chain($original_class);
604
605
631
595
        my @config_files_to_load = ();
606
631
521
        my %tracked_files        = ();
607
608
631
658
        if($config_file) {
609                # Fail early so the error message carries the OS errno string while $!
610                # is still fresh from the -r test, giving a locale-correct message.
611
255
868
                if(!$config_dirs && !-r $config_file) {
612
18
306
                        croak("$class: ", $config_file, ": $!");
613                }
614
615
237
255
                foreach my $ancestor_class (reverse @inheritance_chain) {
616
494
4497
                        my $ancestor_config_file = _find_class_config_file(
617                                $ancestor_class,
618                                $config_file,
619                                $config_dirs
620                        );
621
622                        # Primary file is added separately at the end (highest priority)
623
494
639
                        next if $ancestor_config_file && $ancestor_config_file eq $config_file;
624
625
437
749
                        if($ancestor_config_file && -r $ancestor_config_file && !$tracked_files{$ancestor_config_file}) {
626
58
372
                                push @config_files_to_load, { file => $ancestor_config_file, class => $ancestor_class };
627
58
66
                                $tracked_files{$ancestor_config_file} = 1;
628
58
307
                                $_config_file_stats{$ancestor_config_file} = stat($ancestor_config_file)
629                                        if -f $ancestor_config_file;
630                        }
631                }
632
633                # Premise: $config_file is true (we are inside if($config_file) above).
634
237
1613
                if(!$tracked_files{$config_file} && -r $config_file) {
635
97
188
                        push @config_files_to_load, { file => $config_file, class => $original_class };
636
97
122
                        $tracked_files{$config_file} = 1;
637
97
494
                        $_config_file_stats{$config_file} = stat($config_file)
638                                if -f $config_file;
639                }
640
641
237
7309
                if(!scalar(@config_files_to_load)) {
642
107
107
61
95
                        foreach my $dir (@{$config_dirs}) {
643
145
394
                                my $candidate = File::Spec->catfile($dir, $config_file);
644
145
781
                                if(-r $candidate) {
645
89
137
                                        push @config_files_to_load, { file => $candidate, class => $original_class };
646
89
81
                                        last;   # stop at first readable hit; later dirs are lower priority
647                                }
648                        }
649                }
650        }
651
652
613
1479
        if(@config_files_to_load) {
653                # Sort so that base-class files are loaded before child files.
654                # %class_order is keyed on the chain (UNIVERSAL=0, ..., Child=N).
655
219
172
                my %class_order;
656
219
417
                for my $i (0 .. $#inheritance_chain) {
657
458
584
                        $class_order{ $inheritance_chain[$i] } = $i;
658                }
659                @config_files_to_load = sort {
660
219
31
280
66
                        ($class_order{ $a->{class} } // 999) <=> ($class_order{ $b->{class} } // 999)
661                } @config_files_to_load;
662
663
219
348
                my $merged_params = { %$params };
664
665
219
234
                foreach my $config_info (@config_files_to_load) {
666
244
457
                        my $cfg_file    = $config_info->{file};
667
244
189
                        my $cfg_class   = $config_info->{class};
668
244
198
                        my $section_name = $cfg_class;
669
244
370
                        $section_name =~ s/::/__/g;
670
671                        # Load only the specific file; do not re-pass config_dirs to avoid
672                        # re-scanning directories and picking up the wrong file for this class.
673
244
856
                        my $config = Config::Abstraction->new(
674                                config_file => $cfg_file,
675                                env_prefix  => "${section_name}__"
676                        );
677
678
244
1380230
                        if($config) {
679
233
446
                                my $this_config = $config->merge_defaults(
680                                        defaults => {},
681                                        section  => $section_name,
682                                        merge    => 1,
683                                        deep     => 1
684                                );
685
233
19111
                                $merged_params = _deep_merge($merged_params, $this_config);
686                        } elsif($@) {
687
7
169
                                carp("Warning: Can't load configuration from $cfg_file: $@");
688                        }
689                }
690
691
219
3534
                $params = $merged_params;
692        } elsif(my $config = Config::Abstraction->new(env_prefix => "${class}__")) {
693                # No config file: honour environment variables across the full ancestor chain.
694
14
13142
                my $merged_config = {};
695
696                # Iterate base-first so that each more-specific class overrides the more
697                # general one: UNIVERSAL → GrandParent → Parent → Child.
698
14
17
                foreach my $ancestor_class (@inheritance_chain) {
699
29
30
                        my $section_name = $ancestor_class;
700
29
40
                        $section_name =~ s/::/__/g;
701
702
29
57
                        my $ancestor_env_config = Config::Abstraction->new(env_prefix => "${section_name}__");
703
29
16307
                        if($ancestor_env_config) {
704
15
33
                                my $ancestor_config = $ancestor_env_config->merge_defaults(
705                                        defaults => {},
706                                        section  => $section_name,
707                                        merge    => 1,
708                                        deep     => 1
709                                );
710
15
975
                                $merged_config = _deep_merge($merged_config, $ancestor_config);
711                        }
712                }
713
714
14
137
                $params = $config->merge_defaults(
715                        defaults => $params,
716                        section  => $class,
717                        merge    => 1,
718                        deep     => 1
719                );
720
721
14
751
                $params = _deep_merge($merged_config, $params);
722
723                # SECURITY (S1 extension — config_path traversal + taint):
724                # config_path arrives from Config::Abstraction, which reads %ENV.  Under
725                # taint mode (-T) the value is tainted; any syscall with a tainted argument
726                # is fatal.  Apply the same $RE_PATH_TRAVERSAL guard as config_file (line 491)
727                # before any filesystem probe, so the two guards stay in sync.
728                # Exploit: ClassName__config_path=../../etc/shadow causes the hot-reload watcher
729                # to stat() and track an arbitrary system file, leaking mtime changes via SIGUSR1.
730
14
25
                if($params->{config_path}) {
731                        croak(__PACKAGE__, ': config_path contains path traversal sequences: ',
732                                $params->{config_path})
733
2
4
                                if $params->{config_path} =~ $RE_PATH_TRAVERSAL;
734                        $_config_file_stats{ $params->{config_path} } = stat($params->{config_path})
735
2
18
                                if -f $params->{config_path};
736                }
737        }
738
739
613
264843
        my $croak_on_error = exists($params->{'croak_on_error'}) ? $params->{'croak_on_error'} : 1;
740
613
612
        my $carp_on_warn   = exists($params->{'carp_on_warn'})   ? $params->{'carp_on_warn'}   : 0;
741
742        # User-supplied logger always wins over config-file logger.
743        # $array_logger is defined when the caller passed an arrayref; it was deleted from
744        # $params before config merging so the merge couldn't overwrite it.  Config-file logger
745        # (a hashref from YAML) is only used when the caller gave no explicit logger at all.
746
613
664
        my $logger_spec = defined($array_logger) ? $array_logger : $params->{'logger'};
747
613
740
        $params->{'logger'} = _build_logger($logger_spec, $carp_on_warn);
748
749
613
1246970
        if(!exists($params->{_config_file})) {
750
606
803
                $params->{_config_file} = $config_file if defined $config_file;
751        }
752
613
634
        if(!exists($params->{_config_files})) {
753
607
242
647
415
                $params->{_config_files} = [ map { $_->{file} } @config_files_to_load ]
754                        if @config_files_to_load;
755        }
756
757        # Re-attach stashed coderefs/objects via hash slice
758
613
37
630
40
        @{$params}{ keys %stashed_values } = values %stashed_values if %stashed_values;
759
760
613
1224
        return Return::Set::set_return($params, { 'type' => 'hashref' });
761}
762
763 - 850
=head2 instantiate(%params)

Configure and instantiate a third-party class without modifying the class itself.

C<instantiate> is a convenience wrapper: it calls C<configure>, passes the merged
hashref to C<$class-E<gt>new(...)>, and optionally registers the result for hot reload.
Use it when you need runtime configuration for a class whose source you cannot change.

=head3 Arguments

Takes a flat hash (not a hashref).  Recognized keys:

=over 4

=item * C<class> (Required, string)

The fully-qualified class name to instantiate (e.g., C<'LWP::UserAgent'>).  The class
must already be loaded and must have a C<new> method that accepts a hashref.

=item * All other keys

Passed through to C<configure()> as C<\%params>.  See L</configure($class, \%params)>
for the full list.

=back

=head3 Returns

A blessed object of type C<$class>.

B<Note:> The returned object's hash will contain a C<class> key holding the class name.
This is intentional -- it is left in the hash as a debugging aid so you can always
see which class an object came from.  Do not depend on its absence.

=head3 Side Effects

=over 4

=item * Calls C<configure($class, \%params)> -- see its side effects.

=item * Calls C<$class-E<gt>new(\%merged_params)>.

=item * If the config produced a C<_config_file>, calls C<register_object($class, $obj)>
so the object participates in hot reload.

=back

=head3 Error messages

Same as C<configure()>.  In addition:

=over 4

=item * Any exception thrown by C<$class-E<gt>new(...)> propagates unchanged.

=back

=head3 Usage Example

    use Object::Configure;

    my $ua = Object::Configure::instantiate(
        class       => 'LWP::UserAgent',
        config_file => 'lwp.yml',
        config_dirs => ['/etc/myapp'],
        timeout     => 30,
    );

=head3 API Specification

=head4 Input

    schema => {
        class => {
            type        => 'string',
            required    => 1,
            description => 'Fully-qualified class name; must respond to new(hashref)',
        },
        # all other keys forwarded to configure()
    }

=head4 Output

    type        => 'object',
    description => 'Blessed instance of $class, with class key present in hash',
    notes       => 'class key intentionally left in hash as a debugging aid',

=cut
851
852sub instantiate
853{
854
33
136769
        my $params = Params::Get::get_params('class', @_);
855
856        # 'class' is read into $class then left in $params,
857        # 'class' propagates into configure() as a spurious config key and
858        # ends up in the blessed object's hash, allowing the caller to see
859        # that has come from what, helping debugging
860
33
497
        my $class = $params->{'class'};
861
33
53
        $params = configure($class, $params);
862
863
28
2220
        my $obj = $class->new($params);
864
865
23
154
        register_object($class, $obj) if $params->{_config_file};
866
867
23
43
        return $obj;
868}
869
870 - 973
=head1 HOT RELOAD FEATURES

=head2 enable_hot_reload(%opts)

Fork a background watcher that sends SIGUSR1 to the parent whenever a tracked
configuration file changes on disk.  Objects registered via C<register_object()>
then have their configuration reloaded automatically.

B<Unix only.>  On Windows this function is a silent no-op (SIGUSR1 does not exist).

=head3 Arguments

Takes a flat hash.  All keys are optional.

=over 4

=item * C<interval> (integer E<gt>= 1, default: 10)

Seconds between file-modification checks.  Lower values detect changes faster
but use more CPU.  Zero or negative values are silently replaced with the default.

=item * C<callback> (coderef, optional)

Called in the parent process after each successful config reload.  Useful for
logging or flushing caches.

=back

=head3 Returns

The PID of the watcher child process (integer E<gt> 0), or C<undef>/empty if hot
reload was already active (idempotent: a second call returns immediately without
forking again).

=head3 Side Effects

=over 4

=item * Forks a child process.

=item * The child polls C<%_config_file_stats> and sends C<SIGUSR1> to the parent
on mtime change.

=item * Stores C<{pid =E<gt> $pid, callback =E<gt> $cb}> in C<%_config_watchers>.

=back

=head3 Error messages

=over 4

=item * C<Object::Configure: fork failed: OS-ERROR> -- C<fork()> returned C<undef>.
Check system resource limits (C<ulimit -u>).

=back

=head3 Usage Example

    Object::Configure::enable_hot_reload(
        interval => 5,
        callback => sub { warn "Config reloaded at " . localtime . "\n" },
    );

    while (1) { sleep 1 }  # watcher runs in the background

=head3 API Specification

=head4 Input

    schema => {
        interval => {
            type        => 'integer',
            optional    => 1,
            default     => 10,
            minimum     => 1,
            description => 'Poll interval in seconds',
        },
        callback => {
            type        => 'coderef',
            optional    => 1,
            description => 'Called in parent after each reload',
        },
    }

=head4 Output

    type        => [qw(integer undef)],
    description => 'PID of watcher child; undef/empty if already active',
    condition   => 'value > 0 when defined',

    enable_hot_reload : Interval x Callback -> PID | empty

    Pre:
      interval >= 1   (enforced: negative/zero replaced with DEFAULT_INTERVAL)
      _config_watchers = {}

    Post:
      _config_watchers.pid = result
      _config_watchers.callback = callback
      (forall t: t mod interval = 0 =>
          (exists f in _config_file_stats: mtime(f) changed =>
              send_signal(SIGUSR1, parent_pid)))

=cut
974
975sub enable_hot_reload {
976
65
110068
        my %params = @_;
977
978        # SECURITY (S4): use defined-or (//) not truth-or (||) so that interval=>0
979        # is not silently replaced.  Then guard: a zero or negative interval would make
980        # the child busy-loop, consuming 100% CPU (internal DoS vector).
981
65
200
        my $interval = $params{interval} // $DEFAULT_INTERVAL;
982
65
261
        $interval    = $DEFAULT_INTERVAL unless $interval > 0;
983
65
126
        my $callback = $params{callback};
984
985
65
150
        return if %_config_watchers;    # already watching; avoid double-fork
986
987
59
62289
        if(my $pid = fork()) {
988
43
1837
                $_config_watchers{pid}      = $pid;
989
43
918
                $_config_watchers{callback} = $callback;
990
43
2589
                return $pid;
991        } elsif(defined $pid) {
992                # Child: run forever, signal parent on change
993
16
947
                _run_config_watcher($interval, $callback);
994
0
0
                exit 0;
995        } else {
996
0
0
                croak("Failed to fork config watcher: $!");
997        }
998}
999
1000 - 1042
=head2 disable_hot_reload()

Stop the background watcher and clear hot-reload state.

Safe to call when hot reload is not active (no-op).  After this call,
configuration files are no longer monitored and C<%_config_watchers> is empty.

=head3 Arguments

None.

=head3 Returns

Nothing (void).

=head3 Side Effects

=over 4

=item * Sends SIGTERM to the watcher child.

=item * Polls for up to C<$KILL_TIMEOUT> seconds (default: 5); escalates to SIGKILL
if the child has not exited by then.

=item * Calls C<waitpid> to reap the child.

=item * Clears C<%_config_watchers>.

=back

B<Blocking>: this function may take up to five seconds if the watcher ignores SIGTERM.

=head3 API Specification

=head4 Input

    schema => {}   # no arguments

=head4 Output

    type => 'void'

=cut
1043
1044sub disable_hot_reload {
1045        ## MUTANT_SKIP_BEGIN
1046
106
80409
        if(my $pid = $_config_watchers{pid}) {
1047                # SECURITY (S3 — PID safety):
1048                #   $pid > 1  excludes PID 0 (sends SIGTERM to whole process group — DoS)
1049                #             and PID 1 (init — catastrophic as root).
1050                #   $pid != $$ prevents self-signaling if %_config_watchers is corrupted.
1051                # Exploit mechanism: if global state is poisoned with pid=0 or pid=1,
1052                # kill('TERM', 0) signals every process in the group; kill('TERM', 1) kills
1053                # init as root.  Both are now rejected at the comparison level.
1054
50
1688
                if($pid =~ /\A[0-9]+\z/ && $pid > 1 && $pid != $$) {
1055
43
526
                        kill('TERM', $pid);
1056
1057                        # Poll up to KILL_TIMEOUT seconds; escalate to SIGKILL if SIGTERM is ignored.
1058                        # SIGKILL cannot be caught or deferred so the subsequent waitpid is always safe.
1059
43
1227
                        my $deadline = time() + $KILL_TIMEOUT;
1060
43
562
                        my $kid;
1061
43
93
                        do {
1062
177
13425485
                                $kid = waitpid($pid, WNOHANG);
1063
177
1412
                                if($kid == 0 && time() < $deadline) {
1064
134
974
                                        select undef, undef, undef, $POLL_SLEEP;
1065                                }
1066                        } while($kid == 0 && time() < $deadline);
1067
1068
43
278
                        if($kid == 0) {
1069
0
0
                                kill('KILL', $pid);
1070
0
0
                                waitpid($pid, 0);
1071                        }
1072                }
1073
50
580
                %_config_watchers = ();
1074        }
1075        ## MUTANT_SKIP_END
1076}
1077
1078 - 1122
=head2 reload_config()

Immediately reload configuration from disk for every registered object.

Normally called automatically by the SIGUSR1 handler.  You may call it manually
to force a reload (e.g., in tests or on a custom signal).

=head3 Arguments

None.

=head3 Returns

An integer E<gt>= 0: the count of objects whose configuration was successfully
reloaded.

=head3 Side Effects

=over 4

=item * Reads config files from disk for each registered object.

=item * Updates non-private keys (those not starting with C<_>) in-place on
each live object.

=item * Prunes dead weak references from C<%_object_registry>.

=item * Emits a C<carp> warning (not a croak) if reload fails for any individual
object; other objects are still processed.

=back

=head3 API Specification

=head4 Input

    schema => {}   # no arguments

=head4 Output

    type        => 'integer',
    description => 'Count of objects successfully reloaded',
    condition   => 'value >= 0',

=cut
1123
1124sub reload_config {
1125
80
606806
        my $reloaded_count = 0;
1126
1127
80
167
        foreach my $class_key (keys %_object_registry) {
1128
78
101
                my $objects = $_object_registry{$class_key};
1129
1130
78
101
113
180
                @$objects = grep { defined $$_ } @$objects;     # prune garbage-collected weak refs (check referent, not the ref-to-scalar itself)
1131
1132
78
82
                foreach my $obj_ref (@$objects) {
1133
70
110
                        if(my $obj = $$obj_ref) {
1134                                # Protect the caller's $@ from being clobbered by our internal eval blocks.
1135
70
57
                                local $@;
1136
70
62
                                eval {
1137
70
110
                                        _reload_object_config($obj);
1138
65
433
                                        $reloaded_count++;
1139                                };
1140
70
134
                                if($@) {
1141
5
63
                                        carp("Failed to reload config for object: $@");
1142                                }
1143                        }
1144                }
1145
1146
78
866
                delete $_object_registry{$class_key} unless @$objects;
1147        }
1148
1149
80
105
        return $reloaded_count;
1150}
1151
1152 - 1242
=head2 register_object($class, $obj)

Register a blessed object so it receives configuration updates when files change.

B<Push semantics>: each call I<appends> a new entry to the registry for C<$class>.
It does not replace a previous entry.  Multiple objects of the same class are all
tracked and all reloaded.

=head3 Arguments

=over 4

=item * C<$class> (Required, string)

The class name used to organise the registry.  Typically C<ref($self)> or the
calling package name.

=item * C<$obj> (Required, blessed reference)

The object to register.  B<Must be a blessed reference.>  Passing an unblessed
hashref or any other unblessed value causes an immediate C<croak>.

=back

=head3 Returns

Nothing (void).

=head3 Side Effects

=over 4

=item * Pushes a weak reference to C<$obj> onto C<$_object_registry{$class}>.

=item * On the first call ever (for any class): saves the current C<$SIG{USR1}>
and installs Object::Configure's handler.  On Unix, the handler calls
C<reload_config()> then chains to the prior handler.  On Windows, signal
installation is skipped but C<$_original_usr1_handler> is still set.

=back

=head3 Error messages

=over 4

=item * C<Object::Configure::register_object: Usage ($class, $obj)> --
either C<$class> or C<$obj> was C<undef>.

=item * C<Object::Configure::register_object: $obj must be a blessed reference> --
C<$obj> was defined but not blessed.  This guard prevents DoS via registry flooding
(reloading thousands of unblessed entries on every SIGUSR1).

=back

=head3 Usage Example

    package My::Module;
    use Object::Configure;

    sub new {
        my ($class, %args) = @_;
        my $params = Object::Configure::configure($class, \%args);
        my $self   = bless $params, $class;
        Object::Configure::register_object($class, $self)
            if $self->{_config_file};
        return $self;
    }

=head3 API Specification

=head4 Input

    schema => {
        class => {
            type        => 'string',
            required    => 1,
            description => 'Class name for registry key',
        },
        obj => {
            type        => 'object',
            required    => 1,
            description => 'Blessed object to register; unblessed refs are rejected',
            blessed     => 1,
        },
    }

=head4 Output

    type => 'void'

=cut
1243
1244sub register_object
1245{
1246
171
306017
        my ($class, $obj) = @_;
1247
1248
171
596
        croak(__PACKAGE__, '::register_object: Usage ($class, $obj)')
1249                unless defined($class) && defined($obj);
1250
1251        # SECURITY: enforce the API contract (POD: "$obj must be a blessed reference").
1252        # Accepting unblessed refs silently would allow DoS via registry flooding: an
1253        # adversary or buggy caller can push thousands of unblessed entries; reload_config()
1254        # iterates every entry on every SIGUSR1, degrading throughput proportionally.
1255
142
314
        croak(__PACKAGE__, '::register_object: $obj must be a blessed reference')
1256                unless blessed($obj);
1257
1258
136
148
        my $obj_ref = \$obj;
1259
136
212
        weaken($$obj_ref);
1260
136
136
112
191
        push @{ $_object_registry{$class} }, $obj_ref;
1261
1262        # Install SIGUSR1 handler exactly once.  We save the previous handler so
1263        # we can chain to it (another module may have installed one) and restore it
1264        # on shutdown.  On Windows SIGUSR1 does not exist so we skip the signal work
1265        # but still save $_original_usr1_handler so restore_signal_handlers is safe.
1266
136
192
        if(!defined $_original_usr1_handler) {
1267
60
217
                $_original_usr1_handler = $SIG{USR1} || $SIG_DEFAULT;
1268
1269
60
240
                return if $^O eq $OS_WINDOWS;
1270
1271                $SIG{USR1} = sub {
1272
15
1014785
                        reload_config();
1273
15
24
                        $_config_watchers{callback}->() if $_config_watchers{callback};
1274
1275
15
29
                        if(ref($_original_usr1_handler) eq 'CODE') {
1276
8
14
                                $_original_usr1_handler->();
1277                        } elsif($_original_usr1_handler eq $SIG_DEFAULT
1278                             || $_original_usr1_handler eq $SIG_IGNORE) {
1279                                # DEFAULT for USR1 is typically a no-op; IGNORE means discard
1280                        } else {
1281
2
20
                                carp("Object::Configure: Cannot chain to non-code USR1 handler: $_original_usr1_handler");
1282                        }
1283
60
561
                };
1284        }
1285
1286
136
200
        return;
1287}
1288
1289 - 1326
=head2 restore_signal_handlers()

Restore C<$SIG{USR1}> to the handler that was in place before
C<register_object()> installed the hot-reload handler, and clear
C<$_original_usr1_handler>.

Safe to call even when Object::Configure never installed a handler (no-op).
On Windows this function has no effect (SIGUSR1 does not exist there).

=head3 Arguments

None.

=head3 Returns

Nothing (void).

=head3 Side Effects

=over 4

=item * Sets C<$SIG{USR1}> back to its saved value (Unix only).

=item * Sets C<$_original_usr1_handler> to C<undef>.

=back

=head3 API Specification

=head4 Input

    schema => {}   # no arguments

=head4 Output

    type => 'void'

=cut
1327
1328sub restore_signal_handlers
1329{
1330
92
392456
        if(defined $_original_usr1_handler) {
1331
48
193
                $SIG{USR1} = $_original_usr1_handler unless $^O eq $OS_WINDOWS;
1332
48
494
                $_original_usr1_handler = undef;
1333        }
1334
1335
92
427
        return;
1336}
1337
1338 - 1398
=head2 get_signal_handler_info()

Return a snapshot of the current signal-handler and hot-reload state.
This is a debugging aid; normal application code does not need to call it.

=head3 Arguments

None.

=head3 Returns

A hashref with these keys:

=over 4

=item * C<original_usr1> -- the C<$SIG{USR1}> value that existed before
Object::Configure installed its handler, or C<undef> if no handler was saved yet.

=item * C<current_usr1> -- the currently installed C<$SIG{USR1}> handler (coderef,
C<'DEFAULT'>, C<'IGNORE'>, or C<undef>).

=item * C<hot_reload_active> -- C<1> if C<$_original_usr1_handler> is defined,
C<''> otherwise.

=item * C<watcher_pid> -- the PID of the background watcher child, or C<undef>
if C<enable_hot_reload()> has not been called (or the watcher has been stopped).

=back

=head3 Usage Example

    use Object::Configure;
    use Data::Dumper;

    Object::Configure::enable_hot_reload();
    print Dumper(Object::Configure::get_signal_handler_info());
    # {
    #   original_usr1    => 'DEFAULT',
    #   current_usr1     => sub { ... },
    #   hot_reload_active => 1,
    #   watcher_pid       => 12345,
    # }

=head3 API Specification

=head4 Input

    schema => {}   # no arguments

=head4 Output

    type        => 'hashref',
    description => 'Snapshot of signal-handler and watcher state',
    schema => {
        original_usr1     => { type => [qw(coderef string undef)] },
        current_usr1      => { type => [qw(coderef string undef)] },
        hot_reload_active => { type => 'boolean'                  },
        watcher_pid       => { type => [qw(integer undef)]        },
    }

=cut
1399
1400sub get_signal_handler_info {
1401        return {
1402                original_usr1    => $_original_usr1_handler,
1403                current_usr1     => $SIG{USR1},
1404                hot_reload_active => defined $_original_usr1_handler,
1405                watcher_pid      => $_config_watchers{pid},
1406
18
14211
        };
1407}
1408
1409# ----------------------------------------------------------------------------
1410# Private helpers
1411# All routines below are implementation details; callers must not rely on them.
1412# ----------------------------------------------------------------------------
1413
1414# Purpose:   Consolidate all logger-creation paths into one place.
1415#            Called from configure() and _reconfigure_logger() to eliminate
1416#            the duplication that existed between the two.
1417# Entry:     $spec may be: undef (want default), the string 'NULL' (no logging),
1418#            an ARRAY ref (log-capture array), a HASH ref (options for Log::Abstraction),
1419#            a pre-built Log::Abstraction instance (pass through), or any other
1420#            scalar (treated as a logger name / file path).
1421#            $carp_on_warn is a boolean controlling Carp::carp integration.
1422# Exit:      Returns a Log::Abstraction instance, or the string 'NULL'.
1423# Side:      May allocate a new Log::Abstraction object.
1424sub _build_logger {
1425
663
46555
        my ($spec, $carp_on_warn) = @_;
1426
663
650
        $carp_on_warn //= 0;
1427
1428
663
1504
        return Log::Abstraction->new(carp_on_warn => $carp_on_warn)
1429                unless defined $spec;
1430
1431
124
224
        return $LOGGER_NULL
1432                if !ref($spec) && $spec eq $LOGGER_NULL;
1433
1434
103
262
        return $spec
1435                if blessed($spec) && $spec->isa('Log::Abstraction');
1436
1437
88
116
        if(ref($spec) eq 'ARRAY') {
1438
24
49
                return Log::Abstraction->new(array => $spec, carp_on_warn => $carp_on_warn);
1439        }
1440
1441
64
77
        if(ref($spec) eq 'HASH') {
1442
43
133
                return Log::Abstraction->new({ carp_on_warn => $carp_on_warn, %$spec });
1443        }
1444
1445        # Scalar: a logger name, file path, or other string identifier passed to L::A
1446
21
78
        return Log::Abstraction->new({ carp_on_warn => $carp_on_warn, logger => $spec });
1447}
1448
1449# Purpose:   Build the ancestor chain needed for config-file discovery and env merging.
1450#            Uses the class's own MRO (DFS or C3) via mro::get_linear_isa, which is
1451#            more correct than a hardcoded DFS walk and handles diamond inheritance.
1452#            UNIVERSAL is added explicitly because mro::get_linear_isa does not include
1453#            it unless it appears in @ISA, yet Object::Configure supports universal.yml.
1454# Entry:     $class is a fully-qualified class name that has already been loaded.
1455# Exit:      Returns a list in base-first order: (UNIVERSAL, ..., GrandParent, Parent, Class).
1456#            Result is memoized in %_chain_cache; mro::get_linear_isa is not re-invoked
1457#            for classes already seen in this process.  The cache is valid for the process
1458#            lifetime because @ISA is stable after module load in normal Perl programs.
1459#            Algorithmic cost: O(N) first call; O(1) amortised on subsequent calls.
1460sub _get_inheritance_chain {
1461
661
122076
        my ($class) = @_;
1462
1463        # Cache hit: return a copy of the stored list (caller may modify the returned list).
1464
661
245
864
461
        return @{ $_chain_cache{$class} } if $_chain_cache{$class};
1465
1466
416
416
274
1243
        my @mro = @{ mro::get_linear_isa($class) };
1467
1468        # mro::get_linear_isa returns child-first; reverse to get base-first.
1469        # UNIVERSAL is implicit in Perl's type system but not always in the MRO list,
1470        # so append it when absent to ensure universal.yml is picked up.
1471        # List::Util::any is XS and short-circuits on first match -- faster than grep.
1472
416
465
1306
741
        push @mro, 'UNIVERSAL' unless any { $_ eq 'UNIVERSAL' } @mro;
1473
1474
416
901
        my @chain = reverse @mro;
1475
416
629
        $_chain_cache{$class} = \@chain;   # store arrayref; return list copy below
1476
416
640
        return @chain;
1477}
1478
1479# Purpose:   Find a config file for a specific ancestor class using the same
1480#            naming convention as the primary config file (directory + extension).
1481# Entry:     $class is a fully-qualified class name.
1482#            $base_config_file is the primary config file path (provides dir and ext).
1483#            $config_dirs is an optional arrayref of additional search directories.
1484#            Does NOT modify elements of $config_dirs (trailing-slash removal uses a copy).
1485# Exit:      Returns a readable file path, or undef if nothing found.
1486#            Result (including undef for "not found") is memoized in %_find_cache, keyed
1487#            by (class, base_config_file, config_dirs-elements) joined with NUL bytes.
1488#            Subsequent calls with identical arguments skip all filesystem probes.
1489#            Algorithmic cost: O(extensions) syscalls first call; O(1) amortised.
1490sub _find_class_config_file {
1491
527
51194
        my ($class, $base_config_file, $config_dirs) = @_;
1492
1493        # Build a NUL-separated cache key. NUL cannot appear in Linux file paths (the
1494        # pen-test suite confirms this: Perl's -r warns and returns undef for NUL paths).
1495
527
798
        my $cache_key = join("\0", $class, $base_config_file,
1496                $config_dirs ? @$config_dirs : ());
1497
527
688
        return $_find_cache{$cache_key} if exists $_find_cache{$cache_key};
1498
1499
444
422
        my $class_file = lc($class);
1500
444
573
        $class_file =~ s/::/-/g;
1501
1502
444
2275
        my ($base_vol, $base_dir_part, $base_name_ext) = File::Spec->splitpath($base_config_file);
1503
444
1341
        my (undef, $base_ext) = $base_name_ext =~ /^(.*?)(\.[^.]+)?$/;
1504
444
501
        $base_ext //= '';
1505
444
977
        my $base_dir = File::Spec->catpath($base_vol, $base_dir_part, '');
1506
1507        # Single-exit-point via labeled block so the cache write happens unconditionally.
1508
444
326
        my $found;
1509        SEARCH: {
1510                # Dedup: when $base_ext is already .yml/.conf/etc the first candidate would
1511                # be a duplicate of a later one, causing a redundant filesystem probe.
1512
444
444
265
303
                my %_seen_pat;
1513
444
2220
4129
2454
                foreach my $pattern (grep { !$_seen_pat{$_}++ } (
1514                        File::Spec->catfile($base_dir, "${class_file}${base_ext}"),
1515                        File::Spec->catfile($base_dir, "${class_file}.conf"),
1516                        File::Spec->catfile($base_dir, "${class_file}.yml"),
1517                        File::Spec->catfile($base_dir, "${class_file}.yaml"),
1518                        File::Spec->catfile($base_dir, "${class_file}.json"),
1519                )) {
1520
1630
6530
                        if(-r $pattern && -f $pattern) {
1521
54
54
                                $found = $pattern;
1522
54
77
                                last SEARCH;
1523                        }
1524                }
1525
1526
390
1191
                if($config_dirs && ref($config_dirs) eq 'ARRAY') {
1527
301
310
                        foreach my $dir (@$config_dirs) {
1528                                # Use a copy so the caller's arrayref element is never mutated.
1529
382
348
                                (my $clean_dir = $dir) =~ s{/$}{};
1530
382
195
                                my %_seen_dir_pat;
1531
382
1910
622
1861
                                foreach my $pattern (grep { !$_seen_dir_pat{$_}++ } (
1532                                        "${clean_dir}/${class_file}${base_ext}",
1533                                        "${clean_dir}/${class_file}.conf",
1534                                        "${clean_dir}/${class_file}.yml",
1535                                        "${clean_dir}/${class_file}.yaml",
1536                                        "${clean_dir}/${class_file}.json",
1537                                )) {
1538
1359
6012
                                        if(-r $pattern && -f $pattern) {
1539
58
41
                                                $found = $pattern;
1540
58
140
                                                last SEARCH;
1541                                        }
1542                                }
1543                        }
1544                }
1545        }
1546
1547        # Cache stores undef for "not found"; callers must use exists, not defined.
1548
444
789
        return ($_find_cache{$cache_key} = $found);
1549}
1550
1551# Purpose:   Run as the forked watcher child.  Polls %_config_file_stats and
1552#            sends SIGUSR1 to the parent when any file changes.
1553# Entry:     $interval >= 1 (seconds). $callback is unused in the child (it runs
1554#            in the parent's SIGUSR1 handler).
1555# Exit:      Never returns; terminates via SIGTERM/SIGINT handlers.
1556# Side:      Modifies %_config_file_stats entries in the child's address space only.
1557sub _run_config_watcher {
1558
16
231
        my ($interval, $callback) = @_;
1559
1560        # SECURITY (S5): re-validate $interval in the child process.
1561        # If the parent's fork() fires before enable_hot_reload() applies its own guard
1562        # (race) or state is corrupted between fork and here, sleep(0) or sleep(-1) would
1563        # cause a busy-loop that saturates the CPU.  int() also prevents floating-point
1564        # values like 0.001 from reaching the kernel as a near-zero sleep.
1565
16
486
        $interval = int($interval // $DEFAULT_INTERVAL);
1566
16
345
        $interval = $DEFAULT_INTERVAL unless $interval > 0;
1567
1568
16
16
1730
771
        local $SIG{TERM} = sub { exit 0 };
1569
16
0
578
0
        local $SIG{INT}  = sub { exit 0 };
1570
1571
16
89
        while(1) {
1572
18
2020544
                sleep($interval);
1573
1574
18
312
                my $changes_detected = 0;
1575
1576
2
44
                foreach my $config_file (keys %_config_file_stats) {
1577
2
45
                        if(-f $config_file) {
1578
2
35
                                my $current_stat = stat($config_file);
1579
2
483
                                my $stored_stat  = $_config_file_stats{$config_file};
1580
1581
2
54
                                if(!$stored_stat || $current_stat->mtime > $stored_stat->mtime) {
1582
1
10
                                        $_config_file_stats{$config_file} = $current_stat;
1583
1
16
                                        $changes_detected = 1;
1584                                }
1585                        } else {
1586
0
0
                                delete $_config_file_stats{$config_file};
1587
0
0
                                $changes_detected = 1;
1588                        }
1589                }
1590
1591
2
43
                if($changes_detected && $^O ne $OS_WINDOWS) {
1592
1
31
                        if(my $parent_pid = getppid()) {
1593
1
15
                                kill('USR1', $parent_pid);
1594                        }
1595                }
1596        }
1597}
1598
1599# Purpose:   Reload a single object's configuration from disk and update its fields.
1600#            Private properties (prefix '_') are intentionally skipped to avoid
1601#            clobbering internal bookkeeping set at construction time.
1602# Entry:     $obj must be a blessed reference with a {_config_file} or {_config_files} key.
1603# Exit:      Returns nothing; updates $obj in-place.
1604# Side:      Reads from disk. Calls $obj->_on_config_reload if the method exists.
1605sub _reload_object_config {
1606
82
29466
        my $obj = $_[0];
1607
1608
82
207
        return unless blessed($obj);
1609
1610
80
109
        my $class          = ref($obj);
1611
80
61
        my $original_class = $class;
1612
80
170
        $class =~ s/::/__/g;
1613
1614        # Prefer the most-specific (last) file from the full list; fall back to scalar key
1615
80
72
        my $config_file;
1616
80
38
225
65
        if($obj->{_config_files} && ref($obj->{_config_files}) eq 'ARRAY' && @{ $obj->{_config_files} }) {
1617
32
37
                $config_file = $obj->{_config_files}[-1];
1618        } else {
1619
48
84
                $config_file = $obj->{_config_file} || $obj->{config_file};
1620        }
1621
1622        # SECURITY (S1 — path traversal): an attacker who modifies $obj->{_config_file}
1623        # (e.g., via a deserialization gadget or a malicious config merge) can redirect
1624        # hot-reload to read arbitrary system files.  Reject paths with ".." segments here
1625        # so that even a corrupted object cannot force a traversal read.
1626        # Must come BEFORE the -f check so traversal paths for non-existent files are also rejected.
1627
80
262
        if($config_file && $config_file =~ $RE_PATH_TRAVERSAL) {
1628
7
97
                carp(__PACKAGE__, ': _reload_object_config: refusing path with traversal sequences: ',
1629                        $config_file);
1630
7
1000
                return;
1631        }
1632
1633
73
753
        return unless $config_file && -f $config_file;
1634
1635
55
237
        my $config = Config::Abstraction->new(
1636                config_file => $config_file,
1637                env_prefix  => "${class}__"
1638        );
1639
1640
55
47213
        if($config) {
1641
55
114
                my $new_params = $config->merge_defaults(
1642                        defaults => {},
1643                        section  => $class,
1644                        merge    => 1,
1645                        deep     => 1
1646                );
1647
1648
55
3561
                foreach my $key (keys %$new_params) {
1649
100
112
                        next if $key =~ /^_/;
1650
1651
97
91
                        if($key eq 'logger') {
1652                                # Only the exact 'logger' key triggers logger reconstruction.
1653                                # Keys like 'logger.file' are flat config values, not logger specs.
1654                                # Guard: assign directly for undef (no logger) or literal 'NULL'.
1655                                # Premise 1: _build_logger handles every other spec type.
1656                                # Premise 2: undef/NULL need no construction. Conclusion: rebuild only otherwise.
1657
3
6
                                my $val = $new_params->{$key};
1658
3
12
                                if(!defined($val) || (!ref($val) && $val eq $LOGGER_NULL)) {
1659
2
34
                                        $obj->{$key} = $val;
1660                                } else {
1661
1
3
                                        _reconfigure_logger($obj, $key, $val);
1662                                }
1663                        } else {
1664
94
99
                                $obj->{$key} = $new_params->{$key};
1665                        }
1666                }
1667
1668
55
259
                $obj->_on_config_reload($new_params) if $obj->can('_on_config_reload');
1669
1670                $obj->{logger}->info("Configuration reloaded for $original_class")
1671
55
351
                        if $obj->{logger} && $obj->{logger}->can('info');
1672        }
1673
1674
55
1122
        return;
1675}
1676
1677# Purpose:   Replace the logger on an already-constructed object with one
1678#            built from a new config value (typically a YAML hashref).
1679#            Delegates to _build_logger so logger-creation logic lives in one place.
1680# Entry:     $obj is a blessed hashref. $key is the hash key to update (usually 'logger').
1681#            $logger_config is the new spec from the config file.
1682# Exit:      Returns nothing; updates $obj->{$key} in-place.
1683# Side:      May allocate a new Log::Abstraction instance.
1684sub _reconfigure_logger
1685{
1686
10
14293
        my ($obj, $key, $logger_config) = @_;
1687
10
24
        my $carp_on_warn = $obj->{carp_on_warn} || 0;
1688
10
16
        $obj->{$key} = _build_logger($logger_config, $carp_on_warn);
1689
10
6210
        return;
1690}
1691
1692# Purpose:   Right-precedence deep merge of two hash references.
1693#            Scalar/arrayref values in $overlay replace those in $base entirely;
1694#            nested hashrefs are merged recursively.
1695# Entry:     Both args should be hashrefs (or undef/non-ref, handled gracefully).
1696# Exit:      Returns a new hashref; neither input is modified.
1697sub _deep_merge {
1698
401
117914
        my ($base, $overlay) = @_;
1699
1700
401
732
        return $overlay unless ref($base)    eq 'HASH';
1701
384
431
        return $overlay unless ref($overlay) eq 'HASH';
1702
1703
364
478
        my $result = { %$base };
1704
1705
364
536
        foreach my $key (keys %$overlay) {
1706
4547
3527
                if(ref($overlay->{$key}) eq 'HASH' && ref($result->{$key}) eq 'HASH') {
1707
61
93
                        $result->{$key} = _deep_merge($result->{$key}, $overlay->{$key});
1708                } else {
1709
4486
3258
                        $result->{$key} = $overlay->{$key};
1710                }
1711        }
1712
1713
364
1662
        return $result;
1714}
1715
1716# Clean up the watcher child and restore signal state on interpreter exit.
1717END {
1718
36
2633765
        disable_hot_reload();
1719
36
99
        restore_signal_handlers();
1720}
1721
1722 - 1995
=head1 SEE ALSO

=over 4

=item * L<Config::Abstraction>

=item * L<Log::Abstraction>

=item * L<Test Dashboard|https://nigelhorne.github.io/Object-Configure/coverage/>

=back

=head1 LIMITATIONS

=over 4

=item * B<Global singleton state.> C<%_object_registry>, C<%_config_watchers>, and
C<%_config_file_stats> are package globals.  Two independent subsystems in the same
process share one hot-reload registry and one SIGUSR1 handler.  There is no
instance-level isolation.  A proper fix would wrap state in an object and allow
multiple independent C<Object::Configure> instances, but that would break the
existing constructor-call API (C<configure($class, \%params)>).

=item * B<Hot reload is Unix-only.> SIGUSR1 does not exist on Windows.
All signal-related paths are guarded with C<$^O ne 'MSWin32'>, so the module
loads on Windows but silently skips hot-reload registration.

=item * B<configure() is a God function.> At ~120 lines it handles arg validation,
config-file discovery, MRO walking, multi-file merging, env-var merging, logger
creation, and hot-reload bookkeeping.  Future versions should decompose this into
smaller, independently testable units.

=item * B<_deep_merge reimplements CPAN.> L<Hash::Merge::Simple> or L<Hash::Merge>
provide tested, feature-complete deep merge.  The internal C<_deep_merge> is 15
lines and correct for the current use, but does not handle arrayrefs (they are
replaced wholesale, not merged).  If array-merge semantics are ever needed, switch
to a CPAN module.

=item * B<No encapsulation enforcement.> Private helpers (C<_build_logger>,
C<_get_inheritance_chain>, etc.) are accessible to any caller.  L<Sub::Private>
(enforce mode) would make accidental external use a compile-time error.  It is not
added here to avoid a smoker dependency on a less-common module.

=item * B<configure() signature is positional, instantiate() is named.>  The two
public constructors have inconsistent calling conventions.  Normalising them to named
args would require a deprecation cycle.

=item * B<mro::get_linear_isa and UNIVERSAL.>  Perl's C<mro::get_linear_isa> does
not include C<UNIVERSAL> in its output unless C<UNIVERSAL> appears explicitly in
C<@ISA>.  This module appends C<UNIVERSAL> manually so that C<universal.yml> is
always discovered.  If a future Perl version changes this behaviour the guard
(C<grep { $_ eq 'UNIVERSAL' }>) remains correct.

=back

=head1 Formal Specification

=head2 configure

    configure: Class x Params -> ConfigHash

    Given:
    - C: set of all class names
    - P: set of all parameter hashes
    - F: set of all file paths
    - H: set of all configuration hashes

    State:
    - ConfigFiles: F -> H (maps file paths to configuration content)
    - EnvVars: String -> String (environment variables)
    - InheritanceChain: C -> seq C (ordered sequence of ancestor classes)

    Pre-condition:
    forall class in C, params in P:
        class != empty
        (params.config_file != empty =>
            (exists dir in params.config_dirs: readable(dir/params.config_file))
            OR readable(params.config_file))

    Post-condition:
    forall result in H:
        result = params
                 (+) (merge f in InheritanceConfigFiles(class): ConfigFiles(f))
                 (+) (merge v in RelevantEnvVars(class): v)
        result.logger in Log::Abstraction
        (forall k in dom params:
            (params(k) in CodeRef OR blessed(params(k))) => result(k) = params(k))

    where (+) denotes hash merge with right-precedence

=head2 instantiate

    instantiate: Params -> Object

    Given:
    - P: set of all parameter hashes
    - C: set of all class names
    - O: set of all objects

    Pre-condition:
    forall params in P:
        params.class in C
        params.class.can('new')

    Post-condition:
    forall result in O:
        exists config in H:
            config = configure(params.class, params)
            result = params.class.new(config)
            blessed(result) = params.class
            (config._config_file != empty =>
                result in _object_registry(params.class))

=head2 enable_hot_reload

    enable_hot_reload: Interval x Callback -> PID

    Given:
    - I: set of positive integers (intervals in seconds)
    - CB: set of code references
    - PID: set of process identifiers

    State:
    - _config_watchers: {pid: PID, callback: CB}
    - _config_file_stats: F -> Stat

    Pre-condition:
    forall interval in I, callback in CB union {empty}:
        interval >= 1
        _config_watchers = empty
        OS != 'MSWin32'

    Post-condition:
    forall result in PID:
        result > 0
        _config_watchers.pid = result
        _config_watchers.callback = callback
        (forall t in Time:
            (t mod interval = 0) =>
                (exists f in dom _config_file_stats:
                    mtime(f) > _config_file_stats(f).mtime =>
                        send_signal(SIGUSR1, parent_process)))

=head2 disable_hot_reload

    disable_hot_reload: () -> ()

    State:
    - _config_watchers: {pid: PID, callback: CB}

    Pre-condition:
    true

    Post-condition:
    _config_watchers = empty
    (forall p in PID:
        p = _config_watchers.pid@pre =>
            NOT alive(p))

=head2 reload_config

    reload_config: () -> N

    State:
    - _object_registry: C -> seq ObjectRef
    - ConfigFiles: F -> H

    Pre-condition:
    true

    Post-condition:
    forall result in N:
        result = |{obj in flatten(ran _object_registry) |
                   obj != empty
                   obj._config_file in dom ConfigFiles}|
        (forall obj in flatten(ran _object_registry):
            obj != empty AND obj._config_file in dom ConfigFiles =>
                (forall k in dom ConfigFiles(obj._config_file):
                    k NOT in PrivateKeys =>
                        obj(k)@post = ConfigFiles(obj._config_file)(k)))

    where PrivateKeys = {k | k starts with '_'}

=head2 register_object

    register_object: C x O -> ()

    Given:
    - C: set of class names
    - O: set of blessed objects
    - OR: C -> seq WeakRef(O) (object registry)

    State:
    - _object_registry: OR
    - _original_usr1_handler: SignalHandler union {empty}
    - $SIG{USR1}: SignalHandler

    Pre-condition:
    forall class in C, obj in O:
        class != empty
        obj != empty
        blessed(obj) != empty

    Post-condition:
    forall class in C, obj in O:
        exists ref in _object_registry(class):
            weak(ref) = obj
        (_original_usr1_handler = empty@pre =>
            (_original_usr1_handler@post = $SIG{USR1}@pre
             $SIG{USR1}@post = reload_config_handler))

=head2 restore_signal_handlers

    restore_signal_handlers: () -> ()

    State:
    - _original_usr1_handler: SignalHandler union {empty}
    - $SIG{USR1}: SignalHandler

    Pre-condition:
    true

    Post-condition:
    $SIG{USR1}@post = _original_usr1_handler@pre
    _original_usr1_handler@post = empty

=head2 get_signal_handler_info

    get_signal_handler_info: () -> InfoHash

    Given:
    - IH: set of all info hashes

    State:
    - _original_usr1_handler: SignalHandler union {empty}
    - $SIG{USR1}: SignalHandler union {empty}
    - _config_watchers: {pid: PID, callback: CB}

    Pre-condition:
    true

    Post-condition:
    forall result in IH:
        result.original_usr1 = _original_usr1_handler
        result.current_usr1 = $SIG{USR1}
        result.hot_reload_active = (_original_usr1_handler != empty)
        result.watcher_pid = _config_watchers.pid

=head1 SUPPORT

Please report bugs and feature requests at:

=over 4

=item * RT (CPAN bug tracker): L<http://rt.cpan.org/NoAuth/ReportBug.html?Queue=Object-Configure>

or by e-mail: C<bug-object-configure at rt.cpan.org>

=item * GitHub issues: L<https://github.com/nigelhorne/Object-Configure/issues>

=back

You will be notified automatically of progress on your report.

    perldoc Object::Configure

=head1 LICENCE AND COPYRIGHT

Copyright 2025-2026 Nigel Horne.

Usage is subject to GPL2 licence terms.
If you use it, please let me know.

=cut
1996
19971;