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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +432 -0
- data/README.md +157 -747
- data/lib/mutineer/baseline.rb +1 -1
- data/lib/mutineer/changed_lines.rb +2 -1
- data/lib/mutineer/cli.rb +119 -27
- data/lib/mutineer/concern_blocks.rb +168 -5
- data/lib/mutineer/config.rb +6 -0
- data/lib/mutineer/coverage_map.rb +92 -17
- data/lib/mutineer/daemon_backend.rb +75 -32
- data/lib/mutineer/daemon_client.rb +50 -17
- data/lib/mutineer/daemon_server.rb +92 -40
- data/lib/mutineer/external_backend.rb +150 -17
- data/lib/mutineer/isolation.rb +67 -25
- data/lib/mutineer/job_plan.rb +117 -33
- data/lib/mutineer/kill_channel.rb +24 -14
- data/lib/mutineer/migrate.rb +10 -8
- data/lib/mutineer/minitest_integration/in_test_database_error.rb +184 -0
- data/lib/mutineer/minitest_integration/kill_recorder.rb +25 -17
- data/lib/mutineer/minitest_integration.rb +44 -14
- data/lib/mutineer/mutators/array_literal.rb +4 -2
- data/lib/mutineer/mutators/base.rb +22 -0
- data/lib/mutineer/mutators/call_to_receiver.rb +20 -7
- data/lib/mutineer/mutators/chain_link.rb +6 -6
- data/lib/mutineer/mutators/condition_forcing.rb +6 -3
- data/lib/mutineer/mutators/operand_removal.rb +4 -2
- data/lib/mutineer/mutators/range_literal.rb +105 -4
- data/lib/mutineer/mutators/return_nil.rb +1 -0
- data/lib/mutineer/orphan_guard.rb +18 -6
- data/lib/mutineer/pairing.rb +62 -1
- data/lib/mutineer/project.rb +226 -12
- data/lib/mutineer/rails_worker_db.rb +200 -16
- data/lib/mutineer/redaction.rb +112 -11
- data/lib/mutineer/reporter.rb +109 -21
- data/lib/mutineer/result.rb +33 -5
- data/lib/mutineer/runner.rb +126 -18
- data/lib/mutineer/statement_lines.rb +14 -8
- data/lib/mutineer/subject.rb +13 -5
- data/lib/mutineer/test_runners/minitest.rb +2 -1
- data/lib/mutineer/test_runners.rb +4 -2
- data/lib/mutineer/triage.rb +1 -1
- data/lib/mutineer/version.rb +1 -1
- data/lib/mutineer/worker_pool.rb +2 -1
- data/skills/mutineer/SKILL.md +84 -0
- 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
|
-
#
|
|
356
|
-
#
|
|
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
|
-
#
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
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]
|
|
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
|
|
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
|
|
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},
|
|
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 =
|
|
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]
|
data/lib/mutineer/redaction.rb
CHANGED
|
@@ -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
|
|
23
|
-
#
|
|
24
|
-
#
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|