longleaf 0.3.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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