# Copyright (c) 2014 Perforce Software, Inc. All rights reserved. # # vim:ts=2:sw=2:et:si:ai: # # util.rb # # require 'date' require 'ostruct' require 'P4' require 'p4_error' require 'auth' module HelixVersioningEngine # TODO most of the methods in this module should probably be removed # # Namespace for p4ruby conventions used in the P4 Web API. module Util PROPERTIES = [ :password, :port, :user, :api_level, :charset, :client, :host, :handler, :maxlocktime, :maxresults, :maxscanrows, :prog, :ticketfile ] # Creates your p4 connection using some common forms. # # If you call open with a block, this will call connect before your block # executes, and disconnect afterwards. # # If you do not call open with a block, it is up to the caller to connect # and disconnect. (It's assumed you are calling w/o a block because you want # to manage when the connection actually needs to happen.) def self.open(options = {}) p4 = create_p4(options) # Again, if we're calling using the block, we'll connect and disconnect. # Otherwise, just return the created p4 object. if block_given? begin p4.connect yield p4 rescue P4Exception => ex puts "Util.open ex #{ex}" raise make_p4_error(p4) end else return p4 end ensure p4.disconnect if block_given? && p4 && p4.connected? end # Check for P4 errors def self.error?(p4) !p4.errors.empty? end # Raise an exception if necessary on a P4 Error def self.raise_error(p4) err = p4.messages.find { |m| m.severity > 2 } fail Perforce::P4Error.new(err.msgid, err.severity, err.to_s) end # Assert that no relative directory or Perforce wildcard is in use for each # string in the `paths` array. def self.assert_no_special_paths(paths) paths.each do |path| if Util.wildcard?(path) fail P4Error.default_error("The path '#{path}' contains a Perforce "\ 'wildcard, which is not allowed') end if Util.relative_dir?(path) fail P4Error.default_error("The path '#{path}' is a relative " \ 'directory, which is not allowed') end end end # Returns true when `str` contains a Perforce wildcard def self.wildcard?(str) (str =~ /\.\.\./ || str =~ /\*/) != nil end # Returns true if str is '.' or '..' def self.relative_dir?(str) str == '.' || str == '..' end # Returns true if the string looks like a Perforce authentication ticket. def self.p4_ticket?(str) /^[a-zA-Z0-9]{32,}$/.match(str) != nil end # We only count uuids that were returned via SecureRandom.uuid used to # generate internal security tokens. def self.uuid?(str) /^[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}$/ .match(str) end def self.init_changelist(p4, description) change_spec = p4.fetch_change change_spec._description = description results = p4.save_change(change_spec) results[0].gsub(/^Change (\d+) created./, '\1') end def self.save_content(root, depot_path, content) local_file = local_path(depot_path, root) dir = File.dirname(local_file) FileUtils.mkpath(dir) unless Dir.exist?(dir) IO.write(local_file, content) end def self.existing_path?(existing_results, depot_path) existing_results.any? do |result| result['depotFile'] == depot_path end end def self.mark_change(type, p4, change_id, root, depot_path) local_file = local_path(depot_path, root) results = p4.run(type, '-c', change_id, local_file) end def self.local_path(depot_path, root) stripped = depot_path.gsub(/^\/+/, '') File.join(root, stripped) end def self.singular(plural) matches = { branches: 'branch', clients: 'client', depots: 'depot', groups: 'group', jobs: 'job', labels: 'label', protects: 'protect', servers: 'server', streams: 'stream', triggers: 'trigger', users: 'user' } matches[plural.to_sym] end # Data 'normalization' # # A very annoying aspect of our tagged output is that it often is *slightly* # different between 'plural' and spec forms. This logic is made available # as an option in this API BRANCHES_MAP = { 'branch' => 'Branch' } BRANCHES_DATES = %w(Update Access) CHANGES_MAP = { 'path' => 'Path', 'change' => 'Change', 'time' => 'Date', 'client' => 'Client', 'user' => 'User', 'status' => 'Status', 'type' => 'Type', 'changeType' => 'Type', 'desc' => 'Description' } CHANGES_DATES = ['Date'] CLIENTS_MAP = { 'client' => 'Client' } CLIENTS_DATES = %w(Update Access) DEPOTS_MAP = { 'name' => 'Depot', 'time' => 'Date', 'type' => 'Type', 'map' => 'Map', 'desc' => 'Description' } DEPOTS_DATES = ['Date'] # Note that individual files are handled differently, since the keys are # prefixed DESCRIBE_MAP = CHANGES_MAP DESCRIBE_DATES = CHANGES_DATES DIRS_MAP = { 'dir' => 'Dir' } FILES_MAP = { 'depotFile' => 'DepotFile', 'rev' => 'Revision', 'change' => 'Change', 'action' => 'Action', 'type' => 'Type', 'time' => 'Date' } FILES_DATES = %w(Date) GROUPS_MAP = { 'group' => 'Group', 'maxResults' => 'MaxResults', 'maxScanRows' => 'MaxScanRows', 'maxLockTime' => 'MaxLockTime', 'timeout' => 'Timeout', 'desc' => 'Description', 'user' => 'User', 'isSubGroup' => 'IsSubGroup', 'isOwner' => 'IsOwner', 'isUser' => 'IsUser', 'passTimeout' => 'PasswordTimeout' } JOBS_MAP = {} JOBS_DATES = %w(Date) LABELS_MAP = { 'label' => 'Label' } LABELS_DATES = %w(Update Access) # This isn't supported but kept here for reference. We *might* support this. # OPENED_MAP = { # 'change' => 'Change', # 'client' => 'Client', # 'user' => 'User' # } STREAMS_MAP = { 'desc' => 'Description' } STREAMS_DATES = %w(Update Access) USERS_MAP = { 'passwordChange' => 'PasswordChange' } USERS_DATES = %w(Update Access PasswordChange) # For each 'spec type' returns a function that will ensure that: # - case is consistent between different return calls, prefer 'spec' types # - dates are returned # # Parameters: # - spec_type is the 'plural' form of spec class, e.g., 'users', 'clients' # - offset is the current server offset, retrieve this via 'p4 info' command # # rubocop:disable Metrics/CyclomaticComplexity def self.normalizer(spec_type, offset) case spec_type when 'branches' make_normalizer(BRANCHES_MAP, offset, BRANCHES_DATES) when 'changes' make_normalizer(CHANGES_MAP, offset, CHANGES_DATES) when 'clients' make_normalizer(CLIENTS_MAP, offset, CLIENTS_DATES) when 'depots' make_normalizer(DEPOTS_MAP, offset, DEPOTS_DATES) when 'describe' # This only affects 'base' fields, not fields related to each file base_normalize = make_normalizer(DESCRIBE_MAP, offset, DESCRIBE_DATES) lambda do |results| results = base_normalize.call(results) Util.normalize_describe_files(results) results end when 'dirs' make_normalizer(DIRS_MAP, offset) when 'files' make_normalizer(FILES_MAP, offset, FILES_DATES) when 'groups' date_and_case = make_normalizer(GROUPS_MAP, offset) lambda do |results| results = date_and_case.call(results) Util.replace_unset_with_0(results) results end when 'jobs' make_normalizer(JOBS_MAP, offset, JOBS_DATES) when 'labels' make_normalizer(LABELS_MAP, offset, LABELS_DATES) when 'streams' make_normalizer(STREAMS_MAP, offset, STREAMS_DATES) when 'users' make_normalizer(USERS_MAP, offset, USERS_DATES) else # By default, do no translation return ->(x) { x } end end def self.make_normalizer(field_map, offset, date_fields = nil) lambda do |results| return unless results # We need to ignore any instances of P4::Spec since that will 'validate' # fields on it's accessor methods results.map! do |result| if result.class <= P4::Spec spec = result result = Hash.new result.merge!(spec) else result end end results.each do |result| update_fields(field_map, result) update_dates(date_fields, result, offset) end results end end # These are 'single values' of properties of Group objects we use while # collating them. GROUP_PROPERTIES = %w( Group MaxResults MaxScanRows MaxLockTime Timeout PasswordTimeout ) # Will apply the normalizer to set up a consistent field naming and date # format, but then 'collates' the output into a single list of 'groups'. # # The groups output is kind of funny, because it basically lists users, # with group access fields. It's very non-obvious, and in the end, most # clients will need to run logic like this anyway. def self.collate_group_results(results) collated = results.group_by { |x| x['Group'] } updated = collated.map do |_key, items| # The first item sets most of the values, we then figure out array # subvalues group = {} GROUP_PROPERTIES.each do |p| group[p] = items.first[p] if items.first.key?(p) end group['Users'] = items.find_all { |x| x['IsUser'] == '1' }.map { |x| x['User'] } group['Subgroups'] = items.find_all { |x| x['IsSubGroup'] == '1' }.map { |x| x['User'] } group['Owners'] = items.find_all { |x| x['IsOwner'] == '1' }.map { |x| x['User'] } group end results.replace(updated) end def self.replace_unset_with_0(results) results.each do |r| r.each_key { |k| r[k] = '0' if r[k] == 'unset' } end end FILE_KEYS = %w(depotFile action type rev digest fileSize) # Each file entry in the base describe tagged output contains several # fields, suffixed with an index value. # # P4 Ruby returns the individual 'index fields' already collated by the # key type. def self.normalize_describe_files(results) results.each do |r| # We have to collect each index we discover as we iterate over the keys idx_2_file = {} FILE_KEYS.each do |key| if r.key?(key) r[key].each_index do |idx| mapped_key = Util.map_describe_file_key(key) idx_2_file[idx] = {} unless idx_2_file.key?(idx) idx_2_file[idx][mapped_key] = r[key][idx] end r.delete(key) end end r['Files'] = idx_2_file.values end results end def self.map_describe_file_key(key) case key when /^depotFile$/ return 'DepotFile' when /^action$/ return 'Action' when /^type$/ return 'Type' when /^rev$/ return 'Revision' when /^fileSize$/ return 'FileSize' when /^digest$/ return 'Digest' end end # Returns true if .to_i will actually convert this string to an integer def self.i?(str) (str =~ /\A[-+]?\d+\z/) end # Returns true if we can parse the string as a date def self.date_str?(str) Date.parse(str) rescue false end # In general we get dates without any offset information, which has to be # retrieved via the serverDate field from 'p4 info' def self.p4_date_to_i(offset, p4date) DateTime.parse("#{p4date} #{offset}").to_time.to_i end def self.p4_date_offset(str) DateTime.parse(str).zone end def self.resolve_host(env, settings) if env.key?('P4_HOST') && settings.allow_env_p4_config env['P4_HOST'] else settings.p4['host'] end end def self.resolve_port(env, settings) if env.key?('P4_PORT') && settings.allow_env_p4_config env['P4_PORT'] else settings.p4['port'] end end def self.resolve_charset(env, settings) if env.key?('P4_CHARSET') && settings.allow_env_p4_config env['P4_CHARSET'] else settings.p4['charset'] if settings.p4.key?('charset') end end def self.resolve_password(env, settings) password = env['AUTH_CREDENTIALS'].last if !Util.uuid?(password) password else Auth.read_token(password, settings)['ticket'] end end private def self.create_p4(options) p4 = P4.new init_p4(p4) PROPERTIES.each do|key| p4.method(key.to_s + '=').call(options[key]) if options[key] end # From the WTF department, if you think you don't want a charset set, # make damn sure P4Ruby isn't going to get confused. p4.charset = nil if p4.charset == 'none' # Make P4Ruby only raise exceptions if there are errors. Warnings # (such as 'no such file(s)' don't get the same treatment. p4.exception_level = P4::RAISE_ERRORS p4 end # Before we do anything, clear out any environment variables that may have # leaked in from your environment # # Some of our HTTP APIs (e.g, Git Fusion) *do* utilize environment setups # for a system user, that may be installed alongside this API. We want this # system to always explicitly state which configuration to use. (So, it's # not just a concern of our development environments.) def self.init_p4(p4) p4.client = 'invalid' p4.port = '' p4.host = '' p4.password = '' end def self.make_p4_error(p4) if p4.messages && p4.messages.first m = p4.messages.first HelixVersioningEngine::P4Error.new(m.msgid, m.severity, m.to_s) else HelixVersioningEngine::P4Error.default_error($ERROR_INFO.to_s) end end def self.update_fields(field_map, result) field_map.each_key do |key| next unless result.key?(key) val = result[key] result.delete(key) result[field_map[key]] = val end end # Ensures that all dates looks like epoch second (integer) times def self.update_dates(date_fields, result, offset) if date_fields date_fields.each do |date_key| next unless result.key?(date_key) val = result[date_key] if i?(val) result[date_key] = val.to_i elsif date_str?(val) result[date_key] = p4_date_to_i(offset, val) end end end end end end