From f4813a34c274a764b99d6e706dd016514b66cf45 Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Thu, 27 Aug 2026 18:49:08 +0200 Subject: [PATCH 01/15] compile.c: statically resolve `swap` instructions when possible If the two preceeding instructions are known not to be dependent on the state of the stack, then we can eliminate the `swap` and inverse the two previous instructions. This mostly eliminate instruction in two cases: ```ruby Object.new ``` ``` 0000 opt_getconstant_path ( 1)[Li] 0002 putnil 0003 swap 0004 opt_new , 11 ``` And: ```ruby {**@a, b: 2} ``` ``` 0000 getinstancevariable :@a, ( 1)[Li] 0003 putspecialobject 1 0005 swap 0006 putobject :b 0008 putobject 2 0010 opt_send_without_block ``` Co-Authored-By: John Hawthorn --- compile.c | 60 +++++++++ test/prism/newline_test.rb | 1 + zjit/src/hir/opt_tests.rs | 266 ++++++++++++++++++------------------- zjit/src/hir/tests.rs | 38 +++--- 4 files changed, 213 insertions(+), 152 deletions(-) diff --git a/compile.c b/compile.c index 7fd5f8e33d2d38..a7308c66b1b50e 100644 --- a/compile.c +++ b/compile.c @@ -358,6 +358,29 @@ static void iseq_add_setlocal(rb_iseq_t *iseq, LINK_ANCHOR *const seq, const NOD #define IS_INSN_ID(iobj, insn) (INSN_OF(iobj) == BIN(insn)) #define IS_NEXT_INSN_ID(link, insn) \ ((link)->next && IS_INSN((link)->next) && IS_INSN_ID((link)->next, insn)) +#define IS_NEXT_NEXT_INSN_ID(link, insn) \ + ((link)->next && IS_NEXT_INSN_ID((link)->next, insn)) + +static inline bool +IS_INDEPENDENT_INSN(LINK_ELEMENT *link) +{ + if (!IS_INSN(link)) { + return false; + } + + enum ruby_vminsn_type type = INSN_OF(link); + + return ( + type == BIN(putobject) || + type == BIN(putspecialobject) || + type == BIN(putnil) || + type == BIN(putself) || + type == BIN(duphash) || + type == BIN(getinstancevariable) || + type == BIN(getlocal) || + type == BIN(opt_getconstant_path) + ); +} /* error */ #if CPDEBUG > 0 @@ -1225,6 +1248,25 @@ ELEM_REMOVE(LINK_ELEMENT *elem) } } +/* + * elem1, elem2 => elem2, elem1 + */ +static void +ELEM_SWAP(LINK_ELEMENT *first, LINK_ELEMENT *second) +{ + RUBY_ASSERT(first->next == second); + RUBY_ASSERT(first == second->prev); + + first->prev->next = second; + second->next->prev = first; + + first->next = second->next; + second->next = first; + + second->prev = first->prev; + first->prev = second; +} + static LINK_ELEMENT * FIRST_ELEMENT(const LINK_ANCHOR *const anchor) { @@ -4253,6 +4295,24 @@ iseq_peephole_optimize(rb_iseq_t *iseq, LINK_ELEMENT *list, const int do_tailcal } } + /* + * putself / (or any other independent instruction) + * putnil / (or any other independent instruction) + * swap + * => + * putnil / (or any other independent instruction) + * putself / (or any other independent instruction) + */ + if (IS_NEXT_NEXT_INSN_ID(&iobj->link, swap)) { + LINK_ELEMENT *first = &iobj->link; + LINK_ELEMENT *second = first->next; + LINK_ELEMENT *swap = second->next; + if (IS_INDEPENDENT_INSN(first) && IS_INDEPENDENT_INSN(second)) { + ELEM_REMOVE(swap); + ELEM_SWAP(first, second); + } + } + return COMPILE_OK; } diff --git a/test/prism/newline_test.rb b/test/prism/newline_test.rb index 0accd8af6d81d2..ed797db965619f 100644 --- a/test/prism/newline_test.rb +++ b/test/prism/newline_test.rb @@ -24,6 +24,7 @@ class NewlineTest < TestCase ruby/parser_test.rb ruby/ripper_test.rb ruby/ruby_parser_test.rb + ruby/parameters_signature_test.rb ] base = __dir__ diff --git a/zjit/src/hir/opt_tests.rs b/zjit/src/hir/opt_tests.rs index 53b77e05601e31..842d0770ef855c 100644 --- a/zjit/src/hir/opt_tests.rs +++ b/zjit/src/hir/opt_tests.rs @@ -6125,15 +6125,15 @@ mod hir_opt_tests { v4:BasicObject = LoadArg :self@0 Jump bb3(v4) bb3(v6:BasicObject): + v10:NilClass = Const Value(nil) PatchPoint StableConstantNames(0x1000, C) - v11:ClassSubclass[C@0x1008] = Const Value(VALUE(0x1008)) - v13:NilClass = Const Value(nil) + v13:ClassSubclass[C@0x1008] = Const Value(VALUE(0x1008)) PatchPoint MethodRedefined(C@0x1008, new@0x1009, cme:0x1010) - v43:ObjectSubclass[class_exact:C] = ObjectAllocClass C:VALUE(0x1008) + v42:ObjectSubclass[class_exact:C] = ObjectAllocClass C:VALUE(0x1008) PatchPoint NoSingletonClass(C@0x1008) PatchPoint MethodRedefined(C@0x1008, initialize@0x1038, cme:0x1040) CheckInterrupts - Return v43 + Return v42 "); } @@ -6159,25 +6159,25 @@ mod hir_opt_tests { v4:BasicObject = LoadArg :self@0 Jump bb3(v4) bb3(v6:BasicObject): + v10:NilClass = Const Value(nil) PatchPoint StableConstantNames(0x1000, C) - v11:ClassSubclass[C@0x1008] = Const Value(VALUE(0x1008)) - v13:NilClass = Const Value(nil) - v16:Fixnum[1] = Const Value(1) + v13:ClassSubclass[C@0x1008] = Const Value(VALUE(0x1008)) + v15:Fixnum[1] = Const Value(1) PatchPoint MethodRedefined(C@0x1008, new@0x1009, cme:0x1010) - v46:ObjectSubclass[class_exact:C] = ObjectAllocClass C:VALUE(0x1008) + v45:ObjectSubclass[class_exact:C] = ObjectAllocClass C:VALUE(0x1008) PatchPoint NoSingletonClass(C@0x1008) PatchPoint MethodRedefined(C@0x1008, initialize@0x1038, cme:0x1040) - PushInlineFrame :initialize, v46 (0x1068), num_args=1 + PushInlineFrame :initialize, v45 (0x1068), num_args=1 PatchPoint SingleRactorMode - v64:CShape = LoadField v46, :shape_id@0x1090 - v65:CShape[0x1091] = GuardBitEquals v64, CShape(0x1091) recompile - StoreField v46, :@x@0x1092, v16 - WriteBarrier v46, v16 - v68:CShape[0x1093] = Const CShape(0x1093) - StoreField v46, :shape_id@0x1090, v68 + v63:CShape = LoadField v45, :shape_id@0x1090 + v64:CShape[0x1091] = GuardBitEquals v63, CShape(0x1091) recompile + StoreField v45, :@x@0x1092, v15 + WriteBarrier v45, v15 + v67:CShape[0x1093] = Const CShape(0x1093) + StoreField v45, :shape_id@0x1090, v67 CheckInterrupts PopInlineFrame - Return v46 + Return v45 "); } @@ -6198,15 +6198,15 @@ mod hir_opt_tests { v4:BasicObject = LoadArg :self@0 Jump bb3(v4) bb3(v6:BasicObject): + v10:NilClass = Const Value(nil) PatchPoint StableConstantNames(0x1000, Object) - v11:ClassSubclass[Object@0x1008] = Const Value(VALUE(0x1008)) - v13:NilClass = Const Value(nil) + v13:ClassSubclass[Object@0x1008] = Const Value(VALUE(0x1008)) PatchPoint MethodRedefined(Object@0x1008, new@0x1009, cme:0x1010) - v43:ObjectExact = ObjectAllocClass Object:VALUE(0x1008) + v42:ObjectExact = ObjectAllocClass Object:VALUE(0x1008) PatchPoint NoSingletonClass(Object@0x1008) PatchPoint MethodRedefined(Object@0x1008, initialize@0x1038, cme:0x1040) CheckInterrupts - Return v43 + Return v42 "); } @@ -6228,15 +6228,15 @@ mod hir_opt_tests { v4:BasicObject = LoadArg :self@0 Jump bb3(v4) bb3(v6:BasicObject): + v10:NilClass = Const Value(nil) PatchPoint StableConstantNames(0x1000, Object) - v11:ClassSubclass[Object@0x1008] = Const Value(VALUE(0x1008)) - v13:NilClass = Const Value(nil) + v13:ClassSubclass[Object@0x1008] = Const Value(VALUE(0x1008)) PatchPoint MethodRedefined(Object@0x1008, new@0x1009, cme:0x1010) - v43:ObjectExact = ObjectAllocClass Object:VALUE(0x1008) + v42:ObjectExact = ObjectAllocClass Object:VALUE(0x1008) PatchPoint NoSingletonClass(Object@0x1008) PatchPoint MethodRedefined(Object@0x1008, initialize@0x1038, cme:0x1040) CheckInterrupts - Return v43 + Return v42 "); } @@ -6262,28 +6262,28 @@ mod hir_opt_tests { v4:BasicObject = LoadArg :self@0 Jump bb3(v4) bb3(v6:BasicObject): - v10:BasicObject = GetConstantPath 0x1000 - v12:NilClass = Const Value(nil) - v15:CBool = IsMethodCFunc v10, :new - CondBranch v15, bb6(), bb4() + v10:NilClass = Const Value(nil) + v12:BasicObject = GetConstantPath 0x1000 + v14:CBool = IsMethodCFunc v12, :new + CondBranch v14, bb6(), bb4() bb6(): - v17:HeapBasicObject = ObjectAlloc v10 + v16:HeapBasicObject = ObjectAlloc v12 SideExit NoProfileSend recompile bb4(): PatchPoint NoSingletonClass(Factory@0x1010) PatchPoint MethodRedefined(Factory@0x1010, new@0x1018, cme:0x1020) - v42:ObjectSubclass[class_exact:Factory] = GuardType v10, ObjectSubclass[class_exact:Factory] recompile - PushInlineFrame :new, v42 (0x1048), num_args=0 + v41:ObjectSubclass[class_exact:Factory] = GuardType v12, ObjectSubclass[class_exact:Factory] recompile + PushInlineFrame :new, v41 (0x1048), num_args=0 + v48:NilClass = Const Value(nil) PatchPoint StableConstantNames(0x1070, Object) - v50:ClassSubclass[Object@0x1078] = Const Value(VALUE(0x1078)) - v52:NilClass = Const Value(nil) + v51:ClassSubclass[Object@0x1078] = Const Value(VALUE(0x1078)) PatchPoint MethodRedefined(Object@0x1078, new@0x1018, cme:0x1080) - v86:ObjectExact = ObjectAllocClass Object:VALUE(0x1078) + v84:ObjectExact = ObjectAllocClass Object:VALUE(0x1078) PatchPoint NoSingletonClass(Object@0x1078) PatchPoint MethodRedefined(Object@0x1078, initialize@0x10a8, cme:0x10b0) CheckInterrupts PopInlineFrame - Return v86 + Return v84 "); } @@ -6304,15 +6304,15 @@ mod hir_opt_tests { v4:BasicObject = LoadArg :self@0 Jump bb3(v4) bb3(v6:BasicObject): + v10:NilClass = Const Value(nil) PatchPoint StableConstantNames(0x1000, BasicObject) - v11:ClassSubclass[BasicObject@0x1008] = Const Value(VALUE(0x1008)) - v13:NilClass = Const Value(nil) + v13:ClassSubclass[BasicObject@0x1008] = Const Value(VALUE(0x1008)) PatchPoint MethodRedefined(BasicObject@0x1008, new@0x1009, cme:0x1010) - v43:BasicObjectExact = ObjectAllocClass BasicObject:VALUE(0x1008) + v42:BasicObjectExact = ObjectAllocClass BasicObject:VALUE(0x1008) PatchPoint NoSingletonClass(BasicObject@0x1008) PatchPoint MethodRedefined(BasicObject@0x1008, initialize@0x1038, cme:0x1040) CheckInterrupts - Return v43 + Return v42 "); } @@ -6333,33 +6333,33 @@ mod hir_opt_tests { v4:BasicObject = LoadArg :self@0 Jump bb3(v4) bb3(v6:BasicObject): + v10:NilClass = Const Value(nil) PatchPoint StableConstantNames(0x1000, Hash) - v11:ClassSubclass[Hash@0x1008] = Const Value(VALUE(0x1008)) - v13:NilClass = Const Value(nil) + v13:ClassSubclass[Hash@0x1008] = Const Value(VALUE(0x1008)) PatchPoint MethodRedefined(Hash@0x1008, new@0x1009, cme:0x1010) - v43:HashExact = ObjectAllocClass Hash:VALUE(0x1008) + v42:HashExact = ObjectAllocClass Hash:VALUE(0x1008) PatchPoint NoSingletonClass(Hash@0x1008) PatchPoint MethodRedefined(Hash@0x1008, initialize@0x1038, cme:0x1040) - v47:Fixnum[0] = Const Value(0) - v96:Fixnum[0] = Const Value(0) - v97:NilClass = Const Value(nil) - PushInlineFrame :initialize, v43 (0x1068), num_args=1 - v63:TrueClass = Const Value(true) - v81:CPtr = GetEP 0 - v82:CUInt64 = LoadField v81, :VM_ENV_DATA_INDEX_FLAGS@0x1090 - v83:CBool = IsBlockParamModified v82 - CondBranch v83, bb11(), bb12() + v46:Fixnum[0] = Const Value(0) + v95:Fixnum[0] = Const Value(0) + v96:NilClass = Const Value(nil) + PushInlineFrame :initialize, v42 (0x1068), num_args=1 + v62:TrueClass = Const Value(true) + v80:CPtr = GetEP 0 + v81:CUInt64 = LoadField v80, :VM_ENV_DATA_INDEX_FLAGS@0x1090 + v82:CBool = IsBlockParamModified v81 + CondBranch v82, bb11(), bb12() bb11(): - v85:BasicObject = LoadField v81, :block@0x1091 - Jump bb13(v85) + v84:BasicObject = LoadField v80, :block@0x1091 + Jump bb13(v84) bb12(): - v87:BasicObject = GetBlockParam :block, l0, EP@4 - Jump bb13(v87) - bb13(v80:BasicObject): - v90:BasicObject = InvokeBuiltin rb_hash_init, v43, v47, v63, v63, v80 + v86:BasicObject = GetBlockParam :block, l0, EP@4 + Jump bb13(v86) + bb13(v79:BasicObject): + v89:BasicObject = InvokeBuiltin rb_hash_init, v42, v46, v62, v62, v79 CheckInterrupts PopInlineFrame - Return v43 + Return v42 "); assert_snapshot!(inspect("test"), @"{}"); } @@ -6381,15 +6381,15 @@ mod hir_opt_tests { v4:BasicObject = LoadArg :self@0 Jump bb3(v4) bb3(v6:BasicObject): + v10:NilClass = Const Value(nil) PatchPoint StableConstantNames(0x1000, Array) - v11:ClassSubclass[Array@0x1008] = Const Value(VALUE(0x1008)) - v13:NilClass = Const Value(nil) - v16:Fixnum[1] = Const Value(1) + v13:ClassSubclass[Array@0x1008] = Const Value(VALUE(0x1008)) + v15:Fixnum[1] = Const Value(1) PatchPoint MethodRedefined(Array@0x1008, new@0x1009, cme:0x1010) PatchPoint MethodRedefined(Class@0x1038, new@0x1009, cme:0x1010) - v54:BasicObject = CCallVariadic v11, :Array.new@0x1040, v16 + v53:BasicObject = CCallVariadic v13, :Array.new@0x1040, v15 CheckInterrupts - Return v54 + Return v53 "); } @@ -6410,17 +6410,17 @@ mod hir_opt_tests { v4:BasicObject = LoadArg :self@0 Jump bb3(v4) bb3(v6:BasicObject): + v10:NilClass = Const Value(nil) PatchPoint StableConstantNames(0x1000, Set) - v11:ClassSubclass[Set@0x1008] = Const Value(VALUE(0x1008)) - v13:NilClass = Const Value(nil) + v13:ClassSubclass[Set@0x1008] = Const Value(VALUE(0x1008)) PatchPoint MethodRedefined(Set@0x1008, new@0x1009, cme:0x1010) - v18:HeapBasicObject = ObjectAlloc v11 + v17:HeapBasicObject = ObjectAlloc v13 PatchPoint NoSingletonClass(Set@0x1008) PatchPoint MethodRedefined(Set@0x1008, initialize@0x1038, cme:0x1040) - v46:SetExact = GuardType v18, SetExact recompile - v47:BasicObject = CCallVariadic v46, :Set#initialize@0x1068 + v45:SetExact = GuardType v17, SetExact recompile + v46:BasicObject = CCallVariadic v45, :Set#initialize@0x1068 CheckInterrupts - Return v46 + Return v45 "); } @@ -6441,14 +6441,14 @@ mod hir_opt_tests { v4:BasicObject = LoadArg :self@0 Jump bb3(v4) bb3(v6:BasicObject): + v10:NilClass = Const Value(nil) PatchPoint StableConstantNames(0x1000, String) - v11:ClassSubclass[String@0x1008] = Const Value(VALUE(0x1008)) - v13:NilClass = Const Value(nil) + v13:ClassSubclass[String@0x1008] = Const Value(VALUE(0x1008)) PatchPoint MethodRedefined(String@0x1008, new@0x1009, cme:0x1010) PatchPoint MethodRedefined(Class@0x1038, new@0x1009, cme:0x1010) - v51:BasicObject = CCallVariadic v11, :String.new@0x1040 + v50:BasicObject = CCallVariadic v13, :String.new@0x1040 CheckInterrupts - Return v51 + Return v50 "); } @@ -6469,18 +6469,18 @@ mod hir_opt_tests { v4:BasicObject = LoadArg :self@0 Jump bb3(v4) bb3(v6:BasicObject): + v10:NilClass = Const Value(nil) PatchPoint StableConstantNames(0x1000, Regexp) - v11:ClassSubclass[Regexp@0x1008] = Const Value(VALUE(0x1008)) - v13:NilClass = Const Value(nil) - v16:StringExact[VALUE(0x1010)] = Const Value(VALUE(0x1010)) - v17:StringExact = StringCopy v16 + v13:ClassSubclass[Regexp@0x1008] = Const Value(VALUE(0x1008)) + v15:StringExact[VALUE(0x1010)] = Const Value(VALUE(0x1010)) + v16:StringExact = StringCopy v15 PatchPoint MethodRedefined(Regexp@0x1008, new@0x1018, cme:0x1020) - v47:RegexpExact = ObjectAllocClass Regexp:VALUE(0x1008) + v46:RegexpExact = ObjectAllocClass Regexp:VALUE(0x1008) PatchPoint NoSingletonClass(Regexp@0x1008) PatchPoint MethodRedefined(Regexp@0x1008, initialize@0x1048, cme:0x1050) - v52:BasicObject = CCallVariadic v47, :Regexp#initialize@0x1078, v17 + v51:BasicObject = CCallVariadic v46, :Regexp#initialize@0x1078, v16 CheckInterrupts - Return v47 + Return v46 "); } @@ -21679,91 +21679,91 @@ mod hir_opt_tests { v4:BasicObject = LoadArg :self@0 Jump bb3(v4) bb3(v6:BasicObject): + v10:NilClass = Const Value(nil) PatchPoint StableConstantNames(0x1000, Point) - v11:ClassSubclass[Point@0x1008] = Const Value(VALUE(0x1008)) - v13:NilClass = Const Value(nil) - v16:Fixnum[1] = Const Value(1) - v18:Fixnum[2] = Const Value(2) + v13:ClassSubclass[Point@0x1008] = Const Value(VALUE(0x1008)) + v15:Fixnum[1] = Const Value(1) + v17:Fixnum[2] = Const Value(2) PatchPoint MethodRedefined(Point@0x1008, new@0x1009, cme:0x1010) - v87:ObjectSubclass[class_exact:Point] = ObjectAllocClass Point:VALUE(0x1008) + v85:ObjectSubclass[class_exact:Point] = ObjectAllocClass Point:VALUE(0x1008) PatchPoint NoSingletonClass(Point@0x1008) PatchPoint MethodRedefined(Point@0x1008, initialize@0x1038, cme:0x1040) - PushInlineFrame :initialize, v87 (0x1068), num_args=2 + PushInlineFrame :initialize, v85 (0x1068), num_args=2 PatchPoint SingleRactorMode - v119:CShape = LoadField v87, :shape_id@0x1090 - v120:CShape[0x1091] = GuardBitEquals v119, CShape(0x1091) recompile - StoreField v87, :@x@0x1092, v16 - WriteBarrier v87, v16 - v123:CShape[0x1093] = Const CShape(0x1093) - StoreField v87, :shape_id@0x1090, v123 + v117:CShape = LoadField v85, :shape_id@0x1090 + v118:CShape[0x1091] = GuardBitEquals v117, CShape(0x1091) recompile + StoreField v85, :@x@0x1092, v15 + WriteBarrier v85, v15 + v121:CShape[0x1093] = Const CShape(0x1093) + StoreField v85, :shape_id@0x1090, v121 PatchPoint NoEPEscape(initialize) PatchPoint SingleRactorMode - StoreField v87, :@y@0x1094, v18 - WriteBarrier v87, v18 - v138:CShape[0x1095] = Const CShape(0x1095) - StoreField v87, :shape_id@0x1090, v138 + StoreField v85, :@y@0x1094, v17 + WriteBarrier v85, v17 + v136:CShape[0x1095] = Const CShape(0x1095) + StoreField v85, :shape_id@0x1090, v136 CheckInterrupts PopInlineFrame + v42:NilClass = Const Value(nil) PatchPoint StableConstantNames(0x1098, Point) - v44:ClassSubclass[Point@0x1008] = Const Value(VALUE(0x1008)) - v46:NilClass = Const Value(nil) - v49:Fixnum[1] = Const Value(1) - v51:Fixnum[2] = Const Value(2) + v45:ClassSubclass[Point@0x1008] = Const Value(VALUE(0x1008)) + v47:Fixnum[1] = Const Value(1) + v49:Fixnum[2] = Const Value(2) PatchPoint MethodRedefined(Point@0x1008, new@0x1009, cme:0x1010) - v97:ObjectSubclass[class_exact:Point] = ObjectAllocClass Point:VALUE(0x1008) + v95:ObjectSubclass[class_exact:Point] = ObjectAllocClass Point:VALUE(0x1008) PatchPoint NoSingletonClass(Point@0x1008) PatchPoint MethodRedefined(Point@0x1008, initialize@0x1038, cme:0x1040) - PushInlineFrame :initialize, v97 (0x1068), num_args=2 + PushInlineFrame :initialize, v95 (0x1068), num_args=2 PatchPoint SingleRactorMode - v159:CShape = LoadField v97, :shape_id@0x1090 - v160:CShape[0x1091] = GuardBitEquals v159, CShape(0x1091) recompile - StoreField v97, :@x@0x1092, v49 - WriteBarrier v97, v49 - v163:CShape[0x1093] = Const CShape(0x1093) - StoreField v97, :shape_id@0x1090, v163 + v157:CShape = LoadField v95, :shape_id@0x1090 + v158:CShape[0x1091] = GuardBitEquals v157, CShape(0x1091) recompile + StoreField v95, :@x@0x1092, v47 + WriteBarrier v95, v47 + v161:CShape[0x1093] = Const CShape(0x1093) + StoreField v95, :shape_id@0x1090, v161 PatchPoint NoEPEscape(initialize) PatchPoint SingleRactorMode - StoreField v97, :@y@0x1094, v51 - WriteBarrier v97, v51 - v178:CShape[0x1095] = Const CShape(0x1095) - StoreField v97, :shape_id@0x1090, v178 + StoreField v95, :@y@0x1094, v49 + WriteBarrier v95, v49 + v176:CShape[0x1095] = Const CShape(0x1095) + StoreField v95, :shape_id@0x1090, v176 CheckInterrupts PopInlineFrame PatchPoint NoSingletonClass(Point@0x1008) PatchPoint MethodRedefined(Point@0x1008, ==@0x10a0, cme:0x10a8) - PushInlineFrame :==, v87 (0x10d0), num_args=1 + PushInlineFrame :==, v85 (0x10d0), num_args=1 PatchPoint SingleRactorMode - v197:CShape = LoadField v87, :shape_id@0x1090 - v198:CShape[0x1095] = GuardBitEquals v197, CShape(0x1095) recompile - v199:BasicObject = LoadField v87, :@x@0x1092 + v195:CShape = LoadField v85, :shape_id@0x1090 + v196:CShape[0x1095] = GuardBitEquals v195, CShape(0x1095) recompile + v197:BasicObject = LoadField v85, :@x@0x1092 PatchPoint NoEPEscape(==) PatchPoint MethodRedefined(Point@0x1008, x@0x10f8, cme:0x1100) PatchPoint MethodRedefined(Integer@0x1128, ==@0x10a0, cme:0x1130) - v255:Fixnum = GuardType v199, Fixnum recompile - v257:BoolExact = FixnumEq v255, v49 - v210:CBool = Test v257 - v211:FalseClass = RefineType v257, Falsy - CondBranch v210, bb19(), bb18(v211) + v253:Fixnum = GuardType v197, Fixnum recompile + v255:BoolExact = FixnumEq v253, v47 + v208:CBool = Test v255 + v209:FalseClass = RefineType v255, Falsy + CondBranch v208, bb19(), bb18(v209) bb19(): PatchPoint SingleRactorMode - v218:CShape = LoadField v87, :shape_id@0x1090 - v219:CShape[0x1095] = GuardBitEquals v218, CShape(0x1095) recompile - v220:BasicObject = LoadField v87, :@y@0x1094 + v216:CShape = LoadField v85, :shape_id@0x1090 + v217:CShape[0x1095] = GuardBitEquals v216, CShape(0x1095) recompile + v218:BasicObject = LoadField v85, :@y@0x1094 PatchPoint NoEPEscape(==) PatchPoint NoSingletonClass(Point@0x1008) PatchPoint MethodRedefined(Point@0x1008, y@0x1158, cme:0x1160) - v262:CShape = LoadField v97, :shape_id@0x1090 - v263:CShape[0x1095] = GuardBitEquals v262, CShape(0x1095) recompile - v264:BasicObject = LoadField v97, :@y@0x1094 + v260:CShape = LoadField v95, :shape_id@0x1090 + v261:CShape[0x1095] = GuardBitEquals v260, CShape(0x1095) recompile + v262:BasicObject = LoadField v95, :@y@0x1094 PatchPoint MethodRedefined(Integer@0x1128, ==@0x10a0, cme:0x1130) - v267:Fixnum = GuardType v220, Fixnum recompile - v268:Fixnum = GuardType v264, Fixnum - v269:BoolExact = FixnumEq v267, v268 - Jump bb18(v269) - bb18(v232:BoolExact): + v265:Fixnum = GuardType v218, Fixnum recompile + v266:Fixnum = GuardType v262, Fixnum + v267:BoolExact = FixnumEq v265, v266 + Jump bb18(v267) + bb18(v230:BoolExact): CheckInterrupts PopInlineFrame - Return v232 + Return v230 "); } diff --git a/zjit/src/hir/tests.rs b/zjit/src/hir/tests.rs index 4591ac70fbfd97..6c80b892d08d4f 100644 --- a/zjit/src/hir/tests.rs +++ b/zjit/src/hir/tests.rs @@ -2754,13 +2754,13 @@ pub(crate) mod hir_build_tests { v7:BasicObject = LoadArg :a@1 Jump bb3(v6, v7) bb3(v9:BasicObject, v10:BasicObject): - v16:ClassSubclass[VMFrozenCore] = Const Value(VALUE(0x1008)) - v19:StaticSymbol[:b] = Const Value(VALUE(0x1010)) - v21:Fixnum[1] = Const Value(1) - v23:BasicObject = Send v16, :core#hash_merge_ptr, v10, v19, v21 # SendFallbackReason: Uncategorized(opt_send_without_block) - v25:BasicObject = Send v9, :foo, v23 # SendFallbackReason: Uncategorized(opt_send_without_block) + v15:ClassSubclass[VMFrozenCore] = Const Value(VALUE(0x1008)) + v18:StaticSymbol[:b] = Const Value(VALUE(0x1010)) + v20:Fixnum[1] = Const Value(1) + v22:BasicObject = Send v15, :core#hash_merge_ptr, v10, v18, v20 # SendFallbackReason: Uncategorized(opt_send_without_block) + v24:BasicObject = Send v9, :foo, v22 # SendFallbackReason: Uncategorized(opt_send_without_block) CheckInterrupts - Return v25 + Return v24 "); } @@ -2883,20 +2883,20 @@ pub(crate) mod hir_build_tests { v4:BasicObject = LoadArg :self@0 Jump bb3(v4) bb3(v6:BasicObject): - v10:BasicObject = GetConstantPath 0x1000 - v12:NilClass = Const Value(nil) - v15:CBool = IsMethodCFunc v10, :new - CondBranch v15, bb6(), bb4(v6, v12, v10) + v10:NilClass = Const Value(nil) + v12:BasicObject = GetConstantPath 0x1000 + v14:CBool = IsMethodCFunc v12, :new + CondBranch v14, bb6(), bb4(v6, v10, v12) bb6(): - v17:HeapBasicObject = ObjectAlloc v10 - v19:BasicObject = Send v17, :initialize # SendFallbackReason: Uncategorized(opt_send_without_block) - Jump bb5(v6, v17, v19) - bb4(v22:BasicObject, v23:NilClass, v24:BasicObject): - v27:BasicObject = Send v24, :new # SendFallbackReason: Uncategorized(opt_send_without_block) - Jump bb5(v22, v27, v23) - bb5(v30:BasicObject, v31:BasicObject, v32:BasicObject): - CheckInterrupts - Return v31 + v16:HeapBasicObject = ObjectAlloc v12 + v18:BasicObject = Send v16, :initialize # SendFallbackReason: Uncategorized(opt_send_without_block) + Jump bb5(v6, v16, v18) + bb4(v21:BasicObject, v22:NilClass, v23:BasicObject): + v26:BasicObject = Send v23, :new # SendFallbackReason: Uncategorized(opt_send_without_block) + Jump bb5(v21, v26, v22) + bb5(v29:BasicObject, v30:BasicObject, v31:BasicObject): + CheckInterrupts + Return v30 "); } From ae55fbbae6293341ed5b70e393a34980e96b2fb8 Mon Sep 17 00:00:00 2001 From: Burdette Lamar Date: Thu, 27 Aug 2026 18:10:00 -0500 Subject: [PATCH 02/15] [DOC] New file about filesystem modes (#18521) --- doc/file/filesystem_modes.md | 282 +++++++++++++++++++++++++++++++++++ 1 file changed, 282 insertions(+) create mode 100644 doc/file/filesystem_modes.md diff --git a/doc/file/filesystem_modes.md b/doc/file/filesystem_modes.md new file mode 100644 index 00000000000000..33b69c386119e7 --- /dev/null +++ b/doc/file/filesystem_modes.md @@ -0,0 +1,282 @@ +# Filesystem Modes + +A filesystem entry has an integer _mode_ that specifies: + +- [Permissions][permissions]. +- [Special bits][special bits]. +- [File type][file type]. + +## Getting a Mode + +You can use method File::Stat#mode to get the mode of a filesystem entry. + +Each of these methods returns a File::Stat object for a given filesystem entry. +The first three follow symbolic links; the others don't: + +- File::stat. +- IO#stat. +- Pathname#stat. +- File::lstat. +- File#lstat. +- Pathname#lstat. + +Once you have the File::Stat object, you can fetch the mode for the entry: + +```ruby +File.stat('README.md').mode.to_s(8) # => "100664" +File.stat('doc/').mode.to_s(8) # => "40775" +``` + +On this page, we use a helper method to display a mode +in a convenient form, showing the mode both as an octal integer and a string. +If you're new to this page, it may be helpful +to read about the [helper method][helper method] now. + +## Setting a Mode + +The mode for an entry is initialized when the entry is created: + +```ruby +filepath = '/tmp/t.txt' +File.write(filepath, 'foo') +mode(filepath) # => "100664 -rw-rw-r--" +dirpath = '/tmp/bar' +Dir.mkdir(dirpath) +mode(dirpath) # => "040775 drwxrwxr-x" +File.unlink(filepath) +Dir.rmdir(dirpath) +``` + +You can use one of these methods to change the [permissions][permissions] +and [special bits][special bits] (but not the [file type][file type]): + +- File::chmod. +- FileUtils::chmod. +- FileUtils::chmod_R. +- File#chmod. +- Pathname#chmod. +- FileUtils#chmod. +- FileUtils#chmod_R. + +## Permissions + +A filesystem entry has permissions: + +- Read: whether the file or directory may be read, and by what processes. +- Write: whether the file of directory may be written, and by what processes. +- Execute/search: + + - File: whether the file may be _executed_, and by what processes. + - Directory: whether the directory may be _searched_, and by what processes. + +For a method that actually creates a file in the underlying filesystem +(as opposed to merely creating a File object), permissions may be specified; +the permissions may also be changed: + +```ruby +filepath = '/tmp/t.tmp' +File.new(filepath, 'w', 0755) +mode(filepath) # => "100755 -rwxr-xr-x" +File.chmod(0644, filepath) +mode(filepath) # => "100644 -rw-r--r--" +``` + +For a method that actually creates a directory in the underlying filesystem +(as opposed to merely creating a Dir object), permissions may be specified; +the permissions may also be changed: + +```ruby +dirpath = '/tmp/dir' +Dir.mkdir(dirpath, 0755) +mode(dirpath) # => "040755 drwxr-xr-x" +File.chmod(0644, dirpath) +mode(dirpath) # => "040644 drw-r--r--" +``` + +On non-Posix operating systems, permissions may include only read-only or read-write, +in which case, the remaining permission will resemble typical values. +On Windows, for instance, the default permissions are `0644`; +The only change that can be made is to make the file +read-only, which is reported as `0444`. + +### Directory and \File Permissions + +Permissions for directories and files include read and write permissions. + +The permissions in this table do not involve execute/search, +and so apply similarly to a directory or a file. + +| Octal | \String | Permissions | +|:-----:|---------------|------------------------------------------| +| `000` | `'---------'` | No permissions. | +| `400` | `'r--------'` | Owner read-only. | +| `600` | `'rw-------'` | Owner read-write. | +| `644` | `'rw-r--r--'` | Owner read-write; group/world read-only. | +| `664` | `'rw-rw-r--'` | Owner/group read-write; world read-only. | +| `666` | `'rw-rw-rw-'` | Owner/group/world read-write. | + +### \File Permissions + +Permissions for a file include execute permissions, +in addition to the read and write permissions seen above. + +The permissions in this table, applied to a file, specify execute permissions. + +| Octal | \String | Permissions | +|:--------:|---------------|--------------------------------------------------------------| +| `700` | `'rwx------'` | Owner read-write-execute. | +| `750` | `'rwxr-x---'` | Owner read-write-execute; group read-execute. | +| `755` | `'rwxr-xr-x'` | Owner read-write-execute; group read-execute; world execute. | +| `775` | `'rwxrwxr-x'` | Owner/group read-write-execute; world read-execute. | +| `777` | `'rwxrwxrwx'` | Owner/group/world read-write-execute. | + +### Directory Permissions + +Permissions for a directory include search permissions, +in addition to the read and write permissions seen above. + +The permissions in this table, applied to a directory, specify search permissions. + +| Octal | \String | Permissions | +|:------:|---------------|-----------------------------------------------------------| +| `700` | `'rwx------'` | Owner read-write-search. | +| `750` | `'rwxr-x---'` | Owner read-write-search; group read-search. | +| `755` | `'rwxr-xr-x'` | Owner read-write-search; group read-search; world search. | +| `775` | `'rwxrwxr-x'` | Owner/group read-write-search; world read-search. | +| `777` | `'rwxrwxrwx'` | Owner/group/world read-write-search. | + +## Special Bits + +The fourth octal digit in a mode represents its special bits: + +- Its low-order bit (`1000`) shows whether the [sticky bit][sticky bit] is set. +- The next bit (`2000`) shows whether the [setuid bit][setuid bit] is set. +- The next bit (`4000`) shows whether the [setgid bit][setgid bit] is set. + +| Octal | Meaning | +|:-------:|---------------------------| +| `0000` | None. | +| `1000` | Sticky. | +| `2000` | Setgid. | +| `3000` | Setgid + sticky. | +| `4000` | Setuid. | +| `5000` | Setuid + sticky. | +| `6000` | Setuid + setgid. | +| `7000` | Setuid + setgid + sticky. | + +Examples: + +```ruby +File.write(filepath, '') +File.chmod(00644, filepath) +mode(filepath) # => "100644 -rw-r--r--" # No special bits set. +File.chmod(01644, filepath) +mode(filepath) # => "101644 -rw-r--r-T" # 'T' shows that sticky bit is set. +File.chmod(02644, filepath) +mode(filepath) # => "102644 -rw-r-Sr--" # 'S' shows that setuid bit is set. +File.chmod(04644, filepath) +mode(filepath) # => "104644 -rwSr--r--" # 'S' shows that setgid bit is set. +File.chmod(07644, filepath) +mode(filepath) # => "107644 -rwSr-Sr-T" # All set. +``` + +In each case, if the execute bit is also set, +lowercase letters `'t'` and `'s'` are displayed instead of uppercase `'T'` and `'S'`: + +```ruby +File.chmod(00755, filepath) +mode(filepath) # => "100755 -rwxr-xr-x" +File.chmod(01755, filepath) +mode(filepath) # => "101755 -rwxr-xr-t" +File.chmod(02755, filepath) +mode(filepath) # => "102755 -rwxr-sr-x" +File.chmod(04755, filepath) +mode(filepath) # => "104755 -rwsr-xr-x" +File.chmod(07755, filepath) +mode(filepath) # => "107755 -rwsr-sr-t" +``` + +## \File Type + +The fifth and sixth octal digits in a mode represent a file type: + +| Octal | Character | \File Type | +|----------|:---------:|-------------------| +| `010000` | `'p'` | Pipe. | +| `020000` | `'c'` | Character device. | +| `040000` | `'d'` | Directory. | +| `060000` | `'b'` | Block device. | +| `100000` | `'-'` | Regular file. | +| `120000` | `'l'` | Symbolic link. | +| `140000` | `'s'` | \Socket. | + +Examples: + +```ruby +File.mkfifo('/tmp/pipe', 0666) +mode('/tmp/pipe') # => "010664 prw-rw-r--" # 01; pipe. +mode('/dev/tty') # => "020666 crw-rw-rw-" # 02; character device. +mode('doc/') # => "040775 drwxrwxr-x" # 04; directory. +mode('/dev/loop0') # => "060660 brw-rw----" # 06; block device. +mode('README.md') # => "100664 -rw-rw-r--" # 10; regular file. +File.symlink('lib', '/tmp/link') +mode('/tmp/link') # => "120777 lrwxrwxrwx" # 12; symbolic link. +require 'socket' +UNIXServer.new('/tmp/socket') +mode('/tmp/socket') # => "140775 srwxrwxr-x" # 14; socket. +File.unlink('/tmp/pipe', '/tmp/link' ,'/tmp/socket') +``` + +## Helper Method + +On this page, we use a helper method, `mode`, to show the mode information for a given path: + +```ruby +mode('README.md') # => "0100664 -rw-rw-r--" +mode('/etc') # => "0040755 drwxr-xr-x" +``` + +The [permissions][permissions] are expressed both in: + +- The trailing three digits of the octal value (e.g., `755`, `644`). + + - Left digit: owner permissions. + - Middle digit: group permissions. + - Right digit: world permissions. + +- The trailing nine characters of the string string value + (e.g., `'rwxr-xr-x'`, `'rw-r--r--'`). + + - Left three characters: owner permissions. + - Middle three characters: group permissions. + - Right three characters: world permissions. + +The [special bits][special bits] are expressed in the fourth digit. + +The [file type][file type] is expressed the fifth and sixth digits. + +For the code-curious: + +```ruby +# Return a string containing the mode (octal digits and character string) +# for the given path. +def mode(path) + # Get mode digits from File.lstat. + mode_digits = File.lstat(path).inspect.split(', ').select {|s| s.match('mode')}.first.split('=').last + # Format to size. + formatted_digits = "%06o" % mode_digits + # Get mode characters from ls command. + mode_characters = `ls -ld #{path}`.split(' ').first + # Return both. + "#{formatted_digits} #{mode_characters}" +end +``` + +[permissions]: #permissions +[special bits]: #special-bits +[file type]: #file-type +[helper method]: #helper-method + +[sticky bit]: https://en.wikipedia.org/wiki/Sticky_bit +[setuid bit]: https://en.wikipedia.org/wiki/Setuid +[setgid bit]: https://en.wikipedia.org/wiki/Setuid From 91582950b7f0df718392f2c3c7928dd306d5a86f Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Wed, 26 Aug 2026 17:48:34 +0900 Subject: [PATCH 03/15] Unify jit_payload in iseq_constant_body We cannot have YJIT and ZJIT enabled at the same time, so we can use a single jit_payload pointer in iseq_constant_body to reduce the size by 8 bytes. --- iseq.c | 24 +++++++++--------------- jit.c | 21 +++++++++++++++++++++ vm_core.h | 8 +------- yjit.c | 23 ----------------------- yjit/bindgen/src/main.rs | 2 +- yjit/src/core.rs | 10 +++++----- yjit/src/cruby_bindings.inc.rs | 4 ++-- zjit.c | 24 ------------------------ zjit/bindgen/src/main.rs | 2 +- zjit/src/cruby_bindings.inc.rs | 4 ++-- zjit/src/payload.rs | 4 ++-- 11 files changed, 44 insertions(+), 82 deletions(-) diff --git a/iseq.c b/iseq.c index a88a5745f72635..a2e71a1d451183 100644 --- a/iseq.c +++ b/iseq.c @@ -479,13 +479,7 @@ rb_iseq_mark_and_move(rb_iseq_t *iseq, bool reference_updating) * compile/invalidate). Iseqs are born shareable, so a multi-Ractor local GC * never traverses them. */ const bool jit_payload_lock_p = rb_gc_multi_objspace_p(); - bool jit_payload_p = false; -# if USE_YJIT - if (body->yjit_payload != NULL) jit_payload_p = true; -# endif -# if USE_ZJIT - if (body->zjit_payload != NULL) jit_payload_p = true; -# endif + bool jit_payload_p = body->jit_payload != NULL; #endif if (reference_updating) { #if USE_YJIT || USE_ZJIT @@ -493,19 +487,19 @@ rb_iseq_mark_and_move(rb_iseq_t *iseq, bool reference_updating) if (jit_payload_lock_p) { RB_VM_LOCKING_NO_BARRIER() { # if USE_YJIT - rb_yjit_iseq_update_references(iseq); + if (rb_yjit_enabled_p) rb_yjit_iseq_update_references(iseq); # endif # if USE_ZJIT - rb_zjit_iseq_update_references(body->zjit_payload); + if (rb_zjit_enabled_p) rb_zjit_iseq_update_references(body->jit_payload); # endif } } else { # if USE_YJIT - rb_yjit_iseq_update_references(iseq); + if (rb_yjit_enabled_p) rb_yjit_iseq_update_references(iseq); # endif # if USE_ZJIT - rb_zjit_iseq_update_references(body->zjit_payload); + if (rb_zjit_enabled_p) rb_zjit_iseq_update_references(body->jit_payload); # endif } } @@ -519,19 +513,19 @@ rb_iseq_mark_and_move(rb_iseq_t *iseq, bool reference_updating) if (jit_payload_lock_p) { RB_VM_LOCKING_NO_BARRIER() { # if USE_YJIT - rb_yjit_iseq_mark(body->yjit_payload); + if (rb_yjit_enabled_p) rb_yjit_iseq_mark(body->jit_payload); # endif # if USE_ZJIT - rb_zjit_iseq_mark(body->zjit_payload); + if (rb_zjit_enabled_p) rb_zjit_iseq_mark(body->jit_payload); # endif } } else { # if USE_YJIT - rb_yjit_iseq_mark(body->yjit_payload); + if (rb_yjit_enabled_p) rb_yjit_iseq_mark(body->jit_payload); # endif # if USE_ZJIT - rb_zjit_iseq_mark(body->zjit_payload); + if (rb_zjit_enabled_p) rb_zjit_iseq_mark(body->jit_payload); # endif } } diff --git a/jit.c b/jit.c index d815fb909ccf2c..21a16a156e488c 100644 --- a/jit.c +++ b/jit.c @@ -611,6 +611,27 @@ rb_jit_vm_unlock(unsigned int *recursive_lock_level, const char *file, int line) rb_vm_lock_leave(recursive_lock_level, file, line); } +void * +rb_iseq_get_jit_payload(const rb_iseq_t *iseq) +{ + RUBY_ASSERT_ALWAYS(IMEMO_TYPE_P(iseq, imemo_iseq)); + if (ISEQ_BODY(iseq)) { + return ISEQ_BODY(iseq)->jit_payload; + } + else { + return NULL; + } +} + +void +rb_iseq_set_jit_payload(const rb_iseq_t *iseq, void *payload) +{ + RUBY_ASSERT_ALWAYS(IMEMO_TYPE_P(iseq, imemo_iseq)); + RUBY_ASSERT_ALWAYS(ISEQ_BODY(iseq)); + RUBY_ASSERT_ALWAYS(NULL == ISEQ_BODY(iseq)->jit_payload); + ISEQ_BODY(iseq)->jit_payload = payload; +} + void rb_iseq_reset_jit_func(const rb_iseq_t *iseq) { diff --git a/vm_core.h b/vm_core.h index 5f316b90e147ed..8d3eba50200b7a 100644 --- a/vm_core.h +++ b/vm_core.h @@ -572,20 +572,14 @@ struct rb_iseq_constant_body { rb_jit_func_t jit_exception; // Number of calls on jit_exec_exception() long unsigned jit_exception_calls; + void *jit_payload; #endif #if USE_YJIT - // YJIT stores some data on each iseq. - void *yjit_payload; // Used to estimate how frequently this ISEQ gets called uint64_t yjit_calls_at_interv; #endif -#if USE_ZJIT - // ZJIT stores some data on each iseq. - void *zjit_payload; -#endif - // Hash of the source this iseq was compiled from, or 0 if it is // unavailable. A computed hash of 0 is remapped to another value, so // 0 never denotes a real hash. diff --git a/yjit.c b/yjit.c index 823ae30cfd19e4..2b6f1110275362 100644 --- a/yjit.c +++ b/yjit.c @@ -193,29 +193,6 @@ rb_full_cfunc_return(rb_execution_context_t *ec, VALUE return_value) ec->cfp->sp++; } -// TODO(alan): consider using an opaque pointer for the payload rather than a void pointer -void * -rb_iseq_get_yjit_payload(const rb_iseq_t *iseq) -{ - RUBY_ASSERT_ALWAYS(IMEMO_TYPE_P(iseq, imemo_iseq)); - if (ISEQ_BODY(iseq)) { - return ISEQ_BODY(iseq)->yjit_payload; - } - else { - // Body is NULL when constructing the iseq. - return NULL; - } -} - -void -rb_iseq_set_yjit_payload(const rb_iseq_t *iseq, void *payload) -{ - RUBY_ASSERT_ALWAYS(IMEMO_TYPE_P(iseq, imemo_iseq)); - RUBY_ASSERT_ALWAYS(ISEQ_BODY(iseq)); - RUBY_ASSERT_ALWAYS(NULL == ISEQ_BODY(iseq)->yjit_payload); - ISEQ_BODY(iseq)->yjit_payload = payload; -} - // This is defined only as a named struct inside rb_iseq_constant_body. // By giving it a separate typedef, we make it nameable by rust-bindgen. // Bindgen's temp/anon name isn't guaranteed stable. diff --git a/yjit/bindgen/src/main.rs b/yjit/bindgen/src/main.rs index 1b1a833eed9216..28afb79144f7fc 100644 --- a/yjit/bindgen/src/main.rs +++ b/yjit/bindgen/src/main.rs @@ -242,7 +242,7 @@ fn main() { .allowlist_function("rb_object_shape_count") .allowlist_function("rb_ivar_get_at") .allowlist_function("rb_ivar_get_at_no_ractor_check") - .allowlist_function("rb_iseq_(get|set)_yjit_payload") + .allowlist_function("rb_iseq_(get|set)_jit_payload") .allowlist_function("rb_iseq_pc_at_idx") .allowlist_function("rb_iseq_opcode_at_pc") .allowlist_function("rb_jit_reserve_addr_space") diff --git a/yjit/src/core.rs b/yjit/src/core.rs index bdea7ccceeb8f3..0cc4ea7ce6870e 100644 --- a/yjit/src/core.rs +++ b/yjit/src/core.rs @@ -1806,7 +1806,7 @@ impl IseqPayload { /// Get the payload for an iseq. For safety it's up to the caller to ensure the returned `&mut` /// upholds aliasing rules and that the argument is a valid iseq. pub fn get_iseq_payload(iseq: IseqPtr) -> Option<&'static mut IseqPayload> { - let payload = unsafe { rb_iseq_get_yjit_payload(iseq) }; + let payload = unsafe { rb_iseq_get_jit_payload(iseq) }; let payload: *mut IseqPayload = payload.cast(); unsafe { payload.as_mut() } } @@ -1816,7 +1816,7 @@ pub fn get_or_create_iseq_payload(iseq: IseqPtr) -> &'static mut IseqPayload { type VoidPtr = *mut c_void; let payload_non_null = unsafe { - let payload = rb_iseq_get_yjit_payload(iseq); + let payload = rb_iseq_get_jit_payload(iseq); if payload.is_null() { // Increment the compiled iseq count incr_counter!(compiled_iseq_count); @@ -1827,7 +1827,7 @@ pub fn get_or_create_iseq_payload(iseq: IseqPtr) -> &'static mut IseqPayload { // We allocate in those cases anyways. let new_payload = IseqPayload::default(); let new_payload = Box::into_raw(Box::new(new_payload)); - rb_iseq_set_yjit_payload(iseq, new_payload as VoidPtr); + rb_iseq_set_jit_payload(iseq, new_payload as VoidPtr); new_payload } else { @@ -1904,7 +1904,7 @@ pub extern "C" fn rb_yjit_iseq_free(iseq: IseqPtr) { iseq_free_invariants(iseq); let payload = { - let payload = unsafe { rb_iseq_get_yjit_payload(iseq) }; + let payload = unsafe { rb_iseq_get_jit_payload(iseq) }; if payload.is_null() { // Nothing to free. return; @@ -2032,7 +2032,7 @@ pub extern "C" fn rb_yjit_iseq_mark(payload: *mut c_void) { /// This is a mirror of [rb_yjit_iseq_mark]. #[no_mangle] pub extern "C" fn rb_yjit_iseq_update_references(iseq: IseqPtr) { - let payload = unsafe { rb_iseq_get_yjit_payload(iseq) }; + let payload = unsafe { rb_iseq_get_jit_payload(iseq) }; let payload = if payload.is_null() { // Nothing to update. return; diff --git a/yjit/src/cruby_bindings.inc.rs b/yjit/src/cruby_bindings.inc.rs index 7bfb386c907d88..143eef16e28ed8 100644 --- a/yjit/src/cruby_bindings.inc.rs +++ b/yjit/src/cruby_bindings.inc.rs @@ -1254,8 +1254,6 @@ extern "C" { ) -> VALUE; pub fn rb_c_method_tracing_currently_enabled(ec: *const rb_execution_context_t) -> bool; pub fn rb_full_cfunc_return(ec: *mut rb_execution_context_t, return_value: VALUE); - pub fn rb_iseq_get_yjit_payload(iseq: *const rb_iseq_t) -> *mut ::std::os::raw::c_void; - pub fn rb_iseq_set_yjit_payload(iseq: *const rb_iseq_t, payload: *mut ::std::os::raw::c_void); pub fn rb_get_symbol_id(namep: VALUE) -> ID; pub fn rb_yjit_builtin_function(iseq: *const rb_iseq_t) -> *const rb_builtin_function; pub fn rb_vm_base_ptr(cfp: *mut rb_control_frame_struct) -> *mut VALUE; @@ -1403,6 +1401,8 @@ extern "C" { file: *const ::std::os::raw::c_char, line: ::std::os::raw::c_int, ); + pub fn rb_iseq_get_jit_payload(iseq: *const rb_iseq_t) -> *mut ::std::os::raw::c_void; + pub fn rb_iseq_set_jit_payload(iseq: *const rb_iseq_t, payload: *mut ::std::os::raw::c_void); pub fn rb_iseq_reset_jit_func(iseq: *const rb_iseq_t); pub fn rb_jit_get_page_size() -> u32; pub fn rb_jit_reserve_addr_space(mem_size: u32) -> *mut u8; diff --git a/zjit.c b/zjit.c index 2dcb20b55422c8..e92eb26a409b3f 100644 --- a/zjit.c +++ b/zjit.c @@ -141,30 +141,6 @@ rb_zjit_iseq_insn_set(const rb_iseq_t *iseq, unsigned int insn_idx, enum ruby_vm ISEQ_BODY(iseq)->iseq_encoded[insn_idx] = (VALUE)insn_table[bare_insn]; } -// Get profiling information for ISEQ -void * -rb_iseq_get_zjit_payload(const rb_iseq_t *iseq) -{ - RUBY_ASSERT_ALWAYS(IMEMO_TYPE_P(iseq, imemo_iseq)); - if (ISEQ_BODY(iseq)) { - return ISEQ_BODY(iseq)->zjit_payload; - } - else { - // Body is NULL when constructing the iseq. - return NULL; - } -} - -// Set profiling information for ISEQ -void -rb_iseq_set_zjit_payload(const rb_iseq_t *iseq, void *payload) -{ - RUBY_ASSERT_ALWAYS(IMEMO_TYPE_P(iseq, imemo_iseq)); - RUBY_ASSERT_ALWAYS(ISEQ_BODY(iseq)); - RUBY_ASSERT_ALWAYS(NULL == ISEQ_BODY(iseq)->zjit_payload); - ISEQ_BODY(iseq)->zjit_payload = payload; -} - void rb_zjit_print_exception(void) { diff --git a/zjit/bindgen/src/main.rs b/zjit/bindgen/src/main.rs index ceafd6bbb0ddbf..fe25d56f081a0a 100644 --- a/zjit/bindgen/src/main.rs +++ b/zjit/bindgen/src/main.rs @@ -303,7 +303,7 @@ fn main() { .allowlist_type("rb_iseq_type") .allowlist_type("rb_event_flag_t") .allowlist_function("rb_object_shape_count") - .allowlist_function("rb_iseq_(get|set)_zjit_payload") + .allowlist_function("rb_iseq_(get|set)_jit_payload") .allowlist_function("rb_iseq_pc_at_idx") .allowlist_function("rb_iseq_opcode_at_pc") .allowlist_function("rb_iseq_bare_opcode_at_pc") diff --git a/zjit/src/cruby_bindings.inc.rs b/zjit/src/cruby_bindings.inc.rs index a1b6da8e51c20f..ca22b04274281b 100644 --- a/zjit/src/cruby_bindings.inc.rs +++ b/zjit/src/cruby_bindings.inc.rs @@ -2450,8 +2450,6 @@ unsafe extern "C" { insn_idx: ::std::os::raw::c_uint, bare_insn: ruby_vminsn_type, ); - pub fn rb_iseq_get_zjit_payload(iseq: *const rb_iseq_t) -> *mut ::std::os::raw::c_void; - pub fn rb_iseq_set_zjit_payload(iseq: *const rb_iseq_t, payload: *mut ::std::os::raw::c_void); pub fn rb_zjit_print_exception(); pub fn rb_zjit_singleton_class_p(klass: VALUE) -> bool; pub fn rb_zjit_defined_ivar(obj: VALUE, id: ID, pushval: VALUE) -> VALUE; @@ -2579,6 +2577,8 @@ unsafe extern "C" { file: *const ::std::os::raw::c_char, line: ::std::os::raw::c_int, ); + pub fn rb_iseq_get_jit_payload(iseq: *const rb_iseq_t) -> *mut ::std::os::raw::c_void; + pub fn rb_iseq_set_jit_payload(iseq: *const rb_iseq_t, payload: *mut ::std::os::raw::c_void); pub fn rb_iseq_reset_jit_func(iseq: *const rb_iseq_t); pub fn rb_jit_get_page_size() -> u32; pub fn rb_jit_reserve_addr_space(mem_size: u32) -> *mut u8; diff --git a/zjit/src/payload.rs b/zjit/src/payload.rs index 51b6f4721bf40e..b10e97324afeac 100644 --- a/zjit/src/payload.rs +++ b/zjit/src/payload.rs @@ -110,7 +110,7 @@ pub fn get_or_create_iseq_payload_ptr(iseq: IseqPtr) -> *mut IseqPayload { type VoidPtr = *mut c_void; unsafe { - let payload = rb_iseq_get_zjit_payload(iseq); + let payload = rb_iseq_get_jit_payload(iseq); if payload.is_null() { // Allocate a new payload with Box and transfer ownership to the GC. // We drop the payload with Box::from_raw when the GC frees the ISEQ and calls us. @@ -118,7 +118,7 @@ pub fn get_or_create_iseq_payload_ptr(iseq: IseqPtr) -> *mut IseqPayload { // We allocate in those cases anyways. let new_payload = IseqPayload::new(); let new_payload = Box::into_raw(Box::new(new_payload)); - rb_iseq_set_zjit_payload(iseq, new_payload as VoidPtr); + rb_iseq_set_jit_payload(iseq, new_payload as VoidPtr); new_payload } else { From 25f520c9e15ea46f9e7630b81fdbd5406cca182a Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 27 Aug 2026 18:22:25 +0900 Subject: [PATCH 04/15] [ruby/rubygems] Ignore plugin index entries that point outside the plugin root A path stored relative to the plugin root only means anything inside it, so an entry that climbs out of it did not come from a plugin Bundler installed there. Paths recorded as absolute are left alone, which is how `bundle plugin install --path` and older index files record them. An event left with no plugins once its own are gone goes unmerged rather than empty, so it cannot overwrite one the other index file registered. https://github.com/ruby/rubygems/commit/6a310eae12 Co-Authored-By: Claude Opus 5 --- lib/bundler/plugin/index.rb | 49 ++++++++-- spec/bundler/bundler/plugin/index_spec.rb | 103 ++++++++++++++++++++++ 2 files changed, 147 insertions(+), 5 deletions(-) diff --git a/lib/bundler/plugin/index.rb b/lib/bundler/plugin/index.rb index 0e4be4c7f91f8c..ced9821c66c837 100644 --- a/lib/bundler/plugin/index.rb +++ b/lib/bundler/plugin/index.rb @@ -176,11 +176,15 @@ def load_index(index_file, global = false) # older Bundler versions, which dumped empty hashes as a bare key. index = Gem::YAMLSerializer.load(data) || {} - @commands.merge!(index["commands"] || {}) - @hooks.merge!(index["hooks"] || {}) - @load_paths.merge!(transform_index_paths(index["load_paths"]) {|p| absolutize_path(p, base) }) - @plugin_paths.merge!(transform_index_paths(index["plugin_paths"]) {|p| absolutize_path(p, base) }) - @sources.merge!(index["sources"] || {}) unless global + escaping = escaping_plugins(index, base) + hooks = (index["hooks"] || {}).transform_values {|names| Array(names) - escaping } + + @commands.merge!(owned_by(index["commands"] || {}, escaping)) + # An event whose plugins all escaped is left out rather than merged in empty. + @hooks.merge!(hooks.reject {|_, names| names.empty? }) + @load_paths.merge!(named(transform_index_paths(index["load_paths"]) {|p| absolutize_path(p, base) }, escaping)) + @plugin_paths.merge!(named(transform_index_paths(index["plugin_paths"]) {|p| absolutize_path(p, base) }, escaping)) + @sources.merge!(owned_by(index["sources"] || {}, escaping)) unless global end end @@ -209,6 +213,41 @@ def base_for_index(global) global ? Plugin.global_root : Plugin.root end + # A relative path only means anything inside the root, so an entry that escapes is not one Bundler installed. + def escaping_plugins(index, base) + names = [] + + %w[load_paths plugin_paths].each do |key| + (index[key] || {}).each do |name, value| + escapes = Array(value).any? do |path| + !Pathname.new(path).absolute? && !contained_in?(absolutize_path(path, base), base) + end + + names << name if escapes + end + end + + names.uniq + end + + # Expanded here, not by the caller: what gets stored stays joined, because + # the rest of the class matches it against Plugin.root as written. + def contained_in?(path, base) + path = File.expand_path(path) + base = File.expand_path(base) + + path == base || path.start_with?("#{base}#{File::SEPARATOR}") + end + + # commands and sources are keyed by what they provide, load_paths and plugin_paths by the plugin. + def owned_by(mapping, names) + mapping.reject {|_, plugin| names.include?(plugin) } + end + + def named(mapping, names) + mapping.reject {|plugin, _| names.include?(plugin) } + end + def transform_index_paths(paths) return {} unless paths diff --git a/spec/bundler/bundler/plugin/index_spec.rb b/spec/bundler/bundler/plugin/index_spec.rb index b7f57d05b0ce3b..5b51af363ac016 100644 --- a/spec/bundler/bundler/plugin/index_spec.rb +++ b/spec/bundler/bundler/plugin/index_spec.rb @@ -238,6 +238,45 @@ expect(new_index.load_paths(plugin_name)).to eq([plugin_root.join(plugin_name, "lib").to_s]) end + it "ignores entries that climb out only after an interior parent reference" do + require "rubygems/yaml_serializer" + + escaping_index = { + "commands" => {}, + "hooks" => {}, + "load_paths" => { "escaping-plugin" => [File.join("escaping-plugin", "..", "..", "elsewhere", "lib")] }, + "plugin_paths" => { "escaping-plugin" => File.join("escaping-plugin", "..", "..", "elsewhere") }, + "sources" => {}, + } + + File.open(index.index_file, "w") {|f| f.puts Gem::YAMLSerializer.dump(escaping_index) } + + new_index = Index.new + + expect(new_index.installed?("escaping-plugin")).to be_nil + expect(new_index.load_paths("escaping-plugin")).to be_nil + end + + it "reads a leading tilde in a relative path literally" do + require "rubygems/yaml_serializer" + + plugin_root = Bundler::Plugin.root + + tilde_index = { + "commands" => {}, + "hooks" => {}, + "load_paths" => { plugin_name => [File.join("~nosuchuser", "lib")] }, + "plugin_paths" => { plugin_name => "~nosuchuser" }, + "sources" => {}, + } + + File.open(index.index_file, "w") {|f| f.puts Gem::YAMLSerializer.dump(tilde_index) } + + new_index = Index.new + expect(new_index.plugin_path(plugin_name)).to eq(plugin_root.join("~nosuchuser")) + expect(new_index.load_paths(plugin_name)).to eq([plugin_root.join("~nosuchuser", "lib").to_s]) + end + it "keeps paths outside the plugin root as absolute" do outside_path = tmp.join("outside", "external-plugin") FileUtils.mkdir_p(outside_path.join("lib")) @@ -251,6 +290,70 @@ expect(data["load_paths"]["external-plugin"]).to eq([outside_path.join("lib").to_s]) end + it "ignores entries whose relative paths climb out of the plugin root" do + require "rubygems/yaml_serializer" + + escaping_index = { + "commands" => { "escape" => "escaping-plugin" }, + "hooks" => { "before-eval" => ["escaping-plugin", plugin_name] }, + "load_paths" => { + "escaping-plugin" => ["../../elsewhere/lib"], + plugin_name => [File.join(plugin_name, "lib")], + }, + "plugin_paths" => { "escaping-plugin" => "../../elsewhere", plugin_name => plugin_name }, + "sources" => { "escape" => "escaping-plugin" }, + } + + File.open(index.index_file, "w") {|f| f.puts Gem::YAMLSerializer.dump(escaping_index) } + + new_index = Index.new + + expect(new_index.installed?("escaping-plugin")).to be_nil + expect(new_index.load_paths("escaping-plugin")).to be_nil + expect(new_index.command_plugin("escape")).to be_nil + expect(new_index.source_plugin("escape")).to be_nil + expect(new_index.hook_plugins("before-eval")).to eq([plugin_name]) + expect(new_index.installed?(plugin_name)).to eq(Bundler::Plugin.root.join(plugin_name).to_s) + end + + it "ignores entries whose load paths alone climb out of the plugin root" do + require "rubygems/yaml_serializer" + + escaping_index = { + "commands" => {}, + "hooks" => {}, + "load_paths" => { "escaping-plugin" => ["../../elsewhere/lib"] }, + "plugin_paths" => { "escaping-plugin" => "escaping-plugin" }, + "sources" => {}, + } + + File.open(index.index_file, "w") {|f| f.puts Gem::YAMLSerializer.dump(escaping_index) } + + new_index = Index.new + + expect(new_index.installed?("escaping-plugin")).to be_nil + expect(new_index.load_paths("escaping-plugin")).to be_nil + end + + it "drops hook events whose plugins all climb out of the plugin root" do + require "rubygems/yaml_serializer" + + escaping_index = { + "commands" => {}, + "hooks" => { "before-eval" => ["escaping-plugin"] }, + "load_paths" => { "escaping-plugin" => ["../../elsewhere/lib"] }, + "plugin_paths" => { "escaping-plugin" => "../../elsewhere" }, + "sources" => {}, + } + + File.open(index.index_file, "w") {|f| f.puts Gem::YAMLSerializer.dump(escaping_index) } + + new_index = Index.new + new_index.register_plugin("aplugin", lib_path("aplugin").to_s, [lib_path("aplugin").join("lib").to_s], [], [], []) + + expect(new_index.index_file.read).to_not include("before-eval") + end + it "reads legacy index files with absolute paths" do require "rubygems/yaml_serializer" From ca40db6d4f54ba166790b263cceac3f89dce696a Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 27 Aug 2026 18:22:25 +0900 Subject: [PATCH 05/15] [ruby/rubygems] Relativize a plugin path only when it is really under the root The save side matched the root as a string prefix, so an absolute path that starts with the root but climbs above it was written back relative, and the filter above then dropped it on the next load. Asking the same question both ways leaves it absolute and loadable. https://github.com/ruby/rubygems/commit/b20b941dd5 Co-Authored-By: Claude Opus 5 --- lib/bundler/plugin/index.rb | 11 ++++------- spec/bundler/bundler/plugin/index_spec.rb | 9 +++++++++ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/lib/bundler/plugin/index.rb b/lib/bundler/plugin/index.rb index ced9821c66c837..1d53fd84c6490d 100644 --- a/lib/bundler/plugin/index.rb +++ b/lib/bundler/plugin/index.rb @@ -231,7 +231,7 @@ def escaping_plugins(index, base) end # Expanded here, not by the caller: what gets stored stays joined, because - # the rest of the class matches it against Plugin.root as written. + # #installed_in_plugin_root? matches it against Plugin.root as written. def contained_in?(path, base) path = File.expand_path(path) base = File.expand_path(base) @@ -264,12 +264,9 @@ def relativize_path(path, base) pathname = Pathname.new(path) return path unless pathname.absolute? - base_path = Pathname.new(base) - if pathname == base_path || pathname.to_s.start_with?(base_path.to_s + File::SEPARATOR) - pathname.relative_path_from(base_path).to_s - else - path - end + return path unless contained_in?(pathname.to_s, base) + + pathname.relative_path_from(Pathname.new(base)).to_s end def absolutize_path(path, base) diff --git a/spec/bundler/bundler/plugin/index_spec.rb b/spec/bundler/bundler/plugin/index_spec.rb index 5b51af363ac016..5214069107c979 100644 --- a/spec/bundler/bundler/plugin/index_spec.rb +++ b/spec/bundler/bundler/plugin/index_spec.rb @@ -277,6 +277,15 @@ expect(new_index.load_paths(plugin_name)).to eq([plugin_root.join("~nosuchuser", "lib").to_s]) end + it "keeps an absolute path that only looks like it is under the plugin root" do + escaping_path = File.join(Bundler::Plugin.root.to_s, "..", "..", "escaping-plugin") + + index.register_plugin("escaping-plugin", escaping_path, [File.join(escaping_path, "lib")], [], [], []) + + new_index = Index.new + expect(new_index.installed?("escaping-plugin")).to eq(escaping_path) + end + it "keeps paths outside the plugin root as absolute" do outside_path = tmp.join("outside", "external-plugin") FileUtils.mkdir_p(outside_path.join("lib")) From e722f446b8d253b674983e1b729c3c332a64be1a Mon Sep 17 00:00:00 2001 From: Nobuyoshi Nakada Date: Tue, 4 Aug 2026 03:00:35 +0900 Subject: [PATCH 06/15] [ruby/io-console] Add Windows console constants Merge the unmerged dependency update at the previous merger. https://github.com/ruby/io-console/commit/e712668ca2 --- ext/io/console/depend | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/ext/io/console/depend b/ext/io/console/depend index a38e9ee96cb7b6..2729ec9a6c3c4d 100644 --- a/ext/io/console/depend +++ b/ext/io/console/depend @@ -4,12 +4,11 @@ console.o: console.c # AUTOGENERATED DEPENDENCIES END -win32_vk.inc: win32_vk.list +win32_vk.inc: win32_vk.list $(srcdir)/extract-vk.rb .list.inc: ( \ - $(RUBY) -e "ARGF.read.scan(/^\w+,\s*\KVK_\w+/){|n|puts(%Q[#ifndef #{n}\n# define #{n} UNDEFINED_VK\n#endif])}" \ - $< && \ + $(RUBY) $(srcdir)/extract-vk.rb $< && \ gperf --ignore-case -L ANSI-C -E -C -P -p -j1 -i 1 -g -o -t -K ofs -N console_win32_vk -k* $< \ | sed -f $(top_srcdir)/tool/gperf.sed \ ) > $(@F) From 11d8ba65e6c3a169b6c20525dd65a554602f12d2 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Wed, 26 Aug 2026 13:56:56 +0900 Subject: [PATCH 07/15] [ruby/rubygems] Pin lockfile dependencies to the version their section resolved The parser removed in #9564 turned a `!` entry in the DEPENDENCIES section into a `= ` requirement. Bundler's LockfileParser records the pinned source on the dependency and leaves the requirement at `>= 0`, so `gem install -g` could resolve a newer published version over the locked GIT or PATH one. Keeping the version as each section is read pins prereleases too, and lets a PATH section pin to the gemspec on disk the way the old parser did. https://github.com/ruby/rubygems/commit/3fe9b33a55 Co-Authored-By: Claude Fable 5 --- lib/rubygems/request_set.rb | 17 ++++- test/rubygems/test_gem_request_set.rb | 91 ++++++++++++++++++++++++++- 2 files changed, 104 insertions(+), 4 deletions(-) diff --git a/lib/rubygems/request_set.rb b/lib/rubygems/request_set.rb index d97c313da3020b..b8ba9cc3dbe4e8 100644 --- a/lib/rubygems/request_set.rb +++ b/lib/rubygems/request_set.rb @@ -349,6 +349,8 @@ def load_lockfile(lock_file) # :nodoc: parser = Bundler::LockfileParser.new(File.read(lock_file), lockfile_path: lock_file) + locked_versions = {} + parser.specs.group_by(&:source).each do |source, specs| case source when Bundler::Source::Rubygems @@ -357,6 +359,7 @@ def load_lockfile(lock_file) # :nodoc: lock_set = Gem::Resolver::LockSet.new(remotes) specs.each do |spec| added = lock_set.add(spec.name, spec.version.to_s, spec.platform) + locked_versions[spec.name] ||= spec.version spec.dependencies.each do |dep| added.each {|s| s.add_dependency dep } end @@ -373,6 +376,7 @@ def load_lockfile(lock_file) # :nodoc: source.revision, source.submodules || false ) + locked_versions[spec.name] ||= spec.version spec.dependencies.each {|dep| git_spec.add_dependency dep } end @sets << git_set @@ -380,6 +384,7 @@ def load_lockfile(lock_file) # :nodoc: vendor_set = Gem::Resolver::VendorSet.new specs.each do |spec| loaded = vendor_set.add_vendor_gem(spec.name, source.path.to_s) + locked_versions[spec.name] ||= loaded.version spec.dependencies.each {|dep| loaded.dependencies << dep } end @sets << vendor_set @@ -387,7 +392,17 @@ def load_lockfile(lock_file) # :nodoc: end parser.dependencies.each_value do |dep| - gem dep.name, *dep.requirement.as_list + requirements = dep.requirement.as_list + + # A dependency the lockfile ties to a source replaces whatever it asks + # for with the version that source resolved, the way the parser this + # replaced did. For a PATH section that is the version of the gemspec on + # disk, not the one the lockfile records. + if dep.source && (version = locked_versions[dep.name]) + requirements = [version] + end + + gem dep.name, *requirements end ensure Bundler.instance_variable_set(:@root, previous_root) if defined?(previous_root) diff --git a/test/rubygems/test_gem_request_set.rb b/test/rubygems/test_gem_request_set.rb index 33054aa8e50cc7..9d1d5b36611628 100644 --- a/test/rubygems/test_gem_request_set.rb +++ b/test/rubygems/test_gem_request_set.rb @@ -354,6 +354,7 @@ def test_load_gemdeps_with_lockfile_gem_section a (1) b (1) a (~> 1.0) + b (3-x86_64-linux) PLATFORMS #{Gem::Platform::RUBY} @@ -365,9 +366,23 @@ def test_load_gemdeps_with_lockfile_gem_section rs.load_gemdeps "gem.deps.rb" + assert_equal [dep("b")], rs.dependencies + lock_set = rs.sets.find {|set| Gem::Resolver::LockSet === set } refute_nil lock_set, "LockSet should be created from GEM section" - assert_equal %w[a-1 b-1], lock_set.specs.map(&:full_name).sort + assert_equal %w[a-1 b-1 b-3], lock_set.specs.map(&:full_name).sort + + expected = [ + Gem::Platform::RUBY, + Gem::Platform::RUBY, + Gem::Platform.new("x86_64-linux"), + ] + + assert_equal expected, lock_set.specs.sort_by(&:full_name).map(&:platform) + + spec = lock_set.specs.find {|s| s.full_name == "b-1" } + + assert_equal [dep("a", "~> 1.0")], spec.dependencies end def test_load_gemdeps_with_lockfile_git_section @@ -383,7 +398,9 @@ def test_load_gemdeps_with_lockfile_git_section remote: git://example/a.git revision: deadbeef specs: - a (1) + a (2) + b (>= 3) + c PLATFORMS #{Gem::Platform::RUBY} @@ -395,9 +412,42 @@ def test_load_gemdeps_with_lockfile_git_section rs.load_gemdeps "gem.deps.rb" + assert_equal [dep("a", "= 2")], rs.dependencies + git_set = rs.sets.find {|set| Gem::Resolver::GitSet === set } refute_nil git_set, "GitSet should be created from GIT section" - assert_includes git_set.specs.keys, "a" + assert_equal %w[a-2], git_set.specs.values.map(&:full_name) + + assert_equal [dep("b", ">= 3"), dep("c")], + git_set.specs.values.first.dependencies + end + + def test_load_gemdeps_with_lockfile_git_section_prerelease + rs = Gem::RequestSet.new + + File.open "gem.deps.rb", "w" do |io| + io.puts 'gem "a", :git => "git://example/a.git"' + end + + File.open "gem.deps.rb.lock", "w" do |io| + io.puts <<~LOCKFILE + GIT + remote: git://example/a.git + revision: deadbeef + specs: + a (1.0.0.pre1) + + PLATFORMS + #{Gem::Platform::RUBY} + + DEPENDENCIES + a! + LOCKFILE + end + + rs.load_gemdeps "gem.deps.rb" + + assert_equal [dep("a", "= 1.0.0.pre1")], rs.dependencies end def test_load_gemdeps_with_lockfile_path_section @@ -426,11 +476,46 @@ def test_load_gemdeps_with_lockfile_path_section rs.load_gemdeps "gem.deps.rb" + assert_equal [dep("a", "= 1")], rs.dependencies + vendor_set = rs.sets.find {|set| Gem::Resolver::VendorSet === set } refute_nil vendor_set, "VendorSet should be created from PATH section" assert_equal %w[a-1], vendor_set.specs.values.map(&:full_name) end + def test_load_gemdeps_with_lockfile_path_section_newer_than_lockfile + _, _, directory = vendor_gem "a", 2 + + rs = Gem::RequestSet.new + + File.open "gem.deps.rb", "w" do |io| + io.puts "gem \"a\", :path => #{directory.inspect}" + end + + File.open "gem.deps.rb.lock", "w" do |io| + io.puts <<~LOCKFILE + PATH + remote: #{directory} + specs: + a (1) + + PLATFORMS + #{Gem::Platform::RUBY} + + DEPENDENCIES + a! + LOCKFILE + end + + rs.load_gemdeps "gem.deps.rb" + + assert_equal [dep("a", "= 2")], rs.dependencies + + vendor_set = rs.sets.find {|set| Gem::Resolver::VendorSet === set } + assert_equal %w[a-2], vendor_set.specs.values.map(&:full_name) + assert_equal %w[a-2], vendor_set.find_all(dep("a", "= 2")).map(&:full_name) + end + def test_load_gemdeps_with_missing_lockfile rs = Gem::RequestSet.new From 99d127e5d3ee6a4de1206f63fdad4999391070df Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Wed, 26 Aug 2026 13:57:07 +0900 Subject: [PATCH 08/15] [ruby/rubygems] Restore lockfile remote order for GEM sections Bundler::Source::Rubygems stores remotes in reverse of the lockfile order, so reading them straight through flipped which remote a spec downloads from when a GEM section lists more than one. https://github.com/ruby/rubygems/commit/da017b968f Co-Authored-By: Claude Fable 5 --- lib/rubygems/request_set.rb | 5 +++- test/rubygems/test_gem_request_set.rb | 33 +++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/lib/rubygems/request_set.rb b/lib/rubygems/request_set.rb index b8ba9cc3dbe4e8..8db78270257cb2 100644 --- a/lib/rubygems/request_set.rb +++ b/lib/rubygems/request_set.rb @@ -354,7 +354,10 @@ def load_lockfile(lock_file) # :nodoc: parser.specs.group_by(&:source).each do |source, specs| case source when Bundler::Source::Rubygems - remotes = source.remotes.map {|remote| Gem::Source.new(remote.to_s) } + # Bundler::Source::Rubygems stores remotes in reverse of the lockfile + # order (Bundler::Source::Rubygems#to_lock reverses them back), so + # restore the lockfile order here. + remotes = source.remotes.reverse.map {|remote| Gem::Source.new(remote.to_s) } remotes << Gem::Source.new(Gem::DEFAULT_HOST) if remotes.empty? lock_set = Gem::Resolver::LockSet.new(remotes) specs.each do |spec| diff --git a/test/rubygems/test_gem_request_set.rb b/test/rubygems/test_gem_request_set.rb index 9d1d5b36611628..23dcddbaecc746 100644 --- a/test/rubygems/test_gem_request_set.rb +++ b/test/rubygems/test_gem_request_set.rb @@ -385,6 +385,39 @@ def test_load_gemdeps_with_lockfile_gem_section assert_equal [dep("a", "~> 1.0")], spec.dependencies end + def test_load_gemdeps_with_lockfile_gem_section_multiple_remotes + rs = Gem::RequestSet.new + + File.open "gem.deps.rb", "w" do |io| + io.puts 'gem "a"' + end + + File.open "gem.deps.rb.lock", "w" do |io| + io.puts <<~LOCKFILE + GEM + remote: https://gems.example/ + remote: https://other.example/ + specs: + a (2) + + PLATFORMS + #{Gem::Platform::RUBY} + + DEPENDENCIES + a + LOCKFILE + end + + rs.load_gemdeps "gem.deps.rb" + + lock_set = rs.sets.find {|set| Gem::Resolver::LockSet === set } + refute_nil lock_set, "LockSet should be created from GEM section" + assert_equal %w[a-2], lock_set.specs.map(&:full_name) + + assert_equal %w[https://gems.example/ https://other.example/], + lock_set.specs.flat_map {|s| s.sources.map {|src| src.uri.to_s } } + end + def test_load_gemdeps_with_lockfile_git_section rs = Gem::RequestSet.new From 660aa1a2acf72a14c68ba5e4becbde850ffc553d Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Wed, 26 Aug 2026 13:57:19 +0900 Subject: [PATCH 09/15] [ruby/rubygems] Do not restore Bundler root when it was never saved `defined?(previous_root)` is true even when the assignment never ran, so a failure before the root was saved reset Bundler's root to nil. https://github.com/ruby/rubygems/commit/b2177cd67d Co-Authored-By: Claude Fable 5 --- lib/rubygems/request_set.rb | 3 +- test/rubygems/test_gem_request_set.rb | 40 +++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/lib/rubygems/request_set.rb b/lib/rubygems/request_set.rb index 8db78270257cb2..0a92a1b1f9fedb 100644 --- a/lib/rubygems/request_set.rb +++ b/lib/rubygems/request_set.rb @@ -346,6 +346,7 @@ def load_lockfile(lock_file) # :nodoc: # `gem install -g` lockfile can be parsed without a Bundler environment. previous_root = Bundler.instance_variable_get(:@root) Bundler.instance_variable_set(:@root, Pathname.new(File.expand_path(File.dirname(lock_file)))) + root_swapped = true parser = Bundler::LockfileParser.new(File.read(lock_file), lockfile_path: lock_file) @@ -408,7 +409,7 @@ def load_lockfile(lock_file) # :nodoc: gem dep.name, *requirements end ensure - Bundler.instance_variable_set(:@root, previous_root) if defined?(previous_root) + Bundler.instance_variable_set(:@root, previous_root) if root_swapped end def pretty_print(q) # :nodoc: diff --git a/test/rubygems/test_gem_request_set.rb b/test/rubygems/test_gem_request_set.rb index 23dcddbaecc746..1838732c1998f5 100644 --- a/test/rubygems/test_gem_request_set.rb +++ b/test/rubygems/test_gem_request_set.rb @@ -549,6 +549,46 @@ def test_load_gemdeps_with_lockfile_path_section_newer_than_lockfile assert_equal %w[a-2], vendor_set.find_all(dep("a", "= 2")).map(&:full_name) end + def test_load_lockfile_keeps_bundler_root_when_it_cannot_be_swapped + require "bundler" + + previous_root = Bundler.instance_variable_get(:@root) + Bundler.instance_variable_set(:@root, Pathname.new(@tempdir)) + + rs = Gem::RequestSet.new + def rs.require(*) + raise LoadError + end + + assert_raise LoadError do + rs.load_lockfile "gem.deps.rb.lock" + end + + assert_equal Pathname.new(@tempdir), Bundler.instance_variable_get(:@root) + ensure + Bundler.instance_variable_set(:@root, previous_root) + end + + def test_load_lockfile_restores_bundler_root_when_parsing_fails + require "bundler" + + previous_root = Bundler.instance_variable_get(:@root) + Bundler.instance_variable_set(:@root, Pathname.new(File.join(@tempdir, "elsewhere"))) + + File.open "gem.deps.rb.lock", "w" do |io| + io.puts "<<<<<<< HEAD" + end + + assert_raise Bundler::LockfileError do + Gem::RequestSet.new.load_lockfile "gem.deps.rb.lock" + end + + assert_equal Pathname.new(File.join(@tempdir, "elsewhere")), + Bundler.instance_variable_get(:@root) + ensure + Bundler.instance_variable_set(:@root, previous_root) + end + def test_load_gemdeps_with_missing_lockfile rs = Gem::RequestSet.new From 5eb9a6925b805a17dced976d5b741afe5086aab1 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Wed, 26 Aug 2026 13:57:32 +0900 Subject: [PATCH 10/15] [ruby/rubygems] Keep lockfile parsing from loading Bundler source plugins A PLUGIN SOURCE section sends Bundler::Plugin.from_lock looking for the plugin that handles it, and finding one loads and runs that plugin's `plugins.rb`. The plugin index is read from the lockfile directory, so a repository could run code just by being installed from with `gem install -g`. Nothing here can use a plugin source anyway. https://github.com/ruby/rubygems/commit/1b9f1e166c Co-Authored-By: Claude Fable 5 --- lib/rubygems/request_set.rb | 9 ++++ test/rubygems/test_gem_request_set.rb | 63 +++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/lib/rubygems/request_set.rb b/lib/rubygems/request_set.rb index 0a92a1b1f9fedb..51745296a10c2f 100644 --- a/lib/rubygems/request_set.rb +++ b/lib/rubygems/request_set.rb @@ -348,6 +348,14 @@ def load_lockfile(lock_file) # :nodoc: Bundler.instance_variable_set(:@root, Pathname.new(File.expand_path(File.dirname(lock_file)))) root_swapped = true + # A PLUGIN SOURCE section otherwise sends Bundler::Plugin.from_lock looking + # for the plugin that handles it, which loads and runs that plugin's + # `plugins.rb`. Nothing here can use a plugin source anyway, so borrow the + # flag Bundler itself uses to keep lockfile parsing inert. + previous_gemfile_parse = Bundler::Plugin.instance_variable_get(:@gemfile_parse) + Bundler::Plugin.instance_variable_set(:@gemfile_parse, true) + gemfile_parse_swapped = true + parser = Bundler::LockfileParser.new(File.read(lock_file), lockfile_path: lock_file) locked_versions = {} @@ -410,6 +418,7 @@ def load_lockfile(lock_file) # :nodoc: end ensure Bundler.instance_variable_set(:@root, previous_root) if root_swapped + Bundler::Plugin.instance_variable_set(:@gemfile_parse, previous_gemfile_parse) if gemfile_parse_swapped end def pretty_print(q) # :nodoc: diff --git a/test/rubygems/test_gem_request_set.rb b/test/rubygems/test_gem_request_set.rb index 1838732c1998f5..8c8be04fb9f9a0 100644 --- a/test/rubygems/test_gem_request_set.rb +++ b/test/rubygems/test_gem_request_set.rb @@ -549,6 +549,69 @@ def test_load_gemdeps_with_lockfile_path_section_newer_than_lockfile assert_equal %w[a-2], vendor_set.find_all(dep("a", "= 2")).map(&:full_name) end + def test_load_lockfile_does_not_load_plugins_for_a_plugin_source_section + require "bundler" + require "bundler/plugin" + + plugin = File.join @tempdir, ".bundle", "plugin", "plugins", "example" + loaded = File.join @tempdir, "plugin-was-loaded" + + FileUtils.mkdir_p File.join(plugin, "lib") + + File.open File.join(@tempdir, "Gemfile"), "w" do |io| + io.puts 'source "https://rubygems.org"' + end + + File.open File.join(plugin, "plugins.rb"), "w" do |io| + io.puts "File.write #{loaded.dump}, \"loaded\"" + io.puts "class ExampleSource" + io.puts " include Bundler::Plugin::API::Source" + io.puts "end" + io.puts 'Bundler::Plugin::API.source("example_type", ExampleSource)' + end + + File.open File.join(@tempdir, ".bundle", "plugin", "index"), "w" do |io| + io.puts <<~INDEX + --- + commands: + hooks: + load_paths: + example: + - plugins/example/lib + plugin_paths: + example: plugins/example + sources: + example_type: example + INDEX + end + + File.open "gem.deps.rb.lock", "w" do |io| + io.puts <<~LOCKFILE + PLUGIN SOURCE + remote: https://gems.example/ + type: example_type + specs: + a (1) + + PLATFORMS + #{Gem::Platform::RUBY} + + DEPENDENCIES + a! + LOCKFILE + end + + Bundler::Plugin.reset! + + rs = Gem::RequestSet.new + rs.load_lockfile "gem.deps.rb.lock" + + assert_path_not_exist loaded + assert_equal [dep("a")], rs.dependencies + ensure + Bundler::Plugin.reset! + end + def test_load_lockfile_keeps_bundler_root_when_it_cannot_be_swapped require "bundler" From 7c3a23a0399b6aad4f97e1c88f522bae41910f03 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 27 Aug 2026 14:56:27 +0900 Subject: [PATCH 11/15] [ruby/rubygems] Align the credential_store false vocabulary with to_bool `credential_store` was the only Bundler setting that read `off` as disabled, while every other setting goes through `Settings#to_bool`, whose false vocabulary has never had it. The disabled branch now delegates to `#to_bool` so the two cannot drift again. The true list stays because the setting is tri-state and the boolean spellings still have to be told apart from a backend name. https://github.com/ruby/rubygems/commit/0244255e6a Co-Authored-By: Claude Opus 5 --- lib/bundler/man/bundle-config.1 | 2 ++ lib/bundler/man/bundle-config.1.ronn | 8 +++++ lib/bundler/settings.rb | 22 ++++++++----- spec/bundler/bundler/settings_spec.rb | 47 +++++++++++++++++++++++++++ 4 files changed, 70 insertions(+), 9 deletions(-) diff --git a/lib/bundler/man/bundle-config.1 b/lib/bundler/man/bundle-config.1 index fcfe49fa165959..b8f37ca7d80962 100644 --- a/lib/bundler/man/bundle-config.1 +++ b/lib/bundler/man/bundle-config.1 @@ -107,6 +107,8 @@ A \fBcreated_at\fR timestamp is read as UTC when it carries no time zone offset\ .IP "\(bu" 4 \fBcredential_store\fR (\fBBUNDLE_CREDENTIAL_STORE\fR): Experimental: store and read host credentials (the values otherwise set via \fBbundle config set \fR) in a credential store instead of the plain text config file\. Set it to \fBtrue\fR to use the operating system's native store (macOS Keychain, Linux Secret Service, Windows Credential Manager) when one is available on this platform, or to the name of a backend provided by a third\-party gem, such as \fB1password\fR\. Falls back to the config file when the store is unavailable or fails, warning that the credential was written in plain text\. Defaults to false\. Credentials already written to the config file are not migrated automatically; re\-run \fBbundle config set \fR with the setting enabled to move each one into the store\. Being experimental, the name and behavior of this setting may change in a future release\. .IP +The value is read in three ways\. An empty value and the false values Bundler takes everywhere else (\fBfalse\fR, \fBf\fR, \fBno\fR, \fBn\fR, \fB0\fR) turn the store off\. \fBtrue\fR, \fBt\fR, \fByes\fR, \fBy\fR, \fBon\fR and \fB1\fR select the native store\. Case does not matter for any of those\. Anything else is a backend name, taken as written, and only lowercase letters, digits, \fB_\fR and \fB\-\fR are recognized in one\. So \fBoff\fR names a backend rather than turning the store off, which \fBfalse\fR does\. +.IP A credential kept in the store is never printed back\. \fBbundle config get \fR and \fBbundle config list\fR name the key and say that its value lives in the credential store\. With \fB\-\-parseable\fR, such a key is left out entirely, since that output is meant to be read back by \fBbundle config set\fR\. A third\-party backend is not required to enumerate what it holds, so a credential kept in one may not be listed at all\. .IP The store belongs to the machine's user, not to a project, so \fB\-\-local\fR and \fB\-\-global\fR make no difference to where a credential is kept\. Setting a host's credential in one project changes it for every project on the machine, and unsetting it there removes it everywhere\. Protecting the store itself is the operating system's job, or that of whichever backend you selected\. diff --git a/lib/bundler/man/bundle-config.1.ronn b/lib/bundler/man/bundle-config.1.ronn index 689d6227ca0d81..4e1c67e10d55e4 100644 --- a/lib/bundler/man/bundle-config.1.ronn +++ b/lib/bundler/man/bundle-config.1.ronn @@ -193,6 +193,14 @@ learn more about their operation in [bundle install(1)](bundle-install.1.html). setting enabled to move each one into the store. Being experimental, the name and behavior of this setting may change in a future release. + The value is read in three ways. An empty value and the false values + Bundler takes everywhere else (`false`, `f`, `no`, `n`, `0`) turn the + store off. `true`, `t`, `yes`, `y`, `on` and `1` select the native store. + Case does not matter for any of those. Anything else is a backend name, + taken as written, and only lowercase letters, digits, `_` and `-` are + recognized in one. So `off` names a backend rather than turning the store + off, which `false` does. + A credential kept in the store is never printed back. `bundle config get ` and `bundle config list` name the key and say that its value lives in the credential store. With `--parseable`, such a key is left out diff --git a/lib/bundler/settings.rb b/lib/bundler/settings.rb index e9a26172b7786a..02780bef730c79 100644 --- a/lib/bundler/settings.rb +++ b/lib/bundler/settings.rb @@ -465,17 +465,16 @@ def is_userinfo(value) value.include?(":") end - ## - # The Gem::CredentialStore instance to use, or nil when the - # `credential_store` setting is off. The value is `true`/`"true"` for this - # platform's native backend or a backend name such as `"1password"`. - # Guarded by a cheap lookup so reading and writing settings costs nothing - # extra when the setting is disabled. - # Kept separate from RubyGems so gem signout does not remove Bundler's # host credentials. CREDENTIAL_STORE_SERVICE = "bundler" + ## + # The Gem::CredentialStore for the spec #credential_store_spec returns, + # or nil when the setting is off or this RubyGems has no credential store. + # Guarded by a cheap lookup so reading and writing settings costs nothing + # extra when the setting is disabled. + def active_credential_store(host = nil) spec = credential_store_spec(host) return nil unless spec @@ -493,8 +492,13 @@ def credential_store_spec(host = nil) value = self[:credential_store] if value.nil? # An environment variable can carry bytes String#downcase would reject. - case value.to_s.b.downcase - when "", "false", "0", "no", "off", "f", "n" then nil + normalized = value.to_s.b.downcase + + # Tri-state, unlike a BOOL_KEYS setting, so #to_bool is only consulted + # for the false half. + return nil unless to_bool(normalized) + + case normalized when "true", "1", "yes", "on", "t", "y" then true else value.to_s end diff --git a/spec/bundler/bundler/settings_spec.rb b/spec/bundler/bundler/settings_spec.rb index 42e3dca1280a7b..58027fd0b22ca1 100644 --- a/spec/bundler/bundler/settings_spec.rb +++ b/spec/bundler/bundler/settings_spec.rb @@ -275,6 +275,53 @@ end end + describe "#credential_store_spec" do + it "is off when the setting is unset" do + expect(settings.send(:credential_store_spec)).to be_nil + end + + it "reads the same false spellings as every other Bundler boolean" do + ["", "false", "FALSE", "f", "no", "n", "0"].each do |off| + settings.set_local "credential_store", off + + expect(settings.send(:credential_store_spec)).to be_nil, "#{off.inspect} should turn the store off" + end + end + + it "reads the boolean true spellings as this platform's native store" do + %w[true TRUE t yes y on 1].each do |on| + settings.set_local "credential_store", on + + expect(settings.send(:credential_store_spec)).to be(true), "#{on.inspect} should select the native store" + end + end + + it "reads anything else as a backend name, `off` included" do + %w[1password off 1Password].each do |name| + settings.set_local "credential_store", name + + expect(settings.send(:credential_store_spec)).to eq(name) + end + end + + it "lets a host override the global setting" do + settings.set_local "credential_store", "1password" + settings.set_local "credential_store.gemserver.example.org", "false" + + expect(settings.send(:credential_store_spec, "gemserver.example.org")).to be_nil + expect(settings.send(:credential_store_spec, "other.example.org")).to eq("1password") + end + + it "survives an undecodable environment variable" do + # #to_bool refuses these bytes too, not just String#downcase. + without_env_side_effects do + ENV["BUNDLE_CREDENTIAL_STORE"] = "\xff".dup.force_encoding("UTF-8") + + expect(settings.send(:credential_store_spec)).to eq(ENV["BUNDLE_CREDENTIAL_STORE"]) + end + end + end + describe "#credentials_for" do let(:uri) { Gem::URI("https://gemserver.example.org/") } let(:credentials) { "username:password" } From 9c8e1e5d7889915d53206368d83f21d724c8aa5c Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 27 Aug 2026 14:56:27 +0900 Subject: [PATCH 12/15] [ruby/rubygems] Record why the RubyGems half keeps `off` The two sides of one setting now disagree on `off`, so the list has to say why it stays wider here. Nothing on the way in turns `off` into a boolean, and nothing on this side shares the single vocabulary that Bundler's boolean settings all run through. https://github.com/ruby/rubygems/commit/d12e41a382 Co-Authored-By: Claude Opus 5 --- lib/rubygems/config_file.rb | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/rubygems/config_file.rb b/lib/rubygems/config_file.rb index 6e6e180faa727e..976a610d564a64 100644 --- a/lib/rubygems/config_file.rb +++ b/lib/rubygems/config_file.rb @@ -852,11 +852,18 @@ def warn_credential_store_fallback alert_warning "Could not write the API key to the credential store, so it was written to #{credentials_path} in plain text." end - # Anything that reads as a boolean is one, so RUBYGEMS_CREDENTIAL_STORE=0 - # turns the store off rather than naming a backend gem "0". CREDENTIAL_STORE_OFF = %w[false 0 no off f n].freeze CREDENTIAL_STORE_ON = %w[true 1 yes on t y].freeze + #-- + # Anything that reads as a boolean is one, so RUBYGEMS_CREDENTIAL_STORE=0 + # turns the store off rather than naming a backend gem "0". Both lists are + # this setting's own. Nothing on the way here turns `off` into a boolean, so + # a gemrc saying `off` arrives as a String just as the environment variable + # does. Bundler leaves `off` out of its half because every one of its + # boolean settings shares a single vocabulary that has never had it. Nothing + # here shares that constraint. + def normalize_credential_store(value, default) # An environment variable can carry bytes String#downcase would reject. normalized = value.to_s.b.downcase From f6c8c1f7c65344fff97cad069ae7650115a12730 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Fri, 28 Aug 2026 08:37:57 +0900 Subject: [PATCH 13/15] [ruby/rubygems] Keep the credential_store vocabulary lists private They sit after `private`, which does not apply to constants, so both are public API today. Nothing outside the file reads them, and the setting has not shipped, so this is the last release where making them private costs nothing. https://github.com/ruby/rubygems/commit/18c550a324 Co-Authored-By: Claude Opus 5 --- lib/rubygems/config_file.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/rubygems/config_file.rb b/lib/rubygems/config_file.rb index 976a610d564a64..ea6a1cb8ceae06 100644 --- a/lib/rubygems/config_file.rb +++ b/lib/rubygems/config_file.rb @@ -853,7 +853,10 @@ def warn_credential_store_fallback end CREDENTIAL_STORE_OFF = %w[false 0 no off f n].freeze + private_constant :CREDENTIAL_STORE_OFF + CREDENTIAL_STORE_ON = %w[true 1 yes on t y].freeze + private_constant :CREDENTIAL_STORE_ON #-- # Anything that reads as a boolean is one, so RUBYGEMS_CREDENTIAL_STORE=0 From ed0519b71a6f3eced9c770017eb64c3d9a6ef578 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 27 Aug 2026 14:53:41 +0900 Subject: [PATCH 14/15] [ruby/rubygems] Only coerce exact true and false gemrc values to booleans Alternation binds looser than the anchors, so `/\Atrue|false\Z/` reads as `(\Atrue)|(false\Z)` and quietly turned every gemrc string starting with `true` or ending with `false` into the boolean false. `:credential_store: truestore` disabled the store while `RUBYGEMS_CREDENTIAL_STORE=truestore` returned the backend name. The neighbouring branches move to `\z` for the same reason, since `\Z` also accepts a trailing newline and let `":foo\n"` become a symbol that still carried it. https://github.com/ruby/rubygems/commit/3cfada30ba Co-Authored-By: Claude Opus 5 --- lib/rubygems/config_file.rb | 14 +++++++------- test/rubygems/test_gem_config_file.rb | 14 ++++++++++++++ 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/lib/rubygems/config_file.rb b/lib/rubygems/config_file.rb index ea6a1cb8ceae06..0363e03b3b5b30 100644 --- a/lib/rubygems/config_file.rb +++ b/lib/rubygems/config_file.rb @@ -763,13 +763,13 @@ def self.normalize_credentials_key(host) def self.deep_transform_config_keys!(config) config.transform_keys! do |k| - if k.match?(/\A:(.*)\Z/) + if k.match?(/\A:(.*)\z/) k[1..-1].to_sym - elsif k.include?("__") || k.match?(%r{/\Z}) + elsif k.include?("__") || k.end_with?("/") if k.is_a?(Symbol) - k.to_s.gsub(/__/,".").gsub(%r{/\Z}, "").to_sym + k.to_s.gsub(/__/,".").delete_suffix("/").to_sym else - k.dup.gsub(/__/,".").gsub(%r{/\Z}, "") + k.dup.gsub(/__/,".").delete_suffix("/") end else k @@ -778,11 +778,11 @@ def self.deep_transform_config_keys!(config) config.transform_values! do |v| if v.is_a?(String) - if v.match?(/\A:(.*)\Z/) + if v.match?(/\A:(.*)\z/) v[1..-1].to_sym - elsif v.match?(/\A[+-]?\d+\Z/) + elsif v.match?(/\A[+-]?\d+\z/) v.to_i - elsif v.match?(/\Atrue|false\Z/) + elsif v.match?(/\A(?:true|false)\z/) v == "true" elsif v.empty? nil diff --git a/test/rubygems/test_gem_config_file.rb b/test/rubygems/test_gem_config_file.rb index 9e8c48e05ddce6..2c33192a4b3a83 100644 --- a/test/rubygems/test_gem_config_file.rb +++ b/test/rubygems/test_gem_config_file.rb @@ -896,6 +896,20 @@ def test_accept_string_key assert_equal false, @cfg.verbose end + def test_gemrc_coerces_only_exact_boolean_spellings + File.open @temp_conf, "w" do |fp| + fp.puts ":credential_store: truestore" + fp.puts ":ssl_ca_cert: /home/me/certs-false" + fp.puts ":verbose: false" + end + + util_config_file + + assert_equal "truestore", @cfg.credential_store + assert_equal "/home/me/certs-false", @cfg.ssl_ca_cert + assert_equal false, @cfg.verbose + end + def test_load_ssl_verify_mode_from_config File.open @temp_conf, "w" do |fp| fp.puts ":ssl_verify_mode: 1" From 888a9fe269ff9609e9603e292be7d89c4f7cd8ea Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Fri, 28 Aug 2026 08:40:32 +0900 Subject: [PATCH 15/15] [ruby/rubygems] Document :credential_store in the gemrc option list The option has been readable from gemrc since it was added, but the RDoc list of valid gemrc options never gained a row for it. https://github.com/ruby/rubygems/commit/3521da48ae --- lib/rubygems/config_file.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/rubygems/config_file.rb b/lib/rubygems/config_file.rb index 0363e03b3b5b30..e9cd2c6c355429 100644 --- a/lib/rubygems/config_file.rb +++ b/lib/rubygems/config_file.rb @@ -36,6 +36,7 @@ # +:ipv4_fallback_enabled+:: See #ipv4_fallback_enabled # +:global_gem_cache+:: See #global_gem_cache # +:use_psych+:: See #use_psych +# +:credential_store+:: See #credential_store # +:gemhome+:: See #home # +:gempath+:: See #path # +:sources+:: Sets Gem::sources