security 0.2.0 → 0.3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d010aca28c455caae65e0063ab5ee35336eace64fd8049012c66f77edc634f20
4
- data.tar.gz: a46b316771d171579e241b7c875b99bdaaa0f0cc6e7f9312d1627767b90c3c9e
3
+ metadata.gz: c157bfbd48ec870177f4a269569efc87edad32606bc9a7332b4d26644a13edf9
4
+ data.tar.gz: 275fbb21545cde55939d4512cec08d3c52bcff6a96d5300cb746941813685bed
5
5
  SHA512:
6
- metadata.gz: 106ba3c86bc5254bd1d589d1de99ea992b4cb12731761ff79faeed1cbe48f495451520b6ebbf91c50f6fb41b838ecd01de11eec9ca08d97e39c853cc457844e5
7
- data.tar.gz: 636bfaf6e7a5b88eb38112008d3a46ed13da71cd13e8e16bb9891a24b5fbd8fca85dd58017f505d1c0df45264a1be3773f9bf15e973b688f0069135463407cf6
6
+ metadata.gz: 0f8a12a0decc2ab2e277816af45860be94894bf2b01dc307e5f4d3b88251d8a6676dbf5c0469af862c19a73679a8d444eb1b8502345756ac3595c35d7e38e9fb
7
+ data.tar.gz: e2b77b3d17ec02afb5c99d1a69d638633ab83026777fbb7c883fe4c8ae4b6896f269f38df28208508e15af937ea549eb7a9f95ecd82a32472956b3334605b13c
data/Gemfile CHANGED
@@ -3,8 +3,9 @@
3
3
  source 'https://rubygems.org'
4
4
 
5
5
  gem 'rake', '~> 13.4'
6
- gem 'rspec'
7
- gem 'rubocop'
8
- gem 'simplecov'
6
+ gem 'rspec', '~> 3.13'
7
+ gem 'rubocop', '~> 1.90'
8
+ # simplecov 1.x requires Ruby >= 3.2; hold at 0.x while Ruby 3.1 is supported
9
+ gem 'simplecov', '~> 0.22'
9
10
 
10
11
  gemspec
data/Gemfile.lock CHANGED
@@ -1,17 +1,18 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- security (0.2.0)
4
+ security (0.3.0)
5
5
 
6
6
  GEM
7
7
  remote: https://rubygems.org/
8
8
  specs:
9
9
  ast (2.4.3)
10
10
  diff-lcs (1.6.2)
11
- json (2.21.2)
11
+ docile (1.4.1)
12
+ json (3.0.2)
12
13
  language_server-protocol (3.17.0.6)
13
14
  lint_roller (1.1.0)
14
- parallel (2.1.0)
15
+ parallel (2.2.0)
15
16
  parser (3.3.12.0)
16
17
  ast (~> 2.4.1)
17
18
  racc
@@ -33,8 +34,8 @@ GEM
33
34
  diff-lcs (>= 1.2.0, < 2.0)
34
35
  rspec-support (~> 3.13.0)
35
36
  rspec-support (3.13.7)
36
- rubocop (1.89.0)
37
- json (~> 2.3)
37
+ rubocop (1.91.0)
38
+ json (>= 2.3)
38
39
  language_server-protocol (~> 3.17.0.2)
39
40
  lint_roller (~> 1.1.0)
40
41
  parallel (>= 1.10)
@@ -48,7 +49,12 @@ GEM
48
49
  parser (>= 3.3.7.2)
49
50
  prism (~> 1.7)
50
51
  ruby-progressbar (1.13.0)
51
- simplecov (1.1.1)
52
+ simplecov (0.22.0)
53
+ docile (~> 1.1)
54
+ simplecov-html (~> 0.11)
55
+ simplecov_json_formatter (~> 0.1)
56
+ simplecov-html (0.13.2)
57
+ simplecov_json_formatter (0.1.4)
52
58
  unicode-display_width (3.2.0)
53
59
  unicode-emoji (~> 4.1)
54
60
  unicode-emoji (4.2.0)
@@ -59,10 +65,10 @@ PLATFORMS
59
65
 
60
66
  DEPENDENCIES
61
67
  rake (~> 13.4)
62
- rspec
63
- rubocop
68
+ rspec (~> 3.13)
69
+ rubocop (~> 1.90)
64
70
  security!
65
- simplecov
71
+ simplecov (~> 0.22)
66
72
 
67
73
  BUNDLED WITH
68
74
  2.6.9
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Security
2
2
 
3
- [![Build Status][build status badge]][build status]
3
+ [![CI](https://github.com/fastlane-community/security/actions/workflows/ci.yml/badge.svg)](https://github.com/fastlane-community/security/actions/workflows/ci.yml)
4
4
  [![Gem](https://img.shields.io/gem/v/security.svg?style=flat)](https://rubygems.org/gems/security)
5
5
  [![License](https://img.shields.io/badge/license-MIT-green.svg?style=flat)](https://github.com/fastlane-community/security/blob/main/LICENSE.md)
6
6
 
@@ -14,14 +14,54 @@
14
14
  ```ruby
15
15
  require 'security'
16
16
 
17
- Security::Keychain::default_keychain.filename #=> "/Users/jappleseed/Library/Keychains/login.keychain"
17
+ Security::Keychain.default_keychain.filename #=> "/Users/jappleseed/Library/Keychains/login.keychain-db"
18
18
 
19
- Security::InternetPassword.find(server: "itunesconnect.apple.com").password #=> "p4ssw0rd"
19
+ item = Security::InternetPassword.find(server: "itunesconnect.apple.com")
20
+ item&.password #=> "p4ssw0rd"
20
21
  ```
21
22
 
22
- ## License
23
+ ## Keychains
24
+
25
+ `find`, `add` and `delete` all take an optional `keychain:`, naming the keychain
26
+ to act on. It accepts a `Security::Keychain` or a path. Without one, `security`
27
+ adds to the default keychain and searches the default search list.
28
+
29
+ ```ruby
30
+ keychain = Security::Keychain.new("/path/to/build.keychain-db")
31
+
32
+ Security::InternetPassword.add("example.com", "jappleseed", "p4ssw0rd", keychain: keychain)
33
+ Security::InternetPassword.find(server: "example.com", keychain: keychain)
34
+ Security::InternetPassword.delete(server: "example.com", keychain: keychain)
35
+ ```
36
+
37
+ ## Errors
38
+
39
+ The `security` command line tool reports failures through its exit status, and
40
+ this library distinguishes the two cases a caller needs to tell apart:
23
41
 
24
- MIT
42
+ - **Nothing matched.** `find` returns `nil`. The keychain answered, and it holds
43
+ no such item.
44
+ - **The question could not be answered.** `find` raises `Security::Error`,
45
+ carrying the tool's exit `status` and its `output`. A locked keychain, a
46
+ keychain this process is not allowed to read, or a malformed request all
47
+ land here.
48
+
49
+ ```ruby
50
+ begin
51
+ item = Security::InternetPassword.find(server: "itunesconnect.apple.com")
52
+ rescue Security::Error => e
53
+ warn "could not read the keychain: #{e.message}"
54
+ item = nil
55
+ end
56
+ ```
57
+
58
+ `Keychain.list`, `Keychain.default_keychain` and `Keychain.login_keychain`
59
+ raise `Security::Error` on failure in the same way.
60
+
61
+ The methods that change the keychain — `add`, `delete`, and the `Keychain`
62
+ instance methods — return `true` or `false` and print what the tool reported,
63
+ the way `Kernel#system` does.
64
+
65
+ ## License
25
66
 
26
- [build status]: https://github.com/mattt/Security/actions?query=workflow%3ACI
27
- [build status badge]: https://github.com/mattt/Security/workflows/CI/badge.svg
67
+ [MIT](https://github.com/fastlane-community/security/blob/main/LICENSE.md)
data/UPGRADE.md ADDED
@@ -0,0 +1,7 @@
1
+ # Upgrade Guide
2
+
3
+ ## Upgrading from 0.2 to 0.3
4
+
5
+ In previous versions, `find` attempted to detect failures by searching for a `security: ` prefix in the output. This approach was unreliable: certain errors (like ACL issues) produced no output, while others (like malformed requests) returned a usage banner. These cases would incorrectly result in a `Password` object with `nil` attributes, making it impossible for callers to distinguish a failed request from a successful one. Additionally, failures that *did* have the prefix returned `nil`, which was indistinguishable from a missing item.
6
+
7
+ Starting with version 0.3, both cases now raise a `Security::Error`. If your code relies on `nil` to handle missing keychain items, you should now rescue `Security::Error` to prevent a locked or misconfigured keychain from being treated as empty.
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'English'
4
+ require 'open3'
5
+
6
+ module Security
7
+ # :nodoc:
8
+ module Command
9
+ # TODO: replace with Data.define once Ruby 3.1 support is dropped.
10
+ # :nodoc:
11
+ Result = Struct.new(:stdout, :stderr, :status) do
12
+ def success?
13
+ !status.nil? && status.success?
14
+ end
15
+
16
+ def exitstatus
17
+ status&.exitstatus
18
+ end
19
+
20
+ # `security` splits what it has to say across both streams: find-*-password
21
+ # prints the attributes on stdout and the password on stderr.
22
+ def output
23
+ stdout + stderr
24
+ end
25
+ end
26
+
27
+ module_function
28
+
29
+ # Runs a `security` subcommand and captures what it produced. A missing
30
+ # `security` is a failed result rather than an exception: the tool is only
31
+ # present on macOS, and callers elsewhere should see the same failure they
32
+ # would get from a keychain that could not answer.
33
+ def run(command)
34
+ Result.new(*Open3.capture3(command))
35
+ rescue Errno::ENOENT => e
36
+ # Nothing ran, but the child Ruby forked exited 127 before exec, which is
37
+ # what a shell reports for a missing command, and what this library
38
+ # produced while it still went through one.
39
+ Result.new('', "#{e.message}\n", $CHILD_STATUS)
40
+ end
41
+
42
+ # Runs a `security` subcommand and lets the caller see what it said. The
43
+ # tool reports on stderr whether or not it succeeded: show-keychain-info
44
+ # prints its result there on exit 0.
45
+ def relay(command)
46
+ result = run(command)
47
+ warn result.stderr.chomp unless result.stderr.empty?
48
+
49
+ result
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Security
4
+ # :nodoc:
5
+ class Error < StandardError
6
+ attr_reader :status, :output
7
+
8
+ def initialize(status, output)
9
+ @status = status
10
+ @output = output
11
+
12
+ details = output.strip
13
+ super(details.empty? ? "`security` exited with status #{status}" : "#{details} (status #{status})")
14
+ end
15
+ end
16
+ end
@@ -14,19 +14,19 @@ module Security
14
14
  end
15
15
 
16
16
  def info
17
- system %(security show-keychain-info #{@filename.shellescape})
17
+ Command.relay(%(security show-keychain-info #{@filename.shellescape})).success?
18
18
  end
19
19
 
20
20
  def lock
21
- system %(security lock-keychain #{@filename.shellescape})
21
+ Command.relay(%(security lock-keychain #{@filename.shellescape})).success?
22
22
  end
23
23
 
24
24
  def unlock(password)
25
- system %(security unlock-keychain -p #{password.shellescape} #{@filename.shellescape})
25
+ Command.relay(%(security unlock-keychain -p #{password.shellescape} #{@filename.shellescape})).success?
26
26
  end
27
27
 
28
28
  def delete
29
- system %(security delete-keychain #{@filename.shellescape})
29
+ Command.relay(%(security delete-keychain #{@filename.shellescape})).success?
30
30
  end
31
31
 
32
32
  class << self
@@ -35,29 +35,36 @@ module Security
35
35
  end
36
36
 
37
37
  def list(domain = :user)
38
- raise ArgumentError "Invalid domain #{domain}, expected one of: #{DOMAINS}" unless DOMAINS.include?(domain)
38
+ raise ArgumentError, "Invalid domain #{domain}, expected one of: #{DOMAINS}" unless DOMAINS.include?(domain)
39
39
 
40
- keychains_from_output(`security list-keychains -d #{domain}`)
40
+ keychains_from_command("security list-keychains -d #{domain}")
41
41
  end
42
42
 
43
43
  def lock
44
- system %(security lock-keychain -a)
44
+ Command.relay(%(security lock-keychain -a)).success?
45
45
  end
46
46
 
47
47
  def unlock(password)
48
- system %(security unlock-keychain -p #{password.shellescape})
48
+ Command.relay(%(security unlock-keychain -p #{password.shellescape})).success?
49
49
  end
50
50
 
51
51
  def default_keychain
52
- keychains_from_output(`security default-keychain`).first
52
+ keychains_from_command('security default-keychain').first
53
53
  end
54
54
 
55
55
  def login_keychain
56
- keychains_from_output(`security login-keychain`).first
56
+ keychains_from_command('security login-keychain').first
57
57
  end
58
58
 
59
59
  private
60
60
 
61
+ def keychains_from_command(command)
62
+ result = Command.run(command)
63
+ raise Error.new(result.exitstatus, result.stderr) unless result.success?
64
+
65
+ keychains_from_output(result.stdout)
66
+ end
67
+
61
68
  def keychains_from_output(output)
62
69
  output.split("\n").collect { |line| new(line.strip.gsub(/^"|"$/, '')) }
63
70
  end
@@ -5,6 +5,10 @@ require 'shellwords'
5
5
  module Security
6
6
  # :nodoc:
7
7
  class Password
8
+ # `security` reports a missing item with this exit status. Every other
9
+ # non-zero status is a failure the caller needs to know about.
10
+ ITEM_NOT_FOUND = 44
11
+
8
12
  attr_reader :keychain, :attributes, :password
9
13
 
10
14
  private_class_method :new
@@ -18,9 +22,15 @@ module Security
18
22
  class << self
19
23
  private
20
24
 
21
- def password_from_output(output)
22
- return nil if output.match?(/^security: /)
25
+ def password_from_command(command)
26
+ result = Command.run(command)
27
+ return nil if result.exitstatus == ITEM_NOT_FOUND
28
+ raise Error.new(result.exitstatus, result.stderr) unless result.success?
23
29
 
30
+ password_from_output(result.output)
31
+ end
32
+
33
+ def password_from_output(output)
24
34
  keychain = nil
25
35
  attributes = {}
26
36
  password = nil
@@ -44,6 +54,7 @@ module Security
44
54
 
45
55
  def flags_for_options(options = {})
46
56
  flags = options.dup
57
+ keychain = flags.delete(:keychain)
47
58
  flags[:a] ||= flags.delete(:account)
48
59
  flags[:c] ||= flags.delete(:creator)
49
60
  flags[:C] ||= flags.delete(:type)
@@ -51,7 +62,18 @@ module Security
51
62
  flags[:G] ||= flags.delete(:value)
52
63
  flags[:j] ||= flags.delete(:comment)
53
64
 
54
- flags.delete_if { |_k, v| v.nil? }.collect { |k, v| "-#{k} #{v.shellescape}".strip }.join(' ')
65
+ arguments = flags.compact.collect { |k, v| "-#{k} #{v.shellescape}".strip }
66
+ arguments << filename_for_keychain(keychain) if keychain
67
+
68
+ arguments.join(' ')
69
+ end
70
+
71
+ # `security` takes the keychain to act on as a trailing argument. Without
72
+ # one it uses the default keychain, or the default search list.
73
+ def filename_for_keychain(keychain)
74
+ filename = keychain.is_a?(Keychain) ? keychain.filename : keychain
75
+
76
+ filename.shellescape
55
77
  end
56
78
 
57
79
  def decode_hex_blob(string)
@@ -68,15 +90,15 @@ module Security
68
90
  options[:s] = service
69
91
  options[:w] = password
70
92
 
71
- system "security add-generic-password #{flags_for_options(options)}"
93
+ Command.relay("security add-generic-password #{flags_for_options(options)}").success?
72
94
  end
73
95
 
74
96
  def find(options)
75
- password_from_output(`security 2>&1 find-generic-password -g #{flags_for_options(options)}`)
97
+ password_from_command("security find-generic-password -g #{flags_for_options(options)}")
76
98
  end
77
99
 
78
100
  def delete(options)
79
- system "security delete-generic-password #{flags_for_options(options)}"
101
+ Command.run("security delete-generic-password #{flags_for_options(options)}").success?
80
102
  end
81
103
 
82
104
  private
@@ -95,16 +117,15 @@ module Security
95
117
  options[:a] = account
96
118
  options[:s] = server
97
119
  options[:w] = password
98
-
99
- system "security add-internet-password #{flags_for_options(options)}"
120
+ Command.relay("security add-internet-password #{flags_for_options(options)}").success?
100
121
  end
101
122
 
102
123
  def find(options)
103
- password_from_output(`security 2>&1 find-internet-password -g #{flags_for_options(options)}`)
124
+ password_from_command("security find-internet-password -g #{flags_for_options(options)}")
104
125
  end
105
126
 
106
127
  def delete(options)
107
- system "security delete-internet-password #{flags_for_options(options)}"
128
+ Command.run("security delete-internet-password #{flags_for_options(options)}").success?
108
129
  end
109
130
 
110
131
  private
@@ -2,5 +2,5 @@
2
2
 
3
3
  # :nodoc:
4
4
  module Security
5
- VERSION = '0.2.0'
5
+ VERSION = '0.3.0'
6
6
  end
data/lib/security.rb CHANGED
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'security/version'
4
+ require 'security/error'
5
+ require 'security/command'
4
6
  require 'security/keychain'
5
7
  require 'security/certificate'
6
8
  require 'security/password'
data/security.gemspec CHANGED
@@ -15,7 +15,7 @@ Gem::Specification.new do |s|
15
15
  s.files = Dir['./**/*'].grep_v(%r{\./(bin|log|pkg|script|spec|test|vendor)})
16
16
  s.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) }
17
17
  s.require_paths = ['lib']
18
- s.required_ruby_version = '>= 3.0.0'
18
+ s.required_ruby_version = '>= 3.1.0'
19
19
 
20
20
  s.metadata['rubygems_mfa_required'] = 'true'
21
21
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: security
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Josh Holtz
@@ -21,8 +21,11 @@ files:
21
21
  - "./LICENSE.md"
22
22
  - "./README.md"
23
23
  - "./Rakefile"
24
+ - "./UPGRADE.md"
24
25
  - "./lib/security.rb"
25
26
  - "./lib/security/certificate.rb"
27
+ - "./lib/security/command.rb"
28
+ - "./lib/security/error.rb"
26
29
  - "./lib/security/keychain.rb"
27
30
  - "./lib/security/password.rb"
28
31
  - "./lib/security/version.rb"
@@ -39,7 +42,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
39
42
  requirements:
40
43
  - - ">="
41
44
  - !ruby/object:Gem::Version
42
- version: 3.0.0
45
+ version: 3.1.0
43
46
  required_rubygems_version: !ruby/object:Gem::Requirement
44
47
  requirements:
45
48
  - - ">="