TER1 (Statement): 92.58%
TER2 (Branch): 87.59%
TER3 (LCSAJ): 100.0% (67/67)
Approximate LCSAJ segments: 581
● Covered — this LCSAJ path was executed during testing.
● Not covered — this LCSAJ path was never executed. These are the paths to focus on.
Multiple dots on a line indicate that multiple control-flow paths begin at that line. Hovering over any dot shows:
start → end → jump
Uncovered paths show [NOT COVERED] in the tooltip.
1: package Config::Abstraction; 2: 3: # TODO: import from __DATA__ like Config::Auto in Geo::Coder::Free 4: 5: use strict; 6: use warnings; 7: 8: use Carp; 9: use JSON::MaybeXS 'decode_json'; # Doesn't behave well with require 10: use File::Slurp qw(read_file); 11: use File::Spec; 12: use Hash::Merge qw(merge); 13: use Params::Get 0.15; 14: use Params::Validate::Strict 0.37; 15: use Scalar::Util; 16: use Readonly; 17: 18: # AES-256-GCM constants â do not change without updating _decrypt_enc_value and encrypt_value 19: Readonly::Scalar my $_AES_NONCE_SIZE => 12; # GCM standard nonce length in bytes 20: Readonly::Scalar my $_AES_TAG_SIZE => 16; # GCM authentication tag length in bytes 21: Readonly::Scalar my $_AES_KEY_SIZE => 32; # AES-256 key length in bytes 22: Readonly::Scalar my $_ENC_PAYLOAD_MIN => 28; # shortest valid payload: nonce(12) + tag(16) 23: Readonly::Scalar my $_HEX_KEY_LEN => 64; # 32 bytes expressed as hexadecimal 24: Readonly::Scalar my $_B64_KEY_MIN => 43; # minimum base64/base64url chars for 32 bytes 25: Readonly::Scalar my $_B64_KEY_MAX => 44; # maximum base64/base64url chars for 32 bytes 26: 27: 28: =head1 NAME 29: 30: Config::Abstraction - Merge and manage configuration data from different sources 31: 32: =head1 VERSION 33: 34: Version 0.41 35: 36: =cut 37: 38: our $VERSION = '0.41'; 39: 40: =head1 SYNOPSIS 41: 42: =head2 Pattern 1: Environment overrides file overrides in-code defaults 43: 44: The most common pattern for twelve-factor-style apps. 45: The C<data> argument supplies defaults, a YAML file supplies site configuration, 46: and environment variables allow per-deployment overrides without touching any file. 47: 48: # config/base.yaml 49: # database: 50: # host: db.example.com 51: # port: 5432 52: # user: app 53: 54: use Config::Abstraction; 55: 56: my $config = Config::Abstraction->new( 57: data => { database => { host => 'localhost', port => 5432 } }, 58: config_dirs => ['config'], 59: env_prefix => 'APP_', 60: ); 61: 62: # In production, set APP_DATABASE__HOST=db.prod.example.com in the environment. 63: # That silently overrides the file value, which in turn overrides the default. 64: my $host = $config->get('database.host'); 65: my $port = $config->get('database.port'); 66: 67: =head2 Pattern 2: Command-line arguments override everything 68: 69: Useful for CLI tools where operator flags must win over every other source. 70: 71: # Run as: myscript.pl --APP_LOGLEVEL=debug --APP_DATABASE__HOST=localhost 72: 73: use Config::Abstraction; 74: 75: my $config = Config::Abstraction->new( 76: config_dirs => ['config'], 77: env_prefix => 'APP_', 78: ); 79: 80: # @ARGV is consumed and stripped during new(); the resulting config already 81: # has cli-layer values at the highest precedence. 82: my $loglevel = $config->get('loglevel'); # 'debug' (from --APP_LOGLEVEL) 83: my $db_host = $config->get('database.host'); # 'localhost' (from --APP_DATABASE__HOST) 84: 85: =head2 Pattern 3: Multi-file layering (base + local override) 86: 87: Separates shared defaults from machine-specific tweaks. 88: Every developer has a C<base.yaml>; only the production server has C<local.yaml>. 89: 90: # config/base.yaml -- checked into version control 91: # database: 92: # host: localhost 93: # user: dev 94: # 95: # config/local.yaml -- NOT checked in; production only 96: # database: 97: # host: db.prod.example.com 98: # user: produser 99: # password: s3cr3t 100: 101: use Config::Abstraction; 102: 103: my $config = Config::Abstraction->new( 104: config_dirs => ['config'], # loads base.yaml then local.yaml 105: env_prefix => 'APP_', 106: ); 107: 108: # On a dev machine (no local.yaml): host='localhost', user='dev' 109: # On the prod server: host='db.prod.example.com', user='produser' 110: my $db = $config->get('database.host'); 111: my $user = $config->get('database.user'); 112: 113: =head1 DESCRIPTION 114: 115: C<Config::Abstraction> is a flexible configuration management layer that sits above C<Config::*> modules. 116: It provides a simple way to layer multiple configuration sources with predictable merge order. 117: It lets you define sources such as: 118: 119: =over 4 120: 121: =item * Perl hashes (in-memory defaults or dynamic values) 122: 123: =item * Environment variables (with optional prefixes) 124: 125: =item * Configuration files (YAML, JSON, INI, or plain key=value) 126: 127: =item * Command-line arguments 128: 129: =back 130: 131: Sources are applied in the order they are provided. Later sources override 132: earlier ones unless a key is explicitly set to C<undef> in the later source. 133: 134: In addition to using drivers to load configuration data from multiple file 135: formats (YAML, JSON, XML, and INI), 136: it also allows levels of configuration, each of which overrides the lower levels. 137: So, it also integrates environment variable 138: overrides and command line arguments for runtime configuration adjustments. 139: This module is designed to help developers manage layered configurations that can be loaded from files and overridden at run-time for debugging, 140: offering a modern, robust and dynamic approach 141: to configuration management. 142: 143: =head2 Merge Precedence 144: 145: Sources are applied in the order shown below. Each row wins over every row 146: above it. When the same key appears in multiple sources, the highest-priority 147: source always determines the final value - including when that value is C<undef>. 148: 149: Priority Source Set with 150: -------- ------ -------- 151: 1 (lo) data constructor argument data => { key => 'default' } 152: 2 base.* config files config/base.yaml, base.json, ... 153: 3 base.{env}.* config files config/base.prod.yaml, ... 154: 4 local.* config files config/local.yaml, local.json, ... 155: 5 local.{env}.* config files config/local.prod.yaml, ... 156: 6 default / script-name files config/default.yaml, myapp.yaml, ... 157: 7 config_file / config_files config_file => '/etc/myapp.yaml' 158: 8 Environment variables APP_DATABASE__HOST=db.prod.example.com 159: 9 (hi) CLI arguments (@ARGV) --APP_DATABASE__HOST=db.prod.example.com 160: 161: The active environment is set by the C<environment> constructor option, or 162: auto-detected from C<{env_prefix}ENV> (e.g. C<APP_ENV>), C<PLACK_ENV>, or 163: C<NODE_ENV>. When no environment is configured, rows 3 and 5 are skipped. 164: 165: Within the file tier (rows 2-7), later files override earlier files using 166: C<Hash::Merge> LEFT_PRECEDENT: every key in the later file wins over the same 167: key in an earlier file, even when the later value is C<undef> (YAML C<~>). 168: Nested hashes are merged recursively, so a C<local.yaml> that only sets 169: C<database.host> will not erase C<database.port> from C<base.yaml>. 170: 171: Example - what wins for the key C<database.host> when APP_ENV=prod: 172: 173: data => 'localhost' (overridden by base.yaml) 174: base.yaml => 'db.example.com' (overridden by base.prod.yaml) 175: base.prod.yaml => 'db.prod.example.com' (overridden by local.yaml) 176: local.yaml => 'db.local.example.com' (overridden by local.prod.yaml) 177: local.prod.yaml => 'db.prod-local.example.com' (overridden by $APP_DATABASE__HOST) 178: $APP_DATABASE__HOST => 'db.override.example.com' <-- this wins 179: 180: =head2 KEY FEATURES 181: 182: =over 4 183: 184: =item * Multi-Format Support 185: 186: Supports configuration files in YAML, JSON, XML, and INI formats. 187: Automatically merges configuration data from these different formats, 188: allowing hierarchical configuration management. 189: 190: =item * Environment Variable Overrides 191: 192: Allows environment variables to override values in the configuration files. 193: By setting environment variables with a specific prefix (default: C<APP_>), 194: values in the configuration files can be dynamically adjusted without modifying 195: the file contents. 196: 197: =item * Flattened Configuration Option 198: 199: Optionally supports flattening the configuration structure. This converts deeply 200: nested configuration keys into a flat key-value format (e.g., C<database.user> 201: instead of C<database-E<gt>{user}>). This makes accessing values easier for 202: applications that prefer flat structures or need compatibility with flat 203: key-value stores. 204: 205: =item * Layered Configuration 206: 207: Supports merging multiple layers of configuration files. For example, you can 208: have a C<base.yaml> configuration file that provides default values, and a 209: C<local.yaml> (or C<local.json>, C<local.xml>, etc.) file that overrides 210: specific values. This allows for environment-specific configurations while 211: keeping defaults intact. 212: 213: =item * Merge Strategy 214: 215: The module merges the configuration data intelligently, allowing values in more 216: specific files (like C<local.yaml>, C<local.json>, C<local.xml>, C<local.ini>) 217: to override values in base files. This enables a flexible and layered configuration 218: system where you can set defaults and override them for specific environments. 219: 220: =item * Error Handling 221: 222: Includes error handling for loading configuration files. 223: If any file fails to 224: load (e.g., due to syntax issues), the module will throw descriptive error 225: messages to help with debugging. 226: 227: =item * Centralized Configuration Management 228: 229: Supports remote configuration management files, 230: so that configuration on remote machines can be centrally managed. 231: 232: =back 233: 234: =head2 SUPPORTED FILE FORMATS 235: 236: =over 4 237: 238: =item * YAML (C<*.yaml>, C<*.yml>) 239: 240: Loaded with C<YAML::XS>. 241: 242: =item * JSON (C<*.json>) 243: 244: Loaded with C<JSON::MaybeXS>. 245: 246: =item * XML (C<*.xml>) 247: 248: Loaded with C<XML::Simple> (preferred) or C<XML::PP> (fallback). 249: 250: =item * INI (C<*.ini>) 251: 252: Loaded with C<Config::IniFiles>. 253: 254: =item * TOML (C<*.toml>) 255: 256: Loaded with C<TOML::Tiny>, which implements TOML 1.0. TOML is a good choice 257: for human-editable configuration because its syntax is unambiguous: all values 258: are typed, strings must be quoted, and nesting uses C<[section]> headers or 259: dotted keys rather than indentation. 260: 261: # Example: config/base.toml 262: [database] 263: host = "db.example.com" 264: port = 5432 265: user = "app" 266: 267: [cache] 268: ttl = 300 269: debug = false 270: 271: C<base.toml> and C<local.toml> are discovered automatically in C<config_dirs>; 272: C<local.toml> has higher precedence than C<base.toml>, following the same 273: layering rules as all other formats. TOML is also tried as a fallback parser 274: in the all-parsers chain used for extensionless C<config_file> entries. 275: 276: If C<TOML::Tiny> is not installed, TOML files are silently skipped with a 277: C<carp> warning. 278: 279: =back 280: 281: =head2 ENVIRONMENT VARIABLE HANDLING 282: 283: Configuration values can be overridden via environment variables. Environment variables use double underscores (__) to denote nested configuration keys and single underscores remain as part of the key name under the prefix namespace. 284: 285: For example: 286: 287: APP_DATABASE__USER becomes database.user (nested structure) 288: 289: $ export APP_DATABASE__USER="env_user" 290: 291: will override any value set for `database.user` in the configuration files. 292: 293: APP_LOGLEVEL becomes APP.loglevel (flat under prefix namespace) 294: 295: APP_API__RATE_LIMIT becomes api.rate_limit (mixed usage) 296: 297: This allows you to override both top-level and nested configuration values using environment variables. 298: 299: Configuration values can be overridden via the command line (C<@ARGV>). 300: For instance, if you have a key in the configuration such as C<database.user>, 301: you can override it by adding C<"--APP_DATABASE__USER=other_user_name"> to the command line arguments. 302: This will override any value set for C<database.user> in the configuration files. 303: 304: =head2 EXAMPLE CONFIGURATION FLOW 305: 306: =over 4 307: 308: =item 1. Data Argument 309: 310: The data passed into the constructor via the C<data> argument is the starting point. 311: Essentially, 312: this contains the default values. 313: 314: =item 2. Loading Files 315: 316: The module then looks for configuration files in the specified directories. 317: It loads the following files in order of preference: 318: C<base.yaml>, C<local.yaml>, C<base.json>, C<local.json>, C<base.xml>, 319: C<local.xml>, C<base.ini>, and C<local.ini>. 320: 321: If C<config_file> or C<config_files> is set, those files are loaded last. 322: 323: If no C<config_dirs> is given, try hard to find the files in various places. 324: 325: The value of C<config_dirs> can be overridden at runtime by the environment variable CONFIG_DIR 326: (note that is just one directory, hence it's CONFIG_DIR not CONFIG_DIRS). 327: 328: =item 3. Merging and Resolving 329: 330: The module merges the contents of these files, with more specific configurations 331: (e.g., C<local.*>) overriding general ones (e.g., C<base.*>). 332: 333: =item 4. Environment Overrides 334: 335: After loading and merging the configuration files, 336: the environment variables are 337: checked and used to override any conflicting settings. 338: 339: =item 5. Command Line 340: 341: Next, the command line arguments are checked and used to override any conflicting settings. 342: 343: =item 6. Accessing Values 344: 345: Values in the configuration can be accessed using a dotted notation 346: (e.g., C<'database.user'>), regardless of the file format used. 347: 348: =back 349: 350: =head1 METHODS 351: 352: =head2 new 353: 354: Constructor for creating a new configuration object. 355: 356: Options: 357: 358: =over 4 359: 360: =item * C<config_dirs> 361: 362: An arrayref of directories to look for configuration files 363: (default: C<$CONFIG_DIR>, C<$HOME/.conf>, C<$HOME/config>, C<$HOME/conf>, C<$DOCUMENT_ROOT/conf>, C<$DOCUMENT_ROOT/../conf>, C<conf>). 364: 365: For centralised configuration management, 366: entries beginning with C</../> are treated as remote specifications using the 367: Newcastle Connection convention -- see L</Remote configuration directories (Newcastle Connection)>. 368: 369: =item * C<config_file> 370: 371: Points to a configuration file of any format. 372: 373: =item * C<config_files> 374: 375: An arrayref of files to look for in the configuration directories. 376: Put the more important files later, 377: since later files override earlier ones. 378: 379: Considers the files C<default> and C<$script_name> before looking at C<config_file> and C<config_files>. 380: 381: =item * C<data> 382: 383: A hash ref of default data to prime the configuration with. 384: These are applied before loading 385: other sources and can be overridden by later sources or by explicitly passing 386: options directly to C<new>. 387: 388: $config = Config::Abstraction->new( 389: data => { 390: log_level => 'info', 391: retries => 3, 392: } 393: ); 394: 395: =item * C<defaults> 396: 397: A hash reference that provides default values for the object's own attributes (such as C<config_dirs>, C<logger>, C<flatten>, etc.). 398: If this option is supplied, 399: the object is initialized using the keys in this hash as the base; 400: any other options passed directly to C<new()> (aside from C<env_prefix>) are ignored. 401: This allows you to pre-define a standard configuration profile for the object itself. 402: Note that C<defaults> is distinct from the C<data> option - C<data> supplies the initial configuration values that will be merged with files, environment, and command line, 403: while C<defaults> sets the object's internal parameters. 404: The C<env_prefix> value, 405: if provided as a top-level argument, 406: still takes precedence over any C<env_prefix> that might exist inside the C<defaults> hash. 407: 408: =item * C<encryption_key> 409: 410: A 256-bit (32-byte) AES key used to transparently decrypt C<ENC[...]> values found in any 411: configuration source after the full merge. The key may be supplied as: 412: 413: =over 4 414: 415: =item 32 raw bytes 416: 417: =item 64 lowercase or uppercase hex characters 418: 419: =item 44 Base64 or Base64url characters (standard or URL-safe alphabet, with or without trailing C<=>) 420: 421: =back 422: 423: If no key is configured (and neither C<encryption_key_file> nor the corresponding 424: environment variables are set), C<ENC[...]> tokens are left as literal strings. 425: Decryption requires L<CryptX> (C<Crypt::AuthEnc::GCM>); if that module is absent a 426: C<croak> is raised when an encrypted value is encountered. 427: 428: See L</ENCRYPTED VALUES> for the full workflow. 429: 430: =item * C<encryption_key_file> 431: 432: Path to a file whose first line contains the encryption key in any of the formats accepted 433: by C<encryption_key>. Takes precedence over the C<ENCRYPTION_KEY_FILE> and 434: C<{env_prefix}ENCRYPTION_KEY_FILE> environment variables, but is overridden by a key 435: supplied directly via C<encryption_key>. 436: 437: =item * C<env_prefix> 438: 439: A prefix for environment variable keys and comment line options, e.g. C<MYAPP_DATABASE__USER>, 440: (default: C<'APP_'>). 441: 442: =item * C<environment> 443: 444: The name of the active deployment environment (e.g. C<'dev'>, C<'staging'>, C<'prod'>). 445: When set, config files named C<base.{env}.*> are loaded immediately after their C<base.*> 446: equivalents, and C<local.{env}.*> files are loaded immediately after C<local.*> files, 447: giving two additional override tiers at no cost to the base configuration. 448: 449: my $cfg = Config::Abstraction->new( 450: config_dirs => ['config'], 451: environment => 'prod', # loads base.prod.yaml, local.prod.yaml, ... 452: ); 453: 454: If C<environment> is not given, the value is auto-detected in this order: 455: 456: =over 4 457: 458: =item 1. C<{env_prefix}ENV> environment variable (e.g. C<APP_ENV>) 459: 460: =item 2. C<PLACK_ENV> 461: 462: =item 3. C<NODE_ENV> 463: 464: =back 465: 466: When none of these is set, the environment-specific tiers are silently skipped. 467: The environment name must contain only ASCII letters, digits, hyphens, and underscores 468: (matched against C</^[A-Za-z0-9_\-]+$/>); any other value causes a C<croak>. 469: 470: =item * C<file> 471: 472: Synonym for C<config_file> 473: 474: =item * C<flatten> 475: 476: If true, returns a flat hash structure like C<{database.user}> (default: C<0>) instead of C<{database}{user}>. 477: ` 478: =item * C<level> 479: 480: Level for logging. 481: 482: =item * C<logger> 483: 484: Used for warnings and traces. 485: It can be an object that understands warn() and trace() messages, 486: such as a L<Log::Log4perl> or L<Log::Any> object, 487: a reference to code, 488: a reference to an array, 489: or a filename. 490: 491: =item * C<path> 492: 493: A synonym of C<config_dirs>. 494: 495: =item * C<sep_char> 496: 497: The separator in keys. 498: The default is a C<'.'>, 499: as in dotted notation, 500: such as C<'database.user'>. 501: 502: =item * C<schema> 503: 504: A L<Params::Validate::Strict> compatible schema to validate the configuration file against. 505: 506: =item * C<validators> 507: 508: A hashref mapping dotted config keys to validation rules. Each rule is applied to the 509: corresponding value in the merged configuration immediately after all sources have been 510: merged (or on first access when C<lazy> is set). A validation failure croaks. 511: 512: Each rule may be one of: 513: 514: =over 4 515: 516: =item A type name string 517: 518: validators => { 519: 'database.port' => 'integer', 520: 'app.name' => 'string', 521: 'price' => 'number', 522: 'enabled' => 'boolean', 523: 'tags' => 'array', 524: 'settings' => 'hash', 525: } 526: 527: Supported types: C<integer> (matches C</^-?\d+$/> -- no decimal point), 528: C<number> or C<float> (C<Scalar::Util::looks_like_number>), C<boolean> 529: (C<0>, C<1>, C<true>, C<false>, C<yes>, C<no> -- case-insensitive), 530: C<string> (any defined non-reference scalar), C<array> (arrayref), 531: C<hash> (hashref). 532: 533: =item A compiled regular expression 534: 535: validators => { 536: 'log.level' => qr/^(?:debug|info|warn|error|fatal)$/i, 537: 'app.name' => qr/^\w[\w\-]{1,63}$/, 538: } 539: 540: The value must be defined and must match the regex. 541: 542: =item A coderef 543: 544: validators => { 545: 'database.port' => sub { my $v = shift; defined($v) && $v >= 1 && $v <= 65535 }, 546: } 547: 548: Called with the value as its only argument. Must return a true value; otherwise the 549: constructor croaks. 550: 551: =item A hashref combining multiple constraints 552: 553: validators => { 554: 'database.port' => { 555: type => 'integer', 556: min => 1, 557: max => 65535, 558: required => 1, 559: }, 560: 'api.key' => { 561: pattern => qr/^[A-Za-z0-9]{32}$/, 562: required => 1, 563: }, 564: } 565: 566: Keys: C<type> (type-name string as above), C<pattern> (compiled regex), C<min> (numeric 567: lower bound, inclusive), C<max> (numeric upper bound, inclusive), C<required> (if true, 568: the key must exist and its value must be defined). 569: 570: =back 571: 572: All constraint types may be freely combined: specifying both C<type> and C<pattern> 573: requires the value to satisfy both. 574: 575: =item * C<checker> 576: 577: A prototype string (YAML) or hashref passed to L<Config::Checker> for template-based 578: structural validation. C<Config::Checker> must be installed; if it is absent a C<carp> 579: warning is emitted and validation is skipped. 580: 581: The prototype mirrors the expected config structure. Keys and values can carry type 582: annotations (C<[INTEGER]>, C<[PATH]>, C<[HOSTNAME]>, ...), custom code checks 583: (C<{...}>), and quantity specifiers (C<?>: optional, C<+>: one or more, C<*>: zero or 584: more): 585: 586: my $config = Config::Abstraction->new( 587: config_dirs => ['config'], 588: checker => <<'END_PROTOTYPE', 589: database: 590: host: hostname of the database server[HOSTNAME] 591: port: '?<5432>port number[INTEGER]' 592: user: database username 593: END_PROTOTYPE 594: ); 595: 596: See L<Config::Checker> for the full prototype syntax. 597: 598: =item * C<lazy> 599: 600: When set to a true value, all source discovery and file I/O are deferred until the first 601: call to C<get()>, C<exists()>, C<all()>, C<explain_sources()>, C<prefer_*()>, 602: C<merge_defaults()>, or any AUTOLOAD accessor. This can reduce startup time in 603: applications that construct a C<Config::Abstraction> object before they know whether 604: they will access it. 605: 606: B<Differences from the default (eager) behaviour, and the debugging problems they cause:> 607: 608: =over 4 609: 610: =item 1. C<new()> always succeeds, even when no configuration exists. 611: 612: In eager mode, C<new()> returns C<undef> immediately when no files are found and no 613: C<data> was supplied, so a missing config is caught at the point of construction. 614: In lazy mode, C<new()> always returns a blessed object. If the configuration 615: directories do not exist, the files are missing, or the prefix is wrong, you will 616: not find out until the first accessor call - which may be deep inside your business 617: logic, far from where the object was created. 618: 619: # Problem: the typo in config_dirs goes unnoticed until runtime 620: my $cfg = Config::Abstraction->new( 621: config_dirs => ['/etc/myapp/conifg'], # typo - directory does not exist 622: lazy => 1, 623: ); 624: # ... many lines later ... 625: my $host = $cfg->get('database.host'); # silently returns undef here 626: 627: =item 2. Schema validation errors appear at the wrong place in the call stack. 628: 629: When a C<schema> is supplied, eager mode validates the merged configuration inside 630: C<new()> and throws immediately if the config does not match. In lazy mode, 631: validation is deferred to the first accessor call. A schema violation therefore 632: surfaces as an exception thrown by C<get()> or C<all()>, not by C<new()>, and the 633: stack trace points to the accessor call rather than the construction site. If the 634: object is created in one module and accessed in another, the error message can be 635: very misleading. 636: 637: =item 3. Environment variables and C<@ARGV> are captured at access time, not construction time. 638: 639: The module reads C<%ENV> and C<@ARGV> during C<_load_config()>. In eager mode that 640: happens in C<new()>, so the configuration reflects the environment at the moment the 641: object is built. In lazy mode, any change to C<%ENV> or C<@ARGV> between C<new()> 642: and the first accessor call is silently picked up. This makes test isolation harder: 643: setting an environment variable I<after> constructing a lazy object will affect the 644: values it returns, which is not the case with an eager object. 645: 646: =item 4. File system state may change between construction and first use. 647: 648: Because lazy mode defers all I/O, the files that are read are those that exist at the 649: moment of first access, not at the moment C<new()> is called. If a config file is 650: written, deleted, or replaced between those two points (for example, by another 651: process or test fixture), the object will silently use the new state. An eager object 652: is immune to this race. 653: 654: =item 5. Errors from format parsers appear at accessor call sites. 655: 656: Malformed YAML, JSON, XML, or INI files emit a C<carp> warning and are skipped during 657: loading. In eager mode those warnings appear near the program's startup. In lazy mode 658: they appear during the first accessor call, which can make it look as though a routine 659: in your business logic is generating configuration warnings. 660: 661: =back 662: 663: B<When to use lazy loading:> It is most useful when a C<Config::Abstraction> object is 664: created speculatively at module-load time and may never be accessed (for example, in a 665: web framework where the config object is built for every request but some request paths 666: never read it). Avoid it when startup correctness is important, when you rely on 667: C<new()> returning C<undef> to detect missing configuration, or when you use schema 668: validation and need errors to point at the construction site. 669: 670: =back 671: 672: If just one argument is given, it is assumed to be the name of a file. 673: 674: =cut 675: 676: sub new 677: { ●678 → 681 → 688 678: my $class = shift; 679: my $params;Mutants (Total: 2, Killed: 2, Survived: 0)
680: 681: if(scalar(@_) == 1) { 682: # Just one parameter - the name of a file 683: $params = Params::Get::get_params('file', \@_); 684: } else { 685: $params = Params::Get::get_params(undef, \@_) || {}; 686: } 687: ●688 → 694 → 723 688: $params->{'config_dirs'} //= $params->{'path'}; # Compatibility with Config::Auto 689: 690: $params->{config_dirs} = [ $ENV{CONFIG_DIR} ] if(defined($ENV{CONFIG_DIR})); 691: 692: $params->{'config_file'} //= $params->{'file'} if($params->{'file'});
Mutants (Total: 1, Killed: 1, Survived: 0)
693:
Mutants (Total: 1, Killed: 1, Survived: 0)
694: if(!defined($params->{'config_dirs'})) { 695: if($params->{'config_file'} && File::Spec->file_name_is_absolute($params->{'config_file'})) { 696: $params->{'config_dirs'} = ['']; 697: } else {
698: # Set up the default value for config_dirs 699: if($^O ne 'MSWin32') { 700: $params->{'config_dirs'} = [ '/etc', '/usr/local/etc' ]; 701: } else { 702: $params->{'config_dirs'} = [''];Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_697_4: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomesMutants (Total: 1, Killed: 1, Survived: 0)
703: } 704: if($ENV{'HOME'}) { 705: push @{$params->{'config_dirs'}}, 706: File::Spec->catdir($ENV{'HOME'}, '.conf'), 707: File::Spec->catdir($ENV{'HOME'}, '.config'), 708: File::Spec->catdir($ENV{'HOME'}, 'conf'), 709: } elsif($ENV{'DOCUMENT_ROOT'}) { 710: push @{$params->{'config_dirs'}}, 711: File::Spec->catdir($ENV{'DOCUMENT_ROOT'}, File::Spec->updir(), 'conf'), 712: File::Spec->catdir($ENV{'DOCUMENT_ROOT'}, 'conf'), 713: File::Spec->catdir($ENV{'DOCUMENT_ROOT'}, 'config');
Mutants (Total: 1, Killed: 1, Survived: 0)
714: } 715: if(my $dir = $ENV{'CONFIG_DIR'}) { 716: push @{$params->{'config_dirs'}}, $dir; 717: } else { 718: push @{$params->{'config_dirs'}}, 'conf', 'config'; 719: } 720: } 721: } 722: ●723 → 734 → 755 723: my $self = bless { 724: sep_char => '.', 725: %{$params->{defaults} ? $params->{defaults} : $params}, 726: env_prefix => $params->{env_prefix} || 'APP_', 727: config => {}, 728: }, $class; 729: 730: # Cache the compiled sep_char regex once so get() and exists() do not 731: # recompile it on every key lookup. 732: $self->{'_sep_re'} = qr/\Q$self->{'sep_char'}\E/;
Mutants (Total: 1, Killed: 1, Survived: 0)
733:
Mutants (Total: 1, Killed: 1, Survived: 0)
734: if(my $logger = $self->{'logger'}) { 735: if(!Scalar::Util::blessed($logger)) { 736: # Don't call $self->_load_driver('Log::Abstraction') as it can make a call to logger, which is yet to be set up
Mutants (Total: 1, Killed: 1, Survived: 0)
737: eval "require Log::Abstraction"; 738: if($@) {
Mutants (Total: 1, Killed: 1, Survived: 0)
739: carp(ref($self), ": Log::Abstraction failed to load: $@, disabling logging"); 740: if(ref($logger) eq 'ARRAY') { 741: push @{$logger}, "Log::Abstraction failed to load: $@, disabling logging"; 742: } 743: $self->{'logger'} = undef; # disable: unblessed ref would fatal on method calls 744: } else { 745: Log::Abstraction->import();
746: $self->{'logger'} = Log::Abstraction->new($logger); 747: if($params->{'level'} && $self->{'logger'}->can('level')) { 748: $self->{'logger'}->level($params->{'level'}); 749: } 750: } 751: } 752: } 753: # Fail early when an explicitly-specified key file is missing rather thanMutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_745_5: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomesMutants (Total: 1, Killed: 1, Survived: 0)
754: # silently falling back to unencrypted operation (env-var key files are optional). ●755 → 755 → 760 755: if(defined $self->{'encryption_key_file'}) { 756: my $kf = $self->{'encryption_key_file'}; 757: Carp::croak(ref($self) . ": encryption_key_file '$kf' does not exist") unless -f $kf; 758: }
Mutants (Total: 1, Killed: 1, Survived: 0)
759: ●760 → 760 → 769 760: if($self->{'lazy'}) { 761: # Defer all source scanning to the first accessor call. 762: # Stash validators/checker/schema for later (after _load_config runs). 763: $self->{'_lazy_schema'} = delete $self->{'schema'} if $self->{'schema'}; 764: $self->{'_lazy_validators'} = delete $self->{'validators'} if $self->{'validators'};
Mutants (Total: 2, Killed: 2, Survived: 0)
765: $self->{'_lazy_checker'} = delete $self->{'checker'} if $self->{'checker'}; 766: return $self; 767: } 768: ●769 → 771 → 774 769: $self->_load_config();
Mutants (Total: 1, Killed: 1, Survived: 0)
770: 771: if(my $schema = $params->{'schema'}) { 772: $self->{'config'} = Params::Validate::Strict::validate_strict(schema => $schema, input => $self->{'config'});
Mutants (Total: 1, Killed: 1, Survived: 0)
773: } ●774 → 774 → 777 774: if(my $validators = $params->{'validators'}) { 775: $self->_run_validators($validators);
Mutants (Total: 1, Killed: 1, Survived: 0)
776: } ●777 → 777 → 781 777: if(my $checker = $params->{'checker'}) { 778: $self->_run_checker($checker); 779: }
Mutants (Total: 1, Killed: 1, Survived: 0)
780:
Mutants (Total: 2, Killed: 2, Survived: 0)
●781 → 781 → 784 781: if(defined($self->{'config'}) && scalar(keys %{$self->{'config'}})) { 782: return $self;
Mutants (Total: 2, Killed: 2, Survived: 0)
783: } 784: return undef; 785: } 786: 787: # Trigger deferred loading when the object was constructed with lazy => 1. 788: # Safe to call unconditionally: it is a no-op once loading has completed. 789: sub _ensure_loaded 790: { ●791 → 795 → 798 791: my $self = shift; 792: return unless $self->{'lazy'}; # fast-path: not a lazy object 793: delete $self->{'lazy'}; # prevent re-entry on recursive calls
Mutants (Total: 1, Killed: 1, Survived: 0)
794: $self->_load_config(); 795: if(my $schema = delete $self->{'_lazy_schema'}) { 796: $self->{'config'} = Params::Validate::Strict::validate_strict(schema => $schema, input => $self->{'config'});
Mutants (Total: 1, Killed: 1, Survived: 0)
797: } ●798 → 798 → 801 798: if(my $validators = delete $self->{'_lazy_validators'}) { 799: $self->_run_validators($validators);
800: } ●801 → 801 → 0 801: if(my $checker = delete $self->{'_lazy_checker'}) { 802: $self->_run_checker($checker); 803: } 804: } 805: 806: # Resolve the active deployment environment name. 807: # Checked in order: 'environment' constructor option, {prefix}ENV env var, 808: # PLACK_ENV, NODE_ENV. Returns undef when none is set. 809: # The value must match /^[A-Za-z0-9_\-]+$/ or a croak is raised. 810: sub _get_environment 811: { ●812 → 816 → 825 812: my $self = shift; 813: 814: my $env;Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_799_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomesMutants (Total: 1, Killed: 1, Survived: 0)
815: 816: if(defined($self->{'environment'})) { 817: $env = $self->{'environment'}; 818: } else { 819: my $prefix = $self->{'env_prefix'} // 'APP_'; 820: $env = $ENV{$prefix . 'ENV'} 821: // $ENV{'PLACK_ENV'} 822: // $ENV{'NODE_ENV'}; 823: }
Mutants (Total: 2, Killed: 2, Survived: 0)
824:
Mutants (Total: 2, Killed: 2, Survived: 0)
●825 → 828 → 832 825: return undef unless defined $env; 826: return undef if $env eq '';
Mutants (Total: 1, Killed: 1, Survived: 0)
827: 828: unless($env =~ /^[A-Za-z0-9_-]+$/) { 829: Carp::croak(ref($self) . ": invalid environment name '$env': must contain only alphanumerics, hyphens, or underscores"); 830: }
Mutants (Total: 2, Killed: 2, Survived: 0)
831: 832: return $env; 833: } 834: 835: # _sanitize_yaml_values: Recursively replace CODE and GLOB refs with undef. 836: # YAML::XS can produce CODE refs from !!perl/code tags regardless of DisableCode. 837: # Applying this after LoadFile prevents executable references from reaching callers. 838: sub _sanitize_yaml_values 839: { ●840 → 843 → 846 840: my ($self, $val) = @_;
841: my $r = ref($val);Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_840_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_840_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );Mutants (Total: 1, Killed: 1, Survived: 0)
842: return undef if $r eq 'CODE' || $r eq 'GLOB'; 843: if($r eq 'HASH') { 844: return { map { $_ => $self->_sanitize_yaml_values($val->{$_}) } keys %$val };
Mutants (Total: 1, Killed: 1, Survived: 0)
845: } ●846 → 846 → 849 846: if($r eq 'ARRAY') { 847: return [ map { $self->_sanitize_yaml_values($_) } @$val ];
Mutants (Total: 2, Killed: 2, Survived: 0)
848: } 849: return $val; 850: } 851: 852: # Resolve the AES-256 encryption key from constructor option, env vars, or key file. 853: # Returns 32 raw bytes, or undef when no key is configured. 854: sub _get_encryption_key 855: { ●856 → 860 → 865 856: my $self = shift; 857: 858: my $raw = $self->{'encryption_key'};
Mutants (Total: 1, Killed: 1, Survived: 0)
859: 860: if(!defined($raw)) { 861: my $prefix = $self->{'env_prefix'} // 'APP_'; 862: $raw = $ENV{$prefix . 'ENCRYPTION_KEY'} // $ENV{'ENCRYPTION_KEY'}; 863: }
Mutants (Total: 1, Killed: 1, Survived: 0)
864: ●865 → 865 → 879 865: if(!defined($raw)) { 866: my $prefix = $self->{'env_prefix'} // 'APP_'; 867: my $kf = $self->{'encryption_key_file'} 868: // $ENV{$prefix . 'ENCRYPTION_KEY_FILE'}
Mutants (Total: 1, Killed: 1, Survived: 0)
869: // $ENV{'ENCRYPTION_KEY_FILE'}; 870: if(defined($kf) && -f $kf) { 871: open my $fh, '<', $kf 872: or Carp::croak(ref($self) . ": cannot open encryption_key_file '$kf': $!"); 873: $raw = <$fh>; 874: close $fh; 875: chomp $raw if defined $raw; 876: } 877: }
Mutants (Total: 2, Killed: 2, Survived: 0)
878:
Mutants (Total: 2, Killed: 2, Survived: 0)
879: return undef unless defined $raw; 880: return $self->_decode_encryption_key($raw); 881: } 882: 883: # Accept a key as 32 raw bytes, 64 hex chars, or 43-44 base64/base64url chars. 884: # Returns 32 raw bytes or croaks. 885: sub _decode_encryption_key 886: { ●887 → 891 → 895 887: my ($self, $raw) = @_;
Mutants (Total: 3, Killed: 3, Survived: 0)
888: 889: return $raw if length($raw) == $_AES_KEY_SIZE;
Mutants (Total: 2, Killed: 2, Survived: 0)
890:
Mutants (Total: 2, Killed: 2, Survived: 0)
891: if(length($raw) == $_HEX_KEY_LEN && $raw =~ /^[0-9A-Fa-f]{$_HEX_KEY_LEN}$/) { 892: return pack('H*', $raw); 893: }
Mutants (Total: 7, Killed: 7, Survived: 0)
894: ●895 → 895 → 903 895: if(length($raw) >= $_B64_KEY_MIN && length($raw) <= $_B64_KEY_MAX) { 896: require MIME::Base64; 897: (my $b64 = $raw) =~ tr/-_/+\//; # base64url â base64 898: $b64 .= '=' x ((4 - length($b64) % 4) % 4);
Mutants (Total: 3, Killed: 3, Survived: 0)
899: my $bytes = MIME::Base64::decode_base64($b64); 900: return $bytes if length($bytes) == $_AES_KEY_SIZE; 901: } 902: 903: Carp::croak(ref($self) . ': encryption_key must be ' . $_AES_KEY_SIZE . ' raw bytes, ' . $_HEX_KEY_LEN . ' hex chars, or ' . $_B64_KEY_MAX . ' base64 chars (got length ' . length($raw) . ')'); 904: } 905: 906: # Recursively walk $href and decrypt any leaf value matching ENC[...]. 907: # $seen guards against circular references (possible when Hash::Merge 908: # no-clone mode is active and sub-hashes are shared across the merge tree). 909: sub _decrypt_config_values 910: { ●911 → 915 → 0 911: my ($self, $href, $key, $seen) = @_; 912: $seen //= {}; 913: return if $seen->{Scalar::Util::refaddr($href)}++; 914: 915: for my $k (keys %{$href}) { 916: next if $k eq 'config_path';
Mutants (Total: 1, Killed: 1, Survived: 0)
917: my $v = $href->{$k}; 918: if(ref($v) eq 'HASH') { 919: $self->_decrypt_config_values($v, $key, $seen); 920: } elsif(ref($v) eq 'ARRAY') {
Mutants (Total: 1, Killed: 1, Survived: 0)
921: for my $i (0..$#{$v}) { 922: if(!ref($v->[$i]) && defined($v->[$i]) && $v->[$i] =~ /^ENC\[/) { 923: $v->[$i] = $self->_decrypt_enc_value($v->[$i], $key); 924: } 925: } 926: } elsif(!ref($v) && defined($v) && $v =~ /^ENC\[/) { 927: $href->{$k} = $self->_decrypt_enc_value($v, $key); 928: } 929: } 930: } 931: 932: # Decrypt a single ENC[AES256GCM,<base64url>] token. 933: # Returns the plaintext string or croaks on failure. 934: sub _decrypt_enc_value 935: { ●936 → 938 → 947 936: my ($self, $token, $key) = @_;
Mutants (Total: 1, Killed: 1, Survived: 0)
937: 938: unless($token =~ /^ 939: ENC\[ 940: ([A-Za-z0-9]+) # algorithm name (e.g. AES256GCM) 941: , 942: ([A-Za-z0-9_-]+) # base64url payload (RFC 4648 §5 alphabet) 943: \] 944: $/x) { 945: Carp::croak(ref($self) . ": malformed ENC token: $token"); 946: } ●947 → 949 → 956 947: my ($algo, $b64) = ($1, $2);
Mutants (Total: 1, Killed: 1, Survived: 0)
948: 949: unless(lc($algo) eq 'aes256gcm') { 950: Carp::croak(ref($self) . ": unsupported encryption algorithm '$algo' in ENC token"); 951: } 952: 953: # Decode and validate payload length before requiring CryptX: a structurally 954: # invalid token should be rejected regardless of whether the crypto library 955: # is installed. MIME::Base64 is a core module and always available. ●956 → 959 → 963 956: require MIME::Base64; 957: my $raw = MIME::Base64::decode_base64url($b64);
Mutants (Total: 4, Killed: 4, Survived: 0)
958: 959: if(length($raw) < $_ENC_PAYLOAD_MIN) { 960: Carp::croak(ref($self) . ": ENC token payload is too short to be valid"); 961: }
Mutants (Total: 1, Killed: 1, Survived: 0)
962: ●963 → 963 → 967 963: unless($self->_load_driver('Crypt::AuthEnc::GCM')) { 964: Carp::croak(ref($self) . ': CryptX (Crypt::AuthEnc::GCM) is required to decrypt ENC[] values; install it with: cpanm CryptX'); 965: } 966: ●967 → 977 → 981 967: my $nonce = substr($raw, 0, $_AES_NONCE_SIZE); 968: my $tag = substr($raw, -$_AES_TAG_SIZE); 969: my $ct = substr($raw, $_AES_NONCE_SIZE, length($raw) - $_ENC_PAYLOAD_MIN); 970: 971: my $gcm = Crypt::AuthEnc::GCM->new('AES', $key); 972: $gcm->iv_add($nonce); 973: $gcm->adata_add(''); # end the IV phase; see encrypt_value() 974: my $pt = $gcm->decrypt_add($ct);
Mutants (Total: 1, Killed: 1, Survived: 0)
975: 976: my $ok = eval { $gcm->decrypt_done($tag) }; 977: unless($ok && !$@) { 978: Carp::croak(ref($self) . ": decryption failed (wrong key or tampered data)");
Mutants (Total: 2, Killed: 2, Survived: 0)
979: } 980: 981: return $pt; 982: } 983: 984: # Apply the validators hash supplied as a constructor option. 985: # Each key is a dotted config key; each value is a coderef, regex, type string, or spec hashref. 986: sub _run_validators 987: { ●988 → 990 → 0 988: my ($self, $validators) = @_; 989: 990: for my $key (sort keys %{$validators}) { 991: my $spec = $validators->{$key};
Mutants (Total: 1, Killed: 1, Survived: 0)
992: my $value = $self->get($key);
Mutants (Total: 1, Killed: 1, Survived: 0)
993: 994: if(ref($spec) eq 'CODE') { 995: unless($spec->($value)) { 996: Carp::croak(ref($self) . ": validation failed for '$key': custom validator returned false");
Mutants (Total: 1, Killed: 1, Survived: 0)
997: } 998: } elsif(ref($spec) eq 'Regexp') { 999: unless(defined($value) && $value =~ $spec) { 1000: Carp::croak(ref($self) . ": '$key' value " . 1001: (defined($value) ? "'$value'" : '(undef)') . " does not match required pattern"); 1002: } 1003: } elsif(ref($spec) eq 'HASH') { 1004: $self->_validate_value_spec($key, $value, $spec); 1005: } elsif(!ref($spec)) { 1006: _validate_type($key, $value, $spec); 1007: } else { 1008: Carp::croak(ref($self) . ": invalid validator for '$key': must be a type string, regex, coderef, or hashref"); 1009: } 1010: } 1011: } 1012: 1013: # Apply a hashref spec (type / pattern / min / max / required) to a single value. 1014: sub _validate_value_spec 1015: {
Mutants (Total: 1, Killed: 1, Survived: 0)
●1016 → 1018 → 1021 1016: my ($self, $key, $value, $spec) = @_; 1017: 1018: if($spec->{'required'} && (!$self->exists($key) || !defined($value))) {
Mutants (Total: 1, Killed: 1, Survived: 0)
1019: Carp::croak(ref($self) . ": required key '$key' is missing or undefined"); 1020: } ●1021 → 1021 → 1024 1021: if(my $type = $spec->{'type'}) {
Mutants (Total: 1, Killed: 1, Survived: 0)
1022: _validate_type($key, $value, $type);
Mutants (Total: 1, Killed: 1, Survived: 0)
1023: } ●1024 → 1024 → 1030 1024: if(my $pattern = $spec->{'pattern'}) { 1025: unless(defined($value) && $value =~ $pattern) { 1026: Carp::croak(ref($self) . ": '$key' value " . 1027: (defined($value) ? "'$value'" : '(undef)') . " does not match required pattern");
Mutants (Total: 1, Killed: 1, Survived: 0)
1028: } 1029: }
Mutants (Total: 3, Killed: 3, Survived: 0)
●1030 → 1030 → 1034 1030: if(defined(my $min = $spec->{'min'})) { 1031: Carp::croak(ref($self) . ": '$key' value '$value' is less than minimum $min")
Mutants (Total: 1, Killed: 1, Survived: 0)
1032: if defined($value) && $value < $min; 1033: }
Mutants (Total: 3, Killed: 3, Survived: 0)
●1034 → 1034 → 0 1034: if(defined(my $max = $spec->{'max'})) { 1035: Carp::croak(ref($self) . ": '$key' value '$value' exceeds maximum $max") 1036: if defined($value) && $value > $max; 1037: } 1038: } 1039: 1040: # Validate a single value against a named type. 1041: # Not a method â takes (key, value, type_string). 1042: sub _validate_type 1043: { ●1044 → 1047 → 0 1044: my ($key, $value, $type) = @_;
Mutants (Total: 1, Killed: 1, Survived: 0)
1045: my $ltype = lc($type); 1046: 1047: if($ltype eq 'integer') { 1048: Carp::croak("Config::Abstraction: '$key' must be an integer (got " . 1049: (defined $value ? "'$value'" : 'undef') . ')') 1050: unless defined($value) && $value =~ /^-?[0-9]+$/; 1051: } elsif($ltype eq 'number' || $ltype eq 'float') { 1052: Carp::croak("Config::Abstraction: '$key' must be a number (got " . 1053: (defined $value ? "'$value'" : 'undef') . ')') 1054: unless defined($value) && Scalar::Util::looks_like_number($value); 1055: } elsif($ltype eq 'boolean') { 1056: Carp::croak("Config::Abstraction: '$key' must be a boolean (0/1/true/false/yes/no), got " . 1057: (defined $value ? "'$value'" : 'undef')) 1058: unless defined($value) && $value =~ /^(?:1|0|true|false|yes|no)$/i; 1059: } elsif($ltype eq 'string') { 1060: Carp::croak("Config::Abstraction: '$key' must be a defined string (got undef)") 1061: unless defined($value) && !ref($value); 1062: } elsif($ltype eq 'array') { 1063: Carp::croak("Config::Abstraction: '$key' must be an array reference") 1064: unless ref($value) eq 'ARRAY'; 1065: } elsif($ltype eq 'hash') { 1066: Carp::croak("Config::Abstraction: '$key' must be a hash reference") 1067: unless ref($value) eq 'HASH'; 1068: } else { 1069: Carp::croak("Config::Abstraction: unknown type '$type' in validators for '$key'"); 1070: } 1071: } 1072: 1073: # Invoke Config::Checker with the merged config and caller-supplied prototype. 1074: # prototype may be a YAML string or a hashref. 1075: sub _run_checker 1076: {
Mutants (Total: 1, Killed: 1, Survived: 0)
●1077 → 1079 → 1086 1077: my ($self, $prototype) = @_; 1078: 1079: unless($self->_load_driver('Config::Checker')) { 1080: Carp::carp(ref($self) . ': Config::Checker not available; skipping checker validation'); 1081: return; 1082: } 1083: 1084: # config_checker_source() returns Perl source text (a sub { ... } literal) 1085: # that must be eval'd so it can import into our namespace. 1086: my $checker = eval Config::Checker::config_checker_source(); ## no critic (ProhibitStringyEval) 1087: Carp::croak(ref($self) . ": failed to compile Config::Checker: $@") if $@; 1088: 1089: eval { $checker->($self->{'config'}, $prototype) }; 1090: Carp::croak(ref($self) . ": checker validation failed: $@") if $@; 1091: } 1092: 1093: # Determine if a value is a plain, unblessed, non-reference scalar 1094: # safe to use in regex/string operations. 1095: # Args: value to test 1096: # Returns: 1 if plain scalar, 0 otherwise 1097: sub _is_plain_scalar 1098: {
Mutants (Total: 2, Killed: 2, Survived: 0)
1099: my $val = $_[0];
Mutants (Total: 2, Killed: 2, Survived: 0)
1100:
Mutants (Total: 2, Killed: 2, Survived: 0)
1101: return 0 if !defined($val);
Mutants (Total: 2, Killed: 2, Survived: 0)
1102: return 0 if Scalar::Util::blessed($val); 1103: return 0 if ref($val); 1104: return 1; 1105: } 1106: 1107: # Recursively flatten a nested hashref to dotted keys (always uses '.' as separator). 1108: # Skips the meta-key 'config_path'. Used by explain_sources() and source tracking. 1109: # $seen guards against circular references (e.g. those possible when 1110: # Hash::Merge::set_clone_behavior(0) is active). 1111: # 1112: # _flatten_into writes into a caller-supplied hashref accumulator so that 1113: # each leaf key is written exactly once -- O(N) total writes vs the previous 1114: # O(N^2) pattern of %flat = (%flat, _flatten_keys(...)) at each recursion level. 1115: sub _flatten_into 1116: { ●1117 → 1121 → 0 1117: my ($acc, $hash, $prefix, $seen) = @_; 1118: return unless ref($hash) eq 'HASH'; 1119: my $addr = Scalar::Util::refaddr($hash); 1120: return if $seen->{$addr}++; 1121: for my $k (keys %$hash) {
Mutants (Total: 1, Killed: 1, Survived: 0)
1122: next if $k eq 'config_path'; 1123: my $full = length($prefix) ? "$prefix.$k" : $k; 1124: if(ref($hash->{$k}) eq 'HASH') { 1125: _flatten_into($acc, $hash->{$k}, $full, $seen); 1126: } else { 1127: $acc->{$full} = $hash->{$k}; 1128: } 1129: } 1130: } 1131: 1132: sub _flatten_keys 1133: { 1134: my ($hash, $prefix, $seen) = @_;
Mutants (Total: 2, Killed: 2, Survived: 0)
1135: my %flat; 1136: _flatten_into(\%flat, $hash, $prefix // '', $seen // {}); 1137: return %flat; 1138: } 1139:
Mutants (Total: 1, Killed: 1, Survived: 0)
1140: sub _load_config 1141: { ●1142 → 1142 → 1146 1142: if(!UNIVERSAL::isa((caller)[0], __PACKAGE__)) { 1143: Carp::croak('Illegal Operation: This method can only be called by a subclass'); 1144: } 1145: ●1146 → 1157 → 1171 1146: my $self = shift; 1147: 1148: # Disable Hash::Merge cloning for the duration of this method. 1149: # Storable::dclone (used when clone=1) cannot handle coderefs or blessed 1150: # objects that may appear in the 'data' argument; sharing references is 1151: # safe here because %merged is a fresh, method-local accumulator. 1152: my $saved_clone = Hash::Merge::get_clone_behavior(); 1153: Hash::Merge::set_clone_behavior(0); 1154:
Mutants (Total: 1, Killed: 1, Survived: 0)
1155: my %merged; 1156:
Mutants (Total: 1, Killed: 1, Survived: 0)
1157: if($self->{'data'}) { 1158: # The data argument given to 'new' contains defaults that this routine will override 1159: if(ref($self->{'data'}) eq 'HASH') { 1160: %merged = %{$self->{'data'}}; 1161: push @{$self->{'_source_records'}}, { 1162: type => 'data', 1163: label => 'constructor data argument', 1164: flat_data => { _flatten_keys($self->{'data'}) }, 1165: }; 1166: } else { 1167: Carp::carp(ref($self) . ': data argument must be a hashref; ignoring non-hashref value'); 1168: } 1169: }
Mutants (Total: 1, Killed: 1, Survived: 0)
1170: ●1171 → 1172 → 1176 1171: my $logger = $self->{'logger'}; 1172: if($logger) { 1173: $logger->trace(ref($self), ' ', __LINE__, ': Entered _load_config'); 1174: } 1175: ●1176 → 1186 → 1195 1176: my $environment = $self->_get_environment(); 1177: my @_formats = qw(yaml yml json xml ini toml); 1178: my @_file_list = ( 1179: (map { "base.$_" } @_formats), 1180: ($environment ? (map { "base.$environment.$_" } @_formats) : ()), 1181: (map { "local.$_" } @_formats), 1182: ($environment ? (map { "local.$environment.$_" } @_formats) : ()), 1183: );
Mutants (Total: 4, Killed: 4, Survived: 0)
1184:
Mutants (Total: 1, Killed: 1, Survived: 0)
1185: my @dirs = @{$self->{'config_dirs'}}; 1186: if($self->{'config_file'} && (scalar(@dirs) > 1)) { 1187: if(File::Spec->file_name_is_absolute($self->{'config_file'})) { 1188: # Handle absolute paths 1189: @dirs = (''); 1190: } else { 1191: # Look in the current directory 1192: push @dirs, File::Spec->curdir(); 1193: } 1194: } ●1195 → 1195 → 1539 1195: for my $dir (@dirs) { 1196: next if(!defined($dir)); 1197: 1198: # Newcastle Connection: /../hostname/path means read from a remote host. 1199: # /../ is unreachable on any real filesystem, so this is unambiguous. 1200: # When the hostname resolves to the local machine (localhost, 127.0.0.1, 1201: # ::1, or the system hostname) the /../host/ wrapper is unwrapped and the
Mutants (Total: 1, Killed: 1, Survived: 0)
1202: # enclosed path is processed through the normal local pipeline instead.
Mutants (Total: 1, Killed: 1, Survived: 0)
1203: my $effective_dir = $dir; 1204: if(my ($host, $remote_dir) = $self->_parse_remote_dir($dir)) { 1205: if($self->_is_local_host($host)) { 1206: $effective_dir = $remote_dir; 1207: } else { 1208: $self->_load_remote_dir($host, $remote_dir, \%merged, \@_file_list); 1209: next; 1210: }
Mutants (Total: 1, Killed: 1, Survived: 0)
1211: } 1212: 1213: if(length($effective_dir) && !-d $effective_dir) { 1214: next; 1215: } 1216:
Mutants (Total: 1, Killed: 1, Survived: 0)
1217: for my $file (@_file_list) { 1218: my $path = File::Spec->catfile($effective_dir, $file); 1219: if($logger) { 1220: $logger->debug(ref($self), ' ', __LINE__, ": Looking for configuration $path"); 1221: } 1222: next unless -f $path;
Mutants (Total: 1, Killed: 1, Survived: 0)
1223: next unless -r $path; 1224: 1225: if($logger) { 1226: $logger->debug(ref($self), ' ', __LINE__, ": Loading data from $path"); 1227: } 1228:
Mutants (Total: 1, Killed: 1, Survived: 0)
1229: my $data; 1230: # Only load config modules when they are needed 1231: if ($file =~ /\.ya?ml$/) {
Mutants (Total: 1, Killed: 1, Survived: 0)
1232: $self->_load_driver('YAML::XS', ['LoadFile']);
Mutants (Total: 1, Killed: 1, Survived: 0)
1233: $data = eval { LoadFile($path) }; 1234: if($@) { 1235: if($logger) { 1236: $logger->notice("Failed to load YAML from $path: $@"); 1237: } else { 1238: Carp::carp("Failed to load YAML from $path: $@"); 1239: } 1240: next; 1241: } 1242: $data = $self->_sanitize_yaml_values($data) if defined($data) && ref($data);
Mutants (Total: 1, Killed: 1, Survived: 0)
1243: } elsif ($file =~ /\.json$/) {
Mutants (Total: 1, Killed: 1, Survived: 0)
1244: $data = eval { decode_json(read_file($path)) }; 1245: if($@) { 1246: if($logger) { 1247: $logger->notice("Failed to load JSON from $path: $@"); 1248: } else { 1249: Carp::carp("Failed to load JSON from $path: $@"); 1250: } 1251: next; 1252: }
1253: } elsif($file =~ /\.xml$/) { 1254: my $rc;Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1252_5: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes1255: if($self->_load_driver('XML::Simple', ['XMLin'])) { 1256: my $xml_raw = eval { read_file($path) }; 1257: if(defined($xml_raw) && $xml_raw =~ /<!ENTITY\s+\w+\s+(?:SYSTEM|PUBLIC)\b/i) { 1258: Carp::carp(ref($self) . ": skipping $path: XML external entity declarations not permitted");Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1254_6: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes1259: } elsif(defined($xml_raw)) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1258_7: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes1260: eval { $rc = XMLin(\$xml_raw, ForceArray => 0, KeyAttr => []) }; 1261: if($@) { 1262: if($logger) { 1263: $logger->notice("Failed to load XML from $path: $@"); 1264: } else { 1265: Carp::carp("Failed to load XML from $path: $@"); 1266: } 1267: undef $rc; 1268: } elsif($rc) { 1269: $data = $rc; 1270: }Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1259_8: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomesMutants (Total: 1, Killed: 1, Survived: 0)
1271: } 1272: } 1273: if((!defined($rc)) && $self->_load_driver('XML::PP')) {
Mutants (Total: 1, Killed: 1, Survived: 0)
1274: my $xml_pp = XML::PP->new();
Mutants (Total: 1, Killed: 1, Survived: 0)
1275: $data = read_file($path); 1276: if(my $tree = $xml_pp->parse(\$data)) {
Mutants (Total: 1, Killed: 1, Survived: 0)
1277: if($data = $xml_pp->collapse_structure($tree)) { 1278: $self->{'type'} = 'XML'; 1279: if($data->{'config'}) { 1280: $data = $data->{'config'}; 1281: } 1282: } 1283: } 1284: }
Mutants (Total: 1, Killed: 1, Survived: 0)
1285: } elsif ($file =~ /\.ini$/) { 1286: $self->_load_driver('Config::IniFiles'); 1287: if(my $ini = Config::IniFiles->new(-file => $path)) { 1288: $data = { map { 1289: my $section = $_; 1290: $section => { map { $_ => $ini->val($section, $_) } $ini->Parameters($section) }
Mutants (Total: 1, Killed: 1, Survived: 0)
1291: } $ini->Sections() }; 1292: } else { 1293: if($logger) { 1294: $logger->notice("Failed to load INI from $path: $@"); 1295: } else { 1296: Carp::carp("Failed to load INI from $path: $@"); 1297: }
Mutants (Total: 1, Killed: 1, Survived: 0)
1298: } 1299: } elsif ($file =~ /\.toml$/) { 1300: if($self->_load_driver('TOML::Tiny', ['from_toml'])) { 1301: # scalar() forces read_file into scalar context; without it 1302: # read_file returns a list of lines and from_toml (no prototype)
Mutants (Total: 1, Killed: 1, Survived: 0)
1303: # only sees the first line.
Mutants (Total: 1, Killed: 1, Survived: 0)
1304: my ($toml_data, $toml_err) = from_toml(scalar(read_file($path))); 1305: if($toml_err) { 1306: if($logger) { 1307: $logger->notice("Failed to load TOML from $path: $toml_err"); 1308: } else { 1309: Carp::carp("Failed to load TOML from $path: $toml_err"); 1310: } 1311: } elsif(ref($toml_data) eq 'HASH' && scalar(keys %$toml_data)) { 1312: $data = $toml_data; 1313: }
Mutants (Total: 1, Killed: 1, Survived: 0)
1314: }
Mutants (Total: 1, Killed: 1, Survived: 0)
1315: }
Mutants (Total: 1, Killed: 1, Survived: 0)
1316: if($data) { 1317: if(!ref($data)) { 1318: if($logger) { 1319: $logger->debug(ref($self), ' ', __LINE__, ": ignoring data from $path ($data)"); 1320: }
Mutants (Total: 1, Killed: 1, Survived: 0)
1321: next;
Mutants (Total: 1, Killed: 1, Survived: 0)
1322: } 1323: if(ref($data) ne 'HASH') { 1324: if($logger) { 1325: $logger->debug(ref($self), ' ', __LINE__, ": ignoring data from $path (not a hashref)"); 1326: }
Mutants (Total: 1, Killed: 1, Survived: 0)
1327: next; 1328: } 1329: if($logger) { 1330: $logger->debug(ref($self), ' ', __LINE__, ": Loaded data from $path"); 1331: } 1332: push @{$self->{'_source_records'}}, { 1333: type => 'file', 1334: label => $path, 1335: flat_data => { _flatten_keys($data) }, 1336: }; 1337: %merged = %{ merge( $data, \%merged ) }; 1338: push @{$merged{'config_path'}}, $path; 1339: } 1340: }
Mutants (Total: 1, Killed: 1, Survived: 0)
1341: 1342: # Put $self->{config_file} through all parsers, ignoring all errors, then merge that in 1343: if(!$self->{'script_name'}) { 1344: require File::Basename && File::Basename->import() unless File::Basename->can('basename'); 1345: 1346: # Determine script name 1347: $self->{'script_name'} = File::Basename::basename($ENV{'SCRIPT_NAME'} || $0); 1348: } 1349: 1350: my $script_name = $self->{'script_name'}; 1351: for my $config_file ('default', $script_name, "$script_name.cfg", "$script_name.conf", "$script_name.config", $self->{'config_file'}, @{$self->{'config_files'}}) { 1352: next unless defined($config_file);
1353: # Note that loading $script_name in the current directory could mean loading the script as its own config. 1354: # This test is not foolproof, buyer bewareMutants (Total: 1, Killed: 0, Survived: 1)
- NUM_BOUNDARY_1352_71_!=: Numeric boundary flip == to !=
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );Mutants (Total: 1, Killed: 1, Survived: 0)
1355: next if(($config_file eq $script_name) && ((length($effective_dir) == 0) || ($effective_dir eq File::Spec->curdir()))); 1356: my $path = length($effective_dir) ? File::Spec->catfile($effective_dir, $config_file) : $config_file; 1357: if($logger) {
Mutants (Total: 1, Killed: 1, Survived: 0)
1358: $logger->debug(ref($self), ' ', __LINE__, ": Looking for configuration $path"); 1359: } 1360: if((-f $path) && (-r $path)) {
Mutants (Total: 1, Killed: 1, Survived: 0)
1361: my $data = read_file($path); 1362: my $raw_content = $data; 1363: if($logger) { 1364: $logger->debug(ref($self), ' ', __LINE__, ": Loading data from $path");
Mutants (Total: 1, Killed: 1, Survived: 0)
1365: }
Mutants (Total: 1, Killed: 1, Survived: 0)
1366: eval {
1367: if(($data =~ /^\s*<\?xml/) || ($data =~ /<\/[^>]+>/)) { 1368: if($self->_load_driver('XML::Simple', ['XMLin'])) { 1369: if($data =~ /<!ENTITY\s+\w+\s+(?:SYSTEM|PUBLIC)\b/i) { 1370: Carp::carp(ref($self) . ": skipping $path: XML external entity declarations not permitted"); 1371: undef $data; 1372: } elsif(my $xml_data = XMLin(\$data, ForceArray => 0, KeyAttr => [])) { 1373: $data = $xml_data; 1374: $self->{'type'} = 'XML'; 1375: }Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1366_8: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomesMutants (Total: 1, Killed: 1, Survived: 0)
1376: } elsif($self->_load_driver('XML::PP')) {
Mutants (Total: 1, Killed: 1, Survived: 0)
1377: my $xml_pp = XML::PP->new(); 1378: if(my $tree = $xml_pp->parse(\$data)) {
Mutants (Total: 1, Killed: 1, Survived: 0)
1379: if($data = $xml_pp->collapse_structure($tree)) { 1380: $self->{'type'} = 'XML'; 1381: if($data->{'config'}) { 1382: $data = $data->{'config'}; 1383: } 1384: } 1385: } 1386: } 1387: } elsif($data =~ / 1388: \{ # opening brace 1389: .+ # key material; dot matches newlines because of the s modifier 1390: : # colon separator 1391: . # one value character 1392: \} # closing brace 1393: /xs) { 1394: $self->_load_driver('JSON::Parse'); 1395: # CPanel::JSON is very noisy, so be careful before attempting to use it
1396: my $is_json; 1397: eval { $is_json = JSON::Parse::parse_json($data) };Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1395_7: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomesMutants (Total: 1, Killed: 1, Survived: 0)
1398: if($is_json) { 1399: eval { $data = decode_json($data) }; 1400: if($@) { 1401: undef $data; 1402: } 1403: } else {
1404: undef $data; 1405: } 1406: if($data) { 1407: $self->{'type'} = 'JSON'; 1408: } 1409: } else {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1403_7: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomesMutants (Total: 1, Killed: 1, Survived: 0)
1410: undef $data; 1411: }
Mutants (Total: 1, Killed: 1, Survived: 0)
1412: if(!$data && $raw_content !~ /<!ENTITY\s+\w+\s+(?:SYSTEM|PUBLIC)\b/i) { 1413: $self->_load_driver('YAML::XS', ['LoadFile']); 1414: if((eval { $data = LoadFile($path) }) && (ref($data) eq 'HASH')) { 1415: $data = $self->_sanitize_yaml_values($data); 1416: # Could be colon file, could be YAML, whichever it is break the configuration fields 1417: # foreach my($k, $v) (%{$data}) {
Mutants (Total: 1, Killed: 1, Survived: 0)
1418: foreach my $k (keys %{$data}) { 1419: my $v = $data->{$k}; 1420: if(!defined($v)) { 1421: # e.g. a simple line 1422: # foo: 1423: # with nothing under it 1424: $data->{$k} = undef; 1425: next; 1426: } 1427: # Do not inspect or modify coderefs, blessed objects, or any reference 1428: next unless _is_plain_scalar($v);
Mutants (Total: 1, Killed: 1, Survived: 0)
1429: 1430: next if($v =~ /^"[^"]+"$/); # Single-quoted field â skip comma-splitting 1431: if($v =~ /,/) { 1432: my @vals = split(/\s*,\s*/, $v);
Mutants (Total: 1, Killed: 1, Survived: 0)
1433: delete $data->{$k}; 1434: foreach my $val (@vals) { 1435: if($val =~ /([^=]+)=(.+)/) { 1436: $data->{$k}{$1} = $2; 1437: } else { 1438: $data->{$k}{$val} = 1; 1439: } 1440: }
1441: } 1442: } 1443: if($data) { 1444: $self->{'type'} = 'YAML';Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1440_8: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomesMutants (Total: 1, Killed: 1, Survived: 0)
1445: }
Mutants (Total: 1, Killed: 1, Survived: 0)
1446: } 1447: if((!$data) || (ref($data) ne 'HASH')) { 1448: if($self->_load_driver('TOML::Tiny', ['from_toml'])) { 1449: # scalar() forces read_file into scalar context; without it 1450: # read_file returns a list of lines and from_toml (no prototype)
Mutants (Total: 1, Killed: 1, Survived: 0)
1451: # only sees the first line. 1452: my ($toml_data, $toml_err) = eval { from_toml(scalar(read_file($path))) }; 1453: if(!$toml_err && ref($toml_data) eq 'HASH' && scalar(keys %$toml_data)) { 1454: $data = $toml_data; 1455: $self->{'type'} = 'TOML'; 1456: }
Mutants (Total: 1, Killed: 1, Survived: 0)
1457: } 1458: }
Mutants (Total: 1, Killed: 1, Survived: 0)
1459: if((!$data) || (ref($data) ne 'HASH')) { 1460: $self->_load_driver('Config::IniFiles'); 1461: if(my $ini = Config::IniFiles->new(-file => $path)) { 1462: $data = { map { 1463: my $section = $_;
1464: $section => { map { $_ => $ini->val($section, $_) } $ini->Parameters($section) } 1465: } $ini->Sections() }; 1466: if($data) { 1467: $self->{'type'} = 'INI';Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1463_9: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomesMutants (Total: 1, Killed: 1, Survived: 0)
1468: } 1469: }
1470: if((!$data) || (ref($data) ne 'HASH')) { 1471: # Maybe XML without the leading XML headerMutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1469_9: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes1472: if($self->_load_driver('XML::Simple', ['XMLin'])) { 1473: my $xml_raw = eval { read_file($path) }; 1474: if(defined($xml_raw) && $xml_raw !~ /<!ENTITY\s+\w+\s+(?:SYSTEM|PUBLIC)\b/i) { 1475: eval { $data = XMLin(\$xml_raw, ForceArray => 0, KeyAttr => []) };Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1471_10: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes1476: }Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1475_9: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes1477: } 1478: if((!$data) || (ref($data) ne 'HASH')) { 1479: if($self->_load_driver('Config::Abstract')) { 1480: # Handle RT#164587 1481: open my $oldSTDERR, '>&STDERR'; 1482: close STDERR; 1483: eval { $data = Config::Abstract->new($path) };Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1476_10: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes1484: my $err = $@; 1485: open STDERR, '>&', $oldSTDERR; 1486: if($err) { 1487: undef $data;Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1483_11: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes1488: } elsif($data) { 1489: $data = $data->get_all_settings(); 1490: if(scalar(keys %{$data}) == 0) { 1491: undef $data; 1492: } 1493: } 1494: $self->{'type'} = 'Perl';Mutants (Total: 2, Killed: 0, Survived: 2)
- NUM_BOUNDARY_1487_37_!=: Numeric boundary flip == to !=
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- COND_INV_1487_12: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomesMutants (Total: 1, Killed: 1, Survived: 0)
1495: } 1496: } 1497: if((!$data) || (ref($data) ne 'HASH')) {
1498: $self->_load_driver('Config::Auto'); 1499: my $ca = Config::Auto->new(source => $path); 1500: if($data = $ca->parse()) { 1501: $self->{'type'} = $ca->format(); 1502: } 1503: } 1504: } 1505: }Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1497_10: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomesMutants (Total: 1, Killed: 1, Survived: 0)
1506: }
1507: }; 1508: if($logger) { 1509: if($@) { 1510: $logger->warn(ref($self), ' ', __LINE__, ": $@"); 1511: undef $data; 1512: } else { 1513: $logger->debug(ref($self), ' ', __LINE__, ': Loaded data from', $self->{'type'}, "file $path");Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1506_6: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes1514: } 1515: } 1516: if($data && ref($data) eq 'HASH') { 1517: push @{$self->{'_source_records'}}, { 1518: type => 'file', 1519: label => $path, 1520: flat_data => { _flatten_keys($data) },Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1513_5: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomesMutants (Total: 1, Killed: 1, Survived: 0)
1521: };
Mutants (Total: 1, Killed: 1, Survived: 0)
1522: } 1523: if(scalar(keys %merged)) { 1524: if($data) { 1525: %merged = %{ merge($data, \%merged) }; 1526: } 1527: } elsif($data && (ref($data) eq 'HASH')) { 1528: %merged = %{$data}; 1529: } elsif((!$@) && $logger) { 1530: $logger->debug(ref($self), ' ', __LINE__, ': No configuration file loaded'); 1531: } 1532: 1533: push @{$merged{'config_path'}}, $path; 1534: } 1535: } 1536: } 1537: 1538: # Merge ENV vars ●1539 → 1543 → 1567 1539: my $prefix = $self->{env_prefix}; 1540: $prefix =~ s/__$//; 1541: $prefix =~ s/_$//; 1542: $prefix =~ s/::$//; 1543: for my $key (keys %ENV) {
Mutants (Total: 1, Killed: 1, Survived: 0)
1544: next unless $key =~ /^\Q$self->{env_prefix}\E(.*)$/i; 1545: my $path = lc($1); 1546: if($path =~ /__/) { 1547: my @parts = split /__/, $path; 1548: my $ref = \%merged; 1549: $ref = ($ref->{$_} //= {}) for @parts[0..$#parts-1]; 1550: $ref->{ $parts[-1] } = $ENV{$key}; 1551: push @{$self->{'_source_records'}}, { 1552: type => 'env', 1553: label => $key, 1554: flat_data => { join('.', @parts) => $ENV{$key} }, 1555: }; 1556: } else { 1557: $merged{$prefix}->{$path} = $ENV{$key}; 1558: push @{$self->{'_source_records'}}, { 1559: type => 'env', 1560: label => $key, 1561: flat_data => { "$prefix.$path" => $ENV{$key} }, 1562: }; 1563: } 1564: } 1565: 1566: # Merge command line options ●1567 → 1567 → 1588 1567: foreach my $arg(@ARGV) { 1568: next unless($arg =~ /=/); 1569: my ($key, $value) = split(/=/, $arg, 2); 1570: next unless $key =~ /^--\Q$self->{env_prefix}\E(.*)$/; 1571:
Mutants (Total: 4, Killed: 4, Survived: 0)
1572: my $path = lc($1); 1573: my @parts = split(/__/, $path);
Mutants (Total: 4, Killed: 4, Survived: 0)
1574: if(scalar(@parts) > 0) { 1575: my $ref = \%merged; 1576: if(scalar(@parts) > 1) { 1577: $ref = ($ref->{$_} //= {}) for @parts[0..$#parts-1]; 1578: } 1579: $ref->{$parts[-1]} = $value; 1580: push @{$self->{'_source_records'}}, { 1581: type => 'argv', 1582: label => $arg, 1583: flat_data => { join('.', @parts) => $value }, 1584: }; 1585: }
1586: } 1587: ●1588 → 1588 → 1595 1588: if($self->{'flatten'}) { 1589: $self->_load_driver('Hash::Flatten', ['flatten']); 1590: } else { 1591: $self->_load_driver('Hash::Flatten', ['unflatten']); 1592: } 1593: # $self->{config} = $self->{flatten} ? flatten(\%merged) : unflatten(\%merged); 1594: # Don't unflatten because of RT#166761 ●1595 → 1599 → 0 1595: $self->{config} = $self->{flatten} ? flatten(\%merged) : \%merged; 1596:Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1585_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomesMutants (Total: 1, Killed: 1, Survived: 0)
1597: Hash::Merge::set_clone_behavior($saved_clone); 1598: 1599: if(my $enc_key = $self->_get_encryption_key()) { 1600: $self->_decrypt_config_values($self->{'config'}, $enc_key); 1601: } 1602: } 1603: 1604: =head2 get(key) 1605: 1606: Retrieve a configuration value using dotted key notation (e.g., 1607: C<'database.user'>). Returns C<undef> if the key doesn't exist or if 1608: C<key> is C<undef>. 1609: 1610: =head3 EXAMPLE 1611: 1612: my $cfg = Config::Abstraction->new( 1613: data => { database => { host => 'localhost', port => 5432 } }, 1614: config_dirs => [], 1615: ); 1616: 1617: my $host = $cfg->get('database.host'); # 'localhost' 1618: my $port = $cfg->get('database.port'); # 5432 1619: my $miss = $cfg->get('database.user'); # undef -- key absent 1620: 1621: =head3 API SPECIFICATION 1622: 1623: =head4 Input 1624: 1625: key -- SCALAR -- dotted key path (e.g. 'database.host'). 1626: C<undef> is allowed and returns C<undef> silently. 1627: 1628: =head4 Output 1629: 1630: SCALAR or reference -- the value stored under C<key>, or C<undef> if absent. 1631: 1632: =head3 MESSAGES 1633: 1634: (none) -- missing keys return undef, no warning is raised. 1635: 1636: =head3 PSEUDOCODE 1637: 1638: if key is undef: return undef 1639: call _ensure_loaded 1640: if flatten mode: return config[key] (direct lookup) 1641: parts = split sep_char from key 1642: ref = config hashref 1643: for each part: 1644: if ref is not a HASH: return undef 1645: if part not in ref: return undef 1646: ref = ref[part] 1647: return ref 1648: 1649: =cut 1650: 1651: sub get 1652: {
Mutants (Total: 2, Killed: 2, Survived: 0)
●1653 → 1659 → 1662 1653: my ($self, $key) = @_; 1654: 1655: return undef unless defined $key; 1656:
Mutants (Total: 1, Killed: 1, Survived: 0)
1657: $self->_ensure_loaded();
Mutants (Total: 2, Killed: 2, Survived: 0)
1658: 1659: if($self->{flatten}) { 1660: return $self->{config}{$key}; 1661: }
Mutants (Total: 2, Killed: 2, Survived: 0)
●1662 → 1663 → 1668 1662: my $ref = $self->{'config'}; 1663: for my $part (split $self->{'_sep_re'}, $key) { 1664: return undef unless ref $ref eq 'HASH'; 1665: return unless exists $ref->{$part};
Mutants (Total: 1, Killed: 1, Survived: 0)
1666: $ref = $ref->{$part};
1667: }Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1666_3: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes●1668 → 1668 → 1687 1668: if((defined($ref) && (ref($ref) eq 'HASH') && !$self->{'no_fixate'})) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1667_4: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes1669: if($self->_load_data_reuse()) { 1670: if(ref($ref) eq 'HASH') { 1671: if(!tied %$ref) { 1672: # Pass the hashref directly (not dereferenced) so fixate receives 1673: # a named scalar it can make read-only without flattening the hash 1674: # FIXME: 1675: # What works on MacOS doesn't work 1676: # on Linux and vice versa. 1677: # Something is wrong. 1678: # Data::Reuse::fixate(%{$ref}) if scalar(keys %{$ref}); 1679: # Data::Reuse::fixate($ref) if scalar(keys %{$ref}); 1680: } 1681: } elsif(ref($ref) eq 'ARRAY') { 1682: # RT#171980 1683: # Data::Reuse::fixate(@{$ref}); 1684: }Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1668_5: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomesMutants (Total: 2, Killed: 2, Survived: 0)
1685: } 1686: } 1687: return $ref; 1688: } 1689: 1690: sub _load_data_reuse 1691: { ●1692 → 1705 → 1710 1692: my $self = $_[0];
Mutants (Total: 2, Killed: 2, Survived: 0)
1693: 1694: # Skip fixation entirely if caller has opted out 1695: return 0 if($self->{'no_fixate'});
Mutants (Total: 2, Killed: 2, Survived: 0)
1696:
Mutants (Total: 2, Killed: 2, Survived: 0)
1697: # Return cached result to avoid repeated require attempts 1698: return 1 if($self->{reuse_loaded}); 1699: return 0 if($self->{reuse_failed}); 1700: 1701: eval { 1702: require Data::Reuse;
Mutants (Total: 1, Killed: 1, Survived: 0)
1703: Data::Reuse->import(); 1704: }; 1705: if($@) {
Mutants (Total: 2, Killed: 2, Survived: 0)
1706: # Cache the failure so we do not attempt to load again 1707: $self->{reuse_failed} = 1; 1708: return 0;
1709: } 1710: $self->{reuse_loaded} = 1; 1711: return 1; 1712: } 1713: 1714: =head2 exists(key) 1715: 1716: Test whether a configuration key is present, using dotted key notation 1717: (e.g., C<'database.user'>). Returns C<1> when the key exists (even if its 1718: value is C<undef>), C<0> otherwise. Returns C<0> when C<key> is C<undef>. 1719: 1720: =head3 EXAMPLE 1721: 1722: my $cfg = Config::Abstraction->new( 1723: data => { timeout => undef, retries => 3 }, 1724: config_dirs => [], 1725: ); 1726: 1727: $cfg->exists('timeout'); # 1 -- key present even though value is undef 1728: $cfg->exists('retries'); # 1 1729: $cfg->exists('missing'); # 0 1730: $cfg->exists(undef); # 0 1731: 1732: =head3 API SPECIFICATION 1733: 1734: =head4 Input 1735: 1736: key -- SCALAR -- dotted key path. C<undef> returns C<0>. 1737: 1738: =head4 Output 1739: 1740: boolean 1741: 1742: =head3 MESSAGES 1743: 1744: (none) 1745: 1746: =cut 1747: 1748: sub exists 1749: {Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_1708_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_1708_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );Mutants (Total: 2, Killed: 2, Survived: 0)
●1750 → 1756 → 1759 1750: my ($self, $key) = @_; 1751: 1752: return 0 unless defined $key; 1753:
Mutants (Total: 1, Killed: 1, Survived: 0)
1754: $self->_ensure_loaded();
Mutants (Total: 2, Killed: 2, Survived: 0)
1755: 1756: if($self->{flatten}) { 1757: return exists($self->{config}{$key}) ? 1 : 0; 1758: }
Mutants (Total: 2, Killed: 2, Survived: 0)
●1759 → 1760 → 1765 1759: my $ref = $self->{'config'};
Mutants (Total: 2, Killed: 2, Survived: 0)
1760: for my $part (split $self->{'_sep_re'}, $key) { 1761: return 0 unless ref $ref eq 'HASH'; 1762: return 0 if(!exists($ref->{$part}));
Mutants (Total: 2, Killed: 2, Survived: 0)
1763: $ref = $ref->{$part}; 1764: } 1765: return 1; 1766: } 1767: 1768: =head2 all() 1769: 1770: Returns the entire merged configuration as a hashref, or C<undef> when no 1771: configuration data was found. When C<flatten =E<gt> 1> was given to the 1772: constructor the keys are dotted strings (e.g. C<'database.host'>); otherwise 1773: the hash is nested. 1774: 1775: The special key C<config_path> within the returned hashref is an arrayref 1776: listing every file that was loaded, in load order. 1777: 1778: =head3 EXAMPLE 1779: 1780: my $cfg = Config::Abstraction->new( 1781: data => { host => 'localhost', port => 5432 }, 1782: config_dirs => [], 1783: ); 1784: 1785: my $all = $cfg->all(); 1786: # { host => 'localhost', port => 5432, config_path => [] } 1787: 1788: =head3 API SPECIFICATION 1789: 1790: =head4 Input 1791: 1792: (none) 1793: 1794: =head4 Output 1795: 1796: HASHREF -- the full merged config (includes C<config_path> key). 1797: undef -- when the merged config is empty and no data was supplied. 1798: 1799: =head3 MESSAGES 1800: 1801: (none) -- returns undef silently when empty. 1802: 1803: =cut 1804: 1805: sub all 1806: { 1807: my $self = shift; 1808: 1809: $self->_ensure_loaded(); 1810: 1811: return if(!$self->{config}); 1812: 1813: # This is good for debugging, but not much more and it breaks inheritance, so disabled 1814: # if($self->_load_data_reuse()) { 1815: # Data::Reuse::fixate($self->{config});
Mutants (Total: 2, Killed: 2, Survived: 0)
1816: # } 1817: 1818: return(scalar(keys %{$self->{'config'}})) ? $self->{'config'} : undef; 1819: } 1820: 1821: =head2 explain_sources() 1822: 1823: Returns a hashref describing where each configuration key came from and in 1824: what order the sources that set it were applied. 1825: 1826: Each key of the returned hashref is a dotted key name (e.g. C<'database.user'>). 1827: The corresponding value is a hashref with two fields: 1828: 1829: =over 4 1830: 1831: =item * C<value> 1832: 1833: The final merged value for that key after all sources have been applied. 1834: 1835: =item * C<sources> 1836: 1837: An arrayref of hashrefs, ordered from lowest to highest precedence (i.e. the 1838: last element is always the winning source). Each entry has: 1839: 1840: =over 4 1841: 1842: =item * C<type> -- one of C<'data'>, C<'file'>, C<'env'>, or C<'argv'> 1843: 1844: =item * C<label> -- a human-readable identifier: a file path, an environment 1845: variable name (e.g. C<'APP_DATABASE__USER'>), a CLI argument string, or 1846: C<'constructor data argument'> 1847: 1848: =item * C<value> -- what this source set the key to (may differ from C<value> 1849: at the top level if a later source overrode it) 1850: 1851: =back 1852: 1853: =back 1854: 1855: Keys set by exactly one source have a single-element C<sources> list. 1856: Keys whose value was never overridden will show the same C<value> in both the 1857: top-level field and the sole C<sources> entry. 1858: 1859: =head3 USAGE EXAMPLE 1860: 1861: use Config::Abstraction; 1862: 1863: local $ENV{APP_HOST} = 'prod.example.com'; 1864: 1865: my $cfg = Config::Abstraction->new( 1866: data => { host => 'localhost', port => 5432 }, 1867: config_dirs => ['/etc/myapp'], 1868: ); 1869: 1870: use Data::Dumper; 1871: print Dumper( $cfg->explain_sources() ); 1872: # { 1873: # 'host' => { 1874: # value => 'prod.example.com', 1875: # sources => [ 1876: # { type => 'data', label => 'constructor data argument', value => 'localhost' }, 1877: # { type => 'env', label => 'APP_HOST', value => 'prod.example.com' }, 1878: # ], 1879: # }, 1880: # 'port' => { 1881: # value => 5432, 1882: # sources => [ 1883: # { type => 'data', label => 'constructor data argument', value => 5432 }, 1884: # ], 1885: # }, 1886: # } 1887: 1888: =head3 API SPECIFICATION 1889: 1890: =head4 Input 1891: 1892: None (instance method; takes no arguments beyond C<$self>). 1893: 1894: =head4 Output 1895: 1896: HASHREF where each key is a dotted config key and each value is: 1897: 1898: { 1899: value => SCALAR, 1900: sources => [ 1901: { 1902: type => 'data'|'file'|'env'|'argv', 1903: label => SCALAR, 1904: value => SCALAR, 1905: }, 1906: ... 1907: ], 1908: } 1909: 1910: =cut 1911: 1912: sub explain_sources 1913: { ●1914 → 1921 → 1937 1914: my $self = shift; 1915: 1916: $self->_ensure_loaded(); 1917: 1918: my %final_flat = _flatten_keys($self->{'config'}); 1919: my %result; 1920: 1921: for my $key (keys %final_flat) {
Mutants (Total: 1, Killed: 1, Survived: 0)
1922: my @key_sources; 1923: for my $layer (@{$self->{'_source_records'} // []}) { 1924: if(exists $layer->{'flat_data'}{$key}) { 1925: push @key_sources, { 1926: type => $layer->{'type'}, 1927: label => $layer->{'label'}, 1928: value => $layer->{'flat_data'}{$key}, 1929: }; 1930: } 1931: } 1932: $result{$key} = { 1933: value => $final_flat{$key}, 1934: sources => \@key_sources,
Mutants (Total: 2, Killed: 2, Survived: 0)
1935: }; 1936: } 1937: return \%result; 1938: } 1939: 1940: # --------------------------------------------------------------------------- 1941: # _value_from_type -- scan _source_records for the last record of the given 1942: # type that provided a value for $key. Source records always use '.' as the 1943: # key separator regardless of sep_char, so we normalise $key before the lookup. 1944: # 1945: # Returns ($found, $value): $found is true when the source type contributed to 1946: # the key, letting callers distinguish "source set key to undef" from "source 1947: # never set it". 1948: # --------------------------------------------------------------------------- 1949: sub _value_from_type 1950: { ●1951 → 1961 → 1968 1951: my ($self, $type, $key) = @_; 1952: 1953: return (0, undef) unless defined $key; 1954: 1955: $self->_ensure_loaded(); 1956: 1957: my $sep = $self->{'sep_char'}; 1958: my $flat_key = ($sep eq '.') ? $key : join('.', split /\Q$sep\E/, $key); 1959: 1960: my ($found, $val);
Mutants (Total: 1, Killed: 1, Survived: 0)
1961: for my $layer (@{$self->{'_source_records'} // []}) { 1962: next unless $layer->{'type'} eq $type; 1963: if(exists $layer->{'flat_data'}{$flat_key}) { 1964: $found = 1; 1965: $val = $layer->{'flat_data'}{$flat_key}; 1966: } 1967: } 1968: return ($found, $val); 1969: } 1970: 1971: =head2 prefer_env(key) 1972: 1973: Return the value that an environment variable provided for C<key>, bypassing 1974: any later sources (e.g. CLI arguments) that may have overridden it. 1975: Falls back to the normal merged value from C<get(key)> when no environment 1976: variable contributed to C<key>. 1977: 1978: =head3 EXAMPLE 1979: 1980: local $ENV{APP_DATABASE__HOST} = 'env-host'; 1981: my $host = $cfg->prefer_env('database.host'); 1982: # Returns 'env-host' even if --APP_DATABASE__HOST=cli-host was also passed. 1983: 1984: =head3 API SPECIFICATION 1985: 1986: =head4 Input 1987: 1988: key -- SCALAR -- dotted key path. 1989: 1990: =head4 Output 1991: 1992: SCALAR -- the env-layer value, or C<get(key)> when no env var set it. 1993: 1994: =cut 1995: 1996: sub prefer_env 1997: {
Mutants (Total: 2, Killed: 2, Survived: 0)
1998: my ($self, $key) = @_; 1999: my ($found, $val) = $self->_value_from_type('env', $key); 2000: return $found ? $val : $self->get($key); 2001: } 2002: 2003: =head2 prefer_file(key) 2004: 2005: Return the value that a configuration file provided for C<key>, bypassing 2006: environment variables and CLI arguments that may have overridden it. 2007: Falls back to the normal merged value from C<get(key)> when no file 2008: contributed to C<key>. 2009: 2010: =head3 EXAMPLE 2011: 2012: my $host = $cfg->prefer_file('database.host'); 2013: # Returns the file-sourced value even if APP_DATABASE__HOST is set. 2014: 2015: =head3 API SPECIFICATION 2016: 2017: =head4 Input 2018: 2019: key -- SCALAR -- dotted key path. 2020: 2021: =head4 Output 2022: 2023: SCALAR -- the file-layer value, or C<get(key)> when no file set it. 2024: 2025: =cut 2026: 2027: sub prefer_file 2028: {
Mutants (Total: 2, Killed: 2, Survived: 0)
2029: my ($self, $key) = @_; 2030: my ($found, $val) = $self->_value_from_type('file', $key); 2031: return $found ? $val : $self->get($key); 2032: } 2033: 2034: =head2 prefer_data(key) 2035: 2036: Return the value that the C<data> constructor argument provided for C<key>, 2037: bypassing files, environment variables, and CLI arguments that may have 2038: overridden it. 2039: Falls back to the normal merged value from C<get(key)> when C<data> did not 2040: contribute to C<key>. 2041: 2042: =head3 EXAMPLE 2043: 2044: my $cfg = Config::Abstraction->new( 2045: data => { timeout => 30 }, 2046: config_dirs => ['/etc/myapp'], 2047: ); 2048: my $t = $cfg->prefer_data('timeout'); # always 30, regardless of files/env 2049: 2050: =head3 API SPECIFICATION 2051: 2052: =head4 Input 2053: 2054: key -- SCALAR -- dotted key path. 2055: 2056: =head4 Output 2057: 2058: SCALAR -- the data-layer value, or C<get(key)> when C<data> did not set it. 2059: 2060: =cut 2061: 2062: sub prefer_data 2063: {
Mutants (Total: 2, Killed: 2, Survived: 0)
2064: my ($self, $key) = @_; 2065: my ($found, $val) = $self->_value_from_type('data', $key); 2066: return $found ? $val : $self->get($key); 2067: } 2068: 2069: =head2 prefer_argv(key) 2070: 2071: Return the value that a CLI argument provided for C<key>. 2072: Falls back to the normal merged value from C<get(key)> when no CLI argument 2073: contributed to C<key>. 2074: 2075: Because CLI arguments are the highest-precedence source, this method is 2076: primarily useful for writing self-documenting code or for detecting whether 2077: a key was explicitly supplied on the command line. 2078: 2079: =head3 EXAMPLE 2080: 2081: my $level = $cfg->prefer_argv('log.level'); 2082: # Equivalent to $cfg->get('log.level') unless you specifically need to 2083: # confirm the value came from @ARGV. 2084: 2085: =head3 API SPECIFICATION 2086: 2087: =head4 Input 2088: 2089: key -- SCALAR -- dotted key path. 2090: 2091: =head4 Output 2092: 2093: SCALAR -- the argv-layer value, or C<get(key)> when no CLI arg set it. 2094: 2095: =cut 2096: 2097: sub prefer_argv 2098: {
Mutants (Total: 2, Killed: 2, Survived: 0)
2099: my ($self, $key) = @_; 2100: my ($found, $val) = $self->_value_from_type('argv', $key); 2101: return $found ? $val : $self->get($key); 2102: } 2103: 2104: =head2 encrypt_value($plaintext) 2105: 2106: Encrypt a plaintext string using the configured AES-256-GCM key and return an 2107: C<ENC[AES256GCM,...]> token suitable for storing in a configuration file. 2108: 2109: Each call generates a fresh random nonce, so the same plaintext produces a 2110: different token every time. The token is authenticated (GCM tag); any 2111: modification causes decryption to croak. 2112: 2113: Requires L<CryptX> (C<Crypt::AuthEnc::GCM>, C<Crypt::PRNG>). 2114: 2115: =head3 EXAMPLE 2116: 2117: # Generate a token to paste into base.yaml: 2118: my $cfg = Config::Abstraction->new( 2119: encryption_key => $hex_key, 2120: config_dirs => [], 2121: lazy => 1, 2122: ); 2123: my $token = $cfg->encrypt_value('s3cr3t_password'); 2124: # ENC[AES256GCM,QkJCQkJCQkJCQkJCO6Gfb0o5jwqB1R...] 2125: 2126: # Or use config-dump from the command line: 2127: # config-dump --encrypt-value 's3cr3t_password' --encryption-key $KEY 2128: 2129: =head3 API SPECIFICATION 2130: 2131: =head4 Input 2132: 2133: plaintext -- SCALAR -- the string to encrypt. May be empty. 2134: 2135: =head4 Output 2136: 2137: SCALAR -- the ENC[AES256GCM,...] token (always a printable ASCII string). 2138: 2139: =head3 MESSAGES 2140: 2141: "<class>: no encryption key configured ..." -- croak when no key is available. 2142: "<class>: CryptX (Crypt::AuthEnc::GCM) is required ..." -- croak when CryptX absent. 2143: 2144: =head3 PSEUDOCODE 2145: 2146: call _ensure_loaded 2147: key = _get_encryption_key() -- croak if undef 2148: load Crypt::AuthEnc::GCM and Crypt::PRNG -- croak if absent 2149: nonce = 12 random bytes 2150: gcm = new GCM('AES', key) 2151: gcm.iv_add(nonce) 2152: gcm.adata_add('') -- ends the IV phase (required by CryptX < 0.049) 2153: ciphertext = gcm.encrypt_add(plaintext) 2154: tag = gcm.encrypt_done() 2155: return "ENC[AES256GCM," + base64url(nonce + ciphertext + tag) + "]" 2156: 2157: =cut 2158: 2159: sub encrypt_value 2160: { ●2161 → 2168 → 2171 2161: my ($self, $plaintext) = @_; 2162: 2163: $self->_ensure_loaded(); 2164:
Mutants (Total: 1, Killed: 1, Survived: 0)
2165: my $key = $self->_get_encryption_key() 2166: or Carp::croak(ref($self) . ': no encryption key configured (set encryption_key, encryption_key_file, or ENCRYPTION_KEY env var)'); 2167:
Mutants (Total: 1, Killed: 1, Survived: 0)
2168: unless($self->_load_driver('Crypt::AuthEnc::GCM')) { 2169: Carp::croak(ref($self) . ': CryptX (Crypt::AuthEnc::GCM) is required; install it with: cpanm CryptX'); 2170: } ●2171 → 2171 → 2175 2171: unless($self->_load_driver('Crypt::PRNG', ['random_bytes'])) { 2172: Carp::croak(ref($self) . ': CryptX (Crypt::PRNG) is required; install it with: cpanm CryptX'); 2173: } 2174: 2175: require MIME::Base64; 2176: 2177: my $nonce = random_bytes($_AES_NONCE_SIZE); 2178: my $gcm = Crypt::AuthEnc::GCM->new('AES', $key); 2179: $gcm->iv_add($nonce);
Mutants (Total: 2, Killed: 2, Survived: 0)
2180: # Empty AAD explicitly ends the IV phase. CryptX < 0.049 does not do this 2181: # implicitly, so encrypt_add() straight after iv_add() fails there with 2182: # "Invalid argument provided". The output is identical on newer CryptX. 2183: $gcm->adata_add(''); 2184: my $ct = $gcm->encrypt_add($plaintext); 2185: my $tag = $gcm->encrypt_done(); 2186: 2187: return 'ENC[AES256GCM,' . MIME::Base64::encode_base64url($nonce . $ct . $tag, '') . ']'; 2188: } 2189: 2190: =head2 merge_defaults 2191: 2192: Merge the configuration hash into the given hash. 2193: 2194: package MyPackage; 2195: use Params::Get; 2196: use Config::Abstraction; 2197: 2198: sub new 2199: { 2200: my $class = shift; 2201: 2202: my $params = Params::Get::get_params(undef, \@_) || {}; 2203: 2204: if(my $config = Config::Abstraction->new(env_prefix => "${class}::")) { 2205: $params = $config->merge_defaults(defaults => $params, merge => 1, section => $class); 2206: } 2207: 2208: return bless $params, $class; 2209: } 2210: 2211: Options: 2212: 2213: =over 4 2214: 2215: =item * merge 2216: 2217: Usually, 2218: what's in the object will overwrite what's in the defaults hash, 2219: if given, 2220: the result will be a combination of the hashes. 2221: 2222: =item * section 2223: 2224: Merge in that section from the configuration file. 2225: 2226: =item * deep 2227: 2228: Try harder to merge all configurations from the global section of the configuration file. 2229: 2230: =back 2231:
Mutants (Total: 3, Killed: 3, Survived: 0)
2232: =cut 2233: 2234: sub merge_defaults 2235: {
Mutants (Total: 2, Killed: 2, Survived: 0)
●2236 → 2258 → 2266 2236: my $self = shift; 2237: my $config = $self->all(); 2238: 2239: return $config if(scalar(@_) == 0); 2240: 2241: my $params = Params::Get::get_params('defaults', @_); 2242: my $defaults = $params->{'defaults'}; 2243: return $config if(!defined($defaults)); 2244: my $section = $params->{'section'}; 2245: 2246: # Work on a shallow copy so we never mutate $self->{'config'} directly. 2247: # Without this, 'delete $config->{global}' below would permanently alter 2248: # the live internal hash on every call. 2249: my %config_copy = %{$config}; 2250: $config = \%config_copy;
Mutants (Total: 1, Killed: 1, Survived: 0)
2251:
Mutants (Total: 1, Killed: 1, Survived: 0)
2252: # Save and restore Hash::Merge's clone behaviour so we don't leak the 2253: # no-clone global state into unrelated merge() calls (e.g. inside 2254: # _load_config when a new object is constructed after this method runs). 2255: my $saved_clone = Hash::Merge::get_clone_behavior(); 2256: Hash::Merge::set_clone_behavior(0); 2257: 2258: if(exists $config->{'global'}) {
Mutants (Total: 1, Killed: 1, Survived: 0)
2259: if($params->{'deep'}) { 2260: $defaults = merge($config->{'global'}, $defaults); 2261: } else { 2262: $defaults = { %{$defaults}, %{$config->{'global'}} };
Mutants (Total: 1, Killed: 1, Survived: 0)
2263: } 2264: delete $config->{'global'}; 2265: } ●2266 → 2266 → 2269 2266: if($section && exists $config->{$section}) { 2267: $config = $config->{$section}; 2268: }
Mutants (Total: 2, Killed: 2, Survived: 0)
●2269 → 2270 → 2275 2269: my $result; 2270: if($params->{'merge'}) { 2271: $result = merge($config, $defaults); 2272: } else { 2273: $result = { %{$defaults}, %{$config} }; 2274: } 2275: Hash::Merge::set_clone_behavior($saved_clone); 2276: return $result; 2277: } 2278: 2279: # Helper routine to load a driver. 2280: # NOTE: Log::Abstraction must NOT be loaded via this method - it is
Mutants (Total: 2, Killed: 2, Survived: 0)
2281: # bootstrapped directly in new() to avoid a circular initialisation
Mutants (Total: 2, Killed: 2, Survived: 0)
2282: # dependency where _load_driver would attempt to log via an as-yet 2283: # uninitialised logger. 2284: sub _load_driver
Mutants (Total: 1, Killed: 1, Survived: 0)
2285: {
Mutants (Total: 1, Killed: 1, Survived: 0)
●2286 → 2292 → 2299 2286: my($self, $driver, $imports) = @_; 2287: 2288: return 1 if($self->{'loaded'}{$driver}); 2289: return 0 if($self->{'failed'}{$driver}); 2290: 2291: eval "require $driver"; 2292: if($@) { 2293: if(my $logger = $self->{'logger'}) {
Mutants (Total: 2, Killed: 2, Survived: 0)
2294: $logger->warn(ref($self), ": $driver failed to load: $@"); 2295: } 2296: $self->{'failed'}{$driver} = 1; 2297: return; 2298: } 2299: $driver->import(@{ $imports // [] }); 2300: $self->{'loaded'}{$driver} = 1; 2301: return 1; 2302: } 2303: 2304: =head2 Remote configuration directories (Newcastle Connection) 2305: 2306: Any entry in C<config_dirs> whose path begins with C</../> is treated as a 2307: remote specification rather than a local directory: 2308: 2309: /../hostname/path/to/dir 2310: 2311: The hostname and directory are extracted, and the same standard files searched 2312: locally (C<base.yaml>, C<local.yaml>, C<base.json>, etc.) are fetched from 2313: the remote machine via L<File::Slurp::Remote> over SSH. 2314: 2315: When the hostname resolves to the local machine -- C<localhost>, C<127.0.0.1>, 2316: C<::1>, or the value returned by C<Sys::Hostname::hostname()> (checked as 2317: both fully-qualified and short form, case-insensitively) -- the C</../host/> 2318: wrapper is silently unwrapped and the enclosed path is processed through the 2319: normal local file pipeline instead. No SSH connection is made. This means a 2320: configuration written for a shared remote host degrades gracefully when run on 2321: that host itself: 2322: 2323: # On any other machine: fetches /etc/myapp over SSH 2324: # On cfg-server itself: reads /etc/myapp from disk directly 2325: config_dirs => ['/../cfg-server/etc/myapp'] 2326: 2327: Remote directories participate in the normal merge pipeline and can be freely 2328: mixed with local ones: 2329: 2330: my $cfg = Config::Abstraction->new( 2331: config_dirs => [ 2332: '/etc/myapp', # local 2333: '/../deploy@cfg-server/etc/myapp', # remote via SSH (local on cfg-server) 2334: ], 2335: ); 2336: 2337: SSH authentication is handled by the system SSH client. 2338: No extra constructor options are required; use your SSH agent or 2339: C<~/.ssh/config> for host-specific settings. 2340: 2341: L<File::Slurp::Remote> must be installed for remote directories to work. 2342: If it is absent the directory is silently skipped and a warning is emitted. 2343: 2344: =head3 Why C</../> (the Newcastle Connection convention) 2345: 2346: Several syntaxes were considered for marking a C<config_dirs> entry as remote. 2347: Each alternative was rejected for a concrete reason: 2348: 2349: =over 4 2350: 2351: =item C<hostname/path> -- ambiguous 2352: 2353: Indistinguishable from a relative local directory named C<hostname>. 2354: The module cannot tell at parse time whether C<myserver/etc> is a two-level 2355: local path or a remote specification. 2356: 2357: =item C<hostname:/path> -- collides with Windows drive letters 2358: 2359: The C<X:\> drive-letter convention on Windows uses exactly the same 2360: C<letter:> prefix. A single-letter hostname would be misidentified as a 2361: drive, and path-normalisation code on Windows would mangle it. 2362: 2363: =item C<file://hostname/path> -- wrong semantics 2364: 2365: RFC 8089 defines C<file://> as a reference to a B<local> file. 2366: C<file:///etc/passwd> (three slashes, empty host) is the canonical local form; 2367: C<file://hostname/path> is reserved for the host component but is explicitly 2368: discouraged for general use and is not understood by most tooling as meaning 2369: SSH. 2370: 2371: =item C<ssh://hostname/path> -- requires URI parsing 2372: 2373: Introduces a dependency on URI parsing (or a bespoke prefix check) and 2374: implies a specific transport. C<File::Slurp::Remote> already abstracts 2375: the transport; encoding C<ssh://> in the path would be misleading if a future 2376: version of that module supports other transports such as C<rsync://>. 2377: 2378: =item C</../hostname/path> -- the Newcastle Connection 2379: 2380: The Newcastle Connection (Brownbridge, Dion, Elsworth, 1982) is a distributed 2381: Unix convention in which the token C</../> at the start of a pathname means 2382: "leave the local namespace and enter the named remote host". 2383: It works because C</../> B<cannot exist> as a real filesystem path: 2384: C<..> from the root directory resolves back to the root on every POSIX system, 2385: so C</../> always denotes the root itself, never a child of the root. 2386: No pathname-normalisation step, C<chdir>, or filesystem traversal will ever 2387: produce a path that legitimately begins with C</../>, which means the prefix 2388: is a permanent, collision-free sentinel that requires only a regex to detect. 2389: 2390: =back 2391: 2392: The Newcastle Connection prefix is therefore the only choice that is: 2393: 2394: =over 4 2395: 2396: =item * unambiguous on all platforms (POSIX and Windows) 2397: 2398: =item * impossible to produce accidentally from a real local path 2399: 2400: =item * detectable with a single C<m{^\Q/../\E}> regex, no URI parser needed 2401: 2402: =item * transport-neutral (the hostname is passed to whatever remote driver is installed) 2403: 2404: =back 2405: 2406: =head2 AUTOLOAD 2407: 2408: This module supports dynamic access to configuration keys via AUTOLOAD. 2409: Nested keys are accessible using the separator, 2410: so C<$config-E<gt>database_user()> resolves to C<< $config->{database}->{user} >>, 2411: when C<sep_char> is set to '_'. 2412: 2413: $config = Config::Abstraction->new( 2414: data => { 2415: database => { 2416: user => 'alice', 2417: pass => 'secret' 2418: }, 2419: log_level => 'debug' 2420: }, 2421: flatten => 1, 2422: sep_char => '_' 2423: ); 2424: 2425: my $user = $config->database_user(); # returns 'alice' 2426: 2427: # or 2428: $user = $config->database()->{'user'}; # returns 'alice' 2429: 2430: # Attempting to call a nonexistent key 2431: my $foo = $config->nonexistent_key(); # dies with error 2432: 2433: =cut 2434: 2435: # --------------------------------------------------------------------------- 2436: # _parse_remote_dir -- detect Newcastle Connection style paths. 2437: # 2438: # Returns ($host, $dir) when $dir begins with /../, empty list otherwise. 2439: # The /../ prefix is syntactically impossible for a real local path so no 2440: # real directory entry is ever misidentified. 2441: # --------------------------------------------------------------------------- 2442: sub _parse_remote_dir 2443: { 2444: my ($self, $dir) = @_; 2445: 2446: return unless defined($dir); 2447: return unless $dir =~ m{^\Q/../\E([^/]+)(/.+)?$}; 2448: return ($1, $2 // '/'); 2449: } 2450: 2451: # --------------------------------------------------------------------------- 2452: # _is_local_host -- true when $host refers to the machine running this code. 2453: # 2454: # Strips any user@ prefix first, then checks the four common ways a caller 2455: # might spell "here": the loopback name, the two loopback addresses, and the
Mutants (Total: 2, Killed: 2, Survived: 0)
2456: # system hostname (both fully-qualified and short). Comparison is 2457: # case-insensitive because hostnames are case-insensitive by RFC 1034. 2458: # --------------------------------------------------------------------------- 2459: sub _is_local_host
Mutants (Total: 2, Killed: 2, Survived: 0)
2460: {
Mutants (Total: 2, Killed: 2, Survived: 0)
●2461 → 2473 → 2478 2461: my ($self, $host) = @_;
Mutants (Total: 2, Killed: 2, Survived: 0)
2462: 2463: return 0 unless defined($host) && length($host); 2464: 2465: (my $bare = $host) =~ s/^[^@]+@//; # strip optional user@ prefix
Mutants (Total: 1, Killed: 1, Survived: 0)
2466: 2467: return 1 if lc($bare) eq 'localhost'; 2468: return 1 if $bare eq '127.0.0.1'; 2469: return 1 if $bare eq '::1'; 2470:
Mutants (Total: 2, Killed: 2, Survived: 0)
2471: # Cache the resolved hostname on the object so Sys::Hostname::hostname()
Mutants (Total: 2, Killed: 2, Survived: 0)
2472: # (a syscall) is only made once per Config::Abstraction instance. 2473: unless(defined $self->{'_cached_hostname'}) {
Mutants (Total: 2, Killed: 2, Survived: 0)
2474: require Sys::Hostname; 2475: $self->{'_cached_hostname'} = lc(Sys::Hostname::hostname()); 2476: ($self->{'_cached_short_hostname'} = $self->{'_cached_hostname'}) =~ s/\..*$//; 2477: } 2478: return 1 if lc($bare) eq $self->{'_cached_hostname'}; 2479: return 1 if lc($bare) eq $self->{'_cached_short_hostname'}; 2480: 2481: return 0; 2482: } 2483: 2484: # --------------------------------------------------------------------------- 2485: # _load_remote_dir -- fetch and merge standard config files from one remote 2486: # host/directory pair.
Mutants (Total: 1, Killed: 1, Survived: 0)
2487: # 2488: # Silently skips files that do not exist; warns (or logs) on parse errors. 2489: # Merges into %$merged_ref using the same Hash::Merge LEFT_PRECEDENT strategy 2490: # as the local pipeline, so remote values override earlier local values. 2491: # --------------------------------------------------------------------------- 2492: sub _load_remote_dir 2493: {
Mutants (Total: 1, Killed: 1, Survived: 0)
●2494 → 2494 → 2498 2494: if(!UNIVERSAL::isa((caller)[0], __PACKAGE__)) { 2495: Carp::croak('Illegal Operation: This method can only be called by a subclass'); 2496: } 2497: ●2498 → 2501 → 2509 2498: my ($self, $host, $remote_dir, $merged_ref, $file_list) = @_; 2499: my $logger = $self->{'logger'}; 2500: 2501: unless($self->_load_driver('File::Slurp::Remote')) { 2502: my $msg = ref($self) . ": File::Slurp::Remote required for /../$host$remote_dir but is not installed"; 2503: $logger ? $logger->warn($msg) : Carp::carp($msg); 2504: return; 2505: } 2506: 2507: # Use the same file list as the local pipeline (includes TOML and env-specific tiers). 2508: # Fall back to the legacy list if none was passed (e.g. from very old callers).
Mutants (Total: 1, Killed: 1, Survived: 0)
●2509 → 2513 → 0 2509: my @files = $file_list ? @{$file_list} 2510: : qw(base.yaml base.yml base.json base.xml base.ini base.toml 2511: local.yaml local.yml local.json local.xml local.ini local.toml); 2512: 2513: for my $file (@files) { 2514: my $remote_path = "/../$host$remote_dir/$file"; 2515:
Mutants (Total: 1, Killed: 1, Survived: 0)
2516: if($logger) { 2517: $logger->debug(ref($self), ' ', __LINE__, ": Looking for remote config $remote_path"); 2518: } 2519: 2520: my $raw = $self->_slurp_remote($host, "$remote_dir/$file"); 2521: next unless defined($raw); 2522:
Mutants (Total: 1, Killed: 1, Survived: 0)
2523: if($logger) { 2524: $logger->debug(ref($self), ' ', __LINE__, ": Loading remote config $remote_path"); 2525: } 2526: 2527: my $data = $self->_parse_config_string($raw, $file, $remote_path); 2528: next unless defined($data); 2529: 2530: if(ref($data) ne 'HASH') { 2531: my $msg = ref($self) . ": remote $remote_path did not yield a hash; skipping"; 2532: $logger ? $logger->warn($msg) : Carp::carp($msg); 2533: next; 2534: } 2535: 2536: push @{$self->{'_source_records'}}, { 2537: type => 'file', 2538: label => $remote_path, 2539: flat_data => { _flatten_keys($data) }, 2540: }; 2541: %{$merged_ref} = %{ merge($data, $merged_ref) }; 2542: push @{$merged_ref->{'config_path'}}, $remote_path; 2543: } 2544: } 2545: 2546: # --------------------------------------------------------------------------- 2547: # _slurp_remote -- read a single file from a remote host via SSH. 2548: # 2549: # Wraps File::Slurp::Remote::read_file; returns the raw string on success 2550: # or undef on any error (connection refused, file absent, permission denied). 2551: # Errors are logged at debug level so missing files are not noisy. 2552: # --------------------------------------------------------------------------- 2553: sub _slurp_remote 2554: {
Mutants (Total: 1, Killed: 1, Survived: 0)
●2555 → 2562 → 2568 2555: my ($self, $host, $path) = @_;
Mutants (Total: 1, Killed: 1, Survived: 0)
2556: my $logger = $self->{'logger'}; 2557: 2558: # NOTE: verify the calling convention of your installed File::Slurp::Remote.
Mutants (Total: 2, Killed: 2, Survived: 0)
2559: # Common forms: read_file("$host:$path") or read_file($host, $path). 2560: my $content = eval { File::Slurp::Remote::read_file($host, $path) };
Mutants (Total: 2, Killed: 2, Survived: 0)
2561: 2562: if($@) { 2563: if($logger) { 2564: $logger->debug(ref($self), ' ', __LINE__, ": Could not read $path from $host: $@"); 2565: } 2566: return undef; 2567: } 2568: return $content; 2569: } 2570: 2571: # --------------------------------------------------------------------------- 2572: # _parse_config_string -- parse a raw config string by extension. 2573: # 2574: # Mirrors the format-detection logic used for local files. INI content is
Mutants (Total: 1, Killed: 1, Survived: 0)
2575: # written to a File::Temp scratch file because Config::IniFiles requires a 2576: # real filesystem path; the temp file is removed as soon as parsing returns. 2577: # 2578: # Returns a hashref on success, undef on failure. 2579: # --------------------------------------------------------------------------- 2580: sub _parse_config_string 2581: { ●2582 → 2582 → 2586 2582: if(!UNIVERSAL::isa((caller)[0], __PACKAGE__)) { 2583: Carp::croak('Illegal Operation: This method can only be called by a subclass');
Mutants (Total: 1, Killed: 1, Survived: 0)
2584: } 2585: ●2586 → 2627 → 2634 2586: my ($self, $raw, $filename, $label) = @_; 2587: my $logger = $self->{'logger'}; 2588: my $data; 2589: 2590: eval { 2591: if($filename =~ /\.ya?ml$/i) { 2592: $self->_load_driver('YAML::XS', ['Load']);
Mutants (Total: 1, Killed: 1, Survived: 0)
2593: $data = YAML::XS::Load($raw);
2594: $data = $self->_sanitize_yaml_values($data) if defined($data) && ref($data); 2595: 2596: } elsif($filename =~ /\.json$/i) { 2597: $data = decode_json($raw); 2598:Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_2593_5: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomesMutants (Total: 1, Killed: 1, Survived: 0)
2599: } elsif($filename =~ /\.xml$/i) { 2600: if($self->_load_driver('XML::Simple', ['XMLin'])) { 2601: if($raw !~ /<!ENTITY\s+\w+\s+(?:SYSTEM|PUBLIC)\b/i) { 2602: $data = XMLin(\$raw, ForceArray => 0, KeyAttr => []); 2603: } 2604: } elsif($self->_load_driver('XML::PP')) { 2605: my $pp = XML::PP->new(); 2606: if(my $tree = $pp->parse(\$raw)) { 2607: $data = $pp->collapse_structure($tree); 2608: $data = $data->{'config'} if ($data && $data->{'config'}); 2609: } 2610: }
Mutants (Total: 1, Killed: 1, Survived: 0)
2611: 2612: } elsif($filename =~ /\.ini$/i) { 2613: $self->_load_driver('Config::IniFiles'); 2614: require File::Temp; 2615: my $tmp = File::Temp->new(SUFFIX => '.ini', UNLINK => 1); 2616: print {$tmp} $raw; 2617: $tmp->flush(); 2618: if(my $ini = Config::IniFiles->new(-file => $tmp->filename())) { 2619: $data = { map {
Mutants (Total: 1, Killed: 1, Survived: 0)
2620: my $section = $_; 2621: $section => { map { $_ => $ini->val($section, $_) } $ini->Parameters($section) } 2622: } $ini->Sections() }; 2623: }
Mutants (Total: 2, Killed: 2, Survived: 0)
2624: } 2625: }; 2626:
Mutants (Total: 2, Killed: 2, Survived: 0)
2627: if($@) { 2628: my $err = $@; 2629: my $msg = ref($self) . ": Failed to parse $label: $err"; 2630: $logger ? $logger->warn($msg) : Carp::carp($msg); 2631: return undef; 2632: } 2633: 2634: return $data; 2635: } 2636: 2637: sub AUTOLOAD 2638: { ●2639 → 2660 → 2668 2639: our $AUTOLOAD; 2640: 2641: my $self = shift; 2642: my $key = $AUTOLOAD; 2643: 2644: $key =~ s/.*:://; # remove package name 2645: return if $key eq 'DESTROY'; 2646: 2647: $self->_ensure_loaded(); 2648: 2649: # my $val = $self->get($key); 2650: # return $val if(defined($val)); 2651: 2652: # Always use the merged config, not the raw pre-merge $self->{data}.
Mutants (Total: 1, Killed: 1, Survived: 0)
2653: # Using $self->{data} here would bypass file and env overrides entirely. 2654: my $data = $self->{'config'};
Mutants (Total: 2, Killed: 2, Survived: 0)
2655: my $sep = $self->{'sep_char'};
Mutants (Total: 2, Killed: 2, Survived: 0)
2656: 2657: # When flattening is ON, Hash::Flatten stores keys with '.' as separator. 2658: # Convert the sep_char-separated AUTOLOAD key to the dotted form first, 2659: # then fall back to the raw key in case sep_char happens to be '.'. 2660: if($self->{flatten}) { 2661: my $dot_key = ($sep ne '.') ? do { (my $k = $key) =~ s/\Q$sep\E/./g; $k } : $key; 2662: return $data->{$dot_key} if exists $data->{$dot_key};
Mutants (Total: 1, Killed: 1, Survived: 0)
2663: return $data->{$key} if exists $data->{$key}; 2664: croak "No such config key '$key'"; 2665: } 2666: 2667: # Nested (non-flat) mode: walk the config tree one part at a time. ●2668 → 2669 → 2676 2668: my $val = $data;
Mutants (Total: 2, Killed: 2, Survived: 0)
2669: foreach my $part (split /\Q$sep\E/, $key) { 2670: if((ref($val) eq 'HASH') && (exists $val->{$part})) { 2671: $val = $val->{$part}; 2672: } else { 2673: croak "No such config key '$key'"; 2674: } 2675: } 2676: return $val; 2677: } 2678: 2679: 1; 2680: 2681: =head1 ENCRYPTED VALUES 2682: 2683: Config::Abstraction supports transparent AES-256-GCM encryption of individual 2684: configuration values. This lets you store secrets (passwords, API keys, tokens) 2685: in config files without exposing them as plaintext, even when those files are 2686: committed to version control. 2687: 2688: =head2 Quick start 2689: 2690: B<Step 1 -- generate a key:> 2691: 2692: # 32 random bytes, encoded as 64 hex chars 2693: perl -e 'use Crypt::PRNG qw(random_bytes); use MIME::Base64 qw(encode_base64url); 2694: print encode_base64url(random_bytes(32)), "\n"' 2695: 2696: Store the result in an environment variable or a key file (outside version control): 2697: 2698: export ENCRYPTION_KEY=<the 44-char base64url output> 2699: 2700: B<Step 2 -- encrypt a secret value:> 2701: 2702: perl -MConfig::Abstraction -e ' 2703: my $cfg = Config::Abstraction->new(data => {}); 2704: print $cfg->encrypt_value("my_secret_password"), "\n"; 2705: ' 2706: 2707: This prints something like: 2708: 2709: ENC[AES256GCM,QkJCQkJCQkJCQkJCO6Gfb0o5...] 2710: 2711: B<Step 3 -- paste the token into your config file:> 2712: 2713: # config/base.yaml 2714: database: 2715: host: db.example.com 2716: user: myapp 2717: password: 'ENC[AES256GCM,QkJCQkJCQkJCQkJCO6Gfb0o5...]' 2718: 2719: B<Step 4 -- load and use normally:> 2720: 2721: my $cfg = Config::Abstraction->new(config_dirs => ['config']); 2722: # $cfg->get('database.password') returns 'my_secret_password' -- already decrypted 2723: 2724: =head2 Key configuration 2725: 2726: The encryption key is resolved in this order (first match wins): 2727: 2728: =over 4 2729: 2730: =item 1. C<encryption_key> constructor option (raw bytes, hex, or base64) 2731: 2732: =item 2. C<{env_prefix}ENCRYPTION_KEY> environment variable (default: C<APP_ENCRYPTION_KEY>) 2733: 2734: =item 3. C<ENCRYPTION_KEY> environment variable 2735: 2736: =item 4. File at C<encryption_key_file> constructor option 2737: 2738: =item 5. File at C<{env_prefix}ENCRYPTION_KEY_FILE> environment variable 2739: 2740: =item 6. File at C<ENCRYPTION_KEY_FILE> environment variable 2741: 2742: =back 2743: 2744: The key file should contain the key on its first line in any supported format. 2745: B<Never commit the key to version control.> 2746: 2747: =head2 Token format 2748: 2749: ENC[AES256GCM,<base64url(nonce || ciphertext || tag)>] 2750: 2751: =over 4 2752: 2753: =item * C<AES256GCM> -- AES-256 in GCM mode (authenticated encryption) 2754: 2755: =item * Nonce -- 12 random bytes (fresh per encryption, never reused) 2756: 2757: =item * GCM authentication tag -- 16 bytes; any modification causes decryption to croak 2758: 2759: =item * Base64url encoding -- URL-safe alphabet, no padding ambiguity 2760: 2761: =back 2762: 2763: =head2 Behaviour when no key is configured 2764: 2765: If no key is found, C<ENC[...]> tokens are left as literal strings. This means 2766: the feature is purely opt-in: existing deployments without a key configured are 2767: unaffected. 2768: 2769: =head2 Requirements 2770: 2771: L<CryptX> (C<Crypt::AuthEnc::GCM>, C<Crypt::PRNG>) must be installed: 2772: 2773: cpanm CryptX 2774: 2775: =head1 COMMON PITFALLS 2776: 2777: =head2 1. new() returns undef when no configuration is found 2778: 2779: C<new()> returns C<undef>, not a blessed object, when no configuration data is 2780: found and no C<data> argument was supplied. Every caller must check the return value. 2781: 2782: my $cfg = Config::Abstraction->new(config_dirs => ['/etc/myapp']); 2783: die "No configuration found" unless defined $cfg; 2784: my $host = $cfg->get('database.host'); # safe 2785: 2786: Forgetting the check leads to a cryptic "Can't call method on undef" error later, 2787: with a stack trace that points to C<get()> rather than to the missing config file. 2788: 2789: =head2 2. merge_defaults() requires a named argument, not a bare hashref 2790: 2791: # WRONG -- Params::Get fast-path returns the whole config unchanged 2792: my $merged = $cfg->merge_defaults(\%my_defaults); 2793: 2794: # RIGHT 2795: my $merged = $cfg->merge_defaults(defaults => \%my_defaults); 2796: 2797: With the first form, C<Params::Get> treats the single hashref as the entire 2798: parameter bag and returns the full config without merging anything. 2799: 2800: =head2 3. Shallow merge in merge_defaults() silently drops nested keys from defaults 2801: 2802: Without C<merge =E<gt> 1>, C<merge_defaults()> uses a plain Perl hash merge 2803: (C<{ %defaults, %config }>) at the top level. If the config contains a nested hash 2804: for a key, it entirely replaces the corresponding nested hash in your defaults - any 2805: keys that exist only in the defaults' nested hash are silently discarded. 2806: 2807: my $cfg = Config::Abstraction->new( 2808: data => { db => { host => 'localhost', port => 5432 } }, 2809: config_dirs => [], 2810: ); 2811: 2812: my $merged = $cfg->merge_defaults(defaults => { db => { user => 'guest' } }); 2813: # $merged->{db}{user} is UNDEF -- the whole 'db' hash was replaced by the config's version 2814: 2815: # To combine nested keys from both sides, pass merge => 1: 2816: my $merged = $cfg->merge_defaults(defaults => { db => { user => 'guest' } }, merge => 1); 2817: # $merged->{db}{user} is 'guest', $merged->{db}{host} is 'localhost' 2818: 2819: =head2 4. undef from a higher-priority source permanently wins 2820: 2821: C<Hash::Merge> LEFT_PRECEDENT means that an explicit C<undef> (YAML C<~>) in a 2822: higher-priority source overrides a real value in a lower-priority source, including 2823: when the lower-priority value is defined. 2824: 2825: # base.yaml: timeout: 30 2826: # local.yaml: timeout: ~ 2827: 2828: my $t = $cfg->get('timeout'); # undef -- local.yaml's null wins over base.yaml 2829: 2830: This is intentional: it lets a local config deliberately unset a value. If you 2831: want to detect whether a key was explicitly nulled versus simply absent, use 2832: C<explain_sources()> and inspect the C<sources> list. 2833: 2834: =head2 5. Double underscore vs. single underscore in environment variable names 2835: 2836: Single underscores are part of the key name; double underscores create a nesting level. 2837: 2838: APP_LOG_LEVEL=debug => key 'log_level' (single underscore, flat key) 2839: APP_DATABASE__HOST=db => key 'database.host' (double underscore, nested) 2840: APP_API__RATE_LIMIT=100 => key 'api.rate_limit' (double underscore + single) 2841: 2842: A common mistake is using single underscores expecting nested keys: 2843: 2844: APP_DATABASE_HOST=db # produces key 'database_host', NOT 'database.host' 2845: 2846: =head2 6. Using AUTOLOAD requires sep_char set to '_' 2847: 2848: AUTOLOAD translates method names to config keys using C<sep_char>. The default 2849: C<sep_char> is C<'.'>, but method names cannot contain dots. Set C<sep_char =E<gt> '_'> 2850: to use AUTOLOAD, and be aware that this makes single underscores into hierarchy separators. 2851: 2852: my $cfg = Config::Abstraction->new( 2853: data => { database => { host => 'localhost' } }, 2854: sep_char => '_', 2855: config_dirs => [], 2856: ); 2857: my $host = $cfg->database_host(); # works 2858: my $bad = $cfg->no_such_key(); # dies: No such config key 'no_such_key' 2859: 2860: =head2 7. Absolute config_file paths require an empty or omitted config_dirs 2861: 2862: On Unix, C<File::Spec-E<gt>catfile('/etc', '/absolute/path.yaml')> concatenates the 2863: two strings instead of letting the absolute path take over, producing a wrong path. 2864: When C<config_file> is an absolute path, either omit C<config_dirs> entirely 2865: (the constructor sets it to C<['']> automatically) or pass C<config_dirs =E<gt> ['']> 2866: explicitly. 2867: 2868: # WRONG on Unix -- produces '/etc/etc/myapp/app.yaml' 2869: Config::Abstraction->new( 2870: config_file => '/etc/myapp/app.yaml', 2871: config_dirs => ['/etc'], 2872: ); 2873: 2874: # RIGHT 2875: Config::Abstraction->new( config_file => '/etc/myapp/app.yaml' ); 2876: 2877: =head2 8. Tests must isolate from the developer's real config files 2878: 2879: A call to C<new()> without C<config_dirs> will scan C</etc>, C<~/.conf>, 2880: C<~/.config>, and other default locations and load any C<base.yaml> or 2881: C<local.yaml> it finds there. In a test suite this injects real host 2882: configuration into your test object, causing non-deterministic failures. 2883: 2884: Always pass C<config_dirs =E<gt> []> in tests that use only in-memory C<data>: 2885: 2886: my $cfg = Config::Abstraction->new( 2887: data => { key => 'value' }, 2888: config_dirs => [], # do not scan the filesystem 2889: ); 2890: 2891: =head2 9. lazy => 1 defers errors until the first accessor call 2892: 2893: With C<lazy =E<gt> 1>, C<new()> always returns a blessed object - it cannot return 2894: C<undef> for a missing config, and any schema validation errors surface at the first 2895: C<get()> or C<all()> call rather than at construction time. See the C<lazy> 2896: option documentation in L</new> for the full list of debugging implications. 2897: 2898: =head1 VERSION HISTORY 2899: 2900: Notable changes by release. Full details are in the C<Changes> file. 2901: 2902: =over 4 2903: 2904: =item * B<0.40> (unreleased) 2905: 2906: TOML file support (C<*.toml>) via C<TOML::Tiny>; C<base.toml> and C<local.toml> 2907: are now discovered automatically alongside the YAML/JSON/XML/INI equivalents, 2908: and TOML is also tried in the all-parsers chain for extensionless C<config_file> 2909: entries. 2910: Lazy loading (C<lazy =E<gt> 1> constructor option) defers all source discovery 2911: and file I/O until the first accessor call. 2912: New C<explain_sources()> method returns a per-key audit trail showing every 2913: source that contributed to a value, in precedence order. 2914: New C<prefer_env()>, C<prefer_file()>, C<prefer_data()>, and C<prefer_argv()> 2915: shortcut methods return the value from a specific source layer without re-ordering. 2916: Fixed C<merge_defaults()> permanently mutating the internal config hash 2917: (now works on a shallow copy). 2918: Fixed C<Hash::Merge> clone-behaviour global state leaking between calls 2919: (C<get_clone_behavior()> is now saved and restored, preventing "Can't store CODE 2920: items" crashes when C<data> contains coderefs). 2921: Newcastle Connection remote configuration: C<config_dirs> entries beginning with 2922: C</../hostname/path> are fetched over SSH via L<File::Slurp::Remote>. 2923: Local-host entries (C</../localhost/>, C</../127.0.0.1/>, etc.) are short-circuited 2924: to a plain local read - no SSH connection is made. 2925: 2926: =item * B<0.39> (2026-05-24) 2927: 2928: Disabled C<Data::Reuse::fixate()> - behaviour differed between Linux and macOS 2929: in a way that could not be resolved portably. 2930: 2931: =item * B<0.38> (2026-05-20) 2932: 2933: Fixed corruption of coderefs and blessed objects passed via the C<data> argument 2934: (added C<_is_plain_scalar()> guard in the YAML value-munging loop). 2935: Fixed C<exists()> to return explicit C<0> rather than empty string in flat mode. 2936: Fixed C<Data::Reuse::fixate()> crash in C<get()>. 2937: Fixed circular initialisation crash when the C<logger> option is provided. 2938: Documented the C<defaults> argument to C<new()>. 2939: 2940: =item * B<0.36> (2025-10-15) 2941: 2942: Added C<exists()> method. 2943: Files that fail to parse now emit a warning and are skipped rather than aborting 2944: construction. 2945: 2946: =item * B<0.34> (2025-09-05) 2947: 2948: Added C<bin/config-dump> CLI tool. 2949: Schema validation via C<Params::Validate::Strict> (C<schema> option). 2950: 2951: =item * B<0.25> (2025-05-15) 2952: 2953: Added C<merge_defaults()> C<merge> option (recursive Hash::Merge merge). 2954: 2955: =item * B<0.20> (2025-05-06) 2956: 2957: Added C<merge_defaults()>. 2958: 2959: =item * B<0.19> (2025-05-06) 2960: 2961: The C<data> constructor argument now sets default values that are overridden by files. 2962: 2963: =item * B<0.13> (2025-04-22) 2964: 2965: Added C<data> option to C<new()>. 2966: Added AUTOLOAD support for method-style key access. 2967: Added C<sep_char> option. 2968: 2969: =item * B<0.06> (2025-04-09) 2970: 2971: C<config_path> key in C<all()> output lists the files actually loaded. 2972: Format drivers are now lazy-loaded (only C<require>d when a file of that format is 2973: found). 2974: 2975: =item * B<0.01> (2025-04-07) 2976: 2977: First release. 2978: 2979: =back 2980: 2981: =head1 LIMITATIONS 2982: 2983: =over 4 2984: 2985: =item * B<No separator escaping> 2986: 2987: The separator character (C<sep_char>, default C<.>) cannot be embedded in a key name. 2988: A key that literally contains a dot cannot be accessed via C<get()> when C<sep_char> is 2989: the default. Workaround: set C<sep_char> to a character not present in any key. 2990: 2991: =item * B<Data::Reuse fixation disabled> 2992: 2993: The C<Data::Reuse::fixate()> call inside C<get()> is currently a no-op because the 2994: behaviour of C<Crypt::Storable::dclone> differs between Linux and macOS in a way 2995: that cannot be resolved portably (RT#100461). Hash values returned by C<get()> are 2996: mutable references, not read-only copies. 2997: 2998: =item * B<Windows environment variable case sensitivity> 2999: 3000: On Windows, environment variable names are case-insensitive at the OS level but 3001: case-sensitive in C<%ENV> as seen by Perl. Overriding config keys via environment 3002: variables may silently fail if the case does not match exactly. 3003: 3004: =item * B<AUTOLOAD requires sep_char set to '_'> 3005: 3006: AUTOLOAD method dispatch converts underscores to key separators. If C<sep_char> is 3007: the default C<'.'>, AUTOLOAD cannot reach nested keys because Perl method names cannot 3008: contain dots. 3009: 3010: =item * B<Remote directory TOML support requires File::Slurp::Remote> 3011: 3012: TOML files in Newcastle Connection remote directories are only fetched when the 3013: C<File::Slurp::Remote> module is installed. Without it, remote directories are 3014: skipped entirely regardless of file format. 3015: 3016: =back 3017: 3018: =head1 BUGS 3019: 3020: It should be possible to escape the separator character either with backslashes or quotes. 3021: 3022: Due to the case-insensitive nature of environment variables on Windows, 3023: it may be challenging to override values using environment variables on that platform. 3024: 3025: =head1 REPOSITORY 3026: 3027: L<https://github.com/nigelhorne/Config-Abstraction> 3028: 3029: =head1 SUPPORT 3030: 3031: This module is provided as-is without any warranty. 3032: 3033: Please report any bugs or feature requests to C<bug-config-abstraction at rt.cpan.org>, 3034: or through the web interface at 3035: L<http://rt.cpan.org/NoAuth/ReportBug.html?Queue=Config-Abstraction>. 3036: I will be notified, and then you'll 3037: automatically be notified of progress on your bug as I make changes. 3038: 3039: You can find documentation for this module with the perldoc command. 3040: 3041: perldoc Config::Abstraction 3042: 3043: =head1 SEE ALSO 3044: 3045: =over 4 3046: 3047: =item * L<File::Slurp::Remote> 3048: 3049: Used to fetch configuration from remote hosts when C<config_dirs> contains 3050: Newcastle Connection paths (C</../hostname/path>). 3051: 3052: =item * L<Config::Any> 3053: 3054: =item * L<Config::Auto> 3055: 3056: =item * L<Data::Reuse> 3057: 3058: Used to C<fixate()> elements when installed, unless C<no-fixate> is given 3059: 3060: =item * L<Hash::Merge> 3061: 3062: =item * L<Log::Abstraction> 3063: 3064: =item * L<Test Dashboard|https://nigelhorne.github.io/Config-Abstraction/coverage/> 3065: 3066: =item * Development version on GitHub L<https://github.com/nigelhorne/Config-Abstraction> 3067: 3068: =back 3069: 3070: =head1 AUTHOR 3071: 3072: Nigel Horne, C<< <njh at nigelhorne.com> >> 3073: 3074: =encoding UTF-8 3075: 3076: =head1 FORMAL SPECIFICATION 3077: 3078: =head2 get 3079: 3080: get : Config x Key â Value ⪠{â¥} 3081: get(c, k) â if k = ⥠then ⥠3082: else lookup(c.config, split(c.sep_char, k)) 3083: lookup(h, []) â h 3084: lookup(h, p:rest) â if p â dom(h) then ⥠3085: else lookup(h[p], rest) 3086: 3087: =head2 encrypt_value 3088: 3089: encrypt_value : Config x Plaintext â Token 3090: encrypt_value(c, p) â 3091: let k = resolve_key(c) where k â ⥠3092: let n ~ Uniform(Bytes^12) -- fresh random nonce 3093: let ct = AES256GCM_enc(k, n, p) 3094: let t = GCM_tag(k, n, p) 3095: in "ENC[AES256GCM," || base64url(n || ct || t) || "]" 3096: 3097: =head2 exists 3098: 3099: exists : Config x Key â {0, 1} 3100: exists(c, k) â if k = ⥠then 0 3101: else 1 if lookup(c.config, split(c.sep_char, k)) â ⥠3102: else 0 3103: 3104: =head2 all 3105: 3106: all : Config â HashRef ⪠{â¥} 3107: all(c) â if |dom(c.config)| = 0 then ⥠else c.config 3108: 3109: =head1 LICENCE AND COPYRIGHT 3110: 3111: Copyright 2025-2026 Nigel Horne. 3112: 3113: Usage is subject to the GPL2 licence terms. 3114: If you use it, 3115: please let me know. 3116: 3117: =cut 3118: 3119: 1;