├── .github └── workflows │ └── ruby.yml ├── .gitignore ├── .rubocop.yml ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── MANIFEST ├── README.md ├── Rakefile ├── bin └── gemirro ├── gemirro.gemspec ├── lib ├── gemirro.rb └── gemirro │ ├── cli.rb │ ├── cli │ ├── index.rb │ ├── init.rb │ ├── list.rb │ ├── server.rb │ └── update.rb │ ├── configuration.rb │ ├── gem.rb │ ├── gem_version.rb │ ├── gem_version_collection.rb │ ├── gems_fetcher.rb │ ├── http.rb │ ├── indexer.rb │ ├── mirror_directory.rb │ ├── mirror_file.rb │ ├── server.rb │ ├── source.rb │ ├── utils.rb │ ├── version.rb │ ├── versions_fetcher.rb │ └── versions_file.rb ├── spec ├── fixtures │ ├── gems │ │ └── gemirro-0.0.1.gem │ └── quick │ │ └── gemirro-0.0.1.gemspec.rz ├── gemirro │ ├── cli_spec.rb │ ├── configuration_spec.rb │ ├── gem_spec.rb │ ├── gem_version_collection_spec.rb │ ├── gem_version_spec.rb │ ├── gems_fetcher_spec.rb │ ├── http_spec.rb │ ├── indexer_spec.rb │ ├── mirror_directory_spec.rb │ ├── mirror_file_spec.rb │ ├── server_spec.rb │ ├── source_spec.rb │ ├── versions_fetcher_spec.rb │ └── versions_file_spec.rb └── spec_helper.rb ├── task ├── manifest.rake ├── rspec.rake └── rubocop.rake ├── template ├── config.rb ├── logs │ └── .gitkeep └── public │ ├── dist │ └── css │ │ └── gemirro.css │ └── gems │ └── .gitkeep └── views ├── gem.erb ├── index.erb ├── layout.erb └── not_found.erb /.github/workflows/ruby.yml: -------------------------------------------------------------------------------- 1 | name: "Gemirro tests" 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | rspec: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | ruby: ['3.0', '3.1', '3.2'] 11 | name: Rspec on Ruby ${{ matrix.ruby }} 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: ruby/setup-ruby@v1 15 | with: 16 | ruby-version: ${{ matrix.ruby }} 17 | bundler-cache: true 18 | - run: bundle exec rspec 19 | 20 | rubocop: 21 | runs-on: ubuntu-latest 22 | name: Run rubocop 23 | steps: 24 | - uses: actions/checkout@v4 25 | - uses: ruby/setup-ruby@v1 26 | with: 27 | ruby-version: '3.2' 28 | bundler-cache: true 29 | - run: bundle exec rubocop -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.project 2 | /coverage 3 | /pkg 4 | /.ruby-version 5 | /*.gem 6 | /.bundle 7 | .DS_Store 8 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | SuggestExtensions: false 3 | NewCops: enable 4 | TargetRubyVersion: 3.0 5 | Include: 6 | - '**/Gemfile' 7 | - lib/**/*.rb 8 | - spec/**/*.rb 9 | - gemirro.gemspec 10 | Exclude: 11 | - files/**/* 12 | - templates/**/* 13 | - spec/**/* 14 | - vendor/**/* 15 | 16 | Naming/FileName: 17 | Exclude: 18 | - Rakefile 19 | Metrics/MethodLength: 20 | Enabled: false 21 | Metrics/ClassLength: 22 | Enabled: false 23 | Metrics/CyclomaticComplexity: 24 | Enabled: false 25 | Metrics/PerceivedComplexity: 26 | Enabled: false 27 | Metrics/AbcSize: 28 | Enabled: false 29 | Metrics/BlockLength: 30 | Enabled: false 31 | Security/MarshalLoad: 32 | Enabled: false 33 | Style/ExpandPathArguments: 34 | Enabled: false 35 | Style/OptionalBooleanParameter: 36 | Enabled: false 37 | Lint/MissingSuper: 38 | Enabled: false 39 | Style/TrailingUnderscoreVariable: 40 | Enabled: false 41 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org/' 4 | 5 | gemspec 6 | 7 | group :development, :test do 8 | gem 'fakefs', '~> 2' 9 | gem 'rack-test', '~> 1.1' 10 | gem 'rake', '~> 13' 11 | gem 'rspec', '~> 3.10' 12 | gem 'rubocop', '~> 1' 13 | gem 'simplecov', '~> 0.21' 14 | end 15 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | gemirro (2.0.0) 5 | addressable (~> 2.8) 6 | builder (~> 3.2) 7 | compact_index (~> 0.15) 8 | confstruct (~> 1.1) 9 | erubis (~> 2.7) 10 | httpclient (~> 2.8) 11 | logger (> 0) 12 | mutex_m (> 0) 13 | ostruct (> 0) 14 | parallel (~> 1.21) 15 | rubygems-generate_index (> 0) 16 | sinatra (>= 3.1, < 4.0) 17 | slop (~> 3.6) 18 | stringio (> 0) 19 | thin (~> 1.8) 20 | 21 | GEM 22 | remote: https://rubygems.org/ 23 | specs: 24 | addressable (2.8.7) 25 | public_suffix (>= 2.0.2, < 7.0) 26 | ast (2.4.2) 27 | base64 (0.2.0) 28 | builder (3.3.0) 29 | compact_index (0.15.0) 30 | confstruct (1.1.0) 31 | hashie (>= 3.3, < 5) 32 | daemons (1.4.1) 33 | diff-lcs (1.5.1) 34 | docile (1.4.1) 35 | erubis (2.7.0) 36 | eventmachine (1.2.7) 37 | fakefs (2.8.0) 38 | hashie (4.1.0) 39 | httpclient (2.8.3) 40 | json (2.9.1) 41 | language_server-protocol (3.17.0.3) 42 | logger (1.6.5) 43 | mustermann (3.0.3) 44 | ruby2_keywords (~> 0.0.1) 45 | mutex_m (0.3.0) 46 | ostruct (0.6.1) 47 | parallel (1.26.3) 48 | parser (3.3.7.0) 49 | ast (~> 2.4.1) 50 | racc 51 | public_suffix (6.0.1) 52 | racc (1.8.1) 53 | rack (2.2.14) 54 | rack-protection (3.2.0) 55 | base64 (>= 0.1.0) 56 | rack (~> 2.2, >= 2.2.4) 57 | rack-test (1.1.0) 58 | rack (>= 1.0, < 3) 59 | rainbow (3.1.1) 60 | rake (13.2.1) 61 | regexp_parser (2.10.0) 62 | rspec (3.13.0) 63 | rspec-core (~> 3.13.0) 64 | rspec-expectations (~> 3.13.0) 65 | rspec-mocks (~> 3.13.0) 66 | rspec-core (3.13.2) 67 | rspec-support (~> 3.13.0) 68 | rspec-expectations (3.13.3) 69 | diff-lcs (>= 1.2.0, < 2.0) 70 | rspec-support (~> 3.13.0) 71 | rspec-mocks (3.13.2) 72 | diff-lcs (>= 1.2.0, < 2.0) 73 | rspec-support (~> 3.13.0) 74 | rspec-support (3.13.2) 75 | rubocop (1.70.0) 76 | json (~> 2.3) 77 | language_server-protocol (>= 3.17.0) 78 | parallel (~> 1.10) 79 | parser (>= 3.3.0.2) 80 | rainbow (>= 2.2.2, < 4.0) 81 | regexp_parser (>= 2.9.3, < 3.0) 82 | rubocop-ast (>= 1.36.2, < 2.0) 83 | ruby-progressbar (~> 1.7) 84 | unicode-display_width (>= 2.4.0, < 4.0) 85 | rubocop-ast (1.37.0) 86 | parser (>= 3.3.1.0) 87 | ruby-progressbar (1.13.0) 88 | ruby2_keywords (0.0.5) 89 | rubygems-generate_index (1.1.3) 90 | compact_index (~> 0.15.0) 91 | simplecov (0.22.0) 92 | docile (~> 1.1) 93 | simplecov-html (~> 0.11) 94 | simplecov_json_formatter (~> 0.1) 95 | simplecov-html (0.13.1) 96 | simplecov_json_formatter (0.1.4) 97 | sinatra (3.2.0) 98 | mustermann (~> 3.0) 99 | rack (~> 2.2, >= 2.2.4) 100 | rack-protection (= 3.2.0) 101 | tilt (~> 2.0) 102 | slop (3.6.0) 103 | stringio (3.1.2) 104 | thin (1.8.2) 105 | daemons (~> 1.0, >= 1.0.9) 106 | eventmachine (~> 1.0, >= 1.0.4) 107 | rack (>= 1, < 3) 108 | tilt (2.6.0) 109 | unicode-display_width (3.1.4) 110 | unicode-emoji (~> 4.0, >= 4.0.4) 111 | unicode-emoji (4.0.4) 112 | 113 | PLATFORMS 114 | ruby 115 | 116 | DEPENDENCIES 117 | fakefs (~> 2) 118 | gemirro! 119 | rack-test (~> 1.1) 120 | rake (~> 13) 121 | rspec (~> 3.10) 122 | rubocop (~> 1) 123 | simplecov (~> 0.21) 124 | 125 | BUNDLED WITH 126 | 2.5.16 127 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . 675 | -------------------------------------------------------------------------------- /MANIFEST: -------------------------------------------------------------------------------- 1 | .github/workflows/ruby.yml 2 | .gitignore 3 | .rubocop.yml 4 | Gemfile 5 | Gemfile.lock 6 | LICENSE 7 | MANIFEST 8 | README.md 9 | Rakefile 10 | bin/gemirro 11 | gemirro.gemspec 12 | lib/gemirro.rb 13 | lib/gemirro/cli.rb 14 | lib/gemirro/cli/index.rb 15 | lib/gemirro/cli/init.rb 16 | lib/gemirro/cli/list.rb 17 | lib/gemirro/cli/server.rb 18 | lib/gemirro/cli/update.rb 19 | lib/gemirro/configuration.rb 20 | lib/gemirro/gem.rb 21 | lib/gemirro/gem_version.rb 22 | lib/gemirro/gem_version_collection.rb 23 | lib/gemirro/gems_fetcher.rb 24 | lib/gemirro/http.rb 25 | lib/gemirro/indexer.rb 26 | lib/gemirro/mirror_directory.rb 27 | lib/gemirro/mirror_file.rb 28 | lib/gemirro/server.rb 29 | lib/gemirro/source.rb 30 | lib/gemirro/utils.rb 31 | lib/gemirro/version.rb 32 | lib/gemirro/versions_fetcher.rb 33 | lib/gemirro/versions_file.rb 34 | spec/fixtures/gems/gemirro-0.0.1.gem 35 | spec/fixtures/quick/gemirro-0.0.1.gemspec.rz 36 | spec/gemirro/cli_spec.rb 37 | spec/gemirro/configuration_spec.rb 38 | spec/gemirro/gem_spec.rb 39 | spec/gemirro/gem_version_collection_spec.rb 40 | spec/gemirro/gem_version_spec.rb 41 | spec/gemirro/gems_fetcher_spec.rb 42 | spec/gemirro/http_spec.rb 43 | spec/gemirro/indexer_spec.rb 44 | spec/gemirro/mirror_directory_spec.rb 45 | spec/gemirro/mirror_file_spec.rb 46 | spec/gemirro/server_spec.rb 47 | spec/gemirro/source_spec.rb 48 | spec/gemirro/versions_fetcher_spec.rb 49 | spec/gemirro/versions_file_spec.rb 50 | spec/spec_helper.rb 51 | task/manifest.rake 52 | task/rspec.rake 53 | task/rubocop.rake 54 | template/config.rb 55 | template/logs/.gitkeep 56 | template/public/dist/css/gemirro.css 57 | template/public/gems/.gitkeep 58 | views/gem.erb 59 | views/index.erb 60 | views/layout.erb 61 | views/not_found.erb -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Gemirro | [![Build Status](https://travis-ci.org/PierreRambaud/gemirro.svg?branch=master)](https://travis-ci.org/PierreRambaud/gemirro) [![Gem Version](https://badge.fury.io/rb/gemirro.svg)](http://badge.fury.io/rb/gemirro) 2 | 3 | Gemirro is a Ruby application that makes it easy way to create your own RubyGems mirror without having to push or write all gem you wanted in a configuration file. 4 | It does mirroring without any authentication and you can add your private gems in the `gems` directory. 5 | More, to mirroring a source, you only need to start the server, and gems will automaticly be downloaded when needed. 6 | 7 | ## Requirements 8 | 9 | * Ruby 3.0 or newer 10 | * Enough space to store Gems 11 | * A recent version of Rubygems (`gem update --system`) 12 | 13 | ## Installation 14 | 15 | Assuming RubyGems isn't down you can install the Gem as following: 16 | 17 | ```bash 18 | $ gem install gemirro 19 | ``` 20 | 21 | ## Usage 22 | 23 | The process of setting up a mirror is fairly easy and can be done in few seconds. 24 | 25 | The first step is to set up a new, empty mirror directory. 26 | This is done by running the `gemirro init` command. 27 | 28 | ```bash 29 | $ gemirro init /srv/http/mirror.com/ 30 | ``` 31 | 32 | Once created you can edit the main configuration file called [config.rb](https://github.com/PierreRambaud/gemirro/blob/master/template/config.rb). 33 | This configuration file specifies what source to mirror, destination directory, server host and port, etc. 34 | 35 | Once configured and if you add gem in the `define_source`, you can pull them by running the following command: 36 | 37 | ```bash 38 | $ gemirro update 39 | ``` 40 | 41 | Once all the Gems have been downloaded you'll need to generate an index of all the installed files. This can be done as following: 42 | 43 | ```bash 44 | $ gemirro index 45 | $ gemirro index --update # Or only update new files 46 | ``` 47 | 48 | Last, launch the server, and all requests will check if gems are detected, and download them if necessary and generate index immediately. 49 | 50 | ```bash 51 | $ gemirro server --start 52 | $ gemirro server --status 53 | $ gemirro server --restart 54 | $ gemirro server --stop 55 | 56 | ``` 57 | 58 | If you want to use a custom configuration file not located in the current directory, use the `-c` or `--config` option. 59 | 60 | ### Available commands 61 | 62 | ``` 63 | Usage: gemirro [COMMAND] [OPTIONS] 64 | 65 | Options: 66 | 67 | -v, --version Shows the current version 68 | -h, --help Display this help message. 69 | 70 | Available commands: 71 | 72 | index Retrieve specs list from source. 73 | init Sets up a new mirror 74 | list List available gems. 75 | server Manage web server 76 | update Updates the list of Gems 77 | 78 | See ` --help` for more information on a specific command. 79 | ``` 80 | 81 | ## Apache configuration 82 | 83 | You must activate the apache `proxy` module. 84 | 85 | ```bash 86 | $ sudo a2enmod proxy 87 | $ sudo a2enmod proxy_http 88 | ``` 89 | 90 | Create your VirtualHost and replace following `http://localhost:2000` with your custom server configuration located in your `config.rb` file and restart Apache. 91 | 92 | ``` 93 | 94 | ServerName mirror.gemirro 95 | ProxyPreserveHost On 96 | ProxyRequests off 97 | ProxyPass / http://localhost:2000/ 98 | ProxyPassReverse / http://localhost:2000/ 99 | 100 | ``` 101 | 102 | ## Nginx configuration 103 | 104 | Replace `localhost:2000` with your custom server configuration located in your `config.rb` file and restart Nginx. 105 | 106 | ``` 107 | upstream gemirro { 108 | server localhost:2000; 109 | } 110 | 111 | server { 112 | server_name rbgems; 113 | 114 | location / { 115 | proxy_pass http://gemirro; 116 | proxy_set_header Host $http_host; 117 | proxy_set_header X-Real-IP $remote_addr; 118 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 119 | } 120 | } 121 | ``` 122 | 123 | ## Known issues 124 | 125 | ### could not find a temporary directory 126 | 127 | If you use ruby >= 2.0, some urls in the server throwing errors telling `could not find a temporary directory`. 128 | You only need to do a `chmod o+t /tmp` 129 | 130 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | 3 | GEMSPEC = Gem::Specification.load('gemirro.gemspec') 4 | 5 | Dir['./task/*.rake'].each do |task| 6 | import(task) 7 | end 8 | 9 | task default: %i[spec rubocop] 10 | -------------------------------------------------------------------------------- /bin/gemirro: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require File.expand_path('../../lib/gemirro', __FILE__) 5 | 6 | options = Gemirro::CLI.options 7 | 8 | begin 9 | puts options if options.parse.empty? 10 | rescue Slop::InvalidOptionError => e 11 | puts e.message 12 | puts options 13 | end 14 | -------------------------------------------------------------------------------- /gemirro.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'date' 4 | require File.expand_path('../lib/gemirro/version', __FILE__) 5 | 6 | Gem::Specification.new do |s| 7 | s.name = 'gemirro' 8 | s.version = Gemirro::VERSION 9 | s.authors = ['Pierre Rambaud'] 10 | s.email = 'pierre.rambaud86@gmail.com' 11 | s.license = 'GPL-3.0' 12 | s.summary = 'Gem for easily creating your own gems mirror.' 13 | s.homepage = 'https://github.com/PierreRambaud/gemirro' 14 | s.description = 'Create your own gems mirror.' 15 | s.executables = ['gemirro'] 16 | 17 | s.files = File.read(File.expand_path('../MANIFEST', __FILE__)).split("\n") 18 | 19 | s.required_ruby_version = '>= 3.0' 20 | 21 | s.add_dependency 'addressable', '~>2.8' 22 | s.add_dependency 'builder', '~>3.2' 23 | s.add_dependency 'compact_index', '~> 0.15' 24 | s.add_dependency 'confstruct', '~>1.1' 25 | s.add_dependency 'erubis', '~>2.7' 26 | s.add_dependency 'httpclient', '~>2.8' 27 | s.add_dependency 'logger', '> 0' # stdlib 28 | s.add_dependency 'mutex_m', '> 0' # stdlib 29 | s.add_dependency 'ostruct', '> 0' # stdlib 30 | s.add_dependency 'parallel', '~>1.21' 31 | s.add_dependency 'rubygems-generate_index', '> 0' # stdlib 32 | s.add_dependency 'sinatra', '>=3.1', '<4.0' 33 | s.add_dependency 'slop', '~>3.6' 34 | s.add_dependency 'stringio', '> 0' # stdlib 35 | s.add_dependency 'thin', '~>1.8' 36 | 37 | s.metadata['rubygems_mfa_required'] = 'true' 38 | end 39 | -------------------------------------------------------------------------------- /lib/gemirro.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'builder' 4 | require 'confstruct' 5 | require 'digest/sha2' 6 | require 'fileutils' 7 | require 'httpclient' 8 | require 'json' 9 | require 'logger' 10 | require 'parallel' 11 | require 'rubygems' 12 | require 'rubygems/indexer' 13 | require 'rubygems/user_interaction' 14 | require 'slop' 15 | require 'stringio' 16 | require 'tempfile' 17 | require 'zlib' 18 | 19 | $LOAD_PATH.unshift(File.expand_path('../', __FILE__)) unless $LOAD_PATH.include?(File.expand_path('../', __FILE__)) 20 | 21 | require 'gemirro/version' 22 | require 'gemirro/configuration' 23 | require 'gemirro/utils' 24 | require 'gemirro/gem' 25 | require 'gemirro/gem_version' 26 | require 'gemirro/gem_version_collection' 27 | require 'gemirro/http' 28 | require 'gemirro/indexer' 29 | require 'gemirro/source' 30 | require 'gemirro/mirror_directory' 31 | require 'gemirro/mirror_file' 32 | require 'gemirro/versions_file' 33 | require 'gemirro/versions_fetcher' 34 | require 'gemirro/gems_fetcher' 35 | 36 | require 'gemirro/cli' 37 | require 'gemirro/cli/index' 38 | require 'gemirro/cli/init' 39 | require 'gemirro/cli/list' 40 | require 'gemirro/cli/server' 41 | require 'gemirro/cli/update' 42 | -------------------------------------------------------------------------------- /lib/gemirro/cli.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require File.expand_path('../version', __FILE__) 4 | 5 | module Gemirro 6 | # CLI mode 7 | module CLI 8 | ## 9 | # Hash containing the default Slop options. 10 | # 11 | # @return [Hash] 12 | # 13 | SLOP_OPTIONS = { 14 | strict: true, 15 | help: true, 16 | banner: 'Usage: gemirro [COMMAND] [OPTIONS]' 17 | }.freeze 18 | 19 | ## 20 | # @return [Slop] 21 | # 22 | def self.options 23 | @options ||= default_options 24 | end 25 | 26 | ## 27 | # Loads the specified configuration file or displays an error if it doesn't 28 | # exist. 29 | # 30 | # @param [String] config_file 31 | # @return [Gemirro::Configuration] 32 | # 33 | def self.load_configuration(config_file) 34 | config_file ||= Configuration.default_configuration_file 35 | config_file = File.expand_path(config_file, Dir.pwd) 36 | config_file += '/config.rb' unless config_file.end_with?('.rb') || 37 | !File.directory?(config_file) 38 | 39 | abort "The configuration file #{config_file} does not exist" unless File.file?(config_file) 40 | 41 | require(config_file) 42 | end 43 | 44 | ## 45 | # @return [Slop] 46 | # 47 | def self.default_options 48 | Slop.new(SLOP_OPTIONS.dup) do 49 | separator "\nOptions:\n" 50 | 51 | on :v, :version, 'Shows the current version' do 52 | puts CLI.version_information 53 | end 54 | end 55 | end 56 | 57 | ## 58 | # Returns a String containing some platform/version related information. 59 | # 60 | # @return [String] 61 | # 62 | def self.version_information 63 | "gemirro v#{Gemirro::VERSION} on #{RUBY_DESCRIPTION}" 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/gemirro/cli/index.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Gemirro::CLI.options.command 'index' do 4 | banner 'Usage: gemirro index [OPTIONS]' 5 | description 'Retrieve specs list from source.' 6 | separator "\nOptions:\n" 7 | 8 | on :c=, :config=, 'Path to the configuration file' 9 | on :l=, :log_level=, 'Set logger level' 10 | on :u, :update, 'Update only' 11 | 12 | run do |opts, _args| 13 | Gemirro::CLI.load_configuration(opts[:c]) 14 | config = Gemirro.configuration 15 | config.logger_level = opts[:l] if opts[:l] 16 | 17 | unless File.directory?(config.destination) 18 | config.logger.error("The directory #{config.destination} does not exist") 19 | abort 20 | end 21 | 22 | indexer = Gemirro::Indexer.new 23 | indexer.ui = Gem::SilentUI.new 24 | 25 | if File.exist?(File.join(config.versions_file)) 26 | indexer.download_source_versions 27 | if opts[:u] 28 | config.logger.info('Generating index updates') 29 | indexer.update_index 30 | else 31 | config.logger.info('Generating indexes') 32 | indexer.generate_index 33 | end 34 | else 35 | config.logger.error("#{File.basename(config.versions_file)} file is missing.") 36 | config.logger.error('Run "gemirro update" before running index.') 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/gemirro/cli/init.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Gemirro::CLI.options.command 'init' do 4 | banner 'Usage: gemirro init [DIRECTORY] [OPTIONS]' 5 | description 'Sets up a new mirror' 6 | separator "\nOptions:\n" 7 | 8 | on :force, 'Force overwrite' 9 | 10 | run do |opts, args| 11 | directory = File.expand_path(args[0] || Dir.pwd) 12 | template = Gemirro::Configuration.template_directory 13 | 14 | Dir.mkdir(directory) unless File.directory?(directory) 15 | 16 | if opts[:force] 17 | FileUtils.cp_r(File.join(template, '.'), directory) 18 | else 19 | Dir.glob("#{template}/**/*", File::FNM_DOTMATCH).each do |file| 20 | next if ['.', '..'].include?(File.basename(file)) 21 | 22 | dest = File.join(directory, file.gsub(/^#{template}/, '')) 23 | next if File.exist?(dest) && dest !~ /gemirro.css/ 24 | 25 | FileUtils.cp_r(file, dest) 26 | end 27 | end 28 | 29 | puts "Initialized empty mirror in #{directory}" 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/gemirro/cli/list.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Gemirro::CLI.options.command 'list' do 4 | banner 'Usage: gemirro list [OPTIONS]' 5 | description 'List available gems.' 6 | separator "\nOptions:\n" 7 | 8 | on :c=, :config=, 'Path to the configuration file' 9 | on :l=, :log_level=, 'Set logger level' 10 | 11 | run do |opts, _args| 12 | Gemirro::CLI.load_configuration(opts[:c]) 13 | config = Gemirro.configuration 14 | config.logger_level = opts[:l] if opts[:l] 15 | 16 | unless File.directory?(config.destination) 17 | config.logger.error("The directory #{config.destination} does not exist") 18 | abort 19 | end 20 | 21 | gems = Gemirro::Utils.gems_collection.group_by(&:name).sort 22 | gems.each do |name, versions| 23 | puts "#{name}: (#{versions.map(&:number).join(', ')})" 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/gemirro/cli/server.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Gemirro::CLI.options.command 'server' do 4 | banner 'Usage: gemirro server [OPTIONS]' 5 | description 'Manage web server' 6 | separator "\nOptions:\n" 7 | 8 | on :start, 'Run web server' 9 | on :stop, 'Stop web server' 10 | on :restart, 'Restart web server' 11 | on :status, 'Status of web server' 12 | on :c=, :config=, 'Path to the configuration file' 13 | on :l=, :log_level=, 'Set logger level' 14 | 15 | @pid_file = nil 16 | 17 | run do |opts, _args| 18 | load_configuration(opts) 19 | start if opts[:start] 20 | stop if opts[:stop] 21 | restart if opts[:restart] 22 | status if opts[:status] 23 | end 24 | 25 | def load_configuration(opts) 26 | Gemirro::CLI.load_configuration(opts[:c]) 27 | config = Gemirro.configuration 28 | config.logger_level = opts[:l] if opts[:l] 29 | unless File.directory?(config.destination) 30 | config.logger.error("The directory #{config.destination} does not exist") 31 | abort 32 | end 33 | 34 | @pid_file = File.expand_path(File.join(config.destination, 35 | '..', 36 | 'gemirro.pid')) 37 | require 'gemirro/server' 38 | end 39 | 40 | # Copy stdout because we'll need to reopen it later on 41 | @orig_stdout = $stdout.clone 42 | $PROGRAM_NAME = 'gemirro' 43 | 44 | def create_pid 45 | File.write(@pid_file, Process.pid.to_s) 46 | rescue Errno::EACCES 47 | $stdout.reopen @orig_stdout 48 | puts "Error: Can't write to #{@pid_file} - Permission denied" 49 | exit! 50 | end 51 | 52 | def destroy_pid 53 | File.delete(@pid_file) if File.exist?(@pid_file) && pid == Process.pid 54 | end 55 | 56 | def pid 57 | File.open(@pid_file, 'r') do |f| 58 | return f.gets.to_i 59 | end 60 | rescue Errno::ENOENT 61 | puts "Error: PID File not found #{@pid_file}" 62 | end 63 | 64 | def start 65 | puts 'Starting...' 66 | if File.exist?(@pid_file) && running?(pid) 67 | puts "Error: #{$PROGRAM_NAME} already running" 68 | abort 69 | end 70 | 71 | Process.daemon if Gemirro::Utils.configuration.server.daemonize 72 | create_pid 73 | $stdout.reopen @orig_stdout 74 | puts "done! (PID is #{pid})\n" 75 | Gemirro::Server.run! 76 | destroy_pid 77 | $stdout.reopen File::NULL, 'a' 78 | end 79 | 80 | def stop 81 | process_pid = pid 82 | return if process_pid.nil? 83 | 84 | begin 85 | Process.kill('TERM', process_pid) 86 | Timeout.timeout(30) { sleep 0.1 while running?(process_pid) } 87 | rescue Errno::ESRCH 88 | puts "Error: Couldn't find process with PID #{process_pid}" 89 | exit! 90 | rescue Timeout::Error 91 | puts 'timeout while sending TERM signal, sending KILL signal now... ' 92 | Process.kill('KILL', process_pid) 93 | destroy_pid 94 | end 95 | puts 'done!' 96 | end 97 | 98 | def restart 99 | stop 100 | start 101 | end 102 | 103 | def status 104 | if running?(pid) 105 | puts "#{$PROGRAM_NAME} is running" 106 | else 107 | puts "#{$PROGRAM_NAME} is not running" 108 | abort 109 | end 110 | end 111 | 112 | def running?(process_id) 113 | return false if process_id.nil? 114 | 115 | Process.getpgid(process_id.to_i) != -1 116 | rescue Errno::ESRCH 117 | false 118 | end 119 | end 120 | -------------------------------------------------------------------------------- /lib/gemirro/cli/update.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Gemirro::CLI.options.command 'update' do 4 | banner 'Usage: gemirro update [OPTIONS]' 5 | description 'Updates the list of Gems' 6 | separator "\nOptions:\n" 7 | 8 | on :c=, :config=, 'Path to the configuration file' 9 | on :l=, :log_level=, 'Set logger level' 10 | 11 | run do |opts, _args| 12 | Gemirro::CLI.load_configuration(opts[:c]) 13 | config.logger_level = opts[:l] if opts[:l] 14 | 15 | source = Gemirro.configuration.source 16 | versions = Gemirro::VersionsFetcher.new(source).fetch 17 | gems = Gemirro::GemsFetcher.new(source, versions) 18 | 19 | gems.fetch 20 | 21 | source.gems.each do |gem| 22 | gem.gemspec = true 23 | end 24 | 25 | gems.fetch 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/gemirro/configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Configuration 4 | module Gemirro 5 | ## 6 | # @return [Gemirro::Configuration] 7 | # 8 | def self.configuration 9 | default_config = { 10 | server: { 11 | access_log: '/tmp/gemirro.access.log', 12 | error_log: '/tmp/gemirro.access.log', 13 | daemonize: true 14 | }, 15 | update_thread_count: begin; Etc.nprocessors - 1; rescue StandardError; 4; end, 16 | update_on_fetch: true, 17 | fetch_gem: true 18 | } 19 | @configuration ||= Configuration.new(default_config) 20 | end 21 | 22 | ## 23 | # Configuration class used for storing data about a mirror such as the 24 | # destination directory, source, ignored Gems, etc. 25 | # 26 | class Configuration < Confstruct::Configuration 27 | attr_accessor :source 28 | attr_writer :logger 29 | 30 | LOGGER_LEVEL = { 31 | 'debug' => Logger::DEBUG, 32 | 'warning' => Logger::WARN, 33 | 'info' => Logger::INFO, 34 | 'unknown' => Logger::UNKNOWN, 35 | 'error' => Logger::ERROR, 36 | 'fatal' => Logger::FATAL 37 | }.freeze 38 | 39 | ## 40 | # Returns the logger 41 | # 42 | # @return [Logger] 43 | # 44 | def logger 45 | @logger ||= Logger.new($stdout) 46 | end 47 | 48 | ## 49 | # Set log level 50 | # 51 | # @param [string] 52 | # 53 | # @return [Logger] 54 | # 55 | def logger_level=(level) 56 | logger.level = LOGGER_LEVEL[level] if LOGGER_LEVEL.key?(level) 57 | logger 58 | end 59 | 60 | ## 61 | # Returns the template path to init directory 62 | # 63 | # @return [String] 64 | # 65 | def self.template_directory 66 | File.expand_path('../../../template', __FILE__) 67 | end 68 | 69 | ## 70 | # Returns the views path to render templates 71 | # 72 | # @return [String] 73 | # 74 | def self.views_directory 75 | File.expand_path('../../../views', __FILE__) 76 | end 77 | 78 | ## 79 | # Returns default configuration file path 80 | # 81 | # @return [String] 82 | # 83 | def self.default_configuration_file 84 | File.expand_path('config.rb', Dir.pwd) 85 | end 86 | 87 | ## 88 | # Returns the name of the directory that contains the quick 89 | # specification files. 90 | # 91 | # @return [String] 92 | # 93 | def self.marshal_identifier 94 | "Marshal.#{marshal_version}" 95 | end 96 | 97 | ## 98 | # Returns the name of the file that contains an index of all the versions. 99 | # 100 | # @return [String] 101 | # 102 | def versions_file 103 | return unless @source 104 | 105 | File.expand_path("#{URI.parse(@source.host).host.gsub('.', '_')}_versions", destination.to_s) 106 | end 107 | 108 | ## 109 | # Returns a String containing the Marshal version. 110 | # 111 | # @return [String] 112 | # 113 | def self.marshal_version 114 | "#{Marshal::MAJOR_VERSION}.#{Marshal::MINOR_VERSION}" 115 | end 116 | 117 | ## 118 | # Return mirror directory 119 | # 120 | # @return [Gemirro::MirrorDirectory] 121 | # 122 | def mirror_gems_directory 123 | @mirror_gems_directory ||= MirrorDirectory.new(gems_directory) 124 | end 125 | 126 | ## 127 | # Returns gems directory 128 | # 129 | # @return [String] 130 | # 131 | def gems_directory 132 | File.join(destination.to_s, 'gems') 133 | end 134 | 135 | ## 136 | # Return mirror directory 137 | # 138 | # @return [Gemirro::MirrorDirectory] 139 | # 140 | def mirror_gemspecs_directory 141 | @mirror_gemspecs_directory ||= MirrorDirectory.new(gemspecs_directory) 142 | end 143 | 144 | ## 145 | # Returns gems directory 146 | # 147 | # @return [String] 148 | # 149 | def gemspecs_directory 150 | File.join(destination.to_s, 'quick', self.class.marshal_identifier) 151 | end 152 | 153 | ## 154 | # Returns a Hash containing various Gems to ignore and their versions. 155 | # 156 | # @return [Hash] 157 | # 158 | def ignored_gems 159 | @ignored_gems ||= Hash.new { |hash, key| hash[key] = {} } 160 | end 161 | 162 | ## 163 | # Adds a Gem to the list of Gems to ignore. 164 | # 165 | # @param [String] name 166 | # @param [String] version 167 | # 168 | def ignore_gem(name, version, platform) 169 | ignored_gems[platform] ||= {} 170 | ignored_gems[platform][name] ||= [] 171 | ignored_gems[platform][name] << version 172 | end 173 | 174 | ## 175 | # Checks if a Gem should be ignored. 176 | # 177 | # @param [String] name 178 | # @param [String] version 179 | # @return [TrueClass|FalseClass] 180 | # 181 | def ignore_gem?(name, version, platform) 182 | if ignored_gems[platform][name] 183 | ignored_gems[platform][name].include?(version) 184 | else 185 | false 186 | end 187 | end 188 | 189 | ## 190 | # Define the source to mirror. 191 | # 192 | # @param [String] name 193 | # @param [String] url 194 | # @param [Proc] block 195 | # 196 | def define_source(name, url, &block) 197 | source = Source.new(name, url) 198 | source.instance_eval(&block) 199 | 200 | @source = source 201 | end 202 | end 203 | end 204 | -------------------------------------------------------------------------------- /lib/gemirro/gem.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gemirro 4 | ## 5 | # The Gem class contains data about a Gem such as the name, requirement as 6 | # well as providing some methods to more easily extract the specific version 7 | # number. 8 | # 9 | # @!attribute [r] name 10 | # @return [String] 11 | # @!attribute [r] requirement 12 | # @return [Gem::Requirement] 13 | # @!attribute [r] version 14 | # @return [Gem::Version] 15 | # 16 | class Gem 17 | attr_reader :name, :requirement 18 | attr_accessor :gemspec, :platform 19 | 20 | ONLY_LATEST = %i[latest newest].freeze 21 | 22 | ## 23 | # Returns a `Gem::Version` instance based on the specified requirement. 24 | # 25 | # @param [Gem::Requirement] requirement 26 | # @return [Gem::Version] 27 | # 28 | def self.version_for(requirement) 29 | ::Gem::Version.new(requirement.requirements.max.last.version) 30 | end 31 | 32 | ## 33 | # @param [String] name 34 | # @param [Gem::Requirement|String] requirement 35 | # 36 | def initialize(name, requirement = nil, platform = 'ruby') 37 | requirement ||= ::Gem::Requirement.default 38 | 39 | requirement = ::Gem::Requirement.new(requirement) if requirement.is_a?(String) 40 | 41 | @name = name 42 | @requirement = requirement 43 | @platform = platform 44 | end 45 | 46 | ## 47 | # Returns the version 48 | # 49 | # @return [Gem::Version] 50 | # 51 | def version 52 | @version ||= self.class.version_for(requirement) 53 | end 54 | 55 | ## 56 | # Define if version exists 57 | # 58 | # @return [TrueClass|FalseClass] 59 | # 60 | def version? 61 | version && !version.segments.reject(&:zero?).empty? 62 | end 63 | 64 | ## 65 | # Define if version exists 66 | # 67 | # @return [TrueClass|FalseClass] 68 | # 69 | def only_latest? 70 | @requirement.is_a?(Symbol) && ONLY_LATEST.include?(@requirement) 71 | end 72 | 73 | ## 74 | # Is gemspec 75 | # 76 | # @return [TrueClass|FalseClass] 77 | # 78 | def gemspec? 79 | @gemspec == true 80 | end 81 | 82 | ## 83 | # Returns the filename of the gem file. 84 | # 85 | # @param [String] gem_version 86 | # @return [String] 87 | # 88 | def filename(gem_version = nil) 89 | gem_version ||= version.to_s 90 | n = [name, gem_version] 91 | n.push(@platform) if @platform != 'ruby' 92 | "#{n.join('-')}.gem" 93 | end 94 | 95 | ## 96 | # Returns the filename of the gemspec file. 97 | # 98 | # @param [String] gem_version 99 | # @return [String] 100 | # 101 | def gemspec_filename(gem_version = nil) 102 | gem_version ||= version.to_s 103 | n = [name, gem_version] 104 | n.push(@platform) if @platform != 'ruby' 105 | "#{n.join('-')}.gemspec.rz" 106 | end 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /lib/gemirro/gem_version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gemirro 4 | ## 5 | # The Gem class contains data about a Gem such as the name, requirement as 6 | # well as providing some methods to more easily extract the specific version 7 | # number. 8 | # 9 | # @!attribute [r] name 10 | # @return [String] 11 | # @!attribute [r] number 12 | # @return [Integer] 13 | # @!attribute [r] platform 14 | # @return [String] 15 | # @!attribute [r] version 16 | # @return [Gem::Version] 17 | # 18 | class GemVersion 19 | include Comparable 20 | attr_reader :name, :number, :platform 21 | 22 | ## 23 | # @param [String] name 24 | # @param [String] number 25 | # @param [String] platform 26 | # 27 | def initialize(name, number, platform) 28 | @name = name 29 | @number = number 30 | @platform = platform 31 | end 32 | 33 | ## 34 | # Is for ruby 35 | # 36 | # @return [Boolean] 37 | # 38 | def ruby? 39 | !(@platform =~ /^ruby$/i).nil? 40 | end 41 | 42 | ## 43 | # Retrieve gem version 44 | # 45 | # @return [Gem::Version] 46 | # 47 | def version 48 | @version ||= ::Gem::Version.create(number) 49 | end 50 | 51 | ## 52 | # Compare gem to another 53 | # 54 | # @return [Integer] 55 | # 56 | def <=>(other) 57 | sort = other.name <=> @name 58 | sort = version <=> other.version if sort.zero? 59 | sort = other.ruby? && !ruby? ? 1 : -1 if sort.zero? && 60 | ruby? != other.ruby? 61 | sort = other.platform <=> @platform if sort.zero? 62 | 63 | sort 64 | end 65 | 66 | ## 67 | # Gemfile name 68 | # 69 | # @return [String] 70 | # 71 | def gemfile_name 72 | platform = ruby? ? nil : @platform 73 | [@name, @number, platform].compact.join('-') 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /lib/gemirro/gem_version_collection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gemirro 4 | ## 5 | # The VersionCollection class contains a collection of ::Gem::Version 6 | # 7 | # @!attribute [r] gems 8 | # @return [Array] 9 | # @!attribute [r] grouped 10 | # @return [Array] 11 | # 12 | class GemVersionCollection 13 | include Enumerable 14 | 15 | attr_reader :gems, :grouped 16 | 17 | ## 18 | # @param [Array] gems 19 | # 20 | def initialize(gems = []) 21 | @gems = gems.map do |object| 22 | if object.is_a?(GemVersion) 23 | object 24 | else 25 | GemVersion.new(*object) 26 | end 27 | end 28 | 29 | @gems.sort_by!(&:version) 30 | end 31 | 32 | ## 33 | # Return oldest version of a gem 34 | # 35 | # @return [GemVersion] 36 | # 37 | def oldest 38 | @gems.first 39 | end 40 | 41 | ## 42 | # Return newest version of a gem 43 | # 44 | # @return [GemVersion] 45 | # 46 | def newest 47 | @gems.last 48 | end 49 | 50 | ## 51 | # Return size of a gem 52 | # 53 | # @return [Integer] 54 | # 55 | def size 56 | @gems.size 57 | end 58 | 59 | ## 60 | # Each method 61 | # 62 | def each(&block) 63 | @gems.each(&block) 64 | end 65 | 66 | ## 67 | # Group gems by name 68 | # 69 | # @param [Proc] block 70 | # @return [Array] 71 | # 72 | def by_name(&block) 73 | if @grouped.nil? 74 | @grouped = @gems.group_by(&:name).map do |name, collection| 75 | [name, GemVersionCollection.new(collection)] 76 | end 77 | 78 | @grouped.reject! do |name, _collection| 79 | name.nil? 80 | end 81 | 82 | @grouped.sort_by! do |name, _collection| 83 | name.downcase 84 | end 85 | end 86 | 87 | if block_given? 88 | @grouped.each(&block) 89 | else 90 | @grouped 91 | end 92 | end 93 | 94 | ## 95 | # Find gem by name 96 | # 97 | # @param [String] gemname 98 | # @return [Array] 99 | # 100 | def find_by_name(gemname) 101 | gem = by_name.select do |name, _collection| 102 | name == gemname 103 | end 104 | 105 | gem.first.last if gem.any? 106 | end 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /lib/gemirro/gems_fetcher.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gemirro 4 | ## 5 | # The GemsFetcher class is responsible for downloading Gems from an external 6 | # source. 7 | # 8 | # @!attribute [r] source 9 | # @return [Source] 10 | # @!attribute [r] versions_file 11 | # @return [Gemirro::VersionsFile] 12 | # 13 | class GemsFetcher 14 | attr_reader :source, :versions_file 15 | 16 | ## 17 | # @param [Source] source 18 | # @param [Gemirro::VersionsFile] versions_file 19 | # 20 | def initialize(source, versions_file) 21 | @source = source 22 | @versions_file = versions_file 23 | end 24 | 25 | ## 26 | # Fetches the Gems. 27 | # 28 | def fetch 29 | @source.gems.each do |gem| 30 | versions_for(gem).each do |versions| 31 | gem.platform = versions[1] if versions 32 | version = versions[0] if versions 33 | if gem.gemspec? 34 | gemspec = fetch_gemspec(gem, version) 35 | if gemspec 36 | Utils.configuration.mirror_gemspecs_directory 37 | .add_file(gem.gemspec_filename(version), gemspec) 38 | end 39 | else 40 | gemfile = fetch_gem(gem, version) 41 | if gemfile 42 | Utils.configuration.mirror_gems_directory 43 | .add_file(gem.filename(version), gemfile) 44 | end 45 | end 46 | end 47 | end 48 | end 49 | 50 | ## 51 | # Returns an Array containing the versions that should be fetched for a 52 | # Gem. 53 | # 54 | # @param [Gemirro::Gem] gem 55 | # @return [Array] 56 | # 57 | def versions_for(gem) 58 | return [] unless @versions_file 59 | 60 | available = @versions_file.versions_for(gem.name) 61 | return [available.last] if gem.only_latest? 62 | 63 | versions = available.select do |v| 64 | gem.requirement.satisfied_by?(v[0]) 65 | end 66 | 67 | versions = [available.last] if versions.empty? 68 | 69 | versions 70 | end 71 | 72 | ## 73 | # Tries to download gemspec from a given name and version 74 | # 75 | # @param [Gemirro::Gem] gem 76 | # @param [Gem::Version] version 77 | # @return [String] 78 | # 79 | def fetch_gemspec(gem, version) 80 | filename = gem.gemspec_filename(version) 81 | satisfied = if gem.only_latest? 82 | true 83 | else 84 | gem.requirement.satisfied_by?(version) 85 | end 86 | 87 | if gemspec_exists?(filename) || !satisfied 88 | Utils.logger.debug("Skipping #{filename}") 89 | return 90 | end 91 | 92 | Utils.logger.info("Fetching #{filename}") 93 | fetch_from_source(filename, gem, version, true) 94 | end 95 | 96 | ## 97 | # Tries to download the gem file from a given nam and version 98 | # 99 | # @param [Gemirro::Gem] gem 100 | # @param [Gem::Version] version 101 | # @return [String] 102 | # 103 | def fetch_gem(gem, version) 104 | filename = gem.filename(version) 105 | satisfied = gem.only_latest? || gem.requirement.satisfied_by?(version) 106 | name = gem.name 107 | 108 | if gem_exists?(filename) || ignore_gem?(name, version, gem.platform) || !satisfied 109 | Utils.logger.debug("Skipping #{filename}") 110 | return 111 | end 112 | 113 | Utils.configuration.ignore_gem(gem.name, version, gem.platform) 114 | Utils.logger.info("Fetching #{filename}") 115 | 116 | fetch_from_source(filename, gem, version) 117 | end 118 | 119 | ## 120 | # 121 | # @param [String] filename 122 | # @param [Gemirro::Gem] gem 123 | # @param [Gem::Version] version 124 | # @return [String] 125 | # 126 | def fetch_from_source(filename, gem, version, gemspec = false) 127 | data = nil 128 | begin 129 | data = @source.fetch_gem(filename) unless gemspec 130 | data = @source.fetch_gemspec(filename) if gemspec 131 | rescue StandardError => e 132 | filename = gem.filename(version) 133 | Utils.logger.error("Failed to retrieve #{filename}: #{e.message}") 134 | Utils.logger.debug("Adding #{filename} to the list of ignored Gems") 135 | 136 | Utils.configuration.ignore_gem(gem.name, version, gem.platform) 137 | end 138 | 139 | data 140 | end 141 | 142 | ## 143 | # Checks if a given Gem has already been downloaded. 144 | # 145 | # @param [String] filename 146 | # @return [TrueClass|FalseClass] 147 | # 148 | def gem_exists?(filename) 149 | Utils.configuration.mirror_gems_directory.file_exists?(filename) 150 | end 151 | 152 | ## 153 | # Checks if a given Gemspec has already been downloaded. 154 | # 155 | # @param [String] filename 156 | # @return [TrueClass|FalseClass] 157 | # 158 | def gemspec_exists?(filename) 159 | Utils.configuration.mirror_gemspecs_directory.file_exists?(filename) 160 | end 161 | 162 | ## 163 | # @see Gemirro::Configuration#ignore_gem? 164 | # 165 | def ignore_gem?(*args) 166 | Utils.configuration.ignore_gem?(*args) 167 | end 168 | end 169 | end 170 | -------------------------------------------------------------------------------- /lib/gemirro/http.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gemirro 4 | ## 5 | # The Http class is responsible for executing GET request 6 | # to a specific url and return an response as an HTTP::Message 7 | # 8 | # @!attribute [r] client 9 | # @return [HTTPClient] 10 | # 11 | class Http 12 | attr_accessor :client 13 | 14 | ## 15 | # Requests the given HTTP resource. 16 | # 17 | # @param [String] url 18 | # @return [HTTP::Message] 19 | # 20 | def self.get(url) 21 | response = client.get(url, follow_redirect: true) 22 | 23 | raise HTTPClient::BadResponseError, response.reason unless HTTP::Status.successful?(response.status) 24 | 25 | response 26 | end 27 | 28 | ## 29 | # @return [HTTPClient] 30 | # 31 | def self.client 32 | client ||= HTTPClient.new 33 | config = Utils.configuration 34 | if defined?(config.upstream_user) 35 | user = config.upstream_user 36 | password = config.upstream_password 37 | domain = config.upstream_domain 38 | client.set_auth(domain, user, password) 39 | end 40 | 41 | if defined?(config.proxy) 42 | proxy = config.proxy 43 | client.proxy = (proxy) 44 | end 45 | 46 | # Use my own ca file for self signed cert 47 | if defined?(config.rootca) 48 | abort "The configuration file #{config.rootca} does not exist" unless File.file?(config.rootca) 49 | client.ssl_config.verify_mode = OpenSSL::SSL::VERIFY_PEER 50 | client.ssl_config.set_trust_ca(config.rootca) 51 | elsif defined?(config.verify_mode) 52 | client.ssl_config.verify_mode = OpenSSL::SSL::VERIFY_NONE unless config.verify_mode 53 | end 54 | 55 | # Enforce base auth 56 | if defined?(config.basic_auth) && config.basic_auth 57 | client.www_auth.basic_auth.force_auth = (true) 58 | end 59 | @client = client 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/gemirro/indexer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gemirro 4 | ## 5 | # The Indexer class is responsible for downloading useful file directly 6 | # on the source host, such as specs-*.*.gz, marshal information, etc... 7 | # 8 | # @!attribute [r] files 9 | # @return [Array] 10 | # @!attribute [r] quick_marshal_dir 11 | # @return [String] 12 | # @!attribute [r] directory 13 | # @return [String] 14 | # @!attribute [r] dest_directory 15 | # @return [String] 16 | # @!attribute [r] only_origin 17 | # @return [Boolean] 18 | # @!attribute [r] updated_gems 19 | # @return [Array] 20 | # 21 | class Indexer < ::Gem::Indexer 22 | attr_accessor( 23 | :files, 24 | :quick_marshal_dir, 25 | :directory, 26 | :dest_directory, 27 | :only_origin, 28 | :updated_gems 29 | ) 30 | 31 | ## 32 | # Create an indexer that will index the gems in +directory+. 33 | # 34 | # @param [String] directory Destination directory 35 | # @param [Hash] options Indexer options 36 | # @return [Array] 37 | ## 38 | def initialize(options = {}) 39 | require 'fileutils' 40 | require 'tmpdir' 41 | require 'zlib' 42 | require 'builder/xchar' 43 | require 'compact_index' 44 | 45 | options.merge!({ build_modern: false }) 46 | 47 | @dest_directory = Gemirro.configuration.destination 48 | @directory = 49 | File.join(Dir.tmpdir, "gem_generate_index_#{rand(1_000_000_000)}") 50 | 51 | marshal_name = "Marshal.#{::Gem.marshal_version}" 52 | 53 | @master_index = 54 | File.join(@directory, 'yaml') 55 | @marshal_index = 56 | File.join(@directory, marshal_name) 57 | 58 | @quick_dir = File.join(@directory, 'quick') 59 | @quick_marshal_dir = 60 | File.join(@quick_dir, marshal_name) 61 | @quick_marshal_dir_base = 62 | File.join(@dest_directory, 'quick', marshal_name) # FIX: UGH 63 | 64 | @quick_index = 65 | File.join(@quick_dir, 'index') 66 | @latest_index = 67 | File.join(@quick_dir, 'latest_index') 68 | 69 | @latest_specs_index = 70 | File.join(@directory, "latest_specs.#{::Gem.marshal_version}") 71 | @dest_latest_specs_index = 72 | File.join(@dest_directory, "latest_specs.#{::Gem.marshal_version}") 73 | @infos_dir = 74 | File.join(@dest_directory, 'info') 75 | 76 | @files = [] 77 | end 78 | 79 | ## 80 | # Generate indices on the destination directory 81 | # 82 | # @return [Array] 83 | # 84 | def install_indices 85 | Utils.logger 86 | .debug("Downloading index into production dir #{@dest_directory}") 87 | 88 | files = @files 89 | files.delete @quick_marshal_dir if files.include? @quick_dir 90 | 91 | if files.include?(@quick_marshal_dir) && !files.include?(@quick_dir) 92 | files.delete @quick_marshal_dir 93 | FileUtils.mkdir_p(File.dirname(@quick_marshal_dir_base), verbose: verbose) 94 | if @quick_marshal_dir_base && File.exist?(@quick_marshal_dir_base) 95 | FileUtils.rm_rf(@quick_marshal_dir_base, verbose: verbose) 96 | end 97 | FileUtils.mv(@quick_marshal_dir, @quick_marshal_dir_base, verbose: verbose, force: true) 98 | end 99 | 100 | files.each do |path| 101 | file = path.sub(%r{^#{Regexp.escape @directory}/?}, '') 102 | 103 | source_content = download_from_source(file) 104 | next if source_content.nil? 105 | 106 | MirrorFile.new(File.join(@dest_directory, file)).write(source_content) 107 | 108 | FileUtils.rm_rf(path) 109 | end 110 | end 111 | 112 | ## 113 | # Download file from source (example: rubygems.org) 114 | # 115 | # @param [String] file File path 116 | # @return [String] 117 | # 118 | def download_from_source(file) 119 | source_host = Gemirro.configuration.source.host 120 | Utils.logger.info("Download from source #{source_host}/#{file}") 121 | resp = Http.get("#{source_host}/#{File.basename(file)}") 122 | return unless resp.code == 200 123 | 124 | resp.body 125 | end 126 | 127 | ## 128 | # Build indices 129 | # 130 | # @return [Array] 131 | # 132 | def build_indices 133 | specs = *map_gems_to_specs(gem_file_list) 134 | specs.select! { |s| s.instance_of?(::Gem::Specification) } 135 | ::Gem::Specification.dirs = [] 136 | ::Gem::Specification.all = specs 137 | 138 | build_marshal_gemspecs(specs) 139 | 140 | build_compact_index_names 141 | build_compact_index_infos(specs) 142 | build_compact_index_versions(specs) 143 | end 144 | 145 | ## 146 | # Cache compact_index endpoint /names 147 | # Report all gems with versions available. Does not require opening spec files. 148 | # 149 | # @return nil 150 | # 151 | def build_compact_index_names 152 | Utils.logger.info('[1/1]: Caching /names') 153 | FileUtils.rm_rf(Dir.glob(File.join(@dest_directory, 'names*.list'))) 154 | 155 | gem_name_list = Dir.glob('*.gem', base: File.join(@dest_directory, 'gems')).collect do |x| 156 | x.sub(/-\d+(\.\d+)*(\.[a-zA-Z\d]+)*([-_a-zA-Z\d]+)?\.gem/, '') 157 | end.uniq.sort! 158 | 159 | Tempfile.create('names.list') do |f| 160 | f.write CompactIndex.names(gem_name_list).to_s 161 | f.rewind 162 | FileUtils.cp( 163 | f.path, 164 | File.join( 165 | @dest_directory, 166 | "names.#{Digest::MD5.file(f.path).hexdigest}.#{Digest::SHA256.file(f.path).hexdigest}.list" 167 | ), 168 | verbose: verbose 169 | ) 170 | end 171 | 172 | nil 173 | end 174 | 175 | ## 176 | # Cache compact_index endpoint /versions 177 | # 178 | # @param [Array] specs Gems list 179 | # @param [Boolean] partial Is gem list an update or a full index 180 | # @return nil 181 | # 182 | def build_compact_index_versions(specs, partial = false) 183 | Utils.logger.info('[1/1]: Caching /versions') 184 | 185 | cg = 186 | specs 187 | .sort_by(&:name) 188 | .group_by(&:name) 189 | .collect do |name, gem_versions| 190 | gem_versions = 191 | gem_versions.sort do |a, b| 192 | a.version <=> b.version 193 | end 194 | 195 | info_file = Dir.glob(File.join(@infos_dir, "#{name}.*.*.list")).last 196 | 197 | throw "Info file for #{name} not found" unless info_file 198 | 199 | info_file_checksum = info_file.split('.', -4)[-3] 200 | 201 | CompactIndex::Gem.new( 202 | name, 203 | gem_versions.collect do |y| 204 | CompactIndex::GemVersion.new( 205 | y.version.to_s, 206 | y.platform, 207 | nil, 208 | info_file_checksum 209 | ) 210 | end 211 | ) 212 | end 213 | 214 | Tempfile.create('versions.list') do |f| 215 | previous_versions_file = Dir.glob(File.join(@dest_directory, 'versions.*.*.list')).last 216 | 217 | if partial && previous_versions_file 218 | versions_file = CompactIndex::VersionsFile.new(previous_versions_file) 219 | else 220 | versions_file = CompactIndex::VersionsFile.new(f.path) 221 | f.write format('created_at: %s', Time.now.utc.iso8601) 222 | f.write "\n---\n" 223 | end 224 | 225 | f.write CompactIndex.versions(versions_file, cg) 226 | f.rewind 227 | 228 | FileUtils.rm_rf(Dir.glob(File.join(@dest_directory, 'versions.*.*.list'))) 229 | 230 | FileUtils.cp( 231 | f.path, 232 | File.join( 233 | @dest_directory, 234 | "versions.#{Digest::MD5.file(f.path).hexdigest}.#{Digest::SHA256.file(f.path).hexdigest}.list" 235 | ), 236 | verbose: verbose 237 | ) 238 | end 239 | 240 | nil 241 | end 242 | 243 | ## 244 | # Cache compact_index endpoint /info/[gemname] 245 | # 246 | # @param [Array] specs Gems list 247 | # @param [Boolean] partial Is gem list an update or a full index 248 | # @return nil 249 | # 250 | def build_compact_index_infos(specs, partial = false) 251 | FileUtils.mkdir_p(@infos_dir, verbose: verbose) 252 | 253 | if partial 254 | specs.collect(&:name).uniq do |name| 255 | FileUtils.rm_rf(Dir.glob(File.join(@infos_dir, "#{name}.*.*.list"))) 256 | end 257 | else 258 | FileUtils.rm_rf(Dir.glob(File.join(@infos_dir, '*.list'))) 259 | end 260 | 261 | grouped_specs = specs.sort_by(&:name).group_by(&:name) 262 | grouped_specs.each_with_index do |(name, gem_versions), index| 263 | Utils.logger.info("[#{index + 1}/#{grouped_specs.size}]: Caching /info/#{name}") 264 | 265 | gem_versions = 266 | gem_versions.sort do |a, b| 267 | a.version <=> b.version 268 | end 269 | 270 | versions = 271 | Parallel.map(gem_versions, in_threads: Utils.configuration.update_thread_count) do |spec| 272 | deps = 273 | spec 274 | .dependencies 275 | .select { |d| d.type == :runtime } 276 | .sort_by(&:name) 277 | .collect do |dependency| 278 | CompactIndex::Dependency.new( 279 | dependency.name, 280 | dependency.requirement.to_s 281 | ) 282 | end 283 | 284 | CompactIndex::GemVersion.new( 285 | spec.version, 286 | spec.platform, 287 | Digest::SHA256.file(spec.loaded_from).hexdigest, 288 | nil, 289 | deps, 290 | spec.required_ruby_version.to_s, 291 | spec.required_rubygems_version.to_s 292 | ) 293 | end 294 | 295 | Tempfile.create("info_#{name}.list") do |f| 296 | f.write CompactIndex.info(versions).to_s 297 | f.rewind 298 | 299 | FileUtils.cp( 300 | f.path, 301 | File.join( 302 | @infos_dir, 303 | "#{name}.#{Digest::MD5.file(f.path).hexdigest}.#{Digest::SHA256.file(f.path).hexdigest}.list" 304 | ), 305 | verbose: verbose 306 | ) 307 | end 308 | end 309 | 310 | nil 311 | end 312 | 313 | ## 314 | # Map gems file to specs 315 | # 316 | # @param [Array] gems Gems list 317 | # @return [Array] 318 | # 319 | def map_gems_to_specs(gems) 320 | results = [] 321 | 322 | Parallel.each_with_index(gems, in_threads: Utils.configuration.update_thread_count) do |gemfile, index| 323 | Utils.logger.info("[#{index + 1}/#{gems.size}]: Processing #{gemfile.split('/')[-1]}") 324 | if File.empty?(gemfile) 325 | Utils.logger.warn("Skipping zero-length gem: #{gemfile}") 326 | next 327 | end 328 | 329 | begin 330 | begin 331 | spec = 332 | if ::Gem::Package.respond_to? :open 333 | ::Gem::Package.open(File.open(gemfile, 'rb'), 'r', &:metadata) 334 | else 335 | ::Gem::Package.new(gemfile).spec 336 | end 337 | rescue NotImplementedError 338 | next 339 | end 340 | 341 | spec.loaded_from = gemfile 342 | 343 | # HACK: fuck this shit - borks all tests that use pl1 344 | if File.basename(gemfile, '.gem') != spec.original_name 345 | exp = spec.full_name 346 | exp << " (#{spec.original_name})" if 347 | spec.original_name != spec.full_name 348 | msg = "Skipping misnamed gem: #{gemfile} should be named #{exp}" 349 | Utils.logger.warn(msg) 350 | next 351 | end 352 | 353 | version = spec.version.version 354 | unless version =~ /^\d+(\.\d+)?(\.\d+)?.*/ 355 | msg = "Skipping gem #{spec.full_name} - invalid version #{version}" 356 | Utils.logger.warn(msg) 357 | next 358 | end 359 | 360 | spec.abbreviate 361 | spec.sanitize 362 | 363 | spec 364 | rescue SignalException 365 | msg = 'Received signal, exiting' 366 | Utils.logger.error(msg) 367 | raise 368 | rescue StandardError => e 369 | msg = ["Unable to process #{gemfile}", 370 | "#{e.message} (#{e.class})", 371 | "\t#{e.backtrace.join "\n\t"}"].join("\n") 372 | Utils.logger.debug(msg) 373 | end 374 | 375 | results[index] = spec 376 | end 377 | 378 | # nils can result from insert by index 379 | results.compact 380 | end 381 | 382 | ## 383 | # Handle `index --update`, detecting changed files and file lists. 384 | # 385 | # @return nil 386 | # 387 | def update_index 388 | make_temp_directories 389 | 390 | present_gemfiles = Dir.glob('*.gem', base: File.join(@dest_directory, 'gems')) 391 | indexed_gemfiles = Dir.glob('*.gemspec.rz', base: @quick_marshal_dir_base).collect { |x| x.gsub(/spec.rz$/, '') } 392 | 393 | @updated_gems = [] 394 | 395 | # detect files manually added to public/gems 396 | @updated_gems += (present_gemfiles - indexed_gemfiles).collect { |x| File.join(@dest_directory, 'gems', x) } 397 | # detect files manually deleted from public/gems 398 | @updated_gems += (indexed_gemfiles - present_gemfiles).collect { |x| File.join(@dest_directory, 'gems', x) } 399 | 400 | versions_mtime = 401 | begin 402 | File.stat(Dir.glob(File.join(@dest_directory, 'versions*.list')).last).mtime 403 | rescue StandardError 404 | Time.at(0) 405 | end 406 | newest_mtime = Time.at(0) 407 | 408 | # files that have been replaced 409 | @updated_gems += 410 | gem_file_list.select do |gem| 411 | gem_mtime = File.stat(gem).mtime 412 | newest_mtime = gem_mtime if gem_mtime > newest_mtime 413 | gem_mtime > versions_mtime 414 | end 415 | 416 | @updated_gems.uniq! 417 | 418 | if @updated_gems.empty? 419 | Utils.logger.info('No new gems') 420 | terminate_interaction(0) 421 | end 422 | 423 | specs = map_gems_to_specs(@updated_gems) 424 | 425 | # specs only includes latest discovered files. 426 | # /info/[gemname] can not be rebuilt 427 | # incrementally, so retrive specs for all versions of these gems. 428 | gem_name_updates = specs.collect(&:name).uniq 429 | u2 = 430 | Dir.glob(File.join(File.join(@dest_directory, 'gems'), '*.gem')).select do |possibility| 431 | gem_name_updates.any? { |updated| File.basename(possibility) =~ /^#{updated}-\d/ } 432 | end 433 | 434 | Utils.logger.info('Reloading for /info/[gemname]') 435 | version_specs = map_gems_to_specs(u2) 436 | 437 | ::Gem::Specification.dirs = [] 438 | ::Gem::Specification.all = *specs 439 | build_marshal_gemspecs specs 440 | 441 | build_compact_index_infos(version_specs, true) 442 | build_compact_index_versions(specs, true) 443 | build_compact_index_names 444 | end 445 | 446 | def download_source_versions 447 | Tempfile.create(File.basename(Gemirro.configuration.versions_file)) do |f| 448 | f.write(download_from_source('versions')) 449 | f.close 450 | 451 | FileUtils.rm(Gemirro.configuration.versions_file, verbose: verbose) 452 | FileUtils.cp( 453 | f.path, 454 | Gemirro.configuration.versions_file, 455 | verbose: verbose 456 | ) 457 | end 458 | end 459 | 460 | def verbose 461 | @verbose ||= ::Gem.configuration.really_verbose 462 | end 463 | end 464 | end 465 | -------------------------------------------------------------------------------- /lib/gemirro/mirror_directory.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gemirro 4 | ## 5 | # The MirrorDirectory is used for dealing with files and directories that are 6 | # mirrored from an external source. 7 | # 8 | # @!attribute [r] path 9 | # @return [String] 10 | # 11 | class MirrorDirectory 12 | attr_reader :path 13 | 14 | ## 15 | # @param [String] path 16 | # 17 | def initialize(path) 18 | @path = path 19 | end 20 | 21 | ## 22 | # Creates directory or directories with the given path. 23 | # 24 | # @param [String] dir_path 25 | # @return [Gemirro::MirrorDirectory] 26 | # 27 | def add_directory(dir_path) 28 | full_path = File.join(@path, dir_path) 29 | FileUtils.mkdir_p(full_path) unless File.directory?(full_path) 30 | 31 | self.class.new(full_path) 32 | end 33 | 34 | ## 35 | # Creates a new file with the given name and content. 36 | # 37 | # @param [String] name 38 | # @param [String] content 39 | # @return [Gem::MirrorFile] 40 | # 41 | def add_file(name, content) 42 | full_path = File.join(@path, name) 43 | file = MirrorFile.new(full_path) 44 | 45 | file.write(content) 46 | 47 | file 48 | end 49 | 50 | ## 51 | # Checks if a given file exists in the current directory. 52 | # 53 | # @param [String] name 54 | # @return [TrueClass|FalseClass] 55 | # 56 | def file_exists?(name) 57 | File.file?(File.join(@path, name)) 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/gemirro/mirror_file.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gemirro 4 | ## 5 | # Similar to {Gemirro::MirrorDirectory} the MirrorFile class is used to 6 | # make it easier to read and write data in a directory that mirrors data from 7 | # an external source. 8 | # 9 | # @!attribute [r] path 10 | # @return [String] 11 | # 12 | class MirrorFile 13 | attr_reader :path 14 | 15 | ## 16 | # @param [String] path 17 | # 18 | def initialize(path) 19 | @path = path 20 | end 21 | 22 | ## 23 | # Writes the specified content to the current file. Existing files are 24 | # overwritten. 25 | # 26 | # @param [String] content 27 | # 28 | def write(content) 29 | FileUtils.mkdir_p(File.dirname(@path)) 30 | handle = File.open(@path, 'w') 31 | 32 | handle.write(content) 33 | handle.close 34 | end 35 | 36 | ## 37 | # Reads the content of the current file. 38 | # 39 | # @return [String] 40 | # 41 | def read 42 | handle = File.open(@path, 'r') 43 | content = handle.read 44 | 45 | handle.close 46 | 47 | content 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/gemirro/server.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'sinatra/base' 4 | require 'thin' 5 | require 'uri' 6 | require 'addressable/uri' 7 | require 'base64' 8 | 9 | module Gemirro 10 | ## 11 | # Launch Sinatra server to easily download gems. 12 | # 13 | class Server < Sinatra::Base 14 | access_logger = Logger.new(Utils.configuration.server.access_log).tap do |logger| 15 | ::Logger.class_eval { alias_method :write, :<< } 16 | logger.level = ::Logger::INFO 17 | end 18 | 19 | error_logger = File.new(Utils.configuration.server.error_log, 'a+') 20 | error_logger.sync = true 21 | 22 | before do 23 | env['rack.errors'] = error_logger 24 | Utils.configuration.logger = access_logger 25 | end 26 | 27 | ## 28 | # Configure server 29 | # 30 | configure do 31 | config = Utils.configuration 32 | config.server.host = 'localhost' if config.server.host.nil? 33 | config.server.port = '2000' if config.server.port.nil? 34 | 35 | set :static, true 36 | 37 | set :views, Gemirro::Configuration.views_directory 38 | set :port, config.server.port 39 | set :bind, config.server.host 40 | set :public_folder, config.destination.gsub(%r{/$}, '') 41 | set :environment, config.environment 42 | set :dump_errors, true 43 | set :raise_errors, true 44 | 45 | enable :logging 46 | use Rack::CommonLogger, access_logger 47 | end 48 | 49 | ## 50 | # Set template for not found action 51 | # 52 | # @return [nil] 53 | # 54 | not_found do 55 | content_type 'text/html' 56 | erb(:not_found) 57 | end 58 | 59 | ## 60 | # Display information about one gem, human readable 61 | # 62 | # @return [nil] 63 | # 64 | get('/gem/:gemname') do 65 | gems = Utils.gems_collection 66 | gem = gems.find_by_name(params[:gemname]) 67 | return not_found if gem.nil? 68 | 69 | erb(:gem, {}, gem: gem) 70 | end 71 | 72 | ## 73 | # Display home page containing the list of gems already 74 | # downloaded on the server, human readable 75 | # 76 | # @return [nil] 77 | # 78 | get('/') do 79 | erb(:index, {}, gems: Utils.gems_collection) 80 | end 81 | 82 | ## 83 | # compact_index, Return list of available gem names 84 | # 85 | # @return [nil] 86 | # 87 | get '/names' do 88 | content_type 'text/plain' 89 | 90 | content_path = Dir.glob(File.join(Gemirro.configuration.destination, 'names.*.*.list')).last 91 | _, etag, repr_digest, _ = File.basename(content_path).split('.') 92 | 93 | headers 'etag' => %("#{etag}") 94 | headers 'repr-digest' => %(sha-256=#{Base64.strict_encode64([repr_digest].pack('H*'))}) 95 | send_file content_path 96 | end 97 | 98 | ## 99 | # compact_index, Return list of gem, including versions 100 | # 101 | # @return [nil] 102 | # 103 | get '/versions' do 104 | content_type 'text/plain' 105 | 106 | content_path = Dir.glob(File.join(Utils.configuration.destination, 'versions.*.*.list')).last 107 | _, etag, repr_digest, _ = File.basename(content_path).split('.') 108 | 109 | headers 'etag' => %("#{etag}") 110 | headers 'repr-digest' => %(sha-256=#{Base64.strict_encode64([repr_digest].pack('H*'))}) 111 | send_file content_path 112 | end 113 | 114 | # compact_index, Return gem dependencies for all versions of a gem 115 | # 116 | # @return [nil] 117 | # 118 | get('/info/:gemname') do 119 | gems = Utils.gems_collection 120 | gem = gems.find_by_name(params[:gemname]) 121 | return not_found if gem.nil? 122 | 123 | content_type 'text/plain' 124 | 125 | content_path = Dir.glob(File.join(Utils.configuration.destination, 'info', "#{params[:gemname]}.*.*.list")).last 126 | _, etag, repr_digest, _ = File.basename(content_path).split('.') 127 | 128 | headers 'etag' => %("#{etag}") 129 | headers 'repr-digest' => %(sha-256=#{Base64.strict_encode64([repr_digest].pack('H*'))}) 130 | send_file content_path 131 | end 132 | 133 | ## 134 | # Try to get all request and download files 135 | # if files aren't found. 136 | # 137 | # @return [nil] 138 | # 139 | get('*') do |path| 140 | resource = "#{settings.public_folder}#{path}" 141 | 142 | # Try to download gem 143 | Gemirro::Utils.fetch_gem(resource) unless File.exist?(resource) 144 | # If not found again, return a 404 145 | return not_found unless File.exist?(resource) 146 | 147 | send_file(resource) 148 | end 149 | 150 | ## 151 | # Compile fragments for /api/v1/dependencies 152 | # 153 | # @return [nil] 154 | # 155 | def dependencies_loader(names) 156 | names.collect do |name| 157 | f = File.join(settings.public_folder, 'api', 'v1', 'dependencies', "#{name}.*.*.list") 158 | Marshal.load(File.read(Dir.glob(f).last)) 159 | rescue StandardError => e 160 | env['rack.errors'].write "Cound not open #{f}\n" 161 | env['rack.errors'].write "#{e.message}\n" 162 | e.backtrace.each do |err| 163 | env['rack.errors'].write "#{err}\n" 164 | end 165 | nil 166 | end 167 | .flatten 168 | .compact 169 | end 170 | end 171 | end 172 | -------------------------------------------------------------------------------- /lib/gemirro/source.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gemirro 4 | ## 5 | # The Source class is used for storing information about an external source 6 | # such as the name and the Gems to mirror. 7 | # 8 | # @!attribute [r] name 9 | # @return [String] 10 | # @!attribute [r] host 11 | # @return [String] 12 | # @!attribute [r] gems 13 | # @return [Array] 14 | # 15 | class Source 16 | attr_reader :name, :host, :gems 17 | 18 | ## 19 | # @param [String] name 20 | # @param [String] host 21 | # @param [Array] gems 22 | # 23 | def initialize(name, host, gems = []) 24 | @name = name.downcase.gsub(/\s+/, '_') 25 | @host = host.chomp('/') 26 | @gems = gems 27 | end 28 | 29 | ## 30 | # Fetches a list of all the available Gems and their versions. 31 | # 32 | # @return [String] 33 | # 34 | def fetch_versions 35 | Utils.logger.info( 36 | "Fetching versions on #{@name} (#{@host})" 37 | ) 38 | 39 | Http.get("#{host}/versions").body 40 | end 41 | 42 | ## 43 | # Fetches the `.gem` file of a given Gem and version. 44 | # 45 | # @param [String] name 46 | # @param [String] version 47 | # @return [String] 48 | # 49 | def fetch_gem(filename) 50 | Utils.logger.info( 51 | "Fetching gem #{filename} on #{@host}" 52 | ) 53 | Http.get(host + "/gems/#{filename}").body 54 | end 55 | 56 | ## 57 | # Fetches the `.gemspec.rz` file of a given Gem and version. 58 | # 59 | # @param [String] filename 60 | # @return [String] 61 | # 62 | def fetch_gemspec(filename) 63 | Utils.logger.info( 64 | "Fetching gemspec #{filename} on #{@host}" 65 | ) 66 | marshal = Gemirro::Configuration.marshal_identifier 67 | Http.get(host + "/quick/#{marshal}/#{filename}").body 68 | end 69 | 70 | ## 71 | # Adds a new Gem to the source. 72 | # 73 | # @param [String] name 74 | # @param [String] requirement 75 | # 76 | def gem(name, requirement = nil) 77 | gems << Gem.new(name, requirement) 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /lib/gemirro/utils.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'gemirro/gem_version' 4 | 5 | module Gemirro 6 | ## 7 | # The Utils class is responsible for executing specific traitments 8 | # that are located at least on two other files 9 | # 10 | # @!attribute [r] client 11 | # @return [HTTPClient] 12 | # @!attribute [r] versions_fetcher 13 | # @return [VersionsFetcher] 14 | # @!attribute [r] gems_fetcher 15 | # @return [Gemirro::GemsFetcher] 16 | # 17 | class Utils 18 | attr_reader( 19 | :versions_fetcher, 20 | :gems_fetcher, 21 | :gems_collection, 22 | :stored_gems 23 | ) 24 | 25 | URI_REGEXP = /^(.*)-(\d+(?:\.\d+){1,4}.*?)(?:-(x86-(?:(?:mswin|mingw)(?:32|64)).*?|java))?\.(gem(?:spec\.rz)?)$/ 26 | GEMSPEC_TYPE = 'gemspec.rz' 27 | GEM_TYPE = 'gem' 28 | 29 | ## 30 | # Generate Gems collection from Marshal dump - always the .local file 31 | # 32 | # @return [Gemirro::GemVersionCollection] 33 | # 34 | def self.gems_collection 35 | @gems_collection ||= { files: {}, values: nil } 36 | 37 | file_paths = 38 | Dir.glob(File.join( 39 | Gemirro.configuration.destination, 40 | 'versions.*.*.list' 41 | )) 42 | 43 | has_file_changed = 44 | @gems_collection[:files] != file_paths.each_with_object({}) do |f, r| 45 | r[f] = File.mtime(f) if File.exist?(f) 46 | end 47 | 48 | # Return result if no file changed 49 | return @gems_collection[:values] if !has_file_changed && !@gems_collection[:values].nil? 50 | 51 | gems = [] 52 | 53 | CompactIndex::VersionsFile.new(file_paths.last).contents.each_line.with_index do |line, index| 54 | next if index < 2 55 | 56 | gem_name = line.split[0] 57 | versions = line.split[1..-2].collect { |x| x.split(',') }.flatten # All except first and last 58 | 59 | versions.each do |ver| 60 | version, platform = 61 | if ver.include?('-') 62 | ver.split('-', 2) 63 | else 64 | [ver, 'ruby'] 65 | end 66 | 67 | gems << Gemirro::GemVersion.new(gem_name, version, platform) 68 | end 69 | end 70 | 71 | @gems_collection[:values] = GemVersionCollection.new(gems) 72 | end 73 | 74 | ## 75 | # @see Gemirro::Configuration#logger 76 | # @return [Logger] 77 | # 78 | def self.logger 79 | configuration.logger 80 | end 81 | 82 | ## 83 | # @see Gemirro.configuration 84 | # 85 | def self.configuration 86 | Gemirro.configuration 87 | end 88 | 89 | ## 90 | # @see Gemirro::VersionsFetcher.fetch 91 | # 92 | def self.versions_fetcher 93 | @versions_fetcher ||= Gemirro::VersionsFetcher.new(configuration.source).fetch 94 | end 95 | 96 | ## 97 | # @return [Gemirro::GemsFetcher] 98 | # 99 | def self.gems_fetcher 100 | @gems_fetcher ||= Gemirro::GemsFetcher 101 | .new(configuration.source, versions_fetcher) 102 | end 103 | 104 | ## 105 | # Try to cache gem classes 106 | # 107 | # @param [String] gem_name Gem name 108 | # @return [Gem] 109 | # 110 | def self.stored_gem(gem_name, gem_version, platform = 'ruby') 111 | platform = 'ruby' if platform.nil? 112 | @stored_gems ||= {} 113 | @stored_gems[gem_name] = {} unless @stored_gems.key?(gem_name) 114 | @stored_gems[gem_name][gem_version] = {} unless @stored_gems[gem_name].key?(gem_version) 115 | unless @stored_gems[gem_name][gem_version].key?(platform) 116 | @stored_gems[gem_name][gem_version][platform] ||= Gem.new(gem_name, gem_version, platform) 117 | end 118 | 119 | @stored_gems[gem_name][gem_version][platform] 120 | end 121 | 122 | ## 123 | # Return gem specification from gemname and version 124 | # 125 | # @param [String] gemname 126 | # @param [String] version 127 | # @return [::Gem::Specification] 128 | # 129 | def self.spec_for(gemname, version, platform) 130 | gem = Utils.stored_gem(gemname, version.to_s, platform) 131 | 132 | spec_file = 133 | File.join( 134 | configuration.destination, 135 | 'quick', 136 | Gemirro::Configuration.marshal_identifier, 137 | gem.gemspec_filename 138 | ) 139 | 140 | fetch_gem(spec_file) unless File.exist?(spec_file) 141 | 142 | # this is a separate action 143 | return unless File.exist?(spec_file) 144 | 145 | File.open(spec_file, 'r') do |uz_file| 146 | uz_file.binmode 147 | inflater = Zlib::Inflate.new 148 | begin 149 | inflate_data = inflater.inflate(uz_file.read) 150 | ensure 151 | inflater.finish 152 | inflater.close 153 | end 154 | 155 | Marshal.load(inflate_data) 156 | end 157 | end 158 | 159 | ## 160 | # Try to fetch gem and download its if it's possible, and 161 | # build and install indicies. 162 | # 163 | # @param [String] resource 164 | # @return [Indexer] 165 | # 166 | def self.fetch_gem(resource) 167 | return unless Utils.configuration.fetch_gem 168 | 169 | name = File.basename(resource) 170 | result = name.match(URI_REGEXP) 171 | return unless result 172 | 173 | gem_name, gem_version, gem_platform, gem_type = result.captures 174 | return unless gem_name && gem_version 175 | 176 | begin 177 | gem = Utils.stored_gem(gem_name, gem_version, gem_platform) 178 | gem.gemspec = true if gem_type == GEMSPEC_TYPE 179 | 180 | return if Utils.gems_fetcher.gem_exists?(gem.filename(gem_version)) && gem_type == GEM_TYPE 181 | return if Utils.gems_fetcher.gemspec_exists?(gem.gemspec_filename(gem_version)) && gem_type == GEMSPEC_TYPE 182 | 183 | Utils.logger.info("Try to download #{gem_name} with version #{gem_version}") 184 | Utils.gems_fetcher.source.gems.clear 185 | Utils.gems_fetcher.source.gems.push(gem) 186 | Utils.gems_fetcher.fetch 187 | 188 | update_indexes if Utils.configuration.update_on_fetch 189 | rescue StandardError => e 190 | Utils.logger.error(e) 191 | end 192 | end 193 | 194 | ## 195 | # Update indexes files 196 | # 197 | # @return [Indexer] 198 | # 199 | def self.update_indexes 200 | indexer = Gemirro::Indexer.new 201 | indexer.only_origin = true 202 | indexer.ui = ::Gem::SilentUI.new 203 | 204 | Utils.logger.info('Generating indexes') 205 | indexer.update_index 206 | rescue SystemExit => e 207 | Utils.logger.info(e.message) 208 | end 209 | end 210 | end 211 | -------------------------------------------------------------------------------- /lib/gemirro/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Gemirro Version 4 | module Gemirro 5 | VERSION = '2.0.0' 6 | end 7 | -------------------------------------------------------------------------------- /lib/gemirro/versions_fetcher.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gemirro 4 | ## 5 | # The VersionsFetcher class is used for retrieving the file that contains all 6 | # registered Gems and their versions. 7 | # 8 | # @!attribute [r] source 9 | # @return [Source] 10 | # 11 | class VersionsFetcher 12 | attr_reader :source 13 | 14 | ## 15 | # @param [Source] source 16 | # 17 | def initialize(source) 18 | @source = source 19 | end 20 | 21 | ## 22 | # @return [Gemirro::VersionsFile] 23 | # 24 | def fetch 25 | return unless Gemirro.configuration.versions_file 26 | 27 | VersionsFile.new(read_file(Gemirro.configuration.versions_file)) 28 | end 29 | 30 | ## 31 | # Read file if exists otherwise download its from source 32 | # 33 | # @param [String] file name 34 | # 35 | def read_file(file) 36 | unless File.exist?(file) 37 | throw 'No source defined' unless @source 38 | 39 | File.write(file, @source.fetch_versions) 40 | end 41 | 42 | File.read(file) 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/gemirro/versions_file.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gemirro 4 | ## 5 | # The VersionsFile class acts as a small Ruby wrapper around the RubyGems 6 | # file that contains all Gems and their associated versions. 7 | # 8 | # @!attribute [r] versions 9 | # @return [Array] 10 | # @!attribute [r] versions_hash 11 | # @return [Hash] 12 | # 13 | class VersionsFile 14 | attr_reader :versions_string, :versions_hash 15 | 16 | ## 17 | # Reads the versions file from the specified String. 18 | # 19 | # @param [String] versions_content 20 | # @return [Gemirro::VersionsFile] 21 | # 22 | 23 | ## 24 | # @param [String] versions 25 | # 26 | def initialize(versions_string) 27 | unless versions_string.is_a? String 28 | throw "#{versions_string.class} is wrong format, expect String; #{versions_string.inspect}" 29 | end 30 | 31 | @versions_string = versions_string 32 | @versions_hash = create_versions_hash 33 | end 34 | 35 | ## 36 | # Creates a Hash based on the Array containing all versions. This Hash is 37 | # used to more easily (and faster) iterate over all the gems/versions. 38 | # 39 | # @return [Hash] 40 | # 41 | def create_versions_hash 42 | hash = Hash.new { |h, k| h[k] = [] } 43 | 44 | versions_string.each_line.with_index do |line, index| 45 | next if index < 2 46 | 47 | parts = line.split 48 | gem_name = parts[0] 49 | parts[-1] 50 | versions = parts[1..-2].collect { |x| x.split(',') }.flatten # All except first and last 51 | 52 | versions.each do |ver| 53 | version, platform = 54 | if ver.include?('-') 55 | ver.split('-', 2) 56 | else 57 | [ver, 'ruby'] 58 | end 59 | hash[gem_name] << [gem_name, ::Gem::Version.new(version), platform] 60 | end 61 | end 62 | hash 63 | end 64 | 65 | ## 66 | # Returns an Array containing all the available versions for a Gem. 67 | # 68 | # @param [String] gem 69 | # @return [Array] 70 | # 71 | def versions_for(gem) 72 | versions_hash[gem].map { |version| [version[1], version[2]] } 73 | end 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /spec/fixtures/gems/gemirro-0.0.1.gem: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PierreRambaud/gemirro/b5ffe34c99daadb1f4bdddb5d354cf51043a9088/spec/fixtures/gems/gemirro-0.0.1.gem -------------------------------------------------------------------------------- /spec/fixtures/quick/gemirro-0.0.1.gemspec.rz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PierreRambaud/gemirro/b5ffe34c99daadb1f4bdddb5d354cf51043a9088/spec/fixtures/quick/gemirro-0.0.1.gemspec.rz -------------------------------------------------------------------------------- /spec/gemirro/cli_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'gemirro/cli' 3 | require 'gemirro/mirror_file' 4 | require 'slop' 5 | 6 | # Gemirro tests 7 | module Gemirro 8 | # CLI tests 9 | module CLI 10 | describe 'CLI' do 11 | include FakeFS::SpecHelpers 12 | 13 | it 'should return options' do 14 | options = CLI.options 15 | expect(options).to be_a(::Slop) 16 | expect(options.config[:strict]).to be_truthy 17 | expect(options.config[:banner]) 18 | .to eq('Usage: gemirro [COMMAND] [OPTIONS]') 19 | expect(options.to_s) 20 | .to match(/-v, --version(\s+)Shows the current version/) 21 | expect(options.to_s) 22 | .to match(/-h, --help(\s+)Display this help message./) 23 | 24 | version = options.fetch_option(:v) 25 | expect(version.short).to eq('v') 26 | expect(version.long).to eq('version') 27 | expect { version.call }.to output(/gemirro v.* on ruby/).to_stdout 28 | end 29 | 30 | it 'should retrieve version information' do 31 | expect(CLI.version_information).to eq( 32 | "gemirro v#{VERSION} on #{RUBY_DESCRIPTION}" 33 | ) 34 | end 35 | 36 | it 'should raise SystemExit if file does not exists' do 37 | allow(CLI).to receive(:abort) 38 | .with('The configuration file /config.rb does not exist') 39 | .and_raise SystemExit 40 | expect { CLI.load_configuration('config.rb') }.to raise_error SystemExit 41 | end 42 | 43 | it 'should raise LoadError if content isn\'t ruby' do 44 | file = MirrorFile.new('./config.rb') 45 | file.write('test') 46 | expect { CLI.load_configuration('config.rb') }.to raise_error LoadError 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /spec/gemirro/configuration_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'gemirro/mirror_directory' 3 | require 'gemirro/configuration' 4 | require 'gemirro/source' 5 | 6 | # Configuration tests 7 | module Gemirro 8 | describe 'Configuration' do 9 | include FakeFS::SpecHelpers 10 | 11 | it 'should return configuration' do 12 | expect(Gemirro.configuration).to be_a Configuration 13 | end 14 | 15 | it 'should return logger' do 16 | expect(Gemirro.configuration.logger).to be_a Logger 17 | end 18 | 19 | it 'should return template directory' do 20 | FakeFS::FileSystem.clone( 21 | File.expand_path( 22 | './', 23 | Pathname.new(__FILE__).realpath 24 | ) 25 | ) 26 | expect(Configuration.template_directory).to eq( 27 | File.expand_path( 28 | '../../../template', 29 | Pathname.new(__FILE__).realpath 30 | ) 31 | ) 32 | end 33 | 34 | it 'should return default config file' do 35 | expect(Configuration.default_configuration_file).to eq('/config.rb') 36 | end 37 | 38 | it 'should return marshal identifier' do 39 | expect(Configuration.marshal_identifier).to match(/Marshal\.(\d+)\.(\d+)/) 40 | end 41 | 42 | it 'should error on version since no source is defined' do 43 | expect(Gemirro.configuration.versions_file).to match(nil) 44 | end 45 | 46 | it 'should return marshal file' do 47 | expect(Configuration.marshal_version).to eq( 48 | "#{Marshal::MAJOR_VERSION}.#{Marshal::MINOR_VERSION}" 49 | ) 50 | end 51 | end 52 | 53 | describe 'Configuration::instance' do 54 | before(:each) do 55 | @config = Configuration.new 56 | end 57 | 58 | it 'return mirror directory' do 59 | allow(@config).to receive(:gems_directory).once.and_return('/tmp') 60 | expect(@config.mirror_gems_directory).to be_a(MirrorDirectory) 61 | expect(@config.mirror_gems_directory.path).to eq('/tmp') 62 | end 63 | 64 | it 'should return gems directory' do 65 | allow(@config).to receive(:destination).once.and_return('/tmp') 66 | expect(@config.gems_directory).to eq('/tmp/gems') 67 | end 68 | 69 | it 'should return ignored gems' do 70 | expect(@config.ignored_gems).to eq({}) 71 | expect(@config.ignore_gem?('rake', '1.0.0', 'ruby')).to be_falsy 72 | expect(@config.ignore_gem('rake', '1.0.0', 'ruby')).to eq(['1.0.0']) 73 | expect(@config.ignored_gems).to eq('ruby' => {'rake' => ['1.0.0']}) 74 | expect(@config.ignore_gem?('rake', '1.0.0', 'ruby')).to be_truthy 75 | expect(@config.ignore_gem?('rake', '1.0.0', 'java')).to be_falsy 76 | end 77 | 78 | it 'should add and return source' do 79 | expect(@config.source).to eq(nil) 80 | result = @config.define_source('RubyGems', 'https://rubygems.org') do 81 | end 82 | expect(result).to be_a(Source) 83 | expect(result.gems).to eq([]) 84 | expect(result.host).to eq('https://rubygems.org') 85 | expect(result.name).to eq('rubygems') 86 | end 87 | 88 | 89 | it 'should return versions file with source' do 90 | result = @config.define_source('RubyGems', 'https://rubygems.org') do 91 | end 92 | expect(@config.versions_file).to match(/rubygems_org_versions$/) 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /spec/gemirro/gem_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'gemirro/gem' 3 | 4 | # Gem tests 5 | module Gemirro 6 | describe 'Gem' do 7 | before(:each) do 8 | @gem = Gem.new('gemirro', '0.0.1') 9 | end 10 | 11 | it 'should be initialized with string' do 12 | gem = Gem.new('gemirro', '0.0.1') 13 | expect(gem.name).to eq('gemirro') 14 | expect(gem.requirement).to eq(::Gem::Requirement.new(['= 0.0.1'])) 15 | end 16 | 17 | it 'should be initialized with ::Gem::Requirement' do 18 | requirement = ::Gem::Requirement.new('0.0.1') 19 | gem = Gem.new('gemirro', requirement) 20 | expect(gem.name).to eq('gemirro') 21 | expect(gem.requirement).to be(requirement) 22 | end 23 | 24 | it 'should return version' do 25 | expect(@gem.version).to eq(::Gem::Version.new('0.0.1')) 26 | end 27 | 28 | it 'should check version' do 29 | expect(@gem.version?).to be_truthy 30 | end 31 | 32 | it 'should return gem filename' do 33 | expect(@gem.filename).to eq('gemirro-0.0.1.gem') 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/gemirro/gem_version_collection_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'gemirro/gem_version_collection' 3 | require 'gemirro/gem_version' 4 | 5 | # Gem tests 6 | module Gemirro 7 | describe 'GemVersionCollection' do 8 | it 'should be initialized' do 9 | collection = GemVersionCollection.new([['subzero', 10 | '0.0.1', 11 | 'ruby'], 12 | GemVersion.new('alumina', 13 | '0.0.1', 14 | 'ruby')]) 15 | expect(collection.gems.first).to be_a(GemVersion) 16 | expect(collection.gems.last).to be_a(GemVersion) 17 | expect(collection.oldest).to be(collection.gems.first) 18 | expect(collection.newest).to be(collection.gems.last) 19 | expect(collection.size).to eq(2) 20 | end 21 | 22 | it 'should group and sort gems' do 23 | collection = GemVersionCollection.new([['subzero', 24 | '0.0.1', 25 | 'ruby'], 26 | GemVersion.new('alumina', 27 | '0.0.1', 28 | 'ruby')]) 29 | expect(collection.by_name.first[0]).to eq('alumina') 30 | values = %w[alumina subzero] 31 | collection.by_name do |name, _version| 32 | expect(name).to eq(values.shift) 33 | end 34 | end 35 | 36 | it 'should find gem by name' do 37 | collection = GemVersionCollection.new([['subzero', 38 | '0.0.1', 39 | 'ruby'], 40 | GemVersion.new('alumina', 41 | '0.0.1', 42 | 'ruby'), 43 | GemVersion.new('alumina', 44 | '0.0.2', 45 | 'ruby')]) 46 | expect(collection.find_by_name('something')).to be_nil 47 | expect(collection.find_by_name('alumina').newest.name) 48 | .to eq('alumina') 49 | expect(collection.find_by_name('alumina').newest.version.to_s) 50 | .to eq('0.0.2') 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /spec/gemirro/gem_version_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'gemirro/gem_version' 3 | 4 | # Gem tests 5 | module Gemirro 6 | describe 'GemVersion' do 7 | it 'should be initialized' do 8 | gem = GemVersion.new('gemirro', 9 | '0.0.1', 10 | 'ruby') 11 | expect(gem.name).to eq('gemirro') 12 | expect(gem.number).to eq('0.0.1') 13 | expect(gem.platform).to eq('ruby') 14 | expect(gem.ruby?).to be_truthy 15 | expect(gem.version).to be_a(::Gem::Version) 16 | expect(gem.gemfile_name).to eq('gemirro-0.0.1') 17 | end 18 | 19 | it 'should be initialized with other platform' do 20 | gem = GemVersion.new('gemirro', 21 | '0.0.1', 22 | 'jruby') 23 | expect(gem.name).to eq('gemirro') 24 | expect(gem.number).to eq('0.0.1') 25 | expect(gem.platform).to eq('jruby') 26 | expect(gem.ruby?).to be_falsy 27 | expect(gem.version).to be_a(::Gem::Version) 28 | expect(gem.gemfile_name).to eq('gemirro-0.0.1-jruby') 29 | end 30 | 31 | it 'should compare with an other gem' do 32 | first_gem = GemVersion.new('gemirro', 33 | '0.0.1', 34 | 'ruby') 35 | second_gem = GemVersion.new('gemirro', 36 | '0.0.2', 37 | 'ruby') 38 | third_gem = GemVersion.new('gemirro', 39 | '0.0.1', 40 | 'ruby') 41 | expect(first_gem < second_gem).to eq(true) 42 | expect(second_gem < first_gem).to eq(false) 43 | expect(first_gem == third_gem).to eq(true) 44 | expect(first_gem != second_gem).to eq(true) 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /spec/gemirro/gems_fetcher_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'gemirro/source' 3 | require 'gemirro/gem' 4 | require 'gemirro/versions_file' 5 | require 'gemirro/mirror_file' 6 | require 'gemirro/gems_fetcher' 7 | 8 | # GemsFetcher tests 9 | module Gemirro 10 | describe 'GemsFetcher' do 11 | include FakeFS::SpecHelpers 12 | 13 | before(:each) do 14 | @source = Source.new('RubyGems', 'https://rubygems.org') 15 | @versions_file = VersionsFile.new(%(created_at: 2025-04-24T03:46:59Z\n---\nrack 3.0.0,3.0.1 d545a45462d63b1b4865bbb89a109366)) 16 | @fetcher = GemsFetcher.new(@source, @versions_file) 17 | Gemirro.configuration.ignored_gems.clear 18 | end 19 | 20 | it 'should be initialized' do 21 | expect(@fetcher.source).to be(@source) 22 | expect(@fetcher.versions_file).to be(@versions_file) 23 | end 24 | 25 | it 'should test if gem exists' do 26 | Utils.configuration.destination = './' 27 | expect(@fetcher.gem_exists?('test')).to be_falsy 28 | MirrorDirectory.new('./').add_directory('gems') 29 | MirrorDirectory.new('./').add_directory('quick/Marshal.4.8') 30 | MirrorFile.new('gems/test').write('content') 31 | expect(@fetcher.gem_exists?('test')).to be_truthy 32 | end 33 | 34 | it 'should ignore gem' do 35 | allow(Utils.logger).to receive(:info) 36 | .once.with('Fetching gemirro-0.0.1.gem') 37 | expect(@fetcher.ignore_gem?('gemirro', '0.0.1', 'ruby')).to be_falsy 38 | Utils.configuration.ignore_gem('gemirro', '0.0.1', 'ruby') 39 | expect(@fetcher.ignore_gem?('gemirro', '0.0.1', 'ruby')).to be_truthy 40 | expect(@fetcher.ignore_gem?('gemirro', '0.0.1', 'java')).to be_falsy 41 | end 42 | 43 | it 'should log error when fetch gem failed' do 44 | allow(Utils.logger).to receive(:info) 45 | .once.with('Fetching gemirro-0.0.1.gem') 46 | gem = Gem.new('gemirro') 47 | version = ::Gem::Version.new('0.0.1') 48 | Utils.configuration.ignore_gem('gemirro', '0.0.1', 'ruby') 49 | allow(@source).to receive(:fetch_gem) 50 | .once.with('gemirro', version).and_raise(ArgumentError) 51 | allow(Utils.logger).to receive(:error) 52 | .once.with(/Failed to retrieve/) 53 | allow(Utils.logger).to receive(:debug) 54 | .once.with(/Adding (.*) to the list of ignored Gems/) 55 | 56 | expect(@fetcher.fetch_gem(gem, version)).to be_nil 57 | expect(@fetcher.ignore_gem?('gemirro', '0.0.1', 'ruby')).to be_truthy 58 | end 59 | 60 | it 'should fetch gem' do 61 | allow(Utils.logger).to receive(:info) 62 | .once.with('Fetching gemirro-0.0.1.gem') 63 | MirrorDirectory.new('./').add_directory('gems') 64 | gem = Gem.new('gemirro') 65 | version = ::Gem::Version.new('0.0.1') 66 | allow(@source).to receive(:fetch_gem) 67 | .with('gemirro-0.0.1.gem').and_return('gemirro') 68 | 69 | expect(@fetcher.fetch_gem(gem, version)).to eq('gemirro') 70 | end 71 | 72 | it 'should fetch latest gem' do 73 | allow(Utils.logger).to receive(:info) 74 | .once.with('Fetching gemirro-0.0.1.gem') 75 | MirrorDirectory.new('./').add_directory('gems') 76 | gem = Gem.new('gemirro', :latest) 77 | version = ::Gem::Version.new('0.0.1') 78 | allow(@source).to receive(:fetch_gem) 79 | .with('gemirro-0.0.1.gem').and_return('gemirro') 80 | 81 | expect(@fetcher.fetch_gem(gem, version)).to eq('gemirro') 82 | end 83 | 84 | it 'should fetch gemspec' do 85 | allow(Utils.logger).to receive(:info) 86 | .once.with('Fetching gemirro-0.0.1.gemspec.rz') 87 | MirrorDirectory.new('./').add_directory('quick/Marshal.4.8') 88 | gem = Gem.new('gemirro') 89 | gem.gemspec = true 90 | version = ::Gem::Version.new('0.0.1') 91 | allow(@source).to receive(:fetch_gemspec) 92 | .once.with('gemirro-0.0.1.gemspec.rz').and_return('gemirro') 93 | 94 | expect(@fetcher.fetch_gemspec(gem, version)).to eq('gemirro') 95 | end 96 | 97 | it 'should fetch latest gemspec' do 98 | allow(Utils.logger).to receive(:info) 99 | .once.with('Fetching gemirro-0.0.1.gemspec.rz') 100 | MirrorDirectory.new('./').add_directory('quick/Marshal.4.8') 101 | gem = Gem.new('gemirro', :latest) 102 | gem.gemspec = true 103 | version = ::Gem::Version.new('0.0.1') 104 | allow(@source).to receive(:fetch_gemspec) 105 | .once.with('gemirro-0.0.1.gemspec.rz').and_return('gemirro') 106 | 107 | expect(@fetcher.fetch_gemspec(gem, version)).to eq('gemirro') 108 | end 109 | 110 | it 'should not fetch gemspec if file exists' do 111 | allow(Utils.logger).to receive(:info) 112 | .once.with('Fetching gemirro-0.0.1.gemspec.rz') 113 | allow(@fetcher).to receive(:gemspec_exists?) 114 | .once.with('gemirro-0.0.1.gemspec.rz') 115 | .and_return(true) 116 | allow(Utils.logger).to receive(:debug) 117 | .once.with('Skipping gemirro-0.0.1.gemspec.rz') 118 | 119 | gem = Gem.new('gemirro') 120 | gem.gemspec = true 121 | version = ::Gem::Version.new('0.0.1') 122 | 123 | expect(@fetcher.fetch_gemspec(gem, version)).to be_nil 124 | end 125 | 126 | it 'should retrieve versions for specific gem' do 127 | gem = Gem.new('gemirro', '0.0.2') 128 | allow(@versions_file).to receive(:versions_for) 129 | .once.with('gemirro') 130 | .and_return([[::Gem::Version.new('0.0.1'), 'ruby'], 131 | [::Gem::Version.new('0.0.2'), 'ruby']]) 132 | expect(@fetcher.versions_for(gem)).to eq([[::Gem::Version.new('0.0.2'), 'ruby']]) 133 | end 134 | 135 | it 'should fetch all gems and log debug if gem is not satisfied' do 136 | MirrorDirectory.new('./').add_directory('gems') 137 | gem = Gem.new('gemirro', '0.0.1') 138 | allow(gem.requirement).to receive(:satisfied_by?) 139 | .once.with(nil).and_return(false) 140 | @fetcher.source.gems << gem 141 | allow(Utils.logger).to receive(:debug) 142 | .once.with('Skipping gemirro-0.0.1.gem') 143 | expect(@fetcher.fetch).to eq([gem]) 144 | end 145 | 146 | it 'should fetch all gems' do 147 | gem = Gem.new('gemirro', '0.0.2') 148 | @fetcher.source.gems << gem 149 | gemspec = Gem.new('gemirro', '0.0.1') 150 | gemspec.gemspec = true 151 | @fetcher.source.gems << gemspec 152 | 153 | allow(@fetcher).to receive(:fetch_gemspec) 154 | .once.with(gemspec, nil).and_return('gemfile') 155 | allow(@fetcher).to receive(:fetch_gem) 156 | .once.with(gem, nil).and_return('gemfile') 157 | 158 | allow(Utils.configuration.mirror_gems_directory).to receive(:add_file) 159 | .once.with('gemirro-0.0.2.gem', 'gemfile') 160 | allow(Utils.configuration.mirror_gemspecs_directory) 161 | .to receive(:add_file) 162 | .once.with('gemirro-0.0.1.gemspec.rz', 'gemfile') 163 | expect(@fetcher.fetch).to eq([gem, gemspec]) 164 | end 165 | end 166 | end 167 | -------------------------------------------------------------------------------- /spec/gemirro/http_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'httpclient' 3 | require 'gemirro/http' 4 | 5 | module Gemirro 6 | describe Http do 7 | let(:config) { double('Configuration') } 8 | 9 | before do 10 | Http.instance_variable_set(:@client, nil) 11 | allow(Utils).to receive(:configuration).and_return(config) 12 | end 13 | 14 | describe '.client' do 15 | it 'initializes a new HTTPClient' do 16 | expect(Http.client).to be_a(HTTPClient) 17 | end 18 | 19 | context 'with proxy configuration' do 20 | it 'sets proxy configuration' do 21 | allow(config).to receive(:proxy).and_return('http://proxy.example.com:8080') 22 | 23 | expect(Http.client.proxy.to_s).to eq('http://proxy.example.com:8080') 24 | end 25 | end 26 | 27 | context 'with SSL configuration' do 28 | context 'with invalid root CA path' do 29 | before do 30 | allow(config).to receive(:rootca).and_return('/nonexistent/ca.crt') 31 | allow(File).to receive(:file?).with('/nonexistent/ca.crt').and_return(false) 32 | end 33 | 34 | it 'aborts with error message' do 35 | expect { Http.client }.to raise_error(SystemExit) 36 | end 37 | end 38 | 39 | context 'with verify_mode disabled' do 40 | before do 41 | allow(config).to receive(:verify_mode).and_return(false) 42 | end 43 | 44 | it 'sets SSL verify mode to VERIFY_NONE' do 45 | expect(Http.client.ssl_config.verify_mode).to eq(OpenSSL::SSL::VERIFY_NONE) 46 | end 47 | end 48 | end 49 | 50 | context 'with basic auth forced' do 51 | before do 52 | allow(config).to receive(:basic_auth).and_return(true) 53 | end 54 | 55 | it 'forces basic authentication' do 56 | expect(Http.client.www_auth.basic_auth.force_auth).to be true 57 | end 58 | end 59 | end 60 | 61 | describe '.get' do 62 | let(:client) { instance_double(HTTPClient) } 63 | 64 | before do 65 | allow(Http).to receive(:client).and_return(client) 66 | end 67 | 68 | context 'with successful response' do 69 | let(:response) { double('Response', status: 200, body: 'content') } 70 | 71 | it 'returns response for successful request' do 72 | allow(client).to receive(:get) 73 | .with('http://example.com', follow_redirect: true) 74 | .and_return(response) 75 | 76 | expect(Http.get('http://example.com')).to eq(response) 77 | end 78 | end 79 | 80 | context 'with error response' do 81 | let(:response) { double('Response', status: 404, reason: 'Not Found') } 82 | 83 | it 'raises BadResponseError for failed request' do 84 | allow(client).to receive(:get) 85 | .with('http://example.com', follow_redirect: true) 86 | .and_return(response) 87 | 88 | expect { Http.get('http://example.com') } 89 | .to raise_error(HTTPClient::BadResponseError, 'Not Found') 90 | end 91 | end 92 | end 93 | end 94 | end -------------------------------------------------------------------------------- /spec/gemirro/indexer_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'builder' 3 | require 'rubygems/indexer' 4 | require 'tempfile' 5 | require 'gemirro/source' 6 | require 'gemirro/indexer' 7 | require 'gemirro/mirror_file' 8 | require 'gemirro/mirror_directory' 9 | 10 | # Indexer tests 11 | module Gemirro 12 | describe 'Indexer' do 13 | include FakeFS::SpecHelpers 14 | 15 | before(:each) do 16 | allow_any_instance_of(Logger).to receive(:info) 17 | allow_any_instance_of(Logger).to receive(:debug) 18 | allow_any_instance_of(Logger).to receive(:warn) 19 | end 20 | 21 | it 'should download from source' do 22 | source = Source.new('Rubygems', 'https://rubygems.org') 23 | allow(Gemirro.configuration).to receive(:source).and_return(source) 24 | 25 | dir = MirrorDirectory.new('/tmp') 26 | dir.add_directory('test') 27 | indexer = Indexer.new 28 | 29 | Struct.new('HttpGet', :code, :body) 30 | http_get = Struct::HttpGet.new(200, 'bad') 31 | allow(Http).to receive(:get).once.and_return(http_get) 32 | 33 | expect(indexer.download_from_source('something')) 34 | .to eq('bad') 35 | end 36 | 37 | it 'should install indices' do 38 | allow(Gemirro.configuration).to receive(:destination).and_return('/tmp') 39 | 40 | dir = MirrorDirectory.new('/tmp') 41 | dir.add_directory('test') 42 | dir.add_directory('gem_generate_index/quick/Marshal.4.8') 43 | allow(::Gem.configuration).to receive(:really_verbose) 44 | .once.and_return(true) 45 | 46 | indexer = Indexer.new 47 | indexer.quick_marshal_dir = '/tmp/gem_generate_index/quick/Marshal.4.8' 48 | indexer.dest_directory = '/tmp/test' 49 | indexer.directory = '/tmp/gem_generate_index' 50 | indexer.instance_variable_set('@specs_index', 51 | '/tmp/gem_generate_index/specs.4.8') 52 | indexer.files = [ 53 | '/tmp/gem_generate_index/quick/Marshal.4.8', 54 | '/tmp/gem_generate_index/specs.4.8.gz', 55 | '/tmp/gem_generate_index/something.4.8.gz' 56 | ] 57 | allow(FileUtils).to receive(:mkdir_p).and_return(true) 58 | allow(FileUtils).to receive(:rm_f).and_return(true) 59 | allow(FileUtils).to receive(:mkdir_p) 60 | .once.with('/tmp/test/quick', verbose: true) 61 | allow(FileUtils).to receive(:rm_rf) 62 | .once.with('/tmp/gem_generate_index/something.4.8.gz') 63 | allow(FileUtils).to receive(:rm_rf) 64 | .once.with('/tmp/gem_generate_index/specs.4.8.gz') 65 | allow(FileUtils).to receive(:rm_rf) 66 | .once.with('/tmp/test/quick/Marshal.4.8', verbose: true) 67 | allow(FileUtils).to receive(:mv) 68 | .once 69 | .with('/tmp/gem_generate_index/quick/Marshal.4.8', 70 | '/tmp/test/quick/Marshal.4.8', 71 | verbose: true, force: true) 72 | 73 | allow(FileUtils).to receive(:mv) 74 | 75 | source = Source.new('Rubygems', 'https://rubygems.org') 76 | allow(Gemirro.configuration).to receive(:source).and_return(source) 77 | 78 | wio = StringIO.new('w') 79 | w_gz = Zlib::GzipWriter.new(wio) 80 | w_gz.write(['content']) 81 | w_gz.close 82 | allow(indexer).to receive(:download_from_source).and_return(wio.string) 83 | 84 | allow(Marshal).to receive(:load).and_return(['content']) 85 | allow(Marshal).to receive(:dump).and_return(['content']) 86 | 87 | Struct.new('GzipReader', :read) 88 | gzip_reader = Struct::GzipReader.new(wio.string) 89 | allow(Zlib::GzipReader).to receive(:open) 90 | .once 91 | .with('/tmp/gem_generate_index/specs.4.8.gz') 92 | .and_return(gzip_reader) 93 | 94 | files = indexer.install_indices 95 | expect(files).to eq(['/tmp/gem_generate_index/specs.4.8.gz', 96 | '/tmp/gem_generate_index/something.4.8.gz']) 97 | end 98 | 99 | it 'should build indices' do 100 | indexer = Indexer.new 101 | dir = MirrorDirectory.new('/') 102 | dir.add_directory('gems') 103 | dir.add_directory('quick') 104 | dir.add_directory('tmp') 105 | dir.add_directory("#{indexer.directory.gsub(%r{^/}, '')}/gems") 106 | dir.add_directory("#{indexer.directory.gsub(%r{^/}, '')}/quick") 107 | 108 | fixtures_dir = File.dirname(__FILE__) + '/../fixtures' 109 | FakeFS::FileSystem 110 | .clone("#{fixtures_dir}/gems/gemirro-0.0.1.gem", 111 | "#{indexer.directory}/gems/gemirro-0.0.1.gem") 112 | FakeFS::FileSystem 113 | .clone("#{fixtures_dir}/gems/gemirro-0.0.1.gem", 114 | '/gems/gemirro-0.0.1.gem') 115 | FakeFS::FileSystem 116 | .clone("#{fixtures_dir}/gems/gemirro-0.0.1.gem", 117 | '/gems/gemirral-0.0.1.gem') # Skipping misnamed 118 | FakeFS::FileSystem 119 | .clone("#{fixtures_dir}/quick/gemirro-0.0.1.gemspec.rz", 120 | "#{indexer.directory}/quick/gemirro-0.0.1.gemspec.rz") 121 | 122 | MirrorFile.new('gems/gemirro-0.0.2.gem').write('') # Empty file 123 | MirrorFile.new('gems/gemirro-0.0.3.gem').write('Error') # Empty file 124 | MirrorFile.new('/specs.4.8').write('') 125 | 126 | allow(indexer).to receive(:gem_file_list) 127 | .and_return(['gems/gemirro-0.0.1.gem', 128 | 'gems/gemirro-0.0.2.gem', 129 | 'gems/gemirro-0.0.3.gem', 130 | 'gems/gemirral-0.0.1.gem']) 131 | 132 | allow(indexer).to receive(:build_marshal_gemspecs) 133 | .once 134 | .and_return(["#{indexer.directory}/quick/gemirro-0.0.1.gemspec.rz"]) 135 | 136 | allow(indexer).to receive(:compress_indicies).once.and_return(true) 137 | allow(indexer).to receive(:compress_indices).once.and_return(true) 138 | 139 | indexer.build_indices 140 | end 141 | 142 | it 'should update index and exit ruby gems' do 143 | indexer = Indexer.new 144 | MirrorDirectory.new('/') 145 | MirrorFile.new('/specs.4.8').write('') 146 | expect { indexer.update_index }.to raise_error(::Gem::SystemExitException) 147 | end 148 | 149 | it 'should update index' do 150 | dir = MirrorDirectory.new('/tmp') 151 | dir.add_directory('gem_generate_index/quick/Marshal.4.8') 152 | dir.add_directory('test/gems') 153 | dir.add_directory('test/quick') 154 | 155 | indexer = Indexer.new 156 | indexer.quick_marshal_dir = '/tmp/gem_generate_index/quick/Marshal.4.8' 157 | indexer.dest_directory = '/tmp/test' 158 | indexer.directory = '/tmp/gem_generate_index' 159 | indexer.instance_variable_set('@specs_index', 160 | '/tmp/gem_generate_index/specs.4.8') 161 | 162 | MirrorFile.new("#{indexer.directory}/specs.4.8.gz").write('') 163 | MirrorFile.new("#{indexer.directory}/specs.4.8").write('') 164 | MirrorFile.new("#{indexer.dest_directory}/specs.4.8").write('') 165 | File.utime(Time.at(0), Time.at(0), "#{indexer.dest_directory}/specs.4.8") 166 | 167 | fixtures_dir = File.dirname(__FILE__) + '/../fixtures' 168 | FakeFS::FileSystem 169 | .clone("#{fixtures_dir}/gems/gemirro-0.0.1.gem", 170 | "#{indexer.dest_directory}/gems/gemirro-0.0.1.gem") 171 | FakeFS::FileSystem 172 | .clone("#{fixtures_dir}/quick/gemirro-0.0.1.gemspec.rz", 173 | "#{indexer.directory}/quick/gemirro-0.0.1.gemspec.rz") 174 | 175 | allow(indexer).to receive(:make_temp_directories) 176 | allow(indexer).to receive(:update_specs_index) 177 | allow(indexer).to receive(:compress_indicies) 178 | allow(indexer).to receive(:compress_indices) 179 | allow(indexer).to receive(:build_zlib_file) 180 | # rubocop:disable Metrics/LineLength 181 | allow(indexer).to receive(:build_marshal_gemspecs).once.and_return(["#{indexer.directory}/quick/gemirro-0.0.1.gemspec.rz"]) 182 | # rubocop:enable Metrics/LineLength 183 | 184 | allow(Marshal).to receive(:load).and_return(['content']) 185 | allow(Marshal).to receive(:dump).and_return(['content']) 186 | allow(FileUtils).to receive(:mv) 187 | allow(File).to receive(:utime) 188 | 189 | indexer.update_index 190 | end 191 | end 192 | end 193 | -------------------------------------------------------------------------------- /spec/gemirro/mirror_directory_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'gemirro/mirror_directory' 3 | require 'gemirro/mirror_file' 4 | 5 | # MirrorDirectory tests 6 | module Gemirro 7 | describe 'MirrorDirectory' do 8 | include FakeFS::SpecHelpers 9 | 10 | before(:each) do 11 | @mirror_directory = MirrorDirectory.new('./') 12 | end 13 | 14 | it 'should be initialized' do 15 | expect(@mirror_directory.path).to eq('./') 16 | end 17 | 18 | it 'should add directory' do 19 | expect(@mirror_directory.add_directory('test/test2')) 20 | .to be_a(MirrorDirectory) 21 | expect(File.directory?('./test/test2')).to be_truthy 22 | end 23 | 24 | it 'should add file' do 25 | result = @mirror_directory.add_file('file', 'content') 26 | expect(result).to be_a(MirrorFile) 27 | expect(result.read).to eq('content') 28 | end 29 | 30 | it 'should test if file exists' do 31 | expect(@mirror_directory.file_exists?('test')).to be_falsy 32 | @mirror_directory.add_file('test', 'content') 33 | expect(@mirror_directory.file_exists?('test')).to be_truthy 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/gemirro/mirror_file_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'gemirro/mirror_file' 3 | 4 | # Mirror file tests 5 | module Gemirro 6 | describe 'MirrorFile' do 7 | include FakeFS::SpecHelpers 8 | 9 | before(:each) do 10 | @mirror_file = MirrorFile.new('./test') 11 | end 12 | 13 | it 'should be initialized' do 14 | expect(@mirror_file.path).to eq('./test') 15 | end 16 | 17 | it 'should write and read content' do 18 | expect(@mirror_file.write('content')).to be_nil 19 | expect(@mirror_file.read).to eq('content') 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/gemirro/server_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rack/test' 2 | require 'json' 3 | require 'parallel' 4 | require 'sinatra/base' 5 | require 'thin' 6 | require 'base64' 7 | require 'gemirro/utils' 8 | require 'gemirro/mirror_directory' 9 | require 'gemirro/mirror_file' 10 | require 'gemirro/gem_version_collection' 11 | 12 | ENV['RACK_ENV'] = 'test' 13 | 14 | # Rspec mixin module 15 | module RSpecMixin 16 | include Rack::Test::Methods 17 | def app 18 | require 'gemirro/server' 19 | Gemirro::Server 20 | end 21 | end 22 | 23 | RSpec.configure do |c| 24 | c.include RSpecMixin 25 | end 26 | 27 | # Server tests 28 | module Gemirro 29 | describe 'Gemirro::Server' do 30 | include FakeFS::SpecHelpers 31 | 32 | before(:each) do 33 | @fake_logger = Logger.new(STDOUT) 34 | MirrorDirectory.new('/var/www/gemirro').add_directory('gems') 35 | MirrorDirectory.new('/').add_directory('tmp') 36 | MirrorFile.new('/var/www/gemirro/test').write('content') 37 | Gemirro.configuration.destination = '/var/www/gemirro' 38 | Utils.instance_eval('@cache = nil') 39 | Utils.instance_eval('@gems_orig_collection = nil') 40 | Utils.instance_eval('@gems_source_collection = nil') 41 | FakeFS::FileSystem.clone(Gemirro::Configuration.views_directory) 42 | allow_any_instance_of(Indexer).to receive(:compress_indices) 43 | allow_any_instance_of(Indexer).to receive(:rand).and_return('0') 44 | 45 | source = Source.new('Rubygems', 'https://rubygems.org') 46 | allow(Gemirro.configuration).to receive(:source).and_return(source) 47 | end 48 | 49 | context 'HTML render' do 50 | it 'should display index page' do 51 | allow(Logger).to receive(:new).exactly(3).times.and_return(@fake_logger) 52 | allow(@fake_logger).to receive(:tap) 53 | .and_return(nil) 54 | .and_yield(@fake_logger) 55 | 56 | 57 | MirrorFile.new('/var/www/gemirro/versions.md5.aaa256.list').write('created_at: 2025-01-01T00:00:00Z\n---\nvolay 0.1.0\n') 58 | 59 | get '/' 60 | expect(last_response).to be_ok 61 | end 62 | 63 | it 'should return 404' do 64 | get '/wrong-path' 65 | expect(last_response.status).to eq(404) 66 | expect(last_response).to_not be_ok 67 | end 68 | 69 | it 'should return 404 when gem does not exist' do 70 | get '/gem/something' 71 | expect(last_response.status).to eq(404) 72 | expect(last_response).to_not be_ok 73 | end 74 | 75 | it 'should display gem specifications' do 76 | MirrorFile.new('/var/www/gemirro/versions.md5.aaa256.list').write(%(created_at: 2025-01-01T00:00:00Z\n---\nvolay 0.1.0 checksum)) 77 | 78 | get '/gem/volay' 79 | 80 | expect(last_response.status).to eq(200) 81 | expect(last_response).to be_ok 82 | end 83 | 84 | it 'responds to compact_index /names' do 85 | MirrorFile.new('/var/www/gemirro/names.md5.aaa256.list').write(%(---\nvolay)) 86 | 87 | get '/names' 88 | expect(last_response.status).to eq(200) 89 | expect(last_response).to be_ok 90 | 91 | 92 | expect(last_response.body).to eq(%(---\nvolay)) 93 | expect(last_response.headers['etag']).to eq('"md5"') 94 | expect(last_response.headers['repr-digest']).to eq(%(sha-256=#{Base64.strict_encode64(['aaa256'].pack('H*'))})) 95 | end 96 | 97 | it 'responds to compact_index /info/[gemname]' do 98 | MirrorDirectory.new('/var/www/gemirro/info') 99 | MirrorFile.new('/var/www/gemirro/info/volay.md5.aaa256.list').write('---\n 0.1.0 |checksum:sha256\n') 100 | 101 | 102 | get '/info/volay' 103 | expect(last_response.status).to eq(200) 104 | expect(last_response).to be_ok 105 | expect(last_response.body).to eq('---\n 0.1.0 |checksum:sha256\n') 106 | expect(last_response.headers['etag']).to eq('"md5"') 107 | expect(last_response.headers['repr-digest']).to eq(%(sha-256=#{Base64.strict_encode64(['aaa256'].pack('H*'))})) 108 | end 109 | 110 | 111 | it 'responds to compact_index /versions' do 112 | MirrorFile.new('/var/www/gemirro/versions.md5.aaa256.list').write(%(created_at: 2025-01-01T00:00:00Z\n---\nvolay 0.1.0)) 113 | 114 | get '/versions' 115 | expect(last_response.status).to eq(200) 116 | expect(last_response).to be_ok 117 | expect(last_response.body).to eq(%(created_at: 2025-01-01T00:00:00Z\n---\nvolay 0.1.0)) 118 | expect(last_response.headers['etag']).to eq('"md5"') 119 | expect(last_response.headers['repr-digest']).to eq(%(sha-256=#{Base64.strict_encode64(['aaa256'].pack('H*'))})) 120 | end 121 | 122 | 123 | end 124 | 125 | context 'Download' do 126 | it 'should download existing file' do 127 | get '/test' 128 | expect(last_response.body).to eq('content') 129 | expect(last_response).to be_ok 130 | end 131 | 132 | it 'should try to download gems.' do 133 | source = Gemirro::Source.new('test', 'https://rubygems.org') 134 | 135 | versions_fetcher = Gemirro::VersionsFetcher.new(source) 136 | allow(versions_fetcher).to receive(:fetch).once.and_return(true) 137 | 138 | gems_fetcher = Gemirro::GemsFetcher.new(source, versions_fetcher) 139 | allow(gems_fetcher).to receive(:fetch).once.and_return(true) 140 | allow(gems_fetcher).to receive(:gem_exists?).once.and_return(true) 141 | 142 | Struct.new('GemIndexer') 143 | gem_indexer = Struct::GemIndexer.new 144 | allow(gem_indexer).to receive(:only_origin=).once.and_return(true) 145 | allow(gem_indexer).to receive(:ui=).once.and_return(true) 146 | allow(gem_indexer).to receive(:update_index).once.and_return(true) 147 | 148 | allow(Gemirro.configuration).to receive(:source) 149 | .twice.and_return(source) 150 | allow(Gemirro::GemsFetcher).to receive(:new) 151 | .once.and_return(gems_fetcher) 152 | allow(Gemirro::VersionsFetcher).to receive(:new) 153 | .once.and_return(versions_fetcher) 154 | allow(Gemirro::Indexer).to receive(:new).once.and_return(gem_indexer) 155 | allow(::Gem::SilentUI).to receive(:new).once.and_return(true) 156 | 157 | allow(Gemirro.configuration).to receive(:logger) 158 | .exactly(4).and_return(@fake_logger) 159 | allow(@fake_logger).to receive(:info).exactly(4) 160 | 161 | get '/gems/gemirro-0.0.1.gem' 162 | expect(last_response).to_not be_ok 163 | expect(last_response.status).to eq(404) 164 | 165 | MirrorFile.new('/var/www/gemirro/gems/gemirro-0.0.1.gem') 166 | .write('content') 167 | get '/gems/gemirro-0.0.1.gem' 168 | expect(last_response).to be_ok 169 | expect(last_response.status).to eq(200) 170 | expect(last_response.body).to eq('content') 171 | end 172 | 173 | it 'should catch exceptions' do 174 | source = Gemirro::Source.new('test', 'https://rubygems.org') 175 | 176 | versions_fetcher = Gemirro::VersionsFetcher.new(source) 177 | allow(versions_fetcher).to receive(:fetch).once.and_return(true) 178 | 179 | gems_fetcher = Gemirro::VersionsFetcher.new(source) 180 | allow(gems_fetcher).to receive(:fetch) 181 | .once.and_raise(StandardError, 'Not ok') 182 | 183 | gem_indexer = Struct::GemIndexer.new 184 | allow(gem_indexer).to receive(:only_origin=).once.and_return(true) 185 | allow(gem_indexer).to receive(:ui=).once.and_return(true) 186 | allow(gem_indexer).to receive(:update_index) 187 | .once.and_raise(SystemExit) 188 | 189 | allow(Gemirro.configuration).to receive(:source) 190 | .twice.and_return(source) 191 | allow(Gemirro::GemsFetcher).to receive(:new) 192 | .once.and_return(gems_fetcher) 193 | allow(Gemirro::VersionsFetcher).to receive(:new) 194 | .once.and_return(versions_fetcher) 195 | allow(Gemirro::Indexer).to receive(:new).once.and_return(gem_indexer) 196 | allow(::Gem::SilentUI).to receive(:new).once.and_return(true) 197 | 198 | allow(Gemirro.configuration).to receive(:logger) 199 | .exactly(4).and_return(@fake_logger) 200 | allow(@fake_logger).to receive(:info).exactly(3) 201 | allow(@fake_logger).to receive(:error) 202 | get '/gems/gemirro-0.0.1.gem' 203 | expect(last_response).to_not be_ok 204 | end 205 | end 206 | 207 | context 'dependencies' do 208 | it 'should retrieve nothing and give 404' do 209 | get '/api/v1/dependencies' 210 | expect(last_response.status).to eq(404) 211 | expect(last_response).to_not be_ok 212 | end 213 | 214 | 215 | it 'should retrieve nothing and give 404' do 216 | get '/api/v1/dependencies.json?gems=gemirro' 217 | expect(last_response.status).to eq(404) 218 | expect(last_response).to_not be_ok 219 | end 220 | 221 | it 'should retrieve nothing and give 404' do 222 | MirrorDirectory.new('/var/www/gemirro') 223 | .add_directory('quick/Marshal.4.8') 224 | # rubocop:disable Metrics/LineLength 225 | MirrorFile.new('/var/www/gemirro/quick/Marshal.4.8/' \ 226 | 'volay-0.1.0.gemspec.rz') 227 | .write("x\x9C\x8D\x94]\x8F\xD2@\x14\x86\x89Y\xBB\xB4|\xEC\x12\xD7h" \ 228 | "\xD4h\xD3K\x13J\x01\x97\xC84n\x9A\xA8\xBBi\xE2\xC5\x06\xBB" \ 229 | "{\xC3\x85)\xE5\x00\x13f:u:E\xD1\xC4\xDF\xE6\xB5\xBF\xCAiK" \ 230 | "\x11\xE3GK\xEF\x98\xF7\xBC\xCFy\xCF\xC9\xCCQ=A\x0F\xAE\x80" \ 231 | "\"\xF4>\x82\x00/p\xE0\v\xCC\xC2;\xC1\xDD\xA3\xFA\xF4\xA1k4" \ 232 | "\x06\xA6e\xF6_(Hy\xEBa\xD55\xB4\r#\xFEV\xB1k\xDE\r\xEAdu" \ 233 | "\xB7\xC0cY1U\xE4\xA1\x95\x8A\xD3C7A\xAA\x87)\xB4\x9C\x1FO" \ 234 | "\xBE\xD7\xE4OA\xEA\x17\x16\x82k\xD4o\xBC\xD7\x99\xC2x\xEC" \ 235 | "\xAD@\xBFe$\xA1\xA0\xC7\xDBX\x00\xD5\x05/\xBC\xEFg\xDE\x13" \ 236 | "\xF8\x98`\x0E\x14B1U\xE4w\xEC\x1A\xC7\x17\xAF2\x85\xADd\xC4" \ 237 | "\xBE96\x87\xF9\x1F\xEA\xDF%\x8A\x95\xE3T\x9E\xCC2\xF3i\x9B" \ 238 | "\xA1\xB3\xCC\xFE\rD\x10\xCE!\f\xB6\x1A\xD2\x9C\xD0\xA7\xB2" \ 239 | "\xBF\x13\x8A?\x13<\xEB\x06\x04\xA7b\xD4q\xF8\xAF&\x0E!\xDF" \ 240 | ".~\xEF\xE3\xDC\xCC@\xD2Hl\#@M\x9E\x84BN\x00\x9D:\x11\a\x0E" \ 241 | "\x04\xFC\x18.\xD1#g\x93\xCF\xEB\xC3\x81m\\\xC1\x97\xD9" \ 242 | "\x9Af7\\\xE3l\xD7_\xBC\x02BX\"\xD23\xBB\xF9o\x83A\xB1\x12" \ 243 | "\xBBe\xB7\xED\x93K\xFB\xB4\x82\xB6\x80\xA9K\xB1\x1E\x96" \ 244 | "\x10\xEA\x03sP\xCD\xBFP\x16\xEE\x8D\x85\xBF\x86E\\\x96" \ 245 | "\xC02G\xF9\b\xEC\x16:\x9D\xC3\x06\b\x8B\xD2\xA9\x95\x84" \ 246 | "\xD9\x97\xED\xC3p\x89+\x81\xA9}\xAB`\xD9\x9D\xFF\x03\xF6" \ 247 | "\xD2\xC2\xBF\xCD\xFD`\xDD\x15\x10\x97\xED\xA4.[\xAB\xC6(" \ 248 | "\x94\x05B\xE3\xB1\xBC\xA5e\xF6\xC3\xAA\x11\n\xE5>A\x8CiD " \ 249 | "`\x9B\xF2\x04\xE3\xCA\t\xC6\x87\by-f,`Q\xD9\x1E,sp^q\x0F" \ 250 | "\x85\xD4r\x8Dg\x11\x06\xCE\xC1\xE4>\x9D\xF9\xC9\xFC\xE5" \ 251 | "\xC8YR\x1F\x133`4\xBB\xF9R~\xEF:\x93\xE8\x93\\\x92\xBF\r" \ 252 | "\xA3\t\xF8\x84l\xF5<\xBF\xBE\xF9\xE3Q\xD2?q,\x04\x84:\x0E" \ 253 | "\xF5\xF4\x1D1\xF3\xBA\xE7+!\"\xD4\xEB-\xB1X%\xB3\x14\xD3" \ 254 | "\xCB\xEDw\xEE\xBD\xFDk\xE99OSz\xF3\xEA\xFA]w7\xF5\xAF\xB5" \ 255 | "\x9F+\xFEG\x96") 256 | # rubocop:enable Metrics/LineLength 257 | 258 | MirrorFile.new('/var/www/gemirro/api/v1/dependencies/volay.md5.sha.list') 259 | .write(Marshal.dump([ 260 | { 261 | name: 'volay', 262 | number: "0.1.0", 263 | platform: 'ruby', 264 | dependencies: [ 265 | { 266 | name: 'json', 267 | requirement: '~> 2.1' 268 | } 269 | ] 270 | } 271 | ])) 272 | 273 | gem = Gemirro::GemVersion.new('volay', '0.1.0', 'ruby') 274 | collection = Gemirro::GemVersionCollection.new([gem]) 275 | allow(Utils).to receive(:gems_collection) 276 | .and_return(collection) 277 | get '/api/v1/dependencies.json?gems=volay' 278 | expect(last_response.status).to eq(404) 279 | expect(last_response).to_not be_ok 280 | end 281 | end 282 | end 283 | end 284 | -------------------------------------------------------------------------------- /spec/gemirro/source_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'gemirro/http' 3 | require 'gemirro/utils' 4 | require 'gemirro/source' 5 | 6 | # Source tests 7 | module Gemirro 8 | describe 'Source' do 9 | before(:each) do 10 | @source = Source.new('RubyGems', 'https://rubygems.org') 11 | allow(Utils.logger).to receive(:info) 12 | end 13 | 14 | it 'should be initialized' do 15 | expect(@source.name).to eq('rubygems') 16 | expect(@source.host).to eq('https://rubygems.org') 17 | expect(@source.gems).to eq([]) 18 | end 19 | 20 | it 'should fetch versions' do 21 | Struct.new('FetchVersions', :body) 22 | result = Struct::FetchVersions.new(true) 23 | allow(Http).to receive(:get).once.with( 24 | "https://rubygems.org/versions" 25 | ).and_return(result) 26 | expect(@source.fetch_versions).to be_truthy 27 | end 28 | 29 | it 'should fetch gem' do 30 | Struct.new('FetchGem', :body) 31 | result = Struct::FetchGem.new(true) 32 | allow(Http).to receive(:get).once 33 | .with('https://rubygems.org/gems/gemirro-0.0.1.gem').and_return(result) 34 | expect(@source.fetch_gem('gemirro-0.0.1.gem')).to be_truthy 35 | end 36 | 37 | it 'should fetch gemspec' do 38 | Struct.new('FetchGemspec', :body) 39 | result = Struct::FetchGemspec.new(true) 40 | allow(Http).to receive(:get).once 41 | .with('https://rubygems.org/quick/Marshal.4.8/gemirro-0.0.1.gemspec.rz').and_return(result) 42 | expect(@source.fetch_gemspec('gemirro-0.0.1.gemspec.rz')).to be_truthy 43 | end 44 | 45 | it 'should add gems' do 46 | expect(@source.gems).to eq([]) 47 | @source.gem('gemirro') 48 | result = @source.gems 49 | expect(result[0].name).to eq('gemirro') 50 | expect(result[0].requirement).to be_a(::Gem::Requirement) 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /spec/gemirro/versions_fetcher_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'gemirro/versions_fetcher' 3 | 4 | # VersionsFetcher tests 5 | module Gemirro 6 | describe 'VersionsFetcher' do 7 | include FakeFS::SpecHelpers 8 | 9 | before(:each) do 10 | @source = Source.new('RubyGems', 'https://rubygems.org') 11 | @fetcher = VersionsFetcher.new(@source) 12 | end 13 | 14 | it 'should be initialized' do 15 | expect(@fetcher.source).to be(@source) 16 | end 17 | 18 | it 'should fetch versions' do 19 | allow(@source).to receive(:fetch_versions).once.and_return([]) 20 | allow(VersionsFile).to receive(:load).with('nothing') 21 | allow(File).to receive(:write).once 22 | allow(File).to receive(:read).once.and_return('nothing') 23 | expect(@fetcher.fetch).to be_nil 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/gemirro/versions_file_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'zlib' 3 | require 'gemirro/versions_file' 4 | 5 | # VersionsFile tests 6 | module Gemirro 7 | describe 'VersionsFile' do 8 | include FakeFS::SpecHelpers 9 | 10 | it 'should load versions file' do 11 | spec = %(created_at: 2025-01-01T00:00:00Z\n---\ngemirro 0.0.1.alpha1,0.0.1,0.0.2.alpha2,0.0.2 checksum) 12 | 13 | result = VersionsFile.new(spec) 14 | expect(result).to be_a(VersionsFile) 15 | 16 | expect(result.versions_string).to eq(%(created_at: 2025-01-01T00:00:00Z\n---\ngemirro 0.0.1.alpha1,0.0.1,0.0.2.alpha2,0.0.2 checksum)) 17 | 18 | expect(result.versions_hash).to eq( 19 | 'gemirro' => [ 20 | ['gemirro', ::Gem::Version.new('0.0.1.alpha1'), 'ruby'], 21 | ['gemirro', ::Gem::Version.new('0.0.1'), 'ruby'], 22 | ['gemirro', ::Gem::Version.new('0.0.2.alpha2'), 'ruby'], 23 | ['gemirro', ::Gem::Version.new('0.0.2'), 'ruby'] 24 | ] 25 | ) 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH << File.expand_path('../../lib', __FILE__) 2 | 3 | require 'simplecov' 4 | require 'confstruct' 5 | require 'logger' 6 | require 'fakefs/spec_helpers' 7 | 8 | SimpleCov.start do 9 | add_filter '/spec/' 10 | end 11 | -------------------------------------------------------------------------------- /task/manifest.rake: -------------------------------------------------------------------------------- 1 | desc 'Generates the MANIFEST file' 2 | task :manifest do 3 | files = `git ls-files`.split("\n").sort 4 | handle = File.open(File.expand_path('../../MANIFEST', __FILE__), 'w') 5 | 6 | handle.write(files.join("\n")) 7 | handle.close 8 | end 9 | -------------------------------------------------------------------------------- /task/rspec.rake: -------------------------------------------------------------------------------- 1 | require 'rspec/core/rake_task' 2 | 3 | desc 'Run Rspec tests' 4 | RSpec::Core::RakeTask.new(:spec) do |t| 5 | t.rspec_opts = '--color --format documentation --backtrace' 6 | end 7 | -------------------------------------------------------------------------------- /task/rubocop.rake: -------------------------------------------------------------------------------- 1 | require 'rubocop/rake_task' 2 | 3 | desc 'Run RuboCop' 4 | RuboCop::RakeTask.new(:rubocop) 5 | -------------------------------------------------------------------------------- /template/config.rb: -------------------------------------------------------------------------------- 1 | # This is the main configuration file for your RubyGems mirror. Here you can 2 | # change settings such as the location to store Gem files in and what source 3 | # and Gems you'd like to mirror at start. 4 | Gemirro.configuration.configure do 5 | # Define sinatra environment 6 | environment :production 7 | 8 | # The directory to store indexing information as well as the Gem files in. 9 | destination File.expand_path('../public', __FILE__) 10 | 11 | # If you're in development mode your probably want to switch to a debug 12 | # logging level. 13 | logger.level = Logger::INFO 14 | 15 | # If you want to run your server on a specific host and port, you must 16 | # change the following parameters (server_host and server_port). 17 | # 18 | # server.host 'localhost' 19 | # server.port '2000' 20 | 21 | # If you don't want the server to run daemonized, uncomment the following 22 | # server.daemonize false 23 | server.access_log File.expand_path('../logs/access.log', __FILE__) 24 | server.error_log File.expand_path('../logs/error.log', __FILE__) 25 | 26 | # Number of parallel processes while indexing. Too many will kill 27 | # your indexing process prematurely. 28 | # 29 | # update_threads Etc.nprocessors - 1 30 | # update_threads 4 31 | 32 | # If you don't want to generate indexes after each fetched gem. 33 | # 34 | # update_on_fetch false 35 | 36 | # If you don't want to fetch gem if file does not exists when 37 | # running gemirro server. 38 | # 39 | # fetch_gem false 40 | 41 | # If upstream repository requires authentication 42 | # upstream_user 'username' 43 | # upstream_password 'password' 44 | # upstream_domain 'https://internal.com' 45 | 46 | # Enforce the the base_auth 47 | # basic_auth true 48 | 49 | # Set the proxy server if behind the firewall 50 | # proxy 'http://proxy.internal.com:80' 51 | 52 | # Root CA cert location if additional root ca is added 53 | # This will overwrite verfiy_mode. use PEER as default 54 | # rootca '/etc/root_ca.crt' 55 | 56 | # Not verify certificate in case the proxy has self-signed cert 57 | # verify_mode false 58 | 59 | # You must define a source which where gems will be downloaded. 60 | # All gem in the block will be downloaded with the update command. 61 | # Other gems will be downloaded with the server. 62 | define_source 'rubygems', 'https://rubygems.org' do 63 | gem 'rack', '>= 3.0.0' 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /template/logs/.gitkeep: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /template/public/dist/css/gemirro.css: -------------------------------------------------------------------------------- 1 | body { max-width: 620px; margin: 0 auto; padding: 0; font-family: sans-serif; } 2 | 3 | dl { display: grid; grid-template-columns: 1fr min-content; } 4 | 5 | dt { grid-column: 1; margin: 0; padding: 8px 0; border-bottom: 1px solid #e2e2e2; } 6 | dd { grid-column: 2; margin: 0; padding: 8px 0; border-bottom: 1px solid #e2e2e2; } 7 | 8 | dd.full { grid-column: 1/2; border-bottom: 0; } 9 | dd.description, dd.authors, dd.dependencies { grid-column: 1/2; border-bottom: 0; } 10 | 11 | 12 | a, a:visited, a:active, a:hover { color: #007bff; } 13 | .btn { color: #fff; background: #007bff; padding: 5px; border-radius: 4px; text-decoration: none; } 14 | .btn:visited, .btn:active, .btn:hover { color: #fff; } 15 | 16 | 17 | ul { list-style-type: none; margin: 0; padding: 0 0 0 1em; } 18 | 19 | 20 | @media (prefers-color-scheme: dark) { 21 | html, body { background: #16161d; color: #fff; } 22 | a, a:visited, a:active, a:hover { color: lightskyblue; } 23 | .btn { color: #16161d; background: lightskyblue; padding: 5px; border-radius: 4px; text-decoration: none; } 24 | .btn:visited, .btn:active, .btn:hover { color: #16161d; } 25 | } 26 | -------------------------------------------------------------------------------- /template/public/gems/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PierreRambaud/gemirro/b5ffe34c99daadb1f4bdddb5d354cf51043a9088/template/public/gems/.gitkeep -------------------------------------------------------------------------------- /views/gem.erb: -------------------------------------------------------------------------------- 1 | 2 | Back to Gem Index 3 | 4 | 5 | 6 | 7 |
8 | <% gem.by_name do |name, versions| %> 9 |
10 |
11 |

<%= Rack::Utils.escape_html(name) %>

12 |
13 |
14 |

<%= Rack::Utils.escape_html(versions.newest.number) %>

15 |
16 | <% newest_gem = versions.newest %> 17 | <% spec = Gemirro::Utils.spec_for(name, newest_gem.number, newest_gem.platform) %> 18 | <% if spec %> 19 |
20 | <%= Rack::Utils.escape_html(spec.description) %> 21 |
22 | 23 | <% if spec.dependencies.size > 0 %> 24 |
25 |

Dependencies

26 | 33 |
34 | <% end %> 35 | 36 | <% if spec.authors.size > 0 %> 37 |
38 |

Authors

39 | 46 |
47 | <% end %> 48 | <% end %> 49 | 50 | <% versions.each.reverse_each do |version| %> 51 |
52 | 53 | gem install 54 | <%= Rack::Utils.escape_html(version.name) %> 55 | --version "<%= Rack::Utils.escape_html(version.number) %>" 56 | <% unless version.platform =~ /^ruby/i %> 57 | --platform <%= Rack::Utils.escape_html(version.platform) %> 58 | <% end %> 59 | 60 |
61 |
62 | ">Download 63 |
64 | <% end %> 65 |
66 | <% end %> 67 |
68 | -------------------------------------------------------------------------------- /views/index.erb: -------------------------------------------------------------------------------- 1 | 2 | gem sources -a <%= url("/") %> 3 | 4 | <% if gems.any? %> 5 |
6 | <% gems.by_name do |name, versions| %> 7 |
8 |
9 | "> 10 |

<%= Rack::Utils.escape_html(name) %>

11 |
12 |
13 |
14 |

<%= Rack::Utils.escape_html(versions.newest.number) %>

15 |
16 | 17 | <% spec = Gemirro::Utils.spec_for(name, versions.newest.number, versions.newest.platform) %> 18 | <% if spec.is_a?(::Gem::Specification) %> 19 |
20 | <%= Rack::Utils.escape_html(spec.description) %> 21 |
22 | <% end %> 23 | 24 | <% versions.reverse_each.first(5).each do |version| %> 25 |
26 | 27 | gem install 28 | <%= Rack::Utils.escape_html(version.name) %> 29 | <%= "--prerelease" if version.number.to_s.match(/[a-z]/i) %> 30 | --version "<%= Rack::Utils.escape_html(version.number) %>" 31 | <% unless version.platform =~ /^ruby/i %> 32 | --platform <%= Rack::Utils.escape_html(version.platform) %> 33 | <% end %> 34 | 35 |
36 |
37 | ">Download 38 |
39 | <% end %> 40 | <% if versions.size > 5 %> 41 |
"><%= "And %d More..." % [versions.size - 5] %>
42 | <% end %> 43 |
44 | <% end %> 45 |
46 | <% end %> 47 | -------------------------------------------------------------------------------- /views/layout.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Gemirro - a simple ruby gems mirror 10 | 11 | 12 | 13 | 14 |
15 |

Gemirro

16 |
17 | <%= yield %> 18 | 19 | 20 | -------------------------------------------------------------------------------- /views/not_found.erb: -------------------------------------------------------------------------------- 1 | 2 |

Page not found.

3 | 4 | Back to <%= url('/') %> → 5 | --------------------------------------------------------------------------------