|
| 1 | +# frozen_string_literal: true |
| 2 | + |
| 3 | +require "digest" |
| 4 | +require "fileutils" |
| 5 | +require_relative "../package" |
| 6 | + |
| 7 | +class Gem::CompactIndexClient |
| 8 | + # write cache files in a way that is robust to concurrent modifications |
| 9 | + # if digests are given, the checksums will be verified |
| 10 | + class CacheFile |
| 11 | + DEFAULT_FILE_MODE = 0o644 |
| 12 | + private_constant :DEFAULT_FILE_MODE |
| 13 | + |
| 14 | + class Error < RuntimeError; end |
| 15 | + class ClosedError < Error; end |
| 16 | + |
| 17 | + class DigestMismatchError < Error |
| 18 | + def initialize(digests, expected_digests) |
| 19 | + super "Calculated checksums #{digests.inspect} did not match expected #{expected_digests.inspect}." |
| 20 | + end |
| 21 | + end |
| 22 | + |
| 23 | + # Initialize with a copy of the original file, then yield the instance. |
| 24 | + def self.copy(path, &block) |
| 25 | + new(path) do |file| |
| 26 | + file.initialize_digests |
| 27 | + |
| 28 | + path.open("rb") do |s| |
| 29 | + file.open {|f| IO.copy_stream(s, f) } |
| 30 | + end |
| 31 | + |
| 32 | + yield file |
| 33 | + end |
| 34 | + end |
| 35 | + |
| 36 | + # Write data to a temp file, then replace the original file with it verifying the digests if given. |
| 37 | + def self.write(path, data, digests = nil) |
| 38 | + return unless data |
| 39 | + new(path) do |file| |
| 40 | + file.digests = digests |
| 41 | + file.write(data) |
| 42 | + end |
| 43 | + end |
| 44 | + |
| 45 | + attr_reader :original_path, :path |
| 46 | + |
| 47 | + def initialize(original_path, &block) |
| 48 | + @original_path = original_path |
| 49 | + @perm = original_path.file? ? original_path.stat.mode : DEFAULT_FILE_MODE |
| 50 | + @path = original_path.sub(/$/, ".#{$$}.tmp") |
| 51 | + return unless block_given? |
| 52 | + begin |
| 53 | + yield self |
| 54 | + ensure |
| 55 | + close |
| 56 | + end |
| 57 | + end |
| 58 | + |
| 59 | + def size |
| 60 | + path.size |
| 61 | + end |
| 62 | + |
| 63 | + # initialize the digests using CompactIndexClient::SUPPORTED_DIGESTS, or a subset based on keys. |
| 64 | + def initialize_digests(keys = nil) |
| 65 | + @digests = keys ? SUPPORTED_DIGESTS.slice(*keys) : SUPPORTED_DIGESTS.dup |
| 66 | + @digests.transform_values! {|algo_class| Digest(algo_class).new } |
| 67 | + end |
| 68 | + |
| 69 | + # reset the digests so they don't contain any previously read data |
| 70 | + def reset_digests |
| 71 | + @digests&.each_value(&:reset) |
| 72 | + end |
| 73 | + |
| 74 | + # set the digests that will be verified at the end |
| 75 | + def digests=(expected_digests) |
| 76 | + @expected_digests = expected_digests |
| 77 | + |
| 78 | + if @expected_digests.nil? |
| 79 | + @digests = nil |
| 80 | + elsif @digests |
| 81 | + @digests = @digests.slice(*@expected_digests.keys) |
| 82 | + else |
| 83 | + initialize_digests(@expected_digests.keys) |
| 84 | + end |
| 85 | + end |
| 86 | + |
| 87 | + def digests? |
| 88 | + @digests&.any? |
| 89 | + end |
| 90 | + |
| 91 | + # Open the temp file for writing, reusing original permissions, yielding the IO object. |
| 92 | + def open(write_mode = "wb", perm = @perm, &block) |
| 93 | + raise ClosedError, "Cannot reopen closed file" if @closed |
| 94 | + path.open(write_mode, perm) do |f| |
| 95 | + yield digests? ? Gem::Package::DigestIO.new(f, @digests) : f |
| 96 | + end |
| 97 | + end |
| 98 | + |
| 99 | + # Returns false without appending when no digests since appending is too error prone to do without digests. |
| 100 | + def append(data) |
| 101 | + return false unless digests? |
| 102 | + open("a") {|f| f.write data } |
| 103 | + verify && commit |
| 104 | + end |
| 105 | + |
| 106 | + def write(data) |
| 107 | + reset_digests |
| 108 | + open {|f| f.write data } |
| 109 | + commit! |
| 110 | + end |
| 111 | + |
| 112 | + def commit! |
| 113 | + verify || raise(DigestMismatchError.new(@base64digests, @expected_digests)) |
| 114 | + commit |
| 115 | + end |
| 116 | + |
| 117 | + # Verify the digests, returning true on match, false on mismatch. |
| 118 | + def verify |
| 119 | + return true unless @expected_digests && digests? |
| 120 | + @base64digests = @digests.transform_values!(&:base64digest) |
| 121 | + @digests = nil |
| 122 | + @base64digests.all? {|algo, digest| @expected_digests[algo] == digest } |
| 123 | + end |
| 124 | + |
| 125 | + # Replace the original file with the temp file without verifying digests. |
| 126 | + # The file is permanently closed. |
| 127 | + def commit |
| 128 | + raise ClosedError, "Cannot commit closed file" if @closed |
| 129 | + FileUtils.mv(path, original_path) |
| 130 | + @closed = true |
| 131 | + end |
| 132 | + |
| 133 | + # Remove the temp file without replacing the original file. |
| 134 | + # The file is permanently closed. |
| 135 | + def close |
| 136 | + return if @closed |
| 137 | + FileUtils.remove_file(path) if @path&.file? |
| 138 | + @closed = true |
| 139 | + end |
| 140 | + end |
| 141 | +end |
0 commit comments