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