summaryrefslogtreecommitdiff
path: root/lib/bundler/compact_index_client/cache_file.rb
blob: 5988bc91b3bac440476c8f42be5bc39b63266096 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
# frozen_string_literal: true

require_relative "../vendored_fileutils"
require "rubygems/package"

module Bundler
  class CompactIndexClient
    # write cache files in a way that is robust to concurrent modifications
    # if digests are given, the checksums will be verified
    class CacheFile
      DEFAULT_FILE_MODE = 0o644
      private_constant :DEFAULT_FILE_MODE

      class Error < RuntimeError; end
      class ClosedError < Error; end

      class DigestMismatchError < Error
        def initialize(digests, expected_digests)
          super "Calculated checksums #{digests.inspect} did not match expected #{expected_digests.inspect}."
        end
      end

      # Initialize with a copy of the original file, then yield the instance.
      def self.copy(path, &block)
        new(path) do |file|
          file.initialize_digests

          SharedHelpers.filesystem_access(path, :read) do
            path.open("rb") do |s|
              file.open {|f| IO.copy_stream(s, f) }
            end
          end

          yield file
        end
      end

      # Write data to a temp file, then replace the original file with it verifying the digests if given.
      def self.write(path, data, digests = nil)
        return unless data
        new(path) do |file|
          file.digests = digests
          file.write(data)
        end
      end

      attr_reader :original_path, :path

      def initialize(original_path, &block)
        @original_path = original_path
        @perm = original_path.file? ? original_path.stat.mode : DEFAULT_FILE_MODE
        @path = original_path.sub(/$/, ".#{$$}.tmp")
        return unless block_given?
        begin
          yield self
        ensure
          close
        end
      end

      def size
        path.size
      end

      # initialize the digests using CompactIndexClient::SUPPORTED_DIGESTS, or a subset based on keys.
      def initialize_digests(keys = nil)
        @digests = keys ? SUPPORTED_DIGESTS.slice(*keys) : SUPPORTED_DIGESTS.dup
        @digests.transform_values! {|algo_class| SharedHelpers.digest(algo_class).new }
      end

      # reset the digests so they don't contain any previously read data
      def reset_digests
        @digests&.each_value(&:reset)
      end

      # set the digests that will be verified at the end
      def digests=(expected_digests)
        @expected_digests = expected_digests

        if @expected_digests.nil?
          @digests = nil
        elsif @digests
          @digests = @digests.slice(*@expected_digests.keys)
        else
          initialize_digests(@expected_digests.keys)
        end
      end

      # remove this method when we stop generating md5 digests for legacy etags
      def md5
        @digests && @digests["md5"]
      end

      def digests?
        @digests&.any?
      end

      # Open the temp file for writing, reusing original permissions, yielding the IO object.
      def open(write_mode = "wb", perm = @perm, &block)
        raise ClosedError, "Cannot reopen closed file" if @closed
        SharedHelpers.filesystem_access(path, :write) do
          path.open(write_mode, perm) do |f|
            yield digests? ? Gem::Package::DigestIO.new(f, @digests) : f
          end
        end
      end

      # Returns false without appending when no digests since appending is too error prone to do without digests.
      def append(data)
        return false unless digests?
        open("a") {|f| f.write data }
        verify && commit
      end

      def write(data)
        reset_digests
        open {|f| f.write data }
        commit!
      end

      def commit!
        verify || raise(DigestMismatchError.new(@base64digests, @expected_digests))
        commit
      end

      # Verify the digests, returning true on match, false on mismatch.
      def verify
        return true unless @expected_digests && digests?
        @base64digests = @digests.transform_values!(&:base64digest)
        @digests = nil
        @base64digests.all? {|algo, digest| @expected_digests[algo] == digest }
      end

      # Replace the original file with the temp file without verifying digests.
      # The file is permanently closed.
      def commit
        raise ClosedError, "Cannot commit closed file" if @closed
        SharedHelpers.filesystem_access(original_path, :write) do
          FileUtils.mv(path, original_path)
        end
        @closed = true
      end

      # Remove the temp file without replacing the original file.
      # The file is permanently closed.
      def close
        return if @closed
        FileUtils.remove_file(path) if @path&.file?
        @closed = true
      end
    end
  end
end