Class: Tina4::DatabaseUrl

Inherits:
Object
  • Object
show all
Defined in:
lib/tina4/database_url.rb

Overview

A parsed database connection URL, as a VALUE.

Feature 5 of the feature audit. Ruby had no parser to call: URL handling was inline in Database#initialize, so a URL could not be parsed without building a connection object. The parse could not be unit tested on its own, the four frameworks could not be compared without standing up a database, and tina4 doctor or the setup wizard had nothing to call to validate a URL before using it.

What was there instead was detect_driver: substring regex matching over the whole connection string with a silent else "sqlite" fallback, so an unrecognised URL did not fail - it quietly became SQLite. The app boots, writes to a local file, and nobody learns the real database was never reached. This class raises instead.

Core Principle 6 says a connection string must mean literally the same thing in every framework. spec/fixtures/database_url_corpus.json is the answer key, byte-identical in all four. DISPLAY REDACTS, FIDELITY DOES NOT. #inspect, #to_s and #to_safe_string replace the password with the redaction marker, so a log line, a backtrace or a status payload is safe. Marshal deliberately does not: its contract is a faithful round trip, and a masked Marshal would load an object whose password is the literal "***".

The consequence: DO NOT PERSIST THIS OBJECT. A DatabaseUrl marshalled into a cache, a session or a queue payload puts the password in cleartext on disk. Record #to_safe_string instead. spec/database_url_redaction_spec.rb fails the build if framework code ever marshals one.

Constant Summary collapse

ENGINE_ALIASES =

URL scheme to CANONICAL engine. Aliases resolve ONCE, here, so nothing downstream ever compares raw schemes.

sqlite3 is accepted because the driver is literally named sqlite3 in every framework (Python's sqlite3 module, Ruby's sqlite3 gem, PHP's ext-sqlite3, Node's node:sqlite), so people type it. The "3" is a file-format version, not a different engine, which is why the canonical name stays sqlite.

{
  "sqlite" => "sqlite",
  "sqlite3" => "sqlite",
  "postgres" => "postgres",
  "postgresql" => "postgres",
  "pgsql" => "postgres",
  "mysql" => "mysql",
  "mssql" => "mssql",
  "sqlserver" => "mssql",
  "firebird" => "firebird",
  "mongodb" => "mongodb",
  "mongo" => "mongodb",
  "odbc" => "odbc"
}.freeze
DEFAULT_PORTS =

Applied AT PARSE. The port is part of our contract, not the driver's business: a URL with no port must yield the same struct in all four.

{
  "postgres" => 5432,
  "mysql" => 3306,
  "mssql" => 1433,
  "firebird" => 3050,
  "mongodb" => 27017
}.freeze
REDACTED =

What replaces a credential everywhere. One spelling, so a test can assert on it and a reader recognises it on sight.

"***"
SCHEME =

RFC 3986 scheme grammar. A scheme cannot contain ":", "@", "/" or a space, which is what makes it the ONLY part of an unparseable string that is provably not a password.

/\A[A-Za-z][A-Za-z0-9+.\-]*\z/.freeze
HOST_PORT =

host[/path] with a NUMERIC port. The digit requirement is what does the work: it is why "user:secret" (a URL with the host left off) fails to match and gets redacted, while "192.168.88.99:55432/tina4_py" is echoed.

%r{\A(?:\[[0-9A-Fa-f:.]+\]|[A-Za-z0-9._-]+)(?::\d+)?(?:/.*)?\z}m.freeze
CREDENTIAL_KEYWORDS =

Credential KEYWORDS in the two keyword=value forms that carry one: the ODBC connection string (";"-separated) and the libpq/JDBC query string ("&"-separated). A braced value is matched FIRST because an ODBC value containing ";" must be braced - stopping at the ";" would leave the tail of the password in the output, which is exactly the bug shape this file exists to close.

UID/USER are deliberately NOT redacted: a username is not a secret, and it is most of what makes a failed connection diagnosable.

/\b(PWD|PASSWORD)\s*=\s*(?:\{[^}]*\}|[^;&]*)/i.freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(url, username: nil, password: nil) ⇒ DatabaseUrl

Returns a new instance of DatabaseUrl.

Raises:

  • (ArgumentError)


96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
# File 'lib/tina4/database_url.rb', line 96

def initialize(url, username: nil, password: nil)
  raise ArgumentError, "DatabaseUrl: the URL is empty" if url.nil? || url.to_s.strip.empty?

  url = url.to_s
  @host = nil
  @port = nil
  @database = ""
  @username = nil
  @password = nil
  @connection_string = nil

  if url.start_with?("sqlite:", "sqlite3:")
    parse_sqlite(url)
  elsif url.start_with?("odbc:///")
    @engine = "odbc"
    @connection_string = url[("odbc:///".length)..]
  else
    parse_standard(url)
  end

  # Separate credentials fill in only when the URL carried NONE.
  #
  # ABSENT and BLANK are different values, and the difference is load
  # bearing: the gate is `nil?`, never `empty?`. A URL written
  #   postgres://user:@host/db
  # states an explicitly EMPTY password, so the TINA4_DATABASE_PASSWORD
  # fallback must NOT fire; only
  #   postgres://user@host/db
  # is absent and takes the env value. Measured 2026-08-02: Ruby and PHP
  # obey this, Python and Node return None for the blank form and therefore
  # authenticate with a DIFFERENT password off the SAME .env - which is the
  # kind of divergence nobody notices until an account locks out.
  @username = username if @username.nil? && username && !username.empty?
  @password = password if @password.nil? && password && !password.empty?
end

Instance Attribute Details

#connection_stringObject (readonly)

Returns the value of attribute connection_string.



93
94
95
# File 'lib/tina4/database_url.rb', line 93

def connection_string
  @connection_string
end

#databaseObject (readonly)

Returns the value of attribute database.



93
94
95
# File 'lib/tina4/database_url.rb', line 93

def database
  @database
end

#engineObject (readonly)

Returns the value of attribute engine.



93
94
95
# File 'lib/tina4/database_url.rb', line 93

def engine
  @engine
end

#hostObject (readonly)

Returns the value of attribute host.



93
94
95
# File 'lib/tina4/database_url.rb', line 93

def host
  @host
end

#passwordObject (readonly)

Returns the value of attribute password.



93
94
95
# File 'lib/tina4/database_url.rb', line 93

def password
  @password
end

#portObject (readonly)

Returns the value of attribute port.



93
94
95
# File 'lib/tina4/database_url.rb', line 93

def port
  @port
end

#usernameObject (readonly)

Returns the value of attribute username.



93
94
95
# File 'lib/tina4/database_url.rb', line 93

def username
  @username
end

Class Method Details

.from_env(key = "TINA4_DATABASE_URL") ⇒ Object

Parse the configured URL, or nil when the variable is not set.



133
134
135
136
137
138
# File 'lib/tina4/database_url.rb', line 133

def self.from_env(key = "TINA4_DATABASE_URL")
  url = (ENV[key] || "").strip
  return nil if url.empty?

  new(url, username: ENV["TINA4_DATABASE_USERNAME"], password: ENV["TINA4_DATABASE_PASSWORD"])
end

.invalid_url_error(url) ⇒ Object

The invalid-URL message, with the credential taken OUT.

It still has to be diagnosable, so it keeps the scheme, the host, the port as written (the usual fault) and the database name - only the password becomes ***. When the value has no "://" there is no structure to trust, so the value is not shown at all and the message says why.



230
231
232
233
234
235
236
237
238
239
240
# File 'lib/tina4/database_url.rb', line 230

def self.invalid_url_error(url)
  text = url.to_s
  if text.include?("://")
    "DatabaseUrl: Invalid URL format '#{scrub(text)}' - expected " \
      "scheme://user:password@host:port/database"
  else
    "DatabaseUrl: Invalid URL format - no '://', so there is no scheme to " \
      "connect with. The URL itself is not shown because it can contain a " \
      "password. Expected scheme://user:password@host:port/database"
  end
end

.redact(raw) ⇒ Object

THE redaction primitive. Every connection string that is about to reach a log line, an exception message or a dump goes through here.

It takes a RAW string and never raises, because the paths that most need redacting are exactly the ones where parsing already failed.

Measured in this repo on 2026-08-02, before this existed:

* Database#safe_connection_target carried its OWN regex, and
  postgres://u:p@ss@h:5432/db -> postgres://u:***@ss@h:5432/db
leaked the password tail past the first "@" (the Ruby twin of the PHP
`password=\S*` tail leak), and
  odbc:///...;PWD=<secret>    -> returned VERBATIM
went straight into DatabaseConnectionError, which is raised at the
first query and lands in a 500 body.
* DatabaseUrl's own invalid-URL ArgumentError interpolated the whole raw
URL, and detect_driver raises it on the BOOT path, so one typo in
TINA4_DATABASE_URL wrote the password into the boot log and CI output.

One primitive means the next such bug is fixed once, not four times.



158
159
160
161
162
163
164
165
166
167
168
169
170
171
# File 'lib/tina4/database_url.rb', line 158

def self.redact(raw)
  text = raw.to_s
  return "" if text.empty?

  begin
    # A URL that PARSES gets the round-tripping form rebuilt from the parsed
    # FIELDS. That form cannot leak: the password is never copied into it.
    new(text).to_safe_string
  rescue StandardError
    # Unparseable - fall back to the structural scrub. Redaction must never
    # raise; a raise here would mask the error we were called to describe.
    scrub(text)
  end
end

.redact_authority(authority) ⇒ Object

Take the credential out of a URL authority.

user:pass@host:5432/db -> user:***@host:5432/db
host:5432/db           -> host:5432/db   (no credential present)
user:pass              -> user:***       (host left off - a real typo,
                                        and the only reason the no-"@"
                                        branch cannot echo blindly)


200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
# File 'lib/tina4/database_url.rb', line 200

def self.redact_authority(authority)
  # The LAST "@", because userinfo ends at the last "@" (RFC 3986). Splitting
  # on the FIRST one is how an unencoded "@" inside a password leaves a tail
  # in the output.
  at = authority.rindex("@")
  if at
    userinfo = authority[0, at]
    host = authority[(at + 1)..].to_s
    name, colon, = userinfo.partition(":")
    return colon.empty? ? "#{name}@#{host}" : "#{name}:#{REDACTED}@#{host}"
  end

  return authority if authority.match?(HOST_PORT)

  name, colon, = authority.partition(":")
  colon.empty? ? name : "#{name}:#{REDACTED}"
end

.scrub(text) ⇒ Object

Structural redaction of a string that did NOT parse.



174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
# File 'lib/tina4/database_url.rb', line 174

def self.scrub(text)
  head, separator, rest = text.to_s.partition("://")

  # No "://" - a bare file path, or an ODBC/keyword string. There is no
  # userinfo to find, so only the keyword form can hide a secret here.
  return scrub_keywords(text.to_s) if separator.empty?

  # A head that is not a scheme is unknown text, and unknown text may BE the
  # secret (the measured Python case was `notaurl-with-SuperSecret123`).
  # Say nothing about it rather than guess.
  return REDACTED unless head.match?(SCHEME)

  # The authority is taken to the first "?" or "#", NOT to the first "/".
  # An unencoded "/" inside a password would otherwise cut the region short,
  # push the "@" outside it, and let the password prefix survive.
  cut = rest.index(/[?#]/) || rest.length
  "#{head}://#{redact_authority(rest[0, cut])}#{scrub_keywords(rest[cut..].to_s)}"
end

.scrub_keywords(text) ⇒ Object

Redact the value side of a credential keyword, leaving every other keyword visible so the string is still diagnosable.



220
221
222
# File 'lib/tina4/database_url.rb', line 220

def self.scrub_keywords(text)
  text.gsub(CREDENTIAL_KEYWORDS) { "#{Regexp.last_match(1)}=#{REDACTED}" }
end

Instance Method Details

#dsnObject

Connection target. sqlite and odbc are the whole value.



243
244
245
246
247
248
249
250
251
# File 'lib/tina4/database_url.rb', line 243

def dsn
  return @database if @engine == "sqlite"
  return @connection_string.to_s if @engine == "odbc"

  out = @host.to_s
  out += ":#{@port}" unless @port.nil?
  out += "/#{@database}" unless @database.empty?
  out
end

#inspectObject

inspect lands in backtraces and the console, so it MUST be the safe form.



280
281
282
# File 'lib/tina4/database_url.rb', line 280

def inspect
  "#<Tina4::DatabaseUrl #{to_safe_string}>"
end

#to_json(*args) ⇒ Object



293
294
295
# File 'lib/tina4/database_url.rb', line 293

def to_json(*args)
  to_safe_string.to_json(*args)
end

#to_sObject

Interpolation ("connecting to #url") and JSON both have to be safe, not just inspect. Ruby's inspect already guarded the console and backtraces - these two close the same class of leak PHP has through print_r/var_dump and Node through JSON.stringify, and they make the useless default "#Tina4::DatabaseUrl:0x000..." say something instead.



289
290
291
# File 'lib/tina4/database_url.rb', line 289

def to_s
  to_safe_string
end

#to_safe_stringObject

The URL with the password replaced by ***.

The ONLY form allowed in a log line or an error message: a connection URL in a log is a credential leak. It round-trips the input, so it stays readable as well as safe.



258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
# File 'lib/tina4/database_url.rb', line 258

def to_safe_string
  return "sqlite:///#{@database}" if @engine == "sqlite"
  # ODBC used to return the connection string VERBATIM here, PWD= included -
  # measured 2026-08-02. The negative test "never leaks the password" passed
  # anyway, because the shared corpus had no odbc row for it to check: the
  # guard existed, the test was green, and it protected nothing. Every other
  # keyword stays visible so a failed ODBC connection is still diagnosable.
  return "odbc:///#{self.class.scrub_keywords(@connection_string.to_s)}" if @engine == "odbc"

  out = "#{@engine}://"
  unless @username.nil?
    out += @username
    out += ":***" unless @password.nil?
    out += "@"
  end
  out += @host.to_s
  out += ":#{@port}" unless @port.nil?
  out += "/#{@database}" unless @database.empty?
  out
end