Skip to content

Pathname.new converts with File.path, not to_s, so a non-String is refused (fixes #93) - #156

Open
jiro-by-nori[bot] wants to merge 1 commit into
mainfrom
pathname-new-uses-file-path-conversion
Open

jiro-by-nori[bot] wants to merge 1 commit into
mainfrom
pathname-new-uses-file-path-conversion

Conversation

@jiro-by-nori

@jiro-by-nori jiro-by-nori Bot commented Nov 15, 2026 •

Copy link
Copy Markdown
Contributor

Authored by Jiro, an autonomous coding agent, on behalf of @sam-xif — the code and this description are machine-written.

Fixes #93.

What changed

Pathname#initialize in the prelude assigned path.to_s. CRuby's constructor is @path = File.path(path).dup, so:

  • a String (subclasses included) is the path, unchanged;
  • anything else converts through #to_path, then #to_str — never #to_s;
  • whether a conversion exists is rb_check_funcall's question, not respond_to?'s, so the constructor asks the prelude's __conv_callable? (the same predicate String.try_convert uses): a #to_path that only method_missing serves is called, a custom respond_to_missing? that denies the name is believed, and a #to_path that answers nil does not fall back to #to_str;
  • if the result is not a String: TypeError: Pathname.new requires a String, #to_path or #to_str;
  • a path with a null byte, given directly or produced by #to_path: ArgumentError: path name contains null byte;
  • the path is duped, so mutating the argument afterwards leaves the Pathname alone.

Three wrong answers follow from the to_s coercion, all fixed here: Pathname.new(123)/nil/:sym/[1]/Object.new answered a Pathname where CRuby raises TypeError (a type-error-family exception the validator claims to exclude, so a wrong answer rather than a gate); an object with both #to_path and a different #to_s got the wrong path; and a mutated argument changed the path.

ruby-lean/RubyCore/Generated/Prelude.lean and PreludeJson.lean are the make gen output of that prelude edit.

Test plan

New regression case difftest/corpus/regressions/pathname-new-requires-a-path.rb (plus a fixed sidecar) covering, against CRuby 4.0.5: the six rejection shapes (Integer, nil, true, Symbol, Array, Object.new), Kernel#Pathname on a non-String, #to_path preferred over #to_str, a bad and a nil #to_path not falling back, the three method_missing/respond_to_missing? shapes, both null-byte paths, a Pathname and a String subclass as controls, and the dup.

Command Result
bin/ruby-lean --compare difftest/corpus/regressions/pathname-new-requires-a-path.rb AGREE — identical stdout, no exception
the same case against the pre-change prelude red on every coercion line (integer => #<Pathname:123> vs CRuby's TypeError, to_path wins => "/from-to-s" vs "/from-to-path", argument mutated after => "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/before/after" vs "/before"), so the case pins the defect instead of passing trivially
make conformance + scripts/check-conformance.sh 1287 ran, 1087 agree, 194 declined, 0 disagree — CONFORMANCE OK, the recorded baseline held exactly
difftest run --tier regressions --sut lean 223 agree, 1 gated (pre-existing sorbet-hash-gate), 0 failures; the new case reconciles as held
difftest replay corpus/tier3 --sut lean 57 agree, 13 declined, 0 disagreements
make books 1405 jobs, Build completed successfully (the booted heap is regenerated from the new prelude)
make metatheory OK — theorems rest on propext/Classical.choice/Quot.sound only, and the booted heap satisfies the assumptions
make gen-check all four generated sources fresh
scripts/check-not-weakened.py CHECKS NOT WEAKENED

Not run locally, with the reason: make lint and the Bundler-backed suites (desugar-unit, emitter-unit, difftest-test, soundness, book-checks). The machine this was written on has no C compiler, so bundle install cannot build the pinned json gem and make deps fails before those targets start. The change adds no linted file — ruby-lean/prelude/ and difftest/corpus/ are excluded from RuboCop — and lint passes in CI. The soundness and comparator halves are covered by CI's proofs job.

Related, deliberately not in this PR

… is refused (issue #93)

The prelude's `Pathname#initialize` assigned `path.to_s`, but CRuby's is
`@path = File.path(path).dup`: a String is a path already, anything else
converts through `#to_path` and then `#to_str`, a result that is still not a
String raises `TypeError: Pathname.new requires a String, #to_path or
#to_str`, and a path holding a null byte raises `ArgumentError: path name
contains null byte`. Three wrong answers followed from the `to_s` coercion:
`Pathname.new(123)` answered `#<Pathname:123>` where CRuby raises, an object
with both `#to_path` and a different `#to_s` got the wrong path, and mutating
the argument afterwards changed the Pathname CRuby would have copied.

The constructor is now that conversion, with the `dup` and the null-byte check
in CRuby's order. Which conversions exist is `rb_check_funcall`'s question, not
`respond_to?`'s, so it goes through the prelude's `__conv_callable?`: a
`#to_path` that only `method_missing` serves is called, a custom
`respond_to_missing?` that denies the name is believed, and a `#to_path` that
answers nil does not fall back to `#to_str`. `difftest/corpus/regressions/
pathname-new-requires-a-path.rb` pins all of it; every line of it disagrees
with CRuby against the pre-change prelude.

Validated: `make conformance` holds the baseline exactly (1287 ran, 1087 agree,
194 declined, 0 disagree), the regressions and tier3 tiers agree with the new
case present, `make books` and `make metatheory` build with the booted heap
regenerated, and `make gen-check` reports the generated sources fresh.

Co-authored-by: Jiro <jiro@nori.ai>
@jiro-by-nori
jiro-by-nori Bot force-pushed the pathname-new-uses-file-path-conversion branch from ec38cdc to 9112e2d Compare November 15, 2026 03:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Pathname.new coerces any argument with to_s instead of using to_path/to_str, so it answers where CRuby raises TypeError

1 participant