longleaf 0.3.0 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/.circleci/config.yml +12 -2
  3. data/README.md +11 -1
  4. data/lib/longleaf/candidates/manifest_digest_provider.rb +17 -0
  5. data/lib/longleaf/candidates/single_digest_provider.rb +13 -0
  6. data/lib/longleaf/cli.rb +49 -36
  7. data/lib/longleaf/commands/register_command.rb +3 -3
  8. data/lib/longleaf/commands/validate_config_command.rb +1 -1
  9. data/lib/longleaf/events/register_event.rb +8 -4
  10. data/lib/longleaf/helpers/case_insensitive_hash.rb +38 -0
  11. data/lib/longleaf/helpers/digest_helper.rb +7 -1
  12. data/lib/longleaf/helpers/s3_uri_helper.rb +86 -0
  13. data/lib/longleaf/helpers/selection_options_parser.rb +189 -0
  14. data/lib/longleaf/helpers/service_date_helper.rb +29 -1
  15. data/lib/longleaf/indexing/sequel_index_driver.rb +2 -20
  16. data/lib/longleaf/models/app_fields.rb +4 -2
  17. data/lib/longleaf/models/filesystem_metadata_location.rb +56 -0
  18. data/lib/longleaf/models/filesystem_storage_location.rb +52 -0
  19. data/lib/longleaf/models/metadata_location.rb +47 -0
  20. data/lib/longleaf/models/metadata_record.rb +3 -1
  21. data/lib/longleaf/models/s3_storage_location.rb +133 -0
  22. data/lib/longleaf/models/service_fields.rb +4 -0
  23. data/lib/longleaf/models/storage_location.rb +17 -48
  24. data/lib/longleaf/models/storage_types.rb +9 -0
  25. data/lib/longleaf/preservation_services/rsync_replication_service.rb +9 -11
  26. data/lib/longleaf/preservation_services/s3_replication_service.rb +143 -0
  27. data/lib/longleaf/services/application_config_deserializer.rb +26 -4
  28. data/lib/longleaf/services/application_config_validator.rb +17 -6
  29. data/lib/longleaf/services/configuration_validator.rb +64 -4
  30. data/lib/longleaf/services/filesystem_location_validator.rb +16 -0
  31. data/lib/longleaf/services/metadata_deserializer.rb +41 -9
  32. data/lib/longleaf/services/metadata_persistence_manager.rb +3 -2
  33. data/lib/longleaf/services/metadata_serializer.rb +94 -13
  34. data/lib/longleaf/services/metadata_validator.rb +76 -0
  35. data/lib/longleaf/services/s3_location_validator.rb +19 -0
  36. data/lib/longleaf/services/service_definition_validator.rb +16 -8
  37. data/lib/longleaf/services/service_manager.rb +7 -15
  38. data/lib/longleaf/services/service_mapping_validator.rb +26 -15
  39. data/lib/longleaf/services/storage_location_manager.rb +38 -12
  40. data/lib/longleaf/services/storage_location_validator.rb +41 -30
  41. data/lib/longleaf/specs/config_builder.rb +10 -3
  42. data/lib/longleaf/specs/config_validator_helpers.rb +16 -0
  43. data/lib/longleaf/specs/metadata_builder.rb +1 -0
  44. data/lib/longleaf/version.rb +1 -1
  45. data/longleaf.gemspec +3 -1
  46. data/mkdocs.yml +2 -1
  47. metadata +48 -8
  48. data/.travis.yml +0 -4
  49. data/lib/longleaf/services/storage_path_validator.rb +0 -16
@@ -0,0 +1,133 @@
1
+ require 'longleaf/models/storage_location'
2
+ require 'longleaf/models/storage_types'
3
+ require 'longleaf/helpers/s3_uri_helper'
4
+ require 'uri'
5
+ require 'aws-sdk-s3'
6
+
7
+ module Longleaf
8
+ # A storage location in a s3 bucket
9
+ #
10
+ # Optionally, the location configuration may include an "options" sub-hash in order to provide
11
+ # any of the s3 client options specified in Client initializer:
12
+ # https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/S3/Client.html#constructor_details
13
+
14
+ class S3StorageLocation < StorageLocation
15
+
16
+ IS_URI_REGEX = /\A#{URI::regexp}\z/
17
+
18
+ CLIENT_OPTIONS_FIELD = 'options'
19
+
20
+ # @param name [String] the name of this storage location
21
+ # @param config [Hash] hash containing the configuration options for this location
22
+ # @param md_loc [MetadataLocation] metadata location associated with this storage location
23
+ def initialize(name, config, md_loc)
24
+ super(name, config, md_loc)
25
+
26
+ @bucket_name = S3UriHelper.extract_bucket(@path)
27
+ if @bucket_name.nil?
28
+ raise ArgumentError.new("Unable to identify bucket for location #{@name} from path #{@path}")
29
+ end
30
+
31
+ # Force path to always end with a slash
32
+ @path += '/' unless @path.end_with?('/')
33
+
34
+ custom_options = config[CLIENT_OPTIONS_FIELD]
35
+ if custom_options.nil?
36
+ @client_options = Hash.new
37
+ else
38
+ # Clone options and convert keys to symbols
39
+ @client_options = Hash[custom_options.map { |(k,v)| [k.to_sym,v] } ]
40
+ end
41
+ # If no region directly configured, use region from path
42
+ if !@client_options.key?(:region)
43
+ region = S3UriHelper.extract_region(@path)
44
+ @client_options[:region] = region unless region.nil?
45
+ end
46
+
47
+ @subpath_prefix = S3UriHelper.extract_path(@path)
48
+ end
49
+
50
+ # @return the storage type for this location
51
+ def type
52
+ StorageTypes::S3_STORAGE_TYPE
53
+ end
54
+
55
+ # Get that absolute path to the file associated with the provided metadata path
56
+ # @param md_path [String] metadata file path
57
+ # @raise [ArgumentError] if the md_path is not in this storage location
58
+ # @return [String] the path for the file associated with this metadata
59
+ def get_path_from_metadata_path(md_path)
60
+ raise ArgumentError.new("A file_path parameter is required") if md_path.nil? || md_path.empty?
61
+
62
+ rel_path = @metadata_location.relative_file_path_for(md_path)
63
+
64
+ URI.join(@path, rel_path).to_s
65
+ end
66
+
67
+ # Checks that the path and metadata path defined in this location are available
68
+ # @raise [StorageLocationUnavailableError] if the storage location is not available
69
+ def available?
70
+ begin
71
+ s3_client().head_bucket({ bucket: @bucket_name, use_accelerate_endpoint: false })
72
+ rescue StandardError => e
73
+ raise StorageLocationUnavailableError.new("Destination bucket #{@bucket_name} does not exist " \
74
+ + "or is not accessible: #{e.message}")
75
+ end
76
+ @metadata_location.available?
77
+ end
78
+
79
+ # Get the file path relative to this location
80
+ # @param file_path [String] file path
81
+ # @return the file path relative to this location
82
+ # @raise [ArgumentError] if the file path is not contained by this location
83
+ def relativize(file_path)
84
+ raise ArgumentError.new("Must provide a non-nil path to relativize") if file_path.nil?
85
+
86
+ if file_path.start_with?(@path)
87
+ file_path[@path.length..-1]
88
+ else
89
+ if file_path =~ IS_URI_REGEX
90
+ raise ArgumentError.new("Path #{file_path} is not contained by #{@name}")
91
+ else
92
+ # path already relative
93
+ file_path
94
+ end
95
+ end
96
+ end
97
+
98
+ # Prefixes the provided path with the query path portion of the location's path
99
+ # after the bucket uri, used to place relative paths into the same sub-URL of a bucket.
100
+ # For example:
101
+ # Given a location with 'path' http://example.s3-amazonaws.com/env/test/
102
+ # Where rel_path = 'path/to/text.txt'
103
+ # The result would be 'env/test/path/to/text.txt'
104
+ # @param rel_path relative path to work with
105
+ # @return the given relative path prefixed with the path portion of the storage location path
106
+ def relative_to_bucket_path(rel_path)
107
+ raise ArgumentError.new("Must provide a non-nil path") if rel_path.nil?
108
+
109
+ if @subpath_prefix.nil?
110
+ return rel_path
111
+ end
112
+
113
+ @subpath_prefix + rel_path
114
+ end
115
+
116
+ # @return the bucket used by this storage location
117
+ def s3_bucket
118
+ if @bucket.nil?
119
+ @s3 = Aws::S3::Resource.new(client: s3_client())
120
+ @bucket = @s3.bucket(@bucket_name)
121
+ end
122
+ @bucket
123
+ end
124
+
125
+ # @return the s3 client used by this storage locatio
126
+ def s3_client
127
+ if @client.nil?
128
+ @client = Aws::S3::Client.new(**@client_options)
129
+ end
130
+ @client
131
+ end
132
+ end
133
+ end
@@ -8,5 +8,9 @@ module Longleaf
8
8
 
9
9
  REPLICATE_TO = 'to'
10
10
  DIGEST_ALGORITHMS = 'algorithms'
11
+
12
+ COLLISION_PROPERTY = "replica_collision_policy"
13
+ DEFAULT_COLLISION_POLICY = "replace"
14
+ VALID_COLLISION_POLICIES = ["replace"]
11
15
  end
12
16
  end
@@ -1,34 +1,23 @@
1
- require 'longleaf/services/metadata_serializer'
1
+ require 'longleaf/models/app_fields'
2
2
 
3
3
  module Longleaf
4
4
  # Representation of a configured storage location
5
5
  class StorageLocation
6
+ AF ||= Longleaf::AppFields
7
+
6
8
  attr_reader :name
7
9
  attr_reader :path
8
- attr_reader :metadata_path
9
- attr_reader :metadata_digests
10
+ attr_reader :metadata_location
10
11
 
11
12
  # @param name [String] the name of this storage location
12
- # @param path [String] absolute path where the storage location is located
13
- # @param metadata_path [String] absolute path where the metadata for files in this location will be stored.
14
- # @param metadata_digests list of digest algorithms to use for metadata file digests in this location.
15
- def initialize(name:, path:, metadata_path:, metadata_digests: [])
16
- raise ArgumentError.new("Parameters name, path and metadata_path are required") unless name && path && metadata_path
17
-
18
- @path = path
19
- @path += '/' unless @path.end_with?('/')
13
+ # @param config [Hash] hash containing the configuration options for this location
14
+ # @param md_loc [MetadataLocation] metadata location associated with this storage location
15
+ def initialize(name, config, md_loc)
16
+ raise ArgumentError.new("Config parameter is required") unless config
17
+ @path = config[AF::LOCATION_PATH]
20
18
  @name = name
21
- @metadata_path = metadata_path
22
- @metadata_path += '/' unless @metadata_path.end_with?('/')
23
-
24
- if metadata_digests.nil?
25
- @metadata_digests = []
26
- elsif metadata_digests.is_a?(String)
27
- @metadata_digests = [metadata_digests.downcase]
28
- else
29
- @metadata_digests = metadata_digests.map(&:downcase)
30
- end
31
- DigestHelper::validate_algorithms(@metadata_digests)
19
+ raise ArgumentError.new("Parameters name, path and metadata location are required") unless @name && @path && md_loc
20
+ @metadata_location = md_loc
32
21
  end
33
22
 
34
23
  # Get the path for the metadata file for the given file path located in this storage location.
@@ -39,35 +28,15 @@ module Longleaf
39
28
  raise ArgumentError.new("Provided file path is not contained by storage location #{@name}: #{file_path}") \
40
29
  unless file_path.start_with?(@path)
41
30
 
42
- md_path = file_path.sub(/^#{@path}/, @metadata_path)
43
- # If the file_path is to a file, then add metadata suffix.
44
- if md_path.end_with?('/')
45
- md_path
46
- else
47
- md_path + MetadataSerializer::metadata_suffix
48
- end
49
- end
50
-
51
- # Get the metadata path for the provided file path located in this storage location.
52
- # @param md_path [String] metadata file path
53
- # @raise [ArgumentError] if the md_path is not in this storage location
54
- # @return [String] the path for the file associated with this metadata
55
- def get_path_from_metadata_path(md_path)
56
- raise ArgumentError.new("A file_path parameter is required") if md_path.nil? || md_path.empty?
57
- raise ArgumentError.new("Provided metadata path is not contained by storage location #{@name}: #{md_path}") \
58
- unless md_path&.start_with?(@metadata_path)
31
+ rel_file_path = relativize(file_path)
59
32
 
60
- file_path = md_path.sub(/^#{@metadata_path}/, @path)
61
- file_path.sub(/#{MetadataSerializer::metadata_suffix}$/, '')
33
+ @metadata_location.metadata_path_for(rel_file_path)
62
34
  end
63
35
 
64
- # Checks that the path and metadata path defined in this location are available
65
- # @raise [StorageLocationUnavailableError] if the storage location is not available
66
- def available?
67
- raise StorageLocationUnavailableError.new("Path does not exist or is not a directory: #{@path}")\
68
- unless Dir.exist?(@path)
69
- raise StorageLocationUnavailableError.new("Metadata path does not exist or is not a directory: #{@metadata_path}")\
70
- unless Dir.exist?(@metadata_path)
36
+ # @param [String] path to check
37
+ # @return true if the file path is contained by the path for this location
38
+ def contains?(file_path)
39
+ file_path.start_with?(@path)
71
40
  end
72
41
  end
73
42
  end
@@ -0,0 +1,9 @@
1
+ module Longleaf
2
+ # Storage type constants
3
+ class StorageTypes
4
+ FILESYSTEM_STORAGE_TYPE = 'filesystem'
5
+ S3_STORAGE_TYPE = 's3'
6
+
7
+ DEFAULT_STORAGE_TYPE = FILESYSTEM_STORAGE_TYPE
8
+ end
9
+ end
@@ -4,6 +4,7 @@ require 'longleaf/errors'
4
4
  require 'longleaf/models/file_record'
5
5
  require 'longleaf/models/service_fields'
6
6
  require 'longleaf/events/register_event'
7
+ require 'longleaf/candidates/single_digest_provider'
7
8
  require 'open3'
8
9
 
9
10
  module Longleaf
@@ -21,10 +22,7 @@ module Longleaf
21
22
  # "help", etc. Command will always include "-R". Default "-a".
22
23
  class RsyncReplicationService
23
24
  include Longleaf::Logging
24
-
25
- COLLISION_PROPERTY = "replica_collision_policy"
26
- DEFAULT_COLLISION_POLICY = "replace"
27
- VALID_COLLISION_POLICIES = ["replace"]
25
+ SF ||= Longleaf::ServiceFields
28
26
 
29
27
  RSYNC_COMMAND_PROPERTY = "rsync_command"
30
28
  DEFAULT_COMMAND = "rsync"
@@ -57,14 +55,14 @@ module Longleaf
57
55
  @options = @options + " -R"
58
56
 
59
57
  # Set and validate the replica collision policy
60
- @collision_policy = @service_def.properties[COLLISION_PROPERTY] || DEFAULT_COLLISION_POLICY
61
- if !VALID_COLLISION_POLICIES.include?(@collision_policy)
62
- raise ArgumentError.new("Service #{service_def.name} received invalid #{COLLISION_PROPERTY}" \
63
- + " value #{collision_policy}")
58
+ @collision_policy = @service_def.properties[SF::COLLISION_PROPERTY] || SF::DEFAULT_COLLISION_POLICY
59
+ if !SF::VALID_COLLISION_POLICIES.include?(@collision_policy)
60
+ raise ArgumentError.new("Service #{service_def.name} received invalid #{SF::COLLISION_PROPERTY}" \
61
+ + " value #{@collision_policy}")
64
62
  end
65
63
 
66
64
  # Store and validate destinations
67
- replicate_to = @service_def.properties[ServiceFields::REPLICATE_TO]
65
+ replicate_to = @service_def.properties[SF::REPLICATE_TO]
68
66
  if replicate_to.nil? || replicate_to.empty?
69
67
  raise ArgumentError.new("Service #{service_def.name} must provide one or more replication destinations.")
70
68
  end
@@ -105,7 +103,7 @@ module Longleaf
105
103
  end
106
104
 
107
105
  # Determine the path to the file being replicated relative to its storage location
108
- rel_path = file_rec.path.sub(/\A#{file_rec.storage_location.path}/, "")
106
+ rel_path = file_rec.storage_location.relativize(file_rec.path)
109
107
  # source path with . so that rsync will only create destination directories starting from that point
110
108
  source_path = File.join(file_rec.storage_location.path, "./#{rel_path}")
111
109
 
@@ -177,7 +175,7 @@ module Longleaf
177
175
  register_event = RegisterEvent.new(file_rec: dest_file_rec,
178
176
  app_manager: @app_manager,
179
177
  force: true,
180
- checksums: file_rec.metadata_record.checksums)
178
+ digest_provider: SingleDigestProvider.new(file_rec.metadata_record.checksums))
181
179
  register_event.perform
182
180
  end
183
181
  end
@@ -0,0 +1,143 @@
1
+ require 'longleaf/events/event_names'
2
+ require 'longleaf/logging'
3
+ require 'longleaf/errors'
4
+ require 'longleaf/models/file_record'
5
+ require 'longleaf/models/service_fields'
6
+ require 'longleaf/events/register_event'
7
+ require 'longleaf/models/storage_types'
8
+ require 'aws-sdk-s3'
9
+
10
+ module Longleaf
11
+ # Preservation service which performs replication of a file to one or more s3 destinations.
12
+ #
13
+ # The service definition must contain one or more destinations, specified with the "to" property.
14
+ # These destinations must be either a known s3 storage location. The s3 client configuration
15
+ # is controlled by the storage location.
16
+ #
17
+ # Optional service configuration properties:
18
+ # * replica_collision_policy = specifies the desired outcome if the service attempts to replicate
19
+ # a file which already exists at a destination. Default: "replace".
20
+ class S3ReplicationService
21
+ include Longleaf::Logging
22
+ ST ||= Longleaf::StorageTypes
23
+ SF ||= Longleaf::ServiceFields
24
+
25
+ attr_reader :collision_policy
26
+
27
+ # Initialize a S3ReplicationService from the given service definition
28
+ #
29
+ # @param service_def [ServiceDefinition] the configuration for this service
30
+ # @param app_manager [ApplicationConfigManager] the application configuration
31
+ def initialize(service_def, app_manager)
32
+ @service_def = service_def
33
+ @app_manager = app_manager
34
+
35
+ # Set and validate the replica collision policy
36
+ @collision_policy = @service_def.properties[SF::COLLISION_PROPERTY] || SF::DEFAULT_COLLISION_POLICY
37
+ if !SF::VALID_COLLISION_POLICIES.include?(@collision_policy)
38
+ raise ArgumentError.new("Service #{service_def.name} received invalid #{SF::COLLISION_PROPERTY}" \
39
+ + " value #{@collision_policy}")
40
+ end
41
+
42
+ # Store and validate destinations
43
+ replicate_to = @service_def.properties[SF::REPLICATE_TO]
44
+ if replicate_to.nil? || replicate_to.empty?
45
+ raise ArgumentError.new("Service #{service_def.name} must provide one or more replication destinations.")
46
+ end
47
+ replicate_to = [replicate_to] if replicate_to.is_a?(String)
48
+
49
+ loc_manager = app_manager.location_manager
50
+ # Build list of destinations, translating to storage locations when relevant
51
+ @destinations = Array.new
52
+ replicate_to.each do |dest|
53
+ if loc_manager.locations.key?(dest)
54
+ location = loc_manager.locations[dest]
55
+ if location.type != ST::S3_STORAGE_TYPE
56
+ raise ArgumentError.new(
57
+ "Service #{service_def.name} specifies destination #{dest} which is not of type 's3'")
58
+ end
59
+ @destinations << loc_manager.locations[dest]
60
+ else
61
+ raise ArgumentError.new("Service #{service_def.name} specifies unknown storage location '#{dest}'" \
62
+ + " as a replication destination")
63
+ end
64
+ end
65
+ end
66
+
67
+ # During a replication event, perform replication of the specified file to all configured destinations
68
+ # as necessary.
69
+ #
70
+ # @param file_rec [FileRecord] record representing the file to perform the service on.
71
+ # @param event [String] name of the event this service is being invoked by.
72
+ # @raise [PreservationServiceError] if the rsync replication fails
73
+ def perform(file_rec, event)
74
+ if file_rec.storage_location.type == ST::FILESYSTEM_STORAGE_TYPE
75
+ replicate_from_fs(file_rec)
76
+ else
77
+ raise PreservationServiceError.new("Replication from storage location of type " \
78
+ + "#{file_rec.storage_location.type} to s3 is not supported")
79
+ end
80
+ end
81
+
82
+ def replicate_from_fs(file_rec)
83
+ # Determine the path to the file being replicated relative to its storage location
84
+ rel_path = file_rec.storage_location.relativize(file_rec.path)
85
+
86
+ content_md5 = get_content_md5(file_rec)
87
+
88
+ @destinations.each do |destination|
89
+ # Check that the destination is available before attempting to write
90
+ verify_destination_available(destination, file_rec)
91
+
92
+ rel_to_bucket = destination.relative_to_bucket_path(rel_path)
93
+ file_obj = destination.s3_bucket.object(rel_to_bucket)
94
+ begin
95
+ file_obj.upload_file(file_rec.path, { :content_md5 => content_md5 })
96
+ rescue Aws::S3::Errors::BadDigest => e
97
+ raise ChecksumMismatchError.new("Transfer to bucket '#{destination.s3_bucket.name}' failed, " \
98
+ + "MD5 provided did not match the received content for #{file_rec.path}")
99
+ rescue Aws::Errors::ServiceError => e
100
+ raise PreservationServiceError.new("Failed to transfer #{file_rec.path} to bucket " \
101
+ + "'#{destination.s3_bucket.name}': #{e.message}")
102
+ end
103
+
104
+ logger.info("Replicated #{file_rec.path} to destination #{file_obj.public_url}")
105
+
106
+ # TODO register file in destination
107
+ end
108
+ end
109
+
110
+ # Determine if this service is applicable for the provided event, given the configured service definition
111
+ #
112
+ # @param event [String] name of the event
113
+ # @return [Boolean] returns true if this service is applicable for the provided event
114
+ def is_applicable?(event)
115
+ case event
116
+ when EventNames::PRESERVE
117
+ true
118
+ else
119
+ false
120
+ end
121
+ end
122
+
123
+ private
124
+ def verify_destination_available(destination, file_rec)
125
+ begin
126
+ destination.available?
127
+ rescue StorageLocationUnavailableError => e
128
+ raise StorageLocationUnavailableError.new("Cannot replicate #{file_rec.path} to destination #{destination.name}: " \
129
+ + e.message)
130
+ end
131
+ end
132
+
133
+ def get_content_md5(file_rec)
134
+ md_rec = file_rec.metadata_record
135
+ if md_rec.checksums.key?('md5')
136
+ # base 64 encode the digest, as is required by the Content-Md5 header
137
+ [[md_rec.checksums['md5']].pack("H*")].pack("m0")
138
+ else
139
+ nil
140
+ end
141
+ end
142
+ end
143
+ end
@@ -19,8 +19,9 @@ module Longleaf
19
19
  config_md5 = Digest::MD5.hexdigest(content)
20
20
 
21
21
  make_paths_absolute(config_path, config)
22
- Longleaf::ApplicationConfigValidator.validate(config)
23
- Longleaf::ApplicationConfigManager.new(config, config_md5)
22
+
23
+ ApplicationConfigValidator.new(config).validate_config.raise_if_invalid
24
+ ApplicationConfigManager.new(config, config_md5)
24
25
  end
25
26
 
26
27
  def self.load_config_file(config_path)
@@ -57,9 +58,30 @@ module Longleaf
57
58
  base_pathname = Pathname.new(config_path).expand_path.parent
58
59
 
59
60
  config[AF::LOCATIONS].each do |name, properties|
60
- properties[AF::LOCATION_PATH] = absolution(base_pathname, properties[AF::LOCATION_PATH])
61
+ properties[AF::LOCATION_PATH] = make_file_paths_absolute(base_pathname, properties)
61
62
 
62
- properties[AF::METADATA_PATH] = absolution(base_pathname, properties[AF::METADATA_PATH])
63
+ # Resolve single field metadata location into expanded form
64
+ md_config = properties[AF::METADATA_CONFIG]
65
+ if md_config.nil?
66
+ next
67
+ end
68
+ if md_config.is_a?(String)
69
+ md_config = { AF::LOCATION => m_config }
70
+ end
71
+ md_config[AF::LOCATION_PATH] = make_file_paths_absolute(base_pathname, md_config)
72
+ end
73
+ end
74
+
75
+ def self.make_file_paths_absolute(base_pathname, properties)
76
+ path = properties[AF::LOCATION_PATH]
77
+ return nil if path.nil?
78
+
79
+ uri = URI(path)
80
+
81
+ if uri.scheme.nil? || uri.scheme.casecmp("file") == 0
82
+ absolution(base_pathname, uri.path)
83
+ else
84
+ path
63
85
  end
64
86
  end
65
87