mutineer 1.7.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +432 -0
  3. data/README.md +157 -747
  4. data/lib/mutineer/baseline.rb +1 -1
  5. data/lib/mutineer/changed_lines.rb +2 -1
  6. data/lib/mutineer/cli.rb +119 -27
  7. data/lib/mutineer/concern_blocks.rb +168 -5
  8. data/lib/mutineer/config.rb +6 -0
  9. data/lib/mutineer/coverage_map.rb +92 -17
  10. data/lib/mutineer/daemon_backend.rb +75 -32
  11. data/lib/mutineer/daemon_client.rb +50 -17
  12. data/lib/mutineer/daemon_server.rb +92 -40
  13. data/lib/mutineer/external_backend.rb +150 -17
  14. data/lib/mutineer/isolation.rb +67 -25
  15. data/lib/mutineer/job_plan.rb +117 -33
  16. data/lib/mutineer/kill_channel.rb +24 -14
  17. data/lib/mutineer/migrate.rb +10 -8
  18. data/lib/mutineer/minitest_integration/in_test_database_error.rb +184 -0
  19. data/lib/mutineer/minitest_integration/kill_recorder.rb +25 -17
  20. data/lib/mutineer/minitest_integration.rb +44 -14
  21. data/lib/mutineer/mutators/array_literal.rb +4 -2
  22. data/lib/mutineer/mutators/base.rb +22 -0
  23. data/lib/mutineer/mutators/call_to_receiver.rb +20 -7
  24. data/lib/mutineer/mutators/chain_link.rb +6 -6
  25. data/lib/mutineer/mutators/condition_forcing.rb +6 -3
  26. data/lib/mutineer/mutators/operand_removal.rb +4 -2
  27. data/lib/mutineer/mutators/range_literal.rb +105 -4
  28. data/lib/mutineer/mutators/return_nil.rb +1 -0
  29. data/lib/mutineer/orphan_guard.rb +18 -6
  30. data/lib/mutineer/pairing.rb +62 -1
  31. data/lib/mutineer/project.rb +226 -12
  32. data/lib/mutineer/rails_worker_db.rb +200 -16
  33. data/lib/mutineer/redaction.rb +112 -11
  34. data/lib/mutineer/reporter.rb +109 -21
  35. data/lib/mutineer/result.rb +33 -5
  36. data/lib/mutineer/runner.rb +126 -18
  37. data/lib/mutineer/statement_lines.rb +14 -8
  38. data/lib/mutineer/subject.rb +13 -5
  39. data/lib/mutineer/test_runners/minitest.rb +2 -1
  40. data/lib/mutineer/test_runners.rb +4 -2
  41. data/lib/mutineer/triage.rb +1 -1
  42. data/lib/mutineer/version.rb +1 -1
  43. data/lib/mutineer/worker_pool.rb +2 -1
  44. data/skills/mutineer/SKILL.md +84 -0
  45. metadata +5 -1
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "digest"
4
4
  require "uri"
5
+ require_relative "redaction"
5
6
 
6
7
  module Mutineer
7
8
  # Per-worker database isolation for the daemon path.
@@ -352,20 +353,35 @@ module Mutineer
352
353
  ActiveRecord::Base.establish_connection(worker_db_config(slot))
353
354
  end
354
355
 
355
- # How many database configs the test environment declares. One is normal.
356
- # More than one means the run must use a single worker.
356
+ # The database config names the test environment declares, and the one
357
+ # each worker copies (the config `ActiveRecord::Base` uses). The tool
358
+ # counts the names that `shared_databases` does not list: more than one
359
+ # means the run must use a single worker.
357
360
  #
358
- # @return [Integer]
359
- def self.database_count
360
- return 1 unless available?
361
+ # A config with `database_tasks: false` counts: workers would share it like
362
+ # any other. A replica does not, as before: it reads another config's database.
363
+ # No rescue: a failure must reach the tool, which then keeps one worker.
364
+ #
365
+ # @return [Hash{String => Object}] "names" (Array<String>) and "primary" (String).
366
+ def self.database_names
367
+ names = test_configs.reject(&:replica?).map(&:name)
368
+ { "names" => names, "primary" => primary_database_name }
369
+ end
361
370
 
371
+ # The config name each worker copies: the one `ActiveRecord::Base` uses.
372
+ #
373
+ # @return [String]
374
+ def self.primary_database_name
375
+ ActiveRecord::Base.connection_db_config.name
376
+ end
377
+
378
+ # The test environment's database configs, hidden ones and replicas included.
379
+ #
380
+ # @return [Array<ActiveRecord::DatabaseConfigurations::DatabaseConfig>]
381
+ def self.test_configs
362
382
  env = ENV["RAILS_ENV"].to_s
363
383
  env = "test" if env.empty?
364
- list = ActiveRecord::Base.configurations.configs_for(env_name: env)
365
- count = list.size
366
- count < 1 ? 1 : count
367
- rescue StandardError
368
- 1
384
+ ActiveRecord::Base.configurations.configs_for(env_name: env, include_hidden: true)
369
385
  end
370
386
 
371
387
  # Child-side (after fork): route this process's ActiveRecord at the worker's
@@ -378,7 +394,7 @@ module Mutineer
378
394
  # `schema.rb` again would drop those rows.
379
395
  #
380
396
  # @param worker [Integer] the worker slot index.
381
- # @param schema_path [String, nil] absolute path to `db/schema.rb`, or nil to skip.
397
+ # @param schema_path [String, nil] the app's Ruby schema file ({ruby_schema_path}), or nil to skip.
382
398
  # @param seed [Boolean] copy the base SQLite file into the worker file first.
383
399
  # @return [void]
384
400
  def self.after_fork(worker, schema_path = nil, seed: false)
@@ -433,10 +449,10 @@ module Mutineer
433
449
  # file, which catches an edited schema with the same version. No stored
434
450
  # checksum counts as out of date, as in Rails' own `schema_up_to_date?`.
435
451
  #
436
- # @param schema_path [String] absolute path to `db/schema.rb`.
452
+ # @param schema_path [String] absolute path to the app's Ruby schema file.
437
453
  # @return [Boolean]
438
454
  def self.schema_current?(schema_path)
439
- text = File.read(schema_path)
455
+ text = File.read(schema_path, encoding: "UTF-8")
440
456
  version = schema_file_version(text)
441
457
  conn = ActiveRecord::Base.connection
442
458
  base = ActiveRecord::Base
@@ -459,10 +475,91 @@ module Mutineer
459
475
  text[/define\(version:\s*([\d_]+)/, 1]&.delete("_")&.to_i
460
476
  end
461
477
 
478
+ # Record each pool that runs SQL in this fork from now on. Run in the fork
479
+ # before its tests, so {touched_shared_databases} also sees a pool that a
480
+ # test disconnects or removes before it ends.
481
+ #
482
+ # @return [void]
483
+ def self.watch_sql
484
+ @sql_pools = {} # used as a set: one entry per pool, not per query
485
+ ActiveSupport::Notifications.subscribe("sql.active_record") do |*, payload|
486
+ connection = payload[:connection]
487
+ @sql_pools[connection.pool] = true if connection.respond_to?(:pool)
488
+ end
489
+ end
490
+
491
+ # Names from `shared` whose database this fork used. Run in a fork after
492
+ # its tests: a name here means a test used a database that parallel
493
+ # workers share, so the verdict cannot be trusted. A pool counts when it
494
+ # is still connected, or when it ran SQL since {watch_sql}. It names a
495
+ # shared database when its config has a shared name, when its class also
496
+ # holds a shared writer pool (a replica in the same `connects_to`), or when
497
+ # its config names the same database as a shared config. Transactional
498
+ # tests connect every registered writing pool, so a pool registered for a
499
+ # shared database shows up here after any test.
500
+ #
501
+ # Only names in {database_names} count: the tool warned about any other
502
+ # name, and no worker shares a database that does not exist.
503
+ #
504
+ # No rescue: a failed check must raise (scored `error`), never read as clean.
505
+ # shortcut: a connection made outside Active Record (a raw driver) is not
506
+ # seen; upgrade when such an app needs `shared_databases`.
507
+ #
508
+ # @param shared [Array<String>] database config names in `shared_databases`.
509
+ # @return [Array<String>]
510
+ def self.touched_shared_databases(shared)
511
+ return [] if shared.empty? || !available?
512
+
513
+ shared &= database_names["names"]
514
+ return [] if shared.empty?
515
+ # Rails 7.0 reads `:all` below as a role name and lists no pool, so the
516
+ # check would always pass. Fail every mutant instead of trusting it.
517
+ raise "shared_databases needs Rails 7.1 or later" if ActiveRecord.version < Gem::Version.new("7.1")
518
+
519
+ pools = ActiveRecord::Base.connection_handler.connection_pool_list(:all)
520
+ owners = pools.filter_map { |p| [pool_owner(p), p.db_config.name] if shared.include?(p.db_config.name) }.to_h
521
+ by_database = test_configs.filter_map { |c| [c.database, c.name] if shared.include?(c.name) && c.database }.to_h
522
+ used = pools.select(&:connected?) | (@sql_pools&.keys || [])
523
+ used.filter_map do |pool|
524
+ config = pool.db_config
525
+ shared.include?(config.name) ? config.name : owners[pool_owner(pool)] || by_database[config.database]
526
+ end.uniq
527
+ end
528
+
529
+ # The class a pool belongs to, by name. `connects_to` registers a class's
530
+ # writer and replica pools under it. Rails 8 keeps it in a descriptor.
531
+ #
532
+ # @param pool [ActiveRecord::ConnectionAdapters::ConnectionPool]
533
+ # @return [String]
534
+ def self.pool_owner(pool)
535
+ config = pool.pool_config
536
+ config.respond_to?(:connection_descriptor) ? config.connection_descriptor.name : config.connection_name
537
+ end
538
+
539
+ # The Ruby schema file a SQLite worker loads over an out-of-date copy, as
540
+ # the booted app's config names it. Rails resolves the name: `db/schema.rb`
541
+ # by default, the `schema_dump:` file of the database config (a bare name
542
+ # such as `schema_sqlite.rb` lives in `db/`), or the `SCHEMA` variable.
543
+ # nil when the file does not exist, when the config sets `schema_dump: false`,
544
+ # or when the app uses `structure.sql` (a worker cannot `load` SQL). With nil
545
+ # the worker keeps its copy of the test database as it is.
546
+ #
547
+ # @return [String, nil] absolute path of an existing file, or nil.
548
+ def self.ruby_schema_path
549
+ db_config = ActiveRecord::Base.connection_db_config
550
+ format = schema_format_for(db_config)
551
+ return unless format == :ruby
552
+
553
+ path = ActiveRecord::Tasks::DatabaseTasks.schema_dump_path(db_config, format)
554
+ # Expanded so `load` never searches $LOAD_PATH for a relative SCHEMA value.
555
+ path &&= File.expand_path(path)
556
+ path if path && File.exist?(path)
557
+ end
558
+
462
559
  # Load a Rails `schema.rb` into the current connection with output silenced
463
560
  # (fork child stdout is already File::NULL; this is belt-and-braces).
464
561
  #
465
- # @param schema_path [String] absolute path to `db/schema.rb`.
562
+ # @param schema_path [String] absolute path to the app's Ruby schema file.
466
563
  # @return [void]
467
564
  def self.load_schema(schema_path)
468
565
  ActiveRecord::Migration.verbose = false if defined?(ActiveRecord::Migration)
@@ -518,6 +615,27 @@ module Mutineer
518
615
  adapter == "mysql2" || adapter == "trilogy"
519
616
  end
520
617
 
618
+ # Classify a MySQL `SELECT VERSION()` text against the support matrix.
619
+ # MariaDB is matched by name first: it reports 10.x or 11.x, and some
620
+ # builds prefix `5.5.5-`, so a numeric check alone would misread it.
621
+ #
622
+ # @param version [String, nil]
623
+ # @return [Symbol] `:supported` (MySQL 8), `:not_supported` (before 8),
624
+ # `:not_tested` (MariaDB, MySQL 9 or later), or `:unreadable`.
625
+ def self.mysql_version_support(version)
626
+ text = version.to_s
627
+ return :not_tested if text.match?(/mariadb/i)
628
+
629
+ major = text[/\A(\d+)\.\d+/, 1]
630
+ return :unreadable unless major
631
+
632
+ major = major.to_i
633
+ return :not_supported if major < 8
634
+ return :not_tested if major > 8
635
+
636
+ :supported
637
+ end
638
+
521
639
  # Drop and recreate slots `0...count` from the booted test database.
522
640
  # Holds the exclusive advisory lock only for the copy, then a shared lock
523
641
  # for the rest of this process so a second run cannot drop those databases.
@@ -819,7 +937,8 @@ module Mutineer
819
937
  #
820
938
  # @param count [Integer]
821
939
  # @return [void]
822
- # @raise [RuntimeError] when `count` exceeds {MYSQL_LOCK_SLOTS}, or another run holds the lock.
940
+ # @raise [RuntimeError] when `count` exceeds {MYSQL_LOCK_SLOTS}, the server is
941
+ # MySQL older than 8.0, or another run holds the lock.
823
942
  def self.provision_mysql(count)
824
943
  if count > MYSQL_LOCK_SLOTS
825
944
  raise "MySQL worker databases support at most #{MYSQL_LOCK_SLOTS} workers"
@@ -829,6 +948,10 @@ module Mutineer
829
948
  remember_mysql_base_config(hash)
830
949
  base = hash[:database].to_s
831
950
  conn = open_mysql_connection(hash, base)
951
+ if (refusal = check_mysql_server(conn, password: hash[:password]))
952
+ conn.close
953
+ raise "could not provision #{base}: #{refusal}"
954
+ end
832
955
  unless try_mysql_lock(conn, base, shared: false)
833
956
  conn.close
834
957
  raise "another Mutineer run is using #{base}"
@@ -861,6 +984,56 @@ module Mutineer
861
984
  warn "[mutineer] provisioned #{count} mysql worker database(s) from #{base}"
862
985
  end
863
986
 
987
+ # Read the server version and print a warning when the matrix does not
988
+ # test it. Does not close `conn` or raise: the caller does both. A failed
989
+ # version query only warns, with its cause shown. URL passwords, the
990
+ # DATABASE_URL password and the configured `password` are hidden in the
991
+ # cause and in the version text.
992
+ #
993
+ # @param conn [Mysql2::Client, Trilogy]
994
+ # @param password [String, nil] the configured password, hidden when it appears.
995
+ # @return [String, nil] the refusal text for MySQL older than 8.0, else nil.
996
+ def self.check_mysql_server(conn, password: nil)
997
+ secret = password.to_s
998
+ hide = lambda do |text|
999
+ out = Redaction.text(text)
1000
+ next out if secret.empty?
1001
+ # A short password would match inside ordinary words, so hide it only as a whole word.
1002
+ pattern = secret.length >= 4 ? secret : /(?<![[:alnum:]])#{Regexp.escape(secret)}(?![[:alnum:]])/
1003
+ out.gsub(pattern, Redaction::REDACTED)
1004
+ end
1005
+ failure = nil
1006
+ raw = begin
1007
+ mysql_rows(conn, "SELECT VERSION() AS version").first&.fetch("version", nil)
1008
+ rescue StandardError => e
1009
+ failure = hide.call("#{e.class}: #{e.message.lines.first.to_s.strip}")
1010
+ nil
1011
+ end
1012
+ support = mysql_version_support(raw)
1013
+ version = raw.nil? ? nil : hide.call(raw)
1014
+
1015
+ case support
1016
+ when :not_supported
1017
+ "MySQL #{version} is not supported. --daemon needs MySQL 8.0 or later. " \
1018
+ "Pass --no-daemon (or daemon: false) to stay serial."
1019
+ when :not_tested
1020
+ warn "[mutineer] server version #{version} is not tested with --daemon. " \
1021
+ "The run continues. If a result looks wrong, pass --no-daemon."
1022
+ nil
1023
+ when :unreadable
1024
+ shown = if failure
1025
+ "an error: #{failure}"
1026
+ elsif version.to_s.empty?
1027
+ "nothing"
1028
+ else
1029
+ "\"#{version}\""
1030
+ end
1031
+ warn "[mutineer] could not read the MySQL server version (got #{shown}). " \
1032
+ "The run continues. --daemon is tested on MySQL 8."
1033
+ nil
1034
+ end
1035
+ end
1036
+
864
1037
  # Take one shared GET_LOCK name and drop this process's app connection.
865
1038
  #
866
1039
  # @return [void]
@@ -1111,7 +1284,7 @@ module Mutineer
1111
1284
  ActiveRecord::Base.establish_connection(slot_hash)
1112
1285
  switched = true
1113
1286
  db_config = mysql_db_config(slot_hash)
1114
- format = current_schema_format
1287
+ format = schema_format_for(db_config)
1115
1288
  file = mysql_schema_file(db_config, format)
1116
1289
  ActiveRecord::Tasks::DatabaseTasks.load_schema(db_config, format, file) if file
1117
1290
  rescue StandardError => e
@@ -1142,6 +1315,17 @@ module Mutineer
1142
1315
  ActiveRecord::DatabaseConfigurations::HashConfig.new(env, "primary", slot_hash)
1143
1316
  end
1144
1317
 
1318
+ # Schema format of one database config. Rails 8.1 lets each config set its
1319
+ # own `schema_format:`; older Rails has only the app-wide setting. A string
1320
+ # setting ("ruby") counts the same as the symbol.
1321
+ #
1322
+ # @param db_config [ActiveRecord::DatabaseConfigurations::DatabaseConfig]
1323
+ # @return [Symbol]
1324
+ def self.schema_format_for(db_config)
1325
+ format = db_config.respond_to?(:schema_format) ? db_config.schema_format : current_schema_format
1326
+ format.respond_to?(:to_sym) ? format.to_sym : format
1327
+ end
1328
+
1145
1329
  # Schema format the booted app is using.
1146
1330
  #
1147
1331
  # @return [Symbol]
@@ -5,7 +5,10 @@ require "uri"
5
5
  module Mutineer
6
6
  # Hides database credentials in text bound for a terminal: an error reply,
7
7
  # a log line, a warning. Rails parses DATABASE_URL itself, so an error about
8
- # a malformed one can quote the whole URL.
8
+ # a malformed one can quote the whole URL. Outside a URL a password of 3
9
+ # characters or fewer is hidden only as a whole token, so it cannot blank
10
+ # letters inside other words. A password that exactly equals the username is
11
+ # left visible outside a URL, so `role "postgres"` stays readable.
9
12
  #
10
13
  # Stdlib only: the app-side daemon loads this file without the rest of
11
14
  # mutineer.
@@ -16,27 +19,102 @@ module Mutineer
16
19
  URL_PASSWORD = %r{([a-z][a-z0-9+.\-]*://[^\s:/@"'\\]*):(?:\\.|[^"'\r\n\\])*@}i
17
20
  # What replaces a hidden password.
18
21
  REDACTED = "[REDACTED]"
22
+ # What {text} returns when it fails: never the input, which may hold the
23
+ # password.
24
+ FAILED = "[message hidden: redaction failed]"
25
+ # An ASCII word character, the edge a whole-token match must not touch.
26
+ WORD_CHAR = "[A-Za-z0-9_]"
27
+ # The longest password form hidden only as a whole token. A longer form is
28
+ # hidden wherever it appears, even glued to an escape such as `%40` or
29
+ # `\x41`: failing toward hiding too much is safer for a long secret.
30
+ SHORT_FORM = 3
19
31
 
20
32
  module_function
21
33
 
22
- # Hide the password in any URL, and the DATABASE_URL password wherever it
23
- # appears: whole, percent-decoded, `inspect` escaped, or cut short after
24
- # its `scheme://user:` prefix.
34
+ # Hide the password in any URL, and the DATABASE_URL password (from its
35
+ # `user:password@` pair and from its `password=` query parameter) wherever
36
+ # it appears: whole, percent-decoded, `inspect` escaped, or cut short after
37
+ # its `scheme://user:` prefix. Outside a URL a form of {SHORT_FORM}
38
+ # characters or fewer is hidden only as a whole token. Never raises: text in
39
+ # any encoding comes back as UTF-8, and an internal error returns {FAILED}.
25
40
  #
26
41
  # @param text [#to_s]
27
42
  # @param env [Hash{String => String}] where DATABASE_URL is read.
28
43
  # @return [String]
29
44
  def text(text, env: ENV)
30
- out = text.to_s
31
- head, raw = database_url_parts(env["DATABASE_URL"].to_s)
32
- secrets = raw ? password_forms(raw) : []
45
+ out = utf8(text.to_s)
46
+ url = utf8(env["DATABASE_URL"].to_s)
47
+ # Two readings of the userinfo: up to the last `@` (a malformed password
48
+ # may hold any character) and up to the last `@` before the path (a
49
+ # query value may hold an `@`). Both are hidden: hiding too much is safe.
50
+ parts = [database_url_parts(url), database_url_parts(authority(url))].compact.uniq
33
51
  # Prefixes first, on the caller's text, so no replacement made here can
34
52
  # be mistaken for part of a password.
35
- [head, head&.inspect&.[](1...-1)].compact.uniq.each do |prefix|
36
- out = secrets.reduce(out) { |acc, secret| redact_prefixes(acc, prefix, secret) }
53
+ parts.each do |head, raw|
54
+ forms = password_forms(raw)
55
+ [head, head.inspect[1...-1]].uniq.each do |prefix|
56
+ out = forms.reduce(out) { |acc, secret| redact_prefixes(acc, prefix, secret) }
57
+ end
37
58
  end
38
59
  out = out.gsub(URL_PASSWORD) { "#{Regexp.last_match(1)}:#{REDACTED}@" }
39
- secrets.reduce(out) { |acc, secret| acc.gsub(secret, REDACTED) }
60
+ # A password that exactly equals the username is left visible outside a
61
+ # URL. The username is not a secret: mutineer prints it in every
62
+ # redacted URL, so the value is already shown, and blanking it would
63
+ # hide every role and database name in a `postgres:postgres` setup. The
64
+ # skip needs an exact raw match: a password that only shares a form or a
65
+ # prefix with the username is a separate secret and stays hidden. A
66
+ # `password=` query parameter, which Rails prefers, is always hidden.
67
+ raws = parts.reject { |head, raw| raw == head[(head.index("://") + 3)...-1] }.map(&:last)
68
+ raws.concat(query_passwords(url))
69
+ secrets = raws.compact.uniq.flat_map { |raw| password_forms(raw) }.uniq.sort_by { |form| -form.length }
70
+ secrets.reduce(out) do |acc, secret|
71
+ secret.length > SHORT_FORM ? acc.gsub(secret, REDACTED) : redact_word(acc, secret)
72
+ end
73
+ rescue StandardError
74
+ FAILED
75
+ end
76
+
77
+ # A UTF-8 copy of `str` that every pattern here can match. Valid UTF-8 and
78
+ # ASCII-only text is returned as is. Binary text, or ASCII-compatible text
79
+ # whose bytes are invalid in its own encoding, is read as UTF-8 with bad
80
+ # bytes replaced. Other text (including UTF-16 and UTF-32, valid or not)
81
+ # is converted, with bad bytes and characters UTF-8 lacks replaced:
82
+ # reading UTF-16 bytes as UTF-8 would hide the password behind NULs.
83
+ #
84
+ # @param str [String]
85
+ # @return [String]
86
+ def utf8(str)
87
+ return str if str.ascii_only? || (str.encoding == Encoding::UTF_8 && str.valid_encoding?)
88
+
89
+ reread = str.encoding == Encoding::BINARY || (!str.valid_encoding? && str.encoding.ascii_compatible?)
90
+ return String.new(str, encoding: Encoding::UTF_8).scrub if reread
91
+
92
+ str.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
93
+ end
94
+
95
+ # Replace `form` in `text` only where it is a whole token: no ASCII word
96
+ # character touches either edge, including a punctuation edge.
97
+ #
98
+ # @param text [String]
99
+ # @param form [String] one form of the password.
100
+ # @return [String]
101
+ def redact_word(text, form)
102
+ pattern = "(?<!#{WORD_CHAR})#{Regexp.escape(form)}(?!#{WORD_CHAR})"
103
+ text.gsub(Regexp.new(pattern), REDACTED)
104
+ end
105
+
106
+ # An exception as `Class: message`, redacted. Each part is redacted on its
107
+ # own, so the join cannot raise on mixed encodings (a non-ASCII class name
108
+ # next to, say, a Latin-1 message): a raise inside a rescue would make the
109
+ # raw error the cause of the new one. For the same reason an exception
110
+ # whose own `message` raises gives {FAILED}.
111
+ #
112
+ # @param error [Exception]
113
+ # @return [String]
114
+ def describe(error)
115
+ "#{text(error.class)}: #{text(error.message)}"
116
+ rescue StandardError
117
+ FAILED
40
118
  end
41
119
 
42
120
  # Split DATABASE_URL into its `scheme://user:` head and its raw password.
@@ -58,6 +136,28 @@ module Mutineer
58
136
  [url[0..colon], raw] unless raw.empty?
59
137
  end
60
138
 
139
+ # `url` cut before the first `/`, `?` or `#` after `scheme://`: the part
140
+ # that holds the userinfo of a well-formed URL.
141
+ #
142
+ # @param url [String]
143
+ # @return [String]
144
+ def authority(url)
145
+ start = url.index("://")
146
+ return url unless start
147
+
148
+ url[0...(url.index(%r{[/?#]}, start + 3) || url.length)]
149
+ end
150
+
151
+ # The raw value of each `password=` query parameter, which Rails and libpq
152
+ # both accept in place of a `user:password@` pair. Every one, because
153
+ # Rails keeps the last duplicate.
154
+ #
155
+ # @param url [String]
156
+ # @return [Array<String>]
157
+ def query_passwords(url)
158
+ url.scan(/[?&]password=([^&#]+)/).flatten
159
+ end
160
+
61
161
  # The password as it may appear in a message: raw, percent-decoded, and
62
162
  # each `inspect` escaped. Longest first, so a longer form is replaced
63
163
  # before a form it contains.
@@ -66,7 +166,8 @@ module Mutineer
66
166
  # @return [Array<String>]
67
167
  def password_forms(raw)
68
168
  decoded = begin
69
- URI.decode_www_form_component(raw.gsub("+", "%2B"))
169
+ # Decoding can yield invalid UTF-8 (`%E4`); scrub it like the text.
170
+ utf8(URI.decode_www_form_component(raw.gsub("+", "%2B")))
70
171
  rescue ArgumentError
71
172
  raw
72
173
  end