Class: Tina4::DatabaseUrl
- Inherits:
-
Object
- Object
- Tina4::DatabaseUrl
- 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.
sqlite3is 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 stayssqlite. { "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
-
#connection_string ⇒ Object
readonly
Returns the value of attribute connection_string.
-
#database ⇒ Object
readonly
Returns the value of attribute database.
-
#engine ⇒ Object
readonly
Returns the value of attribute engine.
-
#host ⇒ Object
readonly
Returns the value of attribute host.
-
#password ⇒ Object
readonly
Returns the value of attribute password.
-
#port ⇒ Object
readonly
Returns the value of attribute port.
-
#username ⇒ Object
readonly
Returns the value of attribute username.
Class Method Summary collapse
-
.from_env(key = "TINA4_DATABASE_URL") ⇒ Object
Parse the configured URL, or nil when the variable is not set.
-
.invalid_url_error(url) ⇒ Object
The invalid-URL message, with the credential taken OUT.
-
.redact(raw) ⇒ Object
THE redaction primitive.
-
.redact_authority(authority) ⇒ Object
Take the credential out of a URL authority.
-
.scrub(text) ⇒ Object
Structural redaction of a string that did NOT parse.
-
.scrub_keywords(text) ⇒ Object
Redact the value side of a credential keyword, leaving every other keyword visible so the string is still diagnosable.
Instance Method Summary collapse
-
#dsn ⇒ Object
Connection target.
-
#initialize(url, username: nil, password: nil) ⇒ DatabaseUrl
constructor
A new instance of DatabaseUrl.
-
#inspect ⇒ Object
inspect lands in backtraces and the console, so it MUST be the safe form.
- #to_json(*args) ⇒ Object
-
#to_s ⇒ Object
Interpolation ("connecting to #url") and JSON both have to be safe, not just inspect.
-
#to_safe_string ⇒ Object
The URL with the password replaced by ***.
Constructor Details
#initialize(url, username: nil, password: nil) ⇒ DatabaseUrl
Returns a new instance of DatabaseUrl.
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_string ⇒ Object (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 |
#database ⇒ Object (readonly)
Returns the value of attribute database.
93 94 95 |
# File 'lib/tina4/database_url.rb', line 93 def database @database end |
#engine ⇒ Object (readonly)
Returns the value of attribute engine.
93 94 95 |
# File 'lib/tina4/database_url.rb', line 93 def engine @engine end |
#host ⇒ Object (readonly)
Returns the value of attribute host.
93 94 95 |
# File 'lib/tina4/database_url.rb', line 93 def host @host end |
#password ⇒ Object (readonly)
Returns the value of attribute password.
93 94 95 |
# File 'lib/tina4/database_url.rb', line 93 def password @password end |
#port ⇒ Object (readonly)
Returns the value of attribute port.
93 94 95 |
# File 'lib/tina4/database_url.rb', line 93 def port @port end |
#username ⇒ Object (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.() # 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 = .rindex("@") if at userinfo = [0, at] host = [(at + 1)..].to_s name, colon, = userinfo.partition(":") return colon.empty? ? "#{name}@#{host}" : "#{name}:#{REDACTED}@#{host}" end return if .match?(HOST_PORT) name, colon, = .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}://#{(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
#dsn ⇒ Object
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 |
#inspect ⇒ Object
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_s ⇒ Object
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_string ⇒ Object
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 |