Class: Ruact::Doctor

Inherits:
Object
  • Object
show all
Defined in:
lib/ruact/doctor.rb

Overview

Runs a suite of installation health checks and prints ✓/✗ per check. Extracted from the ruact:doctor Rake task for direct testability (FR27).

Constant Summary collapse

CHECKS =
%i[manifest vite controller layout streaming legacy_constant serialize_only flight_middleware].freeze
LEGACY_CONST =

Built via Array#join so the gem-CI name-propagation guard does not match these literals against itself (Story 5.1 review F4 — the doctor file participates in the guard with no exclusion).

%w[Rails Rsc].join
LEGACY_GEM =
%w[rails rsc].join("_")
LEGACY_CONSTANT_RE =
/(?<![A-Za-z_])(?:#{LEGACY_CONST}|#{LEGACY_GEM})(?![A-Za-z_])/
LEGACY_SCAN_GLOBS =
["config/initializers/**/*.rb", "app/**/*.rb"].freeze
RENAME_DOC_URL =
"https://github.com/luizcg/ruact/blob/main/CHANGELOG.md#renamed"
DESERIALIZE_SIGNALS =

--- Story 13.1: serialize-only invariant tripwire (FR97) ---------------

ruact EMITS React Flight (text/x-component) but must never DESERIALIZE externally-supplied Flight into live Ruby objects — that keeps it outside the React2Shell / CVE-2025-55182 class (a Flight-deserialization RCE). See the ADR addendum in docs/internal/decisions/server-functions-api.md.

The signal literals are assembled from fragments via Array#join so this file does NOT itself contain the matched strings (mirrors the LEGACY_CONST F4 lesson). doctor.rb is also excluded from the scan as defense in depth. Only structural inbound-deserialization signals are used; the raw text/x-component token is deliberately NOT a signal because the gem legitimately EMITS that media type (controller.rb / server.rb) — matching it would false-fail the current, invariant-holding tree.

[
  # a `Deserializer` constant reference (FlightDeserializer, Flight::Deserializer, …);
  # matched as a substring so both the joined and namespaced forms trip it
  /#{%w[Deser ializer].join}/,
  # methods that turn inbound Flight into Ruby objects
  /\b#{%w[deserialize flight].join('_')}\b/,
  /\b#{%w[from flight].join('_')}\b/,
  /\b#{%w[parse flight].join('_')}\b/,
  /\b#{%w[decode flight].join('_')}\b/,
  # React Flight reader entry points invoked from Ruby (NOT createFromFlightPayload,
  # which is the client/browser deserializing the server's own trusted payload)
  /\b#{%w[create From].join}(?:NodeStream|ReadableStream|Fetch)\b/
].freeze
DESERIALIZE_SIGNAL_RE =
Regexp.union(DESERIALIZE_SIGNALS)
ALLOW_FLIGHT_DESERIALIZATION =

A line carrying this annotation is a deliberate, reviewed deserializer and is treated as guarded (the check is a guard, not a blanket ban).

["# ruact:allow", "flight", "deserialization"].join("-")
DOCTOR_FILE =

Exclude THIS file by its exact path (not basename) — its comments contain the literal Deserializer example, so it must not match its own scan; but a differently-located future file also named doctor.rb must still be scanned (review finding R1 — basename exclusion was too broad).

File.expand_path(__FILE__)
SERIALIZE_ONLY_DOC =
"docs/internal/decisions/server-functions-api.md (serialize-only invariant, FR97)"
RESPONSE_TRANSFORMING_MIDDLEWARE =

Response-transforming middleware that can mutate/recompress a streamed text/x-component Flight body and break the wire contract (React-on-Rails ops lesson). Matched by class name so the check needs no hard dependency.

%w[Rack::Deflater].freeze
SUCCESS_STATUSES =

Statuses that do NOT fail the run. Anything else (including a malformed / future status) is treated as a failure (review finding R1).

%i[pass warn].freeze

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(serialize_only_root: File.join(Ruact.gem_path, "lib")) ⇒ Doctor

Returns a new instance of Doctor.

Parameters:

  • serialize_only_root (String) (defaults to: File.join(Ruact.gem_path, "lib"))

    directory whose **/*.rb is scanned for the serialize-only tripwire. Defaults to the gem's own lib/; injectable so specs can point it at a fixture tree.



70
71
72
# File 'lib/ruact/doctor.rb', line 70

def initialize(serialize_only_root: File.join(Ruact.gem_path, "lib"))
  @serialize_only_root = serialize_only_root
end

Class Method Details

.runObject

Runs all checks, prints results, returns true if none FAIL.



75
76
77
# File 'lib/ruact/doctor.rb', line 75

def self.run
  new.run
end

Instance Method Details

#runObject



79
80
81
82
83
84
85
86
87
88
89
# File 'lib/ruact/doctor.rb', line 79

def run
  puts "[ruact] Health check"
  results = CHECKS.map { |check| send(:"check_#{check}") }
  results.each { |status, message| puts format_result(status, message) }
  # A :warn must NOT fail the run (Story 13.1 AC3); only :pass / :warn are
  # success. An unexpected status (rendered `✗`) fails loudly rather than
  # being silently treated as a pass (review finding R1).
  passed = results.all? { |status, _| SUCCESS_STATUSES.include?(status) }
  puts "Run rails generate ruact:install to fix configuration issues" unless passed
  passed
end