1# frozen_string_literal: true
2
3module Gitlab
4  module Gpg
5    extend self
6
7    CleanupError = Class.new(StandardError)
8    BG_CLEANUP_RUNTIME_S = 10
9    FG_CLEANUP_RUNTIME_S = 1
10
11    MUTEX = Mutex.new
12
13    module CurrentKeyChain
14      extend self
15
16      def add(key)
17        GPGME::Key.import(key)
18      end
19
20      def fingerprints_from_key(key)
21        import = GPGME::Key.import(key)
22
23        return [] if import.imported == 0
24
25        import.imports.map(&:fingerprint)
26      end
27    end
28
29    def fingerprints_from_key(key)
30      using_tmp_keychain do
31        CurrentKeyChain.fingerprints_from_key(key)
32      end
33    end
34
35    def primary_keyids_from_key(key)
36      using_tmp_keychain do
37        fingerprints = CurrentKeyChain.fingerprints_from_key(key)
38
39        GPGME::Key.find(:public, fingerprints).map { |raw_key| raw_key.primary_subkey.keyid }
40      end
41    end
42
43    def subkeys_from_key(key)
44      using_tmp_keychain do
45        fingerprints = CurrentKeyChain.fingerprints_from_key(key)
46        raw_keys     = GPGME::Key.find(:public, fingerprints)
47
48        raw_keys.each_with_object({}) do |raw_key, grouped_subkeys|
49          primary_subkey_id = raw_key.primary_subkey.keyid
50
51          grouped_subkeys[primary_subkey_id] = raw_key.subkeys[1..].map do |s|
52            { keyid: s.keyid, fingerprint: s.fingerprint }
53          end
54        end
55      end
56    end
57
58    def user_infos_from_key(key)
59      using_tmp_keychain do
60        fingerprints = CurrentKeyChain.fingerprints_from_key(key)
61
62        GPGME::Key.find(:public, fingerprints).flat_map do |raw_key|
63          raw_key.uids.each_with_object([]) do |uid, arr|
64            name = uid.name.force_encoding('UTF-8')
65            email = uid.email.force_encoding('UTF-8')
66            arr << { name: name, email: email.downcase } if name.valid_encoding? && email.valid_encoding?
67          end
68        end
69      end
70    end
71
72    # Allows thread safe switching of temporary keychain files
73    #
74    # 1. The current thread may use nesting of temporary keychain
75    # 2. Another thread needs to wait for the lock to be released
76    def using_tmp_keychain(&block)
77      if MUTEX.locked? && MUTEX.owned?
78        optimistic_using_tmp_keychain(&block)
79      else
80        ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
81          MUTEX.synchronize do
82            optimistic_using_tmp_keychain(&block)
83          end
84        end
85      end
86    end
87
88    # 1. Returns the custom home directory if one has been set by calling
89    #    `GPGME::Engine.home_dir=`
90    # 2. Returns the default home directory otherwise
91    def current_home_dir
92      GPGME::Engine.info.first.home_dir || GPGME::Engine.dirinfo('homedir')
93    end
94
95    private
96
97    def optimistic_using_tmp_keychain
98      previous_dir = current_home_dir
99      tmp_dir = Dir.mktmpdir
100      GPGME::Engine.home_dir = tmp_dir
101      tmp_keychains_created.increment
102
103      yield
104    ensure
105      GPGME::Engine.home_dir = previous_dir
106
107      begin
108        cleanup_tmp_dir(tmp_dir)
109      rescue CleanupError => e
110        folder_contents = Dir.children(tmp_dir)
111        # This means we left a GPG-agent process hanging. Logging the problem in
112        # sentry will make this more visible.
113        Gitlab::ErrorTracking.track_and_raise_for_dev_exception(e,
114                                       issue_url: 'https://gitlab.com/gitlab-org/gitlab/issues/20918',
115                                       tmp_dir: tmp_dir, contents: folder_contents)
116      end
117
118      tmp_keychains_removed.increment unless File.exist?(tmp_dir)
119    end
120
121    def cleanup_tmp_dir(tmp_dir)
122      # Retry when removing the tmp directory failed, as we may run into a
123      # race condition:
124      # The `gpg-agent` agent process may clean up some files as well while
125      # `FileUtils.remove_entry` is iterating the directory and removing all
126      # its contained files and directories recursively, which could raise an
127      # error.
128      # Failing to remove the tmp directory could leave the `gpg-agent` process
129      # running forever.
130      #
131      # 15 tries will never complete within the maximum time with exponential
132      # backoff. So our limit is the runtime, not the number of tries.
133      Retriable.retriable(max_elapsed_time: cleanup_time, base_interval: 0.1, tries: 15) do
134        FileUtils.remove_entry(tmp_dir) if File.exist?(tmp_dir)
135      end
136    rescue StandardError => e
137      raise CleanupError, e
138    end
139
140    def cleanup_time
141      Gitlab::Runtime.sidekiq? ? BG_CLEANUP_RUNTIME_S : FG_CLEANUP_RUNTIME_S
142    end
143
144    def tmp_keychains_created
145      Gitlab::Metrics.counter(:gpg_tmp_keychains_created_total, 'The number of temporary GPG keychains created')
146    end
147
148    def tmp_keychains_removed
149      Gitlab::Metrics.counter(:gpg_tmp_keychains_removed_total, 'The number of temporary GPG keychains removed')
150    end
151  end
152end
153