http_signature 0.0.2

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.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ce69899c93af8245c7a11f207ae55d487ecc360df04c231d5ae1089afe897a6b
4
+ data.tar.gz: c26e3a5d0a06e97b03354cab9f3e20dc0fee3d06aca01ddd03fb55a7ab9c022e
5
+ SHA512:
6
+ metadata.gz: 0b96af0ec445b8b0f298d74e0743035ff2818d85c827892a8366cdf171338b9bf24cd5183b3317de58bcff01e191b60935e0ec4bc84fef7dcf05bc4d6d622666
7
+ data.tar.gz: 5b709409aa43df9606c5fadfc202721664db25005b7935e5aeb62df7755f22b8fd536f13c5972fb321a361d11e6c9e02ef77e96d553bf7766b9417b40958f6c6
File without changes
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in rpc.gemspec
4
+ gemspec
@@ -0,0 +1,22 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ http-signature (0.0.2)
5
+
6
+ GEM
7
+ remote: https://rubygems.org/
8
+ specs:
9
+ minitest (5.10.3)
10
+ rake (12.2.1)
11
+
12
+ PLATFORMS
13
+ ruby
14
+
15
+ DEPENDENCIES
16
+ bundler
17
+ http-signature!
18
+ minitest
19
+ rake
20
+
21
+ BUNDLED WITH
22
+ 1.16.0
@@ -0,0 +1,168 @@
1
+ # HTTP Signature
2
+ [![CircleCI](https://circleci.com/gh/bolmaster2/http-signature.svg?style=svg)](https://circleci.com/gh/bolmaster2/http-signature)
3
+
4
+ Create and validate HTTP request signature according to this draft: https://tools.ietf.org/html/draft-cavage-http-signatures-08
5
+
6
+ Aims to only implement the creation and validation of the signature without any external dependencies.
7
+ The idea is to implement adapters to popular http libraries to make it easy to use.
8
+
9
+ ## Usage
10
+
11
+ ### Basic
12
+ The most basic usage without any extra headers. The default algorithm is `hmac-sha256`.
13
+ ```ruby
14
+ HTTPSignature.create(
15
+ url: 'https://example.com/foo',
16
+ key_id: 'Test',
17
+ key: 'secret 🙈'
18
+ )
19
+ # 'keyId="Test",algorithm="hmac-sha256",headers="(request-target)",signature="OQ/dHqRW9vFmrW/RCHg7O2Fqx+3uqxJw81p6k9Rcyo4="'
20
+ ```
21
+
22
+ ### With headers, query parameters and a body
23
+ Uses both query parameters (in query string) and a `json` body as a `POST` request.
24
+ Also shows how to set `rsa-sha256` as algorithm. The `digest` is as you see basically
25
+ a `sha-256` digest of the request body.
26
+
27
+ ```ruby
28
+ params = {
29
+ param: 'value',
30
+ pet: 'dog'
31
+ }
32
+
33
+ body = '{"hello": "world"}'
34
+
35
+ headers = {
36
+ 'date': 'Thu, 05 Jan 2014 21:31:40 GMT',
37
+ 'content-type': 'application/json',
38
+ 'digest': HTTPSignature.create_digest(body),
39
+ 'content-length': body.length
40
+ }
41
+
42
+ HTTPSignature.create(
43
+ url: 'https://example.com/foo',
44
+ method: :post,
45
+ query_string_params: params,
46
+ headers: headers,
47
+ key_id: 'Test',
48
+ algorithm: 'rsa-sha256',
49
+ key: File.read('key.pem'),
50
+ body: body
51
+ )
52
+ ```
53
+
54
+ ### With digest header auto-added
55
+ When digest header is omitted it's auto added as last header generated from the `body`:
56
+
57
+ ```ruby
58
+ body = '{"foo": "bar"}'
59
+
60
+ HTTPSignature.create(
61
+ url: 'https://example.com/foo',
62
+ key_id: 'Test',
63
+ key: 'secret 🙈',
64
+ body: body
65
+ )
66
+ # 'keyId="Test",algorithm="hmac-sha256",headers="(request-target) digest",signature="3Jm5jnCSKX3fYLd58RqRdafZKeuSbUEPhn7grCGx4vg="'
67
+ ```
68
+
69
+ ### Validate asymmetric signature
70
+ With an asymmetric algorithm you can't just recreate the same header and see if they
71
+ check out, because you need the private key to do that and because the one validating
72
+ the signature should only have access to the public key, you need to validate it with that.
73
+
74
+ Imagine the incoming HTTP request looks like this:
75
+ ```
76
+ POST /foo HTTP/1.1
77
+ Host: example.com
78
+ Date: Thu, 05 Jan 2014 21:31:40 GMT
79
+ Content-Type: application/json
80
+ Content-Length: 18
81
+ Digest: SHA-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=
82
+ Signature: keyId="Test-1",algorithm="rsa-sha256",headers="(request-target) host date content-type content-length digest",signature="YGPVM1tGHD7CHgTmroy9apLtVazdESzMl4vj1koYHNCMmTEDor4Om5TDZDFaJdny5dF3gq+PQQuPwyknNEvACmSjwVXzljPFxaY/JMZTqAdD0yHTP2Rx0Y/J4GwgKARWTZUmccfVYsXp86PhIlCymzleZzYCzj6shyg9NB7Ht+k="
83
+
84
+ {"hello": "world"}
85
+ ```
86
+
87
+ Let's assume we have this request ☝️ in a `request` object for the sake of the example:
88
+ ```ruby
89
+ HTTPSignature.valid?(
90
+ url: request.url,
91
+ method: request.method,
92
+ headers: request.headers,
93
+ body: request.body,
94
+ key: OpenSSL::PKey::RSA.new('public_key.pem'),
95
+ algorithm: 'rsa-sha256'
96
+ )
97
+ ```
98
+
99
+ ## Setup
100
+ ```
101
+ bundle install
102
+ ```
103
+
104
+ ## Test
105
+ The tests are written with `minitest` using specs. Run them all with `rake`:
106
+ ```bash
107
+ rake test
108
+ ```
109
+ Or a single with pattern matching:
110
+ ```bash
111
+ rake test TEST=test/http_signature_test.rb TESTOPTS="--name=/appends\ the\ query_string_params/"
112
+ ```
113
+
114
+ ## Example usage
115
+ ### Faraday middleware on outgoing requests
116
+ Example of using it on an outgoing request.
117
+ ```ruby
118
+ # TODO: Move this into gem
119
+ class AddRequestSignature < Faraday::Middleware
120
+ def call(env)
121
+ if env[:body]
122
+ env[:request_headers].merge!('Digest' => HTTPSignature.create_digest(env[:body]))
123
+ end
124
+
125
+ # Choose which headers to sign
126
+ headers_filter = %w{ Host Date Digest }
127
+ headers_to_sign = env[:request_headers].select { |k, v| headers_filter.include?(k.to_s) }
128
+
129
+ signature = HTTPSignature.create(
130
+ url: env[:url],
131
+ method: env[:method],
132
+ headers: headers_to_sign,
133
+ key: ENV.fetch('REQUEST_SIGNATURE_KEY'),
134
+ key_id: ENV.fetch('REQUEST_SIGNATURE_KEY_ID'),
135
+ algorithm: 'hmac-sha256',
136
+ body: env[:body] ? env[:body] : ''
137
+ )
138
+
139
+ env[:request_headers].merge!('Signature' => signature)
140
+
141
+ @app.call(env)
142
+ end
143
+ end
144
+
145
+ # Tell faraday to use the middleware. Read more about it here: https://github.com/lostisland/faraday#advanced-middleware-usage
146
+ Faraday.new('http://example.com') do |faraday|
147
+ faraday.use(AddRequestSignature)
148
+ faraday.adapter(Faraday.default_adapter)
149
+ end
150
+
151
+ response = conn.get('/')
152
+ ```
153
+
154
+ ### Rack middleware
155
+ I've written a quite sloppy but totally usable rack middleware that validates every incoming request.
156
+ [See it here](examples/rack_middleware.rb). Soon I'll add it to the gem.
157
+
158
+ ## License
159
+ This project is licensed under the terms of the [MIT license](https://opensource.org/licenses/MIT).
160
+
161
+ ## Todo
162
+ - Structure and add middlewares into gem
163
+ - Add more example of use with different http libraries
164
+ - Implement algorithms:
165
+ - ecdsa-sha256
166
+ - When creating the signing string, follow the spec exactly:
167
+ https://tools.ietf.org/html/draft-cavage-http-signatures-08#section-2.3,
168
+ e.g, concatenate multiple instances of the same headers and remove surrounding whitespaces
@@ -0,0 +1,8 @@
1
+ require 'bundler/gem_tasks'
2
+ require 'rake/testtask'
3
+
4
+ Rake::TestTask.new do |t|
5
+ t.libs << 'test'
6
+ t.pattern = "test/**/*_test.rb"
7
+ t.verbose = false
8
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'http_signature'
4
+
5
+ class AddRequestSignature < Faraday::Middleware
6
+ def call(env)
7
+ if env[:body]
8
+ env[:request_headers].merge!('Digest' => HTTPSignature.create_digest(env[:body]))
9
+ end
10
+
11
+ # Choose which headers to sign
12
+ filtered_headers = %w{ Host Date Digest }
13
+ headers_to_sign = env[:request_headers].select { |k, v| filtered_headers.include?(k.to_s) }
14
+
15
+ headers.select { |header| headers_to_sign.includes(header) }.to_h
16
+
17
+ signature = HTTPSignature.create(
18
+ url: env[:url],
19
+ method: env[:method],
20
+ headers: headers,
21
+ key: ENV.fetch('REQUEST_SIGNATURE_KEY'),
22
+ key_id: ENV.fetch('REQUEST_SIGNATURE_KEY_ID'),
23
+ algorithm: 'hmac-sha256',
24
+ body: env[:body] ? env[:body] : ''
25
+ )
26
+
27
+ env[:request_headers].merge!('Signature' => signature)
28
+
29
+ @app.call(env)
30
+ end
31
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'http_signature'
4
+
5
+ # Rack middleware using http-signature gem to validate signature on every incoming request
6
+ class ValidateRequestSignature
7
+ KEY = ENV.fetch('REQUEST_SIGNATURE_KEY')
8
+
9
+ def initialize(app)
10
+ @app = app
11
+ end
12
+
13
+ def call(env)
14
+ request = Rack::Request.new(env)
15
+ return [401, {}, ['No signature header']] unless request.get_header("HTTP_SIGNATURE")
16
+
17
+ request_body = request.body.gets
18
+ request_headers = parse_request_headers(request)
19
+ begin
20
+ parsed_signature = parse_signature(request_headers)
21
+ rescue
22
+ return [401, {}, ['Invalid signature :(']]
23
+ end
24
+ headers_to_sign = request_headers.select { |k, v| parsed_signature['headers'].include?(k) }
25
+
26
+ params = {
27
+ url: request.path,
28
+ method: request.request_method,
29
+ headers: headers_to_sign,
30
+ key: KEY,
31
+ key_id: parsed_signature['keyId'],
32
+ algorithm: parsed_signature['algorithm'],
33
+ body: request_body ? request_body : '',
34
+ query_string_params: Rack::Utils.parse_nested_query(request.query_string)
35
+ }
36
+
37
+ valid_signature =
38
+ if parsed_signature['algorithm'].include?('rsa')
39
+ HTTPSignature.valid?(**params)
40
+ else
41
+ HTTPSignature.create(**params) == request_headers['signature']
42
+ end
43
+
44
+ if valid_signature
45
+ @app.call(env)
46
+ else
47
+ [401, {}, ['Invalid signature :(']]
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def parse_request_headers(request)
54
+ request_headers = {}
55
+
56
+ request.each_header do |header|
57
+ if header[0].include?('HTTP_') && header[0] != 'HTTP_VERSION'
58
+ request_headers[header[0].gsub('HTTP_', '').gsub("_", "-").downcase] = header[1]
59
+ end
60
+ end
61
+
62
+ request_headers
63
+ end
64
+
65
+ def parse_signature(request_headers)
66
+ Rack::Utils.parse_nested_query(
67
+ request_headers['signature'].gsub(',', '&')
68
+ ).map do |k, v|
69
+ [k, v.tr('"', '')]
70
+ end.to_h
71
+ end
72
+ end
@@ -0,0 +1,23 @@
1
+ lib = File.expand_path('../lib', __FILE__)
2
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
+
4
+ Gem::Specification.new do |spec|
5
+ spec.name = 'http_signature'
6
+ spec.version = '0.0.2'
7
+ spec.authors = ['Joel Larsson']
8
+ spec.email = ['bolmaster2@gmail.com']
9
+
10
+ spec.summary = 'Create and validate HTTP request signature'
11
+ spec.description = 'Create and validate HTTP request signature according to this draft: https://tools.ietf.org/html/draft-cavage-http-signatures-08'
12
+ spec.homepage = 'https://github.com/bolmaster2/http-signature'
13
+ spec.license = 'MIT'
14
+
15
+ spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
16
+ spec.bindir = 'exe'
17
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
18
+ spec.require_paths = ['lib']
19
+
20
+ spec.add_development_dependency 'bundler'
21
+ spec.add_development_dependency 'rake'
22
+ spec.add_development_dependency 'minitest'
23
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+ require 'securerandom'
5
+ require 'base64'
6
+ require 'uri'
7
+
8
+ module HTTPSignature
9
+ # Create signature based on the data sent in
10
+ #
11
+ # @param url [String] Full request url, can include query string as well
12
+ # @param query_string_params [Hash] Query string parameters, appends params to
13
+ # url if query string is already found in it
14
+ # @param body [String] Request body as a string, i.e., the "raw" request body
15
+ # @param headers [Hash] Request headers to include in the signature
16
+ # @param key [String] Key/secret that is used by the corresponding `algorithm`
17
+ # @param key_id [String] Key id
18
+ # @param method [Symbol] Request method, default is `:get`
19
+ # @param algorithm [String] Algorithm to use when signing, check `supported_algorithms` for
20
+ # @return [String] The signature header value to use in "Signature" header
21
+ def self.create(url:, query_string_params: {}, body: '', headers: {}, key:,
22
+ key_id: SecureRandom.hex(8),
23
+ method: :get,
24
+ algorithm: 'hmac-sha256'
25
+ )
26
+
27
+ raise 'Unsupported algorithm :(' unless supported_algorithms.include?(algorithm)
28
+
29
+ uri = URI(url)
30
+ path = uri.path
31
+ headers = add_digest(headers, body)
32
+ headers = convert_headers(headers)
33
+ query = create_query_string(uri, query_string_params)
34
+
35
+ string_to_sign = create_signing_string(method: method, path: path,
36
+ query: query, headers: headers)
37
+
38
+ signature = sign(string_to_sign, key: key, algorithm: algorithm)
39
+ create_signature_header(key_id: key_id, headers: headers, signature: signature,
40
+ algorithm: algorithm)
41
+ end
42
+
43
+ def self.sign(string, key:, algorithm:)
44
+ case algorithm
45
+ when 'hmac-sha256'
46
+ OpenSSL::HMAC.digest('SHA256', key, string)
47
+ when 'hmac-sha512'
48
+ OpenSSL::HMAC.digest('SHA512', key, string)
49
+ when 'rsa-sha256'
50
+ k = OpenSSL::PKey::RSA.new(key)
51
+ k.sign(OpenSSL::Digest::SHA256.new, string)
52
+ when 'rsa-sha512'
53
+ k = OpenSSL::PKey::RSA.new(key)
54
+ k.sign(OpenSSL::Digest::SHA512.new, string)
55
+ end
56
+ end
57
+
58
+ def self.create_signature_header(key_id:, headers: [], signature:, algorithm:)
59
+ headers = headers.map { |h| h.split(':').first }
60
+ header_fields = ['(request-target)'].concat(headers).join(' ')
61
+
62
+ [
63
+ "keyId=\"#{key_id}\"",
64
+ "algorithm=\"#{algorithm}\"",
65
+ "headers=\"#{header_fields}\"",
66
+ "signature=\"#{Base64.strict_encode64(signature)}\""
67
+ ].join(',')
68
+ end
69
+
70
+ # TODO: Support them all: rsa-sha1, rsa-sha512, dsa-sha1, hmac-sha1
71
+ def self.supported_algorithms
72
+ ['hmac-sha256', 'hmac-sha512', 'rsa-sha256', 'rsa-sha512']
73
+ end
74
+
75
+ # Create the digest header based on the request body
76
+ # @param body [String] Raw request body string
77
+ # @return [String] SHA256 and base64 digested string with prefix: 'SHA-256='
78
+ def self.create_digest(body)
79
+ 'SHA-256=' + Digest::SHA256.base64digest(body)
80
+ end
81
+
82
+ # Creates the string to sign
83
+ # See details here: https://tools.ietf.org/html/draft-cavage-http-signatures-08#section-2.3
84
+ # TODO: Concatenate multiple instances of the same headers
85
+ # Also remove leading and trailing whitespace
86
+ # @return [String]
87
+ def self.create_signing_string(method:, path:, query:, headers:)
88
+ [
89
+ "(request-target): #{method.upcase} #{path}#{query}",
90
+ ].concat(headers).join("\n")
91
+ end
92
+
93
+ # Check if signature is valid. Using the exact same parameters as .create
94
+ # minus `key_id`
95
+ #
96
+ # @param url [String] Full request url, can include query string as well
97
+ # @param query_string_params [Hash] Query string parameters, appends params to
98
+ # url if query string is already found in it
99
+ # @param body [String] Request body as a string, i.e., the "raw" request body
100
+ # @param headers [Hash] Request headers to include in the signature
101
+ # @param key [String] Key/secret that is used by the corresponding `algorithm`
102
+ # @param method [Symbol] Request method, default is `:get`
103
+ # @param algorithm [String] Algorithm to use when signing, check `supported_algorithms` for
104
+ # @return [Boolean] Valid or not, Crypto is kinda binary in this case :)
105
+ def self.valid?(url:, query_string_params: {}, body: '', headers: {}, key:, method:, algorithm:)
106
+ raise 'Key needs to be public' unless key.public?
107
+
108
+ # TODO: A lot of the code here is exactly as `.create`, i.e., this could be DRYed :point_down:
109
+ uri = URI(url)
110
+ path = uri.path
111
+ signature = headers.delete(:signature)
112
+ headers = convert_headers(headers)
113
+ query = create_query_string(uri, query_string_params)
114
+
115
+ string_to_sign = create_signing_string(
116
+ method: method, path: path, query: query, headers: headers
117
+ )
118
+
119
+ key.verify(
120
+ get_digest(algorithm), get_signature_from_header(signature), string_to_sign
121
+ )
122
+ end
123
+
124
+ # Maps algoritgm string to digest object
125
+ # @param algorithm [String]
126
+ # @return [OpenSSL::Digest] Instance of `OpenSSL::Digest::SHA256` or OpenSSL::Digest::SHA512
127
+ def self.get_digest(algorithm)
128
+ {
129
+ 'rsa-sha256' => OpenSSL::Digest::SHA256.new,
130
+ 'rsa-sha512' => OpenSSL::Digest::SHA512.new
131
+ }[algorithm]
132
+ end
133
+
134
+ # Extract the actual signature from the whole "Signature" header
135
+ # @param header [String]
136
+ # @return [String]
137
+ def self.get_signature_from_header(header)
138
+ Base64.strict_decode64(header.match(/signature\=\"(.*)\"/)[1])
139
+ end
140
+
141
+ # When query string params is also set on the url, append the params defined
142
+ # in `query_string_params` and make a joint query string
143
+ def self.create_query_string(uri, query_string_params)
144
+ if uri.query || !query_string_params.empty?
145
+ delimiter = uri.query.nil? ? '' : '&'
146
+ '?' + (query_string_params.empty? ? '' : [uri.query.to_s, delimiter, URI.encode_www_form(query_string_params)].join)
147
+ end
148
+ end
149
+ # Convert a header hash into an array with header strings
150
+ # { header: 'value'} -> ['header: value']
151
+ def self.convert_headers(headers)
152
+ headers.map do |key, value|
153
+ [key.to_s.downcase.strip, value.strip].join(': ')
154
+ end
155
+ end
156
+
157
+ def self.add_digest(headers, body)
158
+ headers[:digest] = create_digest(body) unless body.empty?
159
+
160
+ headers
161
+ end
162
+ end
metadata ADDED
@@ -0,0 +1,96 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: http_signature
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.2
5
+ platform: ruby
6
+ authors:
7
+ - Joel Larsson
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2018-04-22 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: minitest
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ description: 'Create and validate HTTP request signature according to this draft:
56
+ https://tools.ietf.org/html/draft-cavage-http-signatures-08'
57
+ email:
58
+ - bolmaster2@gmail.com
59
+ executables: []
60
+ extensions: []
61
+ extra_rdoc_files: []
62
+ files:
63
+ - ".circleci/config.yml"
64
+ - Gemfile
65
+ - Gemfile.lock
66
+ - README.md
67
+ - Rakefile
68
+ - examples/faraday_middleware.rb
69
+ - examples/rack_middleware.rb
70
+ - http_signature.gemspec
71
+ - lib/http_signature.rb
72
+ homepage: https://github.com/bolmaster2/http-signature
73
+ licenses:
74
+ - MIT
75
+ metadata: {}
76
+ post_install_message:
77
+ rdoc_options: []
78
+ require_paths:
79
+ - lib
80
+ required_ruby_version: !ruby/object:Gem::Requirement
81
+ requirements:
82
+ - - ">="
83
+ - !ruby/object:Gem::Version
84
+ version: '0'
85
+ required_rubygems_version: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ requirements: []
91
+ rubyforge_project:
92
+ rubygems_version: 2.7.3
93
+ signing_key:
94
+ specification_version: 4
95
+ summary: Create and validate HTTP request signature
96
+ test_files: []